@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -1,15 +1,33 @@
1
- import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex, analyzeEndpoints } from "../../core/generator/index.ts";
1
+ import { readOpenApiSpec, extractEndpoints, analyzeEndpoints } from "../../core/generator/index.ts";
2
2
  import { getDb } from "../../db/schema.ts";
3
- import { getResultsByRunId, getRunById } from "../../db/queries.ts";
4
- import { printError, printSuccess } from "../output.ts";
3
+ import { loadCoverage } from "../../core/coverage/loader.ts";
4
+ import type { CoverageMatrix, MatrixRow } from "../../core/coverage/reasons.ts";
5
+ import { printError } from "../output.ts";
5
6
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
6
7
 
7
8
  export interface CoverageOptions {
8
- spec: string;
9
- tests: string;
9
+ apiName?: string;
10
+ spec?: string;
10
11
  failOnCoverage?: number;
11
12
  runId?: number;
13
+ /** TASK-255: union across multiple runs. */
14
+ runIds?: number[];
15
+ /** TASK-255: union across all runs in a session (filtered to the API). */
16
+ sessionId?: string;
17
+ /** TASK-274: union across all runs of the API started after this ISO ts. */
18
+ sinceIso?: string;
19
+ /** TASK-274: union across all runs of the API tagged <tag>. */
20
+ tag?: string;
12
21
  json?: boolean;
22
+ /** ARV-28: list not-covered (and partial) endpoints inline so users
23
+ * don't need `--json | jq` to see what's missing. */
24
+ verbose?: boolean;
25
+ /** ARV-265: which metric block(s) to render.
26
+ * - 'test' — pass/hit coverage only (legacy single-block output)
27
+ * - 'audit' — audit-coverage only (every HTTP touch, with source
28
+ * breakdown). Useful when only `zond checks run` happened.
29
+ * - 'both' (default) — both blocks side-by-side. */
30
+ scope?: "test" | "audit" | "both";
13
31
  }
14
32
 
15
33
  const RESET = "\x1b[0m";
@@ -22,160 +40,833 @@ function useColor(): boolean {
22
40
  return process.stdout.isTTY ?? false;
23
41
  }
24
42
 
