refactor: rename lib/ to core/ for clearer core-module separation

Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:20:05 +02:00
parent 7c10fadcb9
commit 0e86925464
371 changed files with 191 additions and 192 deletions

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,25 @@
<?php
namespace MintyPHP\App\Module\Contracts;
use MintyPHP\App\AppContainer;
/**
* Provides module-specific data for the layout navigation context.
*
* Called from appBuildLayoutNavContext() after Core data assembly.
* Return value is merged into the $layoutNav array passed to templates.
*
* IMPORTANT: Only called in web request context. CLI/scheduler callers
* must not invoke layout context providers. Implementations should use
* try/catch around request-dependent calls (e.g. requestInput()) as a
* defensive measure.
*/
interface LayoutContextProvider
{
/**
* @param array<string, mixed> $session Current session data
* @return array<string, mixed> Key-value pairs to merge into $layoutNav
*/
public function provide(array $session, AppContainer $container): array;
}

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 `php bin/console module:deactivate` 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,46 @@
<?php
namespace MintyPHP\App\Module\Contracts;
/**
* Provides search resources contributed by a module.
*
* Each provider returns a list of search resources (with SQL for count/preview/result),
* maps result rows to display items, and provides list URLs for deep-linking.
*/
interface SearchResourceProvider
{
/**
* Return search resource definitions.
*
* Supported SQL placeholders (replaced before parameter binding):
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
*
* All remaining `?` placeholders are bound to the LIKE search term.
* The last `?` in preview_sql is reserved for the preview limit.
*
* @return array<string, array{
* label: string,
* permission: string,
* count_sql: string,
* preview_sql: string,
* result_sql: string,
* tenant_filter?: string
* }>
*/
public function resources(): array;
/**
* Map a raw result row to a display-ready item.
*
* @param array<string, mixed> $row
* @return array{title: string, subtitle?: string, url: string, icon?: string}|null
*/
public function mapResultItem(string $resourceKey, array $row): ?array;
/**
* Return the list URL for the given resource key and encoded search term.
*/
public function listUrl(string $resourceKey, string $encodedSearch): string;
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\App\Module\Contracts;
use MintyPHP\App\AppContainer;
/**
* Populates module-specific session data after login and cleans up on logout.
*
* Called from the login flow after successful authentication. When a module is
* deactivated, its provider is not called — session keys are simply not set.
*/
interface SessionProvider
{
/**
* Populate session keys with module-specific data after successful login.
*
* @param array<string, mixed> $user The authenticated user record
*/
public function populate(array $user, AppContainer $container): void;
/**
* Remove session keys owned by this module (called on logout).
*/
public function clear(AppContainer $container): void;
}

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\App\AppContainer;
/**
* Resolve module-declared classes from DI first and only fall back to direct
* instantiation when the class is not container-bound and has no required
* constructor arguments.
*/
final class ModuleClassResolver
{
public static function resolve(AppContainer $container, string $class): ?object
{
$class = trim($class);
if ($class === '') {
return null;
}
if ($container->has($class)) {
try {
$resolved = $container->get($class);
return is_object($resolved) ? $resolved : null;
} catch (\Throwable) {
return null;
}
}
if (!class_exists($class)) {
return null;
}
try {
$reflection = new \ReflectionClass($class);
if (!$reflection->isInstantiable()) {
return null;
}
$constructor = $reflection->getConstructor();
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
return null;
}
return $reflection->newInstance();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Http\RequestContext;
use MintyPHP\Service\Audit\AuditRecorderInterface;
/**
* 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,
private readonly ?AuditRecorderInterface $systemAuditService = null
) {
}
/**
* @param array<string, mixed> $payload
*/
public function dispatch(string $event, array $payload): void
{
$listeners = $this->listenerMap[$event] ?? [];
foreach ($listeners as $descriptor) {
$class = '';
$method = '';
try {
$class = $descriptor['class'];
$method = $descriptor['method'];
$listener = ModuleClassResolver::resolve($this->container, $class);
if (!$listener instanceof EventListener) {
continue;
}
$listener->{$method}($event, $payload);
} catch (\Throwable $exception) {
$this->recordListenerFailure($event, $class, $method, $exception);
}
}
}
private function recordListenerFailure(
string $event,
string $listenerClass,
string $listenerMethod,
\Throwable $exception
): void {
if ($this->systemAuditService === null) {
return;
}
$this->systemAuditService->record('module.listener.failed', 'failed', [
'metadata' => [
'module_event' => $event,
'listener_class' => $listenerClass,
'listener_method' => $listenerMethod,
'exception_class' => $exception::class,
'request_id' => RequestContext::id(),
],
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\I18n;
use ReflectionProperty;
/**
* Preloads core + module translations into I18n::$strings at boot time.
*
* Since I18n::$strings is private static and lives in a vendor class,
* Reflection is used once per locale to inject the merged translation map.
*/
final class ModuleI18nLoader
{
/**
* @param list<ModuleManifest> $manifests Active module manifests (already sorted)
* @param string $coreI18nDir Absolute path to the core i18n/ directory
*/
public static function preload(array $manifests, string $coreI18nDir): void
{
$locales = ['de', 'en'];
$domain = I18n::$domain ?: 'default';
$prop = new ReflectionProperty(I18n::class, 'strings');
foreach ($locales as $locale) {
// Load core file
$coreFile = $coreI18nDir . '/' . $domain . '_' . $locale . '.json';
$merged = is_file($coreFile)
? (json_decode(file_get_contents($coreFile), true) ?: [])
: [];
// Merge each module's translations on top
foreach ($manifests as $manifest) {
if ($manifest->i18nPath === null) {
continue;
}
$moduleFile = $manifest->i18nPath . '/' . $domain . '_' . $locale . '.json';
if (!is_file($moduleFile)) {
continue;
}
$moduleStrings = json_decode(file_get_contents($moduleFile), true);
if (is_array($moduleStrings)) {
$merged = array_merge($merged, $moduleStrings);
}
}
// Inject via Reflection into I18n::$strings
$strings = $prop->getValue();
$strings[$domain][$locale] = $merged;
$prop->setValue(null, $strings);
}
}
}

View File

@@ -0,0 +1,462 @@
<?php
namespace MintyPHP\App\Module;
use InvalidArgumentException;
/**
* Immutable value object describing a single module's contributions.
*
* Each module ships a `module.php` that returns an associative array.
* ModuleManifest validates and normalises that array into a typed contract.
*/
final class ModuleManifest
{
public readonly string $id;
public readonly string $version;
public readonly bool $enabledByDefault;
public readonly int $loadOrder;
/** @var array<int, array{path: string, target: string, public?: bool}> */
public readonly array $routes;
/** @var list<string> */
public readonly array $publicPaths;
/** @var list<class-string> Container registrar FQCNs */
public readonly array $containerRegistrars;
/** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
public readonly array $uiSlots;
/** @var list<class-string> SearchResourceProvider FQCNs */
public readonly array $searchResources;
/** @var array<string, list<string>> Asset group name → file list */
public readonly array $assetGroups;
/**
* @var list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }>
*/
public readonly array $schedulerJobs;
/** @var list<class-string> LayoutContextProvider FQCNs */
public readonly array $layoutContextProviders;
/** @var list<class-string> SessionProvider FQCNs */
public readonly array $sessionProviders;
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }>
*/
public readonly array $permissions;
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
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 $i18nPath;
public readonly string $basePath;
/**
* @param array<string, mixed> $raw The manifest array returned by module.php
* @param string $basePath Absolute path to the module directory
*/
private function __construct(array $raw, string $basePath)
{
$this->basePath = $basePath;
// — required fields ————————————————————————————
if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') {
throw new InvalidArgumentException('Module manifest must have a non-empty string "id".');
}
$this->id = trim($raw['id']);
$this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0';
$this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false);
$this->loadOrder = (int) ($raw['load_order'] ?? 100);
// — optional contribution arrays ———————————————
$this->routes = self::arrayOf($raw, 'routes');
$this->publicPaths = self::listOf($raw, 'public_paths');
$this->containerRegistrars = self::listOf($raw, 'container_registrars');
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
$this->searchResources = self::listOf($raw, 'search_resources');
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
$this->sessionProviders = self::listOf($raw, 'session_providers');
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$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;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
: null;
$i18nPath = $raw['i18n_path'] ?? null;
$this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/')
: null;
}
/**
* @param array<string, mixed> $raw
*/
public static function fromArray(array $raw, string $basePath): self
{
return new self($raw, $basePath);
}
/**
* @return list<mixed>
*/
private static function listOf(array $raw, string $key): array
{
$value = $raw[$key] ?? [];
return is_array($value) ? array_values($value) : [];
}
/**
* @return array<int|string, mixed>
*/
private static function arrayOf(array $raw, string $key): array
{
$value = $raw[$key] ?? [];
return is_array($value) ? $value : [];
}
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
private static function normalizePermissions(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
);
}
$permissions = [];
foreach (array_values($value) as $index => $permission) {
if (!is_array($permission)) {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
);
}
$key = trim((string) ($permission['key'] ?? ''));
$description = trim((string) ($permission['description'] ?? ''));
if ($key === '' || $description === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
);
}
$permissions[] = [
'key' => $key,
'description' => $description,
'active' => (int) ((bool) ($permission['active'] ?? true)),
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
];
}
return $permissions;
}
/**
* @return list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }>
*/
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
);
}
$jobs = [];
foreach (array_values($value) as $index => $job) {
if (!is_array($job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
);
}
$requiredKeys = [
'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',
];
foreach ($requiredKeys as $requiredKey) {
if (!array_key_exists($requiredKey, $job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
);
}
}
$jobKey = trim((string) ($job['job_key'] ?? ''));
$handler = trim((string) ($job['handler'] ?? ''));
$label = trim((string) ($job['label'] ?? ''));
$description = trim((string) ($job['description'] ?? ''));
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
$moduleId,
$index
)
);
}
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
);
}
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
if (!in_array($defaultType, $allowedTypes, true)) {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
$moduleId,
$index,
$defaultType
)
);
}
$defaultInterval = (int) $job['default_schedule_interval'];
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
$defaultWeekdays = self::normalizeWeekdaysCsv(
$job['default_schedule_weekdays_csv'],
$moduleId,
$index
);
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
);
}
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
);
}
$jobs[] = [
'job_key' => $jobKey,
'handler' => $handler,
'label' => $label,
'description' => $description,
'default_enabled' => (int) ((bool) $job['default_enabled']),
'default_timezone' => $defaultTimezone,
'default_schedule_type' => $defaultType,
'default_schedule_interval' => $defaultInterval,
'default_schedule_time' => $defaultTime,
'default_schedule_weekdays_csv' => $defaultWeekdays,
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
'allowed_schedule_types' => $allowedTypes,
];
}
return $jobs;
}
/**
* @return list<string>
*/
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
{
if (!is_array($value) || $value === []) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
);
}
$types = [];
foreach ($value as $rawType) {
$type = strtolower(trim((string) $rawType));
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
);
}
$types[] = $type;
}
$types = array_values(array_unique($types));
sort($types, SORT_STRING);
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
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
if ($days === []) {
return null;
}
$normalizedDays = [];
foreach ($days as $day) {
if (!preg_match('/^[1-7]$/', $day)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
);
}
$normalizedDays[] = (int) $day;
}
$normalizedDays = array_values(array_unique($normalizedDays));
sort($normalizedDays, SORT_NUMERIC);
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace MintyPHP\App\Module;
use RuntimeException;
/**
* Loads a single module manifest from disk without requiring the module to be enabled.
*
* Unlike ModuleRegistry (which only loads enabled modules), this loader can resolve
* any module by its directory — used by validation and deactivation tooling.
*/
final class ModuleManifestLoader
{
/**
* Load and validate a module manifest from its on-disk location.
*
* @param string $modulesDir Absolute path to the modules/ directory
* @param string $moduleId Directory name / module ID
*
* @throws RuntimeException If the manifest file is missing or invalid
*/
public static function loadFromDisk(string $modulesDir, string $moduleId): ModuleManifest
{
$manifestFile = rtrim($modulesDir, '/') . '/' . $moduleId . '/module.php';
if (!is_file($manifestFile)) {
throw new RuntimeException(
"Module manifest not found at: {$manifestFile}"
);
}
$raw = require $manifestFile;
if (!is_array($raw)) {
throw new RuntimeException(
"Module manifest at '{$manifestFile}' must return an array."
);
}
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
return ModuleManifest::fromArray($raw, dirname($manifestFile));
}
}

