@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,5 @@
1
+ import { unobservedOwnershipPaths } from '../discovery/areas.js'
2
+ import { importCycles } from '../graph/build.js'
1
3
  import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
2
4
  import {
3
5
  ReconciliationReportV1Schema,
@@ -12,22 +14,37 @@ type EntityResolver = (reference: string) => string
12
14
 
13
15
  export type ReconciliationOptions = {
14
16
  /** Compare declarations at a semantic level while retaining raw discovery evidence. */
15
- readonly scope?: 'file' | 'module' | 'package'
17
+ readonly scope?: 'file' | 'module' | 'area' | 'package'
16
18
  /** Omit for backwards-compatible all-relation checking; [] disables missing-declaration findings. */
17
19
  readonly requiredRelationKinds?: readonly string[]
20
+ /** Limit missing-declaration findings to relations between internal project entities. */
21
+ readonly requiredRelationTargets?: 'all' | 'internal'
18
22
  /** Emit one bounded finding for each observed Markdown document without declarations. */
19
23
  readonly includeOrphanedDocuments?: boolean
24
+ /**
25
+ * Configured ownership records, so a path that matches nothing observed can be reported.
26
+ * Omit to skip the check.
27
+ */
28
+ readonly ownership?: readonly { readonly id: string; readonly path: string }[]
29
+ /** Where the ownership records were configured, for the evidence trail. */
30
+ readonly ownershipSource?: string
31
+ /** Report import cycles. On by default; a cycle is structure, and structure is what this compares. */
32
+ readonly reportImportCycles?: boolean
20
33
  }
21
34
 
22
35
  const ignoredDocumentationRelations = new Set(['covers'])
23
36
 
37
+ const isInternalEntity = (id: string): boolean => !id.startsWith('external:') && !id.startsWith('unresolved:')
38
+
24
39
  const metadataDetection = (relation: KnowledgeRelation): string | undefined => {
25
40
  const detection = relation.metadata?.detection
26
41
  return typeof detection === 'string' ? detection : undefined
27
42
  }
28
43
 
44
+ const normalizeDetection = (value: string): string => value === 'dynamic-literal' ? 'dynamic' : value
45
+
29
46
  const relationDetection = (relation: KnowledgeRelation): string =>
30
- relation.discriminator ?? metadataDetection(relation) ?? 'static'
47
+ normalizeDetection(relation.discriminator ?? metadataDetection(relation) ?? 'static')
31
48
 
32
49
  const entityResolver = (snapshots: readonly DiscoverySnapshotV1[]): EntityResolver => {
33
50
  const references = new Map<string, string>()
@@ -63,6 +80,24 @@ const diagnosticCounts = (values: readonly string[]): Record<string, number> =>
63
80
  return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
64
81
  }
65
82
 
83
+ const documentClass = (entity: KnowledgeEntity): string => {
84
+ const classification = entity.metadata?.classification
85
+ return typeof classification === 'string' && classification.length > 0 ? classification : 'unclassified'
86
+ }
87
+
88
+ const countDocumentClasses = (
89
+ documents: readonly KnowledgeEntity[],
90
+ selectedIds?: ReadonlySet<string>,
91
+ ): Record<string, number> => {
92
+ const counts = new Map<string, number>()
93
+ for (const document of documents) {
94
+ if (selectedIds && !selectedIds.has(document.id)) continue
95
+ const classification = documentClass(document)
96
+ counts.set(classification, (counts.get(classification) ?? 0) + 1)
97
+ }
98
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a.localeCompare(b)))
99
+ }
100
+
66
101
  const diagnosticId = (code: string, value: unknown): string =>
67
102
  `reconciliation:${code}:${sha256NormalizedV1(value).slice(0, 32)}`
68
103
 