25
- function extractPathFromUrl(url: string): string | null {
26
- try {
27
- return new URL(url).pathname;
28
- } catch {
29
- return url.startsWith("/") ? url : null;
43
+ interface CoverageBreakdown {
44
+ coveredRows: MatrixRow[];
45
+ partialRows: MatrixRow[];
46
+ uncoveredRows: MatrixRow[];
47
+ }
48
+
49
+ function classifyRows(matrix: CoverageMatrix): CoverageBreakdown {
50
+ const coveredRows: MatrixRow[] = [];
51
+ const partialRows: MatrixRow[] = [];
52
+ const uncoveredRows: MatrixRow[] = [];
53
+ for (const row of matrix.rows) {
54
+ const cells = Object.values(row.cells);
55
+ if (cells.some((c) => c.status === "covered")) coveredRows.push(row);
56
+ else if (cells.some((c) => c.status === "partial")) partialRows.push(row);
57
+ else uncoveredRows.push(row);
30
58
  }
59
+ return { coveredRows, partialRows, uncoveredRows };
31
60
  }
32
61
 
33
- export async function coverageCommand(options: CoverageOptions): Promise<number> {
34
- const { spec, tests } = options;
62
+ /**
63
+ * TASK-280: row-level pass/fail classification used by both the text and JSON
64
+ * outputs so they share a single source of truth.
65
+ *
66
+ * covered2xx — at least one stored result on this endpoint was a
67
+ * passing 2xx (matches `✅ N covered (passing 2xx)`)
68
+ * coveredButNon2xx — endpoint was hit but never produced a 2xx pass
69
+ * (5xx, 4xx, assertion failure — anything that landed
70
+ * a response or generated an `error`)
71
+ * unhit — no stored results at all on this endpoint
72
+ */
73
+ export interface RowBucket {
74
+ endpoint: string;
75
+ method: string;
76
+ path: string;
77
+ /** Latest observed HTTP status across all cells/results on this row, or
78
+ * `null` for unhit / network-error-only rows. */
79
+ lastStatus: number | null;
80
+ /** ARV-379: spec-declared `deprecated` (or text-flagged) for this
81
+ * endpoint. Lets a consumer split "real, closeable gap" from
82
+ * "structurally out of scope by design" without re-reading spec.json. */
83
+ deprecated: boolean;
84
+ }
35
85
 
36
- try {
37
- const doc = await readOpenApiSpec(spec);
38
- const allEndpoints = extractEndpoints(doc);
86
+ export interface BucketBreakdown {
87
+ covered2xx: RowBucket[];
88
+ coveredButNon2xx: RowBucket[];
89
+ unhit: RowBucket[];
90
+ }
91
+
92
+ export function bucketRows(matrix: CoverageMatrix): BucketBreakdown {
93
+ const covered2xx: RowBucket[] = [];
94
+ const coveredButNon2xx: RowBucket[] = [];
95
+ const unhit: RowBucket[] = [];
96
+ for (const row of matrix.rows) {
97
+ const cells = Object.values(row.cells);
98
+ const allResults = cells.flatMap(c => c.results);
99
+ const has2xxPass = allResults.some(
100
+ r => r.status === "pass" && r.responseStatus != null && r.responseStatus >= 200 && r.responseStatus < 300,
101
+ );
102
+ const lastStatus = lastObservedStatus(allResults);
103
+ const bucket: RowBucket = {
104
+ endpoint: row.endpoint,
105
+ method: row.method,
106
+ path: row.path,
107
+ lastStatus,
108
+ deprecated: !!row.deprecated,
109
+ };
110
+ if (has2xxPass) covered2xx.push(bucket);
111
+ else if (allResults.length > 0) coveredButNon2xx.push(bucket);
112
+ else unhit.push(bucket);
113
+ }
114
+ return { covered2xx, coveredButNon2xx, unhit };
115
+ }
116
+
117
+ /**
118
+ * ARV-265: roll up the audit-coverage matrix per producer (`run_kind`).
119
+ * `reached` counts how many spec endpoints have at least one result row
120
+ * across any kind; `totalEvents` is the raw result count (one per HTTP
121
+ * touch). `bySource` keys are the run_kind values that actually appear
122
+ * in `cov.runs` — kinds with zero contribution are omitted from the
123
+ * map so the text reporter doesn't print empty rows.
124
+ *
125
+ * Endpoint attribution uses the same matrix engine output the loader
126
+ * already built (each `MatrixRow` cell carries the results that mapped
127
+ * to that endpoint), but the engine doesn't track which run produced
128
+ * each result. We re-walk the contributing runs and re-load their
129
+ * results to bucket by kind. For the 1184-endpoint github-spec scan
130
+ * this is O(N) over ~1500 result rows — negligible vs the run cost.
131
+ */
132
+ function computeAuditBreakdown(cov: import("../../core/coverage/loader.ts").CoverageLoadResult): {
133
+ reached: number;
134
+ total: number;
135
+ percentage: number;
136
+ totalEvents: number;
137
+ bySource: Record<string, { endpoints: number; events: number }>;
138
+ } {
139
+ const total = cov.matrix.rows.length;
140
+ // reached: endpoints with any result (cells with at least one entry).
141
+ let reached = 0;
142
+ for (const row of cov.matrix.rows) {
143
+ const cells = Object.values(row.cells);
144
+ if (cells.some((c) => c.results.length > 0)) reached += 1;
145
+ }
146
+ const totalEvents = cov.matrix.rows.reduce(
147
+ (sum, row) => sum + Object.values(row.cells).reduce((s, c) => s + c.results.length, 0),
148
+ 0,
149
+ );
150
+ // Re-load per-run results to attribute counts to the producing kind.
151
+ const bySource: Record<string, { endpoints: Set<string>; events: number }> = {};
152
+ for (const run of cov.runs) {
153
+ const kind = run.run_kind;
154
+ const bucket = bySource[kind] ?? { endpoints: new Set<string>(), events: 0 };
155
+ const rows = getResultsByRunId(run.id);
156
+ for (const r of rows) {
157
+ // Skip pre-dispatch skips (max-requests cap, schema-gated) — they have
158
+ // no response and don't represent an actual HTTP touch.
159
+ if (r.response_status == null) continue;
160
+ bucket.events += 1;
161
+ if (typeof r.request_method === "string" && typeof r.request_url === "string") {
162
+ // The result table stores absolute URLs; the matrix engine uses
163
+ // the spec path-template regex to bucket them. For the by-source
164
+ // count we don't need that level of precision — pathname-only is
165
+ // close enough for "did this kind touch endpoint X" telemetry.
166
+ try {
167
+ const u = new URL(r.request_url);
168
+ bucket.endpoints.add(`${r.request_method.toUpperCase()} ${u.pathname}`);
169
+ } catch {
170
+ bucket.endpoints.add(`${r.request_method.toUpperCase()} ${r.request_url}`);
171
+ }
172
+ }
173
+ }
174
+ bySource[kind] = bucket;
175
+ }
176
+ const flatBySource: Record<string, { endpoints: number; events: number }> = {};
177
+ for (const [k, v] of Object.entries(bySource)) {
178
+ flatBySource[k] = { endpoints: v.endpoints.size, events: v.events };
179
+ }
180
+ return {
181
+ reached,
182
+ total,
183
+ percentage: total === 0 ? 0 : Math.round((reached / total) * 100),
184
+ totalEvents,
185
+ bySource: flatBySource,
186
+ };
187
+ }
188
+
189
+ function lastObservedStatus(results: { responseStatus: number | null }[]): number | null {
190
+ for (let i = results.length - 1; i >= 0; i--) {
191
+ const s = results[i]?.responseStatus;
192
+ if (typeof s === "number") return s;
193
+ }
194
+ return null;
195
+ }
39
196
 
40
- if (allEndpoints.length === 0) {
41
- printError("No endpoints found in the OpenAPI spec");
42
- return 1;
197
+ export async function coverageCommand(options: CoverageOptions): Promise<number> {
198
+ try {
199
+ if (options.apiName) {
200
+ return await runMatrixCoverage(options);
43
201
  }
202
+ return await runSpecOnlyCoverage(options);
203
+ } catch (err) {
204
+ const message = err instanceof Error ? err.message : String(err);
205
+ if (options.json) printJson(jsonError("coverage", [message]));
206
+ else printError(message);
207
+ return 2;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Matrix-based path: an endpoint is "covered" iff some result on it landed
213
+ * a 2xx pass. Driven by the latest stored run (or `--runId`). This is the
214
+ * answer to "did we actually exercise the endpoint", which is what
215
+ * `--fail-on-coverage` gates in CI.
216
+ */
217
+ async function runMatrixCoverage(options: CoverageOptions): Promise<number> {
218
+ getDb();
219
+ const scope = options.scope ?? "both";
220
+ const wantTest = scope === "test" || scope === "both";
221
+ const wantAudit = scope === "audit" || scope === "both";
222
+
223
+ const loaderArgs = {
224
+ apiName: options.apiName!,
225
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
226
+ ...(options.sinceIso ? { sinceIso: options.sinceIso } : {}),
227
+ ...(options.tag ? { tag: options.tag } : {}),
228
+ ...(options.runIds && options.runIds.length > 0 ? { runIds: options.runIds } : {}),
229
+ ...(options.runId != null ? { runId: options.runId } : {}),
230
+ } as const;
44
231
 
45
- const covered = await scanCoveredEndpoints(tests);
46
- const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
47
- const coveredCount = allEndpoints.length - uncovered.length;
48
- const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
232
+ // ARV-265: load the matrix twice when scope=both once for the
233
+ // pass/hit "did our suites land 2xx" metric (`scope: 'test'`), and once
234
+ // for the "did zond touch the endpoint at all" audit metric
235
+ // (`scope: 'audit'`). Loader-level scoping keeps the matrix shape
236
+ // identical so both branches reuse classifyRows / bucketRows.
237
+ const covTest = wantTest ? await loadCoverage({ ...loaderArgs, scope: "test" }) : null;
238
+ const covAudit = wantAudit ? await loadCoverage({ ...loaderArgs, scope: "audit" }) : null;
239
+ // Pick whichever block is present as the canonical "cov" — we still
240
+ // need a matrix to enumerate endpoints + spec warnings.
241
+ const cov = covTest ?? covAudit!;
242
+ const total = cov.matrix.rows.length;
243
+ if (total === 0) {
244
+ printError("No endpoints found in the OpenAPI spec");
245
+ return 1;
246
+ }
247
+
248
+ const { coveredRows, partialRows, uncoveredRows } = classifyRows((covTest ?? covAudit!).matrix);
249
+ const coveredCount = coveredRows.length;
250
+ const percentage = Math.round((coveredCount / total) * 100);
251
+
252
+ // ARV-303: envelope/exit-code contract. When the selector resolved to zero
253
+ // runs (closed session, --session-id with no runs, --union tag with no
254
+ // matches), there is no coverage data — that is the only command-level
255
+ // failure of the matrix path. Everything else (uncovered endpoints remain)
256
+ // is a data point, not a failure, so it must not gate the exit code.
257
+ const noRuns = (covTest ?? covAudit!).runs.length === 0;
258
+
259
+ // ARV-265: per-source breakdown for audit-coverage. Walks each
260
+ // contributing run, groups its results by (METHOD, path-template), and
261
+ // tallies distinct endpoints reached + raw event counts. The match is
262
+ // best-effort (uses the same endpoint regex the matrix engine does) so
263
+ // a run that hit URLs not declared in the spec is still counted by
264
+ // event-count even when no endpoint maps.
265
+ const auditBreakdown = covAudit ? computeAuditBreakdown(covAudit) : null;
49
266
 
267
+ // TASK-270: two metrics, two intents:
268
+ // pass_coverage = endpoints with at least one passing 2xx (strict — what
269
+ // single-run output has always meant)
270
+ // hit_coverage = endpoints we touched at all (loose — what `--union`
271
+ // used to silently mean)
272
+ // Show both so the reader doesn't have to re-derive the difference.
273
+ const passCount = coveredRows.length;
274
+ const hitCount = coveredRows.length + partialRows.length;
275
+ const passPct = Math.round((passCount / total) * 100);
276
+ const hitPct = Math.round((hitCount / total) * 100);
277
+
278
+ let passing = 0;
279
+ let apiError = 0;
280
+ let testFailed = 0;
281
+ for (const row of [...coveredRows, ...partialRows]) {
282
+ const cells = Object.values(row.cells);
283
+ const has5xx = cells.some((c) =>
284
+ c.results.some((r) => r.responseStatus != null && r.responseStatus >= 500),
285
+ );
286
+ const has2xxPass = cells.some((c) =>
287
+ c.results.some((r) => r.status === "pass" && r.responseStatus != null && r.responseStatus >= 200 && r.responseStatus < 300),
288
+ );
289
+ const hasFail = cells.some((c) => c.results.some((r) => r.status !== "pass"));
290
+ if (has5xx) apiError++;
291
+ else if (has2xxPass) passing++;
292
+ else if (hasFail) testFailed++;
293
+ }
294
+
295
+ if (!options.json) {
50
296
  const color = useColor();
297
+ const runsForLabel = covTest ?? covAudit!;
298
+ let runLabel: string;
299
+ if (runsForLabel.runs.length === 0) {
300
+ runLabel = runsForLabel.unionMode
301
+ ? ` — no runs match --union ${runsForLabel.unionMode}`
302
+ : " — no runs yet";
303
+ }
304
+ else if (runsForLabel.runs.length === 1) runLabel = ` — Run #${runsForLabel.runs[0]!.id}`;
305
+ else {
306
+ const modeLabel = runsForLabel.unionMode ? ` ${runsForLabel.unionMode}` : "";
307
+ runLabel = ` — union${modeLabel} of ${runsForLabel.runs.length} runs (#${runsForLabel.runs.map(r => r.id).join(", #")})`;
308
+ }
309
+ if (wantTest) {
310
+ // TASK-270: show both metrics on separate lines so CI/triage scripts
311
+ // and humans can pick the one they care about.
312
+ console.log(`test-coverage`);
313
+ console.log(` Pass-coverage (passing 2xx): ${passCount}/${total} endpoints (${passPct}%)${runLabel}`);
314
+ console.log(` Hit-coverage (any response): ${hitCount}/${total} endpoints (${hitPct}%)`);
315
+ // ARV-265: clarified source line. Previously: "only zond run results — probes not counted".
316
+ // After ARV-265, audit-coverage IS the answer for "did checks run touch X" — point users at it.
317
+ console.log(` ${color ? DIM : ""}(source: \`zond run\` results — for \`checks run\` / \`probe\` touches see audit-coverage below)${color ? RESET : ""}`);
318
+ console.log("");
51
319
 
52
- // Enriched mode with run results
53
- let passing = 0;
54
- let apiError = 0;
55
- let testFailed = 0;
56
-
57
- if (options.runId != null) {
58
- getDb();
59
- const run = getRunById(options.runId);
60
- if (!run) {
61
- printError(`Run #${options.runId} not found`);
62
- return 2;
63
- }
64
-
65
- const results = getResultsByRunId(options.runId);
66
-
67
- // Build endpoint status map
68
- const endpointStatus = new Map<string, "passing" | "api_error" | "test_failed">();
69
- for (const r of results) {
70
- if (!r.request_url || !r.request_method) continue;
71
- const urlPath = extractPathFromUrl(r.request_url);
72
- if (!urlPath) continue;
73
- const normalizedUrl = normalizePath(urlPath);
74
-
75
- for (const ep of allEndpoints) {
76
- const regex = specPathToRegex(ep.path);
77
- if (r.request_method === ep.method && regex.test(normalizedUrl)) {
78
- const key = `${ep.method} ${ep.path}`;
79
- const existing = endpointStatus.get(key);
80
-
81
- if (r.response_status !== null && r.response_status >= 500) {
82
- endpointStatus.set(key, "api_error");
83
- } else if (r.status === "fail" || r.status === "error") {
84
- if (existing !== "api_error") {
85
- endpointStatus.set(key, "test_failed");
86
- }
87
- } else if (!existing) {
88
- endpointStatus.set(key, "passing");
89
- }
90
- break;
91
- }
320
+ if (passing > 0) {
321
+ console.log(` ${color ? GREEN : ""}✅ ${passing} covered (passing 2xx)${color ? RESET : ""}`);
322
+ }
323
+ if (apiError > 0) {
324
+ console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} returning 5xx (possibly broken API)${color ? RESET : ""}`);
325
+ }
326
+ if (testFailed > 0) {
327
+ console.log(` ${color ? RED : ""}❌ ${testFailed} hit endpoint but assertions failed${color ? RESET : ""}`);
328
+ }
329
+ if (partialRows.length > 0 && testFailed === 0) {
330
+ console.log(` ${color ? YELLOW : ""}◐ ${partialRows.length} partial (only non-2xx responses)${color ? RESET : ""}`);
331
+ }
332
+ if (uncoveredRows.length > 0) {
333
+ console.log(` ${color ? DIM : ""}⬜ ${uncoveredRows.length} not covered${color ? RESET : ""}`);
334
+ // ARV-75 (feedback round-03 / F16): when some of the not-covered rows
335
+ // are deprecated, surface the count so a user reading "5% uncovered"
336
+ // can attribute the gap to deprecated endpoints (which generate skips
337
+ // by default) instead of suite regression.
338
+ const deprecatedUnhit = uncoveredRows.filter((r) => r.deprecated).length;
339
+ if (deprecatedUnhit > 0) {
340
+ console.log(` ${color ? DIM : ""}↳ ${deprecatedUnhit} of those are deprecated (skipped by \`zond generate\` unless --include-deprecated)${color ? RESET : ""}`);
92
341
  }
93
342
  }
343
+ }
94
344
 
95
- for (const status of endpointStatus.values()) {
96
- if (status === "passing") passing++;
97
- else if (status === "api_error") apiError++;
98
- else if (status === "test_failed") testFailed++;
345
+ if (wantAudit && auditBreakdown) {
346
+ if (wantTest) console.log("");
347
+ console.log(`audit-coverage: ${auditBreakdown.reached}/${auditBreakdown.total} endpoints (${auditBreakdown.percentage}%, ${auditBreakdown.totalEvents} HTTP touches)`);
348
+ console.log(` ${color ? DIM : ""}(any zond producer: run, checks, probe, request, fixture-cascade — ARV-265)${color ? RESET : ""}`);
349
+ const sources = Object.entries(auditBreakdown.bySource).filter(([, v]) => v.events > 0);
350
+ if (sources.length > 0) {
351
+ console.log(` by source:`);
352
+ for (const [kind, v] of sources) {
353
+ console.log(` ${kind.padEnd(8)} ${String(v.endpoints).padStart(4)} endpoints, ${String(v.events).padStart(5)} events`);
354
+ }
99
355
  }
100
356
  }
101
357
 
102
- if (!options.json) {
103
- if (options.runId != null) {
104
- console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
358
+ // ARV-28: --verbose lists not-covered endpoints (and partial) inline,
359
+ // so users don't have to pipe through `--json | jq` for that detail.
360
+ if (options.verbose && (uncoveredRows.length > 0 || partialRows.length > 0)) {
361
+ if (partialRows.length > 0) {
362
+ console.log("");
363
+ console.log(`${color ? YELLOW : ""}Partial (only non-2xx responses):${color ? RESET : ""}`);
364
+ for (const row of partialRows) console.log(` ◐ ${row.endpoint}`);
365
+ }
366
+ if (uncoveredRows.length > 0) {
105
367
  console.log("");
368
+ console.log(`${color ? DIM : ""}Not covered:${color ? RESET : ""}`);
369
+ for (const row of uncoveredRows) console.log(` ⬜ ${row.endpoint}`);
370
+ }
371
+ }
106
372
 
107
- if (passing > 0) {
108
- console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
109
- }
110
- if (apiError > 0) {
111
- console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
112
- }
113
- if (testFailed > 0) {
114
- console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
115
- }
116
- if (uncovered.length > 0) {
117
- console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
373
+ if (cov.matrix.totals.byReason["no-fixtures"] > 0 || cov.matrix.totals.byReason["auth-scope-mismatch"] > 0) {
374
+ console.log("");
375
+ if (cov.matrix.totals.byReason["no-fixtures"] > 0) {
376
+ // TASK-41: clarify what "blocked by no-fixtures" means — these are
377
+ // endpoints whose smoke/CRUD suite is *generated* but `skip_if`-gated
378
+ // on an empty path-param fixture. The fix is a one-shot env edit, not
379
+ // suite regeneration. Point users at the actual remedy.
380
+ console.log(
381
+ ` ${color ? DIM : ""}↳ ${cov.matrix.totals.byReason["no-fixtures"]} ` +
382
+ `cells blocked by no-fixtures (suite generated, awaiting IDs in .env.yaml ` +
383
+ `run \`zond prepare-fixtures --api ${cov.apiName}\` or seed manually).${color ? RESET : ""}`,
384
+ );
385
+ }
386
+ if (cov.matrix.totals.byReason["auth-scope-mismatch"] > 0) {
387
+ console.log(` ${color ? DIM : ""}↳ ${cov.matrix.totals.byReason["auth-scope-mismatch"]} cells blocked by auth-scope-mismatch${color ? RESET : ""}`);
388
+ }
389
+ }
390
+ } else {
391
+ // TASK-280: emit explicit covered2xx / coveredButNon2xx / unhit buckets
392
+ // so JSON consumers see the same breakdown as the text reporter. Legacy
393
+ // fields (covered/uncovered/partial, coveredEndpoints/partialEndpoints/
394
+ // uncoveredEndpoints) are kept as deprecated aliases pending full envelope-
395
+ // policy unification (TASK-184).
396
+ const buckets = bucketRows((covTest ?? covAudit!).matrix);
397
+ // ARV-265: dual-metric JSON envelope. `test_coverage` mirrors the
398
+ // pre-ARV-265 single-block shape (kept as the canonical
399
+ // pass/hit pair); `audit_coverage` is additive and only present
400
+ // when scope includes audit. Legacy top-level fields stay populated
401
+ // from the test block when present (back-compat with TASK-280
402
+ // consumers) so existing CI scripts don't break.
403
+ const json: Record<string, unknown> = {
404
+ // Legacy aliases — DO NOT add new consumers; use `totals.*` and
405
+ // `*Endpoints` arrays below instead.
406
+ covered: coveredCount,
407
+ uncovered: uncoveredRows.length,
408
+ partial: partialRows.length,
409
+ total,
410
+ percentage,
411
+ runId: cov.run?.id ?? null,
412
+ runIds: cov.runs.map((r) => r.id),
413
+ union_mode: cov.unionMode,
414
+ coveredEndpoints: coveredRows.map((r) => r.endpoint),
415
+ partialEndpoints: partialRows.map((r) => r.endpoint),
416
+ uncoveredEndpoints: uncoveredRows.map((r) => r.endpoint),
417
+ // Canonical buckets (TASK-280).
418
+ totals: {
419
+ all: total,
420
+ covered2xx: buckets.covered2xx.length,
421
+ coveredButNon2xx: buckets.coveredButNon2xx.length,
422
+ unhit: buckets.unhit.length,
423
+ },
424
+ // TASK-270: explicit twin metrics — pass_coverage is the strict
425
+ // "does the test land a 2xx", hit_coverage is the loose "did we
426
+ // touch the endpoint at all". Both expressed as endpoint-count and
427
+ // 0..1 ratio so CI scripts don't re-derive them from the buckets.
428
+ pass_coverage: { covered: passCount, total, ratio: total === 0 ? 0 : Number((passCount / total).toFixed(4)) },
429
+ hit_coverage: { covered: hitCount, total, ratio: total === 0 ? 0 : Number((hitCount / total).toFixed(4)) },
430
+ covered2xxEndpoints: buckets.covered2xx,
431
+ coveredButNon2xxEndpoints: buckets.coveredButNon2xx,
432
+ unhitEndpoints: buckets.unhit,
433
+ // ARV-75 (F16): expose deprecated-endpoint counts so CI / agents can
434
+ // distinguish "we missed coverage on a live endpoint" from "the
435
+ // remaining uncovered rows are spec-deprecated and explicitly skipped
436
+ // by zond generate" without re-deriving from the spec.
437
+ deprecated_unhit: uncoveredRows.filter((r) => r.deprecated).length,
438
+ deprecated_total: cov.matrix.rows.filter((r) => r.deprecated).length,
439
+ // ARV-379: the endpoint keys (method+path) marked deprecated in the
440
+ // spec. Intersect with `uncoveredEndpoints` to split "real, closeable
441
+ // gap" from "deprecated, skip by design" without re-reading spec.json.
442
+ // (RowBucket entries in the *Endpoints arrays also carry a per-entry
443
+ // `deprecated` boolean for the object-shaped consumers.)
444
+ deprecatedEndpoints: cov.matrix.rows.filter((r) => r.deprecated).map((r) => r.endpoint),
445
+ // ARV-265: dual-metric envelope.
446
+ test_coverage: wantTest ? {
447
+ pass: { covered: passCount, total, ratio: total === 0 ? 0 : Number((passCount / total).toFixed(4)) },
448
+ hit: { covered: hitCount, total, ratio: total === 0 ? 0 : Number((hitCount / total).toFixed(4)) },
449
+ runIds: covTest?.runs.map((r) => r.id) ?? [],
450
+ } : null,
451
+ };
452
+ if (auditBreakdown) {
453
+ json.audit_coverage = {
454
+ reached: auditBreakdown.reached,
455
+ total: auditBreakdown.total,
456
+ ratio: auditBreakdown.total === 0 ? 0 : Number((auditBreakdown.reached / auditBreakdown.total).toFixed(4)),
457
+ events: auditBreakdown.totalEvents,
458
+ by_source: auditBreakdown.bySource,
459
+ runIds: covAudit?.runs.map((r) => r.id) ?? [],
460
+ };
461
+ }
462
+ // ARV-303: surface the zero-runs case as ok:false so the non-zero exit
463
+ // lines up with the envelope shape, instead of an ok:true envelope
464
+ // alongside exit 1.
465
+ if (noRuns) {
466
+ const sel = cov.unionMode ?? "selection";
467
+ printJson(jsonError("coverage", [
468
+ `No runs match ${sel} — coverage cannot be computed against zero runs`,
469
+ ]));
470
+ } else {
471
+ printJson(jsonOk("coverage", json));
472
+ }
473
+ }
474
+
475
+ // ARV-303: exit-code contract. ok:false (no runs) ⇒ exit 1. Otherwise the
476
+ // coverage was computed successfully (ok:true) ⇒ exit 0 by default — an
477
+ // orchestrator must be able to tell "coverage ran" from "command failed".
478
+ // "Uncovered endpoints remain" no longer gates the exit on its own; use
479
+ // --fail-on-coverage to opt into a threshold gate.
480
+ if (noRuns) return 1;
481
+ if (options.failOnCoverage !== undefined) {
482
+ return percentage < options.failOnCoverage ? 1 : 0;
483
+ }
484
+ return 0;
485
+ }
486
+
487
+ /**
488
+ * Legacy fallback: when neither `--api` nor a current API is set, report
489
+ * spec-only stats. Without a registered collection there is no run to read,
490
+ * so no endpoint is callable-covered — surface that explicitly instead of
491
+ * silently scanning YAML and over-reporting.
492
+ */
493
+ async function runSpecOnlyCoverage(options: CoverageOptions): Promise<number> {
494
+ if (!options.spec) {
495
+ const msg = "Need --api <name> (preferred) or --spec <path>. Coverage is computed against stored run results.";
496
+ if (options.json) printJson(jsonError("coverage", [msg]));
497
+ else printError(msg);
498
+ return 2;
499
+ }
500
+ const doc = await readOpenApiSpec(options.spec);
501
+ const allEndpoints = extractEndpoints(doc);
502
+ if (allEndpoints.length === 0) {
503
+ printError("No endpoints found in the OpenAPI spec");
504
+ return 1;
505
+ }
506
+ const total = allEndpoints.length;
507
+ const percentage = 0;
508
+
509
+ if (!options.json) {
510
+ const color = useColor();
511
+ console.log(`Coverage: 0/${total} endpoints (0%) — no API registered`);
512
+ console.log("");
513
+ console.log(` ${color ? DIM : ""}Register the spec with \`zond add api --spec <path>\` to track run results.${color ? RESET : ""}`);
514
+ const warnings = analyzeEndpoints(allEndpoints);
515
+ if (warnings.length > 0) {
516
+ console.log("");
517
+ console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
518
+ for (const w of warnings) {
519
+ console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
520
+ }
521
+ }
522
+ } else {
523
+ // TASK-250 + ARV-303: the spec-only path emits a shape-stable
524
+ // ok:true envelope even when there's no registered API. The
525
+ // legacy contract here is "we parsed the spec, here is 0% with a
526
+ // null runId" — agents and CI scripts depend on the key shape.
527
+ // ARV-303 only tightens the matrix-coverage path (no runs match
528
+ // --union / --session-id), which DID emit ok:true alongside exit
529
+ // 1; the spec-only path was intentionally informational.
530
+ const unhit = allEndpoints.map((ep) => ({
531
+ endpoint: `${ep.method.toUpperCase()} ${ep.path}`,
532
+ method: ep.method.toUpperCase(),
533
+ path: ep.path,
534
+ lastStatus: null,
535
+ }));
536
+ printJson(jsonOk("coverage", {
537
+ covered: 0,
538
+ uncovered: total,
539
+ partial: 0,
540
+ total,
541
+ percentage,
542
+ runId: null,
543
+ coveredEndpoints: [],
544
+ partialEndpoints: [],
545
+ uncoveredEndpoints: unhit.map(u => u.endpoint),
546
+ totals: { all: total, covered2xx: 0, coveredButNon2xx: 0, unhit: total },
547
+ covered2xxEndpoints: [],
548
+ coveredButNon2xxEndpoints: [],
549
+ unhitEndpoints: unhit,
550
+ pass_coverage: { covered: 0, total, ratio: 0 },
551
+ hit_coverage: { covered: 0, total, ratio: 0 },
552
+ }));
553
+ }
554
+
555
+ if (options.failOnCoverage !== undefined) {
556
+ return percentage < options.failOnCoverage ? 1 : 0;
557
+ }
558
+ return 1;
559
+ }
560
+
561
+ import type { Command } from "commander";
562
+ import { Option } from "commander";
563
+ import { globalJson, resolveApiCollection } from "../resolve.ts";
564
+ import { parseInteger, parsePercentage } from "../argv.ts";
565
+ import { getApi } from "../util/api-context.ts";
566
+ import { readCurrentSession } from "../../core/context/session.ts";
567
+ import { listRunsBySession, getLatestRunByCollection, getRunById, findCollectionByNameOrId, listSessions, getResultsByRunId } from "../../db/queries.ts";
568
+
569
+ /**
570
+ * ARV-55: probe-run classification moved from path-regex heuristic into the
571
+ * persisted `runs.run_kind` column. Coverage's default loader query already
572
+ * filters `run_kind = 'regular'`, so this helper is no longer the gate — it
573
+ * just powers the human-readable warning when the *latest* run (regardless
574
+ * of kind) happens to be a probe-only one, which still surprises users.
575
+ */
576
+ export function isProbeOnlyRun(runId: number): boolean {
577
+ const run = getRunById(runId);
578
+ return run?.run_kind === "probe";
579
+ }
580
+
581
+ export type UnionSpec =
582
+ | { kind: "session" }
583
+ | { kind: "since"; durationMs: number; raw: string }
584
+ | { kind: "tag"; name: string }
585
+ | { kind: "runIds"; ids: number[] };
586
+
587
+ const DURATION_RE = /^(\d+)\s*(s|m|h|d)$/i;
588
+ const UNIT_MS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
589
+
590
+ /** TASK-274: parse a duration like `30m`, `2h`, `7d`. Throws on bad input. */
591
+ export function parseDuration(value: string): number {
592
+ const m = value.trim().match(DURATION_RE);
593
+ if (!m) {
594
+ throw new Error(
595
+ `Invalid duration '${value}' — expected '<N><unit>' where unit is s/m/h/d (e.g. '30m', '24h', '7d').`,
596
+ );
597
+ }
598
+ const n = Number(m[1]!);
599
+ if (!Number.isFinite(n) || n <= 0) {
600
+ throw new Error(`Invalid duration '${value}' — must be a positive integer.`);
601
+ }
602
+ return n * UNIT_MS[m[2]!.toLowerCase()]!;
603
+ }
604
+
605
+ /** TASK-255 + TASK-274: parse `--union <selector>`. Supported forms:
606
+ * - `session` — all runs in the active/specified session
607
+ * - `since:<dur>` (e.g. `since:24h`)— runs of this collection started after now-dur
608
+ * - `tag:<name>` — runs of this collection whose stored tags include <name>
609
+ * - `runs:A,B,C` or bare `A,B,C` — explicit list of run IDs (back-compat)
610
+ * Exported for unit tests. */
611
+ export function parseUnion(value: string): UnionSpec {
612
+ const v = value.trim();
613
+ if (v.length === 0) {
614
+ throw new Error(
615
+ "--union expects 'session', 'since:<dur>', 'tag:<name>', or 'runs:<id1,id2,…>' (also accepts a bare comma-separated id list).",
616
+ );
617
+ }
618
+ const lower = v.toLowerCase();
619
+ if (lower === "session") return { kind: "session" };
620
+
621
+ if (lower.startsWith("since:")) {
622
+ const raw = v.slice("since:".length).trim();
623
+ if (!raw) throw new Error("--union since:<dur> needs a duration (e.g. 'since:24h').");
624
+ return { kind: "since", durationMs: parseDuration(raw), raw };
625
+ }
626
+
627
+ if (lower.startsWith("tag:")) {
628
+ const name = v.slice("tag:".length).trim();
629
+ if (!name) throw new Error("--union tag:<name> needs a tag (e.g. 'tag:smoke').");
630
+ return { kind: "tag", name };
631
+ }
632
+
633
+ // `runs:` prefix is the documented form; bare comma list kept for
634
+ // back-compat with the original TASK-255 surface (`--union 58,59`).
635
+ const idsRaw = lower.startsWith("runs:") ? v.slice("runs:".length) : v;
636
+ const ids = idsRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map((s) => {
637
+ const n = Number(s);
638
+ if (!Number.isInteger(n) || n < 1) {
639
+ throw new Error(
640
+ `--union expects 'session', 'since:<dur>', 'tag:<name>', or 'runs:<id1,id2,…>' — got '${value}'.`,
641
+ );
642
+ }
643
+ return n;
644
+ });
645
+ if (ids.length === 0) {
646
+ throw new Error(
647
+ "--union expects 'session', 'since:<dur>', 'tag:<name>', or 'runs:<id1,id2,…>'.",
648
+ );
649
+ }
650
+ return { kind: "runIds", ids };
651
+ }
652
+
653
+ export function registerCoverage(program: Command): void {
654
+ program
655
+ .command("coverage")
656
+ .description(
657
+ "Analyze API test coverage from stored run results. zond reports two " +
658
+ "metric blocks side-by-side (ARV-265):\n" +
659
+ " test-coverage — pass/hit from `zond run` (regular + probe runs only)\n" +
660
+ " · pass-coverage — endpoint had at least one passing 2xx (strict; CI gate via --fail-on-coverage)\n" +
661
+ " · hit-coverage — endpoint received any response, incl. 5xx / assertion-fail (loose; breadth)\n" +
662
+ " audit-coverage — any HTTP touch from any producer: `run`, `checks run`,\n" +
663
+ " `probe`, `request`, `prepare-fixtures --cascade`. With by-source breakdown.\n" +
664
+ "\n" +
665
+ "Use --scope to print just one block: `--scope test` for legacy single-metric\n" +
666
+ "output, `--scope audit` to answer 'did the scan reach the API at all'.\n" +
667
+ "Producers opt out via ZOND_CHECKS_PERSIST=0 (default: persist on).\n" +
668
+ "\n" +
669
+ "Defaults to the latest stored run for the resolved API; pass " +
670
+ "--run-id to pin a specific run, or --union <selector> to combine " +
671
+ "multiple runs.\n" +
672
+ "\n" +
673
+ "--union selectors:\n" +
674
+ " session Active session (or --session-id <id>) — every run in the\n" +
675
+ " session is folded in; use this for the\n" +
676
+ " tests-run + probes-run pattern from one\n" +
677
+ " `zond session start` block.\n" +
678
+ " since:<dur> Time-window across the API (1h, 24h, 7d, 30m). Folds\n" +
679
+ " every run started within the window — handy for CI\n" +
680
+ " 'last-day coverage' aggregates spanning multiple sessions.\n" +
681
+ " tag:<name> Every run whose stored tags include <name>. Tags come\n" +
682
+ " from suite-level `tags:` plus any explicit `--tag <x>`\n" +
683
+ " on `zond run`. Useful for slicing by class\n" +
684
+ " (e.g. `tag:smoke`, `tag:negative`).\n" +
685
+ " runs:<id1,id2,...> Explicit list of run IDs (e.g. release-vs-release).\n" +
686
+ " A bare `<id1,id2,...>` is also accepted for back-compat.\n" +
687
+ "\n" +
688
+ "Recipe (session form):\n" +
689
+ " zond session start --label combined\n" +
690
+ " zond run apis/<api>/tests\n" +
691
+ " zond run apis/<api>/probes\n" +
692
+ " zond coverage --api <api> --union session\n" +
693
+ "\n" +
694
+ "Exit codes (ARV-303): 0 = coverage computed successfully (envelope " +
695
+ "ok:true — uncovered endpoints remaining is a data point, not a " +
696
+ "failure); 1 = coverage could not be computed (selector resolved to " +
697
+ "zero runs) or pass-coverage < --fail-on-coverage; 2 = bad input or " +
698
+ "read error. Uncovered endpoints only gate the exit when you opt in " +
699
+ "with --fail-on-coverage; that flag gates pass-coverage, not hit-coverage.",
700
+ )
701
+ .option("--api <name>", "Use API collection (auto-resolves spec; reads stored runs)")
702
+ .option("--spec <path>", "Spec-only fallback when no API is registered (no run results)")
703
+ .option("--fail-on-coverage <N>", "Exit 1 when coverage percentage is below N (0–100)", parsePercentage)
704
+ .option("--run-id <number>", "Pin to a specific run instead of the latest", parseInteger("--run-id"))
705
+ .option("--session-id <id>", "Union all runs in this session (filtered to the chosen API)")
706
+ .option(
707
+ "--union <selector>",
708
+ "Combine multiple runs. Selector: 'session', 'since:<dur>' (e.g. since:24h), 'tag:<name>', or 'runs:<id1,id2,…>' (bare comma-list also accepted)",
709
+ )
710
+ .option("--db <path>", "Path to SQLite database file")
711
+ .option("--verbose", "List not-covered (and partial) endpoints inline — same data as `--json` but human-readable")
712
+ .addOption(
713
+ new Option(
714
+ "--scope <scope>",
715
+ "ARV-265: which coverage block(s) to print. `test` = pass-coverage/hit-coverage only (legacy single block from `zond run` results). `audit` = audit-coverage only (every HTTP touch from `run` / `checks run` / `probe` / `request` / fixture-cascade, with by-source breakdown). `both` (default) = both side-by-side.",
716
+ ).choices(["test", "audit", "both"]).default("both"),
717
+ )
718
+ // ARV-35: `--format json` matches the kubectl/gh/aws-cli convention many
719
+ // users reach for first; until ARV-54 lands a workspace-wide alias layer
720
+ // we accept it locally and forward to `--json`. Other values are rejected
721
+ // (no markdown reporter on coverage) so typos still fail loud.
722
+ .addOption(
723
+ new Option("--format <fmt>", "Alias for --json (parity with kubectl/gh/aws-cli)").choices(["json"]),
724
+ )
725
+ .action(async (opts, cmd: Command) => {
726
+ if (opts.format === "json") opts.json = true;
727
+ // ARV-53: only walk the --api chain when --spec wasn't provided —
728
+ // an explicit spec disables the current-API fallback (coverage's
729
+ // legacy mode supports bare-spec usage).
730
+ const apiFlag = opts.spec ? (opts.api as string | undefined) : getApi(cmd, opts);
731
+ let apiName: string | undefined;
732
+ let spec: string | undefined = opts.spec;
733
+
734
+ if (apiFlag) {
735
+ const resolved = resolveApiCollection(apiFlag, opts.db);
736
+ if ("error" in resolved) {
737
+ printError(resolved.error);
738
+ process.exitCode = resolved.error.startsWith("Failed") ? 2 : 1;
739
+ return;
118
740
  }
119
- } else {
120
- // Standard mode
121
- console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
122
- console.log("");
741
+ apiName = apiFlag;
742
+ if (!spec && resolved.spec) spec = resolved.spec;
743
+ }
123
744
 
124
- // Covered endpoints
125
- if (coveredCount > 0) {
126
- console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
127
- for (const ep of allEndpoints) {
128
- if (!uncovered.includes(ep)) {
129
- console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
745
+ // Resolve --union and --session-id. --session-id wins; --union session
746
+ // resolves via .zond/current-session; --union since:/tag:/runs: are
747
+ // routed via discrete loader options so the loader can do one DB query.
748
+ let sessionId: string | undefined = opts.sessionId;
749
+ let runIds: number[] | undefined;
750
+ let sinceIso: string | undefined;
751
+ let tag: string | undefined;
752
+ if (opts.union) {
753
+ try {
754
+ const parsed = parseUnion(opts.union as string);
755
+ if (parsed.kind === "session") {
756
+ const current = readCurrentSession();
757
+ if (!current) {
758
+ // ARV-234: when there's no active session, peek at the most
759
+ // recent one — agents typically hit this right after
760
+ // `session end`. Surface its id in the error so the recovery
761
+ // path is one copy-paste instead of `db sessions`-spelunking.
762
+ let hint = "";
763
+ try {
764
+ const recent = listSessions(1, 0);
765
+ if (recent.length > 0) {
766
+ const r = recent[0]!;
767
+ const endedAt = r.finished_at ? ` (ended ${r.finished_at})` : "";
768
+ hint = ` Most recent session: --session-id ${r.session_id}${endedAt}.`;
769
+ }
770
+ } catch { /* best effort */ }
771
+ printError(`--union session requires an active session (run 'zond session start' first), or pass --session-id <id>.${hint}`);
772
+ process.exitCode = 2;
773
+ return;
130
774
  }
775
+ sessionId = current.id;
776
+ } else if (parsed.kind === "since") {
777
+ // Anchor the window at "now minus dur" — coverage CLI is
778
+ // wall-clock-driven, the loader just sees the resolved ISO.
779
+ sinceIso = new Date(Date.now() - parsed.durationMs).toISOString();
780
+ } else if (parsed.kind === "tag") {
781
+ tag = parsed.name;
782
+ } else {
783
+ runIds = parsed.ids;
131
784
  }
132
- console.log("");
785
+ } catch (err) {
786
+ printError(err instanceof Error ? err.message : String(err));
787
+ process.exitCode = 2;
788
+ return;
133
789
  }
790
+ }
134
791
 
135
- // Uncovered endpoints
136
- if (uncovered.length > 0) {
137
- console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
138
- for (const ep of uncovered) {
139
- console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
140
- }
141
- }
792
+ // since:/tag: only make sense with an API resolved (they query the
793
+ // collection's run history). Fail fast rather than silently scoping to
794
+ // every collection.
795
+ if ((sinceIso || tag) && !apiName) {
796
+ printError("--union since:/tag: requires an API. Pass --api <name> or set the current API.");
797
+ process.exitCode = 2;
798
+ return;
142
799
  }
143
800
 
144
- // Static warnings (always shown in human-readable mode)
145
- const warnings = analyzeEndpoints(allEndpoints);
146
- if (warnings.length > 0) {
147
- console.log("");
148
- console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
149
- for (const w of warnings) {
150
- console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
801
+ // ARV-71 (feedback round-02 / F12): when --api X is set and a zond
802
+ // session is active with more than one run, auto-promote the default
803
+ // to `--union session`. The pre-ARV-71 behaviour ("latest run only")
804
+ // misreads as a coverage regression every time a user runs a partial
805
+ // suite mid-session, and the previous stderr hint was easy to miss
806
+ // (the percentage already looked like a regression). Explicit
807
+ // selectors win, --json keeps the envelope untouched.
808
+ const noSelector = !opts.runId && !sessionId && !runIds && !sinceIso && !tag;
809
+ let promotedToSession = false;
810
+ if (apiName && noSelector) {
811
+ const current = readCurrentSession();
812
+ if (current) {
813
+ const sessRuns = listRunsBySession(current.id);
814
+ if (sessRuns.length > 1) {
815
+ sessionId = current.id;
816
+ promotedToSession = true;
817
+ if (!globalJson(cmd)) {
818
+ process.stderr.write(
819
+ `zond: active session has ${sessRuns.length} runs — defaulting to --union session (pass --run-id <N> for a single run).\n`,
820
+ );
821
+ }
822
+ }
151
823
  }
824
+ // ARV-41: warn when the latest run is probe-only — otherwise
825
+ // `zond coverage` right after `zond run apis/<api>/probes/...`
826
+ // looks like a regression vs the prior smoke/CRUD run.
827
+ try {
828
+ const collection = findCollectionByNameOrId(apiName);
829
+ if (collection) {
830
+ // ARV-55: peek at the absolute latest run (`runKind: 'any'`).
831
+ // Coverage's default loader query already skips probe runs
832
+ // via `run_kind = 'regular'`, so the user won't see a
833
+ // regression — but if their *most recent* invocation was a
834
+ // probe-only run, the inline warning keeps it visible.
835
+ const latest = getLatestRunByCollection(collection.id, { runKind: "any" });
836
+ if (latest && latest.run_kind === "probe") {
837
+ const hint = `Latest run #${latest.id} only executed probe suites — coverage falls back to the prior smoke/CRUD run. ` +
838
+ `For combined coverage, wrap your runs in 'zond session start/end' and pass '--union session' here.`;
839
+ process.stderr.write(`zond: ${hint}\n`);
840
+ }
841
+ // ARV-81: parity with the session-promotion footer above —
842
+ // when we *don't* promote to --union session (no session, or
843
+ // session has 1 run), tell the user which run they're seeing
844
+ // so the single-run snapshot can't be mistaken for a regression.
845
+ if (!promotedToSession && !globalJson(cmd)) {
846
+ const regular = getLatestRunByCollection(collection.id, { runKind: "regular" });
847
+ if (regular) {
848
+ process.stderr.write(
849
+ `zond: using latest run #${regular.id}. For union, pass '--union since:<dur>' or '--union runs:<a,b,...>'.\n`,
850
+ );
851
+ }
852
+ }
853
+ }
854
+ } catch { /* DB inspection is best-effort, don't break coverage */ }
152
855
  }
153
- }
154
-
155
- if (options.json) {
156
- const coveredEndpoints = allEndpoints.filter(ep => !uncovered.includes(ep)).map(ep => `${ep.method} ${ep.path}`);
157
- const uncoveredEndpoints = uncovered.map(ep => `${ep.method} ${ep.path}`);
158
- printJson(jsonOk("coverage", {
159
- covered: coveredCount,
160
- uncovered: uncovered.length,
161
- total: allEndpoints.length,
162
- percentage,
163
- coveredEndpoints,
164
- uncoveredEndpoints,
165
- }));
166
- }
167
856
 
168
- if (options.failOnCoverage !== undefined) {
169
- return percentage < options.failOnCoverage ? 1 : 0;
170
- }
171
- return uncovered.length > 0 ? 1 : 0;
172
- } catch (err) {
173
- const message = err instanceof Error ? err.message : String(err);
174
- if (options.json) {
175
- printJson(jsonError("coverage", [message]));
176
- } else {
177
- printError(message);
178
- }
179
- return 2;
180
- }
857
+ const scope = (opts.scope === "test" || opts.scope === "audit") ? opts.scope : "both";
858
+ process.exitCode = await coverageCommand({
859
+ ...(apiName ? { apiName } : {}),
860
+ ...(spec ? { spec } : {}),
861
+ failOnCoverage: opts.failOnCoverage,
862
+ runId: opts.runId,
863
+ ...(runIds ? { runIds } : {}),
864
+ ...(sessionId ? { sessionId } : {}),
865
+ ...(sinceIso ? { sinceIso } : {}),
866
+ ...(tag ? { tag } : {}),
867
+ json: globalJson(cmd),
868
+ verbose: opts.verbose === true,
869
+ scope,
870
+ });
871
+ });
181
872
  }