verify-modules.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/verify-modules.mjs —— 模块图爬取(任务书第 5 节 A-10)。可重复跑。
  4. *
  5. * 不看首页 200;从 index.html 出发,跟随**整条加载链**:
  6. * 1. index.html:<base href> 存在;内联 importmap 可解析
  7. * 2. 入口 <script type="module" src> → 递归抓静态 import(含 `import '...'`、`import x from`、
  8. * 动态 `import('...')` 的**字面量**参数、`export ... from`)
  9. * 3. 裸标识符(lit / @lit-labs/signals …)按 importmap 解析(含 `xxx/` 前缀映射),再递归
  10. * 4. 每个请求断言:HTTP 200 + Content-Type 为 text/javascript
  11. * 5. 断言 lit 系每个包只命中一个版本路径(单实例)
  12. * 6. 深链接(含一个不存在的路径):200、无重定向、返回的就是 index.html(CDN 回退规则)
  13. *
  14. * 用法:
  15. * node scripts/verify-modules.mjs http://127.0.0.1:4173/
  16. * node scripts/verify-modules.mjs http://127.0.0.1:4173/kangshifu/dev/inno-board/ # 子路径
  17. * node scripts/verify-modules.mjs --serve # 自动起本地服务器(读 index.html 的 base href 决定 mount)
  18. * --json 输出机器可读结果
  19. * 退出码:任一断言失败 = 1
  20. */
  21. import { join, dirname } from 'node:path';
  22. import { fileURLToPath } from 'node:url';
  23. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  24. const args = process.argv.slice(2);
  25. const wantJson = args.includes('--json');
  26. const positional = args.filter((a) => !a.startsWith('--'));
  27. const autoserve = args.includes('--serve');
  28. async function discoverOrigin() {
  29. // 常见本地端口
  30. for (const port of positional.length ? [] : [4173, 4174, 8080, 8000, 3000]) {
  31. try {
  32. const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(800) });
  33. if (r.ok) return `http://127.0.0.1:${port}/`;
  34. } catch { /* try next */ }
  35. }
  36. return null;
  37. }
  38. const failures = [];
  39. const results = [];
  40. function record(name, ok, detail = '') {
  41. results.push({ name, ok, detail });
  42. if (!ok) failures.push({ name, detail });
  43. }
  44. /** 只抓源码里的字面量 import 说明符(不执行代码) */
  45. function staticSpecifiers(code) {
  46. const specs = new Set();
  47. const patterns = [
  48. /\bimport\s+[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g,
  49. /\bexport\s+[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g,
  50. /\bimport\s*['"]([^'"]+)['"]/g,
  51. /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
  52. ];
  53. for (const re of patterns) for (const m of code.matchAll(re)) specs.add(m[1]);
  54. return [...specs];
  55. }
  56. function stripComments(code) {
  57. return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
  58. }
  59. function resolveSpecifier(spec, baseUrl, imports) {
  60. if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/')) return new URL(spec, baseUrl).href;
  61. if (/^https?:/.test(spec)) return spec;
  62. const im = imports || {};
  63. if (im[spec]) return new URL(im[spec], baseUrl).href;
  64. const prefix = Object.keys(im).filter((k) => k.endsWith('/') && spec.startsWith(k)).sort((a, b) => b.length - a.length)[0];
  65. if (prefix) return new URL(im[prefix] + spec.slice(prefix.length), baseUrl).href;
  66. return null; // 未映射的裸标识符 → 浏览器会直接报错
  67. }
  68. const JS_TYPES = /^(text|application)\/javascript\b/;
  69. async function crawl(startUrl) {
  70. const seen = new Map(); // url → { status, type, via }
  71. const queue = [{ url: startUrl, via: '入口' }];
  72. const unresolved = [];
  73. while (queue.length) {
  74. const { url, via } = queue.shift();
  75. if (seen.has(url)) continue;
  76. let res;
  77. try {
  78. res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20000) });
  79. } catch (e) {
  80. seen.set(url, { status: 0, type: '', via, error: e.message });
  81. continue;
  82. }
  83. const type = res.headers.get('content-type') || '';
  84. const status = res.status;
  85. if (status !== 200 || !JS_TYPES.test(type)) {
  86. seen.set(url, { status, type, via });
  87. continue;
  88. }
  89. const code = stripComments(await res.text());
  90. seen.set(url, { status, type, via, specs: staticSpecifiers(code) });
  91. for (const spec of staticSpecifiers(code)) {
  92. const next = resolveSpecifier(spec, url, importsFor(url));
  93. if (!next) { unresolved.push({ from: url, spec }); continue; }
  94. if (!seen.has(next)) queue.push({ url: next, via: `${spec}` });
  95. }
  96. }
  97. return { seen, unresolved };
  98. }
  99. /* ---------- 主流程 ---------- */
  100. let base = positional[0];
  101. if (!base && autoserve) {
  102. const { writeConfig } = await import('./config.mjs');
  103. const want = args.includes('--mount') ? args[args.indexOf('--mount') + 1] : '/';
  104. const env = want === '/' ? 'local' : 'local-sub';
  105. const { prefix } = writeConfig({ env });
  106. const mount = prefix.replace(/\/+$/, '');
  107. const { spawn } = await import('node:child_process');
  108. const port = Number(process.env.VERIFY_PORT || 4180);
  109. const child = spawn(process.execPath, [join(ROOT, 'src/dev/serve.mjs'), '--port', String(port), '--mount', mount || '/'], { stdio: 'ignore' });
  110. process.on('exit', () => child.kill());
  111. base = `http://127.0.0.1:${port}${prefix}`;
  112. await new Promise((r) => setTimeout(r, 700));
  113. }
  114. if (!base) base = await discoverOrigin();
  115. if (!base) {
  116. console.error('用法:node scripts/verify-modules.mjs <baseUrl> (或 --serve 自动起本地服务器)');
  117. process.exit(2);
  118. }
  119. if (!base.endsWith('/')) base += '/';
  120. // 1) index.html + base + importmap
  121. const indexRes = await fetch(base, { redirect: 'manual' });
  122. const indexHtml = await indexRes.text();
  123. record(`index.html 200(${base})`, indexRes.status === 200, `status=${indexRes.status}`);
  124. const baseHref = indexHtml.match(/<base href="([^"]+)"/)?.[1] || null;
  125. record('<base href> 存在', Boolean(baseHref), baseHref ? `= ${baseHref}` : '缺失');
  126. const mapRaw = indexHtml.match(/<script type="importmap">([\s\S]*?)<\/script>/)?.[1];
  127. let importmap = null;
  128. try { importmap = JSON.parse(mapRaw); } catch { /* ignore */ }
  129. record('importmap 内联且可解析', Boolean(importmap?.imports), importmap ? `${Object.keys(importmap.imports).length} 条映射` : '缺失/外链');
  130. const entry = indexHtml.match(/<script type="module" src="([^"]+)"/)?.[1];
  131. record('入口 <script type="module"> 存在', Boolean(entry), entry || '缺失');
  132. // 解析 importmap 相对路径(浏览器按文档 URL 解析)
  133. const imports = importmap ? Object.fromEntries(Object.entries(importmap.imports).map(([k, v]) => [k, new URL(v, base).href])) : {};
  134. function importsFor() { return imports; }
  135. if (entry) {
  136. const entryUrl = new URL(entry, base).href;
  137. const { seen, unresolved } = await crawl(entryUrl);
  138. const moduleUrls = [...seen.keys()];
  139. const bad = [...seen.entries()].filter(([, v]) => v.status !== 200 || !JS_TYPES.test(v.type || ''));
  140. record(`模块图共 ${seen.size} 个请求全部 200 + text/javascript`, bad.length === 0,
  141. bad.map(([u, v]) => `${v.status || 'ERR'} ${v.type} ← ${v.via}\n ${u}`).join('\n'));
  142. record('没有无法解析的裸标识符', unresolved.length === 0, unresolved.map((u) => `${u.spec} ← ${u.from}`).join('\n'));
  143. // lit 单实例:供应商文件只应来自一个版本目录
  144. const vendorDirs = new Set();
  145. for (const u of moduleUrls) {
  146. const m = u.match(/lib\/vendor\/([^/]+)\/(v[^/]+)\//);
  147. if (m) vendorDirs.add(`${m[1]}@${m[2]}`);
  148. }
  149. const perPkg = new Map();
  150. for (const v of vendorDirs) {
  151. const m2 = v.match(/^(.+)@(v.+)$/);
  152. const [pkg, ver] = m2 ? [m2[1], m2[2]] : [v, ''];
  153. if (!perPkg.has(pkg)) perPkg.set(pkg, new Set());
  154. perPkg.get(pkg).add(ver);
  155. }
  156. const multi = [...perPkg.entries()].filter(([, s]) => s.size > 1);
  157. record(`lit 单实例(模块图里 ${vendorDirs.size} 个 vendor 包,每包一个版本)`, multi.length === 0,
  158. multi.map(([k, s]) => `${k}: ${[...s].join(', ')}`).join('\n'));
  159. // importmap 每条目标可达
  160. const mapBad = [];
  161. for (const [k, v] of Object.entries(importmap?.imports || {})) {
  162. if (k.endsWith('/')) {
  163. // 前缀映射:抽一个真实文件验证(该目录下 package.json 的入口)
  164. const dirUrl = new URL(v, base).href;
  165. // 前缀映射只需「下游有真实文件」;dist/ 这类子目录不一定自带 package.json
  166. const probe = new URL(v.endsWith('/') ? v + 'index.js' : v, base).href;
  167. const probeRes = await fetch(probe).catch(() => null);
  168. if (probeRes?.status === 200) continue;
  169. const pkgRes = await fetch(new URL('package.json', dirUrl)).catch(() => null);
  170. if (!pkgRes || pkgRes.status !== 200) { mapBad.push(`${k} → ${dirUrl} 目录不可达`); continue; }
  171. const pkg = await pkgRes.json().catch(() => ({}));
  172. const main = typeof pkg.exports?.['.'] === 'string' ? pkg.exports['.'] : pkg.exports?.['.']?.default || pkg.main;
  173. if (main) {
  174. const r = await fetch(new URL(main, dirUrl));
  175. if (r.status !== 200) mapBad.push(`${k} → ${main} ${r.status}`);
  176. }
  177. continue;
  178. }
  179. const r = await fetch(new URL(v, base)).catch(() => null);
  180. if (!r || r.status !== 200) mapBad.push(`${k} → ${v} ${r?.status}`);
  181. }
  182. record('importmap 每条目标可达', mapBad.length === 0, mapBad.join('\n'));
  183. if (wantJson) {
  184. console.log(JSON.stringify({ base, moduleCount: seen.size, modules: moduleUrls, failures, results }, null, 2));
  185. } else {
  186. for (const u of moduleUrls.sort()) console.log(` · ${u.replace(base, './')}`);
  187. }
  188. }
  189. // 3) 深链接回退:存在的路由与不存在的路径都要 200 且不被重定向
  190. const deep = ['pool', 'report', 'ledger', 'members', 'stages', 'does-not-exist/xyz'];
  191. const deepBad = [];
  192. for (const p of deep) {
  193. const u = new URL(p, base);
  194. const r = await fetch(u, { redirect: 'manual' });
  195. const body = await r.text();
  196. const looksLikeIndex = body.includes('<kf-app') && body.includes('type="module"');
  197. const redirected = r.redirected || (r.status >= 300 && r.status < 400);
  198. if (r.status !== 200 || redirected || !looksLikeIndex) deepBad.push(`${p}: status=${r.status} redirect=${redirected} index=${looksLikeIndex}`);
  199. }
  200. record(`深链接 ${deep.length} 条(含不存在路径)返回 200 index.html、无重定向`, deepBad.length === 0, deepBad.join('\n'));
  201. if (!wantJson) {
  202. console.log('');
  203. for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${!r.ok && r.detail ? `\n ${r.detail.split('\n').join('\n ')}` : ''}`);
  204. console.log(`\n${failures.length === 0 ? 'MODULE GRAPH OK' : `MODULE GRAPH FAILED(${failures.length})`}`);
  205. }
  206. process.exit(failures.length ? 1 : 0);