#!/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 slash = (p) => p.replaceAll('\\', '/');
const srcFiles = walk(join(ROOT, 'src'), [], (p) => p.endsWith('.js') && !slash(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) => slash(f).startsWith(slash(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(slash(f)));
const fetchHits = viewFiles.filter((f) => /\bfetch\s*\(/.test(stripComments(read(f))));
const svcFetch = srcFiles.filter((f) => slash(f).startsWith(slash(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(/