@agentskit/doc-bridge 1.7.44 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/CHANGELOG.md +471 -0
  2. package/CONTRIBUTING.md +29 -4
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15888 -6061
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +91 -9
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/index-Beor6Yhi.d.ts +792 -0
  15. package/dist/index.d.ts +9979 -3257
  16. package/dist/index.js +15954 -5774
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/RELEASE.md +22 -8
  22. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  23. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  24. package/docs/adr/0004-controlled-study-runner.md +25 -0
  25. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  26. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  27. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  28. package/docs/adr/0008-study-verification-boundary.md +21 -0
  29. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  30. package/docs/agent-corpus/INDEX.md +14 -3
  31. package/docs/agent-corpus/OVERVIEW.md +25 -0
  32. package/docs/agent-corpus/chat.md +7 -3
  33. package/docs/agent-corpus/cli.md +18 -2
  34. package/docs/agent-corpus/conformance.md +14 -2
  35. package/docs/agent-corpus/doc-bridge.md +48 -1
  36. package/docs/agent-corpus/doctor.md +10 -2
  37. package/docs/agent-corpus/gates.md +6 -2
  38. package/docs/agent-corpus/mcp.md +15 -2
  39. package/docs/agent-corpus/memory.md +6 -2
  40. package/docs/agent-corpus/query.md +35 -2
  41. package/docs/bench/README.md +122 -0
  42. package/docs/bench/retrieval-baseline-v1.json +28 -0
  43. package/docs/bench/retrieval-suite-v1.json +1033 -0
  44. package/docs/chat-and-rag.md +3 -2
  45. package/docs/for-agents.md +9 -1
  46. package/docs/getting-started.md +4 -11
  47. package/docs/guides/gate-ci.md +11 -1
  48. package/docs/guides/install-and-run.md +9 -65
  49. package/docs/index.md +22 -1
  50. package/docs/knowledge-engine-runbook.md +51 -4
  51. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  52. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  53. package/docs/landing/index.html +119 -5
  54. package/docs/loop-workflow.md +117 -0
  55. package/docs/mcp.md +6 -1
  56. package/docs/parity/public-claims-v1.json +145 -0
  57. package/docs/playbook/doc-bridge-pattern.md +1 -1
  58. package/docs/query.md +90 -2
  59. package/docs/recipes/index-pipeline.md +1 -1
  60. package/docs/schemas/agent-handoff-v1.md +15 -0
  61. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  62. package/docs/spec/benchmark-v1.md +39 -1
  63. package/docs/spec/cli.md +30 -10
  64. package/docs/spec/config-v1.md +192 -8
  65. package/docs/spec/documentation-audit-v1.md +61 -0
  66. package/docs/spec/enrichment-overlay-v1.md +241 -0
  67. package/docs/spec/graph-signals-v1.md +92 -0
  68. package/docs/spec/incremental-scan-v1.md +102 -0
  69. package/docs/spec/markdown-analyzer-v1.md +73 -0
  70. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  71. package/docs/spec/measured-enrichment-v1.md +229 -0
  72. package/docs/spec/public-parity-v1.md +119 -0
  73. package/docs/spec/registry-agents.md +6 -0
  74. package/docs/spec/render-v1.md +122 -0
  75. package/docs/spec/retrieval-index-v1.md +164 -0
  76. package/docs/spec/study-metrics-v1.md +58 -0
  77. package/docs/spec/study-protocol-v1.md +46 -0
  78. package/docs/spec/study-provider-cli-v1.md +116 -0
  79. package/docs/spec/study-runner-v1.md +35 -0
  80. package/docs/spec/study-task-suite-v1.md +41 -0
  81. package/docs/spec/study-verification-v1.md +40 -0
  82. package/docs/study/README.md +84 -0
  83. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  86. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  87. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  88. package/docs/study/ab-baseline-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  90. package/docs/study/ab-baseline-result-v1.json +79 -0
  91. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  92. package/docs/study/historical-evidence-v1.json +252 -0
  93. package/docs/study/observation-ledger-v1.json +30632 -0
  94. package/docs/study/phase3-task-coverage-v1.json +34 -0
  95. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  96. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  97. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  98. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  99. package/docs/study/pilot-round-2026-08-31.json +46 -0
  100. package/docs/study/protocol-v1.json +90 -0
  101. package/docs/study/publication-gate-v1.md +45 -0
  102. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  103. package/docs/study/quality-scorecard-v1.json +38 -0
  104. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  105. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  106. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  107. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  108. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  109. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  110. package/docs/study/round-4-confirmation-v1.json +75 -0
  111. package/docs/study/round-4-confirmation-v1.md +55 -0
  112. package/docs/study/run-plan-v1.json +33 -0
  113. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  114. package/docs/study/task-suite-v1.json +96 -0
  115. package/docs/study/token-efficiency-plan-v1.md +337 -0
  116. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  117. package/docs/study/verification-binding-v1.json +27 -0
  118. package/docs/validation-cycle-plan.md +33 -0
  119. package/docs/verification-harness.md +15 -6
  120. package/ecosystem-claims.json +2 -2
  121. package/ecosystem-upstream.json +2 -2
  122. package/ecosystem.json +4 -4
  123. package/mcpb/manifest.json +9 -1
  124. package/package.json +89 -72
  125. package/scripts/check-ecosystem-upstream.mjs +36 -7
  126. package/scripts/report-visual-check.mjs +20 -3
  127. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  128. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  129. package/src/agents/registry-adapter.ts +192 -24
  130. package/src/audit/documentation.ts +513 -0
  131. package/src/bench/baseline.ts +198 -0
  132. package/src/bench/overlay-delta.ts +139 -0
  133. package/src/bench/retrieval.ts +319 -0
  134. package/src/budget/compile.ts +91 -0
  135. package/src/budget/sections.ts +70 -0
  136. package/src/cli/demo.ts +2 -2
  137. package/src/cli/program.ts +699 -79
  138. package/src/cli/usage.ts +71 -0
  139. package/src/config/defaults.ts +1 -0
  140. package/src/config/index.ts +4 -0
  141. package/src/config/load-config.ts +7 -1
  142. package/src/config/schema.ts +121 -4
  143. package/src/conformance/documentation-standard-v1.ts +22 -14
  144. package/src/discovery/areas.ts +182 -0
  145. package/src/discovery/documentation.ts +255 -23
  146. package/src/discovery/identity.ts +24 -0
  147. package/src/discovery/incremental.ts +314 -0
  148. package/src/discovery/inputs.ts +110 -0
  149. package/src/discovery/markdown.ts +481 -0
  150. package/src/discovery/repository.ts +557 -125
  151. package/src/doctor/run-doctor.ts +246 -27
  152. package/src/enrich/approvals.ts +190 -0
  153. package/src/enrich/cache.ts +93 -0
  154. package/src/enrich/context-pack.ts +272 -0
  155. package/src/enrich/overlay.ts +255 -0
  156. package/src/enrich/review.ts +106 -0
  157. package/src/enrich/stage.ts +374 -0
  158. package/src/enrich/stats.ts +100 -0
  159. package/src/enrich/validate.ts +410 -0
  160. package/src/federation/llms.ts +74 -24
  161. package/src/findings/report.ts +103 -0
  162. package/src/fixes/proposals.ts +4 -3
  163. package/src/graph/build.ts +356 -0
  164. package/src/graph/memory.ts +208 -0
  165. package/src/index-builder/build-handoffs.ts +22 -11
  166. package/src/index-builder/build-index.ts +132 -3
  167. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  168. package/src/index-builder/llms-txt.ts +48 -8
  169. package/src/index-builder/project-corpus.ts +111 -0
  170. package/src/index-builder/watch-index.ts +1 -1
  171. package/src/index.ts +630 -2
  172. package/src/lib/bounded-text.ts +15 -10
  173. package/src/lib/fuzzy-match.ts +235 -0
  174. package/src/mcp/knowledge.ts +554 -0
  175. package/src/mcp/server.ts +113 -18
  176. package/src/metrics/benchmark.ts +21 -0
  177. package/src/parity/check.ts +309 -0
  178. package/src/parity/claims.ts +259 -0
  179. package/src/parity/resolve.ts +160 -0
  180. package/src/query/handoff.ts +326 -0
  181. package/src/query/load-index.ts +53 -1
  182. package/src/query/query.ts +92 -59
  183. package/src/query/search.ts +289 -92
  184. package/src/query/text.ts +155 -0
  185. package/src/reconciliation/reconcile.ts +148 -15
  186. package/src/render/data.ts +356 -0
  187. package/src/render/engine.ts +398 -0
  188. package/src/render/generated.ts +77 -0
  189. package/src/render/render.ts +209 -0
  190. package/src/render/template-source.ts +52 -0
  191. package/src/render/templates.ts +289 -0
  192. package/src/report/html.ts +23 -17
  193. package/src/retrieval/bm25.ts +161 -0
  194. package/src/retrieval/project.ts +495 -0
  195. package/src/retrieval/rank.ts +383 -0
  196. package/src/retrieval/weights.ts +39 -0
  197. package/src/retriever/doc-bridge-retriever.ts +100 -15
  198. package/src/rules/engine.ts +45 -12
  199. package/src/safety/repository.ts +1 -1
  200. package/src/schemas/agent-handoff.ts +56 -0
  201. package/src/schemas/budget.ts +37 -0
  202. package/src/schemas/doc-bridge-index.ts +53 -2
  203. package/src/schemas/enrichment.ts +369 -0
  204. package/src/schemas/json-schemas.ts +39 -2
  205. package/src/schemas/knowledge.ts +19 -3
  206. package/src/schemas/retrieval-index.ts +152 -0
  207. package/src/shims/graphology.d.ts +91 -0
  208. package/src/study/adjudication.ts +196 -0
  209. package/src/study/execution.ts +350 -0
  210. package/src/study/expectations.ts +219 -0
  211. package/src/study/metrics.ts +467 -0
  212. package/src/study/protocol.ts +271 -0
  213. package/src/study/provider-cli.ts +115 -0
  214. package/src/study/provider-telemetry.ts +47 -0
  215. package/src/study/quality-scorecard.ts +164 -0
  216. package/src/study/runner.ts +461 -0
  217. package/src/study/task-suite.ts +321 -0
  218. package/src/study/verification.ts +134 -0
  219. package/src/validate.ts +8 -5
  220. package/src/version.ts +1 -1
  221. package/src/workflow/engine.ts +36 -11
  222. package/dist/index-C2PCQSrB.d.ts +0 -2251
  223. package/scripts/verification-harness.mjs +0 -483
