mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-03 04:02:10 +02:00
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:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
@@ -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)."
|
||||
Reference in New Issue
Block a user