@@ -90,13 +125,34 @@ const semanticEntityId = (
90
125
  id: string,
91
126
  scope: NonNullable<ReconciliationOptions['scope']>,
92
127
  entities: ReadonlyMap<string, KnowledgeEntity>,
93
- packageByModule: ReadonlyMap<string, string>,
128
+ unitByModule: ReadonlyMap<string, string>,
94
129
  ): string => {
95
130
  if (scope === 'file' || id.startsWith('external:') || id.startsWith('unresolved:')) return id
96
131
  const entity = entities.get(id)
97
132
  if (!entity || entity.kind !== 'module' || !entity.path) return id
98
133
  if (scope === 'module') return id
99
- return packageByModule.get(id) ?? id
134
+ return unitByModule.get(id) ?? id
135
+ }
136
+
137
+ /**
138
+ * Which area each module belongs to, read from the containment the analyzer recorded.
139
+ *
140
+ * Areas exist so a single-package repository has something to compare. At package scope every
141
+ * internal relation in such a repository aggregates into one self-loop, which the comparison
142
+ * skips — a thousand observed relations and nothing to say. At area scope the same relations
143
+ * become area-to-area edges that a declaration can confirm or fail to.
144
+ */
145
+ const areaLookup = (
146
+ scope: NonNullable<ReconciliationOptions['scope']>,
147
+ relations: readonly KnowledgeRelation[],
148
+ ): Map<string, string> => {
149
+ if (scope !== 'area') return new Map()
150
+ const result = new Map<string, string>()
151
+ for (const relation of relations) {
152
+ if (relation.kind !== 'contains' || !relation.from.startsWith('area:') || !relation.to.startsWith('module:')) continue
153
+ if (!result.has(relation.to)) result.set(relation.to, relation.from)
154
+ }
155
+ return result
100
156
  }
101
157
 