@@ -8,7 +8,43 @@ import { expandWorkspaceGlobs } from '../lib/glob-expand.js'
8
8
  import { detectPackageManager } from '../lib/package-manager.js'
9
9
  import { toPosix } from '../lib/paths.js'
10
10
  import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
11
- import { DEFAULT_SAFETY_EXCLUDES, safeWalkFiles } from '../safety/repository.js'
11
+ import { safeWalkFiles } from '../safety/repository.js'
12
+ import { GRAPH_ANALYZER_VERSION, areaSuggestionCoverage } from '../graph/build.js'
13
+ import { deriveAreas, type AreaModule } from './areas.js'
14
+ import { entityId } from './identity.js'
15
+ import {
16
+ emptyLedger,
17
+ exportsOf,
18
+ declaredExportsOf,
19
+ fileContentHash,
20
+ indexPriorSnapshot,
21
+ moduleUniverseFingerprint,
22
+ replayableRelations,
23
+ resolutionFingerprint,
24
+ reuseCoverage,
25
+ reuseRefusal,
26
+ type PreviousSnapshot,
27
+ type PriorFile,
28
+ } from './incremental.js'
29
+ import {
30
+ MARKDOWN_ANALYZER_VERSION,
31
+ analyzeMarkdownDocument,
32
+ markdownPathCandidateIndex,
33
+ declaredAudience,
34
+ markdownContentHash,
35
+ parseMarkdownDocument,
36
+ type MarkdownDocumentV1,
37
+ } from './markdown.js'
38
+ import {
39
+ CONFIG_EXTENSIONS,
40
+ DEFAULT_MAX_FILES,
41
+ DOCUMENT_EXTENSIONS,
42
+ SOURCE_EXTENSIONS,
43
+ documentClassification,
44
+ exportedNames,
45
+ safeWalkOptions,
46
+ scriptKind,
47
+ } from './inputs.js'
12
48
  import {
13
49
  DiscoverySnapshotV1Schema,
14
50
  type DiscoverySnapshotV1,
@@ -17,12 +53,11 @@ import {
17
53
  type KnowledgeRelation,
18
54
  } from '../schemas/knowledge.js'
19
55
 
20
- const SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'] as const
21
- const DOCUMENT_EXTENSIONS = ['.md', '.mdx'] as const
22
- const DEFAULT_MAX_FILES = 10_000
23
56
  const EMPTY_HASH = '0'.repeat(64)
24
57
  const DEFAULT_RUNTIME_WIRING_METHODS = ['register', 'use', 'mount', 'attach'] as const
25
58
  const TEST_MODULE_PATTERN = /(?:\.test|\.spec|__tests__)/
59
+ /** Coverage is evidence, not a log: past this many notes the list stops informing anyone. */
60
+ const MAX_MARKDOWN_NOTES = 32
26
61
 
27
62
  type JsonRecord = Record<string, unknown>
28
63
 
@@ -54,6 +89,14 @@ type DiscoveryOptions = {
54
89
  readonly config?: DocBridgeConfigV1
55
90
  readonly maxFiles?: number
56
91
  readonly maxBytes?: number
92
+ /**
93
+ * A snapshot from a previous scan.
94
+ *
95
+ * Entities whose file hash is unchanged are taken from it instead of parsed again. Supplying one
96
+ * cannot change the result: a reused run either produces the same snapshot a cold run would, or
97
+ * the reuse is refused. Omit it to scan from scratch.
98
+ */
99
+ readonly previous?: PreviousSnapshot
57
100
  }
58
101
 
59
102
  const isRecord = (value: unknown): value is JsonRecord =>
@@ -71,17 +114,6 @@ const readJson = (path: string): { readonly value?: JsonRecord; readonly error?:
71
114
  const relativePath = (root: string, path: string): string =>
72
115
  toPosix(relative(root, path)) || '.'
73
116
 
74
- const MAX_ID_LENGTH = 256
75
- const ID_HASH_LENGTH = 32
76
-
77
- const entityId = (kind: string, value: string): string => {
78
- const fullId = `${kind}:${value}`
79
- if (fullId.length <= MAX_ID_LENGTH) return fullId
80
-
81
- const suffix = `:${sha256NormalizedV1(fullId).slice(0, ID_HASH_LENGTH)}`
82
- return `${fullId.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`
83
- }
84
-
85
117
  const lineEvidence = (
86
118
  source: 'code' | 'configuration' | 'documentation',
87
119
  root: string,
@@ -100,13 +132,6 @@ const firstLineContaining = (text: string, pattern: string): number | undefined
100
132
  return line >= 0 ? line + 1 : undefined
101
133
  }
102
134
 
103
- const documentClassification = (path: string): string => {
104
- if (/(^|\/)docs\/for-agents(?:\/|$)/.test(path)) return 'agent'
105
- if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
106
- if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
107
- return 'unclassified'
108
- }
109
-
110
135
  const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
111
136
  typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
112
137
 
@@ -207,88 +232,75 @@ const readCompilerOptions = (root: string): { readonly options: ts.CompilerOptio
207
232
  return { options: config.options }
208
233
  }
209
234
 
210
- const scriptKind = (path: string): ts.ScriptKind => {
211
- switch (extname(path)) {
212
- case '.js': return ts.ScriptKind.JS
213
- case '.jsx': return ts.ScriptKind.JSX
214
- case '.mjs': return ts.ScriptKind.JS
215
- case '.cjs': return ts.ScriptKind.JS
216
- case '.ts': return ts.ScriptKind.TS
217
- case '.tsx': return ts.ScriptKind.TSX
218
- case '.mts': return ts.ScriptKind.TS
219
- case '.cts': return ts.ScriptKind.TS
220
- default: return ts.ScriptKind.Unknown
221
- }
222
- }
223
-
224
235
  const nodeEvidence = (root: string, path: string, sourceFile: ts.SourceFile, node: ts.Node): Evidence => {
225
236
  const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
226
237
  const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1
227
238
  return lineEvidence('code', root, path, start, end)
228
239
  }
229
240
 
230
- const isExported = (node: ts.Node): boolean => {
231
- const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
232
- return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
233
- }
234
-
235
- const exportedNames = (sourceFile: ts.SourceFile): string[] => {
236
- const names = new Set<string>()
237
- const addDeclarationName = (node: ts.Declaration): void => {
238
- if (!isExported(node)) return
239
- const name = ts.getNameOfDeclaration(node)
240
- if (name && ts.isIdentifier(name)) names.add(name.text)
241
- }
242
-
243
- const visit = (node: ts.Node): void => {
244
- if (ts.isExportDeclaration(node)) {
245
- if (!node.exportClause) names.add('*')
246
- else if (ts.isNamedExports(node.exportClause)) {
247
- for (const element of node.exportClause.elements) names.add(element.name.text)
248
- }
249
- } else if (ts.isExportAssignment(node)) {
250
- names.add('default')
251
- } else if (
252
- ts.isClassDeclaration(node) ||
253
- ts.isFunctionDeclaration(node) ||
254
- ts.isInterfaceDeclaration(node) ||
255
- ts.isTypeAliasDeclaration(node) ||
256
- ts.isEnumDeclaration(node) ||
257
- ts.isModuleDeclaration(node)
258
- ) {
259
- addDeclarationName(node)
260
- } else if (ts.isVariableStatement(node) && isExported(node)) {
261
- for (const declaration of node.declarationList.declarations) {
262
- if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
263
- }
264
- }
265
- ts.forEachChild(node, visit)
266
- }
267
- visit(sourceFile)
268
- return [...names].sort()
269
- }
270
-
271
241
  const moduleReferences = (
272
242
  root: string,
273
243
  path: string,
274
244
  sourceFile: ts.SourceFile,
275
245
  runtimeWiringMethods: ReadonlySet<string>,
276
- ): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly hasDynamic: boolean; readonly hasLiteralDynamic: boolean; readonly hasRuntimeWiring: boolean; readonly hasUnresolvedRuntimeWiring: boolean } => {
246
+ ): { readonly references: readonly ImportReference[]; readonly exports: readonly string[]; readonly dynamicEvidence: readonly Evidence[]; readonly hasDynamic: boolean; readonly hasLiteralDynamic: boolean; readonly hasRuntimeWiring: boolean; readonly hasUnresolvedRuntimeWiring: boolean } => {
277
247
  const references: ImportReference[] = []
248
+ const dynamicEvidence: Evidence[] = []
278
249
  let hasDynamic = false
279
250
  let hasLiteralDynamic = false
280
251
  let hasRuntimeWiring = false
281
252
  let hasUnresolvedRuntimeWiring = false
282
253
  const importedBindings = new Map<string, string>()
283
254
  const staticStringBindings = new Map<string, string | undefined>()
255
+ const localBindings = new Set<string>()
256
+ const resolveStaticString = (expression: ts.Expression): string | undefined => {
257
+ if (ts.isStringLiteralLike(expression)) return expression.text
258
+ if (ts.isIdentifier(expression)) return staticStringBindings.get(expression.text)
259
+ if (ts.isParenthesizedExpression(expression)) return resolveStaticString(expression.expression)
260
+ if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.PlusToken) {
261
+ const left = resolveStaticString(expression.left)
262
+ const right = resolveStaticString(expression.right)
263
+ return left !== undefined && right !== undefined ? left + right : undefined
264
+ }
265
+ return undefined
266
+ }
284
267
  const collectStaticStringBindings = (node: ts.Node): void => {
285
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isStringLiteralLike(node.initializer) && ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0) {
268
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isVariableDeclarationList(node.parent) && (node.parent.flags & ts.NodeFlags.Const) !== 0) {
269
+ const value = resolveStaticString(node.initializer)
286
270
  const previous = staticStringBindings.get(node.name.text)
287
- staticStringBindings.set(node.name.text, !staticStringBindings.has(node.name.text) || previous === node.initializer.text ? node.initializer.text : undefined)
271
+ staticStringBindings.set(node.name.text, !staticStringBindings.has(node.name.text) || previous === value ? value : undefined)
288
272
  }
273
+ if (
274
+ (ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node)) &&
275
+ ts.isIdentifier(node.name)
276
+ ) localBindings.add(node.name.text)
277
+ if (
278
+ (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) &&
279
+ node.name
280
+ ) localBindings.add(node.name.text)
289
281
  ts.forEachChild(node, collectStaticStringBindings)
