test/ci(psr4): add the missing migration regression gates

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.
This commit is contained in:
Divarion-D
2026-06-25 20:57:53 +03:00
parent fd35f00c4d
commit e9bc5de0e4
3 changed files with 210 additions and 3 deletions
+25 -3
View File
@@ -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 `<?`/`<?=` view templates as PHP (prod
# runs with short tags on). Without it no_unused_imports cannot see class usage
# inside short-tag blocks and would wrongly strip still-needed imports.
CS_FLAGS := -d short_open_tag=1
# Check only — fails (exit 8) on any diff. Used in CI.
cs:
@php "$(CS_FIXER)" fix --dry-run --diff --config=.php-cs-fixer.dist.php
@php $(CS_FLAGS) "$(CS_FIXER)" fix --dry-run --diff --config=.php-cs-fixer.dist.php
# Apply fixes in place.
cs-fix:
@php "$(CS_FIXER)" fix --config=.php-cs-fixer.dist.php
@php $(CS_FLAGS) "$(CS_FIXER)" fix --config=.php-cs-fixer.dist.php
# ─── PSR-4 regression gates ─────────────────────────────────────
# Helper: print a variable's resolved value (consumed by CI gate scripts).
print-%:
@echo '$($*)'
# Procedural/view files must import every migrated class they use with a
# top-of-file `use` (PHP `use` is positional outside the top scope).
check-procedural-use:
@php -d short_open_tag=1 tools/ci/check_procedural_use.php
# LB archive must never contain privileged code (security blocker 1).
verify-lb-archive:
@bash tools/ci/verify-lb-archive.sh
# Run every fast PSR-4 gate.
gates: check-procedural-use verify-lb-archive
# ─── Generate deleted_files.txt from git diff ───────────────────
# Usage: make generate_deleted_files [LAST_TAG=v1.2.3]
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* CI gate (plan: «процедурные файлы имеют use для мигрированных классов слоя»).
*
* Procedural / view / entry-point files are NOT namespaced. When they reference a
* migrated class by its short name (`SettingsManager::x()`, `new UserRepository`)
* they must import it with `use XcVm\...\SettingsManager;` — otherwise the short
* name resolves to the (now non-existent) global class and faults at runtime.
* PHPStan does not report most of these files (scanDirectories), so this script
* is the regression guard.
*
* Exit 0 = clean. Exit 1 = at least one procedural file uses a migrated class by
* short name without importing it.
*/
$root = dirname(__DIR__, 2) . '/src/';
$skipDir = ['/vendor/', '/tmp/', '/backups/', '/Modules/tmdb/lib/'];
/** Recursively yield every .php under src/, skipping vendored/runtime trees. */
function phpFiles(string $root, array $skipDir): Generator {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if (substr($f->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);
+55
View File
@@ -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)."