@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
@@ -0,0 +1,481 @@
1
+ import { toString as mdastToString } from 'mdast-util-to-string'
2
+ import remarkFrontmatter from 'remark-frontmatter'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkParse from 'remark-parse'
5
+ import { unified } from 'unified'
6
+ import { visit } from 'unist-util-visit'
7
+ import { parse as parseYaml } from 'yaml'
8
+ import type { Root, RootContent } from 'mdast'
9
+
10
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
11
+ import { createFuzzyCandidateIndex, resolveFuzzyReference, type FuzzyCandidateIndex } from '../lib/fuzzy-match.js'
12
+ import { toPosix } from '../lib/paths.js'
13
+ import type { Evidence, KnowledgeRelation } from '../schemas/knowledge.js'
14
+ import { relationId } from './identity.js'
15
+
16
+ /**
17
+ * The Markdown analyzer.
18
+ *
19
+ * Documentation was previously read with regular expressions: frontmatter by one, the `docbridge`
20
+ * block by a hand-written YAML subset, and the prose not at all. Headings, links and inline code
21
+ * were discarded — so a repository where 23 documents linked to other documents and 14 cited
22
+ * source paths produced no edges from any of it, and one document in ninety-two counted as
23
+ * documented. A real parser turns that prose into evidence-backed relations with line numbers,
24
+ * which is the difference between a graph that knows what its documentation says and one that
25
+ * only knows the documentation exists.
26
+ *
27
+ * Every edge here is `observed` with file and line evidence from the node's own position. Nothing
28
+ * is inferred from a near-match unless the near-match is unambiguous.
29
+ */
30
+
31
+ export const MARKDOWN_ANALYZER_VERSION = '1.0.0'
32
+
33
+ /** Headings deeper than this are structure, not subject matter. */
34
+ const MAX_HEADING_DEPTH = 3
35
+ const MAX_HEADINGS = 64
36
+ const MAX_SUMMARY_LENGTH = 400
37
+ const MAX_TITLE_LENGTH = 256
38
+
39
+ /** A document with more relations than this is an index page; the tail adds noise, not knowledge. */
40
+ export const MARKDOWN_RELATION_CAP = 64
41
+
42
+ /**
43
+ * A region a generator owns. The analyzer skips it when collecting mentions, so Doc Bridge does
44
+ * not read its own output back in as evidence about the repository.
45
+ */
46
+ const GENERATED_OPEN = /<!--\s*doc-bridge:generated(?:\s+hash=([A-Za-z0-9]+))?[^>]*-->/
47
+ const GENERATED_CLOSE = /<!--\s*\/\s*doc-bridge:generated\s*-->/
48
+
49
+ const EXTERNAL_LINK = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|mailto:)/i
50
+
51
+ export type MarkdownHeading = {
52
+ readonly depth: number
53
+ readonly text: string
54
+ readonly line: number
55
+ }
56
+
57
+ export type MarkdownReference = {
58
+ /** The link target, or the inline code token. */
59
+ readonly value: string
60
+ /** Link text, when the reference came from a link. */
61
+ readonly text?: string
62
+ readonly line: number
63
+ }
64
+
65
+ export type MarkdownGeneratedRegion = {
66
+ readonly lineStart: number
67
+ readonly lineEnd: number
68
+ readonly hash?: string
69
+ }
70
+
71
+ /** The frontmatter fields the knowledge model understands. Everything else is left alone. */
72
+ export type MarkdownFrontmatter = {
73
+ readonly type?: string
74
+ readonly audience?: string
75
+ readonly owner?: string
76
+ readonly lifecycle?: string
77
+ readonly tier?: string
78
+ }
79
+
80
+ export type MarkdownDocumentV1 = {
81
+ readonly path: string
82
+ readonly title?: string
83
+ readonly headings: readonly MarkdownHeading[]
84
+ readonly summary?: string
85
+ readonly wordCount: number
86
+ readonly frontmatter: MarkdownFrontmatter
87
+ readonly generatedRegions: readonly MarkdownGeneratedRegion[]
88
+ readonly contentHash: string
89
+ /** Links outside generated regions. */
90
+ readonly links: readonly MarkdownReference[]
91
+ /** Inline code tokens outside generated regions. */
92
+ readonly codeTokens: readonly MarkdownReference[]
93
+ /** Raw frontmatter text and the line it starts on, for the declaration parser. */
94
+ readonly frontmatterBlock?: { readonly value: string; readonly line: number }
95
+ }
96
+
97
+ const processor = unified()
98
+ .use(remarkParse)
99
+ .use(remarkFrontmatter, ['yaml'])
100
+ .use(remarkGfm)
101
+
102
+ type Positioned = { readonly position?: { readonly start: { readonly line: number } } | undefined }
103
+
104
+ const lineOf = (node: Positioned): number => node.position?.start.line ?? 1
105
+
106
+ const withinGenerated = (line: number, regions: readonly MarkdownGeneratedRegion[]): boolean =>
107
+ regions.some((region) => line >= region.lineStart && line <= region.lineEnd)
108
+
109
+ /** Cap a summary at a sentence boundary when there is one, and never mid-word. */
110
+ const boundedSummary = (value: string): string | undefined => {
111
+ const text = value.replace(/\s+/g, ' ').trim()
112
+ if (!text) return undefined
113
+ if (text.length <= MAX_SUMMARY_LENGTH) return text
114
+ const sliced = text.slice(0, MAX_SUMMARY_LENGTH)
115
+ const sentenceEnd = Math.max(sliced.lastIndexOf('. '), sliced.lastIndexOf('! '), sliced.lastIndexOf('? '))
116
+ if (sentenceEnd > MAX_SUMMARY_LENGTH * 0.4) return sliced.slice(0, sentenceEnd + 1).trim()
117
+ const wordEnd = sliced.lastIndexOf(' ')
118
+ return (wordEnd > 0 ? sliced.slice(0, wordEnd) : sliced).trim()
119
+ }
120
+
121
+ const frontmatterField = (data: unknown, key: keyof MarkdownFrontmatter): string | undefined => {
122
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
123
+ const value = (data as Record<string, unknown>)[key]
124
+ if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 128)
125
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * The frontmatter fields the knowledge model reads, parsed as real YAML.
131
+ *
132
+ * Unparseable frontmatter yields no fields rather than a guess: the declaration layer is what
133
+ * reports the syntax error, with a line number, and one report is better than two.
134
+ */
135
+ const frontmatterSubset = (value: string | undefined): MarkdownFrontmatter => {
136
+ if (!value) return {}
137
+ let data: unknown
138
+ try {
139
+ data = parseYaml(value)
140
+ } catch {
141
+ return {}
142
+ }
143
+ const subset: Record<string, string> = {}
144
+ for (const key of ['type', 'audience', 'owner', 'lifecycle', 'tier'] as const) {
145
+ const field = frontmatterField(data, key)
146
+ if (field !== undefined) subset[key] = field
147
+ }
148
+ return subset
149
+ }
150
+
151
+ /**
152
+ * The audience a document declares about itself.
153
+ *
154
+ * `audience` is authoritative by definition — it is the author naming their reader. `type` counts
155
+ * only when it happens to name an audience, because in practice it names a document kind
156
+ * (`package`, `module`, `index`), and putting that in the audience field would corrupt the
157
+ * coverage counts that read it.
158
+ */
159
+ const AUDIENCES = new Set(['agent', 'human', 'archive', 'project', 'internal', 'external', 'unclassified'])
160
+
161
+ export const declaredAudience = (frontmatter: MarkdownFrontmatter): string | undefined => {
162
+ if (frontmatter.audience) return frontmatter.audience.toLowerCase()
163
+ const type = frontmatter.type?.toLowerCase()
164
+ return type && AUDIENCES.has(type) ? type : undefined
165
+ }
166
+
167
+ const generatedRegions = (tree: Root, totalLines: number): MarkdownGeneratedRegion[] => {
168
+ const regions: MarkdownGeneratedRegion[] = []
169
+ let open: { line: number; hash?: string } | undefined
170
+
171
+ visit(tree, 'html', (node) => {
172
+ const line = lineOf(node)
173
+ const closing = GENERATED_CLOSE.exec(node.value)
174
+ if (closing) {
175
+ if (open) regions.push({ lineStart: open.line, lineEnd: node.position?.end.line ?? line, ...(open.hash ? { hash: open.hash } : {}) })
176
+ open = undefined
177
+ return
178
+ }
179
+ const opening = GENERATED_OPEN.exec(node.value)
180
+ if (!opening) return
181
+ // A second opening marker closes the previous region: generators emit regions, not nests.
182
+ if (open) regions.push({ lineStart: open.line, lineEnd: line - 1, ...(open.hash ? { hash: open.hash } : {}) })
183
+ open = { line, ...(opening[1] ? { hash: opening[1] } : {}) }
184
+ })
185
+
186
+ // An unclosed marker owns the rest of the file, which is what a trailing generated block is.
187
+ if (open) regions.push({ lineStart: open.line, lineEnd: totalLines, ...(open.hash ? { hash: open.hash } : {}) })
188
+ return regions
189
+ }
190
+
191
+ /**
192
+ * The hash of a document's content, as the entity records it.
193
+ *
194
+ * A byte-order mark is not content — a file that only gained one parses to the same tree — so it
195
+ * is stripped before hashing. Exported because deciding whether a document needs parsing at all
196
+ * means computing the same hash without parsing it.
197
+ */
198
+ export const markdownContentHash = (content: string): string => sha256NormalizedV1(content.replace(/^\uFEFF/, ''))
199
+
200
+ export const parseMarkdownDocument = (path: string, content: string): MarkdownDocumentV1 => {
201
+ const normalized = content.replace(/^\uFEFF/, '')
202
+ const tree = processor.parse(normalized) as Root
203
+ const totalLines = normalized.split(/\r?\n/).length
204
+
205
+ const frontmatterNode = tree.children.find((child): child is RootContent & { type: 'yaml'; value: string } => child.type === 'yaml')
206
+ const regions = generatedRegions(tree, totalLines)
207
+
208
+ const headings: MarkdownHeading[] = []
209
+ const links: MarkdownReference[] = []
210
+ const codeTokens: MarkdownReference[] = []
211
+ let title: string | undefined
212
+ let summary: string | undefined
213
+
214
+ visit(tree, (node) => {
215
+ if (node.type === 'yaml') return
216
+ const line = lineOf(node as { position?: { start: { line: number } } })
217
+
218
+ if (node.type === 'heading') {
219
+ const text = mdastToString(node).trim()
220
+ if (!text) return
221
+ if (!title && node.depth === 1) title = text.slice(0, MAX_TITLE_LENGTH)
222
+ if (node.depth <= MAX_HEADING_DEPTH && headings.length < MAX_HEADINGS) {
223
+ headings.push({ depth: node.depth, text: text.slice(0, MAX_TITLE_LENGTH), line })
224
+ }
225
+ return
226
+ }
227
+
228
+ if (node.type === 'paragraph' && !summary && !withinGenerated(line, regions)) {
229
+ // A paragraph that is only a link is navigation, not a description of the document.
230
+ const onlyLink = node.children.length === 1 && node.children[0]?.type === 'link'
231
+ if (!onlyLink) summary = boundedSummary(mdastToString(node))
232
+ return
233
+ }
234
+
235
+ if (node.type === 'link' && !withinGenerated(line, regions)) {
236
+ const text = mdastToString(node).trim()
237
+ links.push({ value: node.url, ...(text ? { text } : {}), line })
238
+ return
239
+ }
240
+
241
+ if (node.type === 'inlineCode' && !withinGenerated(line, regions)) {
242
+ const value = node.value.trim()
243
+ if (value) codeTokens.push({ value, line })
244
+ }
245
+ })
246
+
247
+ const frontmatter = frontmatterSubset(frontmatterNode?.value)
248
+ const body = tree.children.filter((child) => child.type !== 'yaml')
249
+ const prose = body.map((child) => mdastToString(child)).join(' ')
250
+ const wordCount = prose.split(/\s+/).filter(Boolean).length
251
+
252
+ return {
253
+ path,
254
+ ...(title ? { title } : {}),
255
+ headings,
256
+ ...(summary ? { summary } : {}),
257
+ wordCount,
258
+ frontmatter,
259
+ generatedRegions: regions,
260
+ contentHash: markdownContentHash(normalized),
261
+ links,
262
+ codeTokens,
263
+ ...(frontmatterNode
264
+ ? { frontmatterBlock: { value: frontmatterNode.value, line: lineOf(frontmatterNode) } }
265
+ : {}),
266
+ }
267
+ }
268
+
269
+
270
+
271
+ export type MarkdownResolution = {
272
+ /** Repository-relative paths of every scanned document. */
273
+ readonly documents: ReadonlyMap<string, string>
274
+ /** Repository-relative module path to entity id. */
275
+ readonly modules: ReadonlyMap<string, string>
276
+ /** Repository-relative directory path to area entity id. */
277
+ readonly areas?: ReadonlyMap<string, string>
278
+ /** Package name, and short name, to entity id. */
279
+ readonly packages: ReadonlyMap<string, string>
280
+ /** Exported symbol to the entity ids of every module exporting it. */
281
+ readonly symbols: ReadonlyMap<string, readonly string[]>
282
+ readonly relationCap?: number
283
+ /**
284
+ * Path candidates for near-miss resolution, indexed by length.
285
+ *
286
+ * Built once per snapshot by the caller. Deriving it here meant rebuilding it for every document
287
+ * — with four thousand documents and nine thousand modules, fifty-five million string copies
288
+ * before any analysis, and a similarity scan over the whole universe per unresolved reference.
289
+ * `markdownPathCandidateIndex` builds it from the same three maps, so a caller that omits it
290
+ * still gets identical results, only slowly.
291
+ */
292
+ readonly pathIndex?: FuzzyCandidateIndex
293
+ }
294
+
295
+ /** The candidate index the analyzer wants, built once from a resolution universe. */
296
+ export const markdownPathCandidateIndex = (
297
+ resolution: Pick<MarkdownResolution, 'documents' | 'modules' | 'areas'>,
298
+ ): FuzzyCandidateIndex =>
299
+ createFuzzyCandidateIndex([
300
+ ...resolution.documents.keys(),
301
+ ...resolution.modules.keys(),
302
+ ...(resolution.areas ?? new Map<string, string>()).keys(),
303
+ ])
304
+
305
+ export type MarkdownNote = {
306
+ readonly scope: string
307
+ readonly reason: string
308
+ readonly evidence: readonly Evidence[]
309
+ }
310
+
311
+ export type MarkdownAnalysis = {
312
+ readonly relations: readonly KnowledgeRelation[]
313
+ readonly notes: readonly MarkdownNote[]
314
+ readonly truncated: boolean
315
+ }
316
+
317
+ const documentEvidence = (path: string, line: number): Evidence => ({
318
+ source: 'documentation',
319
+ path,
320
+ lineStart: line,
321
+ lineEnd: line,
322
+ })
323
+
324
+ /** Resolve a relative link against the document's own directory, POSIX-style. */
325
+ const resolveRelative = (from: string, target: string): string | undefined => {
326
+ if (!target || EXTERNAL_LINK.test(target)) return undefined
327
+ const clean = target.split('#')[0]?.split('?')[0] ?? ''
328
+ if (!clean) return undefined
329
+ const base = clean.startsWith('/') ? [] : from.split('/').slice(0, -1)
330
+ const segments = [...base]
331
+ for (const segment of clean.replace(/^\//, '').split('/')) {
332
+ if (!segment || segment === '.') continue
333
+ if (segment === '..') {
334
+ if (!segments.length) return undefined
335
+ segments.pop()
336
+ continue
337
+ }
338
+ segments.push(segment)
339
+ }
340
+ return segments.length ? toPosix(segments.join('/')) : undefined
341
+ }
342
+
343
+ /** A token that could be a repository path, as opposed to prose or a symbol. */
344
+ const pathShaped = (value: string): boolean => /[/.]/.test(value) && !/\s/.test(value)
345
+
346
+ export const analyzeMarkdownDocument = (
347
+ document: MarkdownDocumentV1,
348
+ documentId: string,
349
+ resolution: MarkdownResolution,
350
+ ): MarkdownAnalysis => {
351
+ const cap = resolution.relationCap ?? MARKDOWN_RELATION_CAP
352
+ const relations = new Map<string, KnowledgeRelation>()
353
+ const notes: MarkdownNote[] = []
354
+ const ambiguous = new Map<string, Evidence[]>()
355
+ let truncated = false
356
+
357
+ const add = (kind: string, to: string, line: number, confidence?: 'fuzzy'): void => {
358
+ if (to === documentId) return
359
+ const id = relationId(documentId, kind, to)
360
+ const existing = relations.get(id)
361
+ if (existing) {
362
+ // One relation, every place the document says it — evidence accumulates, the edge does not.
363
+ if (existing.evidence.length < 8) {
364
+ relations.set(id, { ...existing, evidence: [...existing.evidence, documentEvidence(document.path, line)] })
365
+ }
366
+ return
367
+ }
368
+ if (relations.size >= cap) {
369
+ truncated = true
370
+ return
371
+ }
372
+ relations.set(id, {
373
+ id,
374
+ kind,
375
+ from: documentId,
376
+ to,
377
+ provenance: 'observed',
378
+ evidence: [documentEvidence(document.path, line)],
379
+ ...(confidence ? { metadata: { confidence } } : {}),
380
+ })
381
+ }
382
+
383
+ const areas = resolution.areas ?? new Map<string, string>()
384
+ const pathCandidates = resolution.pathIndex ?? markdownPathCandidateIndex(resolution)
385
+
386
+ /** A path-shaped reference: a document link, a module mention, or an unambiguous near-miss. */
387
+ const resolvePath = (candidate: string, line: number, linkKind: 'links-to' | 'mentions'): boolean => {
388
+ const documentEntity = resolution.documents.get(candidate)
389
+ if (documentEntity) {
390
+ add(linkKind === 'links-to' ? 'links-to' : 'mentions', documentEntity, line)
391
+ return true
392
+ }
393
+ const moduleEntity = resolution.modules.get(candidate)
394
+ if (moduleEntity) {
395
+ add('mentions', moduleEntity, line)
396
+ return true
397
+ }
398
+ // A directory is a unit of architecture now: naming one is a mention of the area.
399
+ const areaEntity = areas.get(candidate)
400
+ if (areaEntity) {
401
+ add('mentions', areaEntity, line)
402
+ return true
403
+ }
404
+ const fuzzy = resolveFuzzyReference(candidate, pathCandidates)
405
+ if (!fuzzy) return false
406
+ const target =
407
+ resolution.documents.get(fuzzy.candidate) ?? resolution.modules.get(fuzzy.candidate) ?? areas.get(fuzzy.candidate)
408
+ if (!target) return false
409
+ add(resolution.documents.has(fuzzy.candidate) ? linkKind : 'mentions', target, line, 'fuzzy')
410
+ return true
411
+ }
412
+
413
+ for (const link of document.links) {
414
+ const resolved = resolveRelative(document.path, link.value)
415
+ if (resolved) resolvePath(resolved, link.line, 'links-to')
416
+ // Link text can name a path or package even when the href points elsewhere.
417
+ if (link.text) resolveToken(link.text, link.line)
418
+ }
419
+
420
+ for (const token of document.codeTokens) resolveToken(token.value, token.line)
421
+
422
+ /**
423
+ * An inline code token or a link label. In order: a repository path, a package name, then an
424
+ * exported symbol — and a symbol only when exactly one module exports it, because sending an
425
+ * agent to one of two possible definitions is worse than sending it nowhere.
426
+ */
427
+ function resolveToken(raw: string, line: number): void {
428
+ const value = raw.trim()
429
+ if (!value || value.length > 256) return
430
+
431
+ if (pathShaped(value) || areas.has(value)) {
432
+ const direct = value.replace(/^\.\//, '')
433
+ if (resolution.documents.has(direct) || resolution.modules.has(direct) || areas.has(direct)) {
434
+ resolvePath(direct, line, 'mentions')
435
+ return
436
+ }
437
+ }
438
+
439
+ const packageEntity = resolution.packages.get(value)
440
+ if (packageEntity) {
441
+ add('mentions', packageEntity, line)
442
+ return
443
+ }
444
+
445
+ const modules = resolution.symbols.get(value)
446
+ if (modules?.length === 1 && modules[0]) {
447
+ add('mentions-symbol', modules[0], line)
448
+ return
449
+ }
450
+ if (modules && modules.length > 1) {
451
+ const evidence = ambiguous.get(value) ?? []
452
+ if (evidence.length < 8) evidence.push(documentEvidence(document.path, line))
453
+ ambiguous.set(value, evidence)
454
+ return
455
+ }
456
+
457
+ if (pathShaped(value)) resolvePath(value.replace(/^\.\//, ''), line, 'mentions')
458
+ }
459
+
460
+ for (const [token, evidence] of [...ambiguous.entries()].sort(([a], [b]) => a.localeCompare(b))) {
461
+ notes.push({
462
+ scope: `mentions-symbol:${document.path}:${token}`,
463
+ reason: `"${token}" is exported by ${resolution.symbols.get(token)?.length ?? 0} modules; the reference is ambiguous and produced no relation.`,
464
+ evidence,
465
+ })
466
+ }
467
+
468
+ if (truncated) {
469
+ notes.push({
470
+ scope: `relations:${document.path}`,
471
+ reason: `Document references more than ${cap} entities; the remainder was not recorded.`,
472
+ evidence: [documentEvidence(document.path, 1)],
473
+ })
474
+ }
475
+
476
+ return {
477
+ relations: [...relations.values()].sort((a, b) => a.id.localeCompare(b.id)),
478
+ notes,
479
+ truncated,
480
+ }
481
+ }