290
282
  }
291
283
  collectStaticStringBindings(sourceFile)
284
+ const addImportedBindingReference = (expression: ts.Expression, node: ts.Node): boolean => {
285
+ if (ts.isIdentifier(expression)) {
286
+ const specifier = importedBindings.get(expression.text)
287
+ if (specifier) {
288
+ addReference({ text: specifier } as ts.StringLiteralLike, 'runtime-wiring', node, 'runtime-wiring-static')
289
+ return true
290
+ }
291
+ return false
292
+ }
293
+ if (ts.isPropertyAccessExpression(expression)) return addImportedBindingReference(expression.expression, node)
294
+ if (ts.isCallExpression(expression)) return addImportedBindingReference(expression.expression, node)
295
+ return false
296
+ }
297
+ const isKnownLocal = (expression: ts.Expression): boolean => {
298
+ if (ts.isIdentifier(expression)) return localBindings.has(expression.text) || importedBindings.has(expression.text)
299
+ if (expression.kind === ts.SyntaxKind.ThisKeyword) return true
300
+ if (ts.isPropertyAccessExpression(expression)) return isKnownLocal(expression.expression)
301
+ if (ts.isCallExpression(expression)) return isKnownLocal(expression.expression)
302
+ return ts.isStringLiteralLike(expression)
303
+ }
292
304
  const addReference = (specifier: ts.StringLiteralLike, kind: ImportReference['kind'], node: ts.Node, detection?: ImportReference['detection']): void => {
293
305
  references.push({ specifier: specifier.text, kind, evidence: nodeEvidence(root, path, sourceFile, node), ...(detection ? { detection } : {}) })
294
306
  }
