/** * 死代码体检:从入口出发做 **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(/]+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, '⚠️ 配置里按字符串用到的会命中,需人工确认');