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

42
.agents/README.md Normal file
View File

@@ -0,0 +1,42 @@
# .agents/
Centralized home for the 7-role agent workflow system.
## Structure
```
workflow.md ← Roles, state machine, handover rules (start here)
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
skills/ ← Reusable skill packages (guardrails, planner, grid, PHP CI)
runs/ ← Runtime artifacts per workflow run (gitignored)
```
## Roles
| # | Role | Artifact | Guards |
|---|---|---|---|
| 1 | Analyst | `analysis.json` | — |
| 2 | Planner | `plan.json` | selects guards |
| 3 | Executor | `execution-report.json` | provides evidence |
| 4 | Code Reviewer | `review-code.json` | 13 guards (GR-CORE/TEST/UI) |
| 5 | Security Reviewer | `review-security.json` | 9 guards (GR-SEC) |
| 6 | Acceptance Tester | `review-acceptance.json` | SC-* criteria |
| 7 | Finalizer | `finalize.json` | all reviews + CI |
## Quick Links
| What | Where |
|---|---|
| Workflow & roles | `workflow.md` |
| Guard catalog (22 guards) | `checks/guard-catalog.json` |
| Quality gates (9 gates) | `checks/quality-gates.json` |
| Code review checklist | `checks/guard-checklist.md` |
| Acceptance checklist | `checks/acceptance-checklist.md` |
| Module manifest schema | `contracts/module-manifest.schema.json` |
## Project Context
See `CLAUDE.md` (repo root) for tech stack, architecture rules, and conventions.

View File

@@ -0,0 +1,209 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "module-manifest.schema.json",
"title": "Module Manifest Schema",
"description": "Declarative schema for module.php manifest files under modules/<id>/.",
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_-]*$",
"description": "Module identifier — must match the directory name under modules/."
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Semantic version (e.g. 1.0.0)."
},
"enabled_by_default": {
"type": "boolean",
"default": false
},
"load_order": {
"type": "integer",
"default": 100,
"description": "Lower values load first. Deterministic tie-break by id ASC."
},
"requires": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true,
"default": [],
"description": "Module IDs that must be enabled for this module to function."
},
"routes": {
"type": "array",
"items": {
"type": "object",
"required": ["path", "target"],
"properties": {
"path": { "type": "string", "minLength": 1 },
"target": { "type": "string", "minLength": 1 },
"public": { "type": "boolean", "default": false }
},
"additionalProperties": false
},
"default": []
},
"public_paths": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"container_registrars": {
"type": "array",
"items": { "type": "string", "minLength": 1, "description": "FQCN of a ContainerRegistrar." },
"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" }
}
}
}
},
"default": {}
},
"search_resources": {
"type": "array",
"items": { "type": "string", "minLength": 1, "description": "FQCN of a SearchResourceProvider." },
"default": []
},
"asset_groups": {
"type": "object",
"description": "Asset group name → file list.",
"patternProperties": {
"^.+$": {
"type": "array",
"items": { "type": "string" }
}
},
"default": {}
},
"layout_context_providers": {
"type": "array",
"items": { "type": "string", "minLength": 1, "description": "FQCN of a LayoutContextProvider." },
"default": []
},
"session_providers": {
"type": "array",
"items": { "type": "string", "minLength": 1, "description": "FQCN of a SessionProvider." },
"default": []
},
"authorization_policies": {
"type": "array",
"items": { "type": "string", "minLength": 1, "description": "FQCN of an AuthorizationPolicyInterface." },
"default": []
},
"permissions": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "description"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"active": { "type": "boolean", "default": true },
"is_system": { "type": "boolean", "default": true }
},
"additionalProperties": false
},
"default": []
},
"scheduler_jobs": {
"type": "array",
"items": {
"type": "object",
"required": [
"job_key", "handler", "label", "description",
"default_enabled", "default_timezone", "default_schedule_type",
"default_schedule_interval", "default_schedule_time",
"default_schedule_weekdays_csv", "default_catchup_once",
"allowed_schedule_types"
],
"properties": {
"job_key": { "type": "string", "minLength": 1 },
"handler": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"default_enabled": { "type": ["boolean", "integer"] },
"default_timezone": { "type": "string", "minLength": 1 },
"default_schedule_type": { "type": "string", "enum": ["hourly", "daily", "weekly"] },
"default_schedule_interval": { "type": "integer" },
"default_schedule_time": { "type": ["string", "null"] },
"default_schedule_weekdays_csv": { "type": ["string", "null"] },
"default_catchup_once": { "type": ["boolean", "integer"] },
"allowed_schedule_types": {
"type": "array",
"items": { "type": "string", "enum": ["hourly", "daily", "weekly"] },
"minItems": 1
}
},
"additionalProperties": false
},
"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
}
}
},
"default": {}
},
"deactivation_handler": {
"type": ["string", "null"],
"description": "FQCN of a ModuleDeactivationHandler. Null if no cleanup needed.",
"default": null
},
"migrations_path": {
"type": ["string", "null"],
"description": "Relative path to SQL migration scripts. Resolved against module basePath.",
"default": null
}
},
"additionalProperties": false
}