@@ -309,32 +321,43 @@ const moduleReferences = (
309
321
  importedBindings.set(node.name.text, node.moduleReference.expression.text)
310
322
  } else if (ts.isCallExpression(node)) {
311
323
  if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
312
- if (node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) {
324
+ const specifier = node.arguments[0] ? resolveStaticString(node.arguments[0]) : undefined
325
+ if (specifier !== undefined) {
313
326
  hasLiteralDynamic = true
314
- addReference(node.arguments[0], 'imports', node, 'dynamic-literal')
315
- } else if (node.arguments[0] && ts.isIdentifier(node.arguments[0]) && staticStringBindings.get(node.arguments[0].text)) {
316
- hasLiteralDynamic = true
317
- addReference({ text: staticStringBindings.get(node.arguments[0].text)! } as ts.StringLiteralLike, 'imports', node, 'dynamic-literal')
318
- } else hasDynamic = true
327
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
328
+ addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node, 'dynamic-literal')
329
+ } else {
330
+ hasDynamic = true
331
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
332
+ }
319
333
  } else if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
320
334
  const argument = node.arguments[0]
321
- if (argument && ts.isStringLiteralLike(argument)) addReference(argument, 'imports', node)
322
- else if (argument && ts.isIdentifier(argument) && staticStringBindings.get(argument.text)) addReference({ text: staticStringBindings.get(argument.text)! } as ts.StringLiteralLike, 'imports', node)
323
- else hasDynamic = true
335
+ const specifier = argument ? resolveStaticString(argument) : undefined
336
+ if (specifier !== undefined) {
337
+ /*
338
+ * A literal `require` is a dynamic load that resolved, so it counts as one.
339
+ *
340
+ * The evidence was always recorded here and the flag was not, which left the aggregate
341
+ * entry sampling a file that had no per-file entry of its own — and made the aggregate
342
+ * unreproducible from the per-file facts, which is exactly what a reused scan replays.
343
+ */
344
+ hasLiteralDynamic = true
345
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
346
+ addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node)
347
+ } else {
348
+ hasDynamic = true
349
+ dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
350
+ }
324
351
  } else if (ts.isPropertyAccessExpression(node.expression) && runtimeWiringMethods.has(node.expression.name.text)) {
325
352
  hasRuntimeWiring = true
326
- const before = references.length
327
- let hasPotentialTargetArgument = false
353
+ let hasUnresolvedTarget = false
328
354
  for (const argument of node.arguments) {
329
- if (ts.isIdentifier(argument)) {
330
- hasPotentialTargetArgument = true
331
- const specifier = importedBindings.get(argument.text)
332
- if (specifier) addReference({ text: specifier } as ts.StringLiteralLike, 'runtime-wiring', node, 'runtime-wiring-static')
333
- } else if (ts.isPropertyAccessExpression(argument) || ts.isCallExpression(argument)) {
334
- hasPotentialTargetArgument = true
335
- }
355
+ if (ts.isStringLiteralLike(argument)) continue
356
+ if (addImportedBindingReference(argument, node)) continue
357
+ if (!isKnownLocal(argument)) hasUnresolvedTarget = true
336
358
  }
337
- if (hasPotentialTargetArgument && references.length === before) hasUnresolvedRuntimeWiring = true
359
+ const receiver = node.expression.expression
360
+ if (hasUnresolvedTarget && !isKnownLocal(receiver)) hasUnresolvedRuntimeWiring = true
338
361
  }
339
362
  }
340
363
  ts.forEachChild(node, visit)
@@ -343,6 +366,7 @@ const moduleReferences = (
343
366
  return {
344
367
  references,
345
368
  exports: exportedNames(sourceFile),
369
+ dynamicEvidence,
346
370
  hasDynamic,
347
371
  hasLiteralDynamic,
348
372
  hasRuntimeWiring,
@@ -434,6 +458,10 @@ const hasPackageManagerMetadata = (root: string, rootManifest: JsonRecord | unde
434
458
  existsSync(join(root, 'package-lock.json')),
435
459
  )
436
460
 
461
+ const PIPELINE_VERSION = '1.5.0'
462
+ const ANALYZER_VERSIONS: Readonly<Record<string, string>> = { repository: '1.3.0', 'js-ts': '1.3.5', markdown: MARKDOWN_ANALYZER_VERSION, graph: GRAPH_ANALYZER_VERSION }
463
+ const configurationHashOf = (config: DocBridgeConfigV1 | undefined): string => sha256NormalizedV1(config ?? {})
464
+
437
465
  const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: readonly string[], entities: readonly KnowledgeEntity[], relations: readonly KnowledgeRelation[], coverage: DiscoverySnapshotV1['coverage']): DiscoverySnapshotV1 => {
438
466
  const revision = sourceRevision(root, files)
439
467
  const base = {
@@ -444,34 +472,28 @@ const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: re
444
472
  project: { name: (entities.find((entity) => entity.kind === 'package' && entity.path === '.')?.name ?? basename(root)), root: '.' },
445
473
  sourceRevision: revision.value,
446
474
  sourceRevisionKind: revision.kind,
447
- configurationHash: sha256NormalizedV1(config ?? {}),
448
- pipelineVersion: '1.0.0',
449
- analyzerVersions: { repository: '1.0.0', 'js-ts': '1.3.0' },
475
+ configurationHash: configurationHashOf(config),
476
+ pipelineVersion: PIPELINE_VERSION,
477
+ analyzerVersions: ANALYZER_VERSIONS,
450
478
  entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
451
479
  relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
452
- coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? ({ repository: '1.0.0', 'js-ts': '1.3.0' }[entry.analyzer] ?? '1.0.0') })),
480
+ coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (ANALYZER_VERSIONS[entry.analyzer] ?? '1.0.0') })),
453
481
  }
454
482
  return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
455
483
  }
456
484
 
