@agentskit/doc-bridge 1.7.45 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -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("'"))) {
@@ -99,6 +114,71 @@ const parseListItem = (raw: string, indentation: number): string | undefined =>
99
114
  return rest.trim()
100
115
  }
101
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
+
102
182
  const conventionalPackageReference = (path: string, agentRoot: string): string | undefined => {
103
183
  const prefix = `${agentRoot.replace(/\/$/, '')}/`
104
184
  if (!path.startsWith(prefix)) return undefined
@@ -139,8 +219,9 @@ const resolveEntity = (
139
219
  input: DocumentationDeclarationInput,
140
220
  lineStart: number,
141
221
  unresolved: Map<string, KnowledgeEntity>,
222
+ lookup: EntityLookup,
142
223
  ): KnowledgeEntity => {
143
- const direct = entities.find((entity) => entity.id === reference || entity.aliases?.includes(reference))
224
+ const direct = lookup.get(reference)
144
225
  if (direct) return direct
145
226
  const packageReference = reference.replace(/^package:/, '')
146
227
  const packageCandidates = entities.filter((entity) => {
@@ -150,6 +231,23 @@ const resolveEntity = (
150
231
  })
151
232
  const resolved = packageCandidates.length === 1 ? packageCandidates[0] : undefined
152
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
+
153
251
  const id = `unresolved:${reference}`
154
252
  const existing = unresolved.get(id)
155
253
  if (existing) return existing
@@ -166,6 +264,91 @@ const resolveEntity = (
166
264
 
167
265
  const relationKey = (from: string, to: string, kind: string): string => `${from}\u0000${to}\u0000${kind}`
168
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
+
169
352
  const parseBlock = (
170
353
  input: DocumentationDeclarationInput,
171
354
  options: Pick<DocumentationDeclarationOptions, 'agentRoot'> = {},
@@ -192,21 +375,42 @@ const parseBlock = (
192
375
  let typeLine = -1
193
376
  let packageLine = -1
194
377
  let humanDocLine = -1
378
+ let editRootLine = -1
379
+ let idLine = -1
195
380
  for (let index = 1; index < end; index += 1) {
196
381
  const line = lines[index] ?? ''
197
382
  if (docbridgeLine < 0 && /^docbridge\s*:/.test(line)) docbridgeLine = index
198
383
  if (typeLine < 0 && /^type\s*:/.test(line)) typeLine = index
199
384
  if (packageLine < 0 && /^package\s*:/.test(line)) packageLine = index
200
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
201
388
  }
202
389
  if (docbridgeLine < 0) {
203
390
  const type = typeLine >= 0 ? scalar(lines[typeLine]?.slice('type:'.length) ?? '') : ''
204
391
  const packageReference = packageLine >= 0 ? scalar(lines[packageLine]?.slice('package:'.length) ?? '') : conventionalPath ?? ''
205
392
  if (type === 'package' && packageReference) return { covers: [{ value: packageReference, line: packageLine >= 0 ? packageLine + 1 : typeLine + 1 }], relations: [], diagnostics: [], hasDocbridge: true }
206
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
+ }
207
408
  return { covers: [], relations: [], diagnostics: [], hasDocbridge: false }
208
409
  }
209
410
 
411
+ const fromYaml = parseDocbridgeYaml(input, lines, end)
412
+ if (fromYaml) return { ...fromYaml, hasDocbridge: true }
413
+
210
414
  const diagnostics: DocumentationDiagnostic[] = []
211
415
  const covers: { value: string; line: number }[] = []
212
416
  const relations: RelationFields[] = []
@@ -304,13 +508,14 @@ export const parseDocumentationDeclarations = (
304
508
  const relations: KnowledgeRelation[] = []
305
509
  const documentId = options.documentId ?? `document:${input.path}`
306
510
  const relationClaims = new Map<string, string>()
511
+ const lookup = options.entityLookup ?? entityLookup(options.snapshot.entities)
307
512
 
308
513
  if (!parsed.covers.length && !parsed.relations.length) {
309
514
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_CONTENT_MISSING', 'docbridge must declare covers or relations.', 1)
310
515
  }
311
516
 
312
517
  for (const [index, cover] of parsed.covers.entries()) {
313
- 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)
314
519
  relations.push({
315
520
  id: `relation:declared:${input.path}:covers:${index}`,
316
521
  kind: 'covers',
@@ -336,8 +541,8 @@ export const parseDocumentationDeclarations = (
336
541
  addDiagnostic(diagnostics, input, 'DOCBRIDGE_DETECTION_INVALID', `Invalid relation detection: ${detection}.`, declaration.startLine, declaration.endLine)
337
542
  continue
338
543
  }
339
- const fromEntity = resolveEntity(from, options.snapshot.entities, input, declaration.startLine, unresolved)
340
- 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)
341
546
  const key = relationKey(fromEntity.id, toEntity.id, kind)
342
547
  const previousDetection = relationClaims.get(key)
343
548
  if (previousDetection === detection) addDiagnostic(diagnostics, input, 'DOCBRIDGE_DECLARATION_DUPLICATE', 'Duplicate relation declaration.', declaration.startLine, declaration.endLine)
@@ -371,9 +576,10 @@ export const applyDocumentationDeclarations = (
371
576
  const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
372
577
  const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
373
578
  const diagnostics: DocumentationDiagnostic[] = []
579
+ const lookup = entityLookup(snapshot.entities)
374
580
 
375
581
  for (const document of documents) {
376
- const result = parseDocumentationDeclarations(document, { snapshot, ...options })
582
+ const result = parseDocumentationDeclarations(document, { snapshot, ...options, entityLookup: lookup })
377
583
  diagnostics.push(...result.diagnostics)
378
584
  for (const entity of result.entities) entities.set(entity.id, entity)
379
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}`)
@@ -0,0 +1,314 @@
1
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
2
+ import type { DiscoverySnapshotV1, Coverage, KnowledgeEntity, KnowledgeRelation } from '../schemas/knowledge.js'
3
+
4
+ /**
5
+ * Reusing what has not changed.
6
+ *
7
+ * `EvidenceSchema.contentHash` has always existed and discovery never filled it, so every cache
8
+ * and every overlay could only be keyed on "the whole repository changed" — true between any two
9
+ * commits, and therefore useless. With a hash per file-backed entity, a second scan can tell which
10
+ * files it has already read and skip the expensive part: the TypeScript parse and the Markdown
11
+ * parse, which is where nearly all of discovery's time goes.
12
+ *
13
+ * Reuse is only sound when the answer cannot have changed, and two different things can change it:
14
+ *
15
+ * - An entity's own fields depend on its own bytes alone, so a hash match is enough to reuse it.
16
+ * - A relation depends on what else exists. A module importing `./new.js` resolved to nothing
17
+ * before that file was added and resolves to a module after; a document mentioning `src/new.ts`
18
+ * gains an edge the moment the module appears. So relation reuse also requires that the universe
19
+ * the references resolve against is identical, which is what the fingerprints below capture.
20
+ *
21
+ * The consequence is that a fast run and a cold run produce the same snapshot, byte for byte, or
22
+ * the fast run does not happen. A cache that is only usually right is worse than no cache.
23
+ */
24
+
25
+ export const fileContentHash = (text: string): string => sha256NormalizedV1(text)
26
+
27
+ /** Kinds whose entity is one file, and therefore hashable. */
28
+ export const FILE_BACKED_KINDS = ['module', 'document', 'package'] as const
29
+
30
+ export type PriorFile = {
31
+ readonly entity: KnowledgeEntity
32
+ readonly contentHash: string
33
+ /** Relations this entity is the source of. */
34
+ readonly outgoing: readonly KnowledgeRelation[]
35
+ /** Coverage entries scoped to this file. */
36
+ readonly coverage: readonly Coverage[]
37
+ }
38
+
39
+ /** What a caller may hand back from a previous scan. */
40
+ export type PreviousSnapshot = Pick<DiscoverySnapshotV1, 'entities' | 'relations' | 'coverage'> &
41
+ Partial<Pick<DiscoverySnapshotV1, 'pipelineVersion' | 'analyzerVersions' | 'configurationHash'>>
42
+
43
+ /**
44
+ * Whether a previous snapshot may be reused at all, and why not when it may not.
45
+ *
46
+ * A hash says a file has not changed; it says nothing about whether *this* code would still read
47
+ * it the same way. An analyzer that learned to record a document's headings produces different
48
+ * entities from identical bytes, and a configuration change moves area boundaries and
49
+ * runtime-wiring detection. So the snapshot has to have been produced by this pipeline, these
50
+ * analyzers and this configuration — and a snapshot that does not say which is not trusted, since
51
+ * the alternative is trusting a caller's hand-assembled input with a repository scan.
52
+ */
53
+ export const reuseRefusal = (
54
+ previous: PreviousSnapshot,
55
+ current: Pick<DiscoverySnapshotV1, 'pipelineVersion' | 'analyzerVersions' | 'configurationHash'>,
56
+ ): string | undefined => {
57
+ if (!previous.pipelineVersion || !previous.analyzerVersions || !previous.configurationHash) {
58
+ return 'the previous snapshot does not declare the pipeline, analyzers and configuration it was produced by'
59
+ }
60
+ if (previous.pipelineVersion !== current.pipelineVersion) {
61
+ return `the previous snapshot was produced by pipeline ${previous.pipelineVersion}, not ${current.pipelineVersion}`
62
+ }
63
+ if (sha256NormalizedV1(previous.analyzerVersions) !== sha256NormalizedV1(current.analyzerVersions)) {
64
+ return 'an analyzer version changed, so identical bytes would not produce identical entities'
65
+ }
66
+ if (previous.configurationHash !== current.configurationHash) return 'the configuration changed'
67
+ return undefined
68
+ }
69
+
70
+ export type PriorSnapshot = {
71
+ readonly modules: ReadonlyMap<string, PriorFile>
72
+ readonly documents: ReadonlyMap<string, PriorFile>
73
+ readonly moduleUniverse: string
74
+ readonly resolution: string
75
+ /** Every entity by id, for re-adding an endpoint a reused relation still points at. */
76
+ readonly entities: ReadonlyMap<string, KnowledgeEntity>
77
+ }
78
+
79
+ const hashOf = (entity: KnowledgeEntity): string | undefined => entity.evidence[0]?.contentHash
80
+
81
+ /** Coverage scopes that belong to a single module, by the topic they start with. */
82
+ const MODULE_COVERAGE_PREFIXES = ['dynamic-imports:', 'runtime-wiring:'] as const
83
+ /** Coverage scopes that belong to a single document. */
84
+ const DOCUMENT_COVERAGE_PREFIXES = ['relations:', 'mentions-symbol:'] as const
85
+
86
+ const stringList = (value: unknown): readonly string[] =>
87
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
88
+
89
+ /**
90
+ * Which names a module declares itself, as opposed to forwarding from somewhere else.
91
+ *
92
+ * The distinction decides where a documented symbol resolves to, and it is only visible in the
93
+ * syntax tree — so a module that skipped its parse has to read it back from what the previous scan
94
+ * recorded. `exports` minus `reexports` is that record.
95
+ */
96
+ export const declaredExportsOf = (entity: KnowledgeEntity): readonly string[] => {
97
+ const exports = stringList(entity.metadata?.exports)
98
+ const reexports = new Set(stringList(entity.metadata?.reexports))
99
+ return exports.filter((name) => !reexports.has(name))
100
+ }
101
+
102
+ export const exportsOf = (entity: KnowledgeEntity): readonly string[] => stringList(entity.metadata?.exports)
103
+
104
+ /**
105
+ * What a module's references resolve against: the module paths, the packages, and the compiler
106
+ * options that decide how a specifier becomes a path. A change to any of them can turn an
107
+ * unresolved import into a relation, so it invalidates relation reuse for every module.
108
+ */
109
+ export const moduleUniverseFingerprint = (input: {
110
+ readonly modulePaths: readonly string[]
111
+ readonly packages: readonly { readonly id: string; readonly path: string; readonly name?: string }[]
112
+ readonly compilerOptions: unknown
113
+ }): string =>
114
+ sha256NormalizedV1({
115
+ modules: [...input.modulePaths].sort(),
116
+ packages: [...input.packages].map(({ id, path, name }) => ({ id, path, ...(name ? { name } : {}) })).sort((a, b) => a.id.localeCompare(b.id)),
117
+ compilerOptions: input.compilerOptions,
118
+ })
119
+
120
+ /**
121
+ * What a document's references resolve against: everything a module resolves against, plus the
122
+ * documents, the areas, the package names and which module declares each exported symbol.
123
+ */
124
+ export const resolutionFingerprint = (input: {
125
+ readonly moduleUniverse: string
126
+ readonly documentPaths: readonly string[]
127
+ readonly areaPaths: readonly string[]
128
+ readonly symbols: ReadonlyMap<string, readonly string[]>
129
+ }): string =>
130
+ sha256NormalizedV1({
131
+ moduleUniverse: input.moduleUniverse,
132
+ documents: [...input.documentPaths].sort(),
133
+ areas: [...input.areaPaths].sort(),
134
+ symbols: [...input.symbols.entries()].map(([name, owners]) => [name, [...owners].sort()]).sort(([a], [b]) => String(a).localeCompare(String(b))),
135
+ })
136
+
137
+ /**
138
+ * Index a previous snapshot for reuse, and recompute the fingerprints it was built under.
139
+ *
140
+ * The fingerprints are derived rather than stored: everything they cover is in the snapshot
141
+ * already, and a stored fingerprint is one more thing that can be stale or forged.
142
+ */
143
+ export const indexPriorSnapshot = (previous: PreviousSnapshot, compilerOptions: unknown): PriorSnapshot => {
144
+ const outgoing = new Map<string, KnowledgeRelation[]>()
145
+ for (const relation of previous.relations) {
146
+ const list = outgoing.get(relation.from)
147
+ if (list) list.push(relation)
148
+ else outgoing.set(relation.from, [relation])
149
+ }
150
+
151
+ const collect = (kind: string, prefixes: readonly string[]): Map<string, PriorFile> => {
152
+ const paths = new Map<string, KnowledgeEntity>()
153
+ for (const entity of previous.entities) {
154
+ if (entity.kind !== kind || !entity.path) continue
155
+ if (hashOf(entity)) paths.set(entity.path, entity)
156
+ }
157
+
158
+ /*
159
+ * Per-file coverage, matched back to its file.
160
+ *
161
+ * The scope is `<topic>:<path>` or `<topic>:<path>:<detail>`, and a detail can itself contain
162
+ * a colon, so the path is recovered by trimming from the right until a file is recognised
163
+ * rather than by splitting on the first one.
164
+ */
165
+ const coverage = new Map<string, Coverage[]>()
166
+ for (const entry of previous.coverage) {
167
+ const prefix = prefixes.find((candidate) => entry.scope.startsWith(candidate))
168
+ if (!prefix) continue
169
+ let remainder = entry.scope.slice(prefix.length)
170
+ while (remainder && !paths.has(remainder)) {
171
+ const cut = remainder.lastIndexOf(':')
172
+ remainder = cut < 0 ? '' : remainder.slice(0, cut)
173
+ }
174
+ if (!remainder) continue
175
+ const list = coverage.get(remainder)
176
+ if (list) list.push(entry)
177
+ else coverage.set(remainder, [entry])
178
+ }
179
+
180
+ const result = new Map<string, PriorFile>()
181
+ for (const [path, entity] of paths) {
182
+ result.set(path, {
183
+ entity,
184
+ contentHash: hashOf(entity) as string,
185
+ outgoing: outgoing.get(entity.id) ?? [],
186
+ coverage: coverage.get(path) ?? [],
187
+ })
188
+ }
189
+ return result
190
+ }
191
+
192
+ const modules = collect('module', MODULE_COVERAGE_PREFIXES)
193
+ const documents = collect('document', DOCUMENT_COVERAGE_PREFIXES)
194
+ const packages = previous.entities
195
+ .filter((entity) => entity.kind === 'package')
196
+ .map((entity) => ({ id: entity.id, path: entity.path ?? '.', ...(entity.name ? { name: entity.name } : {}) }))
197
+
198
+ const symbols = new Map<string, readonly string[]>()
199
+ const declaring = new Map<string, string[]>()
200
+ const forwarding = new Map<string, string[]>()
201
+ for (const [, file] of modules) {
202
+ const declared = new Set(declaredExportsOf(file.entity))
203
+ for (const name of exportsOf(file.entity)) {
204
+ if (name === '*' || name === 'default') continue
205
+ const target = declared.has(name) ? declaring : forwarding
206
+ const list = target.get(name)
207
+ if (list) list.push(file.entity.id)
208
+ else target.set(name, [file.entity.id])
209
+ }
210
+ }
211
+ for (const [name, owners] of declaring) symbols.set(name, owners)
212
+ for (const [name, owners] of forwarding) if (!symbols.has(name)) symbols.set(name, owners)
213
+
214
+ const moduleUniverse = moduleUniverseFingerprint({
215
+ modulePaths: [...modules.keys()],
216
+ packages,
217
+ compilerOptions,
218
+ })
219
+
220
+ return {
221
+ modules,
222
+ documents,
223
+ moduleUniverse,
224
+ resolution: resolutionFingerprint({
225
+ moduleUniverse,
226
+ documentPaths: [...documents.keys()],
227
+ areaPaths: previous.entities.filter((entity) => entity.kind === 'area').map((entity) => entity.path ?? ''),
228
+ symbols,
229
+ }),
230
+ entities: new Map(previous.entities.map((entity) => [entity.id, entity])),
231
+ }
232
+ }
233
+
234
+ export type ReuseLedger = {
235
+ /** Entities taken from the previous snapshot instead of recomputed. */
236
+ reusedEntities: number
237
+ /** Files whose syntax tree or Markdown tree had to be built. */
238
+ parsedFiles: string[]
239
+ /** Files whose parse was skipped entirely. */
240
+ skippedFiles: string[]
241
+ /** Why relation reuse was refused, when it was. */
242
+ invalidated: string[]
243
+ }
244
+
245
+ export const emptyLedger = (): ReuseLedger => ({ reusedEntities: 0, parsedFiles: [], skippedFiles: [], invalidated: [] })
246
+
247
+ /** How many file paths a coverage reason lists before it stops being readable. */
248
+ const LISTED_PATHS = 8
249
+
250
+ /**
251
+ * The reuse, as coverage.
252
+ *
253
+ * A run that is ten times faster than the last one has to be able to say why, or nobody can tell
254
+ * a working cache from a broken scan.
255
+ */
256
+ export const reuseCoverage = (ledger: ReuseLedger): Coverage => {
257
+ const total = ledger.skippedFiles.length + ledger.parsedFiles.length
258
+ if (!ledger.skippedFiles.length && !ledger.invalidated.length) {
259
+ return {
260
+ analyzer: 'repository',
261
+ scope: 'reused-entities',
262
+ status: 'not-applicable',
263
+ reason: `No previous snapshot was reused; parsed ${total} file(s).`,
264
+ }
265
+ }
266
+ const listed = [...ledger.parsedFiles].sort().slice(0, LISTED_PATHS)
267
+ const remainder = ledger.parsedFiles.length - listed.length
268
+ return {
269
+ analyzer: 'repository',
270
+ scope: 'reused-entities',
271
+ status: ledger.invalidated.length ? 'partial' : 'complete',
272
+ reason: [
273
+ `Reused ${ledger.reusedEntities} entit${ledger.reusedEntities === 1 ? 'y' : 'ies'} and skipped ${ledger.skippedFiles.length} of ${total} parse(s).`,
274
+ ledger.parsedFiles.length
275
+ ? `Re-parsed: ${listed.join(', ')}${remainder > 0 ? ` and ${remainder} more` : ''}.`
276
+ : 'Nothing needed re-parsing.',
277
+ ...ledger.invalidated.map((reason) => `Reuse refused: ${reason}.`),
278
+ ].join(' '),
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Replay a reused entity's outgoing relations against the entity set that exists now.
284
+ *
285
+ * A relation whose internal target is gone is dropped rather than carried: the file it pointed at
286
+ * was renamed or deleted, and a graph that keeps the edge is lying about the repository. An
287
+ * external endpoint is re-added instead, because an external entity only exists in the snapshot
288
+ * because something referenced it, and the thing that referenced it is exactly what was reused.
289
+ */
290
+ export const replayableRelations = (
291
+ outgoing: readonly KnowledgeRelation[],
292
+ existing: (id: string) => boolean,
293
+ ): {
294
+ readonly relations: readonly KnowledgeRelation[]
295
+ readonly missingEndpoints: readonly string[]
296
+ readonly dropped: readonly KnowledgeRelation[]
297
+ } => {
298
+ const relations: KnowledgeRelation[] = []
299
+ const missingEndpoints: string[] = []
300
+ const dropped: KnowledgeRelation[] = []
301
+ for (const relation of outgoing) {
302
+ if (existing(relation.to)) {
303
+ relations.push(relation)
304
+ continue
305
+ }
306
+ if (relation.to.startsWith('external:') || relation.to.startsWith('unresolved:')) {
307
+ missingEndpoints.push(relation.to)
308
+ relations.push(relation)
309
+ continue
310
+ }
311
+ dropped.push(relation)
312
+ }
313
+ return { relations, missingEndpoints, dropped }
314
+ }