fix(admin): verify the whole SPA build graph, not just the entry

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.
This commit is contained in:
kate522 committed 2026-11-01 13:41:58 +08:00
1 parent df12f51199
commit 324bcfdadc
3 files changed
+131 -12

No files matched your search

+52 -11
View File
@@ -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 {
@@ -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(', ')}`);
});
+1 -1
View File
@@ -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)",