457
485
  export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapshotV1 => {
458
486
  const root = resolve(opts.root ?? process.cwd())
459
- const safety = opts.config?.safety
460
- const maxFiles = opts.maxFiles ?? safety?.maxFiles ?? DEFAULT_MAX_FILES
461
- const maxBytes = opts.maxBytes ?? safety?.maxBytes
462
- const safeOptions = {
463
- exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
464
- maxFiles,
465
- ...(maxBytes !== undefined ? { maxBytes } : {}),
466
- ...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
467
- ...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
468
- }
487
+ const safeOptions = safeWalkOptions(opts.config, {
488
+ maxFiles: opts.maxFiles ?? opts.config?.safety?.maxFiles ?? DEFAULT_MAX_FILES,
489
+ ...(opts.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {}),
490
+ })
469
491
  const rootManifestPath = join(root, 'package.json')
470
492
  const rootManifest = readJson(rootManifestPath).value
471
493
  const packageResult = discoverPackages(root, rootManifest, opts.config)
472
494
  const sourceWalk = safeWalkFiles(root, { extensions: SOURCE_EXTENSIONS, ...safeOptions })
473
495
  const documentWalk = safeWalkFiles(root, { extensions: DOCUMENT_EXTENSIONS, ...safeOptions })
474
- const configWalk = safeWalkFiles(root, { extensions: ['.json', '.yaml', '.yml', '.js', '.ts'], ...safeOptions })
496
+ const configWalk = safeWalkFiles(root, { extensions: CONFIG_EXTENSIONS, ...safeOptions })
475
497
  const sourcePaths = sourceWalk.files
476
498
  const documentPaths = documentWalk.files
477
499
  const configPaths = configWalk.files
@@ -502,28 +524,280 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
502
524
 
503
525
  for (const pkg of packageResult.packages) {
504
526
  const text = readFileSync(pkg.manifestPath, 'utf8')
505
- addEntity({ id: pkg.id, kind: 'package', name: pkg.name ?? pkg.path, path: pkg.path, provenance: 'observed', evidence: [lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, '"name"'))] })
527
+ addEntity({
528
+ id: pkg.id,
529
+ kind: 'package',
530
+ name: pkg.name ?? pkg.path,
531
+ path: pkg.path,
532
+ provenance: 'observed',
533
+ evidence: [
534
+ {
535
+ ...lineEvidence('configuration', root, pkg.manifestPath, firstLineContaining(text, '"name"')),
536
+ contentHash: fileContentHash(text),
537
+ },
538
+ ],
539
+ })
540
+ }
541
+
542
+ const compiler = readCompilerOptions(root)
543
+ /*
544
+ * What the previous scan already knows.
545
+ *
546
+ * Indexed before anything is parsed, because the decision to parse a file at all depends on
547
+ * whether its hash matches what that scan recorded.
548
+ */
549
+ const ledger = emptyLedger()
550
+ const refusal = opts.previous
551
+ ? reuseRefusal(opts.previous, { pipelineVersion: PIPELINE_VERSION, analyzerVersions: ANALYZER_VERSIONS, configurationHash: configurationHashOf(opts.config) })
552
+ : undefined
553
+ if (refusal) ledger.invalidated.push(refusal)
554
+ const prior = opts.previous && !refusal ? indexPriorSnapshot(opts.previous, compiler.options) : undefined
555
+
556
+ /*
557
+ * Whether a reference can resolve differently than it did last time.
558
+ *
559
+ * A module's own bytes decide its entity; what it resolves *to* depends on which modules and
560
+ * packages exist and on the compiler options that turn a specifier into a path. If any of that
561
+ * moved, nothing is reused however unchanged a file is — an import of `./new.js` resolved to
562
+ * nothing yesterday and resolves to a module today. Reusing the entity alone would save no
563
+ * parse, because the pass that reads references would have to build the tree regardless.
564
+ */
565
+ const moduleUniverse = moduleUniverseFingerprint({
566
+ modulePaths: sourcePaths.map((absPath) => relativePath(root, absPath)),
567
+ packages: packageResult.packages.map((pkg) => ({ id: pkg.id, path: pkg.path, ...(pkg.name ? { name: pkg.name } : {}) })),
568
+ compilerOptions: compiler.options,
569
+ })
570
+ const reuseModuleRelations = Boolean(prior) && prior?.moduleUniverse === moduleUniverse
571
+ if (prior && !reuseModuleRelations) {
572
+ ledger.invalidated.push('the set of modules, packages or compiler options changed')
506
573
  }
507
574
 
508
575
  const modules = new Map<string, ModuleInfo>()
576
+ const modulesByPath = new Map<string, string>()
577
+ const reusedModules = new Set<string>()
578
+ const areaModules: AreaModule[] = []
579
+ const declaringModules = new Map<string, string[]>()
580
+ const exportingModules = new Map<string, string[]>()
581
+ const registerSymbols = (id: string, exports: readonly string[], declared: ReadonlySet<string>): void => {
582
+ for (const name of exports) {
583
+ if (name === '*' || name === 'default') continue
584
+ const owners = declared.has(name) ? declaringModules : exportingModules
585
+ const existing = owners.get(name)
586
+ if (existing) existing.push(id)
587
+ else owners.set(name, [id])
588
+ }
589
+ }
590
+
509
591
  for (const absPath of sourcePaths) {
510
592
  const path = relativePath(root, absPath)
511
593
  const pkg = packageForModule(packageResult.packages, absPath)
512
594
  const id = entityId('module', path)
513
595
  const text = readFileSync(absPath, 'utf8')
514
- const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
515
- const exports = exportedNames(sourceFile)
596
+ const contentHash = fileContentHash(text)
516
597
  modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
517
- addEntity({ id, kind: 'module', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('code', root, absPath, 1, sourceFile.getLineAndCharacterOfPosition(sourceFile.getEnd()).line + 1)], ...(exports.length ? { metadata: { exports, test: TEST_MODULE_PATTERN.test(path) } } : {}) })
598
+ modulesByPath.set(path, id)
599
+ if (pkg) areaModules.push({ moduleId: id, path, packageId: pkg.id, packagePath: pkg.path })
600
+
601
+ const priorModule = reuseModuleRelations ? prior?.modules.get(path) : undefined
602
+ if (priorModule && priorModule.contentHash === contentHash) {
603
+ /*
604
+ * The file is byte-identical to the one that produced this entity, so the entity is the
605
+ * answer — no syntax tree needed. Which names it declares as opposed to forwards is read
606
+ * back from `reexports`, because that distinction only exists in the tree.
607
+ */
608
+ addEntity(priorModule.entity)
609
+ reusedModules.add(path)
610
+ ledger.reusedEntities += 1
611
+ registerSymbols(id, exportsOf(priorModule.entity), new Set(declaredExportsOf(priorModule.entity)))
612
+ } else {
613
+ const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
614
+ const exports = exportedNames(sourceFile)
615
+ const declared = new Set(exportedNames(sourceFile, { declaredOnly: true }))
616
+ const reexports = exports.filter((name) => !declared.has(name))
617
+ registerSymbols(id, exports, declared)
618
+ addEntity({
619
+ id,
620
+ kind: 'module',
621
+ name: basename(absPath),
622
+ path,
623
+ provenance: 'observed',
624
+ evidence: [
625
+ {
626
+ ...lineEvidence('code', root, absPath, 1, sourceFile.getLineAndCharacterOfPosition(sourceFile.getEnd()).line + 1),
627
+ contentHash,
628
+ },
629
+ ],
630
+ ...(exports.length
631
+ ? { metadata: { exports, ...(reexports.length ? { reexports } : {}), test: TEST_MODULE_PATTERN.test(path) } }
632
+ : {}),
633
+ })
634
+ }
518
635
  if (pkg) addRelation({ id: entityId('relation', `${pkg.id}:contains:${id}`), kind: 'contains', from: pkg.id, to: id, provenance: 'observed', evidence: [lineEvidence('code', root, absPath, 1)] })
