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)
|
templates/ ← Starter JSON payloads per role (7 templates)
|
||||||
prompts/ ← Role-specific prompt baselines (7 prompts)
|
prompts/ ← Role-specific prompt baselines (7 prompts)
|
||||||
checks/ ← Guard catalog (22 guards), quality gates (9), checklists
|
checks/ ← Guard catalog (22 guards), quality gates (9), checklists
|
||||||
|
← Enforcement policy + guard enforcement map
|
||||||
skills/ ← Reusable skill packages (guardrails, planner, grid, PHP CI)
|
skills/ ← Reusable skill packages (guardrails, planner, grid, PHP CI)
|
||||||
runs/ ← Runtime artifacts per workflow run (gitignored)
|
runs/ ← Runtime artifacts per workflow run (gitignored)
|
||||||
```
|
```
|
||||||
@@ -33,6 +34,8 @@ runs/ ← Runtime artifacts per workflow run (gitignored)
|
|||||||
| Workflow & roles | `workflow.md` |
|
| Workflow & roles | `workflow.md` |
|
||||||
| Guard catalog (22 guards) | `checks/guard-catalog.json` |
|
| Guard catalog (22 guards) | `checks/guard-catalog.json` |
|
||||||
| Quality gates (9 gates) | `checks/quality-gates.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` |
|
| Code review checklist | `checks/guard-checklist.md` |
|
||||||
| Acceptance checklist | `checks/acceptance-checklist.md` |
|
| Acceptance checklist | `checks/acceptance-checklist.md` |
|
||||||
| Module manifest schema | `contracts/module-manifest.schema.json` |
|
| 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
|
- `GR-UI-REUSE` existing JS/CSS checked before adding new functions/rules
|
||||||
|
|
||||||
## Quality Gates
|
## 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-004` structural checks reported when relevant
|
||||||
- `QG-005` JS smoke for JS-touching changes
|
- `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
|
# 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",
|
"$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",
|
"title": "Module Manifest Schema",
|
||||||
"description": "Declarative schema for module.php manifest files under modules/<id>/.",
|
"description": "Declarative schema for module.php manifest files under modules/<id>/.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -61,40 +61,53 @@
|
|||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
"ui_slots": {
|
"ui_slots": {
|
||||||
"type": "object",
|
|
||||||
"description": "Keyed by slot name (e.g. 'aside.tab_panel'). Values are arrays of contribution objects.",
|
"description": "Keyed by slot name (e.g. 'aside.tab_panel'). Values are arrays of contribution objects.",
|
||||||
"patternProperties": {
|
"anyOf": [
|
||||||
"^.+$": {
|
{
|
||||||
"type": "array",
|
"type": "object",
|
||||||
"items": {
|
"patternProperties": {
|
||||||
"type": "object",
|
"^.+$": {
|
||||||
"required": ["key"],
|
"type": "array",
|
||||||
"properties": {
|
"items": {
|
||||||
"key": { "type": "string", "minLength": 1 },
|
"type": "object",
|
||||||
"label": { "type": "string" },
|
"required": ["key"],
|
||||||
"icon": { "type": "string" },
|
"properties": {
|
||||||
"href": { "type": "string" },
|
"key": { "type": "string", "minLength": 1 },
|
||||||
"permission": { "type": "string" },
|
"label": { "type": "string" },
|
||||||
"panel_template": { "type": "string" },
|
"icon": { "type": "string" },
|
||||||
"template": { "type": "string" },
|
"href": { "type": "string" },
|
||||||
"path": { "type": "string" },
|
"permission": { "type": "string" },
|
||||||
"script": { "type": "string" },
|
"panel_template": { "type": "string" },
|
||||||
"export": { "type": "string" },
|
"template": { "type": "string" },
|
||||||
"selector": { "type": "string" },
|
"path": { "type": "string" },
|
||||||
"scope": { "type": "string" },
|
"script": { "type": "string" },
|
||||||
"config_path": { "type": "string" },
|
"export": { "type": "string" },
|
||||||
"default_config": { "type": "object" },
|
"selector": { "type": "string" },
|
||||||
"phase": { "type": "string", "enum": ["early", "default", "late"] },
|
"scope": { "type": "string" },
|
||||||
"type": { "type": "string" },
|
"config_path": { "type": "string" },
|
||||||
"order": { "type": "integer", "default": 100 },
|
"default_config": {
|
||||||
"details_storage": { "type": "string" },
|
"anyOf": [
|
||||||
"details_open_active": { "type": "boolean" },
|
{ "type": "object" },
|
||||||
"base_url": { "type": "string" },
|
{ "type": "array", "maxItems": 0 }
|
||||||
"group": { "type": "string", "description": "Sidebar nav group key for grouping related items under a details/summary section." }
|
]
|
||||||
|
},
|
||||||
|
"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": {}
|
"default": {}
|
||||||
},
|
},
|
||||||
"search_resources": {
|
"search_resources": {
|
||||||
@@ -103,14 +116,22 @@
|
|||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
"asset_groups": {
|
"asset_groups": {
|
||||||
"type": "object",
|
|
||||||
"description": "Asset group name → file list.",
|
"description": "Asset group name → file list.",
|
||||||
"patternProperties": {
|
"anyOf": [
|
||||||
"^.+$": {
|
{
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
"^.+$": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" }
|
"maxItems": 0
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
"default": {}
|
"default": {}
|
||||||
},
|
},
|
||||||
"layout_context_providers": {
|
"layout_context_providers": {
|
||||||
@@ -136,8 +157,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"key": { "type": "string", "minLength": 1 },
|
"key": { "type": "string", "minLength": 1 },
|
||||||
"description": { "type": "string", "minLength": 1 },
|
"description": { "type": "string", "minLength": 1 },
|
||||||
"active": { "type": "boolean", "default": true },
|
"active": { "type": ["boolean", "integer"], "default": true },
|
||||||
"is_system": { "type": "boolean", "default": true }
|
"is_system": { "type": ["boolean", "integer"], "default": true }
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -177,22 +198,30 @@
|
|||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
"event_listeners": {
|
"event_listeners": {
|
||||||
"type": "object",
|
|
||||||
"description": "Event name → list of listener descriptors.",
|
"description": "Event name → list of listener descriptors.",
|
||||||
"patternProperties": {
|
"anyOf": [
|
||||||
"^.+$": {
|
{
|
||||||
"type": "array",
|
"type": "object",
|
||||||
"items": {
|
"patternProperties": {
|
||||||
"type": "object",
|
"^.+$": {
|
||||||
"required": ["class", "method"],
|
"type": "array",
|
||||||
"properties": {
|
"items": {
|
||||||
"class": { "type": "string", "minLength": 1, "description": "FQCN implementing EventListener." },
|
"type": "object",
|
||||||
"method": { "type": "string", "minLength": 1 }
|
"required": ["class", "method"],
|
||||||
},
|
"properties": {
|
||||||
"additionalProperties": false
|
"class": { "type": "string", "minLength": 1, "description": "FQCN implementing EventListener." },
|
||||||
|
"method": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 0
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
"default": {}
|
"default": {}
|
||||||
},
|
},
|
||||||
"deactivation_handler": {
|
"deactivation_handler": {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
1. `docker compose exec php vendor/bin/phpunit`
|
1. `docker compose exec php vendor/bin/phpunit`
|
||||||
2. `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
|
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`
|
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)
|
## Struktur-Gates (schnell)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Primary goals:
|
|||||||
Source of truth:
|
Source of truth:
|
||||||
- Guard catalog (22 guards): `.agents/checks/guard-catalog.json`
|
- Guard catalog (22 guards): `.agents/checks/guard-catalog.json`
|
||||||
- Quality gates (9 gates): `.agents/checks/quality-gates.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/`
|
- Contracts: `.agents/contracts/`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -81,6 +83,17 @@ analyzed → planned → executing → reviewing → finalize → done
|
|||||||
The reviewing state runs Code Reviewer, Security Reviewer, and Acceptance Tester.
|
The reviewing state runs Code Reviewer, Security Reviewer, and Acceptance Tester.
|
||||||
If any reviewer returns `FAIL`, state goes back to `executing` with explicit findings.
|
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
|
## Handover Artifacts
|
||||||
|
|
||||||
| Role | Artifact |
|
| 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)
|
- Repository sanitizeLimitOffset (RepositoryInterfaceContractTest)
|
||||||
- List init standard, legacy filter toggle, template RBAC (architecture tests)
|
- List init standard, legacy filter toggle, template RBAC (architecture tests)
|
||||||
- Component lifecycle contract (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**.
|
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.
|
- **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** (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).
|
- **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).
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -202,7 +202,8 @@ These rules are enforced by guards GR-SEC-001 through GR-SEC-009 in `.agents/che
|
|||||||
|
|
||||||
## Quality Gates
|
## 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 |
|
| 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-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` |
|
||||||
| QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` |
|
| 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-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
|
## Environment
|
||||||
|
|
||||||
|
|||||||
24
bin/dev
24
bin/dev
@@ -15,7 +15,9 @@ Commands:
|
|||||||
down Stop local containers
|
down Stop local containers
|
||||||
logs [service] Follow docker compose logs (optional service)
|
logs [service] Follow docker compose logs (optional service)
|
||||||
console <args...> Run php bin/console inside php container
|
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
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +97,14 @@ cmd_logs() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_qa() {
|
cmd_qa_required() {
|
||||||
require_docker_compose
|
require_docker_compose
|
||||||
docker compose exec -T php vendor/bin/phpunit
|
"${repo_root}/bin/qa-required.sh"
|
||||||
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
|
cmd_qa_extended() {
|
||||||
|
require_docker_compose
|
||||||
|
"${repo_root}/bin/qa-extended.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ $# -eq 0 ]]; then
|
if [[ $# -eq 0 ]]; then
|
||||||
@@ -128,7 +132,13 @@ case "${command}" in
|
|||||||
run_console "$@"
|
run_console "$@"
|
||||||
;;
|
;;
|
||||||
qa)
|
qa)
|
||||||
cmd_qa "$@"
|
cmd_qa_required "$@"
|
||||||
|
;;
|
||||||
|
qa-required)
|
||||||
|
cmd_qa_required "$@"
|
||||||
|
;;
|
||||||
|
qa-extended)
|
||||||
|
cmd_qa_extended "$@"
|
||||||
;;
|
;;
|
||||||
-h|--help|help)
|
-h|--help|help)
|
||||||
usage
|
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",
|
"phpmailer/phpmailer": "^7.0",
|
||||||
"dompdf/dompdf": "^3.1",
|
"dompdf/dompdf": "^3.1",
|
||||||
"endroid/qr-code": "^6.0",
|
"endroid/qr-code": "^6.0",
|
||||||
"league/commonmark": "^2.8"
|
"league/commonmark": "^2.8",
|
||||||
|
"opis/json-schema": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"mintyphp/tools": "*",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "91c05b180a4c7a7b830ea9a02bf7dca4",
|
"content-hash": "0926942d029bad48009daddfa3a97395",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -870,6 +870,196 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-13T03:05:33+00:00"
|
"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",
|
"name": "phpmailer/phpmailer",
|
||||||
"version": "v7.0.2",
|
"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
|
# 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.
|
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.
|
- Tages-Workflow fuer Entwicklung und Checks.
|
||||||
- `/docs/howto-modul-erstellen.md`
|
- `/docs/howto-modul-erstellen.md`
|
||||||
- Neues Modul erstellen: Scaffold, Manifest, Route, Permission, UI-Slot, Test.
|
- 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`
|
- `/docs/howto-dokumentation-erweitern.md`
|
||||||
- Regeln fuer konsistente, wachsende Doku.
|
- Regeln fuer konsistente, wachsende Doku.
|
||||||
|
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ Der Docker-Scheduler-Service ruft dieses Kommando automatisch alle 60 Sekunden a
|
|||||||
|
|
||||||
### module:validate
|
### 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
|
```bash
|
||||||
docker compose exec php php bin/console module:validate <module-id>
|
docker compose exec php php bin/console module:validate <module-id>
|
||||||
docker compose exec php php bin/console module:validate --all
|
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
|
### make:module
|
||||||
|
|
||||||
@@ -143,9 +143,12 @@ bin/dev down
|
|||||||
bin/dev logs [service]
|
bin/dev logs [service]
|
||||||
bin/dev console module:sync
|
bin/dev console module:sync
|
||||||
bin/dev qa
|
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 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
|
## Neue Kommandos anlegen
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Kurze Definition of Done vor Merge.
|
|||||||
|
|
||||||
## Qualitätschecks
|
## 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/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/phpstan analyse -c phpstan.neon --no-progress`
|
||||||
- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
|
- [ ] `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>`
|
- [ ] 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)
|
- [ ] 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: `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/**`)
|
- [ ] `bin/docs-link-check.sh` (QG-008, scannt standardmaessig ohne `.agents/runs/**`)
|
||||||
- [ ] bei Docs-/Skills-Aenderungen: `bin/codex-skills-sync.sh --check`
|
- [ ] `bin/codex-skills-sync.sh --check` (QG-009)
|
||||||
- [ ] bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors)
|
- [ ] bei JS-Änderungen: Browser-Smoke mit DevTools-Console (keine JS-Errors)
|
||||||
- [ ] bei API-Änderungen: `docs/openapi.yaml` aktualisiert
|
- [ ] bei API-Änderungen: `docs/openapi.yaml` aktualisiert
|
||||||
- [ ] Struktur-Gates geprüft (`rg` für Instanziierungsregeln)
|
- [ ] 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.
|
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.
|
`/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
|
## Namespace-Konvention
|
||||||
|
|
||||||
Alle PHP-Klassen eines Moduls muessen den Namespace `MintyPHP\Module\<Name>\*` verwenden.
|
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));
|
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);
|
$manifest = ModuleManifest::fromArray($raw, $modulePath);
|
||||||
|
|
||||||
if ($manifest->id !== $moduleId) {
|
if ($manifest->id !== $moduleId) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class ValidateCommand extends Command
|
|||||||
php bin/console module:validate --all
|
php bin/console module:validate --all
|
||||||
|
|
||||||
Validates:
|
Validates:
|
||||||
|
- Manifest validates against .agents/contracts/module-manifest.schema.json
|
||||||
- Manifest parses without error
|
- Manifest parses without error
|
||||||
- All declared classes exist and implement correct interfaces
|
- All declared classes exist and implement correct interfaces
|
||||||
- PHP classes use MintyPHP\Module\* namespace
|
- PHP classes use MintyPHP\Module\* namespace
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(RuntimeException::class);
|
$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']);
|
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ final class ModuleRegistryTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(RuntimeException::class);
|
$this->expectException(RuntimeException::class);
|
||||||
$this->expectExceptionMessage("must define a non-empty 'key'");
|
$this->expectExceptionMessage('Manifest schema validation failed');
|
||||||
|
|
||||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
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 = [
|
$jsonFiles = [
|
||||||
'.agents/checks/guard-catalog.json',
|
'.agents/checks/guard-catalog.json',
|
||||||
'.agents/checks/quality-gates.json',
|
'.agents/checks/quality-gates.json',
|
||||||
|
'.agents/checks/enforcement-policy.json',
|
||||||
|
'.agents/checks/guard-enforcement-map.json',
|
||||||
'.agents/contracts/analyst.schema.json',
|
'.agents/contracts/analyst.schema.json',
|
||||||
'.agents/contracts/planner.schema.json',
|
'.agents/contracts/planner.schema.json',
|
||||||
'.agents/contracts/executor.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;
|
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.
|
* i18n/default_en.json and vice versa.
|
||||||
*/
|
*/
|
||||||
class I18nKeyCompletenessContractTest extends TestCase
|
class I18nKeyCompletenessContractTest extends TestCase
|
||||||
@@ -31,7 +31,7 @@ class I18nKeyCompletenessContractTest extends TestCase
|
|||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
[],
|
[],
|
||||||
$missingInEn,
|
$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)
|
. implode("\n", $missingInEn)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class I18nKeyCompletenessContractTest extends TestCase
|
|||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
[],
|
[],
|
||||||
$missingInDe,
|
$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)
|
. implode("\n", $missingInDe)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
|||||||
use PHPUnit\Framework\TestCase;
|
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
|
* matching default_de.json and default_en.json with identical keys and
|
||||||
* non-empty values.
|
* 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