From e9bc5de0e4f3d033fb2f05fbc53e8b07c8a684e3 Mon Sep 17 00:00:00 2001 From: Divarion-D Date: Thu, 25 Jun 2026 20:57:53 +0300 Subject: [PATCH] test/ci(psr4): add the missing migration regression gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the automated gates the PSR-4 plan specified but that were verified only manually per phase: PHPUnit (run by the existing test job): - AutoloadOrderTest — Composer autoloader registered; the retired XC_Autoloader scanner is NOT in the SPL stack; init() is a no-op; no igbinary class-map cache is written. - BootstrapPathsTest — no live require/include points at a lowercase renamed dir (the Фаза-1 grep-gate, as a runtime guard). - ConsoleDiscoveryTest — console.php FQCN discovery resolves every Cli command file and the concrete command surface stays stable. Shell gates (new 'PSR-4 Regression Gates' CI job + 'make gates'): - tools/ci/check_procedural_use.php — procedural/view files must import every migrated class they use, with the `use` ABOVE the usage (PHP imports are positional). Runs with short_open_tag=1 so short-tag templates are analysed. - tools/ci/verify-lb-archive.sh — reproduces the Makefile LB file selection from the real LB_* vars and asserts no privileged tree (Admin/Reseller/Player controllers, Domain/User|Device, Cli/CronJobs|Commands) ships to an LB node (security blocker 1). Makefile: cs/cs-fix now force short_open_tag=1; new print-%, check-procedural-use, verify-lb-archive and aggregate `gates` targets. --- Makefile | 28 ++++++- tools/ci/check_procedural_use.php | 130 ++++++++++++++++++++++++++++++ tools/ci/verify-lb-archive.sh | 55 +++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 tools/ci/check_procedural_use.php create mode 100644 tools/ci/verify-lb-archive.sh diff --git a/Makefile b/Makefile index 07e36d42..4703feae 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ EXCLUDE_ARGS := $(addprefix --exclude=,$(EXCLUDES)) .PHONY: new lb main lb_copy_files main_copy_files set_permissions create_archive \ lb_archive_move main_archive_move main_install_archive clean \ delete_files_list lb_delete_files_list generate_deleted_files syntax_check \ - phpstan phpstan-baseline cs cs-fix + phpstan phpstan-baseline cs cs-fix check-procedural-use verify-lb-archive gates # ─── Syntax check ─────────────────────────────────────────────── syntax_check: @@ -108,13 +108,35 @@ phpstan-baseline: # .php-cs-fixer.dist.php. CS_FIXER := src/vendor/bin/php-cs-fixer +# short_open_tag=1 so the fixer analyses `getFilename(), -4) !== '.php') { + continue; + } + $p = str_replace('\\', '/', $f->getPathname()); + foreach ($skipDir as $s) { + if (strpos($p, $s) !== false) { + continue 2; + } + } + yield $f->getPathname(); + } +} + +// 1) Build short-name -> FQCN map of every migrated (namespaced XcVm\) class. +// Keep only UNIQUE short names to avoid ambiguous cross-namespace matches. +$byShort = []; +foreach (phpFiles($root, $skipDir) as $file) { + $src = file_get_contents($file); + if (!preg_match('/^namespace\s+(XcVm\\\\[^;]+);/m', $src, $nm)) { + continue; + } + if (preg_match('/^(?:abstract\s+|final\s+)?(?:class|interface|trait|enum)\s+([A-Za-z0-9_]+)/m', $src, $cm)) { + $byShort[$cm[1]][] = $nm[1] . '\\' . $cm[1]; + } +} +$unique = []; +foreach ($byShort as $short => $fqcns) { + if (count(array_unique($fqcns)) === 1) { + $unique[$short] = $fqcns[0]; + } +} + +// 2) Scan procedural files (no namespace) for short-name class use without import. +$violations = []; +foreach (phpFiles($root, $skipDir) as $file) { + $src = file_get_contents($file); + if (preg_match('/^namespace\s+/m', $src)) { + continue; // namespaced class file — covered by PHPStan + } + + // Tokenize so comments/strings never count as code, and track LINE numbers: + // PHP `use` is positional outside the top scope — an import only aliases code + // that textually follows it, so a class used *before* its `use` faults even + // though the import is "present". + $tokens = token_get_all($src); + $importLine = []; // short name => earliest import line + $refs = []; // [short, line] for each ::/new reference + $n = count($tokens); + for ($i = 0; $i < $n; $i++) { + $t = $tokens[$i]; + if (!is_array($t)) { + continue; + } + if ($t[0] === T_USE) { + $seg = null; + for ($j = $i + 1; $j < $n; $j++) { + if (is_array($tokens[$j]) && in_array($tokens[$j][0], + [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) { + $parts = explode('\\', $tokens[$j][1]); + $seg = end($parts); + } elseif ($tokens[$j] === ';' || $tokens[$j] === '{') { + break; + } + } + if ($seg !== null && !isset($importLine[$seg])) { + $importLine[$seg] = $t[2]; + } + continue; + } + if ($t[0] === T_STRING && $t[1][0] >= 'A' && $t[1][0] <= 'Z') { + $prevType = ($i > 0 && is_array($tokens[$i - 1])) ? $tokens[$i - 1][0] : null; + if (in_array($prevType, [T_NS_SEPARATOR, T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_NAME_QUALIFIED], true)) { + continue; + } + $nextType = ($i + 1 < $n && is_array($tokens[$i + 1])) ? $tokens[$i + 1][0] : null; + if ($nextType === T_DOUBLE_COLON || $prevType === T_NEW) { + $refs[] = [$t[1], $t[2]]; + } + } + } + + $rel = substr($file, strlen($root)); + $reported = []; + foreach ($refs as [$short, $line]) { + if (!isset($unique[$short]) || isset($reported[$short])) { + continue; + } + if (!isset($importLine[$short])) { + $reported[$short] = true; + $violations[] = "{$rel}:{$line}: uses {$short} without `use` (migrated → {$unique[$short]})"; + } elseif ($importLine[$short] > $line) { + $reported[$short] = true; + $violations[] = "{$rel}:{$line}: uses {$short} before its `use` on line {$importLine[$short]} (positional alias fault)"; + } + } +} + +if ($violations) { + fwrite(STDERR, "Procedural files missing `use` for migrated classes:\n"); + sort($violations); + fwrite(STDERR, ' ' . implode("\n ", $violations) . "\n"); + exit(1); +} +echo "OK: " . count($unique) . " migrated classes, no procedural short-name use without import.\n"; +exit(0); diff --git a/tools/ci/verify-lb-archive.sh b/tools/ci/verify-lb-archive.sh new file mode 100644 index 00000000..c9582504 --- /dev/null +++ b/tools/ci/verify-lb-archive.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Security gate (plan blocker 1): the LoadBalancer archive must NOT contain +# privileged code. The LB build copies LB_DIRS and then removes LB_DIRS_TO_REMOVE +# / LB_FILES_TO_REMOVE. After the PascalCase rename (Фаза 1) a stale lowercase +# remove path would silently miss, leaking Admin/Reseller controllers, the +# user/device domain and cron jobs to an internet-facing DMZ node. +# +# This reproduces the Makefile's LB file selection from the real LB_* variables +# (no tarball needed) and asserts the sensitive trees are absent. +set -euo pipefail +cd "$(dirname "$0")/../.." + +LB_DIRS=$(make -s print-LB_DIRS) +RM_DIRS=$(make -s print-LB_DIRS_TO_REMOVE) +RM_FILES=$(make -s print-LB_FILES_TO_REMOVE) + +# Shipped manifest: tracked files under LB_DIRS, paths relative to src/. +manifest=$(for d in $LB_DIRS; do git ls-files "src/$d" 2>/dev/null; done | sed 's#^src/##') + +# Apply directory removals. +if [ -n "${RM_DIRS// }" ]; then + rm_re=$(printf '%s' "$RM_DIRS" | tr -s ' ' '|') + manifest=$(printf '%s\n' "$manifest" | grep -Ev "^(${rm_re})/" || true) +fi +# Apply file removals. +for f in $RM_FILES; do + manifest=$(printf '%s\n' "$manifest" | grep -vxF "$f" || true) +done + +# Sensitive trees that must never reach an LB node. +SENSITIVE=( + "Public/Controllers/Admin" + "Public/Controllers/Reseller" + "Public/Controllers/Player" + "Domain/User" + "Domain/Device" + "Cli/CronJobs" + "Cli/Commands" +) + +fail=0 +for s in "${SENSITIVE[@]}"; do + if printf '%s\n' "$manifest" | grep -qE "^${s}/"; then + echo "LEAK: '${s}/' would ship to the LB archive (privileged code)." + printf '%s\n' "$manifest" | grep -E "^${s}/" | sed 's/^/ /' | head -5 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo "FAIL: LB archive contains privileged code — check Makefile LB_DIRS_TO_REMOVE/LB_FILES_TO_REMOVE." + exit 1 +fi +echo "OK: LB manifest excludes all privileged trees ($(printf '%s\n' "$manifest" | grep -c . ) files shipped)."