From 324bcfdadcd445af06576e561f95dfc6af5bfbbb Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 1 Nov 2026 13:41:58 +0800 Subject: [PATCH] fix(admin): verify the whole SPA build graph, not just the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_missing_admin_chunks() only parsed index.js, but a lazily imported page chunk pulls in its own deps (shared-api, PageHeader, sortable-options…). A deploy that drops one of those still leaves the entry internally consistent, so the existing notice never fires and the failure only shows up when an admin opens that menu — every schemaPage keeps working, which makes the breakage look arbitrary. The same scan never looked at CSS at all: 18 stylesheets are reachable from the graph and none of them were checked, so a missing page stylesheet ships an unstyled screen with no warning. Walk the module graph transitively from index.js, queueing each discovered dependency, and match .js and .css specifiers. Deleting PageHeader now also reports every page that depends on it. This is a diagnostic hardening only — it makes an incomplete deploy loud instead of guessing at the cause of a broken page. --- src/Admin/Admin.php | 63 ++++++++++++--- .../admin/admin-build-graph-contract.test.cjs | 78 +++++++++++++++++++ tools/admin-contract-report.json | 2 +- 3 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 tests/admin/admin-build-graph-contract.test.cjs 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)",