update
This commit is contained in:
139
scripts/check-imports.mjs
Normal file
139
scripts/check-imports.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user