84
bin/module-deactivate.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* CLI script to run a module's deactivation handler.
*
* Usage:
* php bin/module-deactivate.php <module-id> --confirm
* php bin/module-deactivate.php <module-id> --dry-run
*
* The module does NOT need to be in the enabled list — the manifest is loaded
* directly from modules/<id>/module.php.
*/
require_once __DIR__ . '/module-cli-bootstrap.php';
$exitCode = moduleCliRunStep('module-deactivate', static function (): int {
global $argv;
$moduleId = $argv[1] ?? '';
$flags = array_slice($argv, 2);
if ($moduleId === '' || $moduleId === '--help') {
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
return $moduleId === '--help' ? 0 : 1;
}
$confirm = in_array('--confirm', $flags, true);
$dryRun = in_array('--dry-run', $flags, true);
if (!$confirm && !$dryRun) {
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
return 1;
}
$projectRoot = moduleCliProjectRoot();
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
if (!is_file($manifestFile)) {
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
return 1;
}
$raw = require $manifestFile;
if (!is_array($raw)) {
fwrite(STDERR, "Error: manifest must return an array.\n");
return 1;
}
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
$handlerClass = $manifest->deactivationHandler;
if ($handlerClass === null) {
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
return 0;
}
if (!class_exists($handlerClass)) {
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
return 1;
}
$handler = new $handlerClass();
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
return 1;
}
if ($dryRun) {
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
return 0;
}
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
$container = app();
$handler->deactivate($container);
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
return 0;
});
exit($exitCode);

View File