519
636
  }
520
637
 
638
+ /*
639
+ * Areas: the directory level between a package and a file.
640
+ *
641
+ * Derived from convention and from what an ownership record already names, then attached to the
642
+ * graph with `contains` — package to area, area to its nested areas, area to module. Each
643
+ * module belongs to exactly one area, the most specific one, so an aggregation at area scope
644
+ * has one answer per module.
645
+ */
646
+ const areas = deriveAreas({
647
+ modules: areaModules,
648
+ ownership: Object.entries(opts.config?.routing?.options?.ownership ?? {}).map(([id, record]) => ({ id, path: record.path })),
649
+ ...(opts.config?.analysis?.areas?.depth !== undefined ? { depth: opts.config.analysis.areas.depth } : {}),
650
+ ...(opts.config?.analysis?.areas?.roots !== undefined ? { roots: opts.config.analysis.areas.roots } : {}),
651
+ })
652
+ const areasById = new Map(areas.map((area) => [area.id, area]))
653
+ const areasByPath = new Map(areas.map((area) => [area.path, area.id]))
654
+
655
+ for (const area of areas) {
656
+ addEntity({
657
+ id: area.id,
658
+ kind: 'area',
659
+ name: area.name,
660
+ path: area.path,
661
+ provenance: 'observed',
662
+ evidence: [
663
+ {
664
+ source: 'derived',
665
+ path: area.path,
666
+ context: `Directory groups ${area.moduleIds.length} module(s).`,
667
+ },
668
+ ],
669
+ metadata: {
670
+ moduleCount: area.moduleIds.length,
671
+ ...(area.ownershipId ? { ownershipId: area.ownershipId } : {}),
672
+ },
673
+ })
674
+
675
+ const parent = area.parentId && areasById.has(area.parentId) ? area.parentId : area.packageId
676
+ addRelation({
677
+ id: entityId('relation', `${parent}:contains:${area.id}`),
678
+ kind: 'contains',
679
+ from: parent,
680
+ to: area.id,
681
+ provenance: 'observed',
682
+ evidence: [{ source: 'derived', path: area.path }],
683
+ })
684
+ for (const moduleId of area.moduleIds) {
685
+ addRelation({
686
+ id: entityId('relation', `${area.id}:contains:${moduleId}`),
687
+ kind: 'contains',
688
+ from: area.id,
689
+ to: moduleId,
690
+ provenance: 'observed',
691
+ evidence: [{ source: 'derived', path: area.path }],
692
+ })
693
+ }
694
+ }
695
+
696
+ /*
697
+ * A symbol resolves to the module that declares it. Only when nothing declares it — a type
698
+ * forwarded through a barrel, say — do the re-exporting modules stand in, and then only if
699
+ * there is exactly one of them.
700
+ */
701
+ const symbolModules = new Map<string, readonly string[]>()
702
+ for (const [name, owners] of declaringModules) symbolModules.set(name, owners)
703
+ for (const [name, owners] of exportingModules) if (!symbolModules.has(name)) symbolModules.set(name, owners)
704
+
705
+ /*
706
+ * Documents are parsed first and added as entities after their relations are known, because
707
+ * whether a document's references were truncated is part of what the entity has to say.
708
+ */
709
+ const markdownDocuments: MarkdownDocumentV1[] = []
710
+ const documentsByPath = new Map<string, string>()
711
+ const documentFiles = new Map<string, string>()
712
+ const unreadableDocuments: string[] = []
521
713
  for (const absPath of documentPaths) {
522
- const path = relativePath(root, absPath)
523
- addEntity({ id: entityId('document', path), kind: 'document', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('documentation', root, absPath, 1)], metadata: { classification: documentClassification(path) } })
714
+ documentFiles.set(relativePath(root, absPath), absPath)
715
+ }
716
+ for (const path of documentFiles.keys()) documentsByPath.set(path, entityId('document', path))
717
+
718
+ /*
719
+ * Whether an id will be in this snapshot.
720
+ *
721
+ * A replayed relation is checked against what the scan is going to produce, not against what it
722
+ * has produced so far: documents and modules get their entities late, and an edge to a file that
723
+ * plainly exists must not be dropped for arriving early.
724
+ */
725
+ const plannedIds = new Set([...modulesByPath.values(), ...documentsByPath.values()])
726
+ const willExist = (id: string): boolean => entities.has(id) || plannedIds.has(id)
727
+
728
+ /**
729
+ * Put a reused entity's edges back.
730
+ *
731
+ * An edge whose internal target is gone is dropped — the file it pointed at was renamed or
732
+ * deleted, and a graph that keeps the edge is lying about the repository. An external or
733
+ * unresolved endpoint is re-created instead, because such an entity is in the snapshot only
734
+ * because something referenced it, and that something is exactly what was reused.
735
+ */
736
+ const replayRelations = (prior: PriorFile): readonly KnowledgeRelation[] => {
737
+ const replay = replayableRelations(prior.outgoing, willExist)
738
+ for (const id of replay.missingEndpoints) {
739
+ if (entities.has(id)) continue
740
+ const relation = prior.outgoing.find((item) => item.to === id)
741
+ addEntity({
742
+ id,
743
+ kind: id.startsWith('external:') ? 'external' : 'unresolved-reference',
744
+ name: id.replace(/^(?:external|unresolved):/, ''),
745
+ provenance: 'observed',
746
+ evidence: relation?.evidence[0] ? [relation.evidence[0]] : [],
747
+ })
748
+ }
749
+ for (const relation of replay.relations) addRelation(relation)
750
+ return replay.relations
751
+ }
752
+
753
+ /*
754
+ * Whether a document's references can resolve differently than they did last time.
755
+ *
756
+ * A document resolves against more than a module does: it can name another document, an area,
757
+ * a package or an exported symbol. So document reuse is refused unless all of that is identical
758
+ * — a symbol that moved from one module to another changes where a mention points without
759
+ * changing a single byte of the document that mentions it.
760
+ */
761
+ const resolution = resolutionFingerprint({
762
+ moduleUniverse,
763
+ documentPaths: [...documentFiles.keys()],
764
+ areaPaths: areas.map((area) => area.path),
765
+ symbols: symbolModules,
766
+ })
767
+ const reuseDocumentRelations = Boolean(prior) && prior?.resolution === resolution
768
+ if (prior && reuseModuleRelations && !reuseDocumentRelations) {
769
+ ledger.invalidated.push('the set of documents, areas or exported symbols changed')
770
+ }
771
+
772
+ const reusedDocuments = new Map<string, PriorFile>()
773
+ for (const [path, absPath] of documentFiles) {
774
+ let text: string
775
+ try {
776
+ text = readFileSync(absPath, 'utf8')
777
+ } catch {
778
+ unreadableDocuments.push(path)
779
+ ledger.parsedFiles.push(path)
780
+ continue
781
+ }
782
+ const priorDocument = prior?.documents.get(path)
783
+ if (reuseDocumentRelations && priorDocument && priorDocument.contentHash === markdownContentHash(text)) {
784
+ /*
785
+ * Byte-identical, resolving against an identical universe: last scan's answer is this
786
+ * scan's answer, and the Markdown tree is never built.
787
+ */
788
+ reusedDocuments.set(path, priorDocument)
789
+ ledger.reusedEntities += 1
790
+ ledger.skippedFiles.push(path)
791
+ continue
792
+ }
793
+ ledger.parsedFiles.push(path)
794
+ try {
795
+ markdownDocuments.push(parseMarkdownDocument(path, text))
796
+ } catch {
797
+ unreadableDocuments.push(path)
798
+ }
524
799
  }
