check.mjs 9.8 KB

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