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",
|
"id": "QG-006",
|
||||||
"type": "fast",
|
"type": "fast",
|
||||||
"name": "PHP Style Check",
|
"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",
|
"id": "QG-007",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen.
|
|||||||
|
|
||||||
## Verbindliche Regeln
|
## 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.
|
2. MUST `composer cs:fix` nur bewusst und mit Diff-Kontrolle ausfuehren.
|
||||||
3. MUST Style-Check vor Merge gruen halten (lokal und optional CI).
|
3. MUST Style-Check vor Merge gruen halten (lokal und optional CI).
|
||||||
4. MUST nach groesseren Fixes mindestens `phpunit` und `phpstan` laufen lassen.
|
4. MUST nach groesseren Fixes mindestens `phpunit` und `phpstan` laufen lassen.
|
||||||
@@ -25,7 +25,7 @@ Coding-Style pruefbar, reproduzierbar und regressionsarm machen.
|
|||||||
|
|
||||||
## Workflow
|
## 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`.
|
2. Falls noetig fixen: `composer cs:fix`.
|
||||||
3. Diff fokussiert pruefen (Imports, Braces, Nebenwirkungen).
|
3. Diff fokussiert pruefen (Imports, Braces, Nebenwirkungen).
|
||||||
4. Qualitaetsgates laufen lassen.
|
4. Qualitaetsgates laufen lassen.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CI soll nur pruefen, nicht formatieren.
|
|||||||
## Minimaler Check
|
## Minimaler Check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer cs:check
|
vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
## Erwartung
|
## Erwartung
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer cs:check
|
vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff --verbose
|
||||||
composer cs:fix
|
composer cs:fix
|
||||||
```
|
```
|
||||||
|
|
||||||
## Praktikabler Ablauf
|
## 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`.
|
2. Danach `cs:fix`.
|
||||||
3. Nur relevante Diffs uebernehmen, grosse Misch-Diffs vermeiden.
|
3. Nur relevante Diffs uebernehmen, grosse Misch-Diffs vermeiden.
|
||||||
4. Anschliessend:
|
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-001 | PHPUnit | `vendor/bin/phpunit` |
|
||||||
| QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` |
|
| QG-002 | PHPStan level 5 | `vendor/bin/phpstan analyse -c phpstan.neon` |
|
||||||
| QG-003 | Architecture Contract | `vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php` |
|
| 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.
|
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):
|
CI-Check (ohne Dateien zu aendern):
|
||||||
|
|
||||||
```bash
|
```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:
|
Lokal automatisch formatieren:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
target: prod
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -23,7 +24,6 @@ services:
|
|||||||
APP_DEBUG: "0"
|
APP_DEBUG: "0"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www
|
- ./:/var/www
|
||||||
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- memcached
|
- memcached
|
||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
target: prod
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -40,7 +41,6 @@ services:
|
|||||||
APP_DEBUG: "0"
|
APP_DEBUG: "0"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www
|
- ./:/var/www
|
||||||
- ./docker/php/php.prod.ini:/usr/local/etc/php/conf.d/zzz-minty-dev.ini:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- memcached
|
- memcached
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
target: dev
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -25,6 +26,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
|
target: dev
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM php:8.5-fpm
|
FROM php:8.5-fpm AS base
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
@@ -11,14 +11,27 @@ RUN apt-get update \
|
|||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
libpng-dev \
|
libpng-dev \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
&& pecl install memcached xdebug \
|
&& pecl install memcached \
|
||||||
&& docker-php-ext-enable memcached xdebug \
|
&& docker-php-ext-enable memcached \
|
||||||
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
&& docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||||
&& docker-php-ext-install pdo_mysql mysqli gd zip \
|
&& docker-php-ext-install pdo_mysql mysqli gd zip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
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/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
|
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.
|
* 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
|
final class ModuleRuntimePageBuilder
|
||||||
{
|
{
|
||||||
@@ -28,65 +32,21 @@ final class ModuleRuntimePageBuilder
|
|||||||
return ['core_entries' => 0, 'module_entries' => 0];
|
return ['core_entries' => 0, 'module_entries' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_link($runtimeDir)) {
|
// ── Staging: build in a sibling directory, swap on success ────
|
||||||
unlink($runtimeDir);
|
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
|
||||||
}
|
$this->ensureCleanDirectory($stagingDir);
|
||||||
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
|
|
||||||
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
|
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);
|
return $result;
|
||||||
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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||||
@@ -108,14 +68,36 @@ final class ModuleRuntimePageBuilder
|
|||||||
/**
|
/**
|
||||||
* Compute the expected fingerprint for a set of modules.
|
* 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
|
* @param array<string, ModuleManifest> $modules
|
||||||
*/
|
*/
|
||||||
public static function expectedFingerprint(array $modules): string
|
public static function expectedFingerprint(array $modules): string
|
||||||
{
|
{
|
||||||
$ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules);
|
$parts = [];
|
||||||
sort($ids);
|
|
||||||
|
|
||||||
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));
|
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
|
private function removeDirectoryContents(string $dir): void
|
||||||
{
|
{
|
||||||
$entries = scandir($dir);
|
$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 {
|
.app-breadcrumb li + li::before {
|
||||||
content: "/";
|
content: "/";
|
||||||
color: var(--app-muted-color);
|
color: var(--app-muted-color);
|
||||||
font-size: 0.75em;
|
font-size: 0.75em; /* em-relative sizing — intentional */
|
||||||
margin-inline-end: 0.375rem;
|
margin-inline-end: 0.375rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
.app-breadcrumb [aria-current="page"] {
|
.app-breadcrumb [aria-current="page"] {
|
||||||
color: var(--app-color);
|
color: var(--app-color);
|
||||||
font-weight: 500;
|
font-weight: var(--font-medium);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user