525
800
 
526
- const compiler = readCompilerOptions(root)
527
801
  const coverage: DiscoverySnapshotV1['coverage'] = [
528
802
  ...[sourceWalk, documentWalk, configWalk].flatMap((walk, index) => walk.incomplete ? [{ analyzer: 'repository', scope: `limits:${['source', 'documentation', 'configuration'][index]}`, status: 'partial' as const, reason: walk.reason }] : []),
529
803
  { analyzer: 'repository', scope: 'package-manager', status: hasPackageManagerMetadata(root, rootManifest) ? 'complete' : 'partial', ...(!hasPackageManagerMetadata(root, rootManifest) ? { reason: `No package manager metadata found; default helper would fall back to ${detectPackageManager(root)}.` } : {}) },
@@ -534,6 +808,113 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
534
808
  { analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
535
809
  ]
536
810
 
811
+ /*
812
+ * What the documentation says, as edges.
813
+ *
814
+ * A link to another document, a path in inline code, an exported name in backticks: each is a
815
+ * claim the repository makes about itself, with a line number to check it against. Package
816
+ * names resolve by their manifest name and, when unambiguous, by their directory name.
817
+ */
818
+ const packageNames = new Map<string, string>()
819
+ const shortNames = new Map<string, string[]>()
820
+ for (const pkg of packageResult.packages) {
821
+ if (pkg.name) packageNames.set(pkg.name, pkg.id)
822
+ const short = pkg.name?.split('/').pop() ?? pkg.path.split('/').pop()
823
+ if (short) {
824
+ const owners = shortNames.get(short)
825
+ if (owners) owners.push(pkg.id)
826
+ else shortNames.set(short, [pkg.id])
827
+ }
828
+ }
829
+ for (const [short, owners] of shortNames) {
830
+ if (owners.length === 1 && owners[0] && !packageNames.has(short)) packageNames.set(short, owners[0])
831
+ }
832
+
833
+ const markdownResolution = {
834
+ documents: documentsByPath,
835
+ modules: modulesByPath,
836
+ // Areas exist now, so a document naming a directory resolves to the unit, not to nothing.
837
+ areas: areasByPath,
838
+ packages: packageNames,
839
+ symbols: symbolModules,
840
+ // One index for the whole run: the analyzer used to rebuild this per document.
841
+ pathIndex: markdownPathCandidateIndex({ documents: documentsByPath, modules: modulesByPath, areas: areasByPath }),
842
+ }
843
+ type MarkdownNote = { readonly scope: string; readonly reason: string; readonly evidence: readonly Evidence[] }
844
+ const notesByDocument = new Map<string, readonly MarkdownNote[]>()
845
+ const truncatedDocuments = new Set<string>()
846
+ for (const document of markdownDocuments) {
847
+ const analysis = analyzeMarkdownDocument(document, entityId('document', document.path), markdownResolution)
848
+ for (const relation of analysis.relations) addRelation(relation)
849
+ notesByDocument.set(document.path, analysis.notes)
850
+ if (analysis.truncated) truncatedDocuments.add(document.path)
851
+ }
852
+
853
+ for (const [path, priorDocument] of reusedDocuments) {
854
+ // The resolution universe is identical, so every edge this document recorded still resolves the same way.
855
+ replayRelations(priorDocument)
856
+ notesByDocument.set(
857
+ path,
858
+ priorDocument.coverage.map((entry) => ({ scope: entry.scope, reason: entry.reason ?? '', evidence: entry.evidence ?? [] })),
859
+ )
860
+ }
861
+
862
+ // Notes follow the walk, not the order documents happened to be parsed in, so reuse cannot move them.
863
+ const markdownNotes: readonly MarkdownNote[] = [...documentFiles.keys()].flatMap((path) => [...(notesByDocument.get(path) ?? [])])
864
+
865
+ const parsedDocuments = new Map(markdownDocuments.map((document) => [document.path, document]))
866
+ for (const [path, absPath] of documentFiles) {
867
+ const reused = reusedDocuments.get(path)
868
+ if (reused) {
869
+ addEntity(reused.entity)
870
+ continue
871
+ }
872
+ const parsed = parsedDocuments.get(path)
873
+ addEntity({
874
+ id: entityId('document', path),
875
+ kind: 'document',
876
+ name: basename(absPath),
877
+ path,
878
+ provenance: 'observed',
879
+ evidence: [
880
+ {
881
+ ...lineEvidence('documentation', root, absPath, 1),
882
+ ...(parsed ? { contentHash: parsed.contentHash } : {}),
883
+ },
884
+ ],
885
+ metadata: {
886
+ classification: (parsed && declaredAudience(parsed.frontmatter)) ?? documentClassification(path),
887
+ ...(parsed?.title ? { title: parsed.title } : {}),
888
+ ...(parsed?.headings.length ? { headings: parsed.headings } : {}),
889
+ ...(parsed?.summary ? { summary: parsed.summary } : {}),
890
+ ...(parsed ? { wordCount: parsed.wordCount } : {}),
891
+ ...(parsed && Object.keys(parsed.frontmatter).length ? { frontmatter: parsed.frontmatter } : {}),
892
+ ...(parsed?.generatedRegions.length ? { generatedRegions: parsed.generatedRegions } : {}),
893
+ ...(truncatedDocuments.has(path) ? { evidenceTruncated: true } : {}),
894
+ },
895
+ })
896
+ }
897
+
898
+ coverage.push({
899
+ analyzer: 'markdown',
900
+ scope: 'documentation-relations',
901
+ status: unreadableDocuments.length ? 'partial' : 'complete',
902
+ reason: unreadableDocuments.length
903
+ ? `${unreadableDocuments.length} document(s) could not be read: ${unreadableDocuments.slice(0, 4).join(', ')}.`
904
+ : `Analyzed ${markdownDocuments.length + reusedDocuments.size} document(s) for links, mentions and exported-symbol references.`,
905
+ })
906
+ for (const note of markdownNotes.slice(0, MAX_MARKDOWN_NOTES)) {
907
+ coverage.push({ analyzer: 'markdown', scope: note.scope, status: 'partial', reason: note.reason, evidence: [...note.evidence.slice(0, 32)] })
908
+ }
909
+ if (markdownNotes.length > MAX_MARKDOWN_NOTES) {
910
+ coverage.push({
911
+ analyzer: 'markdown',
912
+ scope: 'documentation-relations:notes',
913
+ status: 'partial',
914
+ reason: `${markdownNotes.length - MAX_MARKDOWN_NOTES} further ambiguous or truncated reference(s) were not listed.`,
915
+ })
916
+ }
917
+
537
918
  for (const pkg of packageResult.packages) {
538
919
  const text = readFileSync(pkg.manifestPath, 'utf8')
539
920
  for (const dependency of dependencyEntries(pkg.manifest)) {
@@ -552,15 +933,40 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
552
933
  const includeTestRuntimeWiring = opts.config?.analysis?.jsTs?.includeTestRuntimeWiring ?? false
553
934
  let observedLiteralDynamic = false
554
935
  let observedUnresolvedDynamic = false
936
+ const observedDynamicEvidence: Evidence[] = []
555
937
  let observedRuntimeWiring = false
556
938
  let observedUnresolvedRuntimeWiring = false
557
939
  for (const module of modules.values()) {
940
+ const priorModule = prior?.modules.get(module.path)
941
+ if (reuseModuleRelations && priorModule && reusedModules.has(module.path)) {
942
+ // Replay what this module said last time, then the facts the aggregate entries are built from.
943
+ replayRelations(priorModule)
944
+
945
+ const dynamicEntry = priorModule.coverage.find((entry) => entry.scope === `dynamic-imports:${module.path}`)
946
+ const wiringEntry = priorModule.coverage.find((entry) => entry.scope === `runtime-wiring:${module.path}`)
947
+ if (dynamicEntry) {
948
+ coverage.push(dynamicEntry)
949
+ observedUnresolvedDynamic ||= dynamicEntry.status === 'not-analyzed'
950
+ observedLiteralDynamic ||= dynamicEntry.status === 'complete'
951
+ observedDynamicEvidence.push(...(dynamicEntry.evidence ?? []))
952
+ }
953
+ if (wiringEntry) {
954
+ coverage.push(wiringEntry)
955
+ observedRuntimeWiring = true
956
+ observedUnresolvedRuntimeWiring ||= wiringEntry.status === 'not-analyzed'
957
+ }
958
+ ledger.skippedFiles.push(module.path)
959
+ continue
960
+ }
961
+
558
962
  const text = readFileSync(module.absPath, 'utf8')
559
963
  const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
560
964
  const runtimeWiringMethods = includeTestRuntimeWiring || !TEST_MODULE_PATTERN.test(module.path) ? configuredRuntimeWiringMethods : new Set<string>()
561
965
  const references = moduleReferences(root, module.absPath, sourceFile, runtimeWiringMethods)
966
+ ledger.parsedFiles.push(module.path)
562
967
  observedLiteralDynamic ||= references.hasLiteralDynamic
563
968
  observedUnresolvedDynamic ||= references.hasDynamic
969
+ observedDynamicEvidence.push(...references.dynamicEvidence)
564
970
  observedRuntimeWiring ||= references.hasRuntimeWiring
565
971
  observedUnresolvedRuntimeWiring ||= references.hasUnresolvedRuntimeWiring
566
972
  for (const reference of references.references) {
@@ -572,14 +978,19 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
572
978
  }
573
979
  addRelation({ id: entityId('relation', `${module.entityId}:${reference.kind}:${target.targetId}`), kind: reference.kind, from: module.entityId, to: target.targetId, provenance: 'observed', evidence: [reference.evidence], ...(reference.detection ? { metadata: { detection: reference.detection } } : {}) })
574
980
  }
575
- if (references.hasLiteralDynamic || references.hasDynamic) coverage.push({ analyzer: 'js-ts', scope: `dynamic-imports:${module.path}`, status: references.hasDynamic ? 'not-analyzed' : 'complete', reason: references.hasDynamic ? 'A non-literal dynamic import was found; the target is unresolved.' : 'Literal dynamic imports were resolved.', evidence: [lineEvidence('code', root, module.absPath)] })
576
- if (references.hasUnresolvedRuntimeWiring) coverage.push({ analyzer: 'js-ts', scope: `runtime-wiring:${module.path}`, status: 'not-analyzed', reason: 'A runtime registration/wiring call was found without a statically imported target.', evidence: [lineEvidence('code', root, module.absPath)] })
981
+ if (references.hasLiteralDynamic || references.hasDynamic) coverage.push({ analyzer: 'js-ts', scope: `dynamic-imports:${module.path}`, status: references.hasDynamic ? 'not-analyzed' : 'complete', reason: references.hasDynamic ? 'A non-literal dynamic import was found; the target is unresolved.' : 'Literal dynamic imports were resolved.', evidence: [...references.dynamicEvidence.slice(0, 32)] })
982
+ /*
983
+ * Every observed wiring call leaves a per-file record, resolved or not — the aggregate entry
984
+ * below is derived from these, and a fact that exists only in a local variable cannot be
985
+ * replayed by a scan that skipped the parse.
986
+ */
987
+ if (references.hasRuntimeWiring) coverage.push({ analyzer: 'js-ts', scope: `runtime-wiring:${module.path}`, status: references.hasUnresolvedRuntimeWiring ? 'not-analyzed' : 'complete', reason: references.hasUnresolvedRuntimeWiring ? 'A runtime registration/wiring call was found without a statically imported target.' : 'Configured runtime-wiring call(s) were found with statically known targets.', evidence: [lineEvidence('code', root, module.absPath)] })
577
988
  }
578
989
 
579
990
  if (dynamicCoverageIndex >= 0) coverage[dynamicCoverageIndex] = observedUnresolvedDynamic
580
- ? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'partial', reason: 'Literal dynamic imports are resolved; non-literal import expressions and require calls remain unresolved.' }
991
+ ? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'partial', reason: 'Literal dynamic imports are resolved; non-literal import expressions and require calls remain unresolved. Evidence lists representative dynamic loading sites.', evidence: [...observedDynamicEvidence.slice(0, 32)] }
581
992
  : observedLiteralDynamic
