@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
@@ -1,3 +1,6 @@
1
+ import { parseDocument, type Node as YamlNode } from 'yaml'
2
+ import { z } from 'zod'
3
+
1
4
  import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
2
5
  import {
3
6
  DiscoverySnapshotV1Schema,
@@ -24,6 +27,7 @@ export type DocumentationDeclarationOptions = {
24
27
  readonly documentId?: string
25
28
  /** Agent corpus root used for conservative package/app path inference. */
26
29
  readonly agentRoot?: string
30
+ readonly entityLookup?: EntityLookup
27
31
  }
28
32
 
29
33
  export type DocumentationDeclarationResult = {
@@ -48,6 +52,8 @@ type RelationFields = {
48
52
  fields: Set<string>
49
53
  }
50
54
 
55
+ type EntityLookup = ReadonlyMap<string, KnowledgeEntity>
56
+
51
57
  const detectionValues = new Set(['static', 'dynamic', 'external'])
52
58
 
53
59
  const evidence = (path: string, lineStart: number, lineEnd = lineStart): Evidence => ({
@@ -65,6 +71,15 @@ const diagnostic = (
65
71
  lineEnd = lineStart,
66
72
  ): DocumentationDiagnostic => ({ code, message, path: 'docbridge', evidence: evidence(path, lineStart, lineEnd) })
67
73
 
74
+ const entityLookup = (entities: readonly KnowledgeEntity[]): EntityLookup => {
75
+ const lookup = new Map<string, KnowledgeEntity>()
76
+ for (const entity of entities) {
77
+ if (!lookup.has(entity.id)) lookup.set(entity.id, entity)
78
+ for (const alias of entity.aliases ?? []) if (!lookup.has(alias)) lookup.set(alias, entity)
79
+ }
80
+ return lookup
81
+ }
82
+
68
83
  const scalar = (value: string): string => {
69
84
  const trimmed = value.trim()
70
85
  if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
@@ -73,6 +88,97 @@ const scalar = (value: string): string => {
73
88
  return trimmed
74
89
  }
75
90
 
91
+ const isFieldName = (value: string): boolean => {
92
+ if (!/^[A-Za-z]/.test(value)) return false
93
+ for (const character of value.slice(1)) {
94
+ if (!/[A-Za-z0-9_-]/.test(character)) return false
95
+ }
96
+ return true
97
+ }
98
+
99
+ const parseIndentedField = (raw: string, indentation: number): { readonly key: string; readonly value: string } | undefined => {
100
+ const prefix = ' '.repeat(indentation)
101
+ if (!raw.startsWith(prefix) || raw[indentation] === ' ') return undefined
102
+ const body = raw.slice(indentation)
103
+ const separator = body.indexOf(':')
104
+ if (separator <= 0) return undefined
105
+ const key = body.slice(0, separator).trim()
106
+ return isFieldName(key) ? { key, value: body.slice(separator + 1).trim() } : undefined
107
+ }
108
+
109
+ const parseListItem = (raw: string, indentation: number): string | undefined => {
110
+ const prefix = `${' '.repeat(indentation)}-`
111
+ if (!raw.startsWith(prefix)) return undefined
112
+ const rest = raw.slice(prefix.length)
113
+ if (rest && !/\s/.test(rest[0] ?? '')) return undefined
114
+ return rest.trim()
115
+ }
116
+
117
+ /*
118
+ * The `docbridge` block is real YAML validated by Zod.
119
+ *
120
+ * It used to be read by a hand-written parser for a YAML subset, which meant a quoted list, a
121
+ * multi-line string or an anchor was a syntax error in a file every other tool considered valid.
122
+ * The schema is deliberately structural rather than semantic: it says what shape the block has,
123
+ * while the checks that a relation names all four of its fields and a known detection value stay
124
+ * downstream, where they already produce the aggregated messages this contract promises.
125
+ */
126
+ const DocbridgeRelationSchema = z
127
+ .object({
128
+ from: z.string().min(1).optional(),
129
+ to: z.string().min(1).optional(),
130
+ kind: z.string().min(1).optional(),
131
+ detection: z.string().min(1).optional(),
132
+ })
133
+ .strict()
134
+
135
+ const DocbridgeBlockSchema = z
136
+ .object({
137
+ covers: z.array(z.string().min(1)).optional(),
138
+ relations: z.array(DocbridgeRelationSchema).optional(),
139
+ })
140
+ .strict()
141
+
142
+ /**
143
+ * Map a schema issue onto the diagnostic code it has always had.
144
+ *
145
+ * The codes are a published contract — a repository may fail its build on one — so adopting a
146
+ * schema must not rename them. Zod says precisely what is wrong and where; this decides which
147
+ * existing code that corresponds to.
148
+ */
149
+ const issueCode = (issue: z.core.$ZodIssue): string => {
150
+ const [first, second, third] = issue.path
151
+ if (issue.code === 'unrecognized_keys') return 'DOCBRIDGE_FIELD_UNKNOWN'
152
+ if (first === undefined) return 'DOCBRIDGE_BLOCK_MALFORMED'
153
+ if (first === 'covers') return second === undefined ? 'DOCBRIDGE_COVERS_INVALID' : 'DOCBRIDGE_REFERENCE_MISSING'
154
+ if (first === 'relations') {
155
+ if (second === undefined) return 'DOCBRIDGE_RELATIONS_INVALID'
156
+ return third === undefined ? 'DOCBRIDGE_RELATION_INVALID' : 'DOCBRIDGE_FIELD_UNKNOWN'
157
+ }
158
+ return 'DOCBRIDGE_STRUCTURE_INVALID'
159
+ }
160
+
161
+ const issueMessage = (issue: z.core.$ZodIssue): string => {
162
+ const field = issue.path.length ? `docbridge.${issue.path.join('.')}` : 'docbridge'
163
+ const keys = issue.code === 'unrecognized_keys' ? `: ${issue.keys.join(', ')}` : ''
164
+ return `${field}: ${issue.message}${keys}`
165
+ }
166
+
167
+ /** Absolute file line of an offset inside the frontmatter body. */
168
+ const lineResolver = (body: string, frontmatterLine: number) => (offset: number): number => {
169
+ const safe = Math.max(0, Math.min(offset, body.length))
170
+ let line = frontmatterLine + 1
171
+ for (let index = 0; index < safe; index += 1) if (body[index] === '\n') line += 1
172
+ return line
173
+ }
174
+
175
+ type YamlRanged = { readonly range?: readonly [number, number, number] | null }
176
+
177
+ const nodeStart = (node: unknown): number | undefined => {
178
+ const range = (node as YamlRanged | null | undefined)?.range
179
+ return range ? range[0] : undefined
180
+ }
181
+
76
182
  const conventionalPackageReference = (path: string, agentRoot: string): string | undefined => {
77
183
  const prefix = `${agentRoot.replace(/\/$/, '')}/`
78
184
  if (!path.startsWith(prefix)) return undefined
@@ -113,8 +219,9 @@ const resolveEntity = (
113
219
  input: DocumentationDeclarationInput,
114
220
  lineStart: number,
115
221
  unresolved: Map<string, KnowledgeEntity>,
222
+ lookup: EntityLookup,
116
223
  ): KnowledgeEntity => {
117
- const direct = entities.find((entity) => entity.id === reference || entity.aliases?.includes(reference))
224
+ const direct = lookup.get(reference)
118
225
  if (direct) return direct
119
226
  const packageReference = reference.replace(/^package:/, '')
120
227
  const packageCandidates = entities.filter((entity) => {
@@ -124,6 +231,23 @@ const resolveEntity = (
124
231
  })
125
232
  const resolved = packageCandidates.length === 1 ? packageCandidates[0] : undefined
126
233
  if (resolved) return resolved
234
+
235
+ /*
236
+ * An ownership id resolves to the area that carries it.
237
+ *
238
+ * Agent documents declare coverage by ownership id (`editRoot: src/query` becomes ownership
239
+ * `doc-bridge-query`), and until areas existed there was nothing in the graph with that
240
+ * identity — every such declaration became an unresolved reference, and the audit reported a
241
+ * documented area as a gap.
242
+ */
243
+ const ownedAreas = entities.filter((entity) => entity.kind === 'area' && entity.metadata?.ownershipId === reference)
244
+ if (ownedAreas.length === 1 && ownedAreas[0]) return ownedAreas[0]
245
+
246
+ // A path the repository knows an area, a module or a document by names that thing.
247
+ const path = reference.replace(/^\.\//, '').replace(/\/$/, '')
248
+ const byPath = entities.filter((entity) => (entity.kind === 'area' || entity.kind === 'module' || entity.kind === 'document') && entity.path === path)
249
+ if (byPath.length === 1 && byPath[0]) return byPath[0]
250
+
127
251
  const id = `unresolved:${reference}`
128
252
  const existing = unresolved.get(id)
129
253
  if (existing) return existing
@@ -140,6 +264,91 @@ const resolveEntity = (
140
264
 
141
265
  const relationKey = (from: string, to: string, kind: string): string => `${from}\u0000${to}\u0000${kind}`
142
266
 
267
+ /**
268
+ * Read the `docbridge` block as YAML.
269
+ *
270
+ * Returns `undefined` when YAML cannot read the block at all — a tab in the indentation, a
271
+ * duplicate key, a scalar where a mapping was opened. The lenient line scanner below then takes
272
+ * over, because on a mangled block a per-line diagnostic is more use to the author than a single
273
+ * parser error, and because those diagnostics are the published contract.
274
+ */
275
+ const parseDocbridgeYaml = (
276
+ input: DocumentationDeclarationInput,
277
+ lines: readonly string[],
278
+ end: number,
279
+ ):
280
+ | {
281
+ readonly covers: readonly { value: string; line: number }[]
282
+ readonly relations: readonly RelationFields[]
283
+ readonly diagnostics: readonly DocumentationDiagnostic[]
284
+ }
285
+ | undefined => {
286
+ const body = lines.slice(1, end).join('\n')
287
+ const document = parseDocument(body, { prettyErrors: false })
288
+ if (document.errors.length) return undefined
289
+
290
+ let data: unknown
291
+ try {
292
+ data = document.toJS()
293
+ } catch {
294
+ return undefined
295
+ }
296
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
297
+ const raw = (data as Record<string, unknown>).docbridge
298
+ const lineAt = lineResolver(body, 1)
299
+ const blockNode = document.get('docbridge', true) as YamlNode | undefined
300
+
301
+ const diagnostics: DocumentationDiagnostic[] = []
302
+ // `docbridge:` with nothing under it is empty, not malformed: DOCBRIDGE_CONTENT_MISSING says so
303
+ // downstream, and two diagnostics for one mistake is one too many.
304
+ if (raw === null || raw === undefined) return { covers: [], relations: [], diagnostics }
305
+
306
+ const parsed = DocbridgeBlockSchema.safeParse(raw)
307
+ if (!parsed.success) {
308
+ for (const issue of parsed.error.issues) {
309
+ const node = issue.path.length ? (document.getIn(['docbridge', ...issue.path], true) as YamlNode | undefined) : blockNode
310
+ diagnostics.push(diagnostic(input.path, issueCode(issue), issueMessage(issue), lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0)))
311
+ }
312
+ }
313
+
314
+ /*
315
+ * Keep what is individually valid. One unknown key should not discard the declarations around
316
+ * it: the author gets the precise error and the graph still gets the edges they did declare.
317
+ */
318
+ const covers: { value: string; line: number }[] = []
319
+ const relations: RelationFields[] = []
320
+ // `raw` is neither null nor undefined here: the early return above settled that.
321
+ const record = typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined
322
+
323
+ if (Array.isArray(record?.covers)) {
324
+ for (const [index, entry] of record.covers.entries()) {
325
+ if (typeof entry !== 'string' || !entry.trim()) continue
326
+ const node = document.getIn(['docbridge', 'covers', index], true) as YamlNode | undefined
327
+ covers.push({ value: entry.trim(), line: lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0) })
328
+ }
329
+ }
330
+
331
+ if (Array.isArray(record?.relations)) {
332
+ for (const [index, entry] of record.relations.entries()) {
333
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue
334
+ const node = document.getIn(['docbridge', 'relations', index], true) as YamlNode | undefined
335
+ const startLine = lineAt(nodeStart(node) ?? nodeStart(blockNode) ?? 0)
336
+ const relation: RelationFields = { startLine, endLine: startLine, fields: new Set() }
337
+ for (const field of ['from', 'to', 'kind', 'detection'] as const) {
338
+ const value = (entry as Record<string, unknown>)[field]
339
+ if (typeof value !== 'string' || !value.trim()) continue
340
+ relation.fields.add(field)
341
+ relation[field] = value.trim()
342
+ const valueNode = document.getIn(['docbridge', 'relations', index, field], true) as YamlNode | undefined
343
+ relation.endLine = Math.max(relation.endLine, lineAt(nodeStart(valueNode) ?? nodeStart(node) ?? 0))
344
+ }
345
+ relations.push(relation)
346
+ }
347
+ }
348
+
349
+ return { covers, relations, diagnostics }
350
+ }
351
+
143
352
  const parseBlock = (
144
353
  input: DocumentationDeclarationInput,
145
354
  options: Pick<DocumentationDeclarationOptions, 'agentRoot'> = {},
@@ -166,21 +375,42 @@ const parseBlock = (
166
375
  let typeLine = -1
167
376
  let packageLine = -1
168
377
  let humanDocLine = -1
378
+ let editRootLine = -1
379
+ let idLine = -1
169
380
  for (let index = 1; index < end; index += 1) {
170
381
  const line = lines[index] ?? ''
171
382
  if (docbridgeLine < 0 && /^docbridge\s*:/.test(line)) docbridgeLine = index
172
383
  if (typeLine < 0 && /^type\s*:/.test(line)) typeLine = index
173
384
  if (packageLine < 0 && /^package\s*:/.test(line)) packageLine = index
174
385
  if (humanDocLine < 0 && /^humanDoc\s*:/.test(line)) humanDocLine = index
386
+ if (editRootLine < 0 && /^editRoot\s*:/.test(line)) editRootLine = index
387
+ if (idLine < 0 && /^id\s*:/.test(line)) idLine = index
175
388
  }
176
389
  if (docbridgeLine < 0) {
177
390
  const type = typeLine >= 0 ? scalar(lines[typeLine]?.slice('type:'.length) ?? '') : ''
178
391
  const packageReference = packageLine >= 0 ? scalar(lines[packageLine]?.slice('package:'.length) ?? '') : conventionalPath ?? ''
179
392
  if (type === 'package' && packageReference) return { covers: [{ value: packageReference, line: packageLine >= 0 ? packageLine + 1 : typeLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
180
393
  if (conventionalPath) return { covers: [{ value: packageReference, line: humanDocLine >= 0 ? humanDocLine + 1 : 1 }], relations: [], diagnostics: [], hasDocbridge: true }
394
+
395
+ /*
396
+ * `id` plus `editRoot` is a coverage declaration.
397
+ *
398
+ * The corpus already uses this pair to own a directory — it is what fills the ownership map
399
+ * and the handoff — but discovery never read it, so the graph had no edge from the sidecar to
400
+ * the unit it owns. With areas in the graph the reference resolves, and a documented area
401
+ * stops being reported as a gap.
402
+ */
403
+ const editRoot = editRootLine >= 0 ? scalar(lines[editRootLine]?.slice('editRoot:'.length) ?? '') : ''
404
+ const identifier = idLine >= 0 ? scalar(lines[idLine]?.slice('id:'.length) ?? '') : ''
405
+ if (editRoot && identifier) {
406
+ return { covers: [{ value: identifier, line: editRootLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
407
+ }
181
408
  return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
182
409
  }
183
410
 
411
+ const fromYaml = parseDocbridgeYaml(input, lines, end)
412
+ if (fromYaml) return { ...fromYaml, hasDocbridge: true }
413
+
184
414
  const diagnostics: DocumentationDiagnostic[] = []
185
415
  const covers: { value: string; line: number }[] = []
186
416
  const relations: RelationFields[] = []
@@ -208,11 +438,10 @@ const parseBlock = (
208
438
  section = undefined
209
439
  continue
210
440
  }
211
- if (/^ {2}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
441
+ const sectionField = parseIndentedField(raw, 2)
442
+ if (sectionField) {
212
443
  finishRelation()
213
- const match = /^ {2}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
214
- const key = match?.[1]
215
- const value = match?.[2] ?? ''
444
+ const { key, value } = sectionField
216
445
  if (key !== 'covers' && key !== 'relations') {
217
446
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown docbridge field: ${key ?? '(missing)'}.`, line)
218
447
  section = undefined
@@ -229,35 +458,36 @@ const parseBlock = (
229
458
  }
230
459
  continue
231
460
  }
232
- if (section === 'covers' && /^ {4}-\s*/.test(raw)) {
233
- const value = scalar(raw.replace(/^ {4}-\s*/, ''))
461
+ const listValue = parseListItem(raw, 4)
462
+ if (section === 'covers' && listValue !== undefined) {
463
+ const value = scalar(listValue)
234
464
  if (!value) addDiagnostic(diagnostics, input, 'DOCBRIDGE_REFERENCE_MISSING', 'covers entries must not be empty.', line)
235
465
  else covers.push({ value, line })
236
466
  continue
237
467
  }
238
- if (section === 'relations' && /^ {4}-\s*/.test(raw)) {
468
+ if (section === 'relations' && listValue !== undefined) {
239
469
  finishRelation()
240
- const firstField = /^ {4}-\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
470
+ const firstField = parseIndentedField(` ${listValue}`, 4)
241
471
  current = { startLine: line, endLine: line, fields: new Set() }
242
- if (firstField?.[1]) {
243
- current.fields.add(firstField[1])
244
- current[firstField[1] as 'from' | 'to' | 'kind' | 'detection'] = scalar(firstField[2] ?? '')
245
- } else if (raw.replace(/^ {4}-\s*/, '').trim()) {
472
+ if (firstField?.key) {
473
+ current.fields.add(firstField.key)
474
+ current[firstField.key as 'from' | 'to' | 'kind' | 'detection'] = scalar(firstField.value)
475
+ } else if (listValue) {
246
476
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_RELATION_INVALID', 'Relation entries must be field mappings.', line)
247
477
  }
248
478
  continue
249
479
  }
250
- if (section === 'relations' && current && /^ {6}[A-Za-z][A-Za-z0-9_-]*\s*:/.test(raw)) {
251
- const field = /^ {6}([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(raw)
252
- const key = field?.[1]
480
+ const field = parseIndentedField(raw, 6)
481
+ if (section === 'relations' && current && field) {
482
+ const { key, value } = field
253
483
  current.endLine = line
254
- if (!key || !['from', 'to', 'kind', 'detection'].includes(key)) {
255
- addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown relation field: ${key ?? '(missing)'}.`, line)
484
+ if (!['from', 'to', 'kind', 'detection'].includes(key)) {
485
+ addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_UNKNOWN', `Unknown relation field: ${key}.`, line)
256
486
  } else if (current.fields.has(key)) {
257
487
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_FIELD_DUPLICATE', `Duplicate relation field: ${key}.`, line)
258
488
  } else {
259
489
  current.fields.add(key)
260
- current[key as 'from' | 'to' | 'kind' | 'detection'] = scalar(field?.[2] ?? '')
490
+ current[key as 'from' | 'to' | 'kind' | 'detection'] = scalar(value)
261
491
  }
262
492
  continue
263
493
  }
@@ -278,13 +508,14 @@ export const parseDocumentationDeclarations = (
278
508
  const relations: KnowledgeRelation[] = []
279
509
  const documentId = options.documentId ?? `document:${input.path}`
280
510
  const relationClaims = new Map<string, string>()
511
+ const lookup = options.entityLookup ?? entityLookup(options.snapshot.entities)
281
512
 
282
513
  if (!parsed.covers.length && !parsed.relations.length) {
283
514
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_CONTENT_MISSING', 'docbridge must declare covers or relations.', 1)
284
515
  }
285
516
 
286
517
  for (const [index, cover] of parsed.covers.entries()) {
287
- const target = resolveEntity(cover.value, options.snapshot.entities, input, cover.line, unresolved)
518
+ const target = resolveEntity(cover.value, options.snapshot.entities, input, cover.line, unresolved, lookup)
288
519
  relations.push({
289
520
  id: `relation:declared:${input.path}:covers:${index}`,
290
521
  kind: 'covers',
@@ -310,8 +541,8 @@ export const parseDocumentationDeclarations = (
310
541
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_DETECTION_INVALID', `Invalid relation detection: ${detection}.`, declaration.startLine, declaration.endLine)
311
542
  continue
312
543
  }
313
- const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved)
314
- const toEntity = resolveEntity(to, options.snapshot.entities, input, declaration.startLine, unresolved)
544
+ const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved, lookup)
545
+ const toEntity = resolveEntity(to, options.snapshot.entities, input, declaration.startLine, unresolved, lookup)
315
546
  const key = relationKey(fromEntity.id, toEntity.id, kind)
316
547
  const previousDetection = relationClaims.get(key)
317
548
  if (previousDetection === detection) addDiagnostic(diagnostics, input, 'DOCBRIDGE_DECLARATION_DUPLICATE', 'Duplicate relation declaration.', declaration.startLine, declaration.endLine)
@@ -345,9 +576,10 @@ export const applyDocumentationDeclarations = (
345
576
  const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
346
577
  const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
347
578
  const diagnostics: DocumentationDiagnostic[] = []
579
+ const lookup = entityLookup(snapshot.entities)
348
580
 
349
581
  for (const document of documents) {
350
- const result = parseDocumentationDeclarations(document, { snapshot, ...options })
582
+ const result = parseDocumentationDeclarations(document, { snapshot, ...options, entityLookup: lookup })
351
583
  diagnostics.push(...result.diagnostics)
352
584
  for (const entity of result.entities) entities.set(entity.id, entity)
353
585
  for (const relation of result.relations) relations.set(relation.id, relation)
@@ -0,0 +1,24 @@
1
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
2
+
3
+ /**
4
+ * Entity and relation identity, in one place.
5
+ *
6
+ * Every analyzer and every projection has to agree on what a module or a document is called, or
7
+ * the graph silently grows two nodes for one file and nothing joins. The hash suffix keeps a very
8
+ * long path addressable without exceeding the schema's identifier bound.
9
+ */
10
+
11
+ const MAX_ID_LENGTH = 256
12
+ const ID_HASH_LENGTH = 32
13
+
14
+ export const entityId = (kind: string, value: string): string => {
15
+ const fullId = `${kind}:${value}`
16
+ if (fullId.length <= MAX_ID_LENGTH) return fullId
17
+
18
+ const suffix = `:${sha256NormalizedV1(fullId).slice(0, ID_HASH_LENGTH)}`
19
+ return `${fullId.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`
20
+ }
21
+
22
+ /** Identity of an observed relation: its endpoints and kind, plus an optional discriminator. */
23
+ export const relationId = (from: string, kind: string, to: string, discriminator?: string): string =>
24
+ entityId('relation', discriminator ? `${from}:${kind}:${to}:${discriminator}` : `${from}:${kind}:${to}`)