@@ -4,6 +4,7 @@ namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer; use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\App\Module\ModuleRegistry; use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Http\CookieStoreInterface; use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface; use MintyPHP\Http\RequestRuntimeInterface;
@@ -130,7 +131,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
$c->get(DatabaseSessionRepository::class), $c->get(DatabaseSessionRepository::class),
// Wrapped in a closure to break circular dependency: // Wrapped in a closure to break circular dependency:
// UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory // UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory
static fn (): AssignableRoleService => $c->get(AssignableRoleService::class) static fn (): AssignableRoleService => $c->get(AssignableRoleService::class),
$c->has(ModuleEventDispatcher::class) ? $c->get(ModuleEventDispatcher::class) : null
)); ));
$container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory( $container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory(
$c->get(AuthRepositoryFactory::class), $c->get(AuthRepositoryFactory::class),

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\App\Module\Contracts;
/**
* Interface for module event listeners.
*
* Modules declare listeners in their manifest under 'event_listeners'.
* The dispatcher resolves listeners from the container and calls handle().
*/
interface EventListener
{
/**
* @param string $event Event name (e.g. 'user.deleted')
* @param array<string, mixed> $payload Event-specific data
*/
public function handle(string $event, array $payload): void;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace MintyPHP\App\Module\Contracts;
use MintyPHP\App\AppContainer;
/**
* Handles cleanup when a module is deactivated.
*
* Invoked by `bin/module-deactivate.php` after a module has been removed
* from the enabled list. The handler receives the full app container and
* can perform cleanup tasks like removing orphaned data or revoking permissions.
*/
interface ModuleDeactivationHandler
{
public function deactivate(AppContainer $container): void;
}

View File

@@ -0,0 +1,49 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\EventListener;
/**
* Fire-and-forget event dispatcher for module lifecycle events.
*
* Listeners are declared in module manifests and lazy-resolved from the container.
* A failing listener does not break the dispatch chain — the exception is swallowed
* so that one misbehaving module cannot disrupt core flows.
*/
final class ModuleEventDispatcher
{
/**
* @param array<string, list<array{class: class-string, method: string}>> $listenerMap
*/
public function __construct(
private readonly array $listenerMap,
private readonly AppContainer $container
) {
}
/**
* @param array<string, mixed> $payload
*/
public function dispatch(string $event, array $payload): void
{
$listeners = $this->listenerMap[$event] ?? [];
foreach ($listeners as $descriptor) {
try {
$class = $descriptor['class'];
$method = $descriptor['method'];
$listener = $this->container->get($class);
if (!$listener instanceof EventListener) {
$listener = new $class();
}
$listener->{$method}($event, $payload);
} catch (\Throwable) {
// Swallow — one failing listener must not break the chain.
}
}
}
}

View File

@@ -72,6 +72,15 @@ final class ModuleManifest
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */ /** @var list<class-string> AuthorizationPolicyInterface FQCNs */
public readonly array $authorizationPolicies; public readonly array $authorizationPolicies;
/** @var list<string> Module IDs required by this module */
public readonly array $requires;
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
public readonly array $eventListeners;
/** @var class-string|null ModuleDeactivationHandler FQCN */
public readonly ?string $deactivationHandler;
public readonly ?string $migrationsPath; public readonly ?string $migrationsPath;
public readonly string $basePath; public readonly string $basePath;
@@ -107,6 +116,14 @@ final class ModuleManifest
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id); $this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies'); $this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
$deactivationHandler = $raw['deactivation_handler'] ?? null;
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
? trim($deactivationHandler)
: null;
$migrationsPath = $raw['migrations_path'] ?? null; $migrationsPath = $raw['migrations_path'] ?? null;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== '' $this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/') ? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
@@ -332,6 +349,83 @@ final class ModuleManifest
return $types; return $types;
} }
/**
* @return array<string, list<array{class: class-string, method: string}>>
*/
private static function normalizeEventListeners(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
);
}
$listeners = [];
foreach ($value as $eventName => $handlers) {
$eventName = trim((string) $eventName);
if ($eventName === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
);
}
if (!is_array($handlers)) {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
);
}
$normalizedHandlers = [];
foreach (array_values($handlers) as $index => $handler) {
if (!is_array($handler)) {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
);
}
$class = trim((string) ($handler['class'] ?? ''));
$method = trim((string) ($handler['method'] ?? ''));
if ($class === '' || $method === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
);
}
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
}
if ($normalizedHandlers !== []) {
$listeners[$eventName] = $normalizedHandlers;
}
}
return $listeners;
}
/**
* @return list<string>
*/
private static function normalizeRequires(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
);
}
$requires = [];
foreach (array_values($value) as $index => $item) {
if (!is_string($item) || trim($item) === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
);
}
$requires[] = trim($item);
}
return array_values(array_unique($requires));
}
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
{ {
$raw = trim((string) $value); $raw = trim((string) $value);

View File

@@ -87,6 +87,9 @@ final class ModuleRegistry
/** @var array<string, string> scheduler job key => owning module id */ /** @var array<string, string> scheduler job key => owning module id */
private array $schedulerJobOwners = []; private array $schedulerJobOwners = [];
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
private array $mergedEventListeners = [];
/** /**
* Boot the registry from a modules directory and activation config. * Boot the registry from a modules directory and activation config.
* *
@@ -150,6 +153,7 @@ final class ModuleRegistry
$registry->registerModule($manifest); $registry->registerModule($manifest);
} }
$registry->validateDependencies();
$registry->finalizeMergedContributions(); $registry->finalizeMergedContributions();
return $registry; return $registry;
@@ -354,6 +358,14 @@ final class ModuleRegistry
$this->mergedAuthorizationPolicies[] = $policyClass; $this->mergedAuthorizationPolicies[] = $policyClass;
} }
// — Merge event listeners ——————————————————————————
foreach ($manifest->eventListeners as $eventName => $handlers) {
if (!isset($this->mergedEventListeners[$eventName])) {
$this->mergedEventListeners[$eventName] = [];
}
array_push($this->mergedEventListeners[$eventName], ...$handlers);
}
// — Merge scheduler jobs ————————————————————————— // — Merge scheduler jobs —————————————————————————
foreach ($manifest->schedulerJobs as $schedulerJob) { foreach ($manifest->schedulerJobs as $schedulerJob) {
$jobKey = trim((string) $schedulerJob['job_key']); $jobKey = trim((string) $schedulerJob['job_key']);
@@ -388,6 +400,52 @@ final class ModuleRegistry
} }
} }
private function validateDependencies(): void
{
$enabledIds = array_keys($this->modules);
// Check all required modules are enabled
foreach ($this->modules as $manifest) {
foreach ($manifest->requires as $requiredId) {
if (!isset($this->modules[$requiredId])) {
throw new RuntimeException(
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
);
}
}
}
// Check for circular dependencies (DFS cycle detection)
$visited = [];
$inStack = [];
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
if (isset($inStack[$moduleId])) {
throw new RuntimeException(
"Circular module dependency detected involving module '{$moduleId}'."
);
}
if (isset($visited[$moduleId])) {
return;
}
$inStack[$moduleId] = true;
if (isset($this->modules[$moduleId])) {
foreach ($this->modules[$moduleId]->requires as $requiredId) {
$visit($requiredId);
}
}
unset($inStack[$moduleId]);
$visited[$moduleId] = true;
};
foreach ($enabledIds as $moduleId) {
$visit($moduleId);
}
}
private function finalizeMergedContributions(): void private function finalizeMergedContributions(): void
{ {
$this->mergedPublicPaths = array_values(array_unique(array_filter( $this->mergedPublicPaths = array_values(array_unique(array_filter(
@@ -626,4 +684,10 @@ final class ModuleRegistry
{ {
return $this->mergedSchedulerJobs; return $this->mergedSchedulerJobs;
} }
/** @return array<string, list<array{class: class-string, method: string}>> */
public function getEventListeners(): array
{
return $this->mergedEventListeners;
}
} }

View File