582
- ? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'complete', reason: 'All observed dynamic imports used literal targets and were resolved.' }
993
+ ? { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'complete', reason: 'All observed dynamic imports used literal targets and were resolved.', evidence: [...observedDynamicEvidence.slice(0, 32)] }
583
994
  : { analyzer: 'js-ts', scope: 'dynamic-imports', status: 'not-applicable', reason: 'No dynamic loading expression was observed.' }
584
995
  const runtimeCoverageIndex = coverage.findIndex((entry) => entry.scope === 'runtime-wiring')
585
996
  if (runtimeCoverageIndex >= 0) coverage[runtimeCoverageIndex] = observedUnresolvedRuntimeWiring
@@ -588,6 +999,27 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
588
999
  ? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'complete', reason: 'All observed configured runtime-wiring calls resolved to static bindings.' }
589
1000
  : { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' }
590
1001
 
1002
+ /*
1003
+ * Community suggestions, last, because they need the finished import graph.
1004
+ *
1005
+ * A cluster of modules that move together is a hypothesis about where an area boundary might
1006
+ * be. It is reported as coverage — status `not-analyzed`, because whether the cluster is an area
1007
+ * is a question nobody has answered — and never as an area entity. A clustering algorithm does
1008
+ * not get to name the architecture.
1009
+ */
1010
+ coverage.push(
1011
+ ...areaSuggestionCoverage({ entities: [...entities.values()], relations: [...relations.values()] }),
1012
+ )
1013
+
1014
+ /*
1015
+ * What this run reused, last, because only now is it known.
1016
+ *
1017
+ * A run that finishes in a tenth of the time has to be able to say why. This entry is the one
1018
+ * part of the snapshot that describes the run rather than the repository — the entities, the
1019
+ * relations and every other coverage entry are identical to what a cold scan would produce.
1020
+ */
1021
+ coverage.push(reuseCoverage(ledger))
1022
+
591
1023
  return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
592
1024
  }
593
1025