@agentskit/doc-bridge 1.7.45 → 1.10.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 (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  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 +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  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/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -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,14 +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-archive(?:\/|$)/.test(path)) return 'archive'
106
- if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
107
- if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
108
- return 'unclassified'
109
- }
110
-
111
135
  const packageName = (manifest: JsonRecord, fallback: string): string | undefined =>
112
136
  typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : fallback || undefined
113
137
 
@@ -208,67 +232,12 @@ const readCompilerOptions = (root: string): { readonly options: ts.CompilerOptio
208
232
  return { options: config.options }
209
233
  }
210
234
 
211
- const scriptKind = (path: string): ts.ScriptKind => {
212
- switch (extname(path)) {
213
- case '.js': return ts.ScriptKind.JS
214
- case '.jsx': return ts.ScriptKind.JSX
215
- case '.mjs': return ts.ScriptKind.JS
216
- case '.cjs': return ts.ScriptKind.JS
217
- case '.ts': return ts.ScriptKind.TS
218
- case '.tsx': return ts.ScriptKind.TSX
219
- case '.mts': return ts.ScriptKind.TS
220
- case '.cts': return ts.ScriptKind.TS
221
- default: return ts.ScriptKind.Unknown
222
- }
223
- }
224
-
225
235
  const nodeEvidence = (root: string, path: string, sourceFile: ts.SourceFile, node: ts.Node): Evidence => {
226
236
  const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1
227
237
  const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1
228
238
  return lineEvidence('code', root, path, start, end)
229
239
  }
230
240
 
231
- const isExported = (node: ts.Node): boolean => {
232
- const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
233
- return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
234
- }
235
-
236
- const exportedNames = (sourceFile: ts.SourceFile): string[] => {
237
- const names = new Set<string>()
238
- const addDeclarationName = (node: ts.Declaration): void => {
239
- if (!isExported(node)) return
240
- const name = ts.getNameOfDeclaration(node)
241
- if (name && ts.isIdentifier(name)) names.add(name.text)
242
- }
243
-
244
- const visit = (node: ts.Node): void => {
245
- if (ts.isExportDeclaration(node)) {
246
- if (!node.exportClause) names.add('*')
247
- else if (ts.isNamedExports(node.exportClause)) {
248
- for (const element of node.exportClause.elements) names.add(element.name.text)
249
- }
250
- } else if (ts.isExportAssignment(node)) {
251
- names.add('default')
252
- } else if (
253
- ts.isClassDeclaration(node) ||
254
- ts.isFunctionDeclaration(node) ||
255
- ts.isInterfaceDeclaration(node) ||
256
- ts.isTypeAliasDeclaration(node) ||
257
- ts.isEnumDeclaration(node) ||
258
- ts.isModuleDeclaration(node)
259
- ) {
260
- addDeclarationName(node)
261
- } else if (ts.isVariableStatement(node) && isExported(node)) {
262
- for (const declaration of node.declarationList.declarations) {
263
- if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
264
- }
265
- }
266
- ts.forEachChild(node, visit)
267
- }
268
- visit(sourceFile)
269
- return [...names].sort()
270
- }
271
-
272
241
  const moduleReferences = (
273
242
  root: string,
274
243
  path: string,
@@ -365,6 +334,14 @@ const moduleReferences = (
365
334
  const argument = node.arguments[0]
366
335
  const specifier = argument ? resolveStaticString(argument) : undefined
367
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
368
345
  dynamicEvidence.push(nodeEvidence(root, path, sourceFile, node))
369
346
  addReference({ text: specifier } as ts.StringLiteralLike, 'imports', node)
370
347
  } else {
@@ -481,6 +458,10 @@ const hasPackageManagerMetadata = (root: string, rootManifest: JsonRecord | unde
481
458
  existsSync(join(root, 'package-lock.json')),
482
459
  )
483
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
+
484
465
  const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: readonly string[], entities: readonly KnowledgeEntity[], relations: readonly KnowledgeRelation[], coverage: DiscoverySnapshotV1['coverage']): DiscoverySnapshotV1 => {
485
466
  const revision = sourceRevision(root, files)
486
467
  const base = {
@@ -491,34 +472,28 @@ const artifact = (root: string, config: DocBridgeConfigV1 | undefined, files: re
491
472
  project: { name: (entities.find((entity) => entity.kind === 'package' && entity.path === '.')?.name ?? basename(root)), root: '.' },
492
473
  sourceRevision: revision.value,
493
474
  sourceRevisionKind: revision.kind,
494
- configurationHash: sha256NormalizedV1(config ?? {}),
495
- pipelineVersion: '1.1.8',
496
- analyzerVersions: { repository: '1.1.1', 'js-ts': '1.3.4' },
475
+ configurationHash: configurationHashOf(config),
476
+ pipelineVersion: PIPELINE_VERSION,
477
+ analyzerVersions: ANALYZER_VERSIONS,
497
478
  entities: [...entities].sort((a, b) => a.id.localeCompare(b.id)),
498
479
  relations: [...relations].sort((a, b) => a.id.localeCompare(b.id)),
499
- coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? ({ repository: '1.1.1', 'js-ts': '1.3.4' }[entry.analyzer] ?? '1.0.0') })),
480
+ coverage: coverage.map((entry) => ({ ...entry, analyzerVersion: entry.analyzerVersion ?? (ANALYZER_VERSIONS[entry.analyzer] ?? '1.0.0') })),
500
481
  }