@@ -10,6 +10,7 @@ use MintyPHP\App\Container\Registrars\RepositoryFactoryRegistrar;
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar; use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
use MintyPHP\App\Container\Registrars\SettingsRegistrar; use MintyPHP\App\Container\Registrars\SettingsRegistrar;
use MintyPHP\App\Container\Registrars\UserRegistrar; use MintyPHP\App\Container\Registrars\UserRegistrar;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\App\Module\ModuleRegistry; use MintyPHP\App\Module\ModuleRegistry;
$container = new AppContainer(); $container = new AppContainer();
@@ -43,6 +44,10 @@ $modulesDir = dirname(__DIR__, 2) . '/modules';
$moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules); $moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry); $container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
$container->set(ModuleEventDispatcher::class, static fn () => new ModuleEventDispatcher(
$moduleRegistry->getEventListeners(),
$container
));
// Disallow overriding existing core bindings from module registrars. // Disallow overriding existing core bindings from module registrars.
$container->protectExistingBindings(); $container->protectExistingBindings();

View File

@@ -42,7 +42,6 @@ class PermissionService
public const USERS_ACCESS_PDF = 'users.access_pdf'; public const USERS_ACCESS_PDF = 'users.access_pdf';
public const USERS_SELF_UPDATE = 'users.self_update'; public const USERS_SELF_UPDATE = 'users.self_update';
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments'; public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
public const ADDRESS_BOOK_VIEW = 'address_book.view';
public const TENANTS_VIEW = 'tenants.view'; public const TENANTS_VIEW = 'tenants.view';
public const TENANTS_CREATE = 'tenants.create'; public const TENANTS_CREATE = 'tenants.create';
public const TENANTS_UPDATE = 'tenants.update'; public const TENANTS_UPDATE = 'tenants.update';

View File

