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