501
482
  return DiscoverySnapshotV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
502
483
  }
503
484
 
504
485
  export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapshotV1 => {
505
486
  const root = resolve(opts.root ?? process.cwd())
506
- const safety = opts.config?.safety
507
- const maxFiles = opts.maxFiles ?? safety?.maxFiles ?? DEFAULT_MAX_FILES
508
- const maxBytes = opts.maxBytes ?? safety?.maxBytes
509
- const safeOptions = {
510
- exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
511
- maxFiles,
512
- ...(maxBytes !== undefined ? { maxBytes } : {}),
513
- ...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
514
- ...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
515
- }
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
+ })
516
491
  const rootManifestPath = join(root, 'package.json')
517
492
  const rootManifest = readJson(rootManifestPath).value
518
493
  const packageResult = discoverPackages(root, rootManifest, opts.config)
519
494
  const sourceWalk = safeWalkFiles(root, { extensions: SOURCE_EXTENSIONS, ...safeOptions })
520
495
  const documentWalk = safeWalkFiles(root, { extensions: DOCUMENT_EXTENSIONS, ...safeOptions })
521
- const configWalk = safeWalkFiles(root, { extensions: ['.json', '.yaml', '.yml', '.js', '.ts'], ...safeOptions })
496
+ const configWalk = safeWalkFiles(root, { extensions: CONFIG_EXTENSIONS, ...safeOptions })
522
497
  const sourcePaths = sourceWalk.files
523
498
  const documentPaths = documentWalk.files
524
499
  const configPaths = configWalk.files
@@ -549,28 +524,281 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
549
524
 
550
525
  for (const pkg of packageResult.packages) {
551
526
  const text = readFileSync(pkg.manifestPath, 'utf8')
552
- 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')
553
573
  }
554
574
 
555
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
+
556
591
  for (const absPath of sourcePaths) {
557
592
  const path = relativePath(root, absPath)
558
593
  const pkg = packageForModule(packageResult.packages, absPath)
559
594
  const id = entityId('module', path)
560
595
  const text = readFileSync(absPath, 'utf8')
561
- const sourceFile = ts.createSourceFile(absPath, text, ts.ScriptTarget.Latest, true, scriptKind(absPath))
562
- const exports = exportedNames(sourceFile)
596
+ const contentHash = fileContentHash(text)
563
597
  modules.set(resolve(absPath), { absPath, path, entityId: id, ...(pkg ? { packageId: pkg.id } : {}) })
564
- 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
+ }
565
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)] })
566
636
  }