@@ -184,7 +184,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW) if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)
&& !$this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW) && !$this->hasPermission($actorUserId, 'address_book.view') // Registered by addressbook module when active
&& !$canEditUser) { && !$canEditUser) {
return AuthorizationDecision::deny(403, 'forbidden'); return AuthorizationDecision::deny(403, 'forbidden');
} }
@@ -409,7 +409,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|| ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE)) || ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE))
), ),
'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE), 'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE),
'can_view_address_book' => $this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW), 'can_view_address_book' => $this->hasPermission($actorUserId, 'address_book.view'), // Registered by addressbook module when active
'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META), 'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META),
'can_view_user_audit' => $canViewUserAudit, 'can_view_user_audit' => $canViewUserAudit,
'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF), 'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF),

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Service\Auth; namespace MintyPHP\Service\Auth;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\Auth; use MintyPHP\Auth;
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -42,7 +43,8 @@ class AuthService
private readonly TenantSsoService $tenantSsoService, private readonly TenantSsoService $tenantSsoService,
private readonly AuthSessionTenantContextService $authSessionTenantContextService, private readonly AuthSessionTenantContextService $authSessionTenantContextService,
private readonly SystemAuditService $systemAuditService, private readonly SystemAuditService $systemAuditService,
private readonly SessionStoreInterface $sessionStore private readonly SessionStoreInterface $sessionStore,
private readonly ?ModuleEventDispatcher $eventDispatcher = null
) { ) {
} }
@@ -130,6 +132,11 @@ class AuthService
'actor_tenant_id' => $this->currentTenantIdFromSession(), 'actor_tenant_id' => $this->currentTenantIdFromSession(),
]); ]);
$this->eventDispatcher?->dispatch('user.login', [
'user_id' => $userId,
'provider' => 'local',
]);
return ['ok' => true]; return ['ok' => true];
} }
@@ -218,6 +225,12 @@ class AuthService
'actor_tenant_id' => $this->currentTenantIdFromSession(), 'actor_tenant_id' => $this->currentTenantIdFromSession(),
'metadata' => ['provider' => $loginProvider], 'metadata' => ['provider' => $loginProvider],
]); ]);
$this->eventDispatcher?->dispatch('user.login', [
'user_id' => $userId,
'provider' => $loginProvider,
]);
return ['ok' => true, 'user' => $user]; return ['ok' => true, 'user' => $user];
} }
@@ -276,6 +289,11 @@ class AuthService
{ {
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0); $actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
$actorTenantId = $this->currentTenantIdFromSession(); $actorTenantId = $this->currentTenantIdFromSession();
$this->eventDispatcher?->dispatch('user.logout', [
'user_id' => $actorUserId,
]);
$this->rememberMeService->forgetCurrentUser(); $this->rememberMeService->forgetCurrentUser();
$this->logoutWithModuleCleanup(); $this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.logout', 'success', [ $this->recordAuthEvent('auth.logout', 'success', [

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Service\Auth; namespace MintyPHP\Service\Auth;
use MintyPHP\App\AppContainer; use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\Http\CookieStoreInterface; use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface; use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
@@ -100,7 +101,8 @@ class AuthServicesFactory
$this->createTenantSsoService(), $this->createTenantSsoService(),
$this->createAuthSessionTenantContextService(), $this->createAuthSessionTenantContextService(),
$this->auditServicesFactory->createSystemAuditService(), $this->auditServicesFactory->createSystemAuditService(),
$this->sessionStore $this->sessionStore,
$this->resolveEventDispatcher()
); );
} }
@@ -148,6 +150,15 @@ class AuthServicesFactory
return new OidcHttpGateway(); return new OidcHttpGateway();
} }
private function resolveEventDispatcher(): ?ModuleEventDispatcher
{
if ($this->appContainer->has(ModuleEventDispatcher::class)) {
$dispatcher = $this->appContainer->get(ModuleEventDispatcher::class);
return $dispatcher instanceof ModuleEventDispatcher ? $dispatcher : null;
}
return null;
}
private function createAuthSessionTenantContextService(): AuthSessionTenantContextService private function createAuthSessionTenantContextService(): AuthSessionTenantContextService
{ {
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService( return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Service\User; namespace MintyPHP\Service\User;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\I18n; use MintyPHP\I18n;
use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
@@ -36,7 +37,8 @@ class UserAccountService
private readonly TenantScopeService $scopeGateway, private readonly TenantScopeService $scopeGateway,
private readonly UserDirectoryGateway $directoryGateway, private readonly UserDirectoryGateway $directoryGateway,
private readonly SystemAuditService $systemAuditService, private readonly SystemAuditService $systemAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly ?ModuleEventDispatcher $eventDispatcher = null
) { ) {
} }
@@ -112,6 +114,12 @@ class UserAccountService
'target_uuid' => (string) ($user['uuid'] ?? ''), 'target_uuid' => (string) ($user['uuid'] ?? ''),
]); ]);
$this->eventDispatcher?->dispatch('user.deleted', [
'user_id' => $userId,
'uuid' => (string) ($user['uuid'] ?? ''),
'actor_user_id' => $currentUserId,
]);
return ['ok' => true, 'user' => $user]; return ['ok' => true, 'user' => $user];
} }
@@ -291,6 +299,12 @@ class UserAccountService
], ],
]); ]);
$this->eventDispatcher?->dispatch('user.created', [
'user_id' => $userId,
'uuid' => is_string($uuid) ? $uuid : '',
'actor_user_id' => $currentUserId,
]);
return ['ok' => true, 'form' => $form, 'uuid' => $uuid]; return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
} }

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Service\User; namespace MintyPHP\Service\User;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\Repository\Access\UserRoleRepositoryInterface; use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface; use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Support\DatabaseSessionRepository;
@@ -33,7 +34,8 @@ class UserServicesFactory
private readonly UserRepositoryFactory $userRepositoryFactory, private readonly UserRepositoryFactory $userRepositoryFactory,
private readonly UserGatewayFactory $userGatewayFactory, private readonly UserGatewayFactory $userGatewayFactory,
private readonly DatabaseSessionRepository $databaseSessionRepository, private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly \Closure $assignableRoleServiceFactory private readonly \Closure $assignableRoleServiceFactory,
private readonly ?ModuleEventDispatcher $eventDispatcher = null
) { ) {
} }
@@ -49,7 +51,8 @@ class UserServicesFactory
$this->userGatewayFactory->getTenantScopeService(), $this->userGatewayFactory->getTenantScopeService(),
$this->createUserDirectoryGateway(), $this->createUserDirectoryGateway(),
$this->auditServicesFactory->createSystemAuditService(), $this->auditServicesFactory->createSystemAuditService(),
$this->databaseSessionRepository $this->databaseSessionRepository,
$this->eventDispatcher
); );
} }

View File

@@ -15,6 +15,7 @@ use MintyPHP\Service\Access\PermissionService;
final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface
{ {
public const ABILITY_VIEW = 'addressbook.view'; public const ABILITY_VIEW = 'addressbook.view';
public const PERMISSION_KEY = 'address_book.view';
public function __construct( public function __construct(
private readonly PermissionService $permissionService private readonly PermissionService $permissionService
@@ -33,7 +34,7 @@ final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterfa
return AuthorizationDecision::deny(403, 'forbidden'); return AuthorizationDecision::deny(403, 'forbidden');
} }
if (!$this->permissionService->userHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)) { if (!$this->permissionService->userHas($actorUserId, self::PERMISSION_KEY)) {
return AuthorizationDecision::deny(403, 'forbidden'); return AuthorizationDecision::deny(403, 'forbidden');
} }

View File

@@ -31,8 +31,8 @@ final class AddressBookLayoutProvider implements LayoutContextProvider
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''), 'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''), 'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
'activeCustomFilters' => self::normalizeCustomFilterQuery($query), 'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null) 'peopleGroups' => is_array($session['module.addressbook.departments_by_tenant'] ?? null)
? $session['available_departments_by_tenant'] ? $session['module.addressbook.departments_by_tenant']
: [], : [],
], ],
]; ];

