diff --git a/.agents/README.md b/.agents/README.md index c181f57..68b12d0 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -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` | diff --git a/.agents/checks/enforcement-policy.json b/.agents/checks/enforcement-policy.json new file mode 100644 index 0000000..1f8fd1e --- /dev/null +++ b/.agents/checks/enforcement-policy.json @@ -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." + } +} diff --git a/.agents/checks/guard-checklist.md b/.agents/checks/guard-checklist.md index e4f143c..5208fdc 100644 --- a/.agents/checks/guard-checklist.md +++ b/.agents/checks/guard-checklist.md @@ -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 diff --git a/.agents/checks/guard-enforcement-map.json b/.agents/checks/guard-enforcement-map.json new file mode 100644 index 0000000..ef079a9 --- /dev/null +++ b/.agents/checks/guard-enforcement-map.json @@ -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//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//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//review-code.json", + ".agents/runs//review-acceptance.json" + ] + }, + { + "guard_id": "GR-TEST-002", + "enforcement_mode": "hybrid", + "evidence_source": [ + "tests/Architecture/CoreStarterkitContractTest.php", + ".agents/runs//review-code.json" + ] + }, + { + "guard_id": "GR-UI-014", + "enforcement_mode": "reviewer", + "evidence_source": [ + ".agents/runs//review-code.json", + ".agents/runs//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//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//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//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//review-security.json" + ] + }, + { + "guard_id": "GR-SEC-009", + "enforcement_mode": "hybrid", + "evidence_source": [ + "tests/Architecture/ApiResourceContractTest.php", + ".agents/runs//review-security.json" + ] + } + ] +} diff --git a/.agents/contracts/module-manifest.schema.json b/.agents/contracts/module-manifest.schema.json index 1b539b9..7dba30d 100644 --- a/.agents/contracts/module-manifest.schema.json +++ b/.agents/contracts/module-manifest.schema.json @@ -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//.", "type": "object", @@ -61,40 +61,53 @@ "default": [] }, "ui_slots": { - "type": "object", "description": "Keyed by slot name (e.g. 'aside.tab_panel'). Values are arrays of contribution objects.", - "patternProperties": { - "^.+$": { - "type": "array", - "items": { - "type": "object", - "required": ["key"], - "properties": { - "key": { "type": "string", "minLength": 1 }, - "label": { "type": "string" }, - "icon": { "type": "string" }, - "href": { "type": "string" }, - "permission": { "type": "string" }, - "panel_template": { "type": "string" }, - "template": { "type": "string" }, - "path": { "type": "string" }, - "script": { "type": "string" }, - "export": { "type": "string" }, - "selector": { "type": "string" }, - "scope": { "type": "string" }, - "config_path": { "type": "string" }, - "default_config": { "type": "object" }, - "phase": { "type": "string", "enum": ["early", "default", "late"] }, - "type": { "type": "string" }, - "order": { "type": "integer", "default": 100 }, - "details_storage": { "type": "string" }, - "details_open_active": { "type": "boolean" }, - "base_url": { "type": "string" }, - "group": { "type": "string", "description": "Sidebar nav group key for grouping related items under a details/summary section." } + "anyOf": [ + { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "array", + "items": { + "type": "object", + "required": ["key"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "label": { "type": "string" }, + "icon": { "type": "string" }, + "href": { "type": "string" }, + "permission": { "type": "string" }, + "panel_template": { "type": "string" }, + "template": { "type": "string" }, + "path": { "type": "string" }, + "script": { "type": "string" }, + "export": { "type": "string" }, + "selector": { "type": "string" }, + "scope": { "type": "string" }, + "config_path": { "type": "string" }, + "default_config": { + "anyOf": [ + { "type": "object" }, + { "type": "array", "maxItems": 0 } + ] + }, + "phase": { "type": "string", "enum": ["early", "default", "late"] }, + "type": { "type": "string" }, + "order": { "type": "integer", "default": 100 }, + "details_storage": { "type": "string" }, + "details_open_active": { "type": "boolean" }, + "base_url": { "type": "string" }, + "group": { "type": "string", "description": "Sidebar nav group key for grouping related items under a details/summary section." } + } + } } } + }, + { + "type": "array", + "maxItems": 0 } - }, + ], "default": {} }, "search_resources": { @@ -103,14 +116,22 @@ "default": [] }, "asset_groups": { - "type": "object", "description": "Asset group name → file list.", - "patternProperties": { - "^.+$": { + "anyOf": [ + { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "array", + "items": { "type": "string" } + } + } + }, + { "type": "array", - "items": { "type": "string" } + "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,22 +198,30 @@ "default": [] }, "event_listeners": { - "type": "object", "description": "Event name → list of listener descriptors.", - "patternProperties": { - "^.+$": { - "type": "array", - "items": { - "type": "object", - "required": ["class", "method"], - "properties": { - "class": { "type": "string", "minLength": 1, "description": "FQCN implementing EventListener." }, - "method": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false + "anyOf": [ + { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "array", + "items": { + "type": "object", + "required": ["class", "method"], + "properties": { + "class": { "type": "string", "minLength": 1, "description": "FQCN implementing EventListener." }, + "method": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } } + }, + { + "type": "array", + "maxItems": 0 } - }, + ], "default": {} }, "deactivation_handler": { diff --git a/.agents/skills/core-guardrails/references/quality-gates.md b/.agents/skills/core-guardrails/references/quality-gates.md index fa890ba..d1db799 100644 --- a/.agents/skills/core-guardrails/references/quality-gates.md +++ b/.agents/skills/core-guardrails/references/quality-gates.md @@ -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) diff --git a/.agents/workflow.md b/.agents/workflow.md index 25dcff4..0b3f943 100644 --- a/.agents/workflow.md +++ b/.agents/workflow.md @@ -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 diff --git a/.gitea/workflows/qa-required.yaml b/.gitea/workflows/qa-required.yaml new file mode 100644 index 0000000..2e15ae0 --- /dev/null +++ b/.gitea/workflows/qa-required.yaml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index dddb278..310b6b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/bin/dev b/bin/dev index f3d63b3..03ef6c7 100755 --- a/bin/dev +++ b/bin/dev @@ -15,7 +15,9 @@ Commands: down Stop local containers logs [service] Follow docker compose logs (optional service) console 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 diff --git a/bin/qa-extended.sh b/bin/qa-extended.sh new file mode 100755 index 0000000..4e5509e --- /dev/null +++ b/bin/qa-extended.sh @@ -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)." diff --git a/bin/qa-required.sh b/bin/qa-required.sh new file mode 100755 index 0000000..2c35e7b --- /dev/null +++ b/bin/qa-required.sh @@ -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)." diff --git a/composer.json b/composer.json index 4332c4c..cf657d4 100644 --- a/composer.json +++ b/composer.json @@ -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": "*", diff --git a/composer.lock b/composer.lock index 48df14e..6b046c9 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/docs/howto-gitea-required-checks.md b/docs/howto-gitea-required-checks.md new file mode 100644 index 0000000..62674d0 --- /dev/null +++ b/docs/howto-gitea-required-checks.md @@ -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`. diff --git a/docs/index.md b/docs/index.md index 0617a2e..15b24a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/reference-cli-commands.md b/docs/reference-cli-commands.md index 51f9241..ebd3b69 100644 --- a/docs/reference-cli-commands.md +++ b/docs/reference-cli-commands.md @@ -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 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 diff --git a/docs/reference-entwickler-checkliste.md b/docs/reference-entwickler-checkliste.md index ab0cc30..3cd8dd4 100644 --- a/docs/reference-entwickler-checkliste.md +++ b/docs/reference-entwickler-checkliste.md @@ -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=` - [ ] 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) diff --git a/docs/reference-module-manifest-contract.md b/docs/reference-module-manifest-contract.md index 6945eab..b130059 100644 --- a/docs/reference-module-manifest-contract.md +++ b/docs/reference-module-manifest-contract.md @@ -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 ` + +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\\*` verwenden. diff --git a/lib/App/Module/ModuleManifestLoader.php b/lib/App/Module/ModuleManifestLoader.php index f57fdd3..df9ea96 100644 --- a/lib/App/Module/ModuleManifestLoader.php +++ b/lib/App/Module/ModuleManifestLoader.php @@ -37,6 +37,8 @@ final class ModuleManifestLoader ); } + ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile); + return ModuleManifest::fromArray($raw, dirname($manifestFile)); } } diff --git a/lib/App/Module/ModuleManifestSchemaValidator.php b/lib/App/Module/ModuleManifestSchemaValidator.php new file mode 100644 index 0000000..7c93e67 --- /dev/null +++ b/lib/App/Module/ModuleManifestSchemaValidator.php @@ -0,0 +1,108 @@ + */ + 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)); + } +} diff --git a/lib/App/Module/ModuleRegistry.php b/lib/App/Module/ModuleRegistry.php index cb12a7a..9df204e 100644 --- a/lib/App/Module/ModuleRegistry.php +++ b/lib/App/Module/ModuleRegistry.php @@ -128,6 +128,8 @@ final class ModuleRegistry ); } + ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile); + $manifest = ModuleManifest::fromArray($raw, $modulePath); if ($manifest->id !== $moduleId) { diff --git a/lib/Console/Commands/Module/ValidateCommand.php b/lib/Console/Commands/Module/ValidateCommand.php index e8f6744..bc20a2c 100644 --- a/lib/Console/Commands/Module/ValidateCommand.php +++ b/lib/Console/Commands/Module/ValidateCommand.php @@ -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 diff --git a/tests/App/Module/ModuleRegistryTest.php b/tests/App/Module/ModuleRegistryTest.php index 98dcce5..afa01bd 100644 --- a/tests/App/Module/ModuleRegistryTest.php +++ b/tests/App/Module/ModuleRegistryTest.php @@ -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']); } diff --git a/tests/Architecture/AgentSystemEnforcementContractTest.php b/tests/Architecture/AgentSystemEnforcementContractTest.php new file mode 100644 index 0000000..c9f201b --- /dev/null +++ b/tests/Architecture/AgentSystemEnforcementContractTest.php @@ -0,0 +1,183 @@ +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 + */ + 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; + } +} diff --git a/tests/Architecture/AgentSystemFileContractTest.php b/tests/Architecture/AgentSystemFileContractTest.php index 6bb5409..b3f9d7a 100644 --- a/tests/Architecture/AgentSystemFileContractTest.php +++ b/tests/Architecture/AgentSystemFileContractTest.php @@ -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', diff --git a/tests/Architecture/ApiSessionIsolationContractTest.php b/tests/Architecture/ApiSessionIsolationContractTest.php new file mode 100644 index 0000000..41d60e1 --- /dev/null +++ b/tests/Architecture/ApiSessionIsolationContractTest.php @@ -0,0 +1,87 @@ +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 $extensions + * @return list + */ + 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; + } +} diff --git a/tests/Architecture/I18nKeyCompletenessContractTest.php b/tests/Architecture/I18nKeyCompletenessContractTest.php index c99d026..c3795d1 100644 --- a/tests/Architecture/I18nKeyCompletenessContractTest.php +++ b/tests/Architecture/I18nKeyCompletenessContractTest.php @@ -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) ); } diff --git a/tests/Architecture/ModuleI18nContractTest.php b/tests/Architecture/ModuleI18nContractTest.php index ce0d0a7..3cece32 100644 --- a/tests/Architecture/ModuleI18nContractTest.php +++ b/tests/Architecture/ModuleI18nContractTest.php @@ -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. */ diff --git a/tests/Architecture/ModuleManifestSchemaValidationContractTest.php b/tests/Architecture/ModuleManifestSchemaValidationContractTest.php new file mode 100644 index 0000000..d2e52d0 --- /dev/null +++ b/tests/Architecture/ModuleManifestSchemaValidationContractTest.php @@ -0,0 +1,66 @@ +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' + ); + } +} diff --git a/tests/Architecture/SecurityRemoteAssetContractTest.php b/tests/Architecture/SecurityRemoteAssetContractTest.php new file mode 100644 index 0000000..56b53c0 --- /dev/null +++ b/tests/Architecture/SecurityRemoteAssetContractTest.php @@ -0,0 +1,138 @@ +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 $extensions + * @return list + */ + 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 $extensions + * @return list + */ + 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 $files + * @return list + */ + 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; + } +}