@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.
- package/CHANGELOG.md +488 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15871 -6218
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +95 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
- package/dist/index.d.ts +9880 -1062
- package/dist/index.js +16143 -6127
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +157 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +34 -15
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +126 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +197 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +485 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- 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
|
+
}
|