| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- #!/usr/bin/env node
- /**
- * scripts/verify-modules.mjs —— 模块图爬取(任务书第 5 节 A-10)。可重复跑。
- *
- * 不看首页 200;从 index.html 出发,跟随**整条加载链**:
- * 1. index.html:<base href> 存在;内联 importmap 可解析
- * 2. 入口 <script type="module" src> → 递归抓静态 import(含 `import '...'`、`import x from`、
- * 动态 `import('...')` 的**字面量**参数、`export ... from`)
- * 3. 裸标识符(lit / @lit-labs/signals …)按 importmap 解析(含 `xxx/` 前缀映射),再递归
- * 4. 每个请求断言:HTTP 200 + Content-Type 为 text/javascript
- * 5. 断言 lit 系每个包只命中一个版本路径(单实例)
- * 6. 深链接(含一个不存在的路径):200、无重定向、返回的就是 index.html(CDN 回退规则)
- *
- * 用法:
- * node scripts/verify-modules.mjs http://127.0.0.1:4173/
- * node scripts/verify-modules.mjs http://127.0.0.1:4173/kangshifu/dev/inno-board/ # 子路径
- * node scripts/verify-modules.mjs --serve # 自动起本地服务器(读 index.html 的 base href 决定 mount)
- * --json 输出机器可读结果
- * 退出码:任一断言失败 = 1
- */
- import { join, dirname } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
- const args = process.argv.slice(2);
- const wantJson = args.includes('--json');
- const positional = args.filter((a) => !a.startsWith('--'));
- const autoserve = args.includes('--serve');
- async function discoverOrigin() {
- // 常见本地端口
- for (const port of positional.length ? [] : [4173, 4174, 8080, 8000, 3000]) {
- try {
- const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(800) });
- if (r.ok) return `http://127.0.0.1:${port}/`;
- } catch { /* try next */ }
- }
- return null;
- }
- const failures = [];
- const results = [];
- function record(name, ok, detail = '') {
- results.push({ name, ok, detail });
- if (!ok) failures.push({ name, detail });
- }
- /** 只抓源码里的字面量 import 说明符(不执行代码) */
- function staticSpecifiers(code) {
- const specs = new Set();
- const patterns = [
- /\bimport\s+[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g,
- /\bexport\s+[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g,
- /\bimport\s*['"]([^'"]+)['"]/g,
- /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
- ];
- for (const re of patterns) for (const m of code.matchAll(re)) specs.add(m[1]);
- return [...specs];
- }
- function stripComments(code) {
- return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
- }
- function resolveSpecifier(spec, baseUrl, imports) {
- if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) return new URL(spec, baseUrl).href;
- if (/^https?:/.test(spec)) return spec;
- const im = imports || {};
- if (im[spec]) return new URL(im[spec], baseUrl).href;
- const prefix = Object.keys(im).filter((k) => k.endsWith('/') && spec.startsWith(k)).sort((a, b) => b.length - a.length)[0];
- if (prefix) return new URL(im[prefix] + spec.slice(prefix.length), baseUrl).href;
- return null; // 未映射的裸标识符 → 浏览器会直接报错
- }
- const JS_TYPES = /^(text|application)\/javascript\b/;
- async function crawl(startUrl) {
- const seen = new Map(); // url → { status, type, via }
- const queue = [{ url: startUrl, via: '入口' }];
- const unresolved = [];
- while (queue.length) {
- const { url, via } = queue.shift();
- if (seen.has(url)) continue;
- let res;
- try {
- res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20000) });
- } catch (e) {
- seen.set(url, { status: 0, type: '', via, error: e.message });
- continue;
- }
- const type = res.headers.get('content-type') || '';
- const status = res.status;
- if (status !== 200 || !JS_TYPES.test(type)) {
- seen.set(url, { status, type, via });
- continue;
- }
- const code = stripComments(await res.text());
- seen.set(url, { status, type, via, specs: staticSpecifiers(code) });
- for (const spec of staticSpecifiers(code)) {
- const next = resolveSpecifier(spec, url, importsFor(url));
- if (!next) { unresolved.push({ from: url, spec }); continue; }
- if (!seen.has(next)) queue.push({ url: next, via: `${spec}` });
- }
- }
- return { seen, unresolved };
- }
- /* ---------- 主流程 ---------- */
- let base = positional[0];
- if (!base && autoserve) {
- const { writeConfig } = await import('./config.mjs');
- const want = args.includes('--mount') ? args[args.indexOf('--mount') + 1] : '/';
- const env = want === '/' ? 'local' : 'local-sub';
- const { prefix } = writeConfig({ env });
- const mount = prefix.replace(/\/+$/, '');
- const { spawn } = await import('node:child_process');
- const port = Number(process.env.VERIFY_PORT || 4180);
- const child = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', mount || '/'], { stdio: 'ignore' });
- process.on('exit', () => child.kill());
- base = `http://127.0.0.1:${port}${prefix}`;
- await new Promise((r) => setTimeout(r, 700));
- }
- if (!base) base = await discoverOrigin();
- if (!base) {
- console.error('用法:node scripts/verify-modules.mjs <baseUrl> (或 --serve 自动起本地服务器)');
- process.exit(2);
- }
- if (!base.endsWith('/')) base += '/';
- // 1) index.html + base + importmap
- const indexRes = await fetch(base, { redirect: 'manual' });
- const indexHtml = await indexRes.text();
- record(`index.html 200(${base})`, indexRes.status === 200, `status=${indexRes.status}`);
- const baseHref = indexHtml.match(/<base href="([^"]+)"/)?.[1] || null;
- record('<base href> 存在', Boolean(baseHref), baseHref ? `= ${baseHref}` : '缺失');
- const mapRaw = indexHtml.match(/<script type="importmap">([\s\S]*?)<\/script>/)?.[1];
- let importmap = null;
- try { importmap = JSON.parse(mapRaw); } catch { /* ignore */ }
- record('importmap 内联且可解析', Boolean(importmap?.imports), importmap ? `${Object.keys(importmap.imports).length} 条映射` : '缺失/外链');
- const entry = indexHtml.match(/<script type="module" src="([^"]+)"/)?.[1];
- record('入口 <script type="module"> 存在', Boolean(entry), entry || '缺失');
- // 解析 importmap 相对路径(浏览器按文档 URL 解析)
- const imports = importmap ? Object.fromEntries(Object.entries(importmap.imports).map(([k, v]) => [k, new URL(v, base).href])) : {};
- function importsFor() { return imports; }
- if (entry) {
- const entryUrl = new URL(entry, base).href;
- const { seen, unresolved } = await crawl(entryUrl);
- const moduleUrls = [...seen.keys()];
- const bad = [...seen.entries()].filter(([, v]) => v.status !== 200 || !JS_TYPES.test(v.type || ''));
- record(`模块图共 ${seen.size} 个请求全部 200 + text/javascript`, bad.length === 0,
- bad.map(([u, v]) => `${v.status || 'ERR'} ${v.type} ← ${v.via}\n ${u}`).join('\n'));
- record('没有无法解析的裸标识符', unresolved.length === 0, unresolved.map((u) => `${u.spec} ← ${u.from}`).join('\n'));
- // lit 单实例:供应商文件只应来自一个版本目录
- const vendorDirs = new Set();
- for (const u of moduleUrls) {
- const m = u.match(/lib\/vendor\/([^/]+)\/(v[^/]+)\//);
- if (m) vendorDirs.add(`${m[1]}@${m[2]}`);
- }
- const perPkg = new Map();
- for (const v of vendorDirs) {
- const m2 = v.match(/^(.+)@(v.+)$/);
- const [pkg, ver] = m2 ? [m2[1], m2[2]] : [v, ''];
- if (!perPkg.has(pkg)) perPkg.set(pkg, new Set());
- perPkg.get(pkg).add(ver);
- }
- const multi = [...perPkg.entries()].filter(([, s]) => s.size > 1);
- record(`lit 单实例(模块图里 ${vendorDirs.size} 个 vendor 包,每包一个版本)`, multi.length === 0,
- multi.map(([k, s]) => `${k}: ${[...s].join(', ')}`).join('\n'));
- // importmap 每条目标可达
- const mapBad = [];
- for (const [k, v] of Object.entries(importmap?.imports || {})) {
- if (k.endsWith('/')) {
- // 前缀映射:抽一个真实文件验证(该目录下 package.json 的入口)
- const dirUrl = new URL(v, base).href;
- // 前缀映射只需「下游有真实文件」;dist/ 这类子目录不一定自带 package.json
- const probe = new URL(v.endsWith('/') ? v + 'index.js' : v, base).href;
- const probeRes = await fetch(probe).catch(() => null);
- if (probeRes?.status === 200) continue;
- const pkgRes = await fetch(new URL('package.json', dirUrl)).catch(() => null);
- if (!pkgRes || pkgRes.status !== 200) { mapBad.push(`${k} → ${dirUrl} 目录不可达`); continue; }
- const pkg = await pkgRes.json().catch(() => ({}));
- const main = typeof pkg.exports?.['.'] === 'string' ? pkg.exports['.'] : pkg.exports?.['.']?.default || pkg.main;
- if (main) {
- const r = await fetch(new URL(main, dirUrl));
- if (r.status !== 200) mapBad.push(`${k} → ${main} ${r.status}`);
- }
- continue;
- }
- const r = await fetch(new URL(v, base)).catch(() => null);
- if (!r || r.status !== 200) mapBad.push(`${k} → ${v} ${r?.status}`);
- }
- record('importmap 每条目标可达', mapBad.length === 0, mapBad.join('\n'));
- if (wantJson) {
- console.log(JSON.stringify({ base, moduleCount: seen.size, modules: moduleUrls, failures, results }, null, 2));
- } else {
- for (const u of moduleUrls.sort()) console.log(` · ${u.replace(base, './')}`);
- }
- }
- // 3) 深链接回退:存在的路由与不存在的路径都要 200 且不被重定向
- const deep = ['pool', 'report', 'ledger', 'members', 'stages', 'does-not-exist/xyz'];
- const deepBad = [];
- for (const p of deep) {
- const u = new URL(p, base);
- const r = await fetch(u, { redirect: 'manual' });
- const body = await r.text();
- const looksLikeIndex = body.includes('<kf-app') && body.includes('type="module"');
- const redirected = r.redirected || (r.status >= 300 && r.status < 400);
- if (r.status !== 200 || redirected || !looksLikeIndex) deepBad.push(`${p}: status=${r.status} redirect=${redirected} index=${looksLikeIndex}`);
- }
- record(`深链接 ${deep.length} 条(含不存在路径)返回 200 index.html、无重定向`, deepBad.length === 0, deepBad.join('\n'));
- if (!wantJson) {
- console.log('');
- for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${!r.ok && r.detail ? `\n ${r.detail.split('\n').join('\n ')}` : ''}`);
- console.log(`\n${failures.length === 0 ? 'MODULE GRAPH OK' : `MODULE GRAPH FAILED(${failures.length})`}`);
- }
- process.exit(failures.length ? 1 : 0);
|