102
158
  const packageLookup = (scope: NonNullable<ReconciliationOptions['scope']>, entities: ReadonlyMap<string, KnowledgeEntity>): Map<string, string> => {
@@ -119,13 +175,13 @@ const aggregatedRelations = (
119
175
  relations: readonly KnowledgeRelation[],
120
176
  scope: NonNullable<ReconciliationOptions['scope']>,
121
177
  entities: ReadonlyMap<string, KnowledgeEntity>,
122
- packageByModule: ReadonlyMap<string, string>,
178
+ unitByModule: ReadonlyMap<string, string>,
123
179
  ): KnowledgeRelation[] => {
124
180
  if (scope === 'file') return [...relations]
125
181
  const groups = new Map<string, KnowledgeRelation[]>()
126
182
  for (const relation of relations) {
127
- const from = semanticEntityId(relation.from, scope, entities, packageByModule)
128
- const to = semanticEntityId(relation.to, scope, entities, packageByModule)
183
+ const from = semanticEntityId(relation.from, scope, entities, unitByModule)
184
+ const to = semanticEntityId(relation.to, scope, entities, unitByModule)
129
185
  const detection = relationDetection(relation)
130
186
  const key = `${from}\u0000${to}\u0000${relation.kind}\u0000${detection}`
131
187
  const group = groups.get(key) ?? []
@@ -137,7 +193,6 @@ const aggregatedRelations = (
137
193
  const parts = key.split('\u0000')
138
194
  const from = parts[0] ?? first.from
139
195
  const to = parts[1] ?? first.to
140
- const kind = parts[2] ?? first.kind
141
196
  const detection = parts[3] ?? relationDetection(first)
142
197
  const mergedEvidence = mergeEvidence(...group)
143
198
  return {
@@ -193,10 +248,10 @@ export const reconcileKnowledge = (
193
248
  const resolveEntity = entityResolver([observed, declared])
194
249
  const entities = entityById([observed, declared])
195
250
  const scope = options.scope ?? 'file'
196
- const packageByModule = packageLookup(scope, entities)
251
+ const unitByModule = scope === 'area' ? areaLookup(scope, observed.relations) : packageLookup(scope, entities)
197
252
  const allDeclaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared')
198
- const observedRelations = aggregatedRelations(observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, packageByModule)
199
- const declaredRelations = aggregatedRelations(declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, packageByModule)
253
+ const observedRelations = aggregatedRelations(observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, unitByModule)
254
+ const declaredRelations = aggregatedRelations(declared.relations.filter((relation) => relation.provenance === 'declared' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, unitByModule)
200
255
  const requiredRelationKinds = options.requiredRelationKinds === undefined ? undefined : new Set(options.requiredRelationKinds)
201
256
  const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
202
257
 
@@ -240,9 +295,23 @@ export const reconcileKnowledge = (
240
295
  declaredByBase.set(base, group)
241
296
  }
242
297
 
298
+ const observedDetectionsByBase = new Map<string, Set<string>>()
299
+ const observedByBase = new Map<string, KnowledgeRelation[]>()
300
+ for (const relation of observedRelations) {
301
+ const base = relationBase(relation, resolveEntity)
302
+ const detections = observedDetectionsByBase.get(base) ?? new Set<string>()
303
+ detections.add(relationDetection(relation))
304
+ observedDetectionsByBase.set(base, detections)
305
+ const group = observedByBase.get(base) ?? []
306
+ group.push(relation)
307
+ observedByBase.set(base, group)
308
+ }
309
+
243
310
  for (const group of declaredByBase.values()) {
244
311
  const detections = new Set(group.map(relationDetection))
245
312
  if (detections.size < 2) continue
313
+ const observedDetections = observedDetectionsByBase.get(relationBase(group[0] as KnowledgeRelation, resolveEntity))
314
+ if (observedDetections && [...detections].every((detection) => observedDetections.has(detection))) continue
246
315
  diagnostics.push(reportDiagnostic(
247
316
  'CONFLICTING_DECLARATIONS',
248
317
  'conflict',
@@ -270,7 +339,11 @@ export const reconcileKnowledge = (
270
339
  undefined,
271
340
  [relation.id, match.id],
272
341
  ))
273
- } else if (coverageAvailable(observed, relation) && (requiredRelationKinds === undefined || requiredRelationKinds.has(relation.kind))) {
342
+ } else if (
343
+ coverageAvailable(observed, relation) &&
344
+ (requiredRelationKinds === undefined || requiredRelationKinds.has(relation.kind)) &&
345
+ (options.requiredRelationTargets !== 'internal' || (relation.from !== relation.to && isInternalEntity(relation.from) && isInternalEntity(relation.to)))
346
+ ) {
274
347
  diagnostics.push(reportDiagnostic(
275
348
  'RELATION_UNDOCUMENTED',
276
349
  'undocumented',
@@ -286,7 +359,7 @@ export const reconcileKnowledge = (
286
359
  }
287
360
 
288
361
  for (const relation of declaredRelations) {
289
- const candidates = observedRelations.filter((candidate) => relationBase(candidate, resolveEntity) === relationBase(relation, resolveEntity))
362
+ const candidates = observedByBase.get(relationBase(relation, resolveEntity)) ?? []
290
363
  const detection = relationDetection(relation)
291
364
  if (candidates.some((candidate) => relationMatches(candidate, relation, resolveEntity))) continue
292
365
  if (isUnresolved(resolveEntity(relation.from), entities) || isUnresolved(resolveEntity(relation.to), entities)) continue
@@ -317,6 +390,61 @@ export const reconcileKnowledge = (
317
390
  }
318
391
  }
319
392
 
393
+ /*
394
+ * Import cycles.
395
+ *
396
+ * A cycle is not a documentation gap, so it is not an undocumented relation; it is a structural
397
+ * fact worth a reader's attention, with every edge that forms it as evidence — a diagnostic
398
+ * whose loop a reader cannot trace is a claim, not a finding.
399
+ */
400
+ if (options.reportImportCycles !== false) {
401
+ for (const cycle of importCycles(observed)) {
402
+ diagnostics.push(reportDiagnostic(
403
+ 'IMPORT_CYCLE',
404
+ 'unresolved',
405
+ 'warn',
406
+ `Import cycle across ${cycle.nodes.length} modules: ${cycle.nodes.join(' → ')} → ${cycle.nodes[0] ?? ''}.`,
407
+ cycle.evidence,
408
+ { cycle: cycle.nodes },
409
+ cycle.nodes,
410
+ cycle.relationIds,
411
+ 'Break the cycle by moving the shared code into a module both sides import, or invert one dependency.',
412
+ ))
413
+ }
414
+ }
415
+
416
+ /*
417
+ * An ownership path that matches nothing observed.
418
+ *
419
+ * A renamed or mistyped directory is invisible otherwise: the handoff still resolves and still
420
+ * looks correct, it just points an agent at a directory that no longer holds what it claims.
421
+ * The evidence is the configuration, because that is where the claim was made.
422
+ */
423
+ if (options.ownership?.length) {
424
+ const observedPaths = observed.entities
425
+ .map((entity) => entity.path)
426
+ .filter((path): path is string => typeof path === 'string')
427
+ for (const record of unobservedOwnershipPaths(options.ownership, observedPaths)) {
428
+ diagnostics.push(reportDiagnostic(
429
+ 'OWNERSHIP_PATH_UNOBSERVED',
430
+ 'stale-or-unverified',
431
+ 'warn',
432
+ `Ownership "${record.id}" declares path ${record.path}, which no observed module or document lives under.`,
433
+ [
434
+ {
435
+ source: 'configuration',
436
+ path: options.ownershipSource ?? 'doc-bridge.config.json',
437
+ context: `routing.options.ownership.${record.id}.path = ${record.path}`,
438
+ },
439
+ ],
440
+ { ownership: record.id, path: record.path },
441
+ undefined,
442
+ undefined,
443
+ 'Point the ownership record at a directory that exists, or remove the record.',
444
+ ))
445
+ }
446
+ }
447
+
320
448
  const sortedDiagnostics = [...diagnostics].sort((a, b) => a.id.localeCompare(b.id))
321
449
  const packageEntities = observed.entities.filter((entity) => entity.kind === 'package')
322
450
  const packageStatus = { fresh: 0, stale: 0, missing: 0, unverified: 0 }
@@ -335,9 +463,13 @@ export const reconcileKnowledge = (
335
463
  else if (statuses.has('undocumented') || statuses.has('unresolved') || statuses.has('not-analyzed')) packageStatus.unverified += 1
336
464
  else packageStatus.fresh += 1
337
465
  }
466
+ const documents = observed.entities.filter((entity) => entity.kind === 'document')
467
+ const documentedDocumentIds = new Set(allDeclaredRelations.filter((relation) => relation.from.startsWith('document:')).map((relation) => relation.from))
338
468
  const documentation = {
339
- documentCount: observed.entities.filter((entity) => entity.kind === 'document').length,
340
- documentedDocumentCount: new Set(allDeclaredRelations.filter((relation) => relation.from.startsWith('document:')).map((relation) => relation.from)).size,
469
+ documentCount: documents.length,
470
+ documentedDocumentCount: documentedDocumentIds.size,
471
+ documentClassificationCounts: countDocumentClasses(documents),
472
+ documentedDocumentClassificationCounts: countDocumentClasses(documents, documentedDocumentIds),
341
473
  packageCount: packageEntities.length,
342
474
  packageStatus,
343
475
  }
@@ -360,6 +492,7 @@ export const reconcileKnowledge = (
360
492
  diagnosticCount: sortedDiagnostics.length,
361
493
  ...(options.scope === undefined ? {} : { scope: options.scope }),
362
494
  ...(options.requiredRelationKinds === undefined ? {} : { requiredRelationKinds: [...new Set(options.requiredRelationKinds)].sort() }),
495
+ ...(options.requiredRelationTargets === undefined ? {} : { requiredRelationTargets: options.requiredRelationTargets }),
363
496
  diagnosticsByCode: diagnosticCounts(sortedDiagnostics.map((diagnostic) => diagnostic.code)),
364
497
  diagnosticsByStatus: diagnosticCounts(sortedDiagnostics.map((diagnostic) => diagnostic.status)),
365
498
  documentation,
@@ -0,0 +1,356 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { FILE_BACKED_KINDS } from '../discovery/incremental.js'
3
+ import { handoffForEntity } from '../query/handoff.js'
4
+ import type { AgentHandoffV1 } from '../schemas/agent-handoff.js'
5
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
6
+ import type { DiscoverySnapshotV1, KnowledgeEntity, ReconciliationReportV1 } from '../schemas/knowledge.js'
7
+ import type { RetrievalEntry } from '../schemas/retrieval-index.js'
8
+
9
+ /**
10
+ * The view models the templates render.
11
+ *
12
+ * Every value a template can print is computed here, from the canonical artifacts and nothing
13
+ * else: the index and its projection, a reconciliation report, two snapshots, an overlay. Lists
14
+ * are sorted before they reach a template and nothing carries a timestamp, so the same artifacts
15
+ * render to the same bytes. The templates decide how a value looks; this decides what the values
16
+ * are, which is the boundary that keeps rendering deterministic and keeps a template from ever
17
+ * needing to call anything.
18
+ */
19
+
20
+ export type RenderedPage = {
21
+ /** Where the page belongs, relative to the output directory. */
22
+ readonly path: string
23
+ readonly content: string
24
+ }
25
+
26
+ const MAX_SYMBOLS = 12
27
+ const SHORT_HASH = 12
28
+
29
+ const byPath = <T extends { readonly path: string }>(a: T, b: T): number => a.path.localeCompare(b.path) || 0
30
+
31
+ /**
32
+ * A file name for a unit: `src/query` becomes `src-query.md`, and nothing escapes the output
33
+ * directory. The ends are trimmed with a loop, because an alternation anchored at the end
34
+ * backtracks quadratically on a value that is mostly separators.
35
+ */
36
+ export const pageFileName = (value: string): string => {
37
+ const slug = value.replace(/[^A-Za-z0-9._-]+/g, '-')
38
+ let start = 0
39
+ while (start < slug.length && (slug[start] === '-' || slug[start] === '.')) start += 1
40
+ let end = slug.length
41
+ while (end > start && slug[end - 1] === '-') end -= 1
42
+ return `${slug.slice(start, end) || 'root'}.md`
43
+ }
44
+
45
+ const shortHash = (hash: string | undefined): string => (hash ? hash.slice(0, SHORT_HASH) : '(none)')
46
+
47
+ const projectionOf = (index: DocBridgeIndexV1) => {
48
+ if (!index.projection) throw new Error('The index carries no retrieval projection; rebuild it with `ak-docs index` before rendering.')
49
+ return index.projection
50
+ }
51
+
52
+ const checksSourceOf = (handoff: AgentHandoffV1 | undefined): string => {
53
+ const value = handoff?.metadata?.checksSource
54
+ return typeof value === 'string' ? value : ''
55
+ }
56
+
57
+ const safeHandoff = (index: DocBridgeIndexV1, id: string, config: DocBridgeConfigV1, root: string | undefined): AgentHandoffV1 | undefined => {
58
+ try {
59
+ return handoffForEntity(index, id, config, root ? { root } : {})
60
+ } catch {
61
+ // A unit the handoff cannot resolve still gets a page; it just has no derived fields.
62
+ return undefined
63
+ }
64
+ }
65
+
66
+ type RelatedView = { readonly path: string; readonly direction: string; readonly strength: number; readonly evidence: string }
67
+
68
+ const relatedView = (handoff: AgentHandoffV1 | undefined): RelatedView[] =>
69
+ [...(handoff?.related ?? [])]
70
+ .map((item) => ({ path: item.path, direction: item.direction, strength: item.strength, evidence: item.evidence.join(', ') }))
71
+ .sort((a, b) => a.path.localeCompare(b.path))
72
+
73
+ // --- area pages
74
+
75
+ export type AreaPageView = {
76
+ readonly id: string
77
+ readonly path: string
78
+ readonly purpose: string
79
+ readonly ownership: string
80
+ readonly modules: readonly { readonly path: string; readonly symbols: string }[]
81
+ readonly documents: readonly { readonly title: string; readonly path: string; readonly relation: string }[]
82
+ readonly related: readonly RelatedView[]
83
+ readonly checks: readonly string[]
84
+ readonly checksSource: string
85
+ readonly findings: readonly { readonly code: string; readonly severity: string; readonly status: string; readonly message: string; readonly evidence: string }[]
86
+ readonly findingsEmpty: string
87
+ }
88
+
89
+ export type AreaPagesOptions = {
90
+ readonly root?: string
91
+ /** The reconciliation report whose diagnostics become "open findings". Without one the page says so. */
92
+ readonly reconciliation?: ReconciliationReportV1
93
+ }
94
+
95
+ const underPath = (path: string, area: string): boolean => path === area || path.startsWith(`${area}/`)
96
+
97
+ /** Where a finding points: its first evidence location, and the relation it is about when it names one. */
98
+ const findingEvidence = (diagnostic: ReconciliationReportV1['diagnostics'][number]): string => {
99
+ const first = [...diagnostic.evidence].sort((a, b) => `${a.path}:${a.lineStart ?? 0}`.localeCompare(`${b.path}:${b.lineStart ?? 0}`))[0]
100
+ const location = !first ? diagnostic.id : first.lineStart ? `${first.path}:${first.lineStart}` : first.path
101
+ const relation = [...(diagnostic.relationIds ?? [])].sort()[0]
102
+ return relation ? `${location} (${relation})` : location
103
+ }
104
+
105
+ export const areaPagesView = (index: DocBridgeIndexV1, config: DocBridgeConfigV1, options: AreaPagesOptions = {}): AreaPageView[] => {
106
+ const projection = projectionOf(index)
107
+ const entries = new Map(projection.entries.map((entry) => [entry.id, entry]))
108
+ const documentTitle = (id: string): { readonly title: string; readonly path: string } | undefined => {
109
+ const entry = entries.get(id)
110
+ return entry && entry.kind === 'document' ? { title: entry.title, path: entry.path } : undefined
111
+ }
112
+ const areas = projection.entries.filter((entry) => entry.kind === 'area').sort((a, b) => a.id.localeCompare(b.id))
113
+
114
+ return areas.map((area) => {
115
+ const modules = projection.entries
116
+ .filter((entry) => entry.kind === 'module' && entry.graph.areaId === area.id)
117
+ .sort(byPath)
118
+ const moduleIds = new Set(modules.map((entry) => entry.id))
119
+ const ownership = area.ownershipId ? index.lookup?.ownership?.[area.ownershipId] : undefined
120
+ const handoff = safeHandoff(index, area.id, config, options.root)
121
+
122
+ /*
123
+ * A document belongs on the page when it covers or mentions the area, or a module inside it —
124
+ * the module-level edge is how most documentation reaches an area. The first relation that
125
+ * brought a document in is the one reported, coverage before mention.
126
+ */
127
+ const documents = new Map<string, { readonly title: string; readonly path: string; readonly relation: string }>()
128
+ const add = (documentId: string, relation: string): void => {
129
+ const document = documentTitle(documentId)
130
+ if (document && !documents.has(documentId)) documents.set(documentId, { ...document, relation })
131
+ }
132
+ for (const documentId of [...area.graph.coveredBy].sort()) add(documentId, 'covers this area')
133
+ for (const module of modules) for (const documentId of [...module.graph.coveredBy].sort()) add(documentId, `covers \`${module.path}\``)
134
+ for (const documentId of [...area.graph.mentionedBy].sort()) add(documentId, 'mentions this area')
135
+ for (const module of modules) for (const documentId of [...module.graph.mentionedBy].sort()) add(documentId, `mentions \`${module.path}\``)
136
+
137
+ const findings = (options.reconciliation?.diagnostics ?? [])
138
+ .filter((diagnostic) => diagnostic.code !== 'RELATION_CONFIRMED')
139
+ .filter(
140
+ (diagnostic) =>
141
+ diagnostic.entityIds?.some((id) => id === area.id || moduleIds.has(id)) ||
142
+ diagnostic.evidence.some((item) => underPath(item.path, area.path)),
143
+ )
144
+ .sort((a, b) => a.code.localeCompare(b.code) || a.id.localeCompare(b.id))
145
+ .map((diagnostic) => ({ code: diagnostic.code, severity: diagnostic.severity, status: diagnostic.status, message: diagnostic.message, evidence: findingEvidence(diagnostic) }))
146
+
147
+ const ownershipParts = ownership
148
+ ? [
149
+ `Ownership record \`${ownership.id}\``,
150
+ ...(ownership.agentDoc ? [`agent document \`${ownership.agentDoc}\``] : []),
151
+ ...(ownership.humanDoc ? [`human guide ${ownership.humanDoc}`] : []),
152
+ ]
153
+ : []
154
+
155
+ return {
156
+ id: area.id,
157
+ path: area.path,
158
+ purpose: ownership?.purpose ?? area.summary ?? `Code area grouping ${modules.length} module(s) under \`${area.path}\`.`,
159
+ ownership: ownershipParts.join('; ') + (ownershipParts.length ? '.' : ''),
160
+ modules: modules.map((entry) => {
161
+ const symbols = entry.symbols ?? []
162
+ return { path: entry.path, symbols: symbols.slice(0, MAX_SYMBOLS).map((symbol) => `\`${symbol}\``).join(', ') + (symbols.length > MAX_SYMBOLS ? ` and ${symbols.length - MAX_SYMBOLS} more` : '') }
163
+ }),
164
+ documents: [...documents.values()].sort(byPath),
165
+ related: relatedView(handoff),
166
+ checks: handoff?.checks ?? [],
167
+ checksSource: checksSourceOf(handoff),
168
+ findings,
169
+ findingsEmpty: options.reconciliation ? 'No open finding touches this area.' : 'No reconciliation report was available; run `ak-docs reconcile` to list open findings.',
170
+ }
171
+ })
172
+ }
173
+
174
+ // --- ownership sidecars
175
+
176
+ export type OwnershipPageView = {
177
+ readonly id: string
178
+ readonly kind: string
179
+ readonly path: string
180
+ readonly humanDoc: string
181
+ readonly purpose: string
182
+ readonly startHere: string
183
+ readonly readBeforeEditing: readonly string[]
184
+ readonly editRoots: readonly string[]
185
+ readonly checks: readonly string[]
186
+ readonly checksSource: string
187
+ readonly related: readonly RelatedView[]
188
+ }
189
+
190
+ export const ownershipPagesView = (index: DocBridgeIndexV1, config: DocBridgeConfigV1, options: { readonly root?: string } = {}): OwnershipPageView[] =>
191
+ Object.values(index.lookup?.ownership ?? {})
192
+ .sort((a, b) => a.id.localeCompare(b.id))
193
+ .map((record) => {
194
+ const handoff = safeHandoff(index, record.id, config, options.root)
195
+ const startHere = handoff?.startHere ?? record.agentDoc ?? config.corpus.agent.index ?? ''
196
+ return {
197
+ id: record.id,
198
+ kind: handoff?.target.type ?? 'package',
199
+ path: record.path,
200
+ humanDoc: record.humanDoc ?? '',
201
+ purpose: record.purpose ?? `Owns \`${record.path}\`.`,
202
+ startHere: startHere || '(no start page)',
203
+ readBeforeEditing: (handoff?.readBeforeEditing ?? []).filter((path) => path !== startHere),
204
+ editRoots: handoff?.editRoots ?? [record.path],
205
+ checks: handoff?.checks ?? record.checks,
206
+ checksSource: checksSourceOf(handoff) || record.checksSource || '',
207
+ related: relatedView(handoff),
208
+ }
209
+ })
210
+
211
+ // --- change digest
212
+
213
+ export type SnapshotForDigest = Pick<DiscoverySnapshotV1, 'entities' | 'relations' | 'sourceRevision'>
214
+
215
+ type FileBacked = { readonly id: string; readonly kind: string; readonly path: string; readonly hash: string }
216
+
217
+ export type ChangeDigestView = {
218
+ readonly summary: string
219
+ readonly changed: readonly { readonly id: string; readonly kind: string; readonly path: string; readonly previousHash: string; readonly currentHash: string }[]
220
+ readonly added: readonly { readonly id: string; readonly kind: string; readonly path: string; readonly currentHash: string }[]
221
+ readonly removed: readonly { readonly id: string; readonly kind: string; readonly path: string; readonly previousHash: string }[]
222
+ readonly documentsToReview: readonly { readonly path: string; readonly because: string }[]
223
+ }
224
+
225
+ /*
226
+ * The digest is defined over file-backed entities, because those are the ones that carry a hash
227
+ * of their bytes (`docs/spec/incremental-scan-v1.md`). An entity without one — an external
228
+ * package, an area — has nothing to move.
229
+ */
230
+ const fileBacked = (snapshot: SnapshotForDigest): Map<string, FileBacked> => {
231
+ const result = new Map<string, FileBacked>()
232
+ for (const entity of snapshot.entities) {
233
+ if (!(FILE_BACKED_KINDS as readonly string[]).includes(entity.kind) || !entity.path) continue
234
+ const hash = entity.evidence[0]?.contentHash
235
+ if (hash) result.set(entity.id, { id: entity.id, kind: entity.kind, path: entity.path, hash })
236
+ }
237
+ return result
238
+ }
239
+
240
+ const DOCUMENT_RELATIONS = new Set(['covers', 'mentions', 'mentions-symbol', 'links-to'])
241
+
242
+ export const changeDigestView = (previous: SnapshotForDigest, current: SnapshotForDigest): ChangeDigestView => {
243
+ const before = fileBacked(previous)
244
+ const after = fileBacked(current)
245
+ const sortIds = (ids: Iterable<string>, of: Map<string, FileBacked>): string[] =>
246
+ [...ids].sort((a, b) => (of.get(a) as FileBacked).path.localeCompare((of.get(b) as FileBacked).path) || a.localeCompare(b))
247
+
248
+ const changed = sortIds([...after.keys()].filter((id) => before.has(id) && (before.get(id) as FileBacked).hash !== (after.get(id) as FileBacked).hash), after)
249
+ .map((id) => ({ id, kind: (after.get(id) as FileBacked).kind, path: (after.get(id) as FileBacked).path, previousHash: shortHash((before.get(id) as FileBacked).hash), currentHash: shortHash((after.get(id) as FileBacked).hash) }))
250
+ const added = sortIds([...after.keys()].filter((id) => !before.has(id)), after)
251
+ .map((id) => ({ id, kind: (after.get(id) as FileBacked).kind, path: (after.get(id) as FileBacked).path, currentHash: shortHash((after.get(id) as FileBacked).hash) }))
252
+ const removed = sortIds([...before.keys()].filter((id) => !after.has(id)), before)
253
+ .map((id) => ({ id, kind: (before.get(id) as FileBacked).kind, path: (before.get(id) as FileBacked).path, previousHash: shortHash((before.get(id) as FileBacked).hash) }))
254
+
255
+ /*
256
+ * "Which documentation should this change have touched": every document that covers, mentions
257
+ * or links to something that moved — and did not move itself. A document that changed alongside
258
+ * its subject is in `changed`, not here.
259
+ */
260
+ const moved = new Set([...changed, ...added].map((entity) => entity.id))
261
+ const reasons = new Map<string, Set<string>>()
262
+ for (const relation of current.relations) {
263
+ if (!DOCUMENT_RELATIONS.has(relation.kind) || !moved.has(relation.to) || moved.has(relation.from)) continue
264
+ const document = after.get(relation.from)
265
+ if (!document || document.kind !== 'document') continue
266
+ const target = after.get(relation.to)
267
+ const because = reasons.get(document.path) ?? new Set<string>()
268
+ because.add(`${relation.kind} \`${target?.path ?? relation.to}\``)
269
+ reasons.set(document.path, because)
270
+ }
271
+ const documentsToReview = [...reasons.entries()]
272
+ .sort(([a], [b]) => a.localeCompare(b))
273
+ .map(([path, because]) => ({ path, because: [...because].sort().join('; ') }))
274
+
275
+ return {
276
+ summary: `${changed.length} changed, ${added.length} added, ${removed.length} removed of ${after.size} file-backed entities (previous revision ${previous.sourceRevision.slice(0, SHORT_HASH)}, current ${current.sourceRevision.slice(0, SHORT_HASH)})`,
277
+ changed,
278
+ added,
279
+ removed,
280
+ documentsToReview,
281
+ }
282
+ }
283
+
284
+ // --- overlay review
285
+
286
+ /**
287
+ * The shape the review page reads from an enrichment overlay. Deliberately loose: the overlay
288
+ * artifact is defined by the enrichment workstream, and the page needs only what a human needs
289
+ * to judge a proposal — what it proposes about which entity, why, how sure the agent was, and
290
+ * where in the repository the evidence is.
291
+ */
292
+ export type OverlayReviewInput = {
293
+ readonly pending?: readonly {
294
+ readonly proposalId: string
295
+ readonly kind: string
296
+ readonly entity: string
297
+ readonly reason?: string
298
+ readonly confidence?: number | string
299
+ readonly evidence?: readonly { readonly path: string; readonly lineStart?: number; readonly lineEnd?: number }[]
300
+ }[]
301
+ }
302
+
303
+ export type OverlayReviewView = {
304
+ readonly present: boolean
305
+ readonly summary: string
306
+ readonly pending: readonly {
307
+ readonly proposalId: string
308
+ readonly kind: string
309
+ readonly entity: string
310
+ readonly reason: string
311
+ readonly confidence: string
312
+ readonly evidence: readonly { readonly link: string }[]
313
+ }[]
314
+ }
315
+
316
+ const evidenceLink = (item: { readonly path: string; readonly lineStart?: number; readonly lineEnd?: number }): string => {
317
+ if (!item.lineStart) return `[${item.path}](${item.path})`
318
+ const range = item.lineEnd && item.lineEnd !== item.lineStart ? `${item.lineStart}-${item.lineEnd}` : `${item.lineStart}`
319
+ const anchor = item.lineEnd && item.lineEnd !== item.lineStart ? `L${item.lineStart}-L${item.lineEnd}` : `L${item.lineStart}`
320
+ return `[${item.path}:${range}](${item.path}#${anchor})`
321
+ }
322
+
323
+ export const overlayReviewView = (overlay: OverlayReviewInput | undefined, source?: string): OverlayReviewView => {
324
+ if (!overlay) return { present: false, summary: '', pending: [] }
325
+ const pending = [...(overlay.pending ?? [])]
326
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.entity.localeCompare(b.entity) || a.proposalId.localeCompare(b.proposalId))
327
+ .map((proposal) => ({
328
+ proposalId: proposal.proposalId,
329
+ kind: proposal.kind,
330
+ entity: proposal.entity,
331
+ reason: proposal.reason ?? '(no reason given)',
332
+ confidence: proposal.confidence === undefined ? 'unknown' : String(proposal.confidence),
333
+ evidence: [...(proposal.evidence ?? [])]
334
+ .sort((a, b) => a.path.localeCompare(b.path) || (a.lineStart ?? 0) - (b.lineStart ?? 0))
335
+ .map((item) => ({ link: evidenceLink(item) })),
336
+ }))
337
+ return {
338
+ present: true,
339
+ summary: `${pending.length} proposal(s) pending review${source ? ` in \`${source}\`` : ''}. Accepting one is a human decision; nothing here is applied automatically.`,
340
+ pending,
341
+ }
342
+ }
343
+
344
+ /** Only for tests and callers that hold a snapshot: the document entities' generated regions, typed. */
345
+ export const generatedRegionsOf = (entity: Pick<KnowledgeEntity, 'metadata'> | undefined): { readonly lineStart: number; readonly lineEnd: number; readonly hash?: string }[] => {
346
+ const raw = entity?.metadata?.generatedRegions
347
+ if (!Array.isArray(raw)) return []
348
+ return raw.flatMap((item) => {
349
+ if (!item || typeof item !== 'object') return []
350
+ const region = item as { lineStart?: unknown; lineEnd?: unknown; hash?: unknown }
351
+ if (typeof region.lineStart !== 'number' || typeof region.lineEnd !== 'number') return []
352
+ return [{ lineStart: region.lineStart, lineEnd: region.lineEnd, ...(typeof region.hash === 'string' ? { hash: region.hash } : {}) }]
353
+ })
354
+ }
355
+
356
+ export type { RetrievalEntry }