diff --git a/src/Admin/Admin.php b/src/Admin/Admin.php index ffb56464..09f6aada 100644 --- a/src/Admin/Admin.php +++ b/src/Admin/Admin.php @@ -145,28 +145,69 @@ class Admin { } /** - * Parse index.js dynamic imports and report missing chunk files. + * Walk the whole build/ module graph and report files a chunk asks for but + * that are not on disk. * - * @return string[] + * Starting from index.js is not enough: a lazily imported page chunk pulls + * in its own deps (shared-api, PageHeader, …), so a deploy that drops any + * of those still leaves the entry consistent while the route 404s at + * runtime. Traversing the graph transitively also catches CSS, which the + * entry-only scan never verified — a missing page stylesheet silently + * ships an unstyled admin screen. + * + * @return string[] Missing relative file names, e.g. "UsersBadge.a1b2c3.js". */ private static function find_missing_admin_chunks(string $build_dir, string $entry_file): array { - $src = file_get_contents($entry_file); - if (!is_string($src) || $src === '') { + if (!is_file($entry_file)) { + return ['index.js']; + } + + $entry_src = file_get_contents($entry_file); + if (!is_string($entry_src) || $entry_src === '') { return ['index.js unreadable']; } - if (!preg_match_all('/\.\/([A-Za-z0-9_.-]+\.js)/', $src, $matches)) { - return []; - } + // Relative specifiers the bundler emits: static `from "./x.js"`, + // dynamic `import("./x.js")`, bare `import "./x.css"` and the + // __vite__mapDeps lookup table. + $specifier = '/\.\/([A-Za-z0-9_.-]+\.(?:js|css))/'; $missing = []; - foreach (array_unique($matches[1]) as $chunk) { - if (!is_file($build_dir . $chunk)) { - $missing[] = $chunk; + $visited = []; + $queue = ['index.js']; + + while ($queue !== []) { + $file = array_shift($queue); + if (isset($visited[$file])) { + continue; + } + $visited[$file] = true; + + $path = $build_dir . $file; + // A .css entry in the graph is a leaf: nothing is imported from it. + if (!is_file($path) || substr($file, -3) !== '.js') { + continue; + } + + $src = file_get_contents($path); + if (!is_string($src) || $src === '') { + continue; + } + + if (!preg_match_all($specifier, $src, $matches)) { + continue; + } + + foreach (array_unique($matches[1]) as $dep) { + if (!is_file($build_dir . $dep)) { + $missing[$dep] = true; + continue; + } + $queue[] = $dep; } } - return $missing; + return array_keys($missing); } public function add_module_type_attribute(string $tag, string $handle): string { diff --git a/tests/admin/admin-build-graph-contract.test.cjs b/tests/admin/admin-build-graph-contract.test.cjs new file mode 100644 index 00000000..eff59f4a --- /dev/null +++ b/tests/admin/admin-build-graph-contract.test.cjs @@ -0,0 +1,78 @@ +'use strict'; + +/** + * 后台 SPA 构建完整性检查契约。 + * + * src/Admin/Admin.php::find_missing_admin_chunks() 必须遍历整张模块图, + * 而不是只扫 index.js:懒加载的页面 chunk 还会拉自己的依赖 + * (shared-api、PageHeader…),只扫入口时这些文件缺失照样"看起来正常", + * 直到用户在后台点开对应菜单才 404。CSS 也必须纳入——入口扫描从来不检查 + * 样式表,缺一个页面样式只会让后台变裸样式,不会有任何告警。 + */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const adminPhp = fs.readFileSync(path.join(root, 'src/Admin/Admin.php'), 'utf8'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +const methodBody = (() => { + const start = adminPhp.indexOf('private static function find_missing_admin_chunks'); + assert.notEqual(start, -1, 'find_missing_admin_chunks() must exist in src/Admin/Admin.php'); + const open = adminPhp.indexOf('{', start); + let depth = 0; + for (let i = open; i < adminPhp.length; i++) { + if (adminPhp[i] === '{') depth++; + else if (adminPhp[i] === '}') { + depth--; + if (depth === 0) return adminPhp.slice(open, i + 1); + } + } + throw new Error('unbalanced braces parsing find_missing_admin_chunks()'); +})(); + +test('the chunk check walks the module graph instead of scanning the entry only', () => { + // A work queue + visited set is what makes the walk transitive. + assert.match(methodBody, /\$queue/, 'must seed a traversal queue'); + assert.match(methodBody, /\$visited/, 'must track visited files to terminate'); + assert.match(methodBody, /while\s*\(/, 'must loop until the queue drains'); + // The dependency of a chunk must itself be re-read and followed. + assert.match(methodBody, /\$queue\[\]\s*=\s*\$dep/, 'discovered deps must be queued for traversal'); +}); + +test('the chunk check covers CSS as well as JS', () => { + // A missing page stylesheet used to be invisible; the specifier must match both. + assert.match(methodBody, /\.\(\?:js\|css\)/, 'specifier pattern must match .js and .css'); +}); + +test('a missing entry file is reported rather than silently passing', () => { + assert.match(methodBody, /!is_file\(\$entry_file\)/, 'must guard an absent index.js'); +}); + +test('the real build graph is complete', () => { + // Guard against committing a build/ whose graph does not resolve. + const buildDir = path.join(root, 'assets/admin/build'); + if (!fs.existsSync(path.join(buildDir, 'index.js'))) { + // build/ is gitignored and deploy-only; nothing to assert when absent. + return; + } + + const files = new Set(fs.readdirSync(buildDir)); + const referenced = new Set(); + const walk = (file) => { + if (referenced.has(file)) return; + referenced.add(file); + if (!file.endsWith('.js') || !files.has(file)) return; + const src = fs.readFileSync(path.join(buildDir, file), 'utf8'); + for (const m of src.matchAll(/\.\/([A-Za-z0-9_.-]+\.(?:js|css))/g)) { + walk(m[1]); + } + }; + walk('index.js'); + + const missing = [...referenced].filter((f) => !files.has(f)); + assert.deepEqual(missing, [], `build/ is missing: ${missing.join(', ')}`); +}); diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index e557c0ad..5c0deb15 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-11-01T04:11:06+00:00", + "generated_at": "2026-11-01T05:33:16+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",