View 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));
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
/**
* Synchronizes module-defined permissions into the permissions table.
*/
final class ModulePermissionSynchronizer
{
public function __construct(
private readonly ModuleRegistry $moduleRegistry,
private readonly PermissionRepositoryInterface $permissionRepository,
private readonly string $modulesDir = ''
) {
}
/**
* @return array{created: int, updated: int, unchanged: int, deactivated: int, total: int}
*/
public function sync(): array
{
$created = 0;
$updated = 0;
$unchanged = 0;
$total = 0;
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
$total++;
$key = trim((string) $permissionDefinition['key']);
if ($key === '') {
throw new \RuntimeException('Module permission definition has empty key.');
}
$desired = [
'key' => $key,
'description' => trim((string) $permissionDefinition['description']),
'active' => (int) $permissionDefinition['active'],
'is_system' => (int) $permissionDefinition['is_system'],
];
$existing = $this->permissionRepository->findByKey($key);
if (!is_array($existing)) {
$createdId = $this->permissionRepository->create($desired);
if ($createdId === null) {
throw new \RuntimeException("Failed to create module permission '{$key}'.");
}
$created++;
continue;
}
$existingId = (int) ($existing['id'] ?? 0);
if ($existingId <= 0) {
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
}
if ($this->isPermissionEqual($existing, $desired)) {
$unchanged++;
continue;
}
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
if (!$wasUpdated) {
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
}
$updated++;
}
$deactivated = $this->deactivateOrphaned();
return [
'created' => $created,
'updated' => $updated,
'unchanged' => $unchanged,
'deactivated' => $deactivated,
'total' => $total,
];
}
/**
* Deactivate module-owned permissions that no active module claims.
*
* Only touches permissions whose key is declared in at least one module
* manifest on disk (enabled or not). Core seed permissions are never touched.
* Setting active=0 is reversible — re-enabling the module and running sync
* restores active=1.
*/
private function deactivateOrphaned(): int
{
$activeKeys = $this->moduleRegistry->getPermissionKeys();
$allModuleKeys = $this->collectAllModulePermissionKeys();
// Nothing to deactivate if we can't determine module-owned keys
if ($allModuleKeys === []) {
return 0;
}
$systemPermissions = $this->permissionRepository->listSystem();
$deactivated = 0;
foreach ($systemPermissions as $permission) {
$key = (string) $permission['key'];
$id = (int) $permission['id'];
$isActive = (int) $permission['active'];
if ($key === '' || $id <= 0) {
continue;
}
// Already inactive — nothing to do
if ($isActive === 0) {
continue;
}
// Still claimed by an active module — keep it
if (in_array($key, $activeKeys, true)) {
continue;
}
// Not a module-owned permission — never touch core seed permissions
if (!in_array($key, $allModuleKeys, true)) {
continue;
}
// Orphaned module permission: declared by a module on disk but not by any active module
$this->permissionRepository->update($id, [
'key' => $key,
'description' => (string) $permission['description'],
'active' => 0,
'is_system' => 1,
]);
$deactivated++;
}
return $deactivated;
}
/**
* Scan all module manifests on disk (not just enabled) and collect their permission keys.
* This determines which permissions are module-owned vs core seed data.
*
* @return list<string>
*/
private function collectAllModulePermissionKeys(): array
{
if ($this->modulesDir === '' || !is_dir($this->modulesDir)) {
return [];
}
$keys = [];
$entries = scandir($this->modulesDir);
if ($entries === false) {
return [];
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$manifestFile = $this->modulesDir . '/' . $entry . '/module.php';
if (!is_file($manifestFile)) {
continue;
}
try {
$raw = require $manifestFile;
if (!is_array($raw)) {
continue;
}
$permissions = $raw['permissions'] ?? [];
if (!is_array($permissions)) {
continue;
}
foreach ($permissions as $permission) {
if (is_array($permission)) {
$key = trim((string) ($permission['key'] ?? ''));
if ($key !== '') {
$keys[] = $key;
}
}
}
} catch (\Throwable) {
// Skip broken manifests — don't break the sync
continue;
}
}
return array_values(array_unique($keys));
}
/**
* @param array<string, mixed> $existing
* @param array<string, mixed> $desired
*/
private function isPermissionEqual(array $existing, array $desired): bool
{
$existingDescription = trim((string) ($existing['description'] ?? ''));
$existingActive = (int) ($existing['active'] ?? 0);
$existingSystem = (int) ($existing['is_system'] ?? 0);
return $existingDescription === (string) ($desired['description'] ?? '')
&& $existingActive === (int) ($desired['active'] ?? 0)
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
}
}

