This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

139
scripts/check-imports.mjs Normal file
View File

@@ -0,0 +1,139 @@
// 一次性检查:所有相对 import 的具名导出是否真实存在。
// 渲染进程的 UI 文件不会被 node 测试加载,语法正确但 import 了不存在的名字时
// 只会在 Electron 里白屏 —— 这个脚本用来提前抓出来。
//
// 用法node scripts/check-imports.mjs
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// UI 模块在模块作用域就会碰 document / window注册全局监听等
// 所以先塞一个最小 DOM 桩 —— 我们只关心 import 能不能解析,不跑实际逻辑。
const noop = () => {};
const stubEl = new Proxy({}, {
get: (t, k) => (k === 'style' || k === 'dataset' || k === 'classList')
? stubEl
: (k in t ? t[k] : noop),
set: () => true
});
globalThis.document = new Proxy({}, {
get: (t, k) => {
if (k === 'body' || k === 'documentElement' || k === 'head') return stubEl;
if (k === 'querySelectorAll') return () => [];
if (k === 'getElementById' || k === 'querySelector') return () => null;
if (k === 'createElement') return () => stubEl;
return noop;
}
});
globalThis.window = new Proxy({}, {
get: (t, k) => (k === 'matchMedia')
? () => ({ matches: false, addEventListener: noop, addListener: noop })
: (k === 'innerWidth' || k === 'innerHeight') ? 1000 : noop,
set: () => true
});
// Node 21+ 把 navigator 变成自带 getter 的全局(只读),直接赋值会抛
// "Cannot set property navigator of #<Object> which has only a getter"。
// 用 defineProperty 走 configurable 通道原模块toolbar.js只是读 navigator.platform。
if (!Object.getOwnPropertyDescriptor(globalThis, 'navigator')?.configurable) {
// 极小概率:未来 Node 把 navigator 锁成 non-configurable —— 退到 try/catch 兜底
try { globalThis.navigator = { platform: 'Win32', userAgent: 'node' }; } catch {}
} else {
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'Win32', userAgent: 'node' },
writable: true,
configurable: true
});
}
globalThis.localStorage = { getItem: () => null, setItem: noop, removeItem: noop };
const files = [];
(function walk(d) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.js')) files.push(p);
}
})('src');
let bad = 0;
for (const f of files) {
const src = fs.readFileSync(f, 'utf8');
// 三类 import 都要扫:
// 1) 具名 import { a, b as c } from 'x'
// 2) 默认 import X from 'x' → 检查 mod.default 存在
// 3) 命名空间 import * as X from 'x' → 默认通过,无需检查具体名字
//
// 旧实现只匹配单行的具名 import多行 import跨行排版的 { a, b })会被
// 静悄悄放过 —— 那恰好是 UI 文件里最常见的风格。
const namedRe = /import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g;
const defaultRe = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
const nsRe = /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
const seenSpecs = new Set();
const tryLoad = async (spec, fromFile) => {
if (!spec.startsWith('.')) return null;
const target = path.resolve(path.dirname(fromFile), spec);
const key = `${fromFile}::${spec}`;
if (seenSpecs.has(key)) return null;
seenSpecs.add(key);
if (!fs.existsSync(target)) {
console.log(`NO FILE: ${fromFile} -> ${spec}`);
bad++;
return null;
}
return await import(pathToFileURL(target).href);
};
// 1) 具名导入
let m;
while ((m = namedRe.exec(src))) {
const spec = m[2];
const mod = await tryLoad(spec, f);
if (!mod) continue;
// 拆具名列表容忍换行、尾随逗号、as 重命名。
// 关键:检查 `n in mod` 时必须用**源导出名**(即 `as` 之前的部分),
// 不是局部名 —— 模块导出的永远是源名,重命名只在当前模块内可见。
// 旧实现用 .pop() 取到的是局部名('bar'),对 `{ foo as bar }` 永远
// 找不到 'bar',把这次正常的 rename 误报为 MISSING EXPORT。
const names = m[1]
.split(',')
.map(s => {
const parts = s.trim().split(/\s+as\s+/);
return (parts[0] || '').trim();
})
.filter(Boolean);
for (const n of names) {
if (!(n in mod)) {
console.log(`MISSING EXPORT: ${n} (${f} <- ${spec})`);
bad++;
}
}
}
// 2) 默认导入
while ((m = defaultRe.exec(src))) {
// 跳过已经匹配的具名导入regex 共享游标,靠 lookahead 排除具名)
if (/\{[\s\S]*\}\s*from\s*['"]/.test(src.slice(m.index, m.index + m[0].length + 50))) continue;
const localName = m[1];
const spec = m[2];
const mod = await tryLoad(spec, f);
if (!mod) continue;
if (!('default' in mod) || mod.default === undefined) {
console.log(`MISSING DEFAULT: ${localName} (${f} <- ${spec})`);
bad++;
}
}
// 3) 命名空间 import仅当模块本身不存在时才算失败 —— 不去校验具体成员。
while ((m = nsRe.exec(src))) {
const spec = m[2];
await tryLoad(spec, f);
}
}
console.log(bad === 0
? `✓ 所有 import 均可解析(${files.length} 个文件)`
: `${bad} 处问题`);
process.exit(bad > 0 ? 1 : 0);