@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
@@ -0,0 +1,259 @@
1
+ import { z } from 'zod'
2
+
3
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
4
+
5
+ /**
6
+ * What this repository says in public, and where the truth lives.
7
+ *
8
+ * Documentation drifts silently. The four study figures in `README.md` also appear in
9
+ * `docs/study/README.md`, and both are only as current as the artifacts under `docs/study/`: an
10
+ * edit to one of the three would leave the other two stating a number the repository has moved
11
+ * past, and nothing compared them, because nothing said which repository fact each public
12
+ * sentence was standing in for. (A sample that declares itself illustrative — the doctor block in
13
+ * `README.md` says so in as many words — is not a claim, and the registry does not name it.)
14
+ *
15
+ * A claim names that fact. It declares where the canonical value comes from (`evidence`), how the
16
+ * claim appears in prose (`template`), and which public surfaces are expected to carry it. The
17
+ * checker resolves the value, finds the occurrences, and reports the difference — so a stale
18
+ * sentence is a finding with a file, a line and a remediation rather than something a reader
19
+ * happens to notice.
20
+ */
21
+
22
+ export const PUBLIC_CLAIMS_SCHEMA_VERSION = 1 as const
23
+ export const PUBLIC_CLAIMS_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
24
+
25
+ const hash = z.string().regex(/^[a-f0-9]{64}$/)
26
+ const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/)
27
+ const surfacePath = z
28
+ .string()
29
+ .min(1)
30
+ .max(512)
31
+ /*
32
+ * A surface is a file in this repository, addressed the way every other artifact addresses one:
33
+ * repository-relative, forward slashes, no escape upwards. An absolute path in the registry
34
+ * would put an operator's home directory in a report that is meant to be publication-safe.
35
+ */
36
+ .regex(/^[A-Za-z0-9._][A-Za-z0-9._/-]*$/, 'A surface is a repository-relative path.')
37
+ .refine((value) => !value.includes('..'), 'A surface may not climb out of the repository.')
38
+
39
+ /** How the stated value is read out of prose. Exactly one `{value}`; everything else is literal. */
40
+ const template = z
41
+ .string()
42
+ .min(1)
43
+ .max(240)
44
+ .refine((value) => value.split('{value}').length === 2, 'A template must contain exactly one {value}.')
45
+ .refine((value) => !value.includes('\n'), 'A template matches within one line.')
46
+
47
+ export const CLAIM_VALUE_TYPES = ['number', 'percent', 'semver', 'text'] as const
48
+ export type ClaimValueType = (typeof CLAIM_VALUE_TYPES)[number]
49
+
50
+ /*
51
+ * A transform renders a repository number the way prose states it. Two of them are signed on
52
+ * purpose: prose says "18.46% fewer" and "39.75 seconds lower", carrying the direction in a word
53
+ * the checker cannot read, so the magnitude is rendered only while the measurement is still
54
+ * negative. A sign that flips stops resolving instead of matching the same digits for the opposite
55
+ * result.
56
+ */
57
+ export const CLAIM_TRANSFORMS = ['identity', 'round', 'percent-1dp', 'percent-0dp', 'negative-percent-2dp', 'negative-seconds-2dp'] as const
58
+ export type ClaimTransform = (typeof CLAIM_TRANSFORMS)[number]
59
+
60
+ /**
61
+ * Where a canonical value comes from. Every kind is deterministic and local: a field in a committed
62
+ * file, a count over the snapshot this run produced, a figure the doctor measured, or the existence
63
+ * of a CLI command. Nothing here reaches the network, and nothing asks a model.
64
+ */
65
+ const EvidenceSchema = z.discriminatedUnion('kind', [
66
+ z.object({ kind: z.literal('package-field'), field: z.string().min(1).max(128) }).strict(),
67
+ z
68
+ .object({
69
+ kind: z.literal('artifact-field'),
70
+ path: surfacePath,
71
+ /** A dotted path into the JSON artifact: `metrics.hitAt3`. */
72
+ field: z.string().min(1).max(256),
73
+ transform: z.enum(CLAIM_TRANSFORMS).optional(),
74
+ })
75
+ .strict(),
76
+ z
77
+ .object({
78
+ kind: z.literal('artifact-sum'),
79
+ path: surfacePath,
80
+ /** The array to sum over: `arms`. */
81
+ arrayField: z.string().min(1).max(256),
82
+ /** The numeric field to sum inside each element: `observationCount`. */
83
+ field: z.string().min(1).max(256),
84
+ transform: z.enum(CLAIM_TRANSFORMS).optional(),
85
+ })
86
+ .strict(),
87
+ z
88
+ .object({
89
+ kind: z.literal('snapshot-count'),
90
+ entityKind: z.enum(['document', 'module', 'area', 'package']),
91
+ })
92
+ .strict(),
93
+ z
94
+ .object({
95
+ kind: z.literal('doctor-metric'),
96
+ metric: z.enum([
97
+ 'agent-docs-indexed',
98
+ 'agent-docs-total',
99
+ 'grade',
100
+ 'score',
101
+ 'reachability-pct',
102
+ 'connectivity-pct',
103
+ 'benchmark-hit-at-3-pct',
104
+ ]),
105
+ })
106
+ .strict(),
107
+ z.object({ kind: z.literal('cli-command'), command: z.string().min(1).max(128) }).strict(),
108
+ ])
109
+
110
+ export type ClaimEvidence = z.infer<typeof EvidenceSchema>
111
+
112
+ const ClaimSchema = z
113
+ .object({
114
+ claimId: identifier,
115
+ /** What the claim asserts, for a reader of the report. Never a path or a URL. */
116
+ statement: z.string().min(1).max(512),
117
+ /** Who answers for it. An ownership id, a team, or a person's handle. */
118
+ owner: z.string().min(1).max(128),
119
+ valueType: z.enum(CLAIM_VALUE_TYPES),
120
+ /** How the value appears in prose. A `cli-command` claim has no value to read, so it has none. */
121
+ template: template.optional(),
122
+ evidence: EvidenceSchema,
123
+ /**
124
+ * Per-surface wording, when one fact is stated differently in different places: a README bullet
125
+ * and a study page carry the same number in different sentences, and a claim that could only
126
+ * hold one template would have to be split into two claims about one fact.
127
+ */
128
+ templates: z.record(surfacePath, template).optional(),
129
+ /** Surfaces that must state the claim. A surface that omits it is a `missing` finding. */
130
+ required: z.array(surfacePath).min(1).max(32),
131
+ /** Surfaces that may state it. Checked when the claim appears, never required to. */
132
+ optional: z.array(surfacePath).max(32).optional(),
133
+ severity: z.enum(['error', 'warn']),
134
+ remediation: z.string().min(1).max(512),
135
+ })
136
+ .strict()
137
+ .superRefine((value, context) => {
138
+ if (value.evidence.kind === 'cli-command') {
139
+ if (value.template !== undefined || value.templates !== undefined) {
140
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ['template'], message: 'A cli-command claim is a presence check and carries no template.' })
141
+ }
142
+ return
143
+ }
144
+ if (value.template === undefined) {
145
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ['template'], message: 'A claim that states a value needs a template.' })
146
+ }
147
+ })
148
+
149
+ export type PublicClaim = z.infer<typeof ClaimSchema>
150
+
151
+ /** A finding a maintainer decided to accept. The reason is mandatory: silence is what drift needs. */
152
+ const ExceptionSchema = z
153
+ .object({
154
+ claimId: identifier,
155
+ surface: surfacePath,
156
+ reason: z.string().min(8).max(512),
157
+ /** Who accepted it, so the decision has an owner like every other approval here. */
158
+ acceptedBy: z.string().min(1).max(128),
159
+ })
160
+ .strict()
161
+
162
+ export type PublicClaimException = z.infer<typeof ExceptionSchema>
163
+
164
+ const ClaimsPayloadSchema = z
165
+ .object({
166
+ type: z.literal('public-claims'),
167
+ schemaVersion: z.literal(PUBLIC_CLAIMS_SCHEMA_VERSION),
168
+ registryVersion: identifier,
169
+ claims: z.array(ClaimSchema).min(1).max(512),
170
+ exceptions: z.array(ExceptionSchema).max(128),
171
+ })
172
+ .strict()
173
+
174
+ export const PublicClaimsV1Schema = ClaimsPayloadSchema.extend({
175
+ contentHash: hash,
176
+ contentHashAlgo: z.literal(PUBLIC_CLAIMS_CONTENT_HASH_ALGO),
177
+ }).strict()
178
+
179
+ export type PublicClaimsV1 = z.infer<typeof PublicClaimsV1Schema>
180
+
181
+ const assertConsistent = (registry: PublicClaimsV1): void => {
182
+ const ids = registry.claims.map((claim) => claim.claimId)
183
+ if (new Set(ids).size !== ids.length) throw new Error('The claim registry contains duplicate claim ids.')
184
+ const known = new Set(ids)
185
+ for (const exception of registry.exceptions) {
186
+ if (!known.has(exception.claimId)) {
187
+ throw new Error(`Exception references unknown claim "${exception.claimId}".`)
188
+ }
189
+ const claim = registry.claims.find((entry) => entry.claimId === exception.claimId)
190
+ const surfaces = new Set([...(claim?.required ?? []), ...(claim?.optional ?? [])])
191
+ if (!surfaces.has(exception.surface)) {
192
+ throw new Error(`Exception for "${exception.claimId}" names surface "${exception.surface}", which the claim does not declare.`)
193
+ }
194
+ }
195
+ for (const claim of registry.claims) {
196
+ const overlap = (claim.optional ?? []).filter((surface) => claim.required.includes(surface))
197
+ if (overlap.length) throw new Error(`Claim "${claim.claimId}" lists ${overlap[0]} as both required and optional.`)
198
+ const declared = new Set([...claim.required, ...(claim.optional ?? [])])
199
+ for (const surface of Object.keys(claim.templates ?? {})) {
200
+ if (!declared.has(surface)) throw new Error(`Claim "${claim.claimId}" has a template for "${surface}", which it does not declare as a surface.`)
201
+ }
202
+ }
203
+ }
204
+
205
+ export const createPublicClaims = (input: unknown): PublicClaimsV1 => {
206
+ const payload = ClaimsPayloadSchema.parse(input)
207
+ const hashable = { ...payload, contentHashAlgo: PUBLIC_CLAIMS_CONTENT_HASH_ALGO }
208
+ const registry = PublicClaimsV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
209
+ assertConsistent(registry)
210
+ return registry
211
+ }
212
+
213
+ export const parsePublicClaims = (input: unknown): PublicClaimsV1 => {
214
+ const registry = PublicClaimsV1Schema.parse(input)
215
+ if (contentHashForArtifactV1(registry) !== registry.contentHash) throw new Error('Invalid public-claims content hash.')
216
+ assertConsistent(registry)
217
+ return registry
218
+ }
219
+
220
+ /** Every surface the registry addresses, in a stable order. */
221
+ export const claimSurfaces = (registry: PublicClaimsV1): readonly string[] =>
222
+ [...new Set(registry.claims.flatMap((claim) => [...claim.required, ...(claim.optional ?? [])]))].sort()
223
+
224
+ export const exceptionFor = (
225
+ registry: PublicClaimsV1,
226
+ claimId: string,
227
+ surface: string,
228
+ ): PublicClaimException | undefined =>
229
+ registry.exceptions.find((entry) => entry.claimId === claimId && entry.surface === surface)
230
+
231
+ const ESCAPE = /[.*+?^${}()|[\]\\]/g
232
+
233
+ /**
234
+ * The pattern a template becomes.
235
+ *
236
+ * The literal halves are escaped, so a registry cannot smuggle a regex into the checker, and
237
+ * `{value}` becomes one bounded character class per value type. One capture, no nesting and no
238
+ * ambiguity: this runs over every public surface, and a pattern that backtracks on a long line is
239
+ * a denial of service with a documentation excuse.
240
+ */
241
+ const CAPTURE: Readonly<Record<ClaimValueType, string>> = {
242
+ number: '(\\d{1,12}(?:\\.\\d{1,4})?)',
243
+ percent: '(\\d{1,3}(?:\\.\\d{1,2})?)',
244
+ semver: '(\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}(?:-[0-9A-Za-z.-]{1,32})?)',
245
+ text: '([0-9A-Za-z][0-9A-Za-z .,\\-/@]{0,96})',
246
+ }
247
+
248
+ /** The wording a surface is expected to use: its own, or the claim's default. */
249
+ export const templateFor = (claim: PublicClaim, surface: string): string => claim.templates?.[surface] ?? claim.template ?? '{value}'
250
+
251
+ export const claimPattern = (claim: PublicClaim, surface?: string): RegExp => {
252
+ const [before = '', after = ''] = (surface === undefined ? claim.template ?? '{value}' : templateFor(claim, surface)).split('{value}')
253
+ const escape = (value: string): string => value.replace(ESCAPE, '\\$&')
254
+ return new RegExp(`${escape(before)}${CAPTURE[claim.valueType]}${escape(after)}`, 'g')
255
+ }
256
+
257
+ /** What the claim reads like once the canonical value is in it: the sentence a surface should carry. */
258
+ export const renderClaim = (claim: PublicClaim, value: string, surface?: string): string =>
259
+ (surface === undefined ? claim.template ?? '{value}' : templateFor(claim, surface)).replace('{value}', value)
@@ -0,0 +1,160 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import type { DoctorReport } from '../doctor/run-doctor.js'
6
+ import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
7
+ import { CLI_COMMAND_USAGE } from '../cli/usage.js'
8
+ import type { ClaimEvidence, ClaimTransform, PublicClaim } from './claims.js'
9
+
10
+ /**
11
+ * Resolving the canonical value, and what to do when it cannot be resolved.
12
+ *
13
+ * A resolver reads one repository fact: a field in a committed file, a count over the snapshot this
14
+ * run produced, a figure the doctor measured, or whether a CLI command exists. When a resolver
15
+ * cannot answer — the artifact is absent, the field is missing, the doctor was not run — the claim
16
+ * is reported `not-analyzed`. It is never reported as a match: a claim nobody could check is not a
17
+ * claim anybody verified, and quietly passing it is how a parity report becomes decoration.
18
+ */
19
+
20
+ export type ResolvedClaim =
21
+ | { readonly status: 'resolved'; readonly value: string }
22
+ | { readonly status: 'not-analyzed'; readonly reason: string }
23
+
24
+ export type ResolveContext = {
25
+ readonly root: string
26
+ readonly config: DocBridgeConfigV1
27
+ /** The snapshot this run produced. Absent when the caller could not build one. */
28
+ readonly snapshot?: DiscoverySnapshotV1
29
+ /** The doctor's report. Absent unless the caller measured it; a doctor claim is then unchecked. */
30
+ readonly doctor?: DoctorReport
31
+ }
32
+
33
+ const readJson = (path: string): unknown => JSON.parse(readFileSync(path, 'utf8')) as unknown
34
+
35
+ const dotted = (value: unknown, field: string): unknown =>
36
+ field.split('.').reduce<unknown>((current, key) => {
37
+ if (current === null || typeof current !== 'object') return undefined
38
+ return (current as Record<string, unknown>)[key]
39
+ }, value)
40
+
41
+ const applyTransform = (value: number, transform: ClaimTransform | undefined): string => {
42
+ switch (transform) {
43
+ case 'round':
44
+ return String(Math.round(value))
45
+ case 'percent-1dp':
46
+ return (value * 100).toFixed(1)
47
+ case 'percent-0dp':
48
+ return String(Math.round(value * 100))
49
+ case 'negative-percent-2dp':
50
+ return value < 0 ? (Math.abs(value) * 100).toFixed(2) : ''
51
+ case 'negative-seconds-2dp':
52
+ return value < 0 ? (Math.abs(value) / 1_000).toFixed(2) : ''
53
+ default:
54
+ return String(value)
55
+ }
56
+ }
57
+
58
+ const scalar = (value: unknown, transform: ClaimTransform | undefined): string | undefined => {
59
+ if (typeof value === 'number' && Number.isFinite(value)) return applyTransform(value, transform) || undefined
60
+ if (typeof value === 'string' && value.length > 0 && value.length <= 128) return value
61
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
62
+ return undefined
63
+ }
64
+
65
+ const doctorMetric = (report: DoctorReport, metric: Extract<ClaimEvidence, { kind: 'doctor-metric' }>['metric']): string | undefined => {
66
+ const { coverage } = report
67
+ switch (metric) {
68
+ case 'agent-docs-indexed':
69
+ return String(coverage.agentDocs.indexed)
70
+ case 'agent-docs-total':
71
+ return String(coverage.agentDocs.total)
72
+ case 'grade':
73
+ return report.grade
74
+ case 'score':
75
+ return String(report.score)
76
+ case 'reachability-pct':
77
+ return String(coverage.reachability.pct)
78
+ case 'connectivity-pct':
79
+ return String(coverage.connectivity.pct)
80
+ case 'benchmark-hit-at-3-pct':
81
+ return coverage.benchmark.status === 'measured' ? (coverage.benchmark.hitAt3 * 100).toFixed(1) : undefined
82
+ default:
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ export const resolveClaim = (claim: PublicClaim, context: ResolveContext): ResolvedClaim => {
88
+ const evidence = claim.evidence
89
+ switch (evidence.kind) {
90
+ case 'package-field': {
91
+ const path = resolve(context.root, 'package.json')
92
+ if (!existsSync(path)) return { status: 'not-analyzed', reason: 'package.json is not present.' }
93
+ const value = scalar(dotted(readJson(path), evidence.field), undefined)
94
+ return value === undefined
95
+ ? { status: 'not-analyzed', reason: `package.json has no scalar field "${evidence.field}".` }
96
+ : { status: 'resolved', value }
97
+ }
98
+ case 'artifact-field': {
99
+ const path = resolve(context.root, evidence.path)
100
+ if (!existsSync(path)) return { status: 'not-analyzed', reason: `${evidence.path} is not present.` }
101
+ const value = scalar(dotted(readJson(path), evidence.field), evidence.transform)
102
+ /*
103
+ * An empty rendering is a signed transform refusing: the field is there, but the measurement
104
+ * no longer points the way the prose claims. That is a finding about the claim, not a match.
105
+ */
106
+ return value === undefined
107
+ ? { status: 'not-analyzed', reason: `${evidence.path} has no scalar field "${evidence.field}" that renders under ${evidence.transform ?? 'identity'}.` }
108
+ : { status: 'resolved', value }
109
+ }
110
+ case 'artifact-sum': {
111
+ /*
112
+ * Prose states a total where the artifact stores the parts: "96 executions" is both arms of a
113
+ * round that records 48 per arm. Summing here keeps the claim bound to the measurement rather
114
+ * than to a number somebody added up once.
115
+ */
116
+ const path = resolve(context.root, evidence.path)
117
+ if (!existsSync(path)) return { status: 'not-analyzed', reason: `${evidence.path} is not present.` }
118
+ const array = dotted(readJson(path), evidence.arrayField)
119
+ if (!Array.isArray(array) || array.length === 0) {
120
+ return { status: 'not-analyzed', reason: `${evidence.path} has no non-empty array at "${evidence.arrayField}".` }
121
+ }
122
+ let total = 0
123
+ for (const element of array) {
124
+ const part = dotted(element, evidence.field)
125
+ if (typeof part !== 'number' || !Number.isFinite(part)) {
126
+ return { status: 'not-analyzed', reason: `${evidence.path} has a non-numeric "${evidence.field}" in "${evidence.arrayField}".` }
127
+ }
128
+ total += part
129
+ }
130
+ const value = scalar(total, evidence.transform)
131
+ return value === undefined
132
+ ? { status: 'not-analyzed', reason: `The sum of "${evidence.field}" does not render under ${evidence.transform ?? 'identity'}.` }
133
+ : { status: 'resolved', value }
134
+ }
135
+ case 'snapshot-count': {
136
+ if (!context.snapshot) return { status: 'not-analyzed', reason: 'No snapshot was available to count.' }
137
+ const count = context.snapshot.entities.filter((entity) => entity.kind === evidence.entityKind).length
138
+ return { status: 'resolved', value: String(count) }
139
+ }
140
+ case 'doctor-metric': {
141
+ if (!context.doctor) return { status: 'not-analyzed', reason: 'The doctor was not measured for this run.' }
142
+ const value = doctorMetric(context.doctor, evidence.metric)
143
+ return value === undefined
144
+ ? { status: 'not-analyzed', reason: `The doctor reported no ${evidence.metric}.` }
145
+ : { status: 'resolved', value }
146
+ }
147
+ case 'cli-command': {
148
+ /*
149
+ * A presence claim: the canonical value is the command itself, and the CLI's own usage text is
150
+ * the authority. A command documented in public that the CLI does not offer is the same kind
151
+ * of drift as a stale number, and it fails the same way.
152
+ */
153
+ return CLI_COMMAND_USAGE.includes(evidence.command)
154
+ ? { status: 'resolved', value: evidence.command }
155
+ : { status: 'not-analyzed', reason: `The CLI usage does not offer "${evidence.command}".` }
156
+ }
157
+ default:
158
+ return { status: 'not-analyzed', reason: 'Unknown evidence kind.' }
159
+ }
160
+ }