benchmark-impact.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import { performance } from 'node:perf_hooks'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { register } from 'node:module'
  6. // Node ≥22.6 类型剥离直接跑源码:不依赖构建产物,`pnpm build` 是纯类型门禁(dsh 同款:
  7. // tsc 只做聚合类型检查,产物由打包器产出——见 08-22 本地集成踩坑记录 #12)。
  8. // 仓内源码是无扩展名相对导入,Node ESM 解析不了,先挂补 .ts 的解析钩子再动态导入。
  9. register('./strip-types-loader.mjs', import.meta.url)
  10. const { buildRefGraph, analyzeImpact } = await import('../packages/core/src/impact/index.ts')
  11. // ── fixture:1000 章 + 200 条设定(4 层树)= 1200 文件 ─────────────────────────
  12. // 设定树:`设定/类X/子Y/条N.md`,每条引用它的上一条(条N-5),每层 5 条成 40 级链;
  13. // 1000 章各引用一条设定。预期总边数 = 195(设定链)+ 1000(章→设定)= 1195。
  14. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-benchmark-impact-'))
  15. const SETTING_COUNT = 200
  16. const CHAPTER_COUNT = 1000
  17. const doc = (fields, body) => {
  18. const fm = Object.entries(fields)
  19. .map(([k, v]) => (Array.isArray(v) ? `${k}:\n${v.join('\n')}` : `${k}: ${v}`))
  20. .join('\n')
  21. return fm === '' ? `${body}\n` : `---\n${fm}\n---\n${body}\n`
  22. }
  23. const ref = (p) => ` - 来源:${p}@1`
  24. const settingRel = (i) => `设定/类${i % 5}/子${Math.floor(i / 5) % 5}/条${i}.md`
  25. for (let i = 0; i < SETTING_COUNT; i++) {
  26. const rel = settingRel(i)
  27. const fields = i >= 5 ? { 来源引用: [ref(settingRel(i - 5))] } : {}
  28. fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true })
  29. fs.writeFileSync(path.join(root, rel), doc(fields, `设定 ${i}\n`))
  30. }
  31. for (let i = 0; i < CHAPTER_COUNT; i++) {
  32. const rel = `定稿/卷01/${String(i + 1).padStart(4, '0')}-章.md`
  33. const fields = { 状态: '已定稿', 来源引用: [ref(settingRel(i % SETTING_COUNT))] }
  34. fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true })
  35. fs.writeFileSync(path.join(root, rel), doc(fields, `第 ${i + 1} 章正文\n`))
  36. }
  37. const EXPECTED_EDGES = SETTING_COUNT - 5 + CHAPTER_COUNT
  38. // ── fixture 探针(取数前必须先跑,不满足即抛不出数)──────────────────────────
  39. // 提案 §1.1 的数字前两次都测错:fixture 的 来源引用 漏了 @版本 导致 parseSourceRef
  40. // 全返回 null,等于拿「有效索引」比「什么都没找到的扫描」。此探针防重犯。
  41. function probe(graph) {
  42. let forward = 0
  43. for (const list of graph.forward.values()) forward += list.length
  44. let reverse = 0
  45. for (const list of graph.reverse.values()) reverse += list.length
  46. if (forward !== EXPECTED_EDGES || reverse !== EXPECTED_EDGES) {
  47. throw new Error(`探针失败:正/反边数 ${forward}/${reverse} ≠ 预期 ${EXPECTED_EDGES}(检查 fixture 是否漏 @版本 或解析失败)`)
  48. }
  49. if (graph.悬空引用.length !== 0) throw new Error(`探针失败:悬空引用 ${graph.悬空引用.length} ≠ 0(fixture 应自洽)`)
  50. if (graph.解析失败.length !== 0) throw new Error(`探针失败:解析失败 ${graph.解析失败.length} ≠ 0`)
  51. }
  52. // 一、一次扫描建正/反图(R1 的收益,也是 D1 的成立前提)
  53. let start = performance.now()
  54. const graph = buildRefGraph(root)
  55. const buildMs = performance.now() - start
  56. probe(graph)
  57. // 二、图建好后走 BFS 闭包(证明闭包本身不是瓶颈)
  58. start = performance.now()
  59. analyzeImpact(root, '设定/类0/子0/条0.md', { graph })
  60. const closureMs = performance.now() - start
  61. // 三、逐跳重扫(稻草人对照)——没人会那样写,只作对照
  62. function naiveOneHop(dir = root, acc = []) {
  63. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  64. if (entry.name === '.git' || entry.name === 'node_modules') continue
  65. const abs = path.join(dir, entry.name)
  66. if (entry.isDirectory()) naiveOneHop(abs, acc)
  67. else if (entry.isFile() && entry.name.endsWith('.md')) acc.push(path.relative(root, abs).replace(/\\/g, '/'))
  68. }
  69. return acc
  70. }
  71. function naiveRescan(changed) {
  72. // 逐跳重扫的稻草人:每扩一跳就全仓重扫一遍,拿本轮前沿当命中集合
  73. const seen = new Set([changed])
  74. for (let grew = true; grew;) {
  75. grew = false
  76. const frontier = new Set(seen)
  77. for (const rel of naiveOneHop()) {
  78. if (seen.has(rel)) continue
  79. const text = fs.readFileSync(path.join(root, rel), 'utf8')
  80. const m = /^---\n([\s\S]*?)\n---/.exec(text)
  81. if (m === null) continue
  82. const refs = [...m[1].matchAll(/来源:([^@\n]+)@\d+/g)].map((x) => x[1])
  83. if (refs.some((r) => frontier.has(r))) {
  84. seen.add(rel)
  85. grew = true
  86. }
  87. }
  88. }
  89. return seen
  90. }
  91. start = performance.now()
  92. const naiveSize = naiveRescan('设定/类0/子0/条0.md').size
  93. const naiveMs = performance.now() - start
  94. fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
  95. console.log(JSON.stringify({
  96. files: SETTING_COUNT + CHAPTER_COUNT,
  97. edges: EXPECTED_EDGES,
  98. naiveClosureNodes: naiveSize,
  99. buildGraphMs: Number(buildMs.toFixed(3)),
  100. closureMs: Number(closureMs.toFixed(3)),
  101. naiveRescanMs: Number(naiveMs.toFixed(1)),
  102. note: 'naiveRescanMs 是逐跳重扫的稻草人对照,没人会那样写;ADR 触发条件:真实调用点延迟超 300ms 或出现热路径消费方,才回头上快照层(拍板 D1)',
  103. }))