chore(agents): implement v2 hardening and enforcement-ready QA
This commit is contained in:
@@ -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` |
|
||||
|
||||
77
.agents/checks/enforcement-policy.json
Normal file
77
.agents/checks/enforcement-policy.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
173
.agents/checks/guard-enforcement-map.json
Normal file
173
.agents/checks/guard-enforcement-map.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
24
.gitea/workflows/qa-required.yaml
Normal file
24
.gitea/workflows/qa-required.yaml
Normal 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
|
||||
13
CLAUDE.md
13
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
|
||||
|
||||
|
||||
24
bin/dev
24
bin/dev
@@ -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
45
bin/qa-extended.sh
Executable 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
44
bin/qa-required.sh
Executable 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)."
|
||||
@@ -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
192
composer.lock
generated
@@ -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",
|
||||
|
||||
43
docs/howto-gitea-required-checks.md
Normal file
43
docs/howto-gitea-required-checks.md
Normal 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`.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,6 +37,8 @@ final class ModuleManifestLoader
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
return ModuleManifest::fromArray($raw, dirname($manifestFile));
|
||||
}
|
||||
}
|
||||
|
||||
108
lib/App/Module/ModuleManifestSchemaValidator.php
Normal file
108
lib/App/Module/ModuleManifestSchemaValidator.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,8 @@ final class ModuleRegistry
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
$manifest = ModuleManifest::fromArray($raw, $modulePath);
|
||||
|
||||
if ($manifest->id !== $moduleId) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
183
tests/Architecture/AgentSystemEnforcementContractTest.php
Normal file
183
tests/Architecture/AgentSystemEnforcementContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
87
tests/Architecture/ApiSessionIsolationContractTest.php
Normal file
87
tests/Architecture/ApiSessionIsolationContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
138
tests/Architecture/SecurityRemoteAssetContractTest.php
Normal file
138
tests/Architecture/SecurityRemoteAssetContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user