View File

@@ -0,0 +1,701 @@
<?php
namespace MintyPHP\App\Module;
use Composer\Autoload\ClassLoader;
use RuntimeException;
/**
* Loads, validates and merges module manifests.
*
* Modules are discovered from a configurable directory and activated via an
* allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
*
* The registry is immutable after construction — all contributions are merged
* at boot time and available through typed getters.
*/
final class ModuleRegistry
{
/** @var array<string, ModuleManifest> keyed by module id */
private array $modules = [];
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
private array $mergedRoutes = [];
/** @var list<string> merged public paths */
private array $mergedPublicPaths = [];
/** @var list<class-string> merged container registrar FQCNs */
private array $mergedContainerRegistrars = [];
/** @var array<string, list<mixed>> merged UI slots keyed by slot name */
private array $mergedUiSlots = [];
/** @var list<class-string> merged search resource provider FQCNs */
private array $mergedSearchResources = [];
/** @var array<string, list<string>> merged asset groups */
private array $mergedAssetGroups = [];
/** @var list<class-string> merged layout context provider FQCNs */
private array $mergedLayoutContextProviders = [];
/** @var list<class-string> merged session provider FQCNs */
private array $mergedSessionProviders = [];
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }> merged permissions
*/
private array $mergedPermissions = [];
/** @var list<class-string> merged authorization policy FQCNs */
private array $mergedAuthorizationPolicies = [];
/**
* @var list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
private array $mergedSchedulerJobs = [];
/** @var array<string, string> route target => owning module id */
private array $routeTargetOwners = [];
/** @var array<string, string> route path => owning module id */
private array $routePathOwners = [];
/** @var array<string, string> permission key => owning module id */
private array $permissionOwners = [];
/** @var array<string, string> scheduler job key => owning module id */
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.
*
* @param string $modulesDir Absolute path to the modules/ directory
* @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
*/
public static function boot(string $modulesDir, array $enabledModuleIds): self
{
$registry = new self();
if (!is_dir($modulesDir)) {
return $registry;
}
// Discover and load manifests for enabled modules
$manifests = [];
foreach ($enabledModuleIds as $moduleId) {
$moduleId = trim($moduleId);
if ($moduleId === '') {
continue;
}
$modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
$manifestFile = $modulePath . '/module.php';
if (!is_file($manifestFile)) {
throw new RuntimeException(
"Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
);
}
$raw = require $manifestFile;
if (!is_array($raw)) {
throw new RuntimeException(
"Module manifest at '{$manifestFile}' must return an array."
);
}
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
$manifest = ModuleManifest::fromArray($raw, $modulePath);
if ($manifest->id !== $moduleId) {
throw new RuntimeException(
"Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
);
}
$manifests[] = $manifest;
}
// Sort deterministically: load_order ASC, then id ASC
usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
});
// Register module lib dirs with Composer's PSR-4 autoloader so that
// module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
self::registerModuleAutoloading($manifests);
// Preload core + module translations into I18n before any t() call.
$coreI18nDir = rtrim($modulesDir, '/') . '/../i18n';
if (is_dir($coreI18nDir)) {
ModuleI18nLoader::preload($manifests, $coreI18nDir);
}
// Register and merge
foreach ($manifests as $manifest) {
$registry->registerModule($manifest);
}
$registry->validateDependencies();
$registry->finalizeMergedContributions();
return $registry;
}
/**
* Resolve enabled module IDs from config array + ENV override.
*
* @param array{enabled_modules?: list<string>} $config From config/modules.php
* @return list<string>
*/
public static function resolveEnabledModules(array $config): array
{
$fromConfig = $config['enabled_modules'] ?? [];
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
// Semantics:
// - not set => use config/modules.php
// - set '' => explicit "no modules"
// - set list => exact list from env
if ($fromEnvRaw !== false) {
$fromEnv = trim((string) $fromEnvRaw);
$fromConfig = $fromEnv === ''
? []
: array_map('trim', explode(',', $fromEnv));
}
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
}
/**
* Register module lib/ directories with Composer's ClassLoader.
*
* @param list<ModuleManifest> $manifests
*/
private static function registerModuleAutoloading(array $manifests): void
{
$composerLoader = null;
foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) {
$composerLoader = $autoloader[0];
break;
}
}
if ($composerLoader === null) {
return;
}
foreach ($manifests as $manifest) {
$libDir = rtrim($manifest->basePath, '/') . '/lib';
if (is_dir($libDir)) {
$composerLoader->addPsr4('MintyPHP\\', [$libDir]);
}
}
}
private function registerModule(ModuleManifest $manifest): void
{
if (isset($this->modules[$manifest->id])) {
throw new RuntimeException(
"Duplicate module id: '{$manifest->id}' is already registered."
);
}
$this->modules[$manifest->id] = $manifest;
// — Merge routes (fail-fast on collision) ——————————————
foreach ($manifest->routes as $rawRoute) {
$route = $this->normalizeRoute($rawRoute, $manifest->id);
$path = $route['path'];
$target = $route['target'];
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
$owner = $this->routeTargetOwners[$target];
throw new RuntimeException(
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
if (isset($this->routePathOwners[$path])) {
$owner = $this->routePathOwners[$path];
throw new RuntimeException(
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
$this->routeTargetOwners[$target] = $manifest->id;
$this->routePathOwners[$path] = $manifest->id;
$this->mergedRoutes[] = $route;
if (!empty($route['public'])) {
$this->mergedPublicPaths[] = $path;
}
}
// — Merge public paths ———————————————————————————
array_push($this->mergedPublicPaths, ...$manifest->publicPaths);
// — Merge container registrars ————————————————————
array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);
// — Merge UI slots (fail-fast on duplicate keys within same slot) ——
foreach ($manifest->uiSlots as $slotName => $contributions) {
if (!isset($this->mergedUiSlots[$slotName])) {
$this->mergedUiSlots[$slotName] = [];
}
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $contribution) {
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath);
$key = (string) $normalizedContribution['key'];
foreach ($this->mergedUiSlots[$slotName] as $existing) {
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
if ($existingKey === $key) {
throw new RuntimeException(
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
);
}
}
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
}
}
// — Merge search resources (fail-fast on duplicate provider) ——
foreach ($manifest->searchResources as $provider) {
if (!is_string($provider) || trim($provider) === '') {
throw new RuntimeException(
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($provider, $this->mergedSearchResources, true)) {
throw new RuntimeException(
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedSearchResources[] = $provider;
}
// — Merge asset groups ————————————————————————————
foreach ($manifest->assetGroups as $groupName => $files) {
if (isset($this->mergedAssetGroups[$groupName])) {
throw new RuntimeException(
"Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
);
}
$this->mergedAssetGroups[$groupName] = $files;
}
// — Merge permissions (fail-fast on duplicate) ———————
foreach ($manifest->permissions as $permission) {
$permissionKey = trim((string) $permission['key']);
if ($permissionKey === '') {
throw new RuntimeException(
"Permission in module '{$manifest->id}' must define non-empty 'key'."
);
}
if (isset($this->permissionOwners[$permissionKey])) {
$owner = $this->permissionOwners[$permissionKey];
throw new RuntimeException(
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->permissionOwners[$permissionKey] = $manifest->id;
$this->mergedPermissions[] = [
'key' => $permissionKey,
'description' => trim((string) $permission['description']),
'active' => (int) $permission['active'],
'is_system' => (int) $permission['is_system'],
];
}
// — Merge layout context providers ————————————————
foreach ($manifest->layoutContextProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedLayoutContextProviders[] = $providerClass;
}
// — Merge session providers ——————————————————————
foreach ($manifest->sessionProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedSessionProviders[] = $providerClass;
}
// — Merge authorization policies (fail-fast on duplicate) ———
foreach ($manifest->authorizationPolicies as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
throw new RuntimeException(
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
throw new RuntimeException(
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
);
}
$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 —————————————————————————
foreach ($manifest->schedulerJobs as $schedulerJob) {
$jobKey = trim((string) $schedulerJob['job_key']);
if ($jobKey === '') {
throw new RuntimeException(
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
);
}
if (isset($this->schedulerJobOwners[$jobKey])) {
$owner = $this->schedulerJobOwners[$jobKey];
throw new RuntimeException(
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->schedulerJobOwners[$jobKey] = $manifest->id;
$this->mergedSchedulerJobs[] = [
'job_key' => $jobKey,
'handler' => trim((string) $schedulerJob['handler']),
'label' => trim((string) $schedulerJob['label']),
'description' => trim((string) $schedulerJob['description']),
'default_enabled' => (int) $schedulerJob['default_enabled'],
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
'default_schedule_time' => $schedulerJob['default_schedule_time'],
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
'module_id' => $manifest->id,
];
}
}
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
{
$this->mergedPublicPaths = array_values(array_unique(array_filter(
$this->mergedPublicPaths,
static fn (mixed $path): bool => trim((string) $path) !== ''
)));
foreach ($this->mergedUiSlots as $slotName => $items) {
usort($items, static function (array $a, array $b): int {
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
if ($orderCmp !== 0) {
return $orderCmp;
}
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
if ($moduleCmp !== 0) {
return $moduleCmp;
}
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
});
foreach ($items as &$item) {
unset($item['__module_id']);
}
unset($item);
$this->mergedUiSlots[$slotName] = $items;
}
}
/**
* @return array{path: string, target: string, module_id: string, public?: bool}
*/
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
{
if (!is_array($rawRoute)) {
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
}
$path = trim((string) ($rawRoute['path'] ?? ''));
$target = trim((string) ($rawRoute['target'] ?? ''));
if ($path === '' || $target === '') {
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
}
$normalized = [
'path' => $path,
'target' => $target,
'module_id' => $moduleId,
];
if (array_key_exists('public', $rawRoute)) {
$normalized['public'] = (bool) $rawRoute['public'];
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array
{
if (!is_array($rawContribution)) {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
);
}
$key = trim((string) ($rawContribution['key'] ?? ''));
if ($key === '') {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
);
}
// Resolve relative template paths to absolute using the module's basePath.
foreach (['panel_template', 'template'] as $templateKey) {
if (!isset($rawContribution[$templateKey])) {
continue;
}
$templatePath = trim((string) $rawContribution[$templateKey]);
if ($templatePath !== '' && !str_starts_with($templatePath, '/')) {
$rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath;
}
}
$normalized = $rawContribution;
$normalized['key'] = $key;
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
$normalized['__module_id'] = $moduleId;
$normalized['module_id'] = $moduleId;
return $normalized;
}
// ─── Getters ────────────────────────────────────────────────────────
/** @return array<string, ModuleManifest> */
public function getModules(): array
{
return $this->modules;
}
public function hasModule(string $id): bool
{
return isset($this->modules[$id]);
}
public function getModule(string $id): ModuleManifest
{
if (!isset($this->modules[$id])) {
throw new RuntimeException("Module '{$id}' is not registered.");
}
return $this->modules[$id];
}
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
public function getRoutes(): array
{
return $this->mergedRoutes;
}
/** @return list<string> */
public function getPublicPaths(): array
{
return $this->mergedPublicPaths;
}
/** @return list<class-string> */
public function getContainerRegistrars(): array
{
return $this->mergedContainerRegistrars;
}
/** @return array<string, list<mixed>> */
public function getUiSlots(): array
{
return $this->mergedUiSlots;
}
/**
* @return list<mixed> Contributions for a specific slot
*/
public function getSlotContributions(string $slotName): array
{
return $this->mergedUiSlots[$slotName] ?? [];
}
/** @return list<class-string> */
public function getSearchResources(): array
{
return $this->mergedSearchResources;
}
/** @return array<string, list<string>> */
public function getAssetGroups(): array
{
return $this->mergedAssetGroups;
}
/** @return list<class-string> */
public function getLayoutContextProviders(): array
{
return $this->mergedLayoutContextProviders;
}
/** @return list<class-string> */
public function getSessionProviders(): array
{
return $this->mergedSessionProviders;
}
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
public function getPermissions(): array
{
return $this->mergedPermissions;
}
/** @return list<string> */
public function getPermissionKeys(): array
{
return array_values(array_map(
static fn (array $permission): string => (string) $permission['key'],
$this->mergedPermissions
));
}
/** @return list<class-string> */
public function getAuthorizationPolicies(): array
{
return $this->mergedAuthorizationPolicies;
}
/**
* Collect unique ability strings from all module UI slot contributions.
*
* Used by UiAccessService to resolve module abilities into the layout auth map.
* Each ability maps to itself (key = ability, value = ability).
*
* @return array<string, string>
*/
public function getModuleUiAbilities(): array
{
$abilities = [];
foreach ($this->mergedUiSlots as $items) {
foreach ($items as $item) {
$ability = trim((string) ($item['permission'] ?? ''));
if ($ability !== '') {
$abilities[$ability] = $ability;
}
}
}
return $abilities;
}
/**
* @return list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
public function getSchedulerJobs(): array
{
return $this->mergedSchedulerJobs;
}
/** @return array<string, list<array{class: class-string, method: string}>> */
public function getEventListeners(): array
{
return $this->mergedEventListeners;
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace MintyPHP\App\Module;
use RuntimeException;
/**
* Publishes module web assets to a runtime web/modules directory.
*/
final class ModuleRuntimeAssetPublisher
{
/**
* @param array<string, ModuleManifest> $modules
* @return array{created: int, updated: int, removed: int, unchanged: int}
*/
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
{
$normalizedMode = strtolower(trim($mode));
if ($normalizedMode === '') {
$normalizedMode = 'symlink';
}
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
}
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
}
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
}
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
$desired = [];
foreach ($modules as $manifest) {
if (!$manifest instanceof ModuleManifest) {
continue;
}
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
if (!is_dir($moduleWebDir)) {
continue;
}
$desired[$manifest->id] = $moduleWebDir;
}
$entries = scandir($runtimeModulesDir);
if ($entries === false) {
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (isset($desired[$entry])) {
continue;
}
$path = $runtimeModulesDir . '/' . $entry;
$this->removePath($path);
$result['removed']++;
}
foreach ($desired as $moduleId => $moduleWebDir) {
$targetPath = $runtimeModulesDir . '/' . $moduleId;
$targetExists = file_exists($targetPath) || is_link($targetPath);
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
$targetReal = realpath($targetPath);
$sourceReal = realpath($moduleWebDir);
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
$result['unchanged']++;
continue;
}
}
if ($targetExists) {
$this->removePath($targetPath);
}
if ($normalizedMode === 'symlink') {
if (!@symlink($moduleWebDir, $targetPath)) {
throw new RuntimeException(
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
);
}
} else {
$this->copyDirectory($moduleWebDir, $targetPath);
}
if ($targetExists) {
$result['updated']++;
} else {
$result['created']++;
}
}
return $result;
}
private function removePath(string $path): void
{
if (is_link($path)) {
if (!unlink($path)) {
throw new RuntimeException("Cannot remove symlink: {$path}");
}
return;
}
if (is_file($path)) {
if (!unlink($path)) {
throw new RuntimeException("Cannot remove file: {$path}");
}
return;
}
if (!is_dir($path)) {
return;
}
$entries = scandir($path);
if ($entries === false) {
throw new RuntimeException("Cannot scan directory: {$path}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$this->removePath($path . '/' . $entry);
}
if (!rmdir($path)) {
throw new RuntimeException("Cannot remove directory: {$path}");
}
}
private function copyDirectory(string $sourceDir, string $targetDir): void
{
if (!is_dir($sourceDir)) {
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
}
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
}
$entries = scandir($sourceDir);
if ($entries === false) {
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $sourceDir . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (is_link($source)) {
$linkTarget = readlink($source);
if ($linkTarget === false || !symlink($linkTarget, $target)) {
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
}
continue;
}
if (is_dir($source)) {
$this->copyDirectory($source, $target);
continue;
}
if (!is_file($source)) {
continue;
}
if (!copy($source, $target)) {
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
}
}
}
}

View File

@@ -0,0 +1,302 @@
<?php
namespace MintyPHP\App\Module;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
/**
* Builds a runtime PageRoot by symlinking core and enabled module pages.
*
* Build uses atomic staging+swap: output is constructed in a temporary staging
* directory alongside the live target and only swapped into place after
* successful validation. Partial failures cannot leave inconsistent live state.
*/
final class ModuleRuntimePageBuilder
{
/**
* @param array<string, ModuleManifest> $modules
* @return array{core_entries: int, module_entries: int}
*/
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
{
$corePages = realpath($corePagesDir);
if ($corePages === false) {
throw new RuntimeException('Core pages/ directory not found.');
}
if (count($modules) === 0) {
$this->pointRuntimeToCore($runtimeDir, $corePages);
return ['core_entries' => 0, 'module_entries' => 0];
}
// ── Staging: build in a sibling directory, swap on success ────
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
$this->ensureCleanDirectory($stagingDir);
try {
$result = $this->populateDirectory($stagingDir, $corePages, $modules);
} catch (\Throwable $e) {
$this->removeDirectory($stagingDir);
throw $e;
}
// ── Atomic swap ──────────────────────────────────────────────
$this->swapIntoLive($stagingDir, $runtimeDir);
return $result;
}
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
{
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
$this->removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
}
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!@symlink($corePages, $runtimeDir)) {
throw new RuntimeException("Failed to create symlink: {$runtimeDir}{$corePages}");
}
}
// ── Fingerprint: detect stale runtime state ─────────────────────
/**
* Compute the expected fingerprint for a set of modules.
*
* The digest includes module IDs, each manifest file content hash,
* and top-level page-entry signatures so that manifest-only or
* page-structure changes are detected even when the module ID list
* is unchanged.
*
* @param array<string, ModuleManifest> $modules
*/
public static function expectedFingerprint(array $modules): string
{
$parts = [];
$manifests = $modules;
uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);
foreach ($manifests as $manifest) {
$manifestFile = $manifest->basePath . '/module.php';
$manifestHash = is_file($manifestFile)
? md5_file($manifestFile)
: 'missing';
$pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');
$parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
}
if ($parts === []) {
return '';
}
return hash('sha256', implode('|', $parts));
}
/**
* Read the persisted fingerprint from disk.
*/
public static function readFingerprint(string $runtimeDir): string
{
$file = dirname($runtimeDir) . '/.module-fingerprint';
return is_file($file) ? trim((string) file_get_contents($file)) : '';
}
/**
* Write a fingerprint after a successful build so web/index.php can
* detect stale runtime state without rebuilding on every request.
*
* @param array<string, ModuleManifest> $modules
*/
public static function writeFingerprint(string $runtimeDir, array $modules): void
{
$file = dirname($runtimeDir) . '/.module-fingerprint';
file_put_contents($file, self::expectedFingerprint($modules));
}
// ── Internal helpers ────────────────────────────────────────────
/**
* Populate a directory with core + module page symlinks.
*
* @param array<string, ModuleManifest> $modules
* @return array{core_entries: int, module_entries: int}
*/
private function populateDirectory(string $targetDir, string $corePages, array $modules): array
{
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages directory.');
}
$coreCount = 0;
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$coreCount++;
}
$modulePageCount = 0;
foreach ($modules as $manifest) {
$modulePagesDir = $manifest->basePath . '/pages';
if (!is_dir($modulePagesDir)) {
continue;
}
$entries = scandir($modulePagesDir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $modulePagesDir . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (file_exists($target) || is_link($target)) {
throw new RuntimeException(
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
);
}
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$modulePageCount++;
}
}
return [
'core_entries' => $coreCount,
'module_entries' => $modulePageCount,
];
}
/**
* Swap staging directory into the live runtime path.
*
* Uses rename() for an atomic filesystem operation (same filesystem).
*/
private function swapIntoLive(string $stagingDir, string $runtimeDir): void
{
// Remove existing live target
$oldDir = null;
if (is_link($runtimeDir)) {
unlink($runtimeDir);
} elseif (is_dir($runtimeDir)) {
// Move old live dir aside, then remove after swap
$oldDir = $runtimeDir . '.old-' . getmypid();
if (!@rename($runtimeDir, $oldDir)) {
$this->removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
$oldDir = null;
}
} else {
$oldDir = null;
}
// Atomic rename of staging → live
if (!@rename($stagingDir, $runtimeDir)) {
throw new RuntimeException(
"Failed to swap staging directory into live runtime path: {$stagingDir}{$runtimeDir}"
);
}
// Clean up old directory if we moved it aside
if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
$this->removeDirectory($oldDir);
}
}
private function ensureCleanDirectory(string $dir): void
{
if (is_dir($dir)) {
$this->removeDirectoryContents($dir);
rmdir($dir);
}
if (is_link($dir)) {
unlink($dir);
}
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Cannot create staging directory: ' . $dir);
}
}
/**
* Return sorted top-level entry names from a module pages directory.
*
* @return list<string>
*/
private static function topLevelPageEntries(string $pagesDir): array
{
if (!is_dir($pagesDir)) {
return [];
}
$entries = scandir($pagesDir);
if ($entries === false) {
return [];
}
$entries = array_values(array_filter(
$entries,
static fn (string $e): bool => $e !== '.' && $e !== '..',
));
sort($entries);
return $entries;
}
private function removeDirectory(string $dir): void
{
if (is_link($dir)) {
unlink($dir);
return;
}
if (!is_dir($dir)) {
return;
}
$this->removeDirectoryContents($dir);
rmdir($dir);
}
private function removeDirectoryContents(string $dir): void
{
$entries = scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_link($path)) {
unlink($path);
continue;
}
if (is_dir($path)) {
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($path);
continue;
}
unlink($path);
}
}
}