check.mjs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/check.mjs —— 静态体检(部署前,对应任务书第 5 节 A 组 1–9 项 + C 组凭据红线)。
  4. *
  5. * 做什么:
  6. * 1 无装饰器语法 2 相对 import 全带 .js(且目标文件存在)
  7. * 3 无 window.KF_* 4 无 innerHTML
  8. * 5 core 不碰 DOM / fetch 6 视图层不直接 fetch
  9. * 7 base href / importmap 前缀 / 部署前缀 三处同源
  10. * 8 无 @lit-labs/router 9 lit 单实例(importmap 只有一个版本路径)
  11. * C 前端无凭据(masterKey / 密钥值)
  12. * 附:语法 node --check、裸标识符都在 importmap、自定义元素标签带连字符、define 幂等、
  13. * wc 组件全部 extends LitElement(禁原生 wc 手写)
  14. *
  15. * 用法:node scripts/check.mjs [--env prod]
  16. */
  17. import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
  18. import { join, dirname, relative } from 'node:path';
  19. import { fileURLToPath } from 'node:url';
  20. import { execFileSync } from 'node:child_process';
  21. const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  22. const ENV = process.argv.includes('--env') ? process.argv[process.argv.indexOf('--env') + 1] : 'local';
  23. let fail = 0;
  24. const results = [];
  25. function check(no, name, ok, detail = '') {
  26. results.push({ no, name, ok, detail });
  27. console.log(`${ok ? '✓' : '✗'} [${no}] ${name}${!ok && detail ? `\n ${detail.split('\n').join('\n ')}` : ''}`);
  28. if (!ok) fail++;
  29. }
  30. function walk(dir, out = [], filter = () => true) {
  31. for (const e of readdirSync(dir, { withFileTypes: true })) {
  32. if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
  33. const p = join(dir, e.name);
  34. if (e.isDirectory()) walk(p, out, filter);
  35. else if (filter(p)) out.push(p);
  36. }
  37. return out;
  38. }
  39. /** 去注释(保留字符串:判断 import / innerHTML 这类字面量) */
  40. const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
  41. const slash = (p) => p.replaceAll('\\', '/');
  42. const srcFiles = walk(join(ROOT, 'src'), [], (p) => p.endsWith('.js') && !slash(p).includes('/dev/'));
  43. const rel = (p) => relative(ROOT, p);
  44. const read = (p) => readFileSync(p, 'utf8');
  45. // 1. 装饰器
  46. const DECO = /^\s*@(customElement|property|state|consume|provide|query|queryAll|queryAssignedElements|queryAssignedNodes|queryAsync)\b/m;
  47. const decoHits = srcFiles.filter((f) => DECO.test(stripComments(read(f))));
  48. check(1, '无装饰器语法', decoHits.length === 0, decoHits.map((f) => rel(f)).join('\n'));
  49. // 2. 相对 import 带 .js 且目标存在
  50. const suffixBad = [];
  51. for (const f of srcFiles) {
  52. const src = stripComments(read(f));
  53. for (const m of src.matchAll(/(?:from|import)\s*\(?\s*['"](\.[^'"]*)['"]/g)) {
  54. const spec = m[1];
  55. if (!/\.js$/.test(spec)) suffixBad.push(`${rel(f)}: import '${spec}' 缺少 .js`);
  56. else if (!existsSync(join(dirname(f), spec))) suffixBad.push(`${rel(f)}: import '${spec}' 目标不存在`);
  57. }
  58. }
  59. check(2, '相对 import 全带 .js 且目标存在', suffixBad.length === 0, suffixBad.join('\n'));
  60. // 3. window.KF_*
  61. const kfGlobal = srcFiles.filter((f) => /window\.KF_/.test(read(f)));
  62. check(3, '无 window.KF_* 全局挂载', kfGlobal.length === 0, kfGlobal.map((f) => rel(f)).join('\n'));
  63. // 4. innerHTML
  64. const htmlBad = srcFiles.filter((f) => /innerHTML/.test(stripComments(read(f))));
  65. check(4, '无 innerHTML 拼接', htmlBad.length === 0, htmlBad.map((f) => rel(f)).join('\n'));
  66. // 5. core 不碰 DOM / fetch(去注释、去 import 路径里的词)
  67. const coreFiles = srcFiles.filter((f) => slash(f).startsWith(slash(join(ROOT, 'src', 'core')) + '/'));
  68. const DOM_GLOBALS = /\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|CustomEvent|fetch)\b/;
  69. const coreHits = [];
  70. for (const f of coreFiles) {
  71. const body = stripComments(read(f));
  72. body.split('\n').forEach((line, i) => {
  73. if (/^\s*(import|export)\b.*\bfrom\b/.test(line)) return; // 只查语句,不查 import 路径
  74. const m = line.match(DOM_GLOBALS);
  75. if (m) coreHits.push(`${rel(f)}:${i + 1} ${line.trim().slice(0, 100)}`);
  76. });
  77. }
  78. check(5, `core 层不碰 DOM/fetch(${coreFiles.length} 个文件)`, coreHits.length === 0, coreHits.join('\n'));
  79. // 6. 视图 / 组件不直接 fetch(网络只在 services → utils/http.js)
  80. const viewFiles = srcFiles.filter((f) => /\/(pages|layout|components)\//.test(slash(f)));
  81. const fetchHits = viewFiles.filter((f) => /\bfetch\s*\(/.test(stripComments(read(f))));
  82. const svcFetch = srcFiles.filter((f) => slash(f).startsWith(slash(join(ROOT, 'src', 'services')) + '/') && /fetch\s*\(/.test(stripComments(read(f))));
  83. check(6, '视图/组件不直接 fetch(且 services 也不直连 fetch)', fetchHits.length === 0 && svcFetch.length === 0,
  84. [...fetchHits, ...svcFetch].map((f) => rel(f)).join('\n'));
  85. // 7. base / importmap / 部署前缀 三处同源
  86. const html = read(join(ROOT, 'index.html'));
  87. const base = html.match(/<base href="([^"]+)"/)?.[1];
  88. const mapRaw = html.match(/<script type="importmap">([\s\S]*?)<\/script>/)?.[1];
  89. let map = null;
  90. try { map = JSON.parse(mapRaw); } catch { map = null; }
  91. const prefixFile = join(ROOT, 'deploy/prefix', `${ENV}.txt`);
  92. const stored = existsSync(prefixFile) ? read(prefixFile).trim() : null;
  93. const entrySrc = html.match(/<script type="module" src="([^"]+)"/)?.[1];
  94. const sameSource = Boolean(base && map && stored && base === stored && entrySrc?.startsWith(stored) &&
  95. Object.values(map.imports).every((v) => v.startsWith(stored)));
  96. check(7, `base href / importmap / 部署前缀 三处同源(env=${ENV})`, sameSource,
  97. `base=${base} prefix文件=${stored} 入口=${entrySrc}\nimportmap 首条=${map ? Object.values(map.imports)[0] : '解析失败'}`);
  98. // 8. 禁 @lit-labs/router
  99. const routerHits = [...srcFiles, join(ROOT, 'index.html')].filter((f) => /@lit-labs\/router/.test(read(f)));
  100. check(8, '无 @lit-labs/router', routerHits.length === 0, routerHits.map((f) => rel(f)).join('\n'));
  101. // 9. lit 单实例:importmap 里每个包只指向一个版本目录
  102. const verDirs = new Map();
  103. if (map) for (const v of Object.values(map.imports)) {
  104. const m = v.match(/lib\/vendor\/([^/]+)\/(v[^/]+)\//);
  105. if (m) verDirs.set(m[1], new Set([...(verDirs.get(m[1]) || []), m[2]]));
  106. }
  107. const multi = [...verDirs.entries()].filter(([, s]) => s.size > 1);
  108. // 同时确认实际源码没有绕过 importmap 直接引 vendor 路径
  109. const directVendor = srcFiles.filter((f) => /lib\/vendor\//.test(stripComments(read(f))));
  110. check(9, 'lit 单实例(importmap 每包一个版本,源码不绕过 importmap)', multi.length === 0 && directVendor.length === 0,
  111. [...multi.map(([k, s]) => `${k}: ${[...s].join(', ')}`), ...directVendor.map((f) => `${rel(f)} 直接引用 lib/vendor`)].join('\n'));
  112. // C. 前端无凭据
  113. const credFiles = srcFiles.filter((f) => f.endsWith('.js'));
  114. const credHits = [];
  115. const patterns = [/masterKey/i, /X-Parse-Master-Key/i, /MASTER_KEY/, /secretKey/i];
  116. for (const f of srcFiles) {
  117. const body = read(f);
  118. for (const re of patterns) if (re.test(body)) credHits.push(`${rel(f)}: ${re}`);
  119. // 形如很长的随机串(32 位以上十六进制/大小写字母数字)也可能是密钥
  120. const long = body.match(/['"`][A-Za-z0-9+/=_-]{40,}['"`]/g);
  121. if (long) credHits.push(`${rel(f)}: 疑似密钥长串 ${long[0].slice(0, 12)}…`);
  122. }
  123. // 凭据文件里的真实值不得出现在 src/ 与 index.html
  124. const credFile = join(dirname(ROOT), '.credentials');
  125. let realLeak = [];
  126. if (existsSync(credFile)) {
  127. for (const line of read(credFile).split('\n')) {
  128. const m = line.match(/^([A-Z_]+)=["']?([^"']{16,})["']?$/);
  129. if (!m || /app_id|DB|url/i.test(m[1])) continue;
  130. const v = m[2];
  131. if (srcFiles.some((f) => read(f).includes(v)) || html.includes(v)) realLeak.push(m[1]);
  132. }
  133. }
  134. check('C1', '前端无凭据(masterKey / 密钥值)', credHits.length === 0 && realLeak.length === 0,
  135. [...credHits, ...realLeak.map((k) => `.credentials 的 ${k} 出现在了前端代码里`)].join('\n'));
  136. void credFiles;
  137. // —— 附加(示例仓 check.mjs 的同款红线) ——
  138. let syntaxBad = [];
  139. for (const f of srcFiles) {
  140. try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); } catch { syntaxBad.push(rel(f)); }
  141. }
  142. try { execFileSync(process.execPath, ['--check', join(ROOT, 'src/dev/serve.mjs')], { stdio: 'pipe' }); } catch { syntaxBad.push('src/dev/serve.mjs'); }
  143. check('S', '全部 .js 通过 node --check', syntaxBad.length === 0, syntaxBad.join('\n'));
  144. // 裸标识符都必须在 importmap 里
  145. const bareBad = [];
  146. for (const f of srcFiles) {
  147. for (const m of stripComments(read(f)).matchAll(/(?:from|import)\s*\(?\s*['"]([^.'"/][^'"]*)['"]/g)) {
  148. const spec = m[1];
  149. if (spec.startsWith('node:')) continue;
  150. // importmap 支持「尾部带 / 的前缀映射」:lit/directives/x.js 由 "lit/" 这条覆盖
  151. const im = map?.imports || {};
  152. const ok = im[spec] || Object.keys(im).some((k) => k.endsWith('/') && spec.startsWith(k));
  153. if (!ok) bareBad.push(`${rel(f)}: ${spec}`);
  154. }
  155. }
  156. check('I', '裸标识符都在 importmap 里', bareBad.length === 0, bareBad.join('\n'));
  157. // 自定义元素:标签带连字符 + define 幂等 + wc 用 Lit 实现
  158. const defs = [];
  159. const defineBad = [];
  160. const litBad = [];
  161. for (const f of srcFiles) {
  162. const body = read(f);
  163. for (const m of body.matchAll(/customElements\.define\(\s*'([^']+)'/g)) {
  164. defs.push([f, m[1]]);
  165. if (!m[1].includes('-')) defineBad.push(`${rel(f)}: ${m[1]}`);
  166. if (!/customElements\.get\(/.test(body) && !/if\s*\(!customElements\.get/.test(body) && !f.includes('components/base.js')) defineBad.push(`${rel(f)}: define(${m[1]}) 未判重`);
  167. if (slash(f).includes('/components/') && !/extends\s+LitElement/.test(body) && !f.endsWith('index.js') && !f.endsWith('base.js')) litBad.push(`${rel(f)}: ${m[1]} 不是 LitElement`);
  168. }
  169. }
  170. check('E', `自定义元素标签带连字符、注册幂等、wc 用 Lit 实现(${defs.length} 个)`, defineBad.length === 0 && litBad.length === 0,
  171. [...defineBad, ...litBad].join('\n'));
  172. console.log(`\n${fail === 0 ? 'CHECK OK' : `CHECK FAILED(${fail})`}`);
  173. process.exit(fail ? 1 : 0);