feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)

Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.

Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider

Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.

Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:43:25 +01:00
parent d9805c45d3
commit c364e2b46d
33 changed files with 2639 additions and 167 deletions

176
bin/module-build.php Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env php
<?php
/**
* Builds the runtime page root by symlinking module pages into storage/runtime/pages/.
*
* Usage: php bin/module-build.php
*
* The runtime page root mirrors the core pages/ directory and adds symlinks for
* each enabled module's pages. MintyPHP Router::$pageRoot points to this directory.
*
* This script is idempotent and uses a lock file to prevent concurrent builds.
*
* Exit codes:
* 0 — build successful (or nothing to do)
* 1 — error during build
*/
declare(strict_types=1);
chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
require 'config/config.php';
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
use MintyPHP\App\Module\ModuleRegistry;
$exitCode = 0;
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
try {
// Ensure runtime directory exists
$runtimeParent = dirname($runtimeDir);
if (!is_dir($runtimeParent)) {
mkdir($runtimeParent, 0775, true);
}
// Lock to prevent concurrent builds
$lock = fopen($lockFile, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
exit(0);
}
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
$corePages = realpath(__DIR__ . '/../pages');
if ($corePages === false) {
throw new RuntimeException('Core pages/ directory not found.');
}
// If no modules, ensure runtime dir points to core pages
if (count($modules) === 0) {
// Remove existing runtime dir if it's a real directory (not a symlink)
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
}
// Symlink runtime → core pages
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
symlink($corePages, $runtimeDir);
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
exit(0);
}
// With modules: build merged directory
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!is_dir($runtimeDir)) {
mkdir($runtimeDir, 0775, true);
}
// Clean existing symlinks in runtime dir
removeDirectoryContents($runtimeDir);
// Symlink all core page directories/files
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages/ directory.');
}
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
symlink($source, $target);
}
// Add module page symlinks (fail-fast on collision)
$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 = $runtimeDir . '/' . $entry;
if (file_exists($target) || is_link($target)) {
throw new RuntimeException(
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
);
}
symlink($source, $target);
$modulePageCount++;
}
}
fwrite(STDOUT, sprintf(
"module-build: runtime pages built — %d core entries + %d module entries.\n",
count($coreEntries) - 2, // minus . and ..
$modulePageCount
));
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
if (isset($lock) && is_resource($lock)) {
flock($lock, LOCK_UN);
fclose($lock);
}
}
exit($exitCode);
/**
* Remove all files and symlinks in a directory (non-recursive, top-level only).
*/
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);
} elseif (is_dir($path)) {
// Recursively remove directories that were created (shouldn't happen in normal flow)
$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);
} else {
unlink($path);
}
}
}

118
bin/module-migrate.php Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env php
<?php
/**
* Applies pending module migrations for all enabled modules.
*
* Usage: php bin/module-migrate.php
*
* Each module declares a migrations_path in its manifest. SQL files in that
* directory are applied in alphabetical order. Applied files are tracked in the
* module_migrations table to ensure idempotency.
*
* Exit codes:
* 0 — all migrations applied (or nothing to do)
* 1 — error during migration
*/
declare(strict_types=1);
chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
require 'config/config.php';
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\DB;
$exitCode = 0;
$totalApplied = 0;
try {
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
if (count($modules) === 0) {
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
exit(0);
}
// Ensure tracking table exists (safe if already created by db/updates/ script)
DB::execute(
'CREATE TABLE IF NOT EXISTS `module_migrations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`module_id` VARCHAR(64) NOT NULL,
`filename` VARCHAR(255) NOT NULL,
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
foreach ($modules as $manifest) {
$migrationsPath = $manifest->migrationsPath;
if ($migrationsPath === null || !is_dir($migrationsPath)) {
continue;
}
// Discover SQL files
$files = glob($migrationsPath . '/*.sql');
if ($files === false || count($files) === 0) {
continue;
}
sort($files); // alphabetical order
// Get already-applied filenames
$applied = [];
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]);
foreach ($rows as $row) {
$applied[$row['filename']] = true;
}
foreach ($files as $file) {
$filename = basename($file);
if (isset($applied[$filename])) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false || trim($sql) === '') {
fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename));
continue;
}
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
// Execute migration (may contain multiple statements)
$statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== '');
foreach ($statements as $statement) {
DB::execute($statement);
}
// Record as applied
DB::execute(
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
[$manifest->id, $filename]
);
fwrite(STDOUT, "OK\n");
$totalApplied++;
}
}
if ($totalApplied === 0) {
fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n");
} else {
fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied));
}
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
DB::close();
}
exit($exitCode);