feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks

Six targeted improvements to the modular monolith platform:

1. Fix AddressBook session key prefix to follow module.<id>.* convention
2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module
3. Add declarative JSON schema for module manifests (.agents/contracts/)
4. Add `requires` field with missing-dependency and circular-dependency detection
5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout)
6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php)

Includes 15 new architecture/unit tests covering all new functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:20:13 +01:00
parent 866d43e15a
commit ef72b34c40
31 changed files with 1041 additions and 75 deletions

View File

@@ -11,18 +11,23 @@ class AgentSystemContractTest extends TestCase
public function testAgentSystemJsonFilesAreValidJson(): void
{
$jsonFiles = [
'agent-system/checks/guard-catalog.json',
'agent-system/checks/quality-gates.json',
'agent-system/contracts/planner.schema.json',
'agent-system/contracts/executor.schema.json',
'agent-system/contracts/reviewer-guards.schema.json',
'agent-system/contracts/reviewer-acceptance.schema.json',
'agent-system/contracts/finalizer.schema.json',
'agent-system/templates/plan.template.json',
'agent-system/templates/execution-report.template.json',
'agent-system/templates/review-guards.template.json',
'agent-system/templates/review-acceptance.template.json',
'agent-system/templates/finalize.template.json',
'.agents/checks/guard-catalog.json',
'.agents/checks/quality-gates.json',
'.agents/contracts/analyst.schema.json',
'.agents/contracts/planner.schema.json',
'.agents/contracts/executor.schema.json',
'.agents/contracts/reviewer-code.schema.json',
'.agents/contracts/reviewer-security.schema.json',
'.agents/contracts/reviewer-acceptance.schema.json',
'.agents/contracts/finalizer.schema.json',
'.agents/templates/analysis.template.json',
'.agents/templates/plan.template.json',
'.agents/templates/execution-report.template.json',
'.agents/templates/review-code.template.json',
'.agents/templates/review-security.template.json',
'.agents/templates/review-acceptance.template.json',
'.agents/templates/finalize.template.json',
'.agents/contracts/module-manifest.schema.json',
];
foreach ($jsonFiles as $file) {
@@ -33,27 +38,42 @@ class AgentSystemContractTest extends TestCase
public function testGuardCatalogIdsAreUniqueAndWellFormed(): void
{
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
$this->assertNotSame([], $guards, 'Guard catalog must not be empty.');
$ids = [];
foreach ($guards as $guard) {
$id = (string) ($guard['id'] ?? '');
$source = (string) ($guard['source'] ?? '');
$this->assertMatchesRegularExpression('/^GR-[A-Z]+-[0-9]{3}$/', $id, 'Invalid guard id: ' . $id);
$this->assertNotSame('', $source, 'Missing guard source for ' . $id);
$sourcePath = explode('#', $source)[0];
$this->readProjectFile($sourcePath);
$reviewer = (string) ($guard['reviewer'] ?? '');
$this->assertMatchesRegularExpression('/^GR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}$/', $id, 'Invalid guard id: ' . $id);
$this->assertContains($reviewer, ['code', 'security'], 'Invalid reviewer for ' . $id);
$ids[] = $id;
}
$this->assertSame($ids, array_values(array_unique($ids)), 'Duplicate guard IDs found.');
}
public function testGuardCatalogReviewerAssignment(): void
{
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
foreach ($guards as $guard) {
$id = (string) ($guard['id'] ?? '');
$reviewer = (string) ($guard['reviewer'] ?? '');
if (str_starts_with($id, 'GR-SEC-')) {
$this->assertSame('security', $reviewer, $id . ' must be assigned to security reviewer');
} else {
$this->assertSame('code', $reviewer, $id . ' must be assigned to code reviewer');
}
}
}
public function testQualityGateIdsAreUniqueAndWellFormed(): void
{
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
$gates = $this->decodeJson('.agents/checks/quality-gates.json');
$items = is_array($gates['gates'] ?? null) ? $gates['gates'] : [];
$this->assertNotSame([], $items, 'Quality gates must not be empty.');
@@ -71,8 +91,8 @@ class AgentSystemContractTest extends TestCase
public function testTemplatesReferenceKnownGuardAndGateIds(): void
{
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
$gates = $this->decodeJson('.agents/checks/quality-gates.json');
$guardIds = array_map(
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
@@ -82,7 +102,7 @@ class AgentSystemContractTest extends TestCase
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
);
$planTemplate = $this->decodeJson('agent-system/templates/plan.template.json');
$planTemplate = $this->decodeJson('.agents/templates/plan.template.json');
$planGuardIds = is_array($planTemplate['guardrails']['required_guard_ids'] ?? null)
? $planTemplate['guardrails']['required_guard_ids']
: [];
@@ -96,39 +116,74 @@ class AgentSystemContractTest extends TestCase
$this->assertContains($id, $gateIds, 'Unknown gate id in plan template: ' . (string) $id);
}
$reviewGuardsTemplate = $this->decodeJson('agent-system/templates/review-guards.template.json');
$reviewGuardIds = is_array($reviewGuardsTemplate['checked_guard_ids'] ?? null)
? $reviewGuardsTemplate['checked_guard_ids']
$reviewCodeTemplate = $this->decodeJson('.agents/templates/review-code.template.json');
$reviewGuardIds = is_array($reviewCodeTemplate['checked_guard_ids'] ?? null)
? $reviewCodeTemplate['checked_guard_ids']
: [];
$reviewGateIds = is_array($reviewGuardsTemplate['checked_quality_gate_ids'] ?? null)
? $reviewGuardsTemplate['checked_quality_gate_ids']
$reviewGateIds = is_array($reviewCodeTemplate['checked_quality_gate_ids'] ?? null)
? $reviewCodeTemplate['checked_quality_gate_ids']
: [];
foreach ($reviewGuardIds as $id) {
$this->assertContains($id, $guardIds, 'Unknown guard id in review template: ' . (string) $id);
$this->assertContains($id, $guardIds, 'Unknown guard id in review-code template: ' . (string) $id);
}
foreach ($reviewGateIds as $id) {
$this->assertContains($id, $gateIds, 'Unknown gate id in review template: ' . (string) $id);
$this->assertContains($id, $gateIds, 'Unknown gate id in review-code template: ' . (string) $id);
}
$reviewSecurityTemplate = $this->decodeJson('.agents/templates/review-security.template.json');
$secGuardIds = is_array($reviewSecurityTemplate['checked_guard_ids'] ?? null)
? $reviewSecurityTemplate['checked_guard_ids']
: [];
foreach ($secGuardIds as $id) {
$this->assertContains($id, $guardIds, 'Unknown guard id in review-security template: ' . (string) $id);
}
}
public function testRolePromptsReferenceGuardCatalogAndQualityGates(): void
public function testRolePromptsReferenceGuardCatalog(): void
{
$plannerPrompt = $this->readProjectFile('agent-system/prompts/planner.md');
$executorPrompt = $this->readProjectFile('agent-system/prompts/executor.md');
$reviewerPrompt = $this->readProjectFile('agent-system/prompts/reviewer-guards.md');
$plannerPrompt = $this->readProjectFile('.agents/prompts/planner.md');
$executorPrompt = $this->readProjectFile('.agents/prompts/executor.md');
$codeReviewerPrompt = $this->readProjectFile('.agents/prompts/reviewer-code.md');
$securityReviewerPrompt = $this->readProjectFile('.agents/prompts/reviewer-security.md');
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $plannerPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $plannerPrompt);
$this->assertStringContainsString('DOCS-SKILLS-AUDIT-xxx', $plannerPrompt);
$this->assertStringContainsString('QG-008', $plannerPrompt);
$this->assertStringContainsString('QG-009', $plannerPrompt);
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $executorPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $executorPrompt);
$this->assertStringContainsString('QG-008', $executorPrompt);
$this->assertStringContainsString('QG-009', $executorPrompt);
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $reviewerPrompt);
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $reviewerPrompt);
$this->assertStringContainsString('agent-system/checks/docs-skills-audit-policy.md', $reviewerPrompt);
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $plannerPrompt);
$this->assertStringContainsString('.agents/checks/quality-gates.json', $plannerPrompt);
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $executorPrompt);
$this->assertStringContainsString('.agents/checks/quality-gates.json', $executorPrompt);
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $codeReviewerPrompt);
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $securityReviewerPrompt);
}
public function testAllSevenRolePromptsExist(): void
{
$roles = ['analyst', 'planner', 'executor', 'reviewer-code', 'reviewer-security', 'reviewer-acceptance', 'finalizer'];
foreach ($roles as $role) {
$this->readProjectFile('.agents/prompts/' . $role . '.md');
}
}
public function testAllSevenContractsExist(): void
{
$contracts = ['analyst', 'planner', 'executor', 'reviewer-code', 'reviewer-security', 'reviewer-acceptance', 'finalizer'];
foreach ($contracts as $contract) {
$decoded = json_decode($this->readProjectFile('.agents/contracts/' . $contract . '.schema.json'), true);
$this->assertIsArray($decoded, 'Invalid JSON schema: ' . $contract);
}
}
public function testAllSevenTemplatesExist(): void
{
$templates = [
'analysis', 'plan', 'execution-report',
'review-code', 'review-security', 'review-acceptance', 'finalize',
];
foreach ($templates as $template) {
$decoded = json_decode($this->readProjectFile('.agents/templates/' . $template . '.template.json'), true);
$this->assertIsArray($decoded, 'Invalid JSON template: ' . $template);
}
}
/**