Files
breadcrumb-the-shire/core/App/Module/ModuleRuntimePageBuilder.php
fs 0e86925464 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>
2026-04-13 23:20:42 +02:00

303 lines
9.9 KiB
PHP

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