567
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
+ ...(opts.config?.analysis?.areas?.exclude !== undefined ? { exclude: opts.config.analysis.areas.exclude } : {}),
652
+ })
653
+ const areasById = new Map(areas.map((area) => [area.id, area]))
654
+ const areasByPath = new Map(areas.map((area) => [area.path, area.id]))
655
+
656
+ for (const area of areas) {
657
+ addEntity({
658
+ id: area.id,
659
+ kind: 'area',
660
+ name: area.name,
661
+ path: area.path,
662
+ provenance: 'observed',
663
+ evidence: [
664
+ {
665
+ source: 'derived',
666
+ path: area.path,
667
+ context: `Directory groups ${area.moduleIds.length} module(s).`,
668
+ },
669
+ ],
670
+ metadata: {
671
+ moduleCount: area.moduleIds.length,
672
+ ...(area.ownershipId ? { ownershipId: area.ownershipId } : {}),
673
+ },
674
+ })
675
+
676
+ const parent = area.parentId && areasById.has(area.parentId) ? area.parentId : area.packageId
677
+ addRelation({
678
+ id: entityId('relation', `${parent}:contains:${area.id}`),
679
+ kind: 'contains',
680
+ from: parent,
681
+ to: area.id,
682
+ provenance: 'observed',
683
+ evidence: [{ source: 'derived', path: area.path }],
684
+ })
685
+ for (const moduleId of area.moduleIds) {
686
+ addRelation({
687
+ id: entityId('relation', `${area.id}:contains:${moduleId}`),
688
+ kind: 'contains',
689
+ from: area.id,
690
+ to: moduleId,
691
+ provenance: 'observed',
692
+ evidence: [{ source: 'derived', path: area.path }],
693
+ })
694
+ }
695
+ }
696
+
697
+ /*
698
+ * A symbol resolves to the module that declares it. Only when nothing declares it — a type
699
+ * forwarded through a barrel, say — do the re-exporting modules stand in, and then only if
700
+ * there is exactly one of them.
701
+ */
702
+ const symbolModules = new Map<string, readonly string[]>()
703
+ for (const [name, owners] of declaringModules) symbolModules.set(name, owners)
704
+ for (const [name, owners] of exportingModules) if (!symbolModules.has(name)) symbolModules.set(name, owners)
705
+
706
+ /*
707
+ * Documents are parsed first and added as entities after their relations are known, because
708
+ * whether a document's references were truncated is part of what the entity has to say.
709
+ */
710
+ const markdownDocuments: MarkdownDocumentV1[] = []
711
+ const documentsByPath = new Map<string, string>()
712
+ const documentFiles = new Map<string, string>()
713
+ const unreadableDocuments: string[] = []
568
714
  for (const absPath of documentPaths) {
569
- const path = relativePath(root, absPath)
570
- addEntity({ id: entityId('document', path), kind: 'document', name: basename(absPath), path, provenance: 'observed', evidence: [lineEvidence('documentation', root, absPath, 1)], metadata: { classification: documentClassification(path) } })
715
+ documentFiles.set(relativePath(root, absPath), absPath)
716
+ }
717
+ for (const path of documentFiles.keys()) documentsByPath.set(path, entityId('document', path))
718
+
719
+ /*
720
+ * Whether an id will be in this snapshot.
721
+ *
722
+ * A replayed relation is checked against what the scan is going to produce, not against what it
723
+ * has produced so far: documents and modules get their entities late, and an edge to a file that
724
+ * plainly exists must not be dropped for arriving early.
725
+ */
726
+ const plannedIds = new Set([...modulesByPath.values(), ...documentsByPath.values()])
727
+ const willExist = (id: string): boolean => entities.has(id) || plannedIds.has(id)
728
+
729
+ /**
730
+ * Put a reused entity's edges back.
731
+ *
732
+ * An edge whose internal target is gone is dropped — the file it pointed at was renamed or
733
+ * deleted, and a graph that keeps the edge is lying about the repository. An external or
734
+ * unresolved endpoint is re-created instead, because such an entity is in the snapshot only
735
+ * because something referenced it, and that something is exactly what was reused.
736
+ */
737
+ const replayRelations = (prior: PriorFile): readonly KnowledgeRelation[] => {
738
+ const replay = replayableRelations(prior.outgoing, willExist)
739
+ for (const id of replay.missingEndpoints) {
740
+ if (entities.has(id)) continue
741
+ const relation = prior.outgoing.find((item) => item.to === id)
742
+ addEntity({
743
+ id,
744
+ kind: id.startsWith('external:') ? 'external' : 'unresolved-reference',
745
+ name: id.replace(/^(?:external|unresolved):/, ''),
746
+ provenance: 'observed',
747
+ evidence: relation?.evidence[0] ? [relation.evidence[0]] : [],
748
+ })
749
+ }
750
+ for (const relation of replay.relations) addRelation(relation)
751
+ return replay.relations
752
+ }
753
+
754
+ /*
755
+ * Whether a document's references can resolve differently than they did last time.
756
+ *
757
+ * A document resolves against more than a module does: it can name another document, an area,
758
+ * a package or an exported symbol. So document reuse is refused unless all of that is identical
759
+ * — a symbol that moved from one module to another changes where a mention points without
760
+ * changing a single byte of the document that mentions it.
761
+ */
762
+ const resolution = resolutionFingerprint({
763
+ moduleUniverse,
764
+ documentPaths: [...documentFiles.keys()],
765
+ areaPaths: areas.map((area) => area.path),
766
+ symbols: symbolModules,
767
+ })
768
+ const reuseDocumentRelations = Boolean(prior) && prior?.resolution === resolution
769
+ if (prior && reuseModuleRelations && !reuseDocumentRelations) {
770
+ ledger.invalidated.push('the set of documents, areas or exported symbols changed')
771
+ }
772
+
773
+ const reusedDocuments = new Map<string, PriorFile>()
774
+ for (const [path, absPath] of documentFiles) {
775
+ let text: string
776
+ try {
777
+ text = readFileSync(absPath, 'utf8')
778
+ } catch {
779
+ unreadableDocuments.push(path)
780
+ ledger.parsedFiles.push(path)
781
+ continue
782
+ }
783
+ const priorDocument = prior?.documents.get(path)
784
+ if (reuseDocumentRelations && priorDocument && priorDocument.contentHash === markdownContentHash(text)) {
785
+ /*
786
+ * Byte-identical, resolving against an identical universe: last scan's answer is this
787
+ * scan's answer, and the Markdown tree is never built.
788
+ */
789
+ reusedDocuments.set(path, priorDocument)
790
+ ledger.reusedEntities += 1
791
+ ledger.skippedFiles.push(path)
792
+ continue
793
+ }
794
+ ledger.parsedFiles.push(path)
795
+ try {
796
+ markdownDocuments.push(parseMarkdownDocument(path, text))
797
+ } catch {
798
+ unreadableDocuments.push(path)
799
+ }
571
800
  }
