harden: restore gate baseline, split dev/prod PHP profile, atomic module runtime build
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, ModuleManifest> $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<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);
|
||||
|
||||
256
tests/App/Module/ModuleRuntimePageBuilderTest.php
Normal file
256
tests/App/Module/ModuleRuntimePageBuilderTest.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\App\Module;
|
||||
|
||||
use MintyPHP\App\Module\ModuleManifest;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class ModuleRuntimePageBuilderTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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', '<?php return ["id" => "mod-a"];');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
// Change manifest content
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "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', '<?php return ["id" => "mod-a"];');
|
||||
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
// Add a new page entry
|
||||
file_put_contents($moduleDir . '/pages/page-b.php', '<?php');
|
||||
|
||||
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $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', '<?php return ["id" => "mod-a"];');
|
||||
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => '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', '<?php return ["id" => "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', '<?php return ' . var_export(['id' => $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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user