@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,8 +1,13 @@
1
+ import { stringify as stringifyYaml } from "yaml";
1
2
  import { getCollections, getRuns, getRunDetail, diagnoseRun, compareRuns } from "../../core/diagnostics/db-analysis.ts";
2
- import { getFilteredResults } from "../../db/queries.ts";
3
+ import { getFilteredResults, getLatestFailingRunId, getLatestRunId, runKindStats, deleteRunsOlderThan, listRunsBySession, listRunsByCollectionFiltered, findCollectionByNameOrId } from "../../db/queries.ts";
3
4
  import { getDb } from "../../db/schema.ts";
4
5
  import { printError } from "../output.ts";
5
6
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
7
+ import { parseStatusFilter, compileStatusFilterToSql, type StatusMatcher } from "../status-filter.ts";
8
+ import { parseUnion, type UnionSpec } from "./coverage.ts";
9
+ import { readCurrentSession } from "../../core/context/session.ts";
10
+ import { readCurrentApi } from "../../core/context/current.ts";
6
11
 
7
12
  export interface DbOptions {
8
13
  subcommand: string;
@@ -12,12 +17,143 @@ export interface DbOptions {
12
17
  dbPath?: string;
13
18
  json?: boolean;
14
19
  method?: string;
15
- status?: number;
20
+ /** Raw `--status` argument (TASK-140). Parsed lazily so help text can show
21
+ * the literal user input in error messages. */
22
+ status?: string;
23
+ /** TASK-266: `db diagnose --latest` — pick the most recent run regardless
24
+ * of failure status (default is most recent failing run). */
25
+ latest?: boolean;
26
+ /** TASK-266: explicit run id override (`--run-id N`). Same effect as
27
+ * passing the id positionally; kept as a flag because the `zond-triage`
28
+ * skill and other agents prefer self-documenting flags. */
29
+ runId?: number;
30
+ /** ARV-266: `db prune --older-than <spec>` — cutoff for retention.
31
+ * Accepts `30d`, `12h`, or a bare integer (days). Omitted ⇒ per-kind
32
+ * documented defaults are applied. */
33
+ olderThan?: string;
34
+ /** ARV-266: restrict prune to one run_kind. */
35
+ kind?: string;
36
+ /** ARV-266: show what would be deleted without deleting. */
37
+ dryRun?: boolean;
38
+ /** ARV-338: `--report yaml` — emit the same payload as YAML instead of
39
+ * JSON, so agents can drop it next to suite YAMLs and diff runs as text. */
40
+ report?: string;
41
+ /** ARV-380: `db diagnose --union <spec>` — aggregate `by_recommended_action`
42
+ * across a run set using coverage's `--union` vocabulary
43
+ * (session | since:<dur> | tag:<name> | runs:<id1,id2,…>). */
44
+ union?: string;
45
+ /** ARV-380: API name for scoping since:/tag: unions to a collection. */
46
+ api?: string;
47
+ }
48
+
49
+ /** ARV-338: shared stdout emitter for run/diagnose/compare payloads. */
50
+ function emitPayload(payload: unknown, format?: string): void {
51
+ console.log(format === "yaml" ? stringifyYaml(payload).trimEnd() : JSON.stringify(payload, null, 2));
52
+ }
53
+
54
+ /** ARV-266: per-run_kind default retention. Noise kinds (check/probe/
55
+ * request/fixture runs) are cheap to regenerate and dominate DB growth —
56
+ * drop after a week. `regular` runs are the signal a user compares against
57
+ * over time — kept forever unless an explicit --older-than says otherwise. */
58
+ const RETENTION_DEFAULT_DAYS: Record<string, number | null> = {
59
+ regular: null,
60
+ check: 7,
61
+ probe: 7,
62
+ request: 7,
63
+ fixture: 7,
64
+ };
65
+
66
+ /** ARV-266: parse `30d` / `12h` / `30` into whole days. Returns null on a
67
+ * malformed spec so the caller can surface a clear error. */
68
+ export function parseRetentionDays(spec: string): number | null {
69
+ const m = spec.trim().match(/^(\d+)\s*([dh]?)$/i);
70
+ if (!m) return null;
71
+ const n = Number.parseInt(m[1]!, 10);
72
+ if (!Number.isFinite(n)) return null;
73
+ if (m[2]?.toLowerCase() === "h") return n / 24;
74
+ return n; // bare number or `d` ⇒ days
75
+ }
76
+
77
+ /** ARV-266: cutoff ISO timestamp `days` before now. */
78
+ function cutoffIso(days: number, now: Date): string {
79
+ return new Date(now.getTime() - days * 86_400_000).toISOString();
80
+ }
81
+
82
+ /** ARV-380: resolve a coverage-style `--union` spec to the concrete run-ids
83
+ * it selects. session/since/tag need context (active session / collection);
84
+ * `runs:` is self-contained. */
85
+ function resolveUnionRunIds(spec: UnionSpec, apiName?: string): { ids: number[] } | { error: string } {
86
+ switch (spec.kind) {
87
+ case "runIds":
88
+ return { ids: spec.ids };
89
+ case "session": {
90
+ const cur = readCurrentSession();
91
+ if (!cur) return { error: "--union session: no active session (start one with `zond session start`, or use --union runs:<ids>)." };
92
+ return { ids: listRunsBySession(cur.id).map((r) => (r as { id: number }).id) };
93
+ }
94
+ case "since":
95
+ case "tag": {
96
+ // Mirror the standard --api chain: explicit flag > ZOND_API(_GLOBAL) > `zond use`.
97
+ const resolvedApi = apiName ?? readCurrentApi();
98
+ if (!resolvedApi) return { error: `--union ${spec.kind}:<...> needs --api <name> (or ZOND_API / \`zond use\`) to scope runs to a collection.` };
99
+ const col = findCollectionByNameOrId(resolvedApi);
100
+ if (!col) return { error: `API '${apiName}' not found. Register it with \`zond add api\` or check the name.` };
101
+ const filter = spec.kind === "since"
102
+ ? { since: new Date(Date.now() - spec.durationMs).toISOString() }
103
+ : { tag: spec.name };
104
+ return { ids: listRunsByCollectionFiltered((col as { id: number }).id, filter).map((r) => r.id) };
105
+ }
106
+ }
107
+ }
108
+
109
+ /** ARV-380: fold `by_recommended_action` (+ summary counts) across a run set.
110
+ * Examples are deduped by method+path+status and capped at 5 per action,
111
+ * mirroring the single-run cap in `diagnoseRun`. */
112
+ function aggregateDiagnose(ids: number[], options: DbOptions) {
113
+ type Example = NonNullable<ReturnType<typeof diagnoseRun>["by_recommended_action"]>[string]["examples"][number];
114
+ const byAction: Record<string, { count: number; examples: Example[]; seen: Set<string> }> = {};
115
+ const summary = { total: 0, passed: 0, failed: 0, api_errors: 0, assertion_failures: 0, network_errors: 0 };
116
+ for (const id of ids) {
117
+ const r = diagnoseRun(id, options.verbose, options.dbPath, options.limit);
118
+ summary.total += r.summary.total;
119
+ summary.passed += r.summary.passed;
120
+ summary.failed += r.summary.failed;
121
+ summary.api_errors += r.summary.api_errors;
122
+ summary.assertion_failures += r.summary.assertion_failures;
123
+ summary.network_errors += r.summary.network_errors;
124
+ for (const [action, bucket] of Object.entries(r.by_recommended_action ?? {})) {
125
+ const agg = byAction[action] ??= { count: 0, examples: [], seen: new Set<string>() };
126
+ agg.count += bucket.count;
127
+ for (const ex of bucket.examples) {
128
+ const key = `${ex.method} ${ex.path} ${ex.status}`;
129
+ if (agg.seen.has(key) || agg.examples.length >= 5) continue;
130
+ agg.seen.add(key);
131
+ agg.examples.push(ex);
132
+ }
133
+ }
134
+ }
135
+ const by_recommended_action: Record<string, { count: number; examples: Example[] }> = {};
136
+ for (const [action, agg] of Object.entries(byAction)) {
137
+ by_recommended_action[action] = { count: agg.count, examples: agg.examples };
138
+ }
139
+ return { runs_diagnosed: ids.length, run_ids: ids, summary, by_recommended_action };
16
140
  }
17
141
 
18
142
  export async function dbCommand(options: DbOptions): Promise<number> {
19
143
  const { subcommand, positional, json } = options;
20
144
 
145
+ if (options.report !== undefined && options.report !== "yaml" && options.report !== "json") {
146
+ const msg = `Invalid --report format: ${options.report}. Supported: json, yaml`;
147
+ if (json) printJson(jsonError(`db ${subcommand}`, [msg]));
148
+ else printError(msg);
149
+ return 2;
150
+ }
151
+ if (options.report === "yaml" && json) {
152
+ const msg = "--report yaml and --json are mutually exclusive — pick one output format";
153
+ printJson(jsonError(`db ${subcommand}`, [msg]));
154
+ return 2;
155
+ }
156
+
21
157
  try {
22
158
  switch (subcommand) {
23
159
  case "collections": {
@@ -66,36 +202,140 @@ export async function dbCommand(options: DbOptions): Promise<number> {
66
202
  // If filtering by method/status, show filtered results instead of full detail
67
203
  if (options.method || options.status !== undefined) {
68
204
  getDb(options.dbPath);
69
- const results = getFilteredResults(id, { method: options.method, status: options.status });
205
+ let statusSql: { sql: string; params: number[] } | undefined;
206
+ if (options.status !== undefined) {
207
+ let matcher: StatusMatcher;
208
+ try {
209
+ matcher = parseStatusFilter(options.status);
210
+ } catch (err) {
211
+ const msg = `Invalid --status: ${(err as Error).message}`;
212
+ if (json) printJson(jsonError("db run", [msg]));
213
+ else printError(msg);
214
+ return 2;
215
+ }
216
+ const compiled = compileStatusFilterToSql(matcher, "response_status");
217
+ if (compiled) statusSql = compiled;
218
+ }
219
+ const results = getFilteredResults(id, { method: options.method, statusSql });
70
220
  if (json) {
71
221
  printJson(jsonOk("db run", { run_id: id, count: results.length, results }));
72
222
  } else {
73
- console.log(JSON.stringify({ run_id: id, count: results.length, results }, null, 2));
223
+ emitPayload({ run_id: id, count: results.length, results }, options.report);
74
224
  }
75
225
  } else {
76
226
  const detail = getRunDetail(id, options.verbose, options.dbPath);
77
227
  if (json) {
78
228
  printJson(jsonOk("db run", detail));
79
229
  } else {
80
- console.log(JSON.stringify(detail, null, 2));
230
+ emitPayload(detail, options.report);
81
231
  }
82
232
  }
83
233
  return 0;
84
234
  }
85
235
 
86
236
  case "diagnose": {
87
- const id = parseInt(positional[0] ?? "", 10);
88
- if (isNaN(id)) {
89
- const msg = "Missing run ID. Usage: zond db diagnose <id>";
237
+ getDb(options.dbPath);
238
+
239
+ // ARV-380: `--union <spec>` aggregates by_recommended_action across a
240
+ // run set (same vocabulary as `zond coverage --union`) instead of the
241
+ // single-run default, so an agent stops looping `db diagnose $id` per
242
+ // run and merging client-side.
243
+ if (options.union !== undefined) {
244
+ let spec: UnionSpec;
245
+ try {
246
+ spec = parseUnion(options.union);
247
+ } catch (err) {
248
+ const msg = (err as Error).message;
249
+ if (json) printJson(jsonError("db diagnose", [msg])); else printError(msg);
250
+ return 2;
251
+ }
252
+ const resolved = resolveUnionRunIds(spec, options.api);
253
+ if ("error" in resolved) {
254
+ if (json) printJson(jsonError("db diagnose", [resolved.error])); else printError(resolved.error);
255
+ return 2;
256
+ }
257
+ if (resolved.ids.length === 0) {
258
+ const msg = `No runs match --union ${options.union}.`;
259
+ if (json) printJson(jsonError("db diagnose", [msg])); else printError(msg);
260
+ return 1;
261
+ }
262
+ const agg = aggregateDiagnose(resolved.ids, options);
263
+ const payload = { ...agg, union: options.union };
264
+ if (json) printJson(jsonOk("db diagnose", payload));
265
+ else emitPayload(payload, options.report);
266
+ return 0;
267
+ }
268
+
269
+ // TASK-266: resolve run id with the priority
270
+ // explicit positional > --run-id > --latest > last failing run.
271
+ // The bare `zond db diagnose` is the agent-friendly default the
272
+ // `zond-triage` skill relies on; `--latest` exists for "show me the
273
+ // last run, even if it passed".
274
+ let id: number | null = null;
275
+ let resolution: "explicit" | "run-id-flag" | "latest" | "latest-failing" = "explicit";
276
+ const positionalRaw = positional[0];
277
+ if (positionalRaw !== undefined && positionalRaw !== "") {
278
+ const n = parseInt(positionalRaw, 10);
279
+ if (isNaN(n)) {
280
+ const msg = `Invalid run id: ${positionalRaw}. Expected a positive integer.`;
281
+ if (json) printJson(jsonError("db diagnose", [msg]));
282
+ else printError(msg);
283
+ return 2;
284
+ }
285
+ id = n;
286
+ } else if (options.runId !== undefined) {
287
+ id = options.runId;
288
+ resolution = "run-id-flag";
289
+ } else if (options.latest) {
290
+ id = getLatestRunId();
291
+ resolution = "latest";
292
+ } else {
293
+ id = getLatestFailingRunId();
294
+ resolution = "latest-failing";
295
+ if (id == null) {
296
+ // No failures — fall back to the latest run so the user still
297
+ // gets a useful payload, with a "no failures" hint.
298
+ const fallback = getLatestRunId();
299
+ if (fallback == null) {
300
+ const msg = "No runs in the database yet. Try `zond run <suite>` first.";
301
+ if (json) printJson(jsonError("db diagnose", [msg]));
302
+ else printError(msg);
303
+ return 1;
304
+ }
305
+ const result = diagnoseRun(fallback, options.verbose, options.dbPath, options.limit);
306
+ const warning = `No failing runs — diagnosing latest run #${fallback} (all passed).`;
307
+ if (json) {
308
+ printJson(jsonOk("db diagnose", { ...result, resolution: "latest-no-failures", run_id: fallback }, [warning]));
309
+ } else {
310
+ process.stderr.write(`zond: ${warning}\n`);
311
+ emitPayload({ ...result, resolution: "latest-no-failures", run_id: fallback }, options.report);
312
+ }
313
+ return 0;
314
+ }
315
+ }
316
+ if (id == null) {
317
+ const msg = "Missing run ID. Usage: zond db diagnose [id] (default: last failing run)";
90
318
  if (json) printJson(jsonError("db diagnose", [msg]));
91
319
  else printError(msg);
92
320
  return 2;
93
321
  }
94
322
  const result = diagnoseRun(id, options.verbose, options.dbPath, options.limit);
95
323
  if (json) {
96
- printJson(jsonOk("db diagnose", result));
324
+ printJson(jsonOk("db diagnose", { ...result, resolution, run_id: id }));
97
325
  } else {
98
- console.log(JSON.stringify(result, null, 2));
326
+ // ARV-208: in human mode, embed resolution + run_id in the stdout
327
+ // JSON so users (and the zond-triage skill) don't need --json just
328
+ // to discover which run was auto-selected by `zond db diagnose --api`.
329
+ // The stderr hint stays as a quick visual cue when running interactively.
330
+ if (resolution !== "explicit") {
331
+ const label = resolution === "latest-failing"
332
+ ? `last failing run #${id}`
333
+ : resolution === "latest"
334
+ ? `latest run #${id}`
335
+ : `run #${id}`;
336
+ process.stderr.write(`zond: diagnosing ${label}\n`);
337
+ }
338
+ emitPayload({ ...result, resolution, run_id: id }, options.report);
99
339
  }
100
340
  return 0;
101
341
  }
@@ -109,17 +349,117 @@ export async function dbCommand(options: DbOptions): Promise<number> {
109
349
  else printError(msg);
110
350
  return 2;
111
351
  }
352
+ // ARV-360: a run compared against itself is always a degenerate green
353
+ // (0 regressions / 0 body_changes) and signals nothing. This bit an
354
+ // audit pipeline that resolved "latest run id" to the same stale run
355
+ // for both A and B — the meaningless diff read as "contract stable".
356
+ // Fail loud instead of emitting a fake-passing comparison.
357
+ if (idA === idB) {
358
+ const msg = `Cannot compare run #${idA} against itself — pass two distinct run ids (a self-compare always reports 0 changes and hides that the ids were resolved wrong).`;
359
+ if (json) printJson(jsonError("db compare", [msg]));
360
+ else printError(msg);
361
+ return 2;
362
+ }
112
363
  const result = compareRuns(idA, idB, options.dbPath);
113
364
  if (json) {
114
365
  printJson(jsonOk("db compare", result));
115
366
  } else {
116
- console.log(JSON.stringify(result, null, 2));
367
+ emitPayload(result, options.report);
368
+ }
369
+ return 0;
370
+ }
371
+
372
+ case "stats": {
373
+ // ARV-266: per-kind row counts so users see DB growth before it bites.
374
+ getDb(options.dbPath);
375
+ const stats = runKindStats();
376
+ const totalRuns = stats.reduce((a, s) => a + s.runs, 0);
377
+ const totalResults = stats.reduce((a, s) => a + s.results, 0);
378
+ if (json) {
379
+ printJson(jsonOk("db stats", { by_kind: stats, totals: { runs: totalRuns, results: totalResults } }));
380
+ } else if (stats.length === 0) {
381
+ console.log("No runs in the database yet.");
382
+ } else {
383
+ console.log(`Row counts by run_kind (${totalRuns} runs, ${totalResults} result rows):`);
384
+ for (const s of stats) {
385
+ const retention = RETENTION_DEFAULT_DAYS[s.run_kind];
386
+ const ret = retention == null ? "kept forever" : `default prune ${retention}d`;
387
+ console.log(` ${s.run_kind.padEnd(9)} ${String(s.runs).padStart(6)} runs ${String(s.results).padStart(8)} results (${ret})`);
388
+ }
389
+ console.log("\nPrune noise kinds with `zond db prune` (per-kind defaults) or `zond db prune --older-than 30d`.");
390
+ }
391
+ return 0;
392
+ }
393
+
394
+ case "prune": {
395
+ // ARV-266: explicit, opt-in retention — never runs unless the user
396
+ // asks. Default (no --older-than) applies the documented per-kind
397
+ // cutoffs; `regular` runs are never dropped by the defaults.
398
+ getDb(options.dbPath);
399
+ const now = new Date();
400
+ // Build the (kind, cutoffIso) work-list.
401
+ let jobs: Array<{ kind?: string; cutoff: string; label: string }> = [];
402
+ if (options.olderThan) {
403
+ const days = parseRetentionDays(options.olderThan);
404
+ if (days == null) {
405
+ const msg = `Invalid --older-than "${options.olderThan}" (expected e.g. 30d, 12h, or a day count).`;
406
+ if (json) printJson(jsonError("db prune", [msg])); else printError(msg);
407
+ return 2;
408
+ }
409
+ jobs = [{ kind: options.kind, cutoff: cutoffIso(days, now), label: `${options.kind ?? "all"} < ${options.olderThan}` }];
410
+ } else {
411
+ // Per-kind defaults. A --kind filter narrows to that one kind.
412
+ for (const [kind, retDays] of Object.entries(RETENTION_DEFAULT_DAYS)) {
413
+ if (retDays == null) continue; // regular ⇒ forever
414
+ if (options.kind && options.kind !== kind) continue;
415
+ jobs.push({ kind, cutoff: cutoffIso(retDays, now), label: `${kind} < ${retDays}d` });
416
+ }
417
+ if (options.kind && RETENTION_DEFAULT_DAYS[options.kind] == null) {
418
+ const msg = `run_kind "${options.kind}" is retained forever by default. Pass --older-than to prune it explicitly.`;
419
+ if (json) printJson(jsonOk("db prune", { deleted: [], dry_run: options.dryRun === true, note: msg }, [msg]));
420
+ else console.log(msg);
421
+ return 0;
422
+ }
423
+ }
424
+
425
+ const deleted: Array<{ scope: string; runs: number; results: number }> = [];
426
+ for (const jobItem of jobs) {
427
+ if (options.dryRun) {
428
+ // Count-only: reuse the delete query's SELECT by inspecting stats
429
+ // is imprecise, so do a lightweight targeted count.
430
+ const { getDb: gd } = await import("../../db/schema.ts");
431
+ const dbh = gd();
432
+ const kindClause = jobItem.kind ? "AND run_kind = ?" : "";
433
+ const params = jobItem.kind ? [jobItem.cutoff, jobItem.kind] : [jobItem.cutoff];
434
+ const row = dbh.query(`SELECT COUNT(*) AS runs FROM runs WHERE started_at < ? ${kindClause}`).get(...params) as { runs: number };
435
+ deleted.push({ scope: jobItem.label, runs: row.runs, results: 0 });
436
+ } else {
437
+ const r = deleteRunsOlderThan(jobItem.cutoff, jobItem.kind);
438
+ deleted.push({ scope: jobItem.label, runs: r.runs, results: r.results });
439
+ }
440
+ }
441
+ const totalRuns = deleted.reduce((a, d) => a + d.runs, 0);
442
+ if (!options.dryRun && totalRuns > 0) {
443
+ // Reclaim freed pages after a large delete.
444
+ const { getDb: gd } = await import("../../db/schema.ts");
445
+ gd().exec("VACUUM");
446
+ }
447
+ if (json) {
448
+ printJson(jsonOk("db prune", { deleted, total_runs: totalRuns, dry_run: options.dryRun === true }));
449
+ } else if (totalRuns === 0) {
450
+ console.log(options.dryRun ? "Nothing would be pruned." : "Nothing to prune.");
451
+ } else {
452
+ console.log(`${options.dryRun ? "Would prune" : "Pruned"} ${totalRuns} run(s):`);
453
+ for (const d of deleted) {
454
+ if (d.runs > 0) console.log(` ${d.scope.padEnd(20)} ${d.runs} run(s)${options.dryRun ? "" : `, ${d.results} result row(s)`}`);
455
+ }
456
+ if (!options.dryRun) console.log("VACUUM reclaimed freed pages.");
117
457
  }
118
458
  return 0;
119
459
  }
120
460
 
121
461
  default: {
122
- const msg = `Unknown db subcommand: ${subcommand}. Available: collections, runs, run, diagnose, compare`;
462
+ const msg = `Unknown db subcommand: ${subcommand}. Available: collections, runs, run, diagnose, compare, stats, prune`;
123
463
  if (json) printJson(jsonError("db", [msg]));
124
464
  else printError(msg);
125
465
  return 2;
@@ -135,3 +475,137 @@ export async function dbCommand(options: DbOptions): Promise<number> {
135
475
  return 2;
136
476
  }
137
477
  }
478
+
479
+ import type { Command } from "commander";
480
+ import { globalJson } from "../resolve.ts";
481
+ import { parsePositiveInt } from "../argv.ts";
482
+
483
+ export function registerDb(program: Command): void {
484
+ const db = program.command("db").description("Query the test database");
485
+
486
+ db
487
+ .command("collections")
488
+ .description("List all API collections")
489
+ .option("--db <path>", "Path to SQLite database file")
490
+ .action(async (opts, cmd: Command) => {
491
+ process.exitCode = await dbCommand({
492
+ subcommand: "collections",
493
+ positional: [],
494
+ dbPath: opts.db,
495
+ json: globalJson(cmd),
496
+ });
497
+ });
498
+
499
+ db
500
+ .command("runs")
501
+ .description("List recent test runs")
502
+ .option("--limit <N>", "Maximum number of runs to display", parsePositiveInt("--limit"))
503
+ .option("--db <path>", "Path to SQLite database file")
504
+ .action(async (opts, cmd: Command) => {
505
+ process.exitCode = await dbCommand({
506
+ subcommand: "runs",
507
+ positional: [],
508
+ limit: opts.limit,
509
+ dbPath: opts.db,
510
+ json: globalJson(cmd),
511
+ });
512
+ });
513
+
514
+ db
515
+ .command("run <id>")
516
+ .description("Show run details")
517
+ .option("--verbose", "Show all results")
518
+ .option("--method <method>", "Filter by HTTP method")
519
+ .option(
520
+ "--status <expr>",
521
+ "Filter by HTTP status. Accepts: exact code (502), class (5xx), range (500-599), comparison (>=500, <400), or comma-separated mix (5xx,429).",
522
+ )
523
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly run snapshot)")
524
+ .option("--db <path>", "Path to SQLite database file")
525
+ .action(async (id: string, opts, cmd: Command) => {
526
+ process.exitCode = await dbCommand({
527
+ subcommand: "run",
528
+ positional: [id],
529
+ verbose: opts.verbose === true,
530
+ method: opts.method,
531
+ status: opts.status,
532
+ report: opts.report,
533
+ dbPath: opts.db,
534
+ json: globalJson(cmd),
535
+ });
536
+ });
537
+
538
+ db
539
+ .command("diagnose [id]")
540
+ .description("Diagnose run failures. Without [id]: defaults to the most recent failing run (TASK-266); falls back to latest run with a 'no failures' note when nothing has failed.")
541
+ .option("--latest", "Diagnose the most recent run regardless of status (TASK-266)")
542
+ .option("--run-id <N>", "Explicit run id override (same as positional [id]; preferred form for agents)", parsePositiveInt("--run-id"))
543
+ .option("--union <spec>", "ARV-380: aggregate by_recommended_action across a run set instead of one run. Vocabulary matches `zond coverage --union`: session | since:<dur> | tag:<name> | runs:<id1,id2,…>. since:/tag: need --api to scope the collection.")
544
+ .option("--api <name>", "API/collection to scope --union since:/tag: against. Falls back to ZOND_API / .zond/current-api.")
545
+ .option("--limit <N>", "Examples per failure group", parsePositiveInt("--limit"))
546
+ .option("--verbose", "Show all examples (not grouped)")
547
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly summary)")
548
+ .option("--db <path>", "Path to SQLite database file")
549
+ .action(async (id: string | undefined, opts, cmd: Command) => {
550
+ process.exitCode = await dbCommand({
551
+ subcommand: "diagnose",
552
+ positional: id !== undefined ? [id] : [],
553
+ limit: opts.limit,
554
+ verbose: opts.verbose === true,
555
+ latest: opts.latest === true,
556
+ runId: opts.runId,
557
+ union: opts.union,
558
+ api: opts.api,
559
+ report: opts.report,
560
+ dbPath: opts.db,
561
+ json: globalJson(cmd),
562
+ });
563
+ });
564
+
565
+ db
566
+ .command("compare <idA> <idB>")
567
+ .description("Compare two runs")
568
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly diff)")
569
+ .option("--db <path>", "Path to SQLite database file")
570
+ .action(async (idA: string, idB: string, opts, cmd: Command) => {
571
+ process.exitCode = await dbCommand({
572
+ subcommand: "compare",
573
+ positional: [idA, idB],
574
+ report: opts.report,
575
+ dbPath: opts.db,
576
+ json: globalJson(cmd),
577
+ });
578
+ });
579
+
580
+ db
581
+ .command("stats")
582
+ .description("ARV-266: row counts per run_kind + default retention, so DB growth is visible before it bites")
583
+ .option("--db <path>", "Path to SQLite database file")
584
+ .action(async (opts, cmd: Command) => {
585
+ process.exitCode = await dbCommand({
586
+ subcommand: "stats",
587
+ positional: [],
588
+ dbPath: opts.db,
589
+ json: globalJson(cmd),
590
+ });
591
+ });
592
+
593
+ db
594
+ .command("prune")
595
+ .description("ARV-266: delete old runs to bound DB growth. Default (no --older-than) applies per-kind retention: check/probe/request/fixture runs older than 7d are dropped; 'regular' runs are kept forever. VACUUMs after a delete. Opt-in — never runs unless invoked.")
596
+ .option("--older-than <spec>", "Uniform cutoff for all kinds (e.g. 30d, 12h, or a day count). Overrides per-kind defaults; prunes 'regular' too.")
597
+ .option("--kind <run_kind>", "Restrict to one run_kind: regular | check | probe | request | fixture")
598
+ .option("--dry-run", "Report what would be deleted without deleting")
599
+ .option("--db <path>", "Path to SQLite database file")
600
+ .action(async (opts, cmd: Command) => {
601
+ process.exitCode = await dbCommand({
602
+ subcommand: "prune",
603
+ positional: [],
604
+ olderThan: opts.olderThan,
605
+ kind: opts.kind,
606
+ dryRun: opts.dryRun === true,
607
+ dbPath: opts.db,
608
+ json: globalJson(cmd),
609
+ });
610
+ });
611
+ }
@@ -37,7 +37,13 @@ export async function describeCommand(options: DescribeOptions): Promise<number>
37
37
  return 0;
38
38
  }
39
39
 
40
- if (options.compact) {
40
+ // No flags = compact listing. Erroring out (exit 2) on `zond describe`
41
+ // breaks scripts that pipe the listing; the listing IS the only useful
42
+ // default since the spec is the only required arg. Single-endpoint detail
43
+ // remains opt-in via --method/--path.
44
+ const useCompact = options.compact || (!options.method && !options.path);
45
+
46
+ if (useCompact) {
41
47
  const endpoints = await describeCompact(options.specPath);
42
48
 
43
49
  if (options.json) {
@@ -55,8 +61,10 @@ export async function describeCommand(options: DescribeOptions): Promise<number>
55
61
  return 0;
56
62
  }
57
63
 
64
+ // We get here only when one of --method/--path is set but not both —
65
+ // single-endpoint detail needs both halves of the address.
58
66
  if (!options.method || !options.path) {
59
- const msg = "Missing --method and --path. Use --compact for all endpoints, or specify --method and --path for one.";
67
+ const msg = "--method and --path must be used together for single-endpoint detail. Drop both for the compact listing.";
60
68
  if (options.json) {
61
69
  printJson(jsonError("describe", [msg]));
62
70
  } else {
@@ -83,3 +91,30 @@ export async function describeCommand(options: DescribeOptions): Promise<number>
83
91
  return 2;
84
92
  }
85
93
  }
94
+
95
+ import type { Command } from "commander";
96
+ import { globalJson, resolveSpecArg } from "../resolve.ts";
97
+
98
+ export function registerDescribe(program: Command): void {
99
+ program
100
+ .command("describe [spec]")
101
+ .description("Describe endpoints from OpenAPI spec")
102
+ .option("--api <name>", "Use the registered API's spec (apis/<name>/spec.json)")
103
+ .option("--db <path>", "Path to SQLite database file")
104
+ .option("--compact", "List all endpoints briefly")
105
+ .option("--list-params", "List all unique parameters across all endpoints")
106
+ .option("--method <method>", "HTTP method for single endpoint detail")
107
+ .option("--path <path>", "Endpoint path for single endpoint detail")
108
+ .action(async (specPos: string | undefined, opts, cmd: Command) => {
109
+ const resolved = resolveSpecArg(specPos, opts.api, opts.db);
110
+ if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
111
+ process.exitCode = await describeCommand({
112
+ specPath: resolved.spec,
113
+ compact: opts.compact === true,
114
+ listParams: opts.listParams === true,
115
+ method: opts.method,
116
+ path: opts.path,
117
+ json: globalJson(cmd),
118
+ });
119
+ });
120
+ }