1
0

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