@agentskit/doc-bridge 1.7.45 → 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 (213) hide show
  1. package/CHANGELOG.md +465 -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 +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  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 +147 -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 +25 -6
  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 +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -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 +484 -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
@@ -0,0 +1,110 @@
1
+ import { extname } from 'node:path'
2
+ import * as ts from 'typescript'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { DEFAULT_SAFETY_EXCLUDES, type SafeWalkOptions } from '../safety/repository.js'
6
+
7
+ /**
8
+ * What counts as a repository input, in one place.
9
+ *
10
+ * The discovery snapshot and the retrieval projection must walk the same files under the same
11
+ * safety limits, or the index would claim to cover entities the snapshot does not have (or miss
12
+ * ones it does). Sharing these definitions makes that agreement structural instead of a comment.
13
+ */
14
+
15
+ export const SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'] as const
16
+ export const DOCUMENT_EXTENSIONS = ['.md', '.mdx'] as const
17
+ export const CONFIG_EXTENSIONS = ['.json', '.yaml', '.yml', '.js', '.ts'] as const
18
+ export const DEFAULT_MAX_FILES = 10_000
19
+
20
+ export const safeWalkOptions = (config: DocBridgeConfigV1 | undefined, overrides: { readonly maxFiles?: number; readonly maxBytes?: number } = {}): SafeWalkOptions => {
21
+ const safety = config?.safety
22
+ const maxBytes = overrides.maxBytes ?? safety?.maxBytes
23
+ return {
24
+ exclude: [...DEFAULT_SAFETY_EXCLUDES, ...(safety?.exclude ?? [])],
25
+ maxFiles: overrides.maxFiles ?? safety?.maxFiles ?? DEFAULT_MAX_FILES,
26
+ ...(maxBytes !== undefined ? { maxBytes } : {}),
27
+ ...(safety?.maxTimeMs !== undefined ? { maxTimeMs: safety.maxTimeMs } : {}),
28
+ ...(safety?.maxMemoryMb !== undefined ? { maxMemoryMb: safety.maxMemoryMb } : {}),
29
+ }
30
+ }
31
+
32
+ /** Which audience a documentation file is written for, from its location. */
33
+ export const documentClassification = (path: string): string => {
34
+ if (/(^|\/)docs\/for-agents(?:\/|$)/.test(path)) return 'agent'
35
+ if (/(^|\/)docs-archive(?:\/|$)/.test(path)) return 'archive'
36
+ if (/(^|\/)docs(?:\/|$)/.test(path)) return 'human'
37
+ if (/(^|\/)(README|CONTRIBUTING|SECURITY|CHANGELOG)(?:\.|$)/i.test(path)) return 'project'
38
+ return 'unclassified'
39
+ }
40
+
41
+ export const scriptKind = (path: string): ts.ScriptKind => {
42
+ switch (extname(path)) {
43
+ case '.js': return ts.ScriptKind.JS
44
+ case '.jsx': return ts.ScriptKind.JSX
45
+ case '.mjs': return ts.ScriptKind.JS
46
+ case '.cjs': return ts.ScriptKind.JS
47
+ case '.ts': return ts.ScriptKind.TS
48
+ case '.tsx': return ts.ScriptKind.TSX
49
+ case '.mts': return ts.ScriptKind.TS
50
+ case '.cts': return ts.ScriptKind.TS
51
+ default: return ts.ScriptKind.Unknown
52
+ }
53
+ }
54
+
55
+ export const isExported = (node: ts.Node): boolean => {
56
+ const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined
57
+ return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false
58
+ }
59
+
60
+ export type ExportedNamesOptions = {
61
+ /**
62
+ * Skip names the module only forwards (`export { x } from './y'`).
63
+ *
64
+ * A barrel file re-exports hundreds of names it does not define. Treating those as definitions
65
+ * makes almost every symbol look like it has two owners, which turns an unambiguous reference
66
+ * into an ambiguous one — so the callers that need "the module that defines this" ask for
67
+ * declarations only.
68
+ */
69
+ readonly declaredOnly?: boolean
70
+ }
71
+
72
+ /** Every name a module exports, sorted. `*` stands for a star re-export. */
73
+ export const exportedNames = (sourceFile: ts.SourceFile, options: ExportedNamesOptions = {}): string[] => {
74
+ const declaredOnly = options.declaredOnly ?? false
75
+ const names = new Set<string>()
76
+ const addDeclarationName = (node: ts.Declaration): void => {
77
+ if (!isExported(node)) return
78
+ const name = ts.getNameOfDeclaration(node)
79
+ if (name && ts.isIdentifier(name)) names.add(name.text)
80
+ }
81
+
82
+ const visit = (node: ts.Node): void => {
83
+ if (ts.isExportDeclaration(node)) {
84
+ if (declaredOnly && node.moduleSpecifier) {
85
+ // Forwarded from elsewhere: the other module is the one that defines it.
86
+ } else if (!node.exportClause) names.add('*')
87
+ else if (ts.isNamedExports(node.exportClause)) {
88
+ for (const element of node.exportClause.elements) names.add(element.name.text)
89
+ }
90
+ } else if (ts.isExportAssignment(node)) {
91
+ names.add('default')
92
+ } else if (
93
+ ts.isClassDeclaration(node) ||
94
+ ts.isFunctionDeclaration(node) ||
95
+ ts.isInterfaceDeclaration(node) ||
96
+ ts.isTypeAliasDeclaration(node) ||
97
+ ts.isEnumDeclaration(node) ||
98
+ ts.isModuleDeclaration(node)
99
+ ) {
100
+ addDeclarationName(node)
101
+ } else if (ts.isVariableStatement(node) && isExported(node)) {
102
+ for (const declaration of node.declarationList.declarations) {
103
+ if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text)
104
+ }
105
+ }
106
+ ts.forEachChild(node, visit)
107
+ }
108
+ visit(sourceFile)
109
+ return [...names].sort()
110
+ }
@@ -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
+ }