|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * @ai-context 首屏静态可达性探针(批 2 包体治理的连通性尺子)。 |
| 4 | + * |
| 5 | + * Why:本仓唯一的「首屏」定义是**模块加载边界**,不是「组件看起来是不是按需渲染」。 |
| 6 | + * 批 1 的「不删清单」被证伪五次,根因就是用「看起来还有人用」代替「删除后的可达性」; |
| 7 | + * 包体治理是同一类问题的镜像 —— `AiConversationDock` 看起来「按需唤起」, |
| 8 | + * 但它被 App.tsx 静态 import 且常驻挂载,于是整条 markdown+katex 栈都在首屏。 |
| 9 | + * 唯一可靠的判据是:从 app/src/main.tsx 出发、只沿静态 ESM import 走。 |
| 10 | + * |
| 11 | + * 口径:静态边 = `from "…"` 与裸 `import "…"`;`import(…)` 是动态边界,**不跟**。 |
| 12 | + * 相对说明符按 Node 解析顺序补扩展名("", .ts, .tsx, .js, .jsx, /index.ts, /index.tsx); |
| 13 | + * 裸说明符按包名归并(`@scope/name` 取两段)。 |
| 14 | + * |
| 15 | + * 副作用:只读文件系统;不写任何文件。 |
| 16 | + * 边界:不做 tsconfig paths 解析、不处理 `export * as ns from`(本仓未使用)。 |
| 17 | + * 用法:node scripts/bundle-eager-graph.mjs [--json] [--entry app/src/main.tsx] |
| 18 | + * 退出码:0 = 正常;1 = 入口不存在。 |
| 19 | + */ |
| 20 | +import { readFileSync, existsSync, statSync } from "node:fs"; |
| 21 | +import { dirname, resolve, relative, sep, join } from "node:path"; |
| 22 | +import { fileURLToPath } from "node:url"; |
| 23 | + |
| 24 | +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 25 | +const argv = process.argv.slice(2); |
| 26 | +const AS_JSON = argv.includes("--json"); |
| 27 | +const entryArg = argv.indexOf("--entry") >= 0 ? argv[argv.indexOf("--entry") + 1] : "app/src/main.tsx"; |
| 28 | +const ENTRY = resolve(ROOT, entryArg); |
| 29 | +const EXT = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]; |
| 30 | +const RE_FROM = /\bfrom\s*["']([^"']+)["']/g; |
| 31 | +const RE_BARE = /\bimport\s*["']([^"']+)["']/g; |
| 32 | + |
| 33 | +function resolveSpec(fromFile, spec) { |
| 34 | + if (!spec.startsWith(".")) return null; |
| 35 | + const base = resolve(dirname(fromFile), spec); |
| 36 | + for (const e of EXT) { |
| 37 | + const p = base + e; |
| 38 | + if (existsSync(p) && statSync(p).isFile()) return p; |
| 39 | + } |
| 40 | + return null; |
| 41 | +} |
| 42 | + |
| 43 | +export function eagerGraph(entry) { |
| 44 | + const visited = new Set(); |
| 45 | + const packages = new Map(); |
| 46 | + const queue = [entry]; |
| 47 | + while (queue.length) { |
| 48 | + const file = queue.shift(); |
| 49 | + if (visited.has(file)) continue; |
| 50 | + visited.add(file); |
| 51 | + let text; |
| 52 | + try { text = readFileSync(file, "utf8"); } catch { continue; } |
| 53 | + const specs = []; |
| 54 | + for (const re of [RE_FROM, RE_BARE]) { |
| 55 | + re.lastIndex = 0; |
| 56 | + let m; |
| 57 | + while ((m = re.exec(text)) !== null) specs.push(m[1]); |
| 58 | + } |
| 59 | + for (const spec of specs) { |
| 60 | + const resolved = resolveSpec(file, spec); |
| 61 | + if (resolved) { if (!visited.has(resolved)) queue.push(resolved); } |
| 62 | + else if (!spec.startsWith(".")) { |
| 63 | + const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]; |
| 64 | + packages.set(name, (packages.get(name) ?? 0) + 1); |
| 65 | + } |
| 66 | + } |
| 67 | + } |
| 68 | + return { files: [...visited].sort(), packages: [...packages.keys()].sort() }; |
| 69 | +} |
| 70 | + |
| 71 | +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { |
| 72 | + if (!existsSync(ENTRY)) { |
| 73 | + console.error(`❌ 入口不存在:${relative(ROOT, ENTRY)}`); |
| 74 | + process.exit(1); |
| 75 | + } |
| 76 | + const { files, packages } = eagerGraph(ENTRY); |
| 77 | + if (AS_JSON) { |
| 78 | + console.log(JSON.stringify({ entry: relative(ROOT, ENTRY), files: files.map((f) => relative(ROOT, f)), packages }, null, 2)); |
| 79 | + } else { |
| 80 | + console.log(`入口:${relative(ROOT, ENTRY)}`); |
| 81 | + console.log(`首屏静态可达应用源文件:${files.length}`); |
| 82 | + console.log(`首屏拉入的 npm 包:${packages.length}`); |
| 83 | + for (const p of packages) console.log(` - ${p}`); |
| 84 | + console.log(`逐文件清单:加 --json 输出`); |
| 85 | + } |
| 86 | +} |
0 commit comments