audit-unused.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * 死代码体检:从入口出发做 **import 图可达性分析**,列出「没人 import 到」的模块,
  3. * 外加静态资源与 npm 依赖的线索。
  4. *
  5. * 怎么跑:node harness/tools/audit-unused.mjs
  6. *
  7. * 为什么用「可达性」而不是「文件名有没有出现在别处」:
  8. * 本项目有大量**注释掉的旧代码**(`[已停用]` / `// import ...`),按文本出现判断会把
  9. * 死文件当成活的 —— 第一版就是这么写的,三项全是「无」,纯属自欺。
  10. *
  11. * ⚠️ 仍是**体检不是判决**,两类已知盲区:
  12. * ① 动态路径(`import(`./pages/${name}`)`)解析不出来 → 可能误报
  13. * ② 模板里靠 `unplugin-vue-components` 自动注册的组件(本项目配了 AntDesignVueResolver)
  14. * → 只影响 node_modules,不影响 src 下的自有组件
  15. * 所以删之前**逐个 grep 复核**。
  16. */
  17. import { readFileSync, existsSync, readdirSync } from 'node:fs';
  18. import { join, dirname, relative, resolve } from 'node:path';
  19. import { fileURLToPath } from 'node:url';
  20. const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
  21. const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.vscode', '.idea']);
  22. const walk = (dir, out = []) => {
  23. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  24. if (entry.isDirectory()) {
  25. if (SKIP_DIRS.has(entry.name)) continue;
  26. walk(join(dir, entry.name), out);
  27. } else {
  28. out.push(join(dir, entry.name));
  29. }
  30. }
  31. return out;
  32. };
  33. const files = walk(repoRoot);
  34. const rel = (f) => relative(repoRoot, f).replace(/\\/g, '/');
  35. /** 入口:index.html 里的 module script + 约定俗成的入口文件 */
  36. const ENTRY_CANDIDATES = ['src/main.ts', 'src/index.ts', 'index.html', 'vite.config.ts'];
  37. /** 取出一段代码里的所有模块说明符(静态 import / 动态 import / require / 裸 `import 'x'`) */
  38. const extractSpecifiers = (code) => {
  39. const out = new Set();
  40. const patterns = [
  41. /(?:^|[\s;{(])import\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // import x from 'y'
  42. /(?:^|[\s;{(])import\s*['"]([^'"]+)['"]/g, // import 'y'
  43. /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // import('y')
  44. /(?:^|[\s;}])export\s+[^'"]*?from\s*['"]([^'"]+)['"]/g, // export { x } from 'y' —— 桶文件靠它,漏了会误报
  45. /(?:^|[\s;}])export\s*\*\s*from\s*['"]([^'"]+)['"]/g, // export * from 'y'
  46. /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, // require('y')
  47. ];
  48. for (const re of patterns) {
  49. for (const m of code.matchAll(re)) out.add(m[1]);
  50. }
  51. return [...out];
  52. };
  53. /** 把模块说明符解析成磁盘文件(支持 @ 别名与省略扩展名 / index) */
  54. const resolveSpecifier = (spec, fromFile) => {
  55. let base;
  56. if (spec.startsWith('@/')) base = join(repoRoot, 'src', spec.slice(2));
  57. else if (spec.startsWith('./') || spec.startsWith('../')) base = resolve(dirname(fromFile), spec);
  58. else return null; // 裸包名(npm 依赖):不参与本次可达性分析
  59. const candidates = [
  60. base,
  61. `${base}.ts`,
  62. `${base}.js`,
  63. `${base}.vue`,
  64. `${base}.json`,
  65. join(base, 'index.ts'),
  66. join(base, 'index.js'),
  67. join(base, 'index.vue'),
  68. ];
  69. return candidates.find((c) => existsSync(c) && !readdirSafe(c)) ?? null;
  70. };
  71. const readdirSafe = (p) => {
  72. try {
  73. return readdirSync(p);
  74. } catch {
  75. return null;
  76. }
  77. };
  78. /* ---------------- 可达性:从入口做 BFS ---------------- */
  79. const reachable = new Set();
  80. const queue = [];
  81. // index.html 里被引用的 TS 也算入口
  82. const indexHtml = join(repoRoot, 'index.html');
  83. if (existsSync(indexHtml)) {
  84. for (const m of readFileSync(indexHtml, 'utf8').matchAll(/<script[^>]+src=['"]([^'"]+)['"]/g)) {
  85. const p = resolve(join(repoRoot, m[1]));
  86. if (existsSync(p)) queue.push(p);
  87. }
  88. }
  89. for (const e of ENTRY_CANDIDATES) {
  90. const p = join(repoRoot, e);
  91. if (existsSync(p)) queue.push(p);
  92. }
  93. while (queue.length) {
  94. const file = queue.pop();
  95. const key = resolve(file);
  96. if (reachable.has(key)) continue;
  97. reachable.add(key);
  98. if (!/\.(ts|vue|js|mjs|html)$/.test(file)) continue;
  99. let code = '';
  100. try {
  101. code = readFileSync(file, 'utf8');
  102. } catch {
  103. continue;
  104. }
  105. for (const spec of extractSpecifiers(code)) {
  106. const target = resolveSpecifier(spec, file);
  107. if (target && !reachable.has(resolve(target))) queue.push(target);
  108. }
  109. }
  110. /* ---------------- 1. 不可达的 src 模块 ---------------- */
  111. const srcCode = files.filter((f) => /^src\//.test(rel(f)) && /\.(ts|js|vue)$/.test(f) && !/\.d\.ts$/.test(f));
  112. const unreachable = srcCode.filter((f) => !reachable.has(resolve(f)) && !/\.config\./.test(f));
  113. /* ---------------- 2. 静态资源 ---------------- */
  114. const codeText = files
  115. .filter((f) => {
  116. const r = rel(f);
  117. if (r.startsWith('harness/') || /\.md$/i.test(r)) return false;
  118. return /\.(ts|vue|js|mjs|html|json|less|scss|css)$/.test(r);
  119. })
  120. .map((f) => {
  121. try {
  122. return readFileSync(f, 'utf8');
  123. } catch {
  124. return '';
  125. }
  126. })
  127. .join('\n');
  128. const ASSET_EXT = /\.(png|jpe?g|svg|gif|webp|mp4|webm|woff2?|ttf|otf|eot)$/i;
  129. const unusedAssets = files
  130. .filter((f) => ASSET_EXT.test(f) && !/^dist\//.test(rel(f)) && !/^harness\//.test(rel(f)))
  131. .filter((f) => (codeText.split(f.split(/[\\/]/).pop()).length - 1) <= 0);
  132. /* ---------------- 3. npm 依赖 ---------------- */
  133. const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
  134. const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
  135. const unusedDeps = deps.filter((dep) => (codeText.split(dep).length - 1) <= 0);
  136. /* ---------------- 输出 ---------------- */
  137. const section = (title, items, note) => {
  138. console.log(`\n=== ${title} ===`);
  139. if (note) console.log(`(${note})`);
  140. if (!items.length) console.log(' 无');
  141. else for (const i of items) console.log(' ', i);
  142. };
  143. console.log(`入口:${ENTRY_CANDIDATES.filter((e) => existsSync(join(repoRoot, e))).join(', ')}`);
  144. console.log(`可达模块:${reachable.size} 个`);
  145. section(
  146. '**没人 import 到**的 src 模块(死代码候选)',
  147. unreachable.map(rel),
  148. '判据:从入口做 import 图可达性;注释掉的 import 不算数'
  149. );
  150. section('疑似没人引用的静态资源', unusedAssets.map(rel), '⚠️ 目录级/批量引用看不出来,需人工确认');
  151. section('疑似没人 import 的 npm 依赖', unusedDeps, '⚠️ 配置里按字符串用到的会命中,需人工确认');