Files
Todo-List/scripts/check-imports.mjs
2026-09-12 13:55:57 +08:00

140 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 一次性检查:所有相对 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);