572
801
 
573
- const compiler = readCompilerOptions(root)
574
802
  const coverage: DiscoverySnapshotV1['coverage'] = [
575
803
  ...[sourceWalk, documentWalk, configWalk].flatMap((walk, index) => walk.incomplete ? [{ analyzer: 'repository', scope: `limits:${['source', 'documentation', 'configuration'][index]}`, status: 'partial' as const, reason: walk.reason }] : []),
576
804
  { 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)}.` } : {}) },
@@ -581,6 +809,113 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
581
809
  { analyzer: 'js-ts', scope: 'generated-code', status: 'not-analyzed', reason: 'Generated code is not interpreted as source architecture.' },
582
810
  ]
583
811
 
812
+ /*
813
+ * What the documentation says, as edges.
814
+ *
815
+ * A link to another document, a path in inline code, an exported name in backticks: each is a
816
+ * claim the repository makes about itself, with a line number to check it against. Package
817
+ * names resolve by their manifest name and, when unambiguous, by their directory name.
818
+ */
819
+ const packageNames = new Map<string, string>()
820
+ const shortNames = new Map<string, string[]>()
821
+ for (const pkg of packageResult.packages) {
822
+ if (pkg.name) packageNames.set(pkg.name, pkg.id)
823
+ const short = pkg.name?.split('/').pop() ?? pkg.path.split('/').pop()
824
+ if (short) {
825
+ const owners = shortNames.get(short)
826
+ if (owners) owners.push(pkg.id)
827
+ else shortNames.set(short, [pkg.id])
828
+ }
829
+ }
830
+ for (const [short, owners] of shortNames) {
831
+ if (owners.length === 1 && owners[0] && !packageNames.has(short)) packageNames.set(short, owners[0])
832
+ }
833
+
834
+ const markdownResolution = {
835
+ documents: documentsByPath,
836
+ modules: modulesByPath,
837
+ // Areas exist now, so a document naming a directory resolves to the unit, not to nothing.
838
+ areas: areasByPath,
839
+ packages: packageNames,
840
+ symbols: symbolModules,
841
+ // One index for the whole run: the analyzer used to rebuild this per document.
842
+ pathIndex: markdownPathCandidateIndex({ documents: documentsByPath, modules: modulesByPath, areas: areasByPath }),
843
+ }
844
+ type MarkdownNote = { readonly scope: string; readonly reason: string; readonly evidence: readonly Evidence[] }
845
+ const notesByDocument = new Map<string, readonly MarkdownNote[]>()
846
+ const truncatedDocuments = new Set<string>()
847
+ for (const document of markdownDocuments) {
848
+ const analysis = analyzeMarkdownDocument(document, entityId('document', document.path), markdownResolution)
849
+ for (const relation of analysis.relations) addRelation(relation)
850
+ notesByDocument.set(document.path, analysis.notes)
851
+ if (analysis.truncated) truncatedDocuments.add(document.path)
852
+ }
853
+
854
+ for (const [path, priorDocument] of reusedDocuments) {
855
+ // The resolution universe is identical, so every edge this document recorded still resolves the same way.
856
+ replayRelations(priorDocument)
857
+ notesByDocument.set(
858
+ path,
859
+ priorDocument.coverage.map((entry) => ({ scope: entry.scope, reason: entry.reason ?? '', evidence: entry.evidence ?? [] })),
860
+ )
861
+ }
862
+
863
+ // Notes follow the walk, not the order documents happened to be parsed in, so reuse cannot move them.
864
+ const markdownNotes: readonly MarkdownNote[] = [...documentFiles.keys()].flatMap((path) => [...(notesByDocument.get(path) ?? [])])
865
+
866
+ const parsedDocuments = new Map(markdownDocuments.map((document) => [document.path, document]))
867
+ for (const [path, absPath] of documentFiles) {
868
+ const reused = reusedDocuments.get(path)
869
+ if (reused) {
870
+ addEntity(reused.entity)
871
+ continue
872
+ }
873
+ const parsed = parsedDocuments.get(path)
874
+ addEntity({
875
+ id: entityId('document', path),
876
+ kind: 'document',
877
+ name: basename(absPath),
878
+ path,
879
+ provenance: 'observed',
880
+ evidence: [
881
+ {
882
+ ...lineEvidence('documentation', root, absPath, 1),
883
+ ...(parsed ? { contentHash: parsed.contentHash } : {}),
884
+ },
885
+ ],
886
+ metadata: {
887
+ classification: (parsed && declaredAudience(parsed.frontmatter)) ?? documentClassification(path),
888
+ ...(parsed?.title ? { title: parsed.title } : {}),
889
+ ...(parsed?.headings.length ? { headings: parsed.headings } : {}),
890
+ ...(parsed?.summary ? { summary: parsed.summary } : {}),
891
+ ...(parsed ? { wordCount: parsed.wordCount } : {}),
892
+ ...(parsed && Object.keys(parsed.frontmatter).length ? { frontmatter: parsed.frontmatter } : {}),
893
+ ...(parsed?.generatedRegions.length ? { generatedRegions: parsed.generatedRegions } : {}),
894
+ ...(truncatedDocuments.has(path) ? { evidenceTruncated: true } : {}),
895
+ },
896
+ })
897
+ }
898
+
899
+ coverage.push({
900
+ analyzer: 'markdown',
901
+ scope: 'documentation-relations',
902
+ status: unreadableDocuments.length ? 'partial' : 'complete',
903
+ reason: unreadableDocuments.length
904
+ ? `${unreadableDocuments.length} document(s) could not be read: ${unreadableDocuments.slice(0, 4).join(', ')}.`
905
+ : `Analyzed ${markdownDocuments.length + reusedDocuments.size} document(s) for links, mentions and exported-symbol references.`,
906
+ })
907
+ for (const note of markdownNotes.slice(0, MAX_MARKDOWN_NOTES)) {
908
+ coverage.push({ analyzer: 'markdown', scope: note.scope, status: 'partial', reason: note.reason, evidence: [...note.evidence.slice(0, 32)] })
909
+ }
910
+ if (markdownNotes.length > MAX_MARKDOWN_NOTES) {
911
+ coverage.push({
912
+ analyzer: 'markdown',
913
+ scope: 'documentation-relations:notes',
914
+ status: 'partial',
915
+ reason: `${markdownNotes.length - MAX_MARKDOWN_NOTES} further ambiguous or truncated reference(s) were not listed.`,
916
+ })
917
+ }
918
+
584
919
  for (const pkg of packageResult.packages) {
585
920
  const text = readFileSync(pkg.manifestPath, 'utf8')
586
921
  for (const dependency of dependencyEntries(pkg.manifest)) {
@@ -603,10 +938,33 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
603
938
  let observedRuntimeWiring = false
604
939
  let observedUnresolvedRuntimeWiring = false
605
940
  for (const module of modules.values()) {
941
+ const priorModule = prior?.modules.get(module.path)
942
+ if (reuseModuleRelations && priorModule && reusedModules.has(module.path)) {
943
+ // Replay what this module said last time, then the facts the aggregate entries are built from.
944
+ replayRelations(priorModule)
945
+
946
+ const dynamicEntry = priorModule.coverage.find((entry) => entry.scope === `dynamic-imports:${module.path}`)
947
+ const wiringEntry = priorModule.coverage.find((entry) => entry.scope === `runtime-wiring:${module.path}`)
948
+ if (dynamicEntry) {
949
+ coverage.push(dynamicEntry)
950
+ observedUnresolvedDynamic ||= dynamicEntry.status === 'not-analyzed'
951
+ observedLiteralDynamic ||= dynamicEntry.status === 'complete'
952
+ observedDynamicEvidence.push(...(dynamicEntry.evidence ?? []))
953
+ }
954
+ if (wiringEntry) {
955
+ coverage.push(wiringEntry)
956
+ observedRuntimeWiring = true
957
+ observedUnresolvedRuntimeWiring ||= wiringEntry.status === 'not-analyzed'
958
+ }
959
+ ledger.skippedFiles.push(module.path)
960
+ continue
961
+ }
962
+
606
963
  const text = readFileSync(module.absPath, 'utf8')
607
964
  const sourceFile = ts.createSourceFile(module.absPath, text, ts.ScriptTarget.Latest, true, scriptKind(module.absPath))
608
965
  const runtimeWiringMethods = includeTestRuntimeWiring || !TEST_MODULE_PATTERN.test(module.path) ? configuredRuntimeWiringMethods : new Set<string>()
609
966
  const references = moduleReferences(root, module.absPath, sourceFile, runtimeWiringMethods)
967
+ ledger.parsedFiles.push(module.path)
610
968
  observedLiteralDynamic ||= references.hasLiteralDynamic
611
969
  observedUnresolvedDynamic ||= references.hasDynamic
612
970
  observedDynamicEvidence.push(...references.dynamicEvidence)
@@ -622,7 +980,12 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
622
980
  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 } } : {}) })
623
981
  }
624
982
  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)] })
625
- 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)] })
983
+ /*
984
+ * Every observed wiring call leaves a per-file record, resolved or not — the aggregate entry
985
+ * below is derived from these, and a fact that exists only in a local variable cannot be
986
+ * replayed by a scan that skipped the parse.
987
+ */
988
+ 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)] })
626
989
  }
627
990
 
628
991
  if (dynamicCoverageIndex >= 0) coverage[dynamicCoverageIndex] = observedUnresolvedDynamic
@@ -637,6 +1000,27 @@ export const discoverRepository = (opts: DiscoveryOptions = {}): DiscoverySnapsh
637
1000
  ? { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'complete', reason: 'All observed configured runtime-wiring calls resolved to static bindings.' }
638
1001
  : { analyzer: 'js-ts', scope: 'runtime-wiring', status: 'not-applicable', reason: 'No configured runtime-wiring call was observed.' }
639
1002
 
1003
+ /*
1004
+ * Community suggestions, last, because they need the finished import graph.
1005
+ *
1006
+ * A cluster of modules that move together is a hypothesis about where an area boundary might
1007
+ * be. It is reported as coverage — status `not-analyzed`, because whether the cluster is an area
1008
+ * is a question nobody has answered — and never as an area entity. A clustering algorithm does
1009
+ * not get to name the architecture.
1010
+ */
1011
+ coverage.push(
1012
+ ...areaSuggestionCoverage({ entities: [...entities.values()], relations: [...relations.values()] }),
1013
+ )
1014
+
1015
+ /*
1016
+ * What this run reused, last, because only now is it known.
1017
+ *
1018
+ * A run that finishes in a tenth of the time has to be able to say why. This entry is the one
1019
+ * part of the snapshot that describes the run rather than the repository — the entities, the
1020
+ * relations and every other coverage entry are identical to what a cold scan would produce.
1021
+ */
1022
+ coverage.push(reuseCoverage(ledger))
1023
+
640
1024
  return artifact(root, opts.config, allFiles, [...entities.values()], [...relations.values()], coverage)
641
1025
  }
642
1026