| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- /**
- * 死代码体检:从入口出发做 **import 图可达性分析**,列出「没人 import 到」的模块,
- * 外加静态资源与 npm 依赖的线索。
- *
- * 怎么跑:node harness/tools/audit-unused.mjs
- *
- * 为什么用「可达性」而不是「文件名有没有出现在别处」:
- * 本项目有大量**注释掉的旧代码**(`[已停用]` / `// import ...`),按文本出现判断会把
- * 死文件当成活的 —— 第一版就是这么写的,三项全是「无」,纯属自欺。
- *
- * ⚠️ 仍是**体检不是判决**,两类已知盲区:
- * ① 动态路径(`import(`./pages/${name}`)`)解析不出来 → 可能误报
- * ② 模板里靠 `unplugin-vue-components` 自动注册的组件(本项目配了 AntDesignVueResolver)
- * → 只影响 node_modules,不影响 src 下的自有组件
- * 所以删之前**逐个 grep 复核**。
- */
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
- import { join, dirname, relative, resolve } from 'node:path';
- import { fileURLToPath } from 'node:url';
- const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
- const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.vscode', '.idea']);
- const walk = (dir, out = []) => {
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
- if (entry.isDirectory()) {
- if (SKIP_DIRS.has(entry.name)) continue;
- walk(join(dir, entry.name), out);
- } else {
- out.push(join(dir, entry.name));
- }
- }
- return out;
- };
- const files = walk(repoRoot);
- const rel = (f) => relative(repoRoot, f).replace(/\\/g, '/');
- /** 入口:index.html 里的 module script + 约定俗成的入口文件 */
- const ENTRY_CANDIDATES = ['src/main.ts', 'src/index.ts', 'index.html', 'vite.config.ts'];
- /** 取出一段代码里的所有模块说明符(静态 import / 动态 import / require / 裸 `import 'x'`) */
- const extractSpecifiers = (code) => {
- const out = new Set();
- const patterns = [
- /(?:^|[\s;{(])import\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // import x from 'y'
- /(?:^|[\s;{(])import\s*['"]([^'"]+)['"]/g, // import 'y'
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // import('y')
- /(?:^|[\s;}])export\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // export { x } from 'y' —— 桶文件靠它,漏了会误报
- /(?:^|[\s;}])export\s*\*\s*from\s*['"]([^'"]+)['"]/g, // export * from 'y'
- /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // require('y')
- ];
- for (const re of patterns) {
- for (const m of code.matchAll(re)) out.add(m[1]);
- }
- return [...out];
- };
- /** 把模块说明符解析成磁盘文件(支持 @ 别名与省略扩展名 / index) */
- const resolveSpecifier = (spec, fromFile) => {
- let base;
- if (spec.startsWith('@/')) base = join(repoRoot, 'src', spec.slice(2));
- else if (spec.startsWith('./') || spec.startsWith('../')) base = resolve(dirname(fromFile), spec);
- else return null; // 裸包名(npm 依赖):不参与本次可达性分析
- const candidates = [
- base,
- `${base}.ts`,
- `${base}.js`,
- `${base}.vue`,
- `${base}.json`,
- join(base, 'index.ts'),
- join(base, 'index.js'),
- join(base, 'index.vue'),
- ];
- return candidates.find((c) => existsSync(c) && !readdirSafe(c)) ?? null;
- };
- const readdirSafe = (p) => {
- try {
- return readdirSync(p);
- } catch {
- return null;
- }
- };
- /* ---------------- 可达性:从入口做 BFS ---------------- */
- const reachable = new Set();
- const queue = [];
- // index.html 里被引用的 TS 也算入口
- const indexHtml = join(repoRoot, 'index.html');
- if (existsSync(indexHtml)) {
- for (const m of readFileSync(indexHtml, 'utf8').matchAll(/<script[^>]+src=['"]([^'"]+)['"]/g)) {
- const p = resolve(join(repoRoot, m[1]));
- if (existsSync(p)) queue.push(p);
- }
- }
- for (const e of ENTRY_CANDIDATES) {
- const p = join(repoRoot, e);
- if (existsSync(p)) queue.push(p);
- }
- while (queue.length) {
- const file = queue.pop();
- const key = resolve(file);
- if (reachable.has(key)) continue;
- reachable.add(key);
- if (!/\.(ts|vue|js|mjs|html)$/.test(file)) continue;
- let code = '';
- try {
- code = readFileSync(file, 'utf8');
- } catch {
- continue;
- }
- for (const spec of extractSpecifiers(code)) {
- const target = resolveSpecifier(spec, file);
- if (target && !reachable.has(resolve(target))) queue.push(target);
- }
- }
- /* ---------------- 1. 不可达的 src 模块 ---------------- */
- const srcCode = files.filter((f) => /^src\//.test(rel(f)) && /\.(ts|js|vue)$/.test(f) && !/\.d\.ts$/.test(f));
- const unreachable = srcCode.filter((f) => !reachable.has(resolve(f)) && !/\.config\./.test(f));
- /* ---------------- 2. 静态资源 ---------------- */
- const codeText = files
- .filter((f) => {
- const r = rel(f);
- if (r.startsWith('harness/') || /\.md$/i.test(r)) return false;
- return /\.(ts|vue|js|mjs|html|json|less|scss|css)$/.test(r);
- })
- .map((f) => {
- try {
- return readFileSync(f, 'utf8');
- } catch {
- return '';
- }
- })
- .join('\n');
- const ASSET_EXT = /\.(png|jpe?g|svg|gif|webp|mp4|webm|woff2?|ttf|otf|eot)$/i;
- const unusedAssets = files
- .filter((f) => ASSET_EXT.test(f) && !/^dist\//.test(rel(f)) && !/^harness\//.test(rel(f)))
- .filter((f) => (codeText.split(f.split(/[\\/]/).pop()).length - 1) <= 0);
- /* ---------------- 3. npm 依赖 ---------------- */
- const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
- const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
- const unusedDeps = deps.filter((dep) => (codeText.split(dep).length - 1) <= 0);
- /* ---------------- 输出 ---------------- */
- const section = (title, items, note) => {
- console.log(`\n=== ${title} ===`);
- if (note) console.log(`(${note})`);
- if (!items.length) console.log(' 无');
- else for (const i of items) console.log(' ', i);
- };
- console.log(`入口:${ENTRY_CANDIDATES.filter((e) => existsSync(join(repoRoot, e))).join(', ')}`);
- console.log(`可达模块:${reachable.size} 个`);
- section(
- '**没人 import 到**的 src 模块(死代码候选)',
- unreachable.map(rel),
- '判据:从入口做 import 图可达性;注释掉的 import 不算数'
- );
- section('疑似没人引用的静态资源', unusedAssets.map(rel), '⚠️ 目录级/批量引用看不出来,需人工确认');
- section('疑似没人 import 的 npm 依赖', unusedDeps, '⚠️ 配置里按字符串用到的会命中,需人工确认');
|