@agentskit/doc-bridge 1.7.45 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +465 -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 +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  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 +147 -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 +25 -6
  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 +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -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 +484 -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,513 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { minimatch } from 'minimatch'
4
+
5
+ import type { DocumentationAuditConfig } from '../config/schema.js'
6
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
7
+ import { frontmatterString, parseFrontmatter } from '../lib/markdown.js'
8
+ import type { DocumentationDiagnostic } from '../discovery/documentation.js'
9
+ import { generatedRegionsOf } from '../render/data.js'
10
+ import { verifyGeneratedRegions } from '../render/generated.js'
11
+ import {
12
+ DiagnosticSeveritySchema,
13
+ EvidenceSchema,
14
+ FindingStatusSchema,
15
+ type DiscoverySnapshotV1,
16
+ type Evidence,
17
+ type FindingStatus,
18
+ type ReconciliationReportV1,
19
+ } from '../schemas/knowledge.js'
20
+ import { z } from 'zod'
21
+
22
+ export const DOCUMENTATION_AUDIT_SCHEMA_VERSION = 1 as const
23
+
24
+ const AuditCategorySchema = z.enum([
25
+ 'quality',
26
+ 'coverage',
27
+ 'structure-gap',
28
+ 'contradiction',
29
+ 'stale',
30
+ 'redundancy',
31
+ 'generated-freshness',
32
+ 'limitation',
33
+ ])
34
+ const AuditConfidenceSchema = z.enum(['high', 'medium', 'low'])
35
+ const DocumentationTierSchema = z.enum(['tier-0', 'tier-1', 'tier-2'])
36
+ const DocumentationDimensionStatusSchema = z.enum(['validated', 'partial', 'not-analyzed'])
37
+ const DimensionAssessmentSchema = z.object({
38
+ status: DocumentationDimensionStatusSchema,
39
+ reason: z.string().min(1).max(1_024),
40
+ }).strict()
41
+
42
+ export const DocumentationAuditFindingSchema = z.object({
43
+ id: z.string().regex(/^[a-f0-9]{64}$/),
44
+ code: z.string().min(1).max(128),
45
+ category: AuditCategorySchema,
46
+ status: FindingStatusSchema,
47
+ severity: DiagnosticSeveritySchema,
48
+ confidence: AuditConfidenceSchema,
49
+ blocking: z.boolean(),
50
+ message: z.string().min(1).max(2_048),
51
+ evidence: z.array(EvidenceSchema).max(64),
52
+ remediation: z.string().max(2_048).optional(),
53
+ }).strict()
54
+
55
+ export const DocumentationAuditDocumentSchema = z.object({
56
+ path: z.string().min(1).max(512),
57
+ classification: z.object({
58
+ type: z.string().min(1).max(128),
59
+ audience: z.string().min(1).max(128),
60
+ lifecycle: z.string().min(1).max(128),
61
+ tier: DocumentationTierSchema,
62
+ critical: z.boolean(),
63
+ }).strict(),
64
+ metadata: z.object({
65
+ owner: z.boolean(),
66
+ lifecycle: z.boolean(),
67
+ sourceOfTruth: z.boolean(),
68
+ validationPath: z.boolean(),
69
+ complete: z.boolean(),
70
+ missing: z.array(z.enum(['owner', 'lifecycle', 'sourceOfTruth', 'validationPath'])).max(4),
71
+ }).strict(),
72
+ dimensions: z.object({
73
+ correctness: DimensionAssessmentSchema,
74
+ completeness: DimensionAssessmentSchema,
75
+ clarity: DimensionAssessmentSchema,
76
+ agentEfficiency: DimensionAssessmentSchema,
77
+ maintainability: DimensionAssessmentSchema,
78
+ }).strict(),
79
+ example: z.object({ present: z.boolean(), validation: DocumentationDimensionStatusSchema }).strict(),
80
+ }).strict()
81
+
82
+ export type DocumentationAuditDocument = z.infer<typeof DocumentationAuditDocumentSchema>
83
+
84
+ export const DocumentationAuditReportV1Schema = z.object({
85
+ type: z.literal('documentation-audit-report'),
86
+ schemaVersion: z.literal(DOCUMENTATION_AUDIT_SCHEMA_VERSION),
87
+ contentHash: z.string().regex(/^[a-f0-9]{64}$/),
88
+ contentHashAlgo: z.literal('sha256-normalized-v1'),
89
+ project: z.object({ name: z.string().min(1).max(128), root: z.string().max(512).optional() }).strict(),
90
+ sourceRevision: z.string().min(1).max(128),
91
+ sourceRevisionKind: z.enum(['git', 'content']),
92
+ configurationHash: z.string().regex(/^[a-f0-9]{64}$/),
93
+ pipelineVersion: z.string().min(1).max(64),
94
+ analyzerVersions: z.record(z.string().min(1).max(128), z.string().min(1).max(64)),
95
+ snapshotHash: z.string().regex(/^[a-f0-9]{64}$/),
96
+ reconciliationHash: z.string().regex(/^[a-f0-9]{64}$/),
97
+ status: z.enum(['pass', 'needs-review', 'blocked']),
98
+ findings: z.array(DocumentationAuditFindingSchema).max(100_000),
99
+ metrics: z.object({
100
+ documentCount: z.number().int().nonnegative(),
101
+ generatedDocumentCount: z.number().int().nonnegative(),
102
+ packageCount: z.number().int().nonnegative(),
103
+ coveredPackageCount: z.number().int().nonnegative(),
104
+ /** The unit `packageCount` counts: areas in a single-package repository, packages otherwise. */
105
+ coverageUnit: z.enum(['package', 'area']).optional(),
106
+ coverageRate: z.number().min(0).max(1).nullable(),
107
+ documentsWithTitle: z.number().int().nonnegative(),
108
+ titleRate: z.number().min(0).max(1).nullable(),
109
+ documentsWithExamples: z.number().int().nonnegative(),
110
+ examplesRate: z.number().min(0).max(1).nullable(),
111
+ documentsMeetingRequiredSections: z.number().int().nonnegative(),
112
+ requiredSectionsRate: z.number().min(0).max(1).nullable(),
113
+ exactDuplicateGroups: z.number().int().nonnegative(),
114
+ structureGapCount: z.number().int().nonnegative(),
115
+ contradictionCount: z.number().int().nonnegative(),
116
+ staleCount: z.number().int().nonnegative(),
117
+ notAnalyzedCount: z.number().int().nonnegative(),
118
+ blockingCount: z.number().int().nonnegative(),
119
+ tierCounts: z.object({ 'tier-0': z.number().int().nonnegative(), 'tier-1': z.number().int().nonnegative(), 'tier-2': z.number().int().nonnegative() }).strict(),
120
+ criticalDocumentCount: z.number().int().nonnegative(),
121
+ criticalDocumentsWithOwner: z.number().int().nonnegative(),
122
+ criticalDocumentsWithLifecycle: z.number().int().nonnegative(),
123
+ criticalDocumentsWithSourceOfTruth: z.number().int().nonnegative(),
124
+ criticalDocumentsWithValidationPath: z.number().int().nonnegative(),
125
+ dimensionStatus: z.object({
126
+ correctness: z.object({ validated: z.number().int().nonnegative(), partial: z.number().int().nonnegative(), 'not-analyzed': z.number().int().nonnegative() }).strict(),
127
+ completeness: z.object({ validated: z.number().int().nonnegative(), partial: z.number().int().nonnegative(), 'not-analyzed': z.number().int().nonnegative() }).strict(),
128
+ clarity: z.object({ validated: z.number().int().nonnegative(), partial: z.number().int().nonnegative(), 'not-analyzed': z.number().int().nonnegative() }).strict(),
129
+ agentEfficiency: z.object({ validated: z.number().int().nonnegative(), partial: z.number().int().nonnegative(), 'not-analyzed': z.number().int().nonnegative() }).strict(),
130
+ maintainability: z.object({ validated: z.number().int().nonnegative(), partial: z.number().int().nonnegative(), 'not-analyzed': z.number().int().nonnegative() }).strict(),
131
+ }).strict(),
132
+ }).strict(),
133
+ documentAssessments: z.array(DocumentationAuditDocumentSchema).max(100_000),
134
+ generatedDocuments: z.array(z.object({ path: z.string().min(1).max(512), freshness: z.literal('not-analyzed') }).strict()).max(128),
135
+ limitations: z.array(z.string().min(1).max(1_024)).max(32),
136
+ }).strict()
137
+
138
+ export type DocumentationAuditFinding = z.infer<typeof DocumentationAuditFindingSchema>
139
+ export type DocumentationAuditReportV1 = z.infer<typeof DocumentationAuditReportV1Schema>
140
+
141
+ type DocumentInput = { readonly path: string; readonly content: string }
142
+
143
+ const normalizedPath = (path: string): string => path.replaceAll('\\', '/').replace(/^\.\//, '')
144
+ const evidenceFor = (path: string, lineStart?: number): Evidence => ({
145
+ source: 'documentation',
146
+ path: normalizedPath(path),
147
+ ...(lineStart === undefined ? {} : { lineStart }),
148
+ })
149
+ const derivedEvidence: Evidence = { source: 'derived', path: '.doc-bridge/documentation-audit' }
150
+ const words = (content: string): number => content.replace(/```[\s\S]*?```/g, ' ').match(/[A-Za-z0-9][A-Za-z0-9'-]*/g)?.length ?? 0
151
+ const hasTitle = (content: string): boolean => {
152
+ if (frontmatterString(parseFrontmatter(content).data, 'title')) return true
153
+ return /^\s*#\s+\S/m.test(content) || /<h1(?:\s[^>]*)?>\s*[^<]+\s*<\/h1>/i.test(content)
154
+ }
155
+ const hasExample = (content: string): boolean => /```[\s\S]*?```/m.test(content) || /^#{1,6}\s+(?:examples?|usage)\b/im.test(content)
156
+ const hasHeading = (content: string, section: string): boolean => {
157
+ const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
158
+ return new RegExp(`^#{1,6}\\s+${escaped}\\s*$`, 'im').test(content)
159
+ }
160
+ const bodyForDuplicate = (content: string): string => {
161
+ const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/)
162
+ const start = lines[0] === '---' ? lines.findIndex((line, index) => index > 0 && line === '---') + 1 : 0
163
+ return lines.slice(start).join('\n').replace(/\s+/g, ' ').trim().toLocaleLowerCase()
164
+ }
165
+ const matches = (path: string, patterns: readonly string[]): boolean => patterns.some((pattern) => minimatch(path, pattern, { dot: true }))
166
+ const rate = (count: number, total: number): number | null => total ? count / total : null
167
+ type DocumentationTier = z.infer<typeof DocumentationTierSchema>
168
+ type DocumentationDimensionStatus = z.infer<typeof DocumentationDimensionStatusSchema>
169
+
170
+ const metadataPresent = (content: string, key: string): boolean => {
171
+ const value = parseFrontmatter(content).data[key]
172
+ return typeof value === 'string' ? value.trim().length > 0 : value === true
173
+ }
174
+
175
+ const inferredDocumentType = (path: string): string => {
176
+ const lower = path.toLocaleLowerCase()
177
+ if (lower === 'agents.md' || lower.endsWith('/agents.md') || lower.includes('/for-agents/')) return 'agent-guidance'
178
+ if (lower.includes('/adr/') || lower.startsWith('adr/')) return 'architecture-decision'
179
+ if (lower.includes('runbook') || lower.includes('/operations/')) return 'runbook'
180
+ if (lower.includes('security')) return 'security'
181
+ if (lower.includes('contribut')) return 'contribution'
182
+ if (lower.includes('architecture')) return 'architecture'
183
+ if (lower.includes('/api/') || lower.includes('/reference/')) return 'reference'
184
+ if (lower.includes('/example') || lower.includes('/recipe')) return 'example'
185
+ return 'guide'
186
+ }
187
+
188
+ const inferredAudience = (path: string): string => {
189
+ const lower = path.toLocaleLowerCase()
190
+ if (lower.includes('/agent-corpus/') || lower.includes('/for-agents/') || lower.endsWith('agents.md')) return 'agent'
191
+ if (lower === 'readme.md' || lower.includes('/readme.')) return 'human-and-agent'
192
+ return 'human'
193
+ }
194
+
195
+ const inferredLifecycle = (path: string): string => /(?:^|\/)(?:archive|archived|historical)(?:\/|$)/i.test(path) ? 'archived' : 'active'
196
+
197
+ const inferredTier = (path: string): DocumentationTier => {
198
+ const lower = path.toLocaleLowerCase()
199
+ if (lower === 'agents.md' || lower.endsWith('/agents.md') || lower.includes('/agent-corpus/') || lower.includes('/for-agents/') || lower.includes('security') || lower.includes('contribut') || lower.includes('runbook') || lower.includes('/operations/')) return 'tier-0'
200
+ if (lower.includes('/adr/') || lower.includes('architecture') || lower.includes('/api/') || lower.includes('/integration') || lower.includes('/packages/') || lower.includes('/apps/') || lower.includes('/spec/')) return 'tier-1'
201
+ return 'tier-2'
202
+ }
203
+
204
+ const tierFor = (path: string, content: string, config: DocumentationAuditConfig): { readonly tier: DocumentationTier; readonly critical: boolean } => {
205
+ const data = parseFrontmatter(content).data
206
+ const rule = config.tierRules?.find((candidate) => matches(path, [candidate.pattern]))
207
+ const explicit = frontmatterString(data, 'tier')
208
+ const tier = (explicit === 'tier-0' || explicit === 'tier-1' || explicit === 'tier-2')
209
+ ? explicit
210
+ : rule?.tier ?? config.defaultTier ?? inferredTier(path)
211
+ const explicitCritical = data.critical === true ? true : data.critical === false ? false : undefined
212
+ return { tier, critical: explicitCritical ?? rule?.critical ?? tier === 'tier-0' }
213
+ }
214
+
215
+ const dimension = (status: DocumentationDimensionStatus, reason: string): { readonly status: DocumentationDimensionStatus; readonly reason: string } => ({ status, reason })
216
+
217
+ const isCritical = (finding: Pick<DocumentationAuditFinding, 'evidence'>, paths: readonly string[]): boolean =>
218
+ paths.length > 0 && finding.evidence.some((item) => matches(item.path, paths))
219
+
220
+ const auditId = (code: string, value: unknown): string => sha256NormalizedV1({ code, value })
221
+
222
+ const createFinding = (
223
+ code: string,
224
+ category: DocumentationAuditFinding['category'],
225
+ status: FindingStatus,
226
+ severity: DocumentationAuditFinding['severity'],
227
+ confidence: DocumentationAuditFinding['confidence'],
228
+ message: string,
229
+ evidence: readonly Evidence[],
230
+ value: unknown,
231
+ criticalPaths: readonly string[],
232
+ remediation?: string,
233
+ ): DocumentationAuditFinding => {
234
+ const normalizedEvidence = [...evidence].sort((a, b) => `${a.path}:${a.lineStart ?? 0}`.localeCompare(`${b.path}:${b.lineStart ?? 0}`))
235
+ const blocking = confidence === 'high' && isCritical({ evidence: normalizedEvidence }, criticalPaths)
236
+ return {
237
+ id: auditId(code, value),
238
+ code,
239
+ category,
240
+ status,
241
+ severity: blocking ? 'error' : severity,
242
+ confidence,
243
+ blocking,
244
+ message,
245
+ evidence: normalizedEvidence,
246
+ ...(remediation ? { remediation } : {}),
247
+ }
248
+ }
249
+
250
+ const diagnosticMapping = (diagnostic: ReconciliationReportV1['diagnostics'][number]): {
251
+ category: DocumentationAuditFinding['category']; status: FindingStatus; confidence: DocumentationAuditFinding['confidence']; severity: DocumentationAuditFinding['severity']
252
+ } | undefined => {
253
+ if (diagnostic.code === 'RELATION_CONFIRMED') return undefined
254
+ if (diagnostic.code === 'DOCUMENTATION_ORPHANED') return undefined
255
+ if (diagnostic.code === 'RELATION_UNDOCUMENTED') return { category: 'structure-gap', status: 'undocumented', confidence: 'high', severity: diagnostic.severity }
256
+ if (diagnostic.code === 'CONFLICTING_DECLARATIONS') return { category: 'contradiction', status: 'conflict', confidence: 'high', severity: diagnostic.severity }
257
+ if (diagnostic.code === 'DECLARED_RELATION_STALE') return { category: 'stale', status: 'stale-or-unverified', confidence: 'high', severity: diagnostic.severity }
258
+ if (diagnostic.code === 'OWNERSHIP_PATH_UNOBSERVED') return { category: 'stale', status: 'stale-or-unverified', confidence: 'high', severity: diagnostic.severity }
259
+ // A cycle is an architecture finding, not a documentation one: reported, never counted as a gap.
260
+ if (diagnostic.code === 'IMPORT_CYCLE') return undefined
261
+ if (diagnostic.code === 'RELATION_NOT_ANALYZED') return { category: 'limitation', status: 'not-analyzed', confidence: 'low', severity: diagnostic.severity }
262
+ if (diagnostic.code === 'UNRESOLVED_ENTITY_REFERENCE') return { category: 'contradiction', status: 'unresolved', confidence: 'high', severity: diagnostic.severity }
263
+ return { category: 'quality', status: 'unresolved', confidence: 'high', severity: diagnostic.severity }
264
+ }
265
+
266
+ export type DocumentationAuditOptions = {
267
+ readonly root: string
268
+ readonly snapshot: DiscoverySnapshotV1
269
+ readonly declared: DiscoverySnapshotV1
270
+ readonly reconciliation: ReconciliationReportV1
271
+ readonly declarationDiagnostics?: readonly DocumentationDiagnostic[]
272
+ readonly config?: DocumentationAuditConfig
273
+ }
274
+
275
+ export const auditDocumentation = (options: DocumentationAuditOptions): DocumentationAuditReportV1 => {
276
+ const config = options.config ?? {}
277
+ const excluded = config.exclude ?? []
278
+ const generatedPaths = config.generatedPaths ?? []
279
+ const criticalPaths = config.criticalPaths ?? []
280
+ const requiredSections = config.requiredSections ?? []
281
+ const documents: DocumentInput[] = options.snapshot.entities
282
+ .filter((entity) => entity.kind === 'document' && entity.path)
283
+ .map((entity) => ({ path: normalizedPath(entity.path as string), content: readFileSync(resolve(options.root, entity.path as string), 'utf8') }))
284
+ .filter((document) => !matches(document.path, excluded))
285
+ .sort((a, b) => a.path.localeCompare(b.path))
286
+ const findings: DocumentationAuditFinding[] = []
287
+ const generated = documents.filter((document) => matches(document.path, generatedPaths))
288
+ const analyzed = documents.filter((document) => !matches(document.path, generatedPaths))
289
+
290
+ /*
291
+ * A generated region whose body no longer hashes to what its marker claims was edited by hand
292
+ * (or written by a different generator). The analyzer records the regions on the document
293
+ * entity; the audit only recomputes the hash of the lines between the markers. Regenerating
294
+ * the region would silently discard the edit, which is why it is a finding first.
295
+ */
296
+ const documentEntities = new Map(
297
+ options.snapshot.entities
298
+ .filter((entity) => entity.kind === 'document' && entity.path)
299
+ .map((entity) => [normalizedPath(entity.path as string), entity] as const),
300
+ )
301
+ for (const document of documents) {
302
+ for (const mismatch of verifyGeneratedRegions(document.content, generatedRegionsOf(documentEntities.get(document.path)))) {
303
+ findings.push(createFinding(
304
+ 'GENERATED_REGION_EDITED', 'generated-freshness', 'stale-or-unverified', 'warn', 'high',
305
+ `Generated region at ${document.path}:${mismatch.lineStart}-${mismatch.lineEnd} no longer matches its marker: the marker says hash=${mismatch.expected}, the content hashes to ${mismatch.actual}. It was edited by hand or written by a different generator.`,
306
+ [{ source: 'documentation', path: document.path, lineStart: mismatch.lineStart, lineEnd: mismatch.lineEnd }],
307
+ `${document.path}:${mismatch.lineStart}`, criticalPaths,
308
+ 'Regenerate the region with ak-docs render, or move the manual text outside the generated markers so it is kept.',
309
+ ))
310
+ }
311
+ }
312
+ /*
313
+ * The unit coverage is measured against.
314
+ *
315
+ * Packages, except in a single-package repository — there the only package is the repository
316
+ * itself, the filter left nothing, and the audit reported "Packages covered: 0/0" while the
317
+ * doctor reported full health. Most repositories are one package, so for them the unit is the
318
+ * area: the directory level that actually has an owner and a document.
319
+ */
320
+ const packageCandidates = options.snapshot.entities.filter((entity) => entity.kind === 'package')
321
+ const singlePackage = packageCandidates.length === 1
322
+ const coverageUnitKind = singlePackage ? 'area' : 'package'
323
+ const packageEntities = options.snapshot.entities
324
+ .filter((entity) =>
325
+ singlePackage ? entity.kind === 'area' : entity.kind === 'package' && entity.path !== '.',
326
+ )
327
+ .sort((a, b) => a.id.localeCompare(b.id))
328
+ const coveredPackages = new Set(options.declared.relations.filter((relation) => relation.kind === 'covers' && relation.from.startsWith('document:')).map((relation) => relation.to))
329
+ const requiredCriticalMetadata = config.requiredCriticalMetadata ?? ['owner', 'lifecycle', 'sourceOfTruth', 'validationPath']
330
+ const documentAssessments: DocumentationAuditDocument[] = documents.map((document) => {
331
+ const data = parseFrontmatter(document.content).data
332
+ const tiering = tierFor(document.path, document.content, config)
333
+ const metadata = {
334
+ owner: metadataPresent(document.content, 'owner'),
335
+ lifecycle: metadataPresent(document.content, 'lifecycle'),
336
+ sourceOfTruth: metadataPresent(document.content, 'sourceOfTruth'),
337
+ validationPath: metadataPresent(document.content, 'validationPath'),
338
+ }
339
+ const missing = requiredCriticalMetadata.filter((key) => !metadata[key])
340
+ const metadataState = { ...metadata, complete: missing.length === 0, missing }
341
+ const title = hasTitle(document.content)
342
+ const examples = hasExample(document.content)
343
+ const sections = requiredSections.every((section) => hasHeading(document.content, section))
344
+ const qualityAnalyzed = !matches(document.path, generatedPaths)
345
+ const assessment: DocumentationAuditDocument = {
346
+ path: document.path,
347
+ classification: {
348
+ type: frontmatterString(data, 'type') ?? inferredDocumentType(document.path),
349
+ audience: frontmatterString(data, 'audience') ?? inferredAudience(document.path),
350
+ lifecycle: frontmatterString(data, 'lifecycle') ?? inferredLifecycle(document.path),
351
+ tier: tiering.tier,
352
+ critical: tiering.critical,
353
+ },
354
+ metadata: {
355
+ ...metadataState,
356
+ },
357
+ dimensions: {
358
+ correctness: dimension('not-analyzed', qualityAnalyzed ? 'Semantic correctness requires code, configuration, and applicable runtime evidence review.' : 'Generated-document correctness is outside this deterministic audit.'),
359
+ completeness: dimension(!qualityAnalyzed ? 'not-analyzed' : title && sections ? 'partial' : 'not-analyzed', title && sections ? 'Required structural signals are present; semantic completeness remains unverified.' : 'Required structural signals are incomplete or not configured.'),
360
+ clarity: dimension(!qualityAnalyzed ? 'not-analyzed' : title ? 'partial' : 'not-analyzed', title ? 'Title and basic structure are present; human clarity review remains unverified.' : 'A title is required before clarity can be assessed.'),
361
+ agentEfficiency: dimension(!qualityAnalyzed ? 'not-analyzed' : title && examples ? 'partial' : 'not-analyzed', title && examples ? 'Title and an example are present; task usefulness and retrieval efficiency remain unverified.' : 'Agent efficiency requires a clear title and example before semantic review.'),
362
+ maintainability: dimension(metadataState.complete ? 'validated' : 'partial', metadataState.complete ? 'Required maintainability metadata is present.' : 'Ownership, lifecycle, source-of-truth, or validation metadata is incomplete.'),
363
+ },
364
+ example: { present: examples, validation: 'not-analyzed' },
365
+ }
366
+ if (tiering.critical && qualityAnalyzed && missing.length > 0) findings.push(createFinding(
367
+ 'DOCUMENTATION_CRITICAL_METADATA_MISSING', 'quality', 'undocumented', 'warn', 'high',
368
+ `Critical ${document.path} is missing maintainability metadata: ${missing.join(', ')}.`,
369
+ [evidenceFor(document.path)], [document.path, missing], criticalPaths,
370
+ 'Add the missing owner, lifecycle, source-of-truth, or validation-path metadata, or declare a tracked exception.',
371
+ ))
372
+ return assessment
373
+ })
374
+
375
+ for (const document of generated) {
376
+ findings.push(createFinding(
377
+ 'GENERATED_DOCUMENT_FRESHNESS_UNVERIFIED', 'generated-freshness', 'not-analyzed', 'info', 'low',
378
+ 'Generated documentation is included in the corpus, but this audit only verifies that it is present; freshness must be proven by its generating check.',
379
+ [evidenceFor(document.path)], document.path, criticalPaths,
380
+ 'Configure the generator command as a freshness check and keep generated content out of direct manual edits.',
381
+ ))
382
+ }
383
+
384
+ for (const document of analyzed) {
385
+ const evidence = evidenceFor(document.path)
386
+ if (!hasTitle(document.content)) findings.push(createFinding('DOCUMENTATION_TITLE_MISSING', 'quality', 'undocumented', 'warn', 'high', 'Documentation file has no level-one title.', [evidence], document.path, criticalPaths, 'Add one concise level-one title that identifies the documented subject.'))
387
+ if (config.minWords !== undefined && words(document.content) < config.minWords) findings.push(createFinding('DOCUMENTATION_TOO_SHORT', 'quality', 'undocumented', 'warn', 'high', `Documentation file has fewer than ${config.minWords} words.`, [evidence], [document.path, config.minWords], criticalPaths, 'Add the missing context or lower the threshold when this file is intentionally a short index.'))
388
+ if (config.requireExamples && !hasExample(document.content)) findings.push(createFinding('DOCUMENTATION_EXAMPLE_MISSING', 'quality', 'undocumented', 'warn', 'high', 'Documentation file has no usage heading or fenced example.', [evidence], document.path, criticalPaths, 'Add a minimal, runnable example or explicitly exempt this document from example requirements.'))
389
+ const missingSections = requiredSections.filter((section) => !hasHeading(document.content, section))
390
+ if (missingSections.length) findings.push(createFinding('DOCUMENTATION_SECTIONS_MISSING', 'quality', 'undocumented', 'warn', 'high', `Documentation file is missing required section(s): ${missingSections.join(', ')}.`, [evidence], [document.path, missingSections], criticalPaths, 'Add the required sections or document a tracked exception.'))
391
+ }
392
+
393
+ if (config.exactDuplicates !== false) {
394
+ const duplicateGroups = new Map<string, DocumentInput[]>()
395
+ for (const document of analyzed) {
396
+ const body = bodyForDuplicate(document.content)
397
+ if (!body) continue
398
+ const group = duplicateGroups.get(body) ?? []
399
+ group.push(document)
400
+ duplicateGroups.set(body, group)
401
+ }
402
+ for (const group of [...duplicateGroups.values()].filter((items) => items.length > 1)) {
403
+ findings.push(createFinding('DOCUMENTATION_EXACT_DUPLICATE', 'redundancy', 'unresolved', 'warn', 'high', `Documentation files contain identical normalized content: ${group.map((item) => item.path).join(', ')}.`, group.map((item) => evidenceFor(item.path)), group.map((item) => item.path), criticalPaths, 'Keep one canonical document and replace the others with links or clearly differentiated scope.'))
404
+ }
405
+ }
406
+
407
+ for (const packageEntity of packageEntities) {
408
+ if (coveredPackages.has(packageEntity.id)) continue
409
+ const evidence = packageEntity.evidence.length ? packageEntity.evidence : [derivedEvidence]
410
+ const label = coverageUnitKind === 'area' ? 'Area' : 'Package'
411
+ findings.push(createFinding(
412
+ coverageUnitKind === 'area' ? 'AREA_DOCUMENTATION_MISSING' : 'PACKAGE_DOCUMENTATION_MISSING',
413
+ 'coverage',
414
+ 'undocumented',
415
+ 'warn',
416
+ 'high',
417
+ `${label} ${packageEntity.path ?? packageEntity.name} has no documentation coverage declaration.`,
418
+ evidence,
419
+ packageEntity.id,
420
+ criticalPaths,
421
+ `Add a docbridge covers declaration to the ${coverageUnitKind} documentation or explicitly exclude the ${coverageUnitKind}.`,
422
+ ))
423
+ }
424
+
425
+ for (const diagnostic of options.reconciliation.diagnostics) {
426
+ const mapping = diagnosticMapping(diagnostic)
427
+ if (!mapping) continue
428
+ findings.push(createFinding(diagnostic.code, mapping.category, mapping.status, mapping.severity, mapping.confidence, diagnostic.message, diagnostic.evidence, diagnostic.id, criticalPaths, diagnostic.remediation))
429
+ }
430
+ for (const diagnostic of options.declarationDiagnostics ?? []) {
431
+ findings.push(createFinding(diagnostic.code, 'quality', 'unresolved', 'error', 'high', diagnostic.message, [diagnostic.evidence], diagnostic.code + diagnostic.evidence.path + diagnostic.evidence.lineStart, criticalPaths, 'Fix the declaration syntax or reference before relying on the documentation graph.'))
432
+ }
433
+
434
+ findings.push(createFinding('DOCUMENTATION_SEMANTICS_NOT_ANALYZED', 'limitation', 'not-analyzed', 'info', 'low', 'Natural-language redundancy, unnecessary prose, and contradictions not expressed as structured Doc Bridge claims require agent or human review.', [derivedEvidence], options.snapshot.contentHash, criticalPaths, 'Run a configured Registry agent review and require human approval before applying any proposed documentation change.'))
435
+ const sortedFindings = [...new Map(findings.map((finding) => [finding.id, finding])).values()].sort((a, b) => a.id.localeCompare(b.id))
436
+ const qualityDocs = analyzed.length
437
+ const requiredSectionDocs = analyzed.filter((document) => requiredSections.every((section) => hasHeading(document.content, section))).length
438
+ const statusCounts = (dimensionName: keyof DocumentationAuditDocument['dimensions']) => ({
439
+ validated: documentAssessments.filter((assessment) => assessment.dimensions[dimensionName].status === 'validated').length,
440
+ partial: documentAssessments.filter((assessment) => assessment.dimensions[dimensionName].status === 'partial').length,
441
+ 'not-analyzed': documentAssessments.filter((assessment) => assessment.dimensions[dimensionName].status === 'not-analyzed').length,
442
+ })
443
+ const criticalAssessments = documentAssessments.filter((assessment) => assessment.classification.critical)
444
+ const base = {
445
+ type: 'documentation-audit-report' as const,
446
+ schemaVersion: DOCUMENTATION_AUDIT_SCHEMA_VERSION,
447
+ contentHash: '0'.repeat(64),
448
+ contentHashAlgo: 'sha256-normalized-v1' as const,
449
+ project: options.snapshot.project,
450
+ sourceRevision: options.snapshot.sourceRevision,
451
+ sourceRevisionKind: options.snapshot.sourceRevisionKind,
452
+ configurationHash: options.snapshot.configurationHash,
453
+ pipelineVersion: options.snapshot.pipelineVersion,
454
+ analyzerVersions: options.snapshot.analyzerVersions,
455
+ snapshotHash: options.snapshot.contentHash,
456
+ reconciliationHash: options.reconciliation.contentHash,
457
+ status: sortedFindings.some((finding) => finding.blocking) ? 'blocked' as const : sortedFindings.some((finding) => finding.severity === 'warn' || finding.status !== 'confirmed') ? 'needs-review' as const : 'pass' as const,
458
+ findings: sortedFindings,
459
+ metrics: {
460
+ documentCount: documents.length,
461
+ generatedDocumentCount: generated.length,
462
+ coverageUnit: coverageUnitKind,
463
+ packageCount: packageEntities.length,
464
+ coveredPackageCount: packageEntities.filter((entity) => coveredPackages.has(entity.id)).length,
465
+ coverageRate: rate(packageEntities.filter((entity) => coveredPackages.has(entity.id)).length, packageEntities.length),
466
+ documentsWithTitle: analyzed.filter((document) => hasTitle(document.content)).length,
467
+ titleRate: rate(analyzed.filter((document) => hasTitle(document.content)).length, qualityDocs),
468
+ documentsWithExamples: analyzed.filter((document) => hasExample(document.content)).length,
469
+ examplesRate: rate(analyzed.filter((document) => hasExample(document.content)).length, qualityDocs),
470
+ documentsMeetingRequiredSections: requiredSectionDocs,
471
+ requiredSectionsRate: rate(requiredSectionDocs, qualityDocs),
472
+ exactDuplicateGroups: sortedFindings.filter((finding) => finding.code === 'DOCUMENTATION_EXACT_DUPLICATE').length,
473
+ structureGapCount: sortedFindings.filter((finding) => finding.category === 'structure-gap' || finding.code === 'PACKAGE_DOCUMENTATION_MISSING' || finding.code === 'AREA_DOCUMENTATION_MISSING').length,
474
+ contradictionCount: sortedFindings.filter((finding) => finding.category === 'contradiction').length,
475
+ staleCount: sortedFindings.filter((finding) => finding.category === 'stale').length,
476
+ notAnalyzedCount: sortedFindings.filter((finding) => finding.status === 'not-analyzed').length,
477
+ blockingCount: sortedFindings.filter((finding) => finding.blocking).length,
478
+ tierCounts: {
479
+ 'tier-0': documentAssessments.filter((assessment) => assessment.classification.tier === 'tier-0').length,
480
+ 'tier-1': documentAssessments.filter((assessment) => assessment.classification.tier === 'tier-1').length,
481
+ 'tier-2': documentAssessments.filter((assessment) => assessment.classification.tier === 'tier-2').length,
482
+ },
483
+ criticalDocumentCount: criticalAssessments.length,
484
+ criticalDocumentsWithOwner: criticalAssessments.filter((assessment) => assessment.metadata.owner).length,
485
+ criticalDocumentsWithLifecycle: criticalAssessments.filter((assessment) => assessment.metadata.lifecycle).length,
486
+ criticalDocumentsWithSourceOfTruth: criticalAssessments.filter((assessment) => assessment.metadata.sourceOfTruth).length,
487
+ criticalDocumentsWithValidationPath: criticalAssessments.filter((assessment) => assessment.metadata.validationPath).length,
488
+ dimensionStatus: {
489
+ correctness: statusCounts('correctness'),
490
+ completeness: statusCounts('completeness'),
491
+ clarity: statusCounts('clarity'),
492
+ agentEfficiency: statusCounts('agentEfficiency'),
493
+ maintainability: statusCounts('maintainability'),
494
+ },
495
+ },
496
+ documentAssessments,
497
+ generatedDocuments: generated.map((document) => ({ path: document.path, freshness: 'not-analyzed' as const })),
498
+ limitations: [
499
+ 'Generated documentation receives presence and freshness-boundary reporting only; generator checks must prove freshness.',
500
+ 'Natural-language semantic contradiction and unnecessary-content detection are not deterministic and remain not-analyzed.',
501
+ 'Agent proposals are advisory and require human approval before any edit.',
502
+ ],
503
+ }
504
+ return DocumentationAuditReportV1Schema.parse({ ...base, contentHash: contentHashForArtifactV1(base) })
505
+ }
506
+
507
+ export const formatDocumentationAuditText = (report: DocumentationAuditReportV1): readonly string[] => [
508
+ `Documentation audit: ${report.status}`,
509
+ `Documents: ${report.metrics.documentCount} | ${report.metrics.coverageUnit === 'area' ? 'Areas' : 'Packages'} covered: ${report.metrics.coveredPackageCount}/${report.metrics.packageCount}`,
510
+ `Title: ${report.metrics.titleRate === null ? 'n/a' : `${Math.round(report.metrics.titleRate * 100)}%`} | Examples: ${report.metrics.examplesRate === null ? 'n/a' : `${Math.round(report.metrics.examplesRate * 100)}%`}`,
511
+ `Gaps: ${report.metrics.structureGapCount} | Contradictions: ${report.metrics.contradictionCount} | Stale: ${report.metrics.staleCount} | Not analyzed: ${report.metrics.notAnalyzedCount}`,
512
+ `Blocking findings: ${report.metrics.blockingCount}`,
513
+ ]