#!/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(/([\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(/