@kirrosh/zond 0.23.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 (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -1,9 +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, getLatestFailingRunId, getLatestRunId } 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";
6
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";
7
11
 
8
12
  export interface DbOptions {
9
13
  subcommand: string;
@@ -23,11 +27,133 @@ export interface DbOptions {
23
27
  * passing the id positionally; kept as a flag because the `zond-triage`
24
28
  * skill and other agents prefer self-documenting flags. */
25
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 };
26
140
  }
27
141
 
28
142
  export async function dbCommand(options: DbOptions): Promise<number> {
29
143
  const { subcommand, positional, json } = options;
30
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
+
31
157
  try {
32
158
  switch (subcommand) {
33
159
  case "collections": {
@@ -94,26 +220,57 @@ export async function dbCommand(options: DbOptions): Promise<number> {
94
220
  if (json) {
95
221
  printJson(jsonOk("db run", { run_id: id, count: results.length, results }));
96
222
  } else {
97
- console.log(JSON.stringify({ run_id: id, count: results.length, results }, null, 2));
223
+ emitPayload({ run_id: id, count: results.length, results }, options.report);
98
224
  }
99
225
  } else {
100
226
  const detail = getRunDetail(id, options.verbose, options.dbPath);
101
227
  if (json) {
102
228
  printJson(jsonOk("db run", detail));
103
229
  } else {
104
- console.log(JSON.stringify(detail, null, 2));
230
+ emitPayload(detail, options.report);
105
231
  }
106
232
  }
107
233
  return 0;
108
234
  }
109
235
 
110
236
  case "diagnose": {
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
+
111
269
  // TASK-266: resolve run id with the priority
112
270
  // explicit positional > --run-id > --latest > last failing run.
113
271
  // The bare `zond db diagnose` is the agent-friendly default the
114
272
  // `zond-triage` skill relies on; `--latest` exists for "show me the
115
273
  // last run, even if it passed".
116
- getDb(options.dbPath);
117
274
  let id: number | null = null;
118
275
  let resolution: "explicit" | "run-id-flag" | "latest" | "latest-failing" = "explicit";
119
276
  const positionalRaw = positional[0];
@@ -151,7 +308,7 @@ export async function dbCommand(options: DbOptions): Promise<number> {
151
308
  printJson(jsonOk("db diagnose", { ...result, resolution: "latest-no-failures", run_id: fallback }, [warning]));
152
309
  } else {
153
310
  process.stderr.write(`zond: ${warning}\n`);
154
- console.log(JSON.stringify(result, null, 2));
311
+ emitPayload({ ...result, resolution: "latest-no-failures", run_id: fallback }, options.report);
155
312
  }
156
313
  return 0;
157
314
  }
@@ -166,6 +323,10 @@ export async function dbCommand(options: DbOptions): Promise<number> {
166
323
  if (json) {
167
324
  printJson(jsonOk("db diagnose", { ...result, resolution, run_id: id }));
168
325
  } else {
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.
169
330
  if (resolution !== "explicit") {
170
331
  const label = resolution === "latest-failing"
171
332
  ? `last failing run #${id}`
@@ -174,7 +335,7 @@ export async function dbCommand(options: DbOptions): Promise<number> {
174
335
  : `run #${id}`;
175
336
  process.stderr.write(`zond: diagnosing ${label}\n`);
176
337
  }
177
- console.log(JSON.stringify(result, null, 2));
338
+ emitPayload({ ...result, resolution, run_id: id }, options.report);
178
339
  }
179
340
  return 0;
180
341
  }
@@ -188,17 +349,117 @@ export async function dbCommand(options: DbOptions): Promise<number> {
188
349
  else printError(msg);
189
350
  return 2;
190
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
+ }
191
363
  const result = compareRuns(idA, idB, options.dbPath);
192
364
  if (json) {
193
365
  printJson(jsonOk("db compare", result));
194
366
  } else {
195
- 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.");
196
457
  }
197
458
  return 0;
198
459
  }
199
460
 
200
461
  default: {
201
- 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`;
202
463
  if (json) printJson(jsonError("db", [msg]));
203
464
  else printError(msg);
204
465
  return 2;
@@ -259,6 +520,7 @@ export function registerDb(program: Command): void {
259
520
  "--status <expr>",
260
521
  "Filter by HTTP status. Accepts: exact code (502), class (5xx), range (500-599), comparison (>=500, <400), or comma-separated mix (5xx,429).",
261
522
  )
523
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly run snapshot)")
262
524
  .option("--db <path>", "Path to SQLite database file")
263
525
  .action(async (id: string, opts, cmd: Command) => {
264
526
  process.exitCode = await dbCommand({
@@ -267,6 +529,7 @@ export function registerDb(program: Command): void {
267
529
  verbose: opts.verbose === true,
268
530
  method: opts.method,
269
531
  status: opts.status,
532
+ report: opts.report,
270
533
  dbPath: opts.db,
271
534
  json: globalJson(cmd),
272
535
  });
@@ -277,8 +540,11 @@ export function registerDb(program: Command): void {
277
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.")
278
541
  .option("--latest", "Diagnose the most recent run regardless of status (TASK-266)")
279
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.")
280
545
  .option("--limit <N>", "Examples per failure group", parsePositiveInt("--limit"))
281
546
  .option("--verbose", "Show all examples (not grouped)")
547
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly summary)")
282
548
  .option("--db <path>", "Path to SQLite database file")
283
549
  .action(async (id: string | undefined, opts, cmd: Command) => {
284
550
  process.exitCode = await dbCommand({
@@ -288,6 +554,9 @@ export function registerDb(program: Command): void {
288
554
  verbose: opts.verbose === true,
289
555
  latest: opts.latest === true,
290
556
  runId: opts.runId,
557
+ union: opts.union,
558
+ api: opts.api,
559
+ report: opts.report,
291
560
  dbPath: opts.db,
292
561
  json: globalJson(cmd),
293
562
  });
@@ -296,11 +565,45 @@ export function registerDb(program: Command): void {
296
565
  db
297
566
  .command("compare <idA> <idB>")
298
567
  .description("Compare two runs")
568
+ .option("--report <format>", "Output format: json (default) or yaml (ARV-338: agent-friendly diff)")
299
569
  .option("--db <path>", "Path to SQLite database file")
300
570
  .action(async (idA: string, idB: string, opts, cmd: Command) => {
301
571
  process.exitCode = await dbCommand({
302
572
  subcommand: "compare",
303
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,
304
607
  dbPath: opts.db,
305
608
  json: globalJson(cmd),
306
609
  });