| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- #!/usr/bin/env node
- /**
- * scripts/check.mjs —— 静态体检(部署前,对应任务书第 5 节 A 组 1–9 项 + C 组凭据红线)。
- *
- * 做什么:
- * 1 无装饰器语法 2 相对 import 全带 .js(且目标文件存在)
- * 3 无 window.KF_* 4 无 innerHTML
- * 5 core 不碰 DOM / fetch 6 视图层不直接 fetch
- * 7 base href / importmap 前缀 / 部署前缀 三处同源
- * 8 无 @lit-labs/router 9 lit 单实例(importmap 只有一个版本路径)
- * C 前端无凭据(masterKey / 密钥值)
- * 附:语法 node --check、裸标识符都在 importmap、自定义元素标签带连字符、define 幂等、
- * wc 组件全部 extends LitElement(禁原生 wc 手写)
- *
- * 用法:node scripts/check.mjs [--env prod]
- */
- import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
- import { join, dirname, relative } from 'node:path';
- import { fileURLToPath } from 'node:url';
- import { execFileSync } from 'node:child_process';
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
- const ENV = process.argv.includes('--env') ? process.argv[process.argv.indexOf('--env') + 1] : 'local';
- let fail = 0;
- const results = [];
- function check(no, name, ok, detail = '') {
- results.push({ no, name, ok, detail });
- console.log(`${ok ? '✓' : '✗'} [${no}] ${name}${!ok && detail ? `\n ${detail.split('\n').join('\n ')}` : ''}`);
- if (!ok) fail++;
- }
- function walk(dir, out = [], filter = () => true) {
- for (const e of readdirSync(dir, { withFileTypes: true })) {
- if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
- const p = join(dir, e.name);
- if (e.isDirectory()) walk(p, out, filter);
- else if (filter(p)) out.push(p);
- }
- return out;
- }
- /** 去注释(保留字符串:判断 import / innerHTML 这类字面量) */
- const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
- const srcFiles = walk(join(ROOT, 'src'), [], (p) => p.endsWith('.js') && !p.includes('/dev/'));
- const rel = (p) => relative(ROOT, p);
- const read = (p) => readFileSync(p, 'utf8');
- // 1. 装饰器
- const DECO = /^\s*@(customElement|property|state|consume|provide|query|queryAll|queryAssignedElements|queryAssignedNodes|queryAsync)\b/m;
- const decoHits = srcFiles.filter((f) => DECO.test(stripComments(read(f))));
- check(1, '无装饰器语法', decoHits.length === 0, decoHits.map((f) => rel(f)).join('\n'));
- // 2. 相对 import 带 .js 且目标存在
- const suffixBad = [];
- for (const f of srcFiles) {
- const src = stripComments(read(f));
- for (const m of src.matchAll(/(?:from|import)\s*\(?\s*['"](\.[^'"]*)['"]/g)) {
- const spec = m[1];
- if (!/\.js$/.test(spec)) suffixBad.push(`${rel(f)}: import '${spec}' 缺少 .js`);
- else if (!existsSync(join(dirname(f), spec))) suffixBad.push(`${rel(f)}: import '${spec}' 目标不存在`);
- }
- }
- check(2, '相对 import 全带 .js 且目标存在', suffixBad.length === 0, suffixBad.join('\n'));
- // 3. window.KF_*
- const kfGlobal = srcFiles.filter((f) => /window\.KF_/.test(read(f)));
- check(3, '无 window.KF_* 全局挂载', kfGlobal.length === 0, kfGlobal.map((f) => rel(f)).join('\n'));
- // 4. innerHTML
- const htmlBad = srcFiles.filter((f) => /innerHTML/.test(stripComments(read(f))));
- check(4, '无 innerHTML 拼接', htmlBad.length === 0, htmlBad.map((f) => rel(f)).join('\n'));
- // 5. core 不碰 DOM / fetch(去注释、去 import 路径里的词)
- const coreFiles = srcFiles.filter((f) => f.includes(`${join(ROOT, 'src', 'core')}/`));
- const DOM_GLOBALS = /\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|CustomEvent|fetch)\b/;
- const coreHits = [];
- for (const f of coreFiles) {
- const body = stripComments(read(f));
- body.split('\n').forEach((line, i) => {
- if (/^\s*(import|export)\b.*\bfrom\b/.test(line)) return; // 只查语句,不查 import 路径
- const m = line.match(DOM_GLOBALS);
- if (m) coreHits.push(`${rel(f)}:${i + 1} ${line.trim().slice(0, 100)}`);
- });
- }
- check(5, `core 层不碰 DOM/fetch(${coreFiles.length} 个文件)`, coreHits.length === 0, coreHits.join('\n'));
- // 6. 视图 / 组件不直接 fetch(网络只在 services → utils/http.js)
- const viewFiles = srcFiles.filter((f) => /\/(pages|layout|components)\//.test(f));
- const fetchHits = viewFiles.filter((f) => /\bfetch\s*\(/.test(stripComments(read(f))));
- const svcFetch = srcFiles.filter((f) => f.includes(`${join(ROOT, 'src', 'services')}/`) && /fetch\s*\(/.test(stripComments(read(f))));
- check(6, '视图/组件不直接 fetch(且 services 也不直连 fetch)', fetchHits.length === 0 && svcFetch.length === 0,
- [...fetchHits, ...svcFetch].map((f) => rel(f)).join('\n'));
- // 7. base / importmap / 部署前缀 三处同源
- const html = read(join(ROOT, 'index.html'));
- const base = html.match(/<base href="([^"]+)"/)?.[1];
- const mapRaw = html.match(/<script type="importmap">([\s\S]*?)<\/script>/)?.[1];
- let map = null;
- try { map = JSON.parse(mapRaw); } catch { map = null; }
- const prefixFile = join(ROOT, 'deploy/prefix', `${ENV}.txt`);
- const stored = existsSync(prefixFile) ? read(prefixFile).trim() : null;
- const entrySrc = html.match(/<script type="module" src="([^"]+)"/)?.[1];
- const sameSource = Boolean(base && map && stored && base === stored && entrySrc?.startsWith(stored) &&
- Object.values(map.imports).every((v) => v.startsWith(stored)));
- check(7, `base href / importmap / 部署前缀 三处同源(env=${ENV})`, sameSource,
- `base=${base} prefix文件=${stored} 入口=${entrySrc}\nimportmap 首条=${map ? Object.values(map.imports)[0] : '解析失败'}`);
- // 8. 禁 @lit-labs/router
- const routerHits = [...srcFiles, join(ROOT, 'index.html')].filter((f) => /@lit-labs\/router/.test(read(f)));
- check(8, '无 @lit-labs/router', routerHits.length === 0, routerHits.map((f) => rel(f)).join('\n'));
- // 9. lit 单实例:importmap 里每个包只指向一个版本目录
- const verDirs = new Map();
- if (map) for (const v of Object.values(map.imports)) {
- const m = v.match(/lib\/vendor\/([^/]+)\/(v[^/]+)\//);
- if (m) verDirs.set(m[1], new Set([...(verDirs.get(m[1]) || []), m[2]]));
- }
- const multi = [...verDirs.entries()].filter(([, s]) => s.size > 1);
- // 同时确认实际源码没有绕过 importmap 直接引 vendor 路径
- const directVendor = srcFiles.filter((f) => /lib\/vendor\//.test(stripComments(read(f))));
- check(9, 'lit 单实例(importmap 每包一个版本,源码不绕过 importmap)', multi.length === 0 && directVendor.length === 0,
- [...multi.map(([k, s]) => `${k}: ${[...s].join(', ')}`), ...directVendor.map((f) => `${rel(f)} 直接引用 lib/vendor`)].join('\n'));
- // C. 前端无凭据
- const credFiles = srcFiles.filter((f) => f.endsWith('.js'));
- const credHits = [];
- const patterns = [/masterKey/i, /X-Parse-Master-Key/i, /MASTER_KEY/, /secretKey/i];
- for (const f of srcFiles) {
- const body = read(f);
- for (const re of patterns) if (re.test(body)) credHits.push(`${rel(f)}: ${re}`);
- // 形如很长的随机串(32 位以上十六进制/大小写字母数字)也可能是密钥
- const long = body.match(/['"`][A-Za-z0-9+/=_-]{40,}['"`]/g);
- if (long) credHits.push(`${rel(f)}: 疑似密钥长串 ${long[0].slice(0, 12)}…`);
- }
- // 凭据文件里的真实值不得出现在 src/ 与 index.html
- const credFile = join(dirname(ROOT), '.credentials');
- let realLeak = [];
- if (existsSync(credFile)) {
- for (const line of read(credFile).split('\n')) {
- const m = line.match(/^([A-Z_]+)=["']?([^"']{16,})["']?$/);
- if (!m || /app_id|DB|url/i.test(m[1])) continue;
- const v = m[2];
- if (srcFiles.some((f) => read(f).includes(v)) || html.includes(v)) realLeak.push(m[1]);
- }
- }
- check('C1', '前端无凭据(masterKey / 密钥值)', credHits.length === 0 && realLeak.length === 0,
- [...credHits, ...realLeak.map((k) => `.credentials 的 ${k} 出现在了前端代码里`)].join('\n'));
- void credFiles;
- // —— 附加(示例仓 check.mjs 的同款红线) ——
- let syntaxBad = [];
- for (const f of srcFiles) {
- try { execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' }); } catch { syntaxBad.push(rel(f)); }
- }
- try { execFileSync(process.execPath, ['--check', join(ROOT, 'src/dev/serve.mjs')], { stdio: 'pipe' }); } catch { syntaxBad.push('src/dev/serve.mjs'); }
- check('S', '全部 .js 通过 node --check', syntaxBad.length === 0, syntaxBad.join('\n'));
- // 裸标识符都必须在 importmap 里
- const bareBad = [];
- for (const f of srcFiles) {
- for (const m of stripComments(read(f)).matchAll(/(?:from|import)\s*\(?\s*['"]([^.'"/][^'"]*)['"]/g)) {
- const spec = m[1];
- if (spec.startsWith('node:')) continue;
- // importmap 支持「尾部带 / 的前缀映射」:lit/directives/x.js 由 "lit/" 这条覆盖
- const im = map?.imports || {};
- const ok = im[spec] || Object.keys(im).some((k) => k.endsWith('/') && spec.startsWith(k));
- if (!ok) bareBad.push(`${rel(f)}: ${spec}`);
- }
- }
- check('I', '裸标识符都在 importmap 里', bareBad.length === 0, bareBad.join('\n'));
- // 自定义元素:标签带连字符 + define 幂等 + wc 用 Lit 实现
- const defs = [];
- const defineBad = [];
- const litBad = [];
- for (const f of srcFiles) {
- const body = read(f);
- for (const m of body.matchAll(/customElements\.define\(\s*'([^']+)'/g)) {
- defs.push([f, m[1]]);
- if (!m[1].includes('-')) defineBad.push(`${rel(f)}: ${m[1]}`);
- if (!/customElements\.get\(/.test(body) && !/if\s*\(!customElements\.get/.test(body) && !f.includes('components/base.js')) defineBad.push(`${rel(f)}: define(${m[1]}) 未判重`);
- if (f.includes('/components/') && !/extends\s+LitElement/.test(body) && !f.endsWith('index.js') && !f.endsWith('base.js')) litBad.push(`${rel(f)}: ${m[1]} 不是 LitElement`);
- }
- }
- check('E', `自定义元素标签带连字符、注册幂等、wc 用 Lit 实现(${defs.length} 个)`, defineBad.length === 0 && litBad.length === 0,
- [...defineBad, ...litBad].join('\n'));
- console.log(`\n${fail === 0 ? 'CHECK OK' : `CHECK FAILED(${fail})`}`);
- process.exit(fail ? 1 : 0);
|