chore(agents): implement v2 hardening and enforcement-ready QA

This commit is contained in:
2026-04-01 19:41:56 +02:00
parent 7121732fcf
commit dba589b495
31 changed files with 1349 additions and 78 deletions

View File

@@ -10,6 +10,7 @@ contracts/ ← JSON schemas for role outputs (7 roles)
templates/ ← Starter JSON payloads per role (7 templates)
prompts/ ← Role-specific prompt baselines (7 prompts)
checks/ ← Guard catalog (22 guards), quality gates (9), checklists
← Enforcement policy + guard enforcement map
skills/ ← Reusable skill packages (guardrails, planner, grid, PHP CI)
runs/ ← Runtime artifacts per workflow run (gitignored)
```
@@ -33,6 +34,8 @@ runs/ ← Runtime artifacts per workflow run (gitignored)
| Workflow & roles | `workflow.md` |
| Guard catalog (22 guards) | `checks/guard-catalog.json` |
| Quality gates (9 gates) | `checks/quality-gates.json` |
| Enforcement policy | `checks/enforcement-policy.json` |
| Guard enforcement map | `checks/guard-enforcement-map.json` |
| Code review checklist | `checks/guard-checklist.md` |
| Acceptance checklist | `checks/acceptance-checklist.md` |
| Module manifest schema | `contracts/module-manifest.schema.json` |

View File

@@ -0,0 +1,77 @@
{
"version": "1.0.0",
"phase": "enforcement-ready",
"full_workflow_required_when": [
{
"id": "risk-security",
"description": "Any change touching authentication, authorization, CSRF, encryption, logging redaction, file uploads, or API bootstrap/security behavior.",
"paths": [
"lib/Http/**",
"lib/Service/Access/**",
"lib/Support/Crypto.php",
"pages/api/**",
"pages/**"
]
},
{
"id": "risk-data-boundary",
"description": "Any change touching tenant-scoped data access, repository query boundaries, DB schema updates, or API contracts.",
"paths": [
"lib/Repository/**",
"db/updates/**",
"db/init/init.sql",
"docs/openapi.yaml"
]
},
{
"id": "risk-modules",
"description": "Any change touching module manifests, module runtime wiring, module routes, module providers, or module migrations/assets.",
"paths": [
"modules/*/module.php",
"lib/App/Module/**",
"storage/runtime/pages/**",
"web/modules/**"
]
},
{
"id": "risk-cross-layer",
"description": "Any feature/addition changing more than one architecture layer (Repository, Service, pages/templates/web, modules, config, db).",
"paths": [
"lib/**",
"pages/**",
"templates/**",
"web/**",
"modules/**",
"config/**",
"db/**"
]
}
],
"quick_fix_allowlist": [
"single-file typo fix without behavior change",
"single-file translation text/key correction without flow change",
"single-file CSS visual adjustment without behavioral JS or backend change",
"single-file docs-only update"
],
"gate_policy": {
"required_gates_always": [
"QG-001",
"QG-002",
"QG-003",
"QG-006",
"QG-008",
"QG-009"
],
"manual_non_blocking": [
"QG-005"
],
"periodic_non_blocking": [
"QG-007"
]
},
"ci_rollout": {
"target_platform": "gitea",
"blocking_enabled": false,
"phase_2_activation": "Enable required status checks in repository branch protection once qa-required is stable on runner infrastructure."
}
}

View File

@@ -23,10 +23,10 @@ Guard IDs live in `.agents/checks/guard-catalog.json` (guards where `reviewer` =
- `GR-UI-REUSE` existing JS/CSS checked before adding new functions/rules
## Quality Gates
- `QG-001` to `QG-003` are run unless explicitly out of scope
- required always (per `enforcement-policy.json`): `QG-001`, `QG-002`, `QG-003`, `QG-006`, `QG-008`, `QG-009`
- `QG-004` structural checks reported when relevant
- `QG-005` JS smoke for JS-touching changes
- `QG-006` PHP style check for PHP-touching changes
- `QG-007` composer-unused is periodic / non-blocking
# Security Review Checklist

View File

@@ -0,0 +1,173 @@
{
"version": "1.0.0",
"entries": [
{
"guard_id": "GR-CORE-003",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/CoreStarterkitContractTest.php"
]
},
{
"guard_id": "GR-CORE-007",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/ApiSchemaContractTest.php",
".agents/runs/<TASK>/review-code.json"
]
},
{
"guard_id": "GR-CORE-010",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/DatabaseUpdatesContractTest.php"
]
},
{
"guard_id": "GR-CORE-011",
"enforcement_mode": "reviewer",
"evidence_source": [
".agents/runs/<TASK>/review-code.json"
]
},
{
"guard_id": "GR-CORE-012",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/PostRedirectGetContractTest.php"
]
},
{
"guard_id": "GR-TEST-001",
"enforcement_mode": "reviewer",
"evidence_source": [
".agents/runs/<TASK>/review-code.json",
".agents/runs/<TASK>/review-acceptance.json"
]
},
{
"guard_id": "GR-TEST-002",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/CoreStarterkitContractTest.php",
".agents/runs/<TASK>/review-code.json"
]
},
{
"guard_id": "GR-UI-014",
"enforcement_mode": "reviewer",
"evidence_source": [
".agents/runs/<TASK>/review-code.json",
".agents/runs/<TASK>/review-acceptance.json"
]
},
{
"guard_id": "GR-UI-LIST",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/ListDataEndpointContractTest.php",
"tests/Architecture/ListTemplateContractTest.php"
]
},
{
"guard_id": "GR-UI-DETAIL",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/DetailPageRuntimeContractTest.php",
"tests/Architecture/DetailActionPolicyRuntimeContractTest.php"
]
},
{
"guard_id": "GR-UI-A11Y",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/AuthLoginTemplateAccessibilityContractTest.php",
"tests/Architecture/AuthLoginFieldAccessibilityContractTest.php"
]
},
{
"guard_id": "GR-UI-I18N",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/I18nKeyCompletenessContractTest.php",
"tests/Architecture/ModuleI18nContractTest.php"
]
},
{
"guard_id": "GR-UI-REUSE",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/CoreStarterkitContractTest.php",
".agents/runs/<TASK>/review-code.json"
]
},
{
"guard_id": "GR-SEC-001",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/PostEndpointCsrfContractTest.php"
]
},
{
"guard_id": "GR-SEC-002",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/SecurityLoggingRedactionContractTest.php",
"tests/Architecture/SecurityLoggingTelemetryContractTest.php"
]
},
{
"guard_id": "GR-SEC-003",
"enforcement_mode": "reviewer",
"evidence_source": [
".agents/runs/<TASK>/review-security.json"
]
},
{
"guard_id": "GR-SEC-004",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/SecurityRemoteAssetContractTest.php"
]
},
{
"guard_id": "GR-SEC-005",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/SecurityCryptoContractTest.php"
]
},
{
"guard_id": "GR-SEC-006",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/FileStorageContractTest.php",
".agents/runs/<TASK>/review-security.json"
]
},
{
"guard_id": "GR-SEC-007",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/ApiSessionIsolationContractTest.php"
]
},
{
"guard_id": "GR-SEC-008",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/AuthzAdminUsersContractTest.php",
"tests/Architecture/AuthzApiBootstrapContractTest.php",
".agents/runs/<TASK>/review-security.json"
]
},
{
"guard_id": "GR-SEC-009",
"enforcement_mode": "hybrid",
"evidence_source": [
"tests/Architecture/ApiResourceContractTest.php",
".agents/runs/<TASK>/review-security.json"
]
}
]
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "module-manifest.schema.json",
"$id": "https://corecore.local/schemas/module-manifest.schema.json",
"title": "Module Manifest Schema",
"description": "Declarative schema for module.php manifest files under modules/<id>/.",
"type": "object",
@@ -61,8 +61,10 @@
"default": []
},
"ui_slots": {
"type": "object",
"description": "Keyed by slot name (e.g. 'aside.tab_panel'). Values are arrays of contribution objects.",
"anyOf": [
{
"type": "object",
"patternProperties": {
"^.+$": {
"type": "array",
@@ -83,7 +85,12 @@
"selector": { "type": "string" },
"scope": { "type": "string" },
"config_path": { "type": "string" },
"default_config": { "type": "object" },
"default_config": {
"anyOf": [
{ "type": "object" },
{ "type": "array", "maxItems": 0 }
]
},
"phase": { "type": "string", "enum": ["early", "default", "late"] },
"type": { "type": "string" },
"order": { "type": "integer", "default": 100 },
@@ -94,7 +101,13 @@
}
}
}
}
},
{
"type": "array",
"maxItems": 0
}
],
"default": {}
},
"search_resources": {
@@ -103,14 +116,22 @@
"default": []
},
"asset_groups": {
"type": "object",
"description": "Asset group name → file list.",
"anyOf": [
{
"type": "object",
"patternProperties": {
"^.+$": {
"type": "array",
"items": { "type": "string" }
}
}
},
{
"type": "array",
"maxItems": 0
}
],
"default": {}
},
"layout_context_providers": {
@@ -136,8 +157,8 @@
"properties": {
"key": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"active": { "type": "boolean", "default": true },
"is_system": { "type": "boolean", "default": true }
"active": { "type": ["boolean", "integer"], "default": true },
"is_system": { "type": ["boolean", "integer"], "default": true }
},
"additionalProperties": false
},
@@ -177,8 +198,10 @@
"default": []
},
"event_listeners": {
"type": "object",
"description": "Event name → list of listener descriptors.",
"anyOf": [
{
"type": "object",
"patternProperties": {
"^.+$": {
"type": "array",
@@ -192,7 +215,13 @@
"additionalProperties": false
}
}
}
},
{
"type": "array",
"maxItems": 0
}
],
"default": {}
},
"deactivation_handler": {

View File

@@ -5,6 +5,12 @@
1. `docker compose exec php vendor/bin/phpunit`
2. `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
3. `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
4. `docker compose exec php vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose`
5. `bin/docs-link-check.sh`
6. `bin/codex-skills-sync.sh --check`
Policy-Hinweis:
- Verbindliche Enforcement-Einteilung (`required`, `manual_non_blocking`, `periodic_non_blocking`) wird zentral in `/.agents/checks/enforcement-policy.json` gepflegt.
## Struktur-Gates (schnell)

View File

@@ -22,6 +22,8 @@ Primary goals:
Source of truth:
- Guard catalog (22 guards): `.agents/checks/guard-catalog.json`
- Quality gates (9 gates): `.agents/checks/quality-gates.json`
- Enforcement policy (workflow trigger + gate enforcement): `.agents/checks/enforcement-policy.json`
- Guard enforcement map (guard -> automated/hybrid/reviewer evidence): `.agents/checks/guard-enforcement-map.json`
- Contracts: `.agents/contracts/`
---
@@ -81,6 +83,17 @@ analyzed → planned → executing → reviewing → finalize → done
The reviewing state runs Code Reviewer, Security Reviewer, and Acceptance Tester.
If any reviewer returns `FAIL`, state goes back to `executing` with explicit findings.
## Workflow Trigger Policy
Workflow triggering is risk-based and centrally defined in:
- `.agents/checks/enforcement-policy.json` (`full_workflow_required_when`)
- `.agents/checks/enforcement-policy.json` (`quick_fix_allowlist`)
Interpretation:
- Full workflow is mandatory for risk/security/data-boundary/module/cross-layer changes
- Quick fixes are only exempt if they match the explicit allowlist
- `>1 layer` remains a useful heuristic but is no longer the sole trigger
## Handover Artifacts
| Role | Artifact |
@@ -123,3 +136,5 @@ The following concerns are covered by QG-001/QG-003/QG-004 and do NOT need manua
- Repository sanitizeLimitOffset (RepositoryInterfaceContractTest)
- List init standard, legacy filter toggle, template RBAC (architecture tests)
- Component lifecycle contract (architecture tests)
- Guard reference integrity (`GR-*`/`QG-*`) across docs/agents/tests/bin
- Guard enforcement mapping completeness and automated evidence file existence

View File

@@ -0,0 +1,24 @@
name: qa-required
on:
push:
branches:
- main
pull_request:
jobs:
qa-required:
name: qa-required
runs-on: ubuntu-latest
# Phase 1: workflow is present and runnable, but branch protection does not
# require this check yet. Enable required checks in Phase 2.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Start project services
run: docker compose up -d
- name: Run required QA gates
run: bin/qa-required.sh

View File

@@ -6,11 +6,11 @@ Multi-tenant admin application built on MintyPHP. Manages tenants, users, depart
All guard IDs (GR-\*) and gate IDs (QG-\*) from `.agents/checks/` are **binding — not advisory**.
- **Any change touching >1 layer or adding a new feature** MUST follow the `.agents/workflow.md` state machine (Analyst → Planner → Executor → Reviewers → Finalizer). Read `.agents/README.md` and the relevant role prompt in `.agents/prompts/` before starting.
- **Quick fixes** (single-file typo, translation update, CSS tweak) may skip the full workflow but MUST still pass mandatory quality gates (QG-001, QG-002, QG-003, QG-006).
- **Full workflow trigger is risk-based** and defined in `.agents/checks/enforcement-policy.json` (`full_workflow_required_when`). Matching changes MUST follow `.agents/workflow.md` (Analyst → Planner → Executor → Reviewers → Finalizer). Read `.agents/README.md` and the relevant role prompt in `.agents/prompts/` before starting.
- **Quick fixes** may skip full workflow only if they match `.agents/checks/enforcement-policy.json` (`quick_fix_allowlist`) and still pass required gates.
- **New modules** MUST conform to `.agents/contracts/module-manifest.schema.json` and pass all 9 security guards (GR-SEC-001 through GR-SEC-009).
Full workflow definition: `.agents/workflow.md` | Guard catalog: `.agents/checks/guard-catalog.json` | Skills: `.agents/skills/`
Full workflow definition: `.agents/workflow.md` | Guard catalog: `.agents/checks/guard-catalog.json` | Enforcement policy: `.agents/checks/enforcement-policy.json` | Guard enforcement map: `.agents/checks/guard-enforcement-map.json` | Skills: `.agents/skills/`
## Tech Stack
@@ -202,7 +202,8 @@ These rules are enforced by guards GR-SEC-001 through GR-SEC-009 in `.agents/che
## Quality Gates
9 gates defined in `.agents/checks/quality-gates.json`. **Mandatory before any merge:**
9 gates defined in `.agents/checks/quality-gates.json`.
Required/optional enforcement is defined in `.agents/checks/enforcement-policy.json`.
| ID | Gate | Command |
|---|---|---|
@@ -210,8 +211,10 @@ These rules are enforced by guards GR-SEC-001 through GR-SEC-009 in `.agents/che
| QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` |
| QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` |
| QG-006 | PHP Style | `vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose` |
| QG-008 | Docs link integrity | `bin/docs-link-check.sh` |
| QG-009 | Codex skills sync | `bin/codex-skills-sync.sh --check` |
Additional gates (fast/periodic): QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused, QG-008 docs links, QG-009 skills sync. See gate catalog for details.
Additional non-blocking gates: QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused. See gate catalog + enforcement policy for details.
## Environment

24
bin/dev
View File

@@ -15,7 +15,9 @@ Commands:
down Stop local containers
logs [service] Follow docker compose logs (optional service)
console <args...> Run php bin/console inside php container
qa Run mandatory quality gates (QG-001/002/003/006)
qa Alias for qa-required
qa-required Run required quality gates (QG-001/002/003/006/008/009)
qa-extended Run qa-required plus non-blocking checks (QG-004/005/007)
USAGE
}
@@ -95,12 +97,14 @@ cmd_logs() {
fi
}
cmd_qa() {
cmd_qa_required() {
require_docker_compose
docker compose exec -T php vendor/bin/phpunit
docker compose exec -T php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
docker compose exec -T php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php
docker compose exec -T php vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose
"${repo_root}/bin/qa-required.sh"
}
cmd_qa_extended() {
require_docker_compose
"${repo_root}/bin/qa-extended.sh"
}
if [[ $# -eq 0 ]]; then
@@ -128,7 +132,13 @@ case "${command}" in
run_console "$@"
;;
qa)
cmd_qa "$@"
cmd_qa_required "$@"
;;
qa-required)
cmd_qa_required "$@"
;;
qa-extended)
cmd_qa_extended "$@"
;;
-h|--help|help)
usage

45
bin/qa-extended.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/.." && pwd)"
cd "${repo_root}"
warn() {
echo "[WARN] $*"
}
# Blocking baseline
bin/qa-required.sh
# QG-004 (non-blocking in phase 1): structural checks as warning-only signal
if command -v rg >/dev/null 2>&1; then
c1="$( (rg 'new\\s+[^\\s(]+Factory\\(' lib/Service || true) | wc -l | tr -d '[:space:]')"
c2="$( (rg --glob '!**/*Factory.php' 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' lib/Service || true) | wc -l | tr -d '[:space:]')"
c3="$( (rg "\\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\\(" pages lib templates web || true) | wc -l | tr -d '[:space:]')"
c4="$( (rg -n 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' pages -g '*.php' || true) | wc -l | tr -d '[:space:]')"
c5="$( (rg -n '\\bDB::' pages -g '*.php' || true) | wc -l | tr -d '[:space:]')"
c6="$( (rg -n 'app\\([^\\n]*Factory::class\\)->create' pages/admin -g '*.php' || true) | wc -l | tr -d '[:space:]')"
c7="$( (rg -n 'Factory::class' pages/admin -g '*.php' || true) | wc -l | tr -d '[:space:]')"
c8="$( (rg -n 'ApiResponse::readJsonBody\\(' pages/api/v1 -g '*.php' || true) | wc -l | tr -d '[:space:]')"
if [[ "${c1}" == "0" && "${c2}" == "0" && "${c3}" == "0" && "${c4}" == "0" && "${c5}" == "0" && "${c6}" == "0" && "${c7}" == "0" && "${c8}" == "0" ]]; then
echo "[OK] QG-004 Structural checks clean"
else
warn "QG-004 Structural checks found potential violations (non-blocking): ${c1}/${c2}/${c3}/${c4}/${c5}/${c6}/${c7}/${c8}"
fi
else
warn "QG-004 skipped: rg is not installed"
fi
# QG-007 (non-blocking): periodic dependency hygiene
if docker compose exec -T php vendor/bin/composer-unused; then
echo "[OK] QG-007 composer-unused"
else
warn "QG-007 composer-unused reported issues (non-blocking in phase 1)"
fi
# QG-005 (manual, non-blocking)
warn "QG-005 manual step required: Browser smoke with DevTools console (expect no new JS errors)."
echo "[OK] Extended QA completed (required gates already passed, optional gates reported)."

44
bin/qa-required.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/.." && pwd)"
cd "${repo_root}"
run_step() {
local label="$1"
shift
echo "[RUN] ${label}"
"$@"
echo "[OK] ${label}"
}
if ! command -v docker >/dev/null 2>&1; then
echo "[ERROR] docker is required." >&2
exit 2
fi
docker compose version >/dev/null 2>&1 || {
echo "[ERROR] docker compose is required." >&2
exit 2
}
run_step "QG-001 PHPUnit" \
docker compose exec -T php vendor/bin/phpunit
run_step "QG-002 PHPStan" \
docker compose exec -T php vendor/bin/phpstan analyse -c phpstan.neon --no-progress
run_step "QG-003 Architecture Core Contract" \
docker compose exec -T php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php
run_step "QG-006 PHP Style Check" \
docker compose exec -T php vendor/bin/php-cs-fixer fix --config=tools/php-cs-fixer/.php-cs-fixer.dist.php --dry-run --diff --verbose
run_step "QG-008 Docs link integrity" \
bin/docs-link-check.sh
run_step "QG-009 Codex skills sync" \
bin/codex-skills-sync.sh --check
echo "[OK] Required QA gates passed (QG-001/002/003/006/008/009)."

View File

@@ -15,7 +15,8 @@
"phpmailer/phpmailer": "^7.0",
"dompdf/dompdf": "^3.1",
"endroid/qr-code": "^6.0",
"league/commonmark": "^2.8"
"league/commonmark": "^2.8",
"opis/json-schema": "^2.6"
},
"require-dev": {
"mintyphp/tools": "*",

192
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "91c05b180a4c7a7b830ea9a02bf7dca4",
"content-hash": "0926942d029bad48009daddfa3a97395",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -870,6 +870,196 @@
},
"time": "2026-02-13T03:05:33+00:00"
},
{
"name": "opis/json-schema",
"version": "2.6.0",
"source": {
"type": "git",
"url": "https://github.com/opis/json-schema.git",
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
"shasum": ""
},
"require": {
"ext-json": "*",
"opis/string": "^2.1",
"opis/uri": "^1.0",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"ext-bcmath": "*",
"ext-intl": "*",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\JsonSchema\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
},
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
}
],
"description": "Json Schema Validator for PHP",
"homepage": "https://opis.io/json-schema",
"keywords": [
"json",
"json-schema",
"schema",
"validation",
"validator"
],
"support": {
"issues": "https://github.com/opis/json-schema/issues",
"source": "https://github.com/opis/json-schema/tree/2.6.0"
},
"time": "2025-10-17T12:46:48+00:00"
},
{
"name": "opis/string",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/opis/string.git",
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"ext-json": "*",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\String\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
},
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
}
],
"description": "Multibyte strings as objects",
"homepage": "https://opis.io/string",
"keywords": [
"multi-byte",
"opis",
"string",
"string manipulation",
"utf-8"
],
"support": {
"issues": "https://github.com/opis/string/issues",
"source": "https://github.com/opis/string/tree/2.1.0"
},
"time": "2025-10-17T12:38:41+00:00"
},
{
"name": "opis/uri",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/opis/uri.git",
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
"shasum": ""
},
"require": {
"opis/string": "^2.0",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Opis\\Uri\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Marius Sarca",
"email": "marius.sarca@gmail.com"
},
{
"name": "Sorin Sarca",
"email": "sarca_sorin@hotmail.com"
}
],
"description": "Build, parse and validate URIs and URI-templates",
"homepage": "https://opis.io",
"keywords": [
"URI Template",
"parse url",
"punycode",
"uri",
"uri components",
"url",
"validate uri"
],
"support": {
"issues": "https://github.com/opis/uri/issues",
"source": "https://github.com/opis/uri/tree/1.1.0"
},
"time": "2021-05-22T15:57:08+00:00"
},
{
"name": "phpmailer/phpmailer",
"version": "v7.0.2",

View File

@@ -0,0 +1,43 @@
# Gitea Required Checks (Phase 1: enforcement-ready)
Letzte Aktualisierung: 2026-04-01
## Ziel
QA-Pflichtchecks fuer CoreCore in Gitea vorbereiten, ohne sie sofort als Merge-Blocker zu aktivieren.
## Enthaltene Basis
- Beispiel-Workflow: `/.gitea/workflows/qa-required.yaml`
- Pflicht-Gate-Einstieg: `/bin/qa-required.sh`
- Policy-Quelle: `/.agents/checks/enforcement-policy.json`
## Was `qa-required` abdeckt
`bin/qa-required.sh` fuehrt die aktuell blockenden Gates aus:
- `QG-001` PHPUnit
- `QG-002` PHPStan
- `QG-003` Architecture Core Contract
- `QG-006` PHP Style Check
- `QG-008` Docs link integrity
- `QG-009` Codex skills sync
## Phase-1 Setup (noch nicht blockend)
1. Sicherstellen, dass der Gitea-Runner Docker + Docker Compose ausfuehren darf.
2. Workflow-Datei nach `.gitea/workflows/qa-required.yaml` uebernehmen.
3. PR gegen `main` oeffnen und pruefen, dass Job `qa-required` gruen laeuft.
4. Branch Protection noch **nicht** auf "required" setzen.
## Phase-2 Aktivierung (blockend)
1. Branch-Protection fuer `main` aktivieren.
2. Statuscheck `qa-required` als required markieren.
3. Optional: weitere Jobs ergaenzen (z. B. nightly/extended Checks), aber nur `qa-required` blockend setzen.
## Hinweise
- `QG-005` (Browser-Smoke) bleibt manuell/non-blocking.
- `QG-007` (composer-unused) bleibt periodisch/non-blocking.
- Fuer lokale Ausfuehrung: `bin/dev qa-required` bzw. `bin/dev qa-extended`.

View File

@@ -1,6 +1,6 @@
# Dokumentation
Letzte Aktualisierung: 2026-03-19
Letzte Aktualisierung: 2026-04-01
Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: Tutorials zum Lernen, How-to Guides zum Nachschlagen von Aufgaben, Explanation zum Verstehen und Reference zum Nachschlagen von Fakten.
@@ -57,6 +57,8 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert:
- Tages-Workflow fuer Entwicklung und Checks.
- `/docs/howto-modul-erstellen.md`
- Neues Modul erstellen: Scaffold, Manifest, Route, Permission, UI-Slot, Test.
- `/docs/howto-gitea-required-checks.md`
- Gitea-Workflow fuer `qa-required` vorbereiten (Phase 1 enforcement-ready).
- `/docs/howto-dokumentation-erweitern.md`
- Regeln fuer konsistente, wachsende Doku.

View File

@@ -97,14 +97,14 @@ Der Docker-Scheduler-Service ruft dieses Kommando automatisch alle 60 Sekunden a
### module:validate
Validiert Manifest, Klassen und Struktur eines Moduls. Prueft Namespace-Konventionen, Interface-Implementierungen, Permission-Key-Formate und Verzeichnisstruktur.
Validiert Manifest, Klassen und Struktur eines Moduls. Prueft JSON-Schema-Compliance des `module.php`, Namespace-Konventionen, Interface-Implementierungen, Permission-Key-Formate und Verzeichnisstruktur.
```bash
docker compose exec php php bin/console module:validate <module-id>
docker compose exec php php bin/console module:validate --all
```
`--all` validiert alle aktiven Module auf einmal.
`--all` validiert alle Module auf Disk (`modules/*/module.php`) auf einmal.
### make:module
@@ -143,9 +143,12 @@ bin/dev down
bin/dev logs [service]
bin/dev console module:sync
bin/dev qa
bin/dev qa-required
bin/dev qa-extended
```
`bin/dev console ...` delegiert intern auf `docker compose exec -T php php bin/console ...`.
`bin/dev qa` ist ein Alias fuer `bin/dev qa-required`.
## Neue Kommandos anlegen

View File

@@ -68,6 +68,8 @@ Kurze Definition of Done vor Merge.
## Qualitätschecks
- [ ] `bin/qa-required.sh` (QG-001/002/003/006/008/009, blocking)
- [ ] optional: `bin/qa-extended.sh` (inkl. QG-004/005/007 als non-blocking Signale)
- [ ] `docker compose exec php vendor/bin/phpunit` zweimal hintereinander in identischer Umgebung (beide Runs gruen)
- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
@@ -76,8 +78,8 @@ Kurze Definition of Done vor Merge.
- [ ] Runtime-Sync erzeugt Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) und `vendor_warnings_ignored=<n>`
- [ ] Keine First-party CLI-Warnings/Notices/Deprecations im Runtime-Sync (Vendor-Warnings sind nur temporaer toleriert und muessen gezaehlt sichtbar sein)
- [ ] bei Docs-/Skills-Aenderungen: `docker compose exec php vendor/bin/phpunit tests/Architecture/CodexSkillReferenceContractTest.php`
- [ ] bei Docs-/Skills-Aenderungen: `bin/docs-link-check.sh` (scannt standardmaessig ohne `.agents/runs/**`)
- [ ] bei Docs-/Skills-Aenderungen: `bin/codex-skills-sync.sh --check`
- [ ] `bin/docs-link-check.sh` (QG-008, scannt standardmaessig ohne `.agents/runs/**`)
- [ ] `bin/codex-skills-sync.sh --check` (QG-009)
- [ ] bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors)
- [ ] bei API-Änderungen: `docs/openapi.yaml` aktualisiert
- [ ] Struktur-Gates geprüft (`rg` für Instanziierungsregeln)

View File

@@ -11,6 +11,18 @@ Verbindlicher Contract fuer `modules/*/module.php`, damit Module zur Runtime det
Dieses Dokument ist die kanonische (normative) Quelle fuer Manifest-, Runtime- und Guard-Regeln des Modul-Systems.
`/docs/reference-extension-readiness.md` referenziert diesen Contract und dupliziert keine Felddefinitionen.
## JSON-Schema-Enforcement
Zusatz zur Laufzeitvalidierung:
- JSON-Schema-Datei: `/.agents/contracts/module-manifest.schema.json`
- Laufzeit-Check bei Modul-Laden: `ModuleManifestSchemaValidator`
- CLI-Check: `php bin/console module:validate <module-id|--all>`
Ein Manifest muss sowohl:
1. das JSON-Schema erfuellen als auch
2. die Laufzeit-Contracts (Klassen/Interfaces/Namespace) bestehen.
## Namespace-Konvention
Alle PHP-Klassen eines Moduls muessen den Namespace `MintyPHP\Module\<Name>\*` verwenden.

View File

@@ -37,6 +37,8 @@ final class ModuleManifestLoader
);
}
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
return ModuleManifest::fromArray($raw, dirname($manifestFile));
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace MintyPHP\App\Module;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Errors\ValidationError;
use Opis\JsonSchema\JsonPointer;
use Opis\JsonSchema\Validator;
use RuntimeException;
final class ModuleManifestSchemaValidator
{
private const DEFAULT_SCHEMA_PATH = '.agents/contracts/module-manifest.schema.json';
/** @var array<string, object> */
private static array $schemaCache = [];
public static function assertValid(
array $rawManifest,
string $moduleId,
?string $manifestFile = null,
?string $schemaFile = null
): void {
$schemaPath = self::resolveSchemaPath($schemaFile);
$schema = self::loadSchema($schemaPath);
$payload = json_decode((string) json_encode($rawManifest), false);
if (!is_object($payload)) {
throw new RuntimeException("Manifest schema validation failed for module '{$moduleId}': manifest payload is not serializable.");
}
$validator = new Validator();
$result = $validator->validate($payload, $schema);
if ($result->isValid()) {
return;
}
$location = $manifestFile ?? ("modules/{$moduleId}/module.php");
$message = self::formatValidationErrors($result->error());
throw new RuntimeException(
"Manifest schema validation failed for module '{$moduleId}' ({$location}): {$message}"
);
}
private static function resolveSchemaPath(?string $schemaFile): string
{
if ($schemaFile !== null && trim($schemaFile) !== '') {
return $schemaFile;
}
return dirname(__DIR__, 3) . '/' . self::DEFAULT_SCHEMA_PATH;
}
private static function loadSchema(string $schemaPath): object
{
$normalizedPath = str_replace('\\', '/', $schemaPath);
if (isset(self::$schemaCache[$normalizedPath])) {
return self::$schemaCache[$normalizedPath];
}
if (!is_file($schemaPath)) {
throw new RuntimeException("Module manifest schema not found: {$schemaPath}");
}
$rawSchema = file_get_contents($schemaPath);
if ($rawSchema === false) {
throw new RuntimeException("Unable to read module manifest schema: {$schemaPath}");
}
$decodedSchema = json_decode($rawSchema);
if (!is_object($decodedSchema)) {
throw new RuntimeException("Invalid JSON schema in {$schemaPath}");
}
self::$schemaCache[$normalizedPath] = $decodedSchema;
return $decodedSchema;
}
private static function formatValidationErrors(?ValidationError $error): string
{
if ($error === null) {
return 'unknown schema validation error';
}
$formatter = new ErrorFormatter();
$messages = $formatter->formatFlat(
$error,
static function (ValidationError $validationError) use ($formatter): string {
$path = JsonPointer::pathToString($validationError->data()->fullPath());
$normalizedPath = $path === '' ? '/' : $path;
$message = $formatter->formatErrorMessage($validationError);
return "{$normalizedPath}: {$message}";
}
);
$messages = array_values(array_unique(array_filter(
$messages,
static fn ($message): bool => is_string($message) && trim($message) !== ''
)));
if ($messages === []) {
return 'unknown schema validation error';
}
return implode('; ', array_slice($messages, 0, 3));
}
}

View File

@@ -128,6 +128,8 @@ final class ModuleRegistry
);
}
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
$manifest = ModuleManifest::fromArray($raw, $modulePath);
if ($manifest->id !== $moduleId) {

View File

@@ -36,6 +36,7 @@ final class ValidateCommand extends Command
php bin/console module:validate --all
Validates:
- Manifest validates against .agents/contracts/module-manifest.schema.json
- Manifest parses without error
- All declared classes exist and implement correct interfaces
- PHP classes use MintyPHP\Module\* namespace

View File

@@ -188,7 +188,7 @@ final class ModuleRegistryTest extends TestCase
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("must define non-empty 'path' and 'target'");
$this->expectExceptionMessage('Manifest schema validation failed');
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
}
@@ -205,7 +205,7 @@ final class ModuleRegistryTest extends TestCase
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("must define a non-empty 'key'");
$this->expectExceptionMessage('Manifest schema validation failed');
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
}

View File

@@ -0,0 +1,183 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class AgentSystemEnforcementContractTest extends TestCase
{
use AgentSystemContractSupport;
use ProjectFileAssertionSupport;
public function testEnforcementPolicyReferencesOnlyKnownQualityGates(): void
{
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
$policy = $this->decodeAgentJson('.agents/checks/enforcement-policy.json');
$knownGateIds = array_map(
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
);
$required = is_array($policy['gate_policy']['required_gates_always'] ?? null)
? $policy['gate_policy']['required_gates_always']
: [];
$manual = is_array($policy['gate_policy']['manual_non_blocking'] ?? null)
? $policy['gate_policy']['manual_non_blocking']
: [];
$periodic = is_array($policy['gate_policy']['periodic_non_blocking'] ?? null)
? $policy['gate_policy']['periodic_non_blocking']
: [];
foreach (array_merge($required, $manual, $periodic) as $gateId) {
$this->assertContains($gateId, $knownGateIds, 'Unknown gate id in enforcement-policy.json: ' . (string) $gateId);
}
}
public function testGuardEnforcementMapCoversAllGuardsExactlyOnce(): void
{
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
$knownGuardIds = array_map(
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
);
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
$mappedGuardIds = array_map(
static fn (array $entry): string => (string) ($entry['guard_id'] ?? ''),
$entries
);
$this->assertSame($mappedGuardIds, array_values(array_unique($mappedGuardIds)), 'Duplicate guard_id in guard-enforcement-map.json.');
foreach ($knownGuardIds as $guardId) {
$this->assertContains($guardId, $mappedGuardIds, 'Missing guard mapping: ' . $guardId);
}
foreach ($mappedGuardIds as $guardId) {
$this->assertContains($guardId, $knownGuardIds, 'Unknown guard_id in guard-enforcement-map.json: ' . $guardId);
}
foreach ($entries as $entry) {
$mode = (string) ($entry['enforcement_mode'] ?? '');
$this->assertContains(
$mode,
['automated', 'hybrid', 'reviewer'],
'Invalid enforcement_mode in guard-enforcement-map.json for ' . (string) ($entry['guard_id'] ?? '')
);
}
}
public function testAutomatedGuardMappingsReferenceExistingTestFiles(): void
{
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
$root = $this->projectRootPath();
foreach ($entries as $entry) {
$guardId = (string) ($entry['guard_id'] ?? '');
$mode = (string) ($entry['enforcement_mode'] ?? '');
if ($mode !== 'automated') {
continue;
}
$sources = is_array($entry['evidence_source'] ?? null) ? $entry['evidence_source'] : [];
$testPaths = array_values(array_filter(
$sources,
static fn ($source): bool => is_string($source) && str_starts_with($source, 'tests/')
));
$this->assertNotSame([], $testPaths, "Automated guard {$guardId} must reference at least one test file in evidence_source.");
foreach ($testPaths as $testPath) {
$this->assertFileExists($root . '/' . $testPath, "Automated guard {$guardId} references missing test file: {$testPath}");
}
}
}
public function testGuardAndGateReferencesAcrossRepoUseKnownIds(): void
{
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
$knownGuardIds = array_fill_keys(array_map(
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
), true);
$knownGateIds = array_fill_keys(array_map(
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
), true);
$unknown = [];
foreach ($this->idReferenceScanFiles() as $relativePath) {
$content = $this->readProjectFile($relativePath);
preg_match_all('/\bGR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}\b/', $content, $guardMatches);
foreach (array_unique($guardMatches[0] ?? []) as $id) {
if (!isset($knownGuardIds[$id])) {
$unknown[] = $relativePath . ': ' . $id;
}
}
preg_match_all('/\bQG-[0-9]{3}\b/', $content, $gateMatches);
foreach (array_unique($gateMatches[0] ?? []) as $id) {
if (!isset($knownGateIds[$id])) {
$unknown[] = $relativePath . ': ' . $id;
}
}
}
$unknown = array_values(array_unique($unknown));
sort($unknown);
$this->assertSame(
[],
$unknown,
"Unknown GR/QG references found in repository:\n" . implode("\n", $unknown)
);
}
/**
* @return list<string>
*/
private function idReferenceScanFiles(): array
{
$root = $this->projectRootPath();
$scanTargets = ['CLAUDE.md', '.agents', 'docs', 'tests', 'bin', '.gitea'];
$extensions = ['md', 'json', 'php', 'sh', 'yml', 'yaml'];
$files = [];
foreach ($scanTargets as $target) {
$fullPath = $root . '/' . $target;
if (is_file($fullPath)) {
$files[] = $target;
continue;
}
if (!is_dir($fullPath)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($fullPath, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$ext = strtolower((string) $file->getExtension());
if (!in_array($ext, $extensions, true)) {
continue;
}
$relativePath = str_replace($root . '/', '', $file->getPathname());
$files[] = $relativePath;
}
}
$files = array_values(array_unique($files));
sort($files);
return $files;
}
}

View File

@@ -13,6 +13,8 @@ class AgentSystemFileContractTest extends TestCase
$jsonFiles = [
'.agents/checks/guard-catalog.json',
'.agents/checks/quality-gates.json',
'.agents/checks/enforcement-policy.json',
'.agents/checks/guard-enforcement-map.json',
'.agents/contracts/analyst.schema.json',
'.agents/contracts/planner.schema.json',
'.agents/contracts/executor.schema.json',

View File

@@ -0,0 +1,87 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* GR-SEC-007: API requests must not start session/cookie/remember-me web flows.
*/
final class ApiSessionIsolationContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testApiActionsDoNotUseSessionOrWebGuardFlows(): void
{
$apiFiles = $this->collectFilesByExtension('pages/api/v1', ['php']);
self::assertNotSame([], $apiFiles, 'Expected API actions under pages/api/v1.');
$patterns = [
'/\bSession::/' => 'Session::*',
'/\bGuard::/' => 'Guard::*',
'/\bsetcookie\s*\(/i' => 'setcookie(...)',
'/\bsession_start\s*\(/i' => 'session_start(...)',
'/remember[-_ ]?me/i' => 'remember-me',
'/session[_ ]?timeout/i' => 'session-timeout',
];
$violations = [];
foreach ($apiFiles as $relativePath) {
$content = $this->readProjectFile($relativePath);
foreach ($patterns as $regex => $label) {
if (preg_match($regex, $content)) {
$violations[] = "{$relativePath} uses {$label}";
}
}
}
sort($violations);
self::assertSame(
[],
$violations,
"API action files contain web-session flow references (GR-SEC-007):\n" . implode("\n", $violations)
);
}
public function testApiBootstrapDoesNotStartSessionOrRememberMeFlow(): void
{
$content = $this->readProjectFile('lib/Http/ApiBootstrap.php');
self::assertStringNotContainsString('Session::start(', $content);
self::assertStringNotContainsString('RememberMe', $content);
self::assertStringNotContainsString('CookieStore', $content);
self::assertStringNotContainsString('session timeout', strtolower($content));
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function collectFilesByExtension(string $relativeDir, array $extensions): array
{
$root = $this->projectRootPath();
$dir = $root . '/' . $relativeDir;
if (!is_dir($dir)) {
return [];
}
$result = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
if (!in_array(strtolower($file->getExtension()), $extensions, true)) {
continue;
}
$result[] = str_replace($root . '/', '', $file->getPathname());
}
sort($result);
return $result;
}
}

View File

@@ -5,7 +5,7 @@ namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* GR-UI-015: Every key in i18n/default_de.json MUST exist in
* GR-UI-I18N: Every key in i18n/default_de.json MUST exist in
* i18n/default_en.json and vice versa.
*/
class I18nKeyCompletenessContractTest extends TestCase
@@ -31,7 +31,7 @@ class I18nKeyCompletenessContractTest extends TestCase
$this->assertSame(
[],
$missingInEn,
"Keys in default_de.json missing from default_en.json (GR-UI-015):\n"
"Keys in default_de.json missing from default_en.json (GR-UI-I18N):\n"
. implode("\n", $missingInEn)
);
}
@@ -46,7 +46,7 @@ class I18nKeyCompletenessContractTest extends TestCase
$this->assertSame(
[],
$missingInDe,
"Keys in default_en.json missing from default_de.json (GR-UI-015):\n"
"Keys in default_en.json missing from default_de.json (GR-UI-I18N):\n"
. implode("\n", $missingInDe)
);
}

View File

@@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* GR-UI-015 (module extension): Every module with an i18n_path must ship
* GR-UI-I18N (module extension): Every module with an i18n_path must ship
* matching default_de.json and default_en.json with identical keys and
* non-empty values.
*/

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\ModuleManifestSchemaValidator;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ModuleManifestSchemaValidationContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testAllModuleManifestsValidateAgainstJsonSchema(): void
{
$root = $this->projectRootPath();
$manifestFiles = glob($root . '/modules/*/module.php') ?: [];
self::assertNotSame([], $manifestFiles, 'Expected at least one module manifest under modules/*/module.php');
foreach ($manifestFiles as $manifestFile) {
$rawManifest = require $manifestFile;
self::assertIsArray($rawManifest, "Manifest {$manifestFile} must return array");
$moduleId = basename(dirname($manifestFile));
$relativeManifestPath = 'modules/' . $moduleId . '/module.php';
ModuleManifestSchemaValidator::assertValid($rawManifest, $moduleId, $relativeManifestPath);
self::addToAssertionCount(1);
}
}
public function testSchemaValidatorRejectsUnknownRootProperty(): void
{
$manifest = [
'id' => 'schema-fixture',
'version' => '1.0.0',
'enabled_by_default' => false,
'load_order' => 100,
'routes' => [],
'public_paths' => [],
'container_registrars' => [],
'ui_slots' => [],
'search_resources' => [],
'asset_groups' => [],
'layout_context_providers' => [],
'session_providers' => [],
'authorization_policies' => [],
'permissions' => [],
'scheduler_jobs' => [],
'event_listeners' => [],
'deactivation_handler' => null,
'migrations_path' => null,
'i18n_path' => null,
'unexpected_root_property' => true,
];
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Manifest schema validation failed for module 'schema-fixture'");
ModuleManifestSchemaValidator::assertValid(
$manifest,
'schema-fixture',
'modules/schema-fixture/module.php'
);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* GR-SEC-004: No external CDN/remote assets in app templates/pages/styles.
*/
final class SecurityRemoteAssetContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testNoRemoteScriptOrStylesheetTagsInAppTemplatesAndPages(): void
{
$files = array_merge(
$this->collectFilesByExtension('pages', ['php', 'phtml']),
$this->collectFilesByExtension('templates', ['php', 'phtml']),
$this->collectModuleFilesByExtension('pages', ['php', 'phtml']),
$this->collectModuleFilesByExtension('templates', ['php', 'phtml'])
);
$pattern = '/<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*["\']\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1)/i';
$violations = $this->findPatternViolations($files, $pattern);
self::assertSame(
[],
$violations,
"Remote script/link asset references found (GR-SEC-004):\n" . implode("\n", $violations)
);
}
public function testNoRemoteCssImportsOrFontUrls(): void
{
$files = array_merge(
$this->collectFilesByExtension('web/css', ['css']),
$this->collectModuleFilesByExtension('web/css', ['css'])
);
$importPattern = '/@import\s+(?:url\()?["\']?\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1)/i';
$fontPattern = '/url\(\s*["\']?\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1).*?\.(?:woff2?|woff|ttf|otf|eot)\b/i';
$violations = array_merge(
$this->findPatternViolations($files, $importPattern),
$this->findPatternViolations($files, $fontPattern)
);
$violations = array_values(array_unique($violations));
sort($violations);
self::assertSame(
[],
$violations,
"Remote CSS imports/font URLs found (GR-SEC-004):\n" . implode("\n", $violations)
);
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function collectFilesByExtension(string $relativeDir, array $extensions): array
{
$root = $this->projectRootPath();
$dir = $root . '/' . $relativeDir;
if (!is_dir($dir)) {
return [];
}
$result = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
if (!in_array(strtolower($file->getExtension()), $extensions, true)) {
continue;
}
$result[] = str_replace($root . '/', '', $file->getPathname());
}
sort($result);
return $result;
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function collectModuleFilesByExtension(string $moduleSubDir, array $extensions): array
{
$root = $this->projectRootPath();
$modulesDir = $root . '/modules';
if (!is_dir($modulesDir)) {
return [];
}
$result = [];
$entries = scandir($modulesDir) ?: [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$dir = $modulesDir . '/' . $entry . '/' . $moduleSubDir;
if (!is_dir($dir)) {
continue;
}
$result = array_merge($result, $this->collectFilesByExtension('modules/' . $entry . '/' . $moduleSubDir, $extensions));
}
$result = array_values(array_unique($result));
sort($result);
return $result;
}
/**
* @param list<string> $files
* @return list<string>
*/
private function findPatternViolations(array $files, string $pattern): array
{
$violations = [];
foreach ($files as $relativePath) {
$content = $this->readProjectFile($relativePath);
if (preg_match($pattern, $content)) {
$violations[] = $relativePath;
}
}
sort($violations);
return $violations;
}
}