@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,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,13 +14,22 @@ 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[]
18
20
  /** Limit missing-declaration findings to relations between internal project entities. */
19
21
  readonly requiredRelationTargets?: 'all' | 'internal'
20
22
  /** Emit one bounded finding for each observed Markdown document without declarations. */
21
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
22
33
  }
23
34
 
24
35
  const ignoredDocumentationRelations = new Set(['covers'])
@@ -114,13 +125,34 @@ const semanticEntityId = (
114
125
  id: string,
115
126
  scope: NonNullable<ReconciliationOptions['scope']>,
116
127
  entities: ReadonlyMap<string, KnowledgeEntity>,
117
- packageByModule: ReadonlyMap<string, string>,
128
+ unitByModule: ReadonlyMap<string, string>,
118
129
  ): string => {
119
130
  if (scope === 'file' || id.startsWith('external:') || id.startsWith('unresolved:')) return id
120
131
  const entity = entities.get(id)
121
132
  if (!entity || entity.kind !== 'module' || !entity.path) return id
122
133
  if (scope === 'module') return id
123
- 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
124
156
  }
125
157
 
126
158
  const packageLookup = (scope: NonNullable<ReconciliationOptions['scope']>, entities: ReadonlyMap<string, KnowledgeEntity>): Map<string, string> => {
@@ -143,13 +175,13 @@ const aggregatedRelations = (
143
175
  relations: readonly KnowledgeRelation[],
144
176
  scope: NonNullable<ReconciliationOptions['scope']>,
145
177
  entities: ReadonlyMap<string, KnowledgeEntity>,
146
- packageByModule: ReadonlyMap<string, string>,
178
+ unitByModule: ReadonlyMap<string, string>,
147
179
  ): KnowledgeRelation[] => {
148
180
  if (scope === 'file') return [...relations]
149
181
  const groups = new Map<string, KnowledgeRelation[]>()
150
182
  for (const relation of relations) {
151
- const from = semanticEntityId(relation.from, scope, entities, packageByModule)
152
- 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)
153
185
  const detection = relationDetection(relation)
154
186
  const key = `${from}\u0000${to}\u0000${relation.kind}\u0000${detection}`
155
187
  const group = groups.get(key) ?? []
@@ -216,10 +248,10 @@ export const reconcileKnowledge = (
216
248
  const resolveEntity = entityResolver([observed, declared])
217
249
  const entities = entityById([observed, declared])
218
250
  const scope = options.scope ?? 'file'
219
- const packageByModule = packageLookup(scope, entities)
251
+ const unitByModule = scope === 'area' ? areaLookup(scope, observed.relations) : packageLookup(scope, entities)
220
252
  const allDeclaredRelations = declared.relations.filter((relation) => relation.provenance === 'declared')
221
- const observedRelations = aggregatedRelations(observed.relations.filter((relation) => relation.provenance === 'observed' && !ignoredDocumentationRelations.has(relation.kind)), scope, entities, packageByModule)
222
- 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)
223
255
  const requiredRelationKinds = options.requiredRelationKinds === undefined ? undefined : new Set(options.requiredRelationKinds)
224
256
  const diagnostics: ReconciliationReportV1['diagnostics'][number][] = []
225
257
 
@@ -264,11 +296,15 @@ export const reconcileKnowledge = (
264
296
  }
265
297
 
266
298
  const observedDetectionsByBase = new Map<string, Set<string>>()
299
+ const observedByBase = new Map<string, KnowledgeRelation[]>()
267
300
  for (const relation of observedRelations) {
268
301
  const base = relationBase(relation, resolveEntity)
269
302
  const detections = observedDetectionsByBase.get(base) ?? new Set<string>()
270
303
  detections.add(relationDetection(relation))
271
304
  observedDetectionsByBase.set(base, detections)
305
+ const group = observedByBase.get(base) ?? []
306
+ group.push(relation)
307
+ observedByBase.set(base, group)
272
308
  }
273
309
 
274
310
  for (const group of declaredByBase.values()) {
@@ -323,7 +359,7 @@ export const reconcileKnowledge = (
323
359
  }
324
360
 
325
361
  for (const relation of declaredRelations) {
326
- const candidates = observedRelations.filter((candidate) => relationBase(candidate, resolveEntity) === relationBase(relation, resolveEntity))
362
+ const candidates = observedByBase.get(relationBase(relation, resolveEntity)) ?? []
327
363
  const detection = relationDetection(relation)
328
364
  if (candidates.some((candidate) => relationMatches(candidate, relation, resolveEntity))) continue
329
365
  if (isUnresolved(resolveEntity(relation.from), entities) || isUnresolved(resolveEntity(relation.to), entities)) continue
@@ -354,6 +390,61 @@ export const reconcileKnowledge = (
354
390
  }
355
391
  }
356
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
+
357
448
  const sortedDiagnostics = [...diagnostics].sort((a, b) => a.id.localeCompare(b.id))
358
449
  const packageEntities = observed.entities.filter((entity) => entity.kind === 'package')
359
450
  const packageStatus = { fresh: 0, stale: 0, missing: 0, unverified: 0 }
@@ -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 }