@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
@@ -22,7 +22,15 @@ import {
22
22
  SPEC_SNAPSHOT_FILENAME,
23
23
  } from "../../core/setup-api.ts";
24
24
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
25
- import { printError, printSuccess } from "../output.ts";
25
+ import { printError, printSuccess, printWarning } from "../output.ts";
26
+ import { readFileSync as readFileSyncNode } from "node:fs";
27
+ import {
28
+ loadSchemaOverlay,
29
+ mergePatch,
30
+ saveSchemaOverlay,
31
+ applySchemaOverlay,
32
+ type ResponseSchemaPatch,
33
+ } from "../../core/spec/schema-overlay.ts";
26
34
 
27
35
  export interface RefreshApiOptions {
28
36
  api: string;
@@ -32,6 +40,11 @@ export interface RefreshApiOptions {
32
40
  insecure?: boolean;
33
41
  json?: boolean;
34
42
  dbPath?: string;
43
+ /** ARV-176: path to a patch.schema.json (from `schema-from-runs`) to fold
44
+ * into the response-schema overlay before rebuilding spec.json. */
45
+ mergeSchema?: string;
46
+ /** ARV-176: overwrite response schemas already declared upstream. */
47
+ force?: boolean;
35
48
  }
36
49
 
37
50
  export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number> {
@@ -92,6 +105,40 @@ export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number
92
105
  // 3. Determine baseUrl from the doc
93
106
  const baseUrl = ((doc as any).servers?.[0]?.url as string | undefined) ?? "";
94
107
 
108
+ // ARV-176: response-schema overlay. Fold a --merge-schema patch into the
109
+ // persistent overlay first, then apply the (possibly-updated) overlay onto
110
+ // the freshly-pulled doc *before* it's serialised to spec.json. Applying on
111
+ // every refresh (not only when --merge-schema is passed) is what makes the
112
+ // overlay survive an upstream re-pull (AC#2).
113
+ let schemaReport: {
114
+ merged_from?: string;
115
+ applied: string[];
116
+ preserved: string[];
117
+ conflicts: string[];
118
+ } | undefined;
119
+ if (opts.mergeSchema) {
120
+ let incoming: ResponseSchemaPatch;
121
+ try {
122
+ incoming = JSON.parse(readFileSyncNode(opts.mergeSchema, "utf-8")) as ResponseSchemaPatch;
123
+ } catch (err) {
124
+ const m = `Failed to read --merge-schema "${opts.mergeSchema}": ${(err as Error).message}`;
125
+ if (opts.json) printJson(jsonError("refresh-api", [m])); else printError(m);
126
+ return 2;
127
+ }
128
+ const merged = mergePatch(loadSchemaOverlay(baseDir), incoming);
129
+ saveSchemaOverlay(baseDir, merged);
130
+ }
131
+ const overlay = loadSchemaOverlay(baseDir);
132
+ if (overlay) {
133
+ const r = applySchemaOverlay(doc, overlay, { force: opts.force === true });
134
+ schemaReport = {
135
+ ...(opts.mergeSchema ? { merged_from: opts.mergeSchema } : {}),
136
+ applied: r.applied,
137
+ preserved: r.preserved,
138
+ conflicts: r.conflicts,
139
+ };
140
+ }
141
+
95
142
  // 4. Write spec.json + 3 artifacts
96
143
  writeArtifactsFromDoc({
97
144
  doc,
@@ -117,6 +164,7 @@ export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number
117
164
  pulledFrom: usedExternal ? specSource : null,
118
165
  endpointCount,
119
166
  artifacts: [".api-catalog.yaml", ".api-resources.yaml", ".api-fixtures.yaml"],
167
+ ...(schemaReport ? { schema_overlay: schemaReport } : {}),
120
168
  };
121
169
 
122
170
  if (opts.json) {
@@ -125,6 +173,12 @@ export async function refreshApiCommand(opts: RefreshApiOptions): Promise<number
125
173
  printSuccess(`Refreshed '${collection.name}' (${endpointCount} endpoints)${usedExternal ? ` from ${specSource}` : ""}`);
126
174
  process.stdout.write(` spec: ${localSpec}\n`);
127
175
  process.stdout.write(` artifacts: ${result.artifacts.join(", ")}\n`);
176
+ if (schemaReport) {
177
+ process.stdout.write(` schema overlay: ${schemaReport.applied.length} applied, ${schemaReport.preserved.length} preserved, ${schemaReport.conflicts.length} conflict(s)\n`);
178
+ for (const c of schemaReport.conflicts) {
179
+ printWarning(`schema overlay conflict (endpoint not in upstream, skipped): ${c}`);
180
+ }
181
+ }
128
182
  process.stdout.write(` Run \`zond doctor --api ${collection.name}\` to verify fixtures.\n`);
129
183
  }
130
184
  return 0;
@@ -156,12 +210,16 @@ export function registerRefreshApi(program: Command): void {
156
210
  .description("Re-snapshot the OpenAPI spec into apis/<name>/spec.json and regenerate the 3 artifacts (catalog/resources/fixtures)")
157
211
  .option("--spec <path>", "Pull fresh from this path or URL (overrides registered source)")
158
212
  .option("--insecure", "Allow self-signed TLS when --spec is an https URL")
213
+ .option("--merge-schema <path>", "ARV-176: fold a patch.schema.json (from `zond schema-from-runs`) into the persistent response-schema overlay (.api-schema.local.yaml) and apply it to spec.json. The overlay survives future refreshes.")
214
+ .option("--force", "ARV-176: overwrite response schemas already declared upstream (default: only fill gaps).")
159
215
  .option("--db <path>", "Path to SQLite database file")
160
216
  .action(async (name: string, opts, cmd: Command) => {
161
217
  process.exitCode = await refreshApiCommand({
162
218
  api: name,
163
219
  spec: opts.spec,
164
220
  insecure: opts.insecure === true,
221
+ mergeSchema: typeof opts.mergeSchema === "string" ? opts.mergeSchema : undefined,
222
+ force: opts.force === true,
165
223
  dbPath: typeof opts.db === "string" ? opts.db : undefined,
166
224
  json: globalJson(cmd),
167
225
  });
@@ -49,7 +49,6 @@ interface BundleEntry {
49
49
  caseStudy?: string;
50
50
  htmlReport?: string;
51
51
  diagnose?: string;
52
- agentDirective?: string | null;
53
52
  }
54
53
 
55
54
  export function parseBundleRange(input: string): number[] | { error: string } {
@@ -186,7 +185,6 @@ export async function reportBundleCommand(options: ReportBundleOptions): Promise
186
185
  const file = join(runDir, "diagnose.json");
187
186
  await Bun.write(file, JSON.stringify(diag, null, 2));
188
187
  entry.diagnose = file;
189
- entry.agentDirective = (diag as unknown as { agent_directive?: string }).agent_directive ?? null;
190
188
  }
191
189
 
192
190
  entries.push(entry);
@@ -214,7 +212,6 @@ export async function reportBundleCommand(options: ReportBundleOptions): Promise
214
212
  caseStudy: e.caseStudy ?? null,
215
213
  htmlReport: e.htmlReport ?? null,
216
214
  diagnose: e.diagnose ?? null,
217
- agentDirective: e.agentDirective ?? null,
218
215
  })),
219
216
  skipped,
220
217
  }),
@@ -284,8 +281,8 @@ function renderIndex(entries: BundleEntry[], skipped: Array<{ runId: number; rea
284
281
  lines.push(`Generated: ${new Date().toISOString()}`);
285
282
  lines.push(`Runs: ${entries.length}` + (skipped.length > 0 ? ` (skipped: ${skipped.length})` : ""));
286
283
  lines.push("");
287
- lines.push("| Run | Spec | Total | Pass | Fail | Skip | Artefacts | Directive |");
288
- lines.push("|----:|------|------:|-----:|-----:|-----:|-----------|-----------|");
284
+ lines.push("| Run | Spec | Total | Pass | Fail | Skip | Artefacts |");
285
+ lines.push("|----:|------|------:|-----:|-----:|-----:|-----------|");
289
286
 
290
287
  for (const e of entries) {
291
288
  const links: string[] = [];
@@ -293,7 +290,7 @@ function renderIndex(entries: BundleEntry[], skipped: Array<{ runId: number; rea
293
290
  if (e.htmlReport) links.push(`[html](${rel(e.htmlReport)})`);
294
291
  if (e.diagnose) links.push(`[diagnose](${rel(e.diagnose)})`);
295
292
  lines.push(
296
- `| ${e.runId} | ${e.spec ?? "—"} | ${e.totals.total} | ${e.totals.passed} | ${e.totals.failed} | ${e.totals.skipped} | ${links.join(" · ") || "—"} | ${truncate(e.agentDirective ?? "", 80)} |`,
293
+ `| ${e.runId} | ${e.spec ?? "—"} | ${e.totals.total} | ${e.totals.passed} | ${e.totals.failed} | ${e.totals.skipped} | ${links.join(" · ") || "—"} |`,
297
294
  );
298
295
  }
299
296
 
@@ -311,8 +308,3 @@ function rel(p: string): string {
311
308
  const tail = seg.slice(-2);
312
309
  return tail.join("/");
313
310
  }
314
-
315
- function truncate(s: string, n: number): string {
316
- if (s.length <= n) return s.replace(/\n/g, " ");
317
- return s.slice(0, n - 1).replace(/\n/g, " ") + "…";
318
- }
@@ -9,6 +9,7 @@ import { resolveCollectionSpec } from "../../core/setup-api.ts";
9
9
  import { findCollectionByNameOrId } from "../../db/queries.ts";
10
10
  import { getDb } from "../../db/schema.ts";
11
11
  import type { AssertionResult } from "../../core/runner/types.ts";
12
+ import { applyEnvWrites } from "./fixtures.ts";
12
13
 
13
14
  // TASK-272: when the request fails authentication (401/403) and the user
14
15
  // did NOT pass `--api <name>`, surface a one-liner pointing at auto-auth via
@@ -86,6 +87,10 @@ export interface RequestOptions {
86
87
  env?: string;
87
88
  api?: string;
88
89
  jsonPath?: string;
90
+ /** ARV-355: write the --json-path scalar into apis/<api>/.env.yaml as this
91
+ * var, so a POST-create's returned id lands in a fixture in one command
92
+ * (the "execute create+capture" primitive of the agent seed loop). */
93
+ capture?: string;
89
94
  dbPath?: string;
90
95
  json?: boolean;
91
96
  /** TASK-142: validate the response body against the OpenAPI response schema. */
@@ -109,6 +114,18 @@ interface SchemaValidationOutcome {
109
114
 
110
115
  export async function requestCommand(options: RequestOptions): Promise<number> {
111
116
  try {
117
+ // ARV-355: --capture needs a value to extract (--json-path) and a target
118
+ // .env.yaml (--api). Fail fast with an actionable message rather than
119
+ // silently no-op'ing mid-seed-loop.
120
+ if (options.capture && (!options.jsonPath || !options.api)) {
121
+ printError(
122
+ "--capture requires --json-path <path> (the field to extract) and --api <name> " +
123
+ "(the .env.yaml to write). E.g. `zond request POST /v1/accounts --api stripe " +
124
+ "--json-path id --capture account`.",
125
+ );
126
+ return 2;
127
+ }
128
+
112
129
  const headers: Record<string, string> = {};
113
130
  if (options.headers) {
114
131
  for (const h of options.headers) {
@@ -141,6 +158,20 @@ export async function requestCommand(options: RequestOptions): Promise<number> {
141
158
  form: useForm,
142
159
  });
143
160
 
161
+ // ARV-265 (B3): persist this ad-hoc call into runs/results when a
162
+ // session is active, so `zond coverage --scope audit` attributes it.
163
+ // No session → no DB write (mirrors `curl`-replacement intent).
164
+ await maybePersistAuditedRequest({
165
+ options,
166
+ method: options.method.toUpperCase(),
167
+ url: options.url,
168
+ headers,
169
+ body: options.body,
170
+ result,
171
+ }).catch((err) => {
172
+ process.stderr.write(`zond: audit persistence failed (${(err as Error).message}).\n`);
173
+ });
174
+
144
175
  let validation: SchemaValidationOutcome | null = null;
145
176
  if (options.validateSchema || options.validateAgainst) {
146
177
  validation = await runSchemaValidation(options, result);
@@ -173,6 +204,33 @@ export async function requestCommand(options: RequestOptions): Promise<number> {
173
204
  if (validation) printSchemaValidation(validation);
174
205
  }
175
206
 
207
+ // ARV-355: create+capture. Write the extracted json-path scalar into the
208
+ // api's .env.yaml so the agent seed loop can chain a created id into
209
+ // dependent fixtures with one command. Deterministic plumbing only — the
210
+ // agent decides the body/endpoint/var; zond just POSTs and captures.
211
+ // Gated to a 2xx response (capturing an id from an error body is a bug —
212
+ // the agent reads the note, revises the body, and retries).
213
+ if (options.capture) {
214
+ const v = result.body;
215
+ const ok2xx = result.status >= 200 && result.status < 300;
216
+ const scalar =
217
+ typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : null;
218
+ if (!ok2xx || scalar === null || scalar.length === 0) {
219
+ process.stderr.write(
220
+ `zond: --capture ${options.capture} skipped — need a non-empty scalar from a 2xx response ` +
221
+ `(status=${result.status}, --json-path '${options.jsonPath}' → ${scalar === null ? "non-scalar/null" : "empty"}). ` +
222
+ `Revise the request and retry.\n`,
223
+ );
224
+ } else {
225
+ const envPath = join(`apis/${options.api}`, ".env.yaml");
226
+ const { backup } = await applyEnvWrites(envPath, { [options.capture]: scalar });
227
+ process.stderr.write(
228
+ `zond: captured ${options.capture}=${scalar} → ${envPath}` +
229
+ (backup ? ` (backup: ${backup})` : "") + "\n",
230
+ );
231
+ }
232
+ }
233
+
176
234
  // TASK-272: post-response auto-auth hint on 401/403 without --api
177
235
  if (
178
236
  !options.json
@@ -211,6 +269,56 @@ export async function requestCommand(options: RequestOptions): Promise<number> {
211
269
  }
212
270
  }
213
271
 
272
+ // ──────────────────────────────────────────────
273
+ // ARV-265 (B3): persist an ad-hoc `zond request` call into runs/results
274
+ // so `zond coverage --scope audit` sees the HTTP touch. Only fires when
275
+ // a session is active — outside a session, the command stays a pure
276
+ // curl-replacement and the DB doesn't grow with one-off requests.
277
+ // ──────────────────────────────────────────────
278
+
279
+ async function maybePersistAuditedRequest(args: {
280
+ options: RequestOptions;
281
+ method: string;
282
+ url: string;
283
+ headers: Record<string, string>;
284
+ body: string | undefined;
285
+ result: { status: number; headers: Record<string, string>; body: unknown; duration_ms: number };
286
+ }): Promise<void> {
287
+ const { readCurrentSession } = await import("../../core/context/session.ts");
288
+ const session = readCurrentSession();
289
+ if (!session) return; // B3: outside-session calls leave no trace.
290
+
291
+ const { checksPersistEnabled, beginAuditRun, finalizeAuditRun } =
292
+ await import("../../core/audit/persist.ts");
293
+ if (!checksPersistEnabled()) return;
294
+
295
+ getDb(args.options.dbPath);
296
+ const collectionId = args.options.api ? findCollectionByNameOrId(args.options.api)?.id : undefined;
297
+ const runId = beginAuditRun({
298
+ runKind: "request",
299
+ ...(collectionId != null ? { collectionId } : {}),
300
+ sessionId: session.id,
301
+ tags: ["request", "ad-hoc"],
302
+ });
303
+ const status = args.result.status >= 200 && args.result.status < 400 ? "pass" : "fail";
304
+ finalizeAuditRun(runId, [
305
+ {
306
+ suiteName: "request/ad-hoc",
307
+ suiteFile: `apis/${args.options.api ?? "_"}/request.yaml`,
308
+ testName: `request::${args.method} ${args.url}`,
309
+ status,
310
+ request: { method: args.method, url: args.url, headers: args.headers, body: args.body },
311
+ response: {
312
+ status: args.result.status,
313
+ headers: args.result.headers,
314
+ body: typeof args.result.body === "string" ? args.result.body : JSON.stringify(args.result.body ?? ""),
315
+ duration_ms: args.result.duration_ms,
316
+ },
317
+ durationMs: args.result.duration_ms,
318
+ },
319
+ ]);
320
+ }
321
+
214
322
  // ──────────────────────────────────────────────
215
323
  // TASK-142: --validate-schema / --validate-against
216
324
  // ──────────────────────────────────────────────
@@ -398,6 +506,13 @@ export function registerRequest(program: Command): void {
398
506
  "(`id=$(zond request --json-path data.id ...)`), objects/arrays as compact JSON. " +
399
507
  "With --json, embeds the extracted value as the envelope's `body` field.",
400
508
  )
509
+ .option(
510
+ "--capture <var>",
511
+ "ARV-355: write the --json-path scalar into apis/<api>/.env.yaml as <var> " +
512
+ "(requires --json-path and --api). Captures a POST-create's returned id into " +
513
+ "a fixture in one command, so the agent seed loop can chain dependent creates. " +
514
+ "Only writes on a 2xx response; a .env.yaml.bak backup is made.",
515
+ )
401
516
  .option("--db <path>", "Path to SQLite database file")
402
517
  .option("--validate-schema", "TASK-142: validate the response body against the OpenAPI response schema (requires --api). Endpoint is auto-resolved from the request method + URL.path; templated paths like /users/{id} are matched via regex. Falls back gracefully if no endpoint matches — pass --validate-against to override.")
403
518
  .option("--validate-against <method:path>", "TASK-142: explicit endpoint override for --validate-schema, e.g. \"GET:/users/{id}\". Use the spec template form (with \"{...}\" placeholders).")
@@ -422,6 +537,7 @@ export function registerRequest(program: Command): void {
422
537
  env: opts.env,
423
538
  api,
424
539
  jsonPath: opts.jsonPath,
540
+ capture: opts.capture,
425
541
  dbPath: opts.db,
426
542
  json: globalJson(cmd),
427
543
  validateSchema: opts.validateSchema === true || typeof opts.validateAgainst === "string",
@@ -189,6 +189,24 @@ export async function runCommand(options: RunOptions): Promise<number> {
189
189
  return 2;
190
190
  }
191
191
  printWarning(`No test files found in ${pathList}`);
192
+ // ARV-357: an empty dir used to exit 0 and write NO --output file, so a
193
+ // scripted pipeline saw a missing file with no error to key on. Still
194
+ // write the requested report (empty JSON `[]` / junit tests=0 envelope)
195
+ // so downstream stages parse "zero tests" instead of silently skipping.
196
+ if (options.output) {
197
+ try {
198
+ const spec = resolveOutput(RUN_OUTPUT_SPEC, { report: options.report, output: options.output });
199
+ if (spec.channel === "file") {
200
+ const content = spec.format === "junit" ? generateJunitXml([]) : generateJsonReport([]);
201
+ await mkdir(pathDirname(spec.path!), { recursive: true });
202
+ await writeFile(spec.path!, content, "utf-8");
203
+ process.stderr.write(`zond: empty report written to ${spec.path!} (0 tests)\n`);
204
+ }
205
+ } catch (err) {
206
+ printError(`Failed to write --output file ${options.output}: ${(err as Error).message}`);
207
+ return 2;
208
+ }
209
+ }
192
210
  return 0;
193
211
  }
194
212
 
@@ -447,9 +465,11 @@ export async function runCommand(options: RunOptions): Promise<number> {
447
465
  }
448
466
 
449
467
  // TASK-282: validate --learn flag combinations early (before run).
468
+ // ARV-199: --learn-apply implies --learn — cf. `git stash apply` (no
469
+ // explicit `git stash` toggle required). The earlier hard error here
470
+ // tripped up scripted callers that only thought to pass --learn-apply.
450
471
  if (options.learnApply && !options.learn) {
451
- printError("--learn-apply requires --learn");
452
- return 2;
472
+ options.learn = true;
453
473
  }
454
474
  if (options.learnApply && !options.learnTarget) {
455
475
  printError("--learn-apply requires --learn-target=test or --learn-target=drifts");
@@ -830,8 +850,16 @@ export async function runCommand(options: RunOptions): Promise<number> {
830
850
  // --json so the JSON envelope stays alone on stdout (this is stderr
831
851
  // anyway, but skip avoids confusing parsers that capture both streams).
832
852
  if (hasFailures && !options.json) {
833
- const total = results.reduce((s, r) => s + r.failed, 0);
834
- process.stderr.write(`zond: ${total} test step(s) failed — exiting with code 1 (pass --no-fail-on-failures to suppress, e.g. for advisory runs).\n`);
853
+ // ARV-318: error steps (env_issue/network couldn't execute) also drive
854
+ // the non-zero exit (see hasFailures above). The old line counted only
855
+ // `failed`, so an all-errored run printed "0 test step(s) failed —
856
+ // exiting with code 1", a contradiction. Name both classes.
857
+ const failed = results.reduce((s, r) => s + r.failed, 0);
858
+ const errored = results.reduce((s, r) => s + r.steps.filter((st) => st.status === "error").length, 0);
859
+ const parts: string[] = [];
860
+ if (failed > 0) parts.push(`${failed} step(s) failed`);
861
+ if (errored > 0) parts.push(`${errored} step(s) errored (couldn't execute — see failure_class)`);
862
+ process.stderr.write(`zond: ${parts.join(", ")} — exiting with code 1 (pass --no-fail-on-failures to suppress, e.g. for advisory runs).\n`);
835
863
  }
836
864
 
837
865
  if (hasFailures && options.failOnFailures === false) {
@@ -1000,7 +1028,7 @@ export function registerRun(program: Command): void {
1000
1028
  .option("--spec <path>", "Path or URL to OpenAPI spec used for --validate-schema (overrides the collection's openapi_spec)")
1001
1029
  .option("--session-id <id>", "Group this run under a session. Resolution order: --session-id flag > ZOND_SESSION_ID env > .zond/current-session file (set by 'zond session start')")
1002
1030
  .option("--learn", "TASK-282: detect status-code drift (passing-test-but-wrong-status). Implies --validate-schema. Without --learn-apply prints the plan; combine with --learn-apply --learn-target=test|drifts to mutate.")
1003
- .option("--learn-apply", "Apply the drift plan instead of printing it. Requires --learn and --learn-target.")
1031
+ .option("--learn-apply", "Apply the drift plan instead of printing it. Implies --learn; still requires --learn-target=test|drifts.")
1004
1032
  .addOption(
1005
1033
  new Option("--learn-target <where>", "What to mutate when --learn-apply is set: rewrite expect.status in YAML (test) or append to apis/<name>/tolerated-drifts.yaml (drifts)")
1006
1034
  .choices(["test", "drifts"]),
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ARV-175: `zond schema-from-runs` — mine 2xx response bodies from a
3
+ * persisted run and emit `patch.schema.json` (inferred JSON Schema per
4
+ * endpoint+status). Pairs with `refresh-api --merge-schema` (ARV-176).
5
+ */
6
+ import { writeFile } from "node:fs/promises";
7
+ import type { Command } from "commander";
8
+ import { readOpenApiSpec, extractEndpoints } from "../../core/generator/index.ts";
9
+ import { schemaFromRuns, type ResultRow } from "../../core/spec/schema-from-runs.ts";
10
+ import { getResultsByRunId, getLatestRunId } from "../../db/queries.ts";
11
+ import { getDb } from "../../db/schema.ts";
12
+ import { resolveApiCollection, globalJson } from "../resolve.ts";
13
+ import { getApi } from "../util/api-context.ts";
14
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
15
+ import { printError, printSuccess, printWarning } from "../output.ts";
16
+ import { parsePositiveInt } from "../argv.ts";
17
+
18
+ export interface SchemaFromRunsCliOptions {
19
+ api?: string;
20
+ spec?: string;
21
+ db?: string;
22
+ run?: number;
23
+ minSamples?: number;
24
+ out?: string;
25
+ engine?: string;
26
+ json?: boolean;
27
+ }
28
+
29
+ export async function schemaFromRunsCommand(
30
+ opts: SchemaFromRunsCliOptions,
31
+ cmd: Command,
32
+ ): Promise<number> {
33
+ const json = opts.json === true || globalJson(cmd);
34
+ const label = "schema-from-runs";
35
+
36
+ // ARV-175 AC#2: only `builtin` is wired. quicktype/genson would each pull
37
+ // a large dependency tree, which contradicts zond's minimal-deps charter.
38
+ const engine = (opts.engine ?? "builtin").toLowerCase();
39
+ if (engine !== "builtin") {
40
+ const msg = `--engine "${opts.engine}" is not available. Only 'builtin' is wired (quicktype/genson would add heavy deps; see src/CLAUDE.md).`;
41
+ if (json) printJson(jsonError(label, [msg])); else printError(msg);
42
+ return 2;
43
+ }
44
+
45
+ // Resolve spec: --spec wins, else --api / current-api collection.
46
+ let spec = opts.spec;
47
+ const apiFlag = opts.spec ? opts.api : getApi(cmd, opts as Record<string, unknown>);
48
+ if (!spec && apiFlag) {
49
+ const resolved = resolveApiCollection(apiFlag, opts.db);
50
+ if ("error" in resolved) {
51
+ if (json) printJson(jsonError(label, [resolved.error])); else printError(resolved.error);
52
+ return resolved.error.startsWith("Failed") ? 2 : 1;
53
+ }
54
+ if (resolved.spec) spec = resolved.spec;
55
+ }
56
+ if (!spec) {
57
+ const msg = "No spec: pass --spec <path> or --api <name> (or set a current API).";
58
+ if (json) printJson(jsonError(label, [msg])); else printError(msg);
59
+ return 2;
60
+ }
61
+
62
+ getDb(opts.db);
63
+ const runId = opts.run ?? getLatestRunId();
64
+ if (runId == null) {
65
+ const msg = "No runs in the database. Run `zond run` / `zond checks run` first, then re-run schema-from-runs.";
66
+ if (json) printJson(jsonError(label, [msg])); else printError(msg);
67
+ return 2;
68
+ }
69
+
70
+ let doc;
71
+ try {
72
+ doc = await readOpenApiSpec(spec);
73
+ } catch (err) {
74
+ const msg = `Failed to read spec at "${spec}": ${(err as Error).message}`;
75
+ if (json) printJson(jsonError(label, [msg])); else printError(msg);
76
+ return 2;
77
+ }
78
+ const endpoints = extractEndpoints(doc);
79
+
80
+ const rows = getResultsByRunId(runId) as unknown as ResultRow[];
81
+ const minSamples = opts.minSamples ?? 2;
82
+ const result = schemaFromRuns({ results: rows, endpoints, minSamples });
83
+
84
+ const emitted = result.groups.filter((g) => g.emitted);
85
+ const skipped = result.groups.filter((g) => !g.emitted);
86
+ const out = opts.out ?? "patch.schema.json";
87
+
88
+ if (emitted.length > 0) {
89
+ await writeFile(out, JSON.stringify(result.patch, null, 2) + "\n", "utf-8");
90
+ }
91
+
92
+ if (json) {
93
+ printJson(jsonOk(label, {
94
+ run_id: runId,
95
+ out: emitted.length > 0 ? out : null,
96
+ min_samples: minSamples,
97
+ emitted: emitted.map((g) => ({ endpoint: g.endpoint, status: g.status, samples: g.samples })),
98
+ skipped: skipped.map((g) => ({ endpoint: g.endpoint, status: g.status, samples: g.samples, reason: g.reason })),
99
+ }));
100
+ return 0;
101
+ }
102
+
103
+ if (emitted.length === 0) {
104
+ printWarning(`No schemas emitted from run #${runId}: no (endpoint, status) group reached --min-samples ${minSamples}. ${skipped.length} group(s) had too few 2xx JSON samples.`);
105
+ return 0;
106
+ }
107
+ printSuccess(`Wrote ${emitted.length} response schema(s) from run #${runId} to ${out}`);
108
+ for (const g of emitted) console.log(` ${g.endpoint} → ${g.status} (${g.samples} samples)`);
109
+ for (const g of skipped) printWarning(`skipped ${g.endpoint} → ${g.status}: ${g.reason} (${g.samples} sample(s))`);
110
+ console.log(`\nNext: zond refresh-api --api <name> --merge-schema ${out}`);
111
+ return 0;
112
+ }
113
+
114
+ export function registerSchemaFromRuns(program: Command): void {
115
+ program
116
+ .command("schema-from-runs")
117
+ .description("ARV-175: infer response JSON Schemas from a run's 2xx bodies → patch.schema.json (pairs with refresh-api --merge-schema). Revives response_schema_conformance on specs that declare no response schemas.")
118
+ .option("--api <name>", "Registered API (spec + DB lookup)")
119
+ .option("--spec <path>", "Explicit OpenAPI spec path (overrides --api)")
120
+ .option("--run <id>", "Run id to mine (default: latest)", parsePositiveInt("--run"))
121
+ .option("--min-samples <n>", "Minimum 2xx samples per endpoint+status to emit (default 2)", parsePositiveInt("--min-samples"))
122
+ .option("--out <path>", "Output path for patch.schema.json (default: patch.schema.json)")
123
+ .option("--engine <name>", "Schema engine. Only 'builtin' is wired (default).", "builtin")
124
+ .option("--db <path>", "SQLite path")
125
+ .action(async (opts, cmd: Command) => {
126
+ process.exitCode = await schemaFromRunsCommand(opts, cmd);
127
+ });
128
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `zond secrets set <key> <value>` — write a raw secret into
3
+ * `apis/<name>/.secrets.yaml` (ARV-377). Before this, rotating an expired
4
+ * token meant hand-editing the gitignored file (or a python script), since
5
+ * `zond fixtures add` only writes `.env.yaml` and the iron rules forbid an
6
+ * agent reading `.secrets.yaml` directly.
7
+ *
8
+ * Two safety properties:
9
+ * • the value is NEVER echoed back (stdout, stderr, or JSON envelope) —
10
+ * only the key name and whether a Bearer prefix was stripped;
11
+ * • a `.secrets.yaml.bak` backup is written before the file is touched,
12
+ * mirroring `fixtures add --apply`.
13
+ *
14
+ * Bearer trap (ARV-367/AC3): `.secrets.yaml` holds the RAW token — zond's
15
+ * runner adds the scheme prefix itself (`Authorization: Bearer <token>`).
16
+ * A pasted `Bearer eyJ…` is therefore always a mistake, so we strip a
17
+ * leading `Bearer ` (case-insensitive) and warn once. `--literal` keeps the
18
+ * value verbatim for the rare case someone really means to store it.
19
+ */
20
+ import type { Command } from "commander";
21
+ import { join } from "node:path";
22
+ import { copyFile } from "node:fs/promises";
23
+
24
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
25
+ import { resolveApiCollection } from "../resolve.ts";
26
+ import { upsertEnvLine } from "./discover.ts";
27
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
28
+ import { printError, printSuccess } from "../output.ts";
29
+ import { globalJson } from "../resolve.ts";
30
+
31
+ interface SecretsSetOptions {
32
+ api?: string;
33
+ literal?: boolean;
34
+ json?: boolean;
35
+ }
36
+
37
+ const BEARER_PREFIX_RE = /^\s*Bearer\s+/i;
38
+
39
+ /** Normalise a pasted secret value: strip a leading `Bearer ` prefix (unless
40
+ * `literal`) so `.secrets.yaml` holds the raw token zond's runner expects.
41
+ * Exported for unit tests. */
42
+ export function normalizeSecretValue(raw: string, literal?: boolean): { value: string; bearerStripped: boolean } {
43
+ if (!literal && BEARER_PREFIX_RE.test(raw)) {
44
+ return { value: raw.replace(BEARER_PREFIX_RE, ""), bearerStripped: true };
45
+ }
46
+ return { value: raw, bearerStripped: false };
47
+ }
48
+
49
+ /** Upsert one `key: "value"` line into `.secrets.yaml`, backing up first.
50
+ * Exported for unit tests. */
51
+ export async function applySecretWrite(
52
+ secretsPath: string,
53
+ key: string,
54
+ value: string,
55
+ ): Promise<{ backup: string | null }> {
56
+ const file = Bun.file(secretsPath);
57
+ const exists = await file.exists();
58
+ let text = exists ? await file.text() : "";
59
+ let backup: string | null = `${secretsPath}.bak`;
60
+ if (exists) {
61
+ try { await copyFile(secretsPath, backup); } catch { backup = null; }
62
+ } else {
63
+ backup = null;
64
+ }
65
+ text = upsertEnvLine(text, key, value);
66
+ if (!text.endsWith("\n")) text += "\n";
67
+ await Bun.write(secretsPath, text);
68
+ return { backup };
69
+ }
70
+
71
+ async function setAction(key: string, valueParts: string[], cmd: Command): Promise<void> {
72
+ const opts = cmd.opts<SecretsSetOptions>();
73
+ const json = opts.json === true || globalJson(cmd);
74
+
75
+ const fail = (m: string, code = 2) => {
76
+ if (json) printJson(jsonError("secrets set", [m])); else printError(m);
77
+ process.exit(code);
78
+ };
79
+
80
+ const apiName = getApi(cmd, { api: opts.api } as Record<string, unknown>);
81
+ if (!apiName) return fail(MISSING_API_MESSAGE);
82
+ const col = resolveApiCollection(apiName, undefined);
83
+ if ("error" in col) return fail(col.error);
84
+ if (!col.baseDir) return fail(`API '${apiName}' has no base_dir registered.`);
85
+
86
+ const k = key.trim();
87
+ if (!k) return fail("Secret key must not be empty. Usage: zond secrets set <key> <value>");
88
+
89
+ // Join variadic value parts so an unquoted `Bearer eyJ…` paste (two argv
90
+ // tokens) still lands as one value instead of dropping the token.
91
+ const rawValue = valueParts.join(" ");
92
+ const { value, bearerStripped } = normalizeSecretValue(rawValue, opts.literal);
93
+ const warnings: string[] = [];
94
+ if (bearerStripped) {
95
+ warnings.push(
96
+ "Stripped a leading 'Bearer ' prefix — .secrets.yaml stores the raw token; zond adds the scheme prefix itself. Pass --literal to keep it verbatim.",
97
+ );
98
+ }
99
+
100
+ const secretsPath = join(col.baseDir, ".secrets.yaml");
101
+ const { backup } = await applySecretWrite(secretsPath, k, value);
102
+
103
+ // NEVER echo the value — key + metadata only.
104
+ if (json) {
105
+ printJson(jsonOk("secrets set", {
106
+ api: apiName,
107
+ secrets: secretsPath,
108
+ key: k,
109
+ bearer_stripped: bearerStripped,
110
+ backup,
111
+ }, warnings));
112
+ } else {
113
+ printSuccess(`Set secret '${k}' in ${secretsPath}` + (backup ? ` (backup: ${backup})` : ""));
114
+ process.stdout.write(" (value not echoed)\n");
115
+ for (const w of warnings) process.stderr.write(`Warning: ${w}\n`);
116
+ }
117
+ process.exit(0);
118
+ }
119
+
120
+ export function registerSecrets(program: Command): void {
121
+ const secrets = program
122
+ .command("secrets")
123
+ .description("Manage apis/<name>/.secrets.yaml (gitignored raw secret values, TASK-170). Values are never echoed back.");
124
+
125
+ secrets
126
+ .command("set <key> <value...>")
127
+ .description("Write a secret to .secrets.yaml (with .secrets.yaml.bak backup). Auto-strips a pasted 'Bearer ' prefix unless --literal. Never prints the value.")
128
+ .option("--api <name>", "Registered API (apis/<name>/.secrets.yaml). Falls back to ZOND_API / .zond/current-api.")
129
+ .option("--literal", "Store the value verbatim; do not strip a leading 'Bearer ' prefix.")
130
+ .action(async (key: string, value: string[], _opts, cmd: Command) => {
131
+ await setAction(key, value, cmd);
132
+ });
133
+ }