View File

@@ -17,7 +17,7 @@ final class AddressBookSessionProvider implements SessionProvider
{ {
$userId = (int) ($user['id'] ?? 0); $userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) { if ($userId <= 0) {
unset($_SESSION['available_departments_by_tenant']); unset($_SESSION['module.addressbook.departments_by_tenant']);
return; return;
} }
@@ -28,13 +28,13 @@ final class AddressBookSessionProvider implements SessionProvider
} }
$sessionStore->set( $sessionStore->set(
'available_departments_by_tenant', 'module.addressbook.departments_by_tenant',
$tenantContext->getAvailableDepartmentsByTenant($userId) $tenantContext->getAvailableDepartmentsByTenant($userId)
); );
} }
public function clear(): void public function clear(): void
{ {
unset($_SESSION['available_departments_by_tenant']); unset($_SESSION['module.addressbook.departments_by_tenant']);
} }
} }

View File

@@ -11,6 +11,7 @@ return [
'version' => '1.0.0', 'version' => '1.0.0',
'enabled_by_default' => false, 'enabled_by_default' => false,
'load_order' => 10, 'load_order' => 10,
'requires' => [],
'routes' => [ 'routes' => [
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops. // Keep canonical route stable without forcing a /index target to avoid Router redirect loops.

View File

@@ -11,6 +11,7 @@ return [
'version' => '1.0.0', 'version' => '1.0.0',
'enabled_by_default' => true, 'enabled_by_default' => true,
'load_order' => 20, 'load_order' => 20,
'requires' => [],
'routes' => [ 'routes' => [
['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'], ['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'],

View File

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

View File

@@ -143,6 +143,57 @@ final class ModuleRegistryContractTest extends TestCase
); );
} }
public function testMissingDependencyThrowsException(): void
{
$fixturesDir = sys_get_temp_dir() . '/module-dep-test-' . uniqid();
mkdir($fixturesDir . '/mod-dep', 0777, true);
file_put_contents($fixturesDir . '/mod-dep/module.php', '<?php return ' . var_export([
'id' => 'mod-dep',
'requires' => ['nonexistent-module'],
], true) . ';');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("requires module 'nonexistent-module' which is not enabled");
try {
ModuleRegistry::boot($fixturesDir, ['mod-dep']);
} finally {
@unlink($fixturesDir . '/mod-dep/module.php');
@rmdir($fixturesDir . '/mod-dep');
@rmdir($fixturesDir);
}
}
public function testCircularDependencyThrowsException(): void
{
$fixturesDir = sys_get_temp_dir() . '/module-circular-test-' . uniqid();
mkdir($fixturesDir . '/mod-x', 0777, true);
mkdir($fixturesDir . '/mod-y', 0777, true);
file_put_contents($fixturesDir . '/mod-x/module.php', '<?php return ' . var_export([
'id' => 'mod-x',
'requires' => ['mod-y'],
], true) . ';');
file_put_contents($fixturesDir . '/mod-y/module.php', '<?php return ' . var_export([
'id' => 'mod-y',
'requires' => ['mod-x'],
], true) . ';');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Circular module dependency');
try {
ModuleRegistry::boot($fixturesDir, ['mod-x', 'mod-y']);
} finally {
@unlink($fixturesDir . '/mod-x/module.php');
@unlink($fixturesDir . '/mod-y/module.php');
@rmdir($fixturesDir . '/mod-x');
@rmdir($fixturesDir . '/mod-y');
@rmdir($fixturesDir);
}
}
public function testConflictDetectionIsStrict(): void public function testConflictDetectionIsStrict(): void
{ {
$fixturesDir = sys_get_temp_dir() . '/module-arch-test-' . uniqid(); $fixturesDir = sys_get_temp_dir() . '/module-arch-test-' . uniqid();

View File

@@ -2,7 +2,9 @@
namespace MintyPHP\Tests\Architecture; namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\App\Module\Contracts\LayoutContextProvider; use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
use MintyPHP\App\Module\Contracts\SearchResourceProvider; use MintyPHP\App\Module\Contracts\SearchResourceProvider;
use MintyPHP\App\Module\Contracts\SessionProvider; use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleManifest; use MintyPHP\App\Module\ModuleManifest;
@@ -128,6 +130,26 @@ final class ModuleStructureContractTest extends TestCase
} }
} }
public function testRequiresFieldContainsOnlyValidModuleIds(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->requires as $index => $requiredId) {
self::assertIsString($requiredId);
self::assertNotEmpty(
trim($requiredId),
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
);
self::assertMatchesRegularExpression(
'/^[a-z][a-z0-9_-]*$/',
$requiredId,
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
);
}
}
self::addToAssertionCount(1);
}
// ─── File structure ────────────────────────────────────────────── // ─── File structure ──────────────────────────────────────────────
public function testModuleHasLibDirectory(): void public function testModuleHasLibDirectory(): void
@@ -542,6 +564,113 @@ final class ModuleStructureContractTest extends TestCase
} }
} }
// ─── Event listener contract ────────────────────────────────────────
public function testEventListenerClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
foreach ($handlers as $index => $handler) {
$class = $handler['class'];
self::assertTrue(
class_exists($class),
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
);
self::assertTrue(
is_subclass_of($class, EventListener::class)
|| in_array(EventListener::class, class_implements($class) ?: [], true),
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
. "must implement EventListener interface"
);
}
}
}
self::addToAssertionCount(1);
}
// ─── Deactivation handler contract ──────────────────────────────
public function testDeactivationHandlerClassImplementsContract(): void
{
foreach (self::$modules as $module) {
$handler = $module['manifest']->deactivationHandler;
if ($handler === null) {
continue;
}
self::assertTrue(
class_exists($handler),
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
);
self::assertTrue(
is_subclass_of($handler, ModuleDeactivationHandler::class)
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
. "must implement ModuleDeactivationHandler interface"
);
}
self::addToAssertionCount(1);
}
// ─── Session key convention ────────────────────────────────────────
/**
* Module session providers must use the 'module.<id>.' prefix for all session keys.
*
* Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
* that the key argument starts with 'module.<module_id>.'.
*/
public function testSessionProvidersUseModulePrefixedKeys(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->sessionProviders as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$reflection = new \ReflectionClass($providerClass);
$file = $reflection->getFileName();
if ($file === false) {
continue;
}
$content = file_get_contents($file);
if ($content === false) {
continue;
}
$expectedPrefix = 'module.' . $module['id'] . '.';
// Check $_SESSION['...'] writes and unset($_SESSION['...'])
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
foreach ($matches[1] as $key) {
self::assertStringStartsWith(
$expectedPrefix,
$key,
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
);
}
}
// Check ->set('...', ...) calls on session store
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
foreach ($matches[1] as $key) {
self::assertStringStartsWith(
$expectedPrefix,
$key,
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
);
}
}
}
}
self::addToAssertionCount(1);
}
// ─── No hardcoded Core ability imports in module pages ─────────── // ─── No hardcoded Core ability imports in module pages ───────────
public function testModulePagesDoNotImportCoreAbilityConstants(): void public function testModulePagesDoNotImportCoreAbilityConstants(): void

