From c34e62d729ea4716972c32db4d0d317212bfe079 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 24 Mar 2026 20:13:16 +0100 Subject: [PATCH] harden: restore gate baseline, split dev/prod PHP profile, atomic module runtime build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A — Gate Reliability: - Fix typography token violations in app-breadcrumb.css - Switch QG-006 from composer cs:check to direct php-cs-fixer - Align all docs/skills with new gate command Phase B — Production Profile: - Multi-stage Dockerfile: dev (xdebug) / prod (no xdebug) - Compose files target explicit build stages Phase C — Module Runtime Hardening: - Atomic staging+swap build in ModuleRuntimePageBuilder - SHA-256 fingerprint from manifest content + page entries - 11 new regression tests for build safety and fingerprint drift Task: STARTERKIT-HARDENING-ONPREM-001 Co-Authored-By: Claude Opus 4.6 (1M context) --- .agents/checks/quality-gates.json | 2 +- .../skills/starterkit-php-style-ci/SKILL.md | 4 +- .../references/ci-dry-run.md | 2 +- .../references/style-workflow.md | 4 +- CLAUDE.md | 2 +- README.md | 2 +- docker-compose.prod.yml | 4 +- docker-compose.yml | 2 + docker/php/Dockerfile | 21 +- lib/App/Module/ModuleRuntimePageBuilder.php | 247 +++++++++++++---- .../Module/ModuleRuntimePageBuilderTest.php | 256 ++++++++++++++++++ web/css/components/app-breadcrumb.css | 4 +- 12 files changed, 475 insertions(+), 75 deletions(-) create mode 100644 tests/App/Module/ModuleRuntimePageBuilderTest.php diff --git a/.agents/checks/quality-gates.json b/.agents/checks/quality-gates.json index b89e97f..211fcbb 100644 --- a/.agents/checks/quality-gates.json +++ b/.agents/checks/quality-gates.json @@ -36,7 +36,7 @@ "id": "QG-006", "type": "fast", "name": "PHP Style Check", - "command": "docker compose exec php composer cs:check" + "command": "docker compose exec php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose" }, { "id": "QG-007", diff --git a/.agents/skills/starterkit-php-style-ci/SKILL.md b/.agents/skills/starterkit-php-style-ci/SKILL.md index d3d47b9..1681e99 100644 --- a/.agents/skills/starterkit-php-style-ci/SKILL.md +++ b/.agents/skills/starterkit-php-style-ci/SKILL.md @@ -17,7 +17,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen. ## Verbindliche Regeln -1. MUST `composer cs:check` fuer nicht-mutierende Pruefung nutzen. +1. MUST `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose` fuer nicht-mutierende Pruefung nutzen (Convenience-Alias: `composer cs:check`). 2. MUST `composer cs:fix` nur bewusst und mit Diff-Kontrolle ausfuehren. 3. MUST Style-Check vor Merge gruen halten (lokal und optional CI). 4. MUST nach groesseren Fixes mindestens `phpunit` und `phpstan` laufen lassen. @@ -25,7 +25,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen. ## Workflow -1. Baseline erfassen: `composer cs:check`. +1. Baseline erfassen: `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose`. 2. Falls noetig fixen: `composer cs:fix`. 3. Diff fokussiert pruefen (Imports, Braces, Nebenwirkungen). 4. Qualitaetsgates laufen lassen. diff --git a/.agents/skills/starterkit-php-style-ci/references/ci-dry-run.md b/.agents/skills/starterkit-php-style-ci/references/ci-dry-run.md index 9c0a624..3cd4122 100644 --- a/.agents/skills/starterkit-php-style-ci/references/ci-dry-run.md +++ b/.agents/skills/starterkit-php-style-ci/references/ci-dry-run.md @@ -7,7 +7,7 @@ CI soll nur pruefen, nicht formatieren. ## Minimaler Check ```bash -composer cs:check +vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose ``` ## Erwartung diff --git a/.agents/skills/starterkit-php-style-ci/references/style-workflow.md b/.agents/skills/starterkit-php-style-ci/references/style-workflow.md index d7d707f..45b08aa 100644 --- a/.agents/skills/starterkit-php-style-ci/references/style-workflow.md +++ b/.agents/skills/starterkit-php-style-ci/references/style-workflow.md @@ -3,13 +3,13 @@ ## Commands ```bash -composer cs:check +vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose composer cs:fix ``` ## Praktikabler Ablauf -1. Erst `cs:check` laufen lassen. +1. Erst Style-Check laufen lassen (`vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose`). 2. Danach `cs:fix`. 3. Nur relevante Diffs uebernehmen, grosse Misch-Diffs vermeiden. 4. Anschliessend: diff --git a/CLAUDE.md b/CLAUDE.md index 4191f6d..839bd0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ These rules are enforced by guards GR-SEC-001 through GR-SEC-009 in `.agents/che | QG-001 | PHPUnit | `vendor/bin/phpunit` | | QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` | | QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` | -| QG-006 | PHP Style | `composer cs:check` | +| QG-006 | PHP Style | `vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose` | Additional gates (fast/periodic): QG-004 structural checks, QG-005 JS smoke, QG-007 composer-unused, QG-008 docs links, QG-009 skills sync. See gate catalog for details. diff --git a/README.md b/README.md index 64c4de4..6e9d245 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress CI-Check (ohne Dateien zu aendern): ```bash -docker compose exec php composer cs:check +docker compose exec php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose ``` Lokal automatisch formatieren: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8bb6611..e98191c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -16,6 +16,7 @@ services: build: context: . dockerfile: docker/php/Dockerfile + target: prod env_file: - .env environment: @@ -23,7 +24,6 @@ services: APP_DEBUG: "0" volumes: - ./:/var/www - - ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro depends_on: - db - memcached @@ -33,6 +33,7 @@ services: build: context: . dockerfile: docker/php/Dockerfile + target: prod env_file: - .env environment: @@ -40,7 +41,6 @@ services: APP_DEBUG: "0" volumes: - ./:/var/www - - ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro depends_on: - db - memcached diff --git a/docker-compose.yml b/docker-compose.yml index cbf6e70..d625989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: build: context: . dockerfile: docker/php/Dockerfile + target: dev env_file: - .env volumes: @@ -25,6 +26,7 @@ services: build: context: . dockerfile: docker/php/Dockerfile + target: dev env_file: - .env environment: diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index afe12b3..ee5a428 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.5-fpm +FROM php:8.5-fpm AS base RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -11,14 +11,27 @@ RUN apt-get update \ libjpeg-dev \ libpng-dev \ libwebp-dev \ - && pecl install memcached xdebug \ - && docker-php-ext-enable memcached xdebug \ + && pecl install memcached \ + && docker-php-ext-enable memcached \ && docker-php-ext-configure gd --with-jpeg --with-webp \ && docker-php-ext-install pdo_mysql mysqli gd zip \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www + +# ── Development profile (default) ──────────────────────────────────── +FROM base AS dev + +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug + COPY docker/php/php.ini /usr/local/etc/php/conf.d/zzz-minty-dev.ini COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/zzz-xdebug.ini -WORKDIR /var/www +# ── Production profile ─────────────────────────────────────────────── +# No xdebug — smaller image, no debug overhead or attack surface. +FROM base AS prod + +COPY docker/php/php.prod.ini /usr/local/etc/php/conf.d/zzz-minty-prod.ini diff --git a/lib/App/Module/ModuleRuntimePageBuilder.php b/lib/App/Module/ModuleRuntimePageBuilder.php index d601e16..2730d33 100644 --- a/lib/App/Module/ModuleRuntimePageBuilder.php +++ b/lib/App/Module/ModuleRuntimePageBuilder.php @@ -9,6 +9,10 @@ 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 { @@ -28,65 +32,21 @@ final class ModuleRuntimePageBuilder return ['core_entries' => 0, 'module_entries' => 0]; } - if (is_link($runtimeDir)) { - unlink($runtimeDir); - } - if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) { - throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir); + // ── 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; } - $this->removeDirectoryContents($runtimeDir); + // ── Atomic swap ────────────────────────────────────────────── + $this->swapIntoLive($stagingDir, $runtimeDir); - $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 = $runtimeDir . '/' . $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 = $runtimeDir . '/' . $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, - ]; + return $result; } public function pointRuntimeToCore(string $runtimeDir, string $corePages): void @@ -108,14 +68,36 @@ final class ModuleRuntimePageBuilder /** * 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 $modules */ public static function expectedFingerprint(array $modules): string { - $ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules); - sort($ids); + $parts = []; - return implode(',', $ids); + $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)); } /** @@ -141,6 +123,153 @@ final class ModuleRuntimePageBuilder file_put_contents($file, self::expectedFingerprint($modules)); } + // ── Internal helpers ──────────────────────────────────────────── + + /** + * Populate a directory with core + module page symlinks. + * + * @param array $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 + */ + 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); diff --git a/tests/App/Module/ModuleRuntimePageBuilderTest.php b/tests/App/Module/ModuleRuntimePageBuilderTest.php new file mode 100644 index 0000000..ae1d8f6 --- /dev/null +++ b/tests/App/Module/ModuleRuntimePageBuilderTest.php @@ -0,0 +1,256 @@ +tmpDir = sys_get_temp_dir() . '/corecore-page-builder-test-' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + $this->removePath($this->tmpDir); + } + + // ── Atomic build / swap ───────────────────────────────────────── + + public function testBuildCreatesRuntimeDirWithCoreAndModuleSymlinks(): void + { + $coreDir = $this->createCorePages(['admin', 'login']); + $moduleA = $this->createModuleWithPages('mod-a', ['mod-a-page']); + $runtimeDir = $this->tmpDir . '/runtime/pages'; + + $builder = new ModuleRuntimePageBuilder(); + $result = $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); + + self::assertSame(2, $result['core_entries']); + self::assertSame(1, $result['module_entries']); + self::assertDirectoryExists($runtimeDir); + self::assertTrue(is_link($runtimeDir . '/admin')); + self::assertTrue(is_link($runtimeDir . '/login')); + self::assertTrue(is_link($runtimeDir . '/mod-a-page')); + } + + public function testBuildWithNoModulesCreatesSymlinkToCore(): void + { + $coreDir = $this->createCorePages(['admin']); + $runtimeDir = $this->tmpDir . '/runtime/pages'; + mkdir(dirname($runtimeDir), 0777, true); + + $builder = new ModuleRuntimePageBuilder(); + $result = $builder->build($runtimeDir, $coreDir, []); + + self::assertSame(0, $result['core_entries']); + self::assertSame(0, $result['module_entries']); + self::assertTrue(is_link($runtimeDir)); + } + + public function testBuildFailureDoesNotLeavePartialRuntimeState(): void + { + $coreDir = $this->createCorePages(['shared-page']); + // Module has a page that collides with core — will cause RuntimeException + $moduleA = $this->createModuleWithPages('mod-a', ['shared-page']); + $runtimeDir = $this->tmpDir . '/runtime/pages'; + + // Pre-populate a valid runtime dir to ensure it survives the failed build + mkdir($runtimeDir, 0777, true); + file_put_contents($runtimeDir . '/sentinel.txt', 'existing'); + + $builder = new ModuleRuntimePageBuilder(); + + try { + $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); + self::fail('Expected RuntimeException for page path collision'); + } catch (RuntimeException $e) { + self::assertStringContainsString('collision', $e->getMessage()); + } + + // The existing runtime dir must still be intact — atomic build + // should not have corrupted it + self::assertDirectoryExists($runtimeDir); + self::assertFileExists($runtimeDir . '/sentinel.txt'); + self::assertSame('existing', file_get_contents($runtimeDir . '/sentinel.txt')); + } + + public function testBuildReplacesExistingRuntimeDir(): void + { + $coreDir = $this->createCorePages(['admin']); + $moduleA = $this->createModuleWithPages('mod-a', ['mod-page']); + $runtimeDir = $this->tmpDir . '/runtime/pages'; + + // First build + $builder = new ModuleRuntimePageBuilder(); + $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); + self::assertTrue(is_link($runtimeDir . '/mod-page')); + + // Second build with different module + $moduleB = $this->createModuleWithPages('mod-b', ['mod-b-page']); + $result = $builder->build($runtimeDir, $coreDir, ['mod-b' => $moduleB]); + + self::assertSame(1, $result['core_entries']); + self::assertSame(1, $result['module_entries']); + self::assertTrue(is_link($runtimeDir . '/mod-b-page')); + // Old module page should be gone + self::assertFalse(is_link($runtimeDir . '/mod-page')); + } + + public function testNoStagingLeftoverOnSuccess(): void + { + $coreDir = $this->createCorePages(['admin']); + $moduleA = $this->createModuleWithPages('mod-a', ['mod-page']); + $runtimeDir = $this->tmpDir . '/runtime/pages'; + + $builder = new ModuleRuntimePageBuilder(); + $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); + + // Staging dir should be cleaned up + $parentDir = dirname($runtimeDir); + $entries = scandir($parentDir); + self::assertNotFalse($entries); + $stagingEntries = array_filter( + $entries, + static fn (string $e): bool => str_starts_with($e, '.pages-staging-'), + ); + self::assertEmpty($stagingEntries, 'Staging directory should be removed after successful build'); + } + + // ── Fingerprint ───────────────────────────────────────────────── + + public function testFingerprintChangesWhenManifestContentChanges(): void + { + $moduleDir = $this->tmpDir . '/modules/mod-a'; + mkdir($moduleDir, 0777, true); + file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); + + $manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir); + $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); + + // Change manifest content + file_put_contents($moduleDir . '/module.php', ' "mod-a", "version" => "2.0.0"];'); + + $fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); + + self::assertNotSame($fp1, $fp2, 'Fingerprint must change when manifest file content changes'); + } + + public function testFingerprintChangesWhenPageEntriesChange(): void + { + $moduleDir = $this->tmpDir . '/modules/mod-a'; + mkdir($moduleDir . '/pages', 0777, true); + file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); + file_put_contents($moduleDir . '/pages/page-a.php', ' 'mod-a'], $moduleDir); + $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); + + // Add a new page entry + file_put_contents($moduleDir . '/pages/page-b.php', ' $manifest]); + + self::assertNotSame($fp1, $fp2, 'Fingerprint must change when page entries change'); + } + + public function testFingerprintStableForSameInputs(): void + { + $moduleDir = $this->tmpDir . '/modules/mod-a'; + mkdir($moduleDir . '/pages', 0777, true); + file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); + file_put_contents($moduleDir . '/pages/page-a.php', ' 'mod-a'], $moduleDir); + $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); + $fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); + + self::assertSame($fp1, $fp2, 'Fingerprint must be deterministic for same inputs'); + } + + public function testFingerprintEmptyForNoModules(): void + { + $fp = ModuleRuntimePageBuilder::expectedFingerprint([]); + self::assertSame('', $fp); + } + + public function testWriteAndReadFingerprintRoundtrip(): void + { + $runtimeDir = $this->tmpDir . '/runtime/pages'; + mkdir(dirname($runtimeDir), 0777, true); + + $moduleDir = $this->tmpDir . '/modules/mod-a'; + mkdir($moduleDir, 0777, true); + file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); + + $manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir); + $modules = ['mod-a' => $manifest]; + + ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules); + + $expected = ModuleRuntimePageBuilder::expectedFingerprint($modules); + $actual = ModuleRuntimePageBuilder::readFingerprint($runtimeDir); + + self::assertSame($expected, $actual); + } + + public function testReadFingerprintReturnsEmptyWhenFileDoesNotExist(): void + { + $runtimeDir = $this->tmpDir . '/runtime/pages'; + self::assertSame('', ModuleRuntimePageBuilder::readFingerprint($runtimeDir)); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function createCorePages(array $entries): string + { + $coreDir = $this->tmpDir . '/pages'; + mkdir($coreDir, 0777, true); + foreach ($entries as $entry) { + mkdir($coreDir . '/' . $entry, 0777, true); + } + return $coreDir; + } + + private function createModuleWithPages(string $id, array $pageEntries): ModuleManifest + { + $moduleDir = $this->tmpDir . '/modules/' . $id; + mkdir($moduleDir . '/pages', 0777, true); + file_put_contents($moduleDir . '/module.php', ' $id], true) . ';'); + foreach ($pageEntries as $entry) { + mkdir($moduleDir . '/pages/' . $entry, 0777, true); + } + return ModuleManifest::fromArray(['id' => $id], $moduleDir); + } + + private function removePath(string $path): void + { + if (!file_exists($path) && !is_link($path)) { + return; + } + if (is_link($path) || is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + $entries = scandir($path); + if ($entries !== false) { + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $this->removePath($path . '/' . $entry); + } + } + @rmdir($path); + } +} diff --git a/web/css/components/app-breadcrumb.css b/web/css/components/app-breadcrumb.css index 5b6d85a..378a683 100644 --- a/web/css/components/app-breadcrumb.css +++ b/web/css/components/app-breadcrumb.css @@ -38,7 +38,7 @@ .app-breadcrumb li + li::before { content: "/"; color: var(--app-muted-color); - font-size: 0.75em; + font-size: 0.75em; /* em-relative sizing — intentional */ margin-inline-end: 0.375rem; pointer-events: none; } @@ -68,7 +68,7 @@ .app-breadcrumb [aria-current="page"] { color: var(--app-color); - font-weight: 500; + font-weight: var(--font-medium); pointer-events: none; } }