View File

@@ -12,8 +12,7 @@ use FilesystemIterator;
* in the search infrastructure, layout context builder, or aside templates. * in the search infrastructure, layout context builder, or aside templates.
* *
* Intentionally allowed in Core (not flagged): * Intentionally allowed in Core (not flagged):
* - PermissionService::ADDRESS_BOOK_VIEW (general people-visibility gate) * - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization — uses inline 'address_book.view' string)
* - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization)
* - modules/addressbook/* (module-owned runtime, pages/assets/providers) * - modules/addressbook/* (module-owned runtime, pages/assets/providers)
* - i18n translation files * - i18n translation files
* - Module directory (lib/Module/, modules/) * - Module directory (lib/Module/, modules/)

View File

@@ -364,7 +364,7 @@ class UserAuthorizationPolicyTest extends TestCase
public function testAvatarViewAllowsInScopeAddressBookViewer(): void public function testAvatarViewAllowsInScopeAddressBookViewer(): void
{ {
$permissionService = $this->permissionGatewayAllowing([ $permissionService = $this->permissionGatewayAllowing([
5 => [PermissionService::ADDRESS_BOOK_VIEW], 5 => ['address_book.view'],
]); ]);
$scopeGateway = $this->createMock(TenantScopeService::class); $scopeGateway = $this->createMock(TenantScopeService::class);

View File

@@ -34,7 +34,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
$this->assertSame([], $_SESSION['available_tenants']); $this->assertSame([], $_SESSION['available_tenants']);
$this->assertTrue((bool) $_SESSION['no_active_tenant']); $this->assertTrue((bool) $_SESSION['no_active_tenant']);
$this->assertArrayNotHasKey('current_tenant', $_SESSION); $this->assertArrayNotHasKey('current_tenant', $_SESSION);
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); $this->assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testHydrateUsesFirstAvailableTenantAsFallback(): void public function testHydrateUsesFirstAvailableTenantAsFallback(): void
@@ -54,7 +54,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
$this->assertSame($availableTenants, $_SESSION['available_tenants']); $this->assertSame($availableTenants, $_SESSION['available_tenants']);
$this->assertFalse((bool) $_SESSION['no_active_tenant']); $this->assertFalse((bool) $_SESSION['no_active_tenant']);
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0)); $this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); $this->assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void public function testHydratePreservesThemeAndPolicyFieldsInSession(): void

View File

@@ -38,7 +38,7 @@ final class AddressBookAuthorizationPolicyTest extends TestCase
public function testAllowedWithPermission(): void public function testAllowedWithPermission(): void
{ {
$permissionService = $this->permissionGatewayAllowing([ $permissionService = $this->permissionGatewayAllowing([
5 => [PermissionService::ADDRESS_BOOK_VIEW], 5 => [AddressBookAuthorizationPolicy::PERMISSION_KEY],
]); ]);
$policy = new AddressBookAuthorizationPolicy($permissionService); $policy = new AddressBookAuthorizationPolicy($permissionService);

View File

@@ -38,7 +38,7 @@ final class LayoutContextProviderTest extends TestCase
$container = new AppContainer(); $container = new AppContainer();
$session = [ $session = [
'available_departments_by_tenant' => [ 'module.addressbook.departments_by_tenant' => [
[ [
'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'], 'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'],
'departments' => [['id' => 1, 'description' => 'Dept 1']], 'departments' => [['id' => 1, 'description' => 'Dept 1']],

View File

@@ -0,0 +1,100 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\App\Module\ModuleEventDispatcher;
use PHPUnit\Framework\TestCase;
final class ModuleEventDispatcherTest extends TestCase
{
public function testDispatchCallsRegisteredListeners(): void
{
$called = false;
$capturedEvent = '';
$capturedPayload = [];
$listener = new class ($called, $capturedEvent, $capturedPayload) implements EventListener {
public function __construct(
private bool &$called,
private string &$capturedEvent,
private array &$capturedPayload
) {
}
public function handle(string $event, array $payload): void
{
$this->called = true;
$this->capturedEvent = $event;
$this->capturedPayload = $payload;
}
};
$container = new AppContainer();
$container->set($listener::class, static fn () => $listener);
$dispatcher = new ModuleEventDispatcher(
['user.deleted' => [['class' => $listener::class, 'method' => 'handle']]],
$container
);
$dispatcher->dispatch('user.deleted', ['user_id' => 42]);
self::assertTrue($called);
self::assertSame('user.deleted', $capturedEvent);
self::assertSame(['user_id' => 42], $capturedPayload);
}
public function testDispatchIsNoOpForUnknownEvent(): void
{
$container = new AppContainer();
$dispatcher = new ModuleEventDispatcher([], $container);
// Should not throw
$dispatcher->dispatch('unknown.event', ['data' => 'test']);
self::addToAssertionCount(1);
}
public function testDispatchContinuesAfterListenerException(): void
{
$secondCalled = false;
$failingListener = new class () implements EventListener {
public function handle(string $event, array $payload): void
{
throw new \RuntimeException('Listener failure');
}
};
$successListener = new class ($secondCalled) implements EventListener {
public function __construct(private bool &$called)
{
}
public function handle(string $event, array $payload): void
{
$this->called = true;
}
};
$container = new AppContainer();
$container->set($failingListener::class, static fn () => $failingListener);
$container->set($successListener::class, static fn () => $successListener);
$dispatcher = new ModuleEventDispatcher(
[
'user.created' => [
['class' => $failingListener::class, 'method' => 'handle'],
['class' => $successListener::class, 'method' => 'handle'],
],
],
$container
);
$dispatcher->dispatch('user.created', ['user_id' => 1]);
self::assertTrue($secondCalled, 'Second listener should be called even after the first one throws');
}
}

View File

@@ -39,22 +39,22 @@ final class SessionProviderTest extends TestCase
public function testPopulateWithInvalidUserClearsSessionKey(): void public function testPopulateWithInvalidUserClearsSessionKey(): void
{ {
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']]; $_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
$provider = new AddressBookSessionProvider(); $provider = new AddressBookSessionProvider();
$provider->populate(['id' => 0], $this->emptyContainer()); $provider->populate(['id' => 0], $this->emptyContainer());
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testPopulateWithMissingUserIdClearsSessionKey(): void public function testPopulateWithMissingUserIdClearsSessionKey(): void
{ {
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']]; $_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
$provider = new AddressBookSessionProvider(); $provider = new AddressBookSessionProvider();
$provider->populate([], $this->emptyContainer()); $provider->populate([], $this->emptyContainer());
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
@@ -69,19 +69,19 @@ final class SessionProviderTest extends TestCase
// in production, bindings are always registered before providers run. // in production, bindings are always registered before providers run.
} }
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testClearRemovesSessionKey(): void public function testClearRemovesSessionKey(): void
{ {
$_SESSION['available_departments_by_tenant'] = [ $_SESSION['module.addressbook.departments_by_tenant'] = [
['tenant' => ['uuid' => 'abc'], 'departments' => []], ['tenant' => ['uuid' => 'abc'], 'departments' => []],
]; ];
$provider = new AddressBookSessionProvider(); $provider = new AddressBookSessionProvider();
$provider->clear(); $provider->clear();
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
public function testClearIsIdempotent(): void public function testClearIsIdempotent(): void
@@ -90,6 +90,6 @@ final class SessionProviderTest extends TestCase
$provider = new AddressBookSessionProvider(); $provider = new AddressBookSessionProvider();
$provider->clear(); $provider->clear();
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION); self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
} }
} }