@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
@@ -16,8 +16,9 @@ import type { Command } from "commander";
16
16
 
17
17
  import { listChecks, runChecks } from "../../core/checks/index.ts";
18
18
  import { listStatefulChecks } from "../../core/checks/stateful.ts";
19
+ import { resolveBudget, isBudget, BUDGETS, type Budget } from "../../core/checks/budget.ts";
19
20
  import { generateSarifReport } from "../../core/checks/sarif.ts";
20
- import { emitToStdout } from "../../core/reporter/ndjson.ts";
21
+ import { emitToStdout, nowIso } from "../../core/reporter/ndjson.ts";
21
22
  import { parseWorkers } from "../../core/runner/async-pool.ts";
22
23
  import { createAdaptiveRateLimiter, createRateLimiter, type RateLimiter } from "../../core/runner/rate-limiter.ts";
23
24
  import { compileOperationFilter } from "../../core/selectors/operation-filter.ts";
@@ -26,11 +27,17 @@ import { readResourceMap } from "./discover.ts";
26
27
  import type { ReadbackDiffConfig, IdempotencyConfig, PaginationConfig, LifecycleConfig, SeedBodyConfig } from "../../core/generator/resources-builder.ts";
27
28
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
28
29
  import { printError, printSuccess } from "../output.ts";
30
+ import { SAFE_HELP, LIVE_HELP, resolveLive } from "../safe-live.ts";
29
31
  import { loadEnvironment } from "../../core/parser/variables.ts";
32
+ import { readFixtureGaps, gapIndex } from "../../core/workspace/fixture-gaps.ts";
30
33
  import { getApi } from "../util/api-context.ts";
31
34
  import { VERSION } from "../version.ts";
32
35
  import { resolveOutput, OutputSpecError, type OutputSpec, type ResolvedOutput } from "../../core/output/index.ts";
33
36
  import type { RunChecksResult } from "../../core/checks/index.ts";
37
+ import type { ChecksCaseEvent } from "../../core/checks/runner.ts";
38
+ import { beginAuditRun, finalizeAuditRun, checksPersistEnabled, type AuditCaseRecord } from "../../core/audit/persist.ts";
39
+ import { readCurrentSession } from "../../core/context/session.ts";
40
+ import { getDb } from "../../db/schema.ts";
34
41
 
35
42
  /**
36
43
  * ARV-118 (m-19): typed declaration of every `--report` / `--output` /
@@ -119,6 +126,21 @@ interface ChecksRunOptions {
119
126
  strict405?: boolean;
120
127
  strict401?: boolean;
121
128
  maxRequests?: number;
129
+ /** ARV-342: operation-window for bounded/resumable sweeps of large specs. */
130
+ maxOps?: number;
131
+ skipOps?: number;
132
+ budget?: string;
133
+ showSuppressed?: boolean;
134
+ /** ARV-308: `--no-fail-on-findings` sets this to false (commander default
135
+ * true) — keep exit 0 even when HIGH/CRITICAL findings exist so an
136
+ * orchestrator can distinguish "found drift" from "command failed". */
137
+ failOnFindings?: boolean;
138
+ /** ARV-308: `--advisory` alias for --no-fail-on-findings. */
139
+ advisory?: boolean;
140
+ /** ARV-299: safe/live parity with `audit`. Default safe — mutating
141
+ * stateful create-chains self-skip; `--live` runs them. */
142
+ safe?: boolean;
143
+ live?: boolean;
122
144
  }
123
145
 
124
146
  function parseAuthHeaders(values: string[] | undefined): Record<string, string> {
@@ -188,6 +210,8 @@ async function deriveResourceConfigsFromApi(
188
210
  limitParam: r.pagination.limit_param,
189
211
  defaultLimit: r.pagination.default_limit,
190
212
  itemsField: r.pagination.items_field,
213
+ pageParam: r.pagination.page_param,
214
+ startPage: r.pagination.start_page,
191
215
  };
192
216
  }
193
217
  if (r.lifecycle) {
@@ -240,6 +264,19 @@ async function derivePathVarsFromApi(apiName: string | undefined, dbPath: string
240
264
  }
241
265
  }
242
266
 
267
+ /** ARV-324: load `.fixture-gaps.yaml` (written by a prior prepare-fixtures/
268
+ * discover run) so findings on a known-empty/inaccessible operation get
269
+ * `fix_fixture` instead of `report_backend_bug`. Undefined (not an empty
270
+ * Set) when there's no API context or no gaps file, so the classifier
271
+ * can tell "nothing to check" apart from "checked, no gaps". */
272
+ async function deriveFixtureGapsFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Set<string> | undefined> {
273
+ if (!apiName) return undefined;
274
+ const col = resolveApiCollection(apiName, dbPath);
275
+ if ("error" in col || !col.baseDir) return undefined;
276
+ const gaps = await readFixtureGaps(col.baseDir);
277
+ return gaps.length > 0 ? gapIndex(gaps) : undefined;
278
+ }
279
+
243
280
  async function deriveAuthHeadersFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Record<string, string>> {
244
281
  if (!apiName) return {};
245
282
  const col = resolveApiCollection(apiName, dbPath);
@@ -338,9 +375,22 @@ function splitList(values: string[] | undefined): string[] | undefined {
338
375
  // instead of hand-listing cross_call_references, idempotency_replay, … —
339
376
  // matching the prior `--phase stateful` UX promise without overloading the
340
377
  // case-generation `--phase` flag.
341
- function expandStatefulAlias(ids: string[] | undefined): string[] | undefined {
378
+ // ARV-325: `ignored_auth` / `open_cors_on_sensitive` live in the stateful
379
+ // *registry* (they need the stateful harness to run), but semantically they
380
+ // are auth/security checks. Users reading `--check stateful` expect
381
+ // state-machine probes, not a full security pass — on Stripe the pair added
382
+ // ~520 extra cases and turned a sub-minute run into ~10 minutes. Keep them
383
+ // runnable by explicit id; just don't smuggle them in through the alias.
384
+ const STATEFUL_ALIAS_EXCLUDED: ReadonlySet<string> = new Set([
385
+ "ignored_auth",
386
+ "open_cors_on_sensitive",
387
+ ]);
388
+
389
+ export function expandStatefulAlias(ids: string[] | undefined): string[] | undefined {
342
390
  if (!ids) return ids;
343
- const statefulIds = listStatefulChecks().map((c) => c.id);
391
+ const statefulIds = listStatefulChecks()
392
+ .map((c) => c.id)
393
+ .filter((id) => !STATEFUL_ALIAS_EXCLUDED.has(id));
344
394
  const out: string[] = [];
345
395
  for (const id of ids) {
346
396
  if (id === "stateful") out.push(...statefulIds);
@@ -432,6 +482,7 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
432
482
  // rounds and CI can't distinguish "spec stable" from "checks ignored deltas").
433
483
  const pathVars = await derivePathVarsFromApi(apiName, opts.db);
434
484
  const resourceConfigs = await deriveResourceConfigsFromApi(apiName, opts.db);
485
+ const fixtureGaps = await deriveFixtureGapsFromApi(apiName, opts.db);
435
486
 
436
487
  const phaseRaw = typeof opts.phase === "string" ? opts.phase : "examples";
437
488
  if (phaseRaw !== "examples" && phaseRaw !== "coverage" && phaseRaw !== "all") {
@@ -503,27 +554,179 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
503
554
  const ndjsonOutputPath: string | undefined = ndjson && resolved.channel === "file" ? resolved.path : undefined;
504
555
  let ndjsonFd: number | undefined;
505
556
  let ndjsonEventCount = 0;
557
+ let ndjsonLastFullyWritten = true;
506
558
  if (ndjsonOutputPath) {
507
559
  ndjsonFd = openSync(ndjsonOutputPath, "w");
508
560
  }
561
+ // ARV-343: accumulate a running summary from the stream so a SIGTERM'd
562
+ // run still emits a terminal `summary` line (operations/cases counted so
563
+ // far) instead of dying before the runner's emit stage — triage's
564
+ // `sweepWindows`/status-dist read `.summary.operations` and had to
565
+ // reconstruct it from raw NDJSON when the window was killed mid-flight.
566
+ const partialOps = new Set<string>();
567
+ const partialChecks = new Set<string>();
568
+ let partialCases = 0;
569
+ let partialFindings = 0;
570
+ const partialBySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
571
+ const accumulate = (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
572
+ if (ev.type === "check_result") {
573
+ partialCases += 1;
574
+ partialChecks.add(ev.check);
575
+ partialOps.add(`${ev.operation.method} ${ev.operation.path}`);
576
+ } else if (ev.type === "check_start") {
577
+ partialOps.add(`${ev.operation.method} ${ev.operation.path}`);
578
+ } else if (ev.type === "finding") {
579
+ partialFindings += 1;
580
+ const sev = ev.finding.severity as keyof typeof partialBySeverity;
581
+ if (sev in partialBySeverity) partialBySeverity[sev] += 1;
582
+ }
583
+ };
509
584
  const ndjsonOnEvent = ndjson
510
585
  ? (ndjsonFd !== undefined
511
586
  ? (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
587
+ accumulate(ev);
588
+ ndjsonLastFullyWritten = false;
512
589
  ndjsonEventCount += 1;
513
590
  writeSync(ndjsonFd!, `${JSON.stringify(ev)}\n`);
591
+ ndjsonLastFullyWritten = true;
514
592
  }
515
- : emitToStdout)
593
+ // ARV-323: count stdout-channel events too — the SIGTERM handler
594
+ // reports `ndjsonEventCount`, and with a shell-redirected stdout
595
+ // stream it claimed "0 event(s) flushed" while the redirect target
596
+ // already held thousands of lines.
597
+ : (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
598
+ accumulate(ev);
599
+ ndjsonEventCount += 1;
600
+ emitToStdout(ev);
601
+ })
516
602
  : undefined;
517
603
 
604
+ // ARV-230: clean SIGTERM/SIGINT shutdown for NDJSON streams. Without
605
+ // this, killing a long `checks run --report ndjson` leaves a truncated
606
+ // last line in the file (or in the consumer's pipe) and downstream
607
+ // `jq -s` chokes until the user strips it with `sed '$d'`. The handler
608
+ // closes the fd (or drains stdout) and exits with the conventional
609
+ // 128+signo code instead of letting Node default-terminate mid-write.
610
+ let removeNdjsonSigHandlers: (() => void) | undefined;
611
+ if (ndjson) {
612
+ const shutdown = (signo: number) => {
613
+ try {
614
+ // ARV-343: flush a partial terminal summary from the running
615
+ // accumulators. Schema-exact (CheckRunSummarySchema) so downstream
616
+ // `jq '.summary.operations'` and status-dist stay analyzable;
617
+ // by_category/skipped are left empty (not derivable in the CLI) —
618
+ // the stderr interrupt note below flags that it's a partial count.
619
+ const partialSummary = {
620
+ type: "summary" as const,
621
+ ts: nowIso(),
622
+ summary: {
623
+ operations: partialOps.size,
624
+ cases: partialCases,
625
+ checks_run: partialChecks.size,
626
+ findings: partialFindings,
627
+ by_severity: { ...partialBySeverity },
628
+ by_category: { security: 0, reliability: 0, contract: 0, hygiene: 0 },
629
+ skipped_outcomes: {},
630
+ skipped_outcomes_grouped: [],
631
+ },
632
+ };
633
+ if (ndjsonFd !== undefined) {
634
+ if (!ndjsonLastFullyWritten) {
635
+ try { writeSync(ndjsonFd, "\n"); } catch { /* fd already gone */ }
636
+ }
637
+ try { writeSync(ndjsonFd, `${JSON.stringify(partialSummary)}\n`); } catch { /* fd already gone */ }
638
+ try { closeSync(ndjsonFd); } catch { /* already closed */ }
639
+ ndjsonFd = undefined;
640
+ } else {
641
+ try { emitToStdout(partialSummary); } catch { /* stdout gone */ }
642
+ }
643
+ } catch { /* swallow; we're tearing down */ }
644
+ // ARV-323: "emitted" not "flushed" — on the stdout channel the last
645
+ // few lines may still sit in the pipe buffer, so the count is a
646
+ // lower bound of what the consumer/file will contain, never an
647
+ // excuse to discard a partial-but-real stream.
648
+ try { process.stderr.write(`zond: NDJSON run interrupted (signal ${signo}); ${ndjsonEventCount} event(s) + a partial summary (${partialOps.size} ops, ${partialCases} cases so far) emitted before interrupt (partial stream is usable).\n`); } catch { /* ignore */ }
649
+ process.exit(128 + signo);
650
+ };
651
+ const onTerm = () => shutdown(15);
652
+ const onInt = () => shutdown(2);
653
+ process.on("SIGTERM", onTerm);
654
+ process.on("SIGINT", onInt);
655
+ removeNdjsonSigHandlers = () => {
656
+ process.off("SIGTERM", onTerm);
657
+ process.off("SIGINT", onInt);
658
+ };
659
+ }
660
+
661
+ // ARV-265: accumulate every HTTP case `runChecks` dispatches so we can
662
+ // persist them into the run/results tables after the run completes. The
663
+ // adapter maps ChecksCaseEvent → AuditCaseRecord (1:1) and groups by
664
+ // synthetic suite path (`apis/<api>/checks/<phase>`) so `detectRunKind`
665
+ // would still classify these rows as `check`-kind on legacy queries.
666
+ const auditPersist = checksPersistEnabled();
667
+ const auditCases: AuditCaseRecord[] = [];
668
+ const auditSuiteRoot = `apis/${apiName ?? "_"}/checks`;
669
+ const onCase = auditPersist
670
+ ? (ev: ChecksCaseEvent) => {
671
+ const phaseLabel = ev.phase === "response" ? "response" : ev.phase.replace("stateful_", "stateful/");
672
+ const suiteFile = `${auditSuiteRoot}/${phaseLabel}.yaml`;
673
+ const status = ev.verdict === "pass" ? "pass"
674
+ : ev.verdict === "fail" ? "fail"
675
+ : ev.verdict === "skip" ? "skip"
676
+ : "error";
677
+ auditCases.push({
678
+ suiteName: `checks/${phaseLabel}`,
679
+ suiteFile,
680
+ testName: `${ev.checkId}::${ev.operation.method.toUpperCase()} ${ev.operation.path}`,
681
+ status,
682
+ request: ev.request,
683
+ ...(ev.response ? { response: ev.response } : {}),
684
+ durationMs: ev.durationMs,
685
+ ...(ev.error ? { error: ev.error } : {}),
686
+ });
687
+ }
688
+ : undefined;
689
+
690
+ let budget: Budget | undefined;
691
+ if (opts.budget !== undefined) {
692
+ if (!isBudget(opts.budget)) {
693
+ printError(`--budget must be one of: ${BUDGETS.join(", ")}; got '${opts.budget}'`);
694
+ process.exit(1);
695
+ }
696
+ budget = opts.budget;
697
+ }
698
+ const includeList = expandStatefulAlias(splitList(opts.check));
699
+ const statefulIds = new Set(listStatefulChecks().map((c) => c.id));
700
+ const includesStateful = includeList?.some((id) => statefulIds.has(id)) === true;
701
+ const budgetResolved = resolveBudget(budget, opts.maxRequests, {
702
+ forceStatefulIfIncluded: includesStateful,
703
+ });
704
+
705
+ // ARV-328: throttled progress line on stderr during long runs (CI jobs
706
+ // and subagents with wall-clock budgets had zero visibility — the only
707
+ // artifact was the growing ndjson file). stderr never corrupts stdout
708
+ // reports; 10s throttle keeps short runs silent.
709
+ const PROGRESS_INTERVAL_MS = 10_000;
710
+ let lastProgressAt = Date.now();
711
+ const onProgress = (p: { done: number; total: number; cases: number }) => {
712
+ const now = Date.now();
713
+ if (now - lastProgressAt < PROGRESS_INTERVAL_MS) return;
714
+ lastProgressAt = now;
715
+ try {
716
+ process.stderr.write(`zond: progress — ${p.done}/${p.total} operations, ${p.cases} case(s) run\n`);
717
+ } catch { /* ignore */ }
718
+ };
719
+
518
720
  try {
519
721
  const result = await runChecks({
520
722
  specPath: specRes.spec,
521
723
  baseUrl: baseRes.baseUrl,
522
- include: expandStatefulAlias(splitList(opts.check)),
724
+ include: includeList,
523
725
  exclude: expandStatefulAlias(splitList(opts.excludeCheck)),
524
726
  timeoutMs: typeof opts.timeout === "number" ? opts.timeout : undefined,
525
727
  authHeaders: Object.keys(authHeaders).length > 0 ? authHeaders : undefined,
526
728
  pathVars: Object.keys(pathVars).length > 0 ? pathVars : undefined,
729
+ fixtureGaps,
527
730
  resourceConfigs,
528
731
  bootstrapCleanupFailed: opts.bootstrapCleanupFailed === true,
529
732
  phase: phaseRaw as "examples" | "coverage" | "all",
@@ -533,13 +736,44 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
533
736
  mode: modeRaw as "positive" | "negative" | "all",
534
737
  operationFilter,
535
738
  onEvent: ndjsonOnEvent,
739
+ onCase,
740
+ onProgress,
536
741
  // ARV-8: bounded concurrency at op-level + optional rate-limiter
537
742
  // gating. workers=1 (default) preserves the pre-ARV-8 sequential
538
743
  // path inside runPool — same observable behaviour.
539
744
  workers,
540
745
  rateLimiter,
541
- maxRequests: typeof opts.maxRequests === "number" && opts.maxRequests > 0 ? opts.maxRequests : undefined,
746
+ maxRequests: budgetResolved.maxRequests,
747
+ skipStateful: budgetResolved.skipStateful,
748
+ maxOps: opts.maxOps,
749
+ skipOps: opts.skipOps,
750
+ safe: !resolveLive(opts),
542
751
  });
752
+
753
+ // ARV-265: persist the accumulated cases as a `run_kind='check'` run
754
+ // so `zond coverage --scope audit` can count them. Failures here
755
+ // degrade to a warning — the user's primary command already succeeded.
756
+ if (auditPersist && auditCases.length > 0) {
757
+ try {
758
+ getDb(opts.db);
759
+ const { findCollectionByNameOrId } = await import("../../db/queries.ts");
760
+ const collectionId = apiName ? findCollectionByNameOrId(apiName)?.id : undefined;
761
+ const session = readCurrentSession();
762
+ const runId = beginAuditRun({
763
+ runKind: "check",
764
+ ...(collectionId != null ? { collectionId } : {}),
765
+ ...(session?.id ? { sessionId: session.id } : {}),
766
+ tags: ["checks", `phase:${phaseRaw}`, `mode:${modeRaw}`],
767
+ });
768
+ finalizeAuditRun(runId, auditCases);
769
+ } catch (err) {
770
+ // Audit persistence is best-effort. Surface the failure on stderr
771
+ // so an agent can detect it (the missing audit-coverage downstream
772
+ // will already point them here) without breaking the run.
773
+ const msg = (err as Error).message;
774
+ process.stderr.write(`zond: audit persistence failed (${msg}). Re-run with ZOND_CHECKS_PERSIST=0 to silence.\n`);
775
+ }
776
+ }
543
777
  const warnings: string[] = [];
544
778
  for (const id of result.selection.unknown) {
545
779
  warnings.push(`Unknown check: "${id}" — ignored. Run \`zond checks list\` to see registered ids.`);
@@ -612,50 +846,104 @@ async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
612
846
  }
613
847
  const skipLine = formatSkippedOutcomes(s.skipped_outcomes);
614
848
  if (skipLine) console.log(` ${skipLine}`);
615
- // ARV-18: aggregate identical findings (same check + same response
616
- // status + same severity) so a 30-operation 401-not-in-spec sweep
617
- // collapses to one row instead of drowning out single-shot findings.
618
- // Per-operation detail is restored under --verbose; the JSON envelope
619
- // and SARIF sidecar always carry the full unaggregated list.
849
+ // ARV-60: spec-level rollup. When the runner detected ≥80% of a
850
+ // check's applicable ops sharing one root cause, print one summary
851
+ // row with an actionable fix hint instead of N per-op rows that
852
+ // all say the same thing. `--verbose` always shows per-op detail
853
+ // (and JSON/SARIF carry the full unaggregated list regardless).
854
+ const rolledUpOps = new Set<string>();
855
+ if (!opts.verbose) {
856
+ for (const sf of result.data.spec_findings) {
857
+ if (sf.kind === "status_drift") {
858
+ for (const op of sf.affected_operations) {
859
+ rolledUpOps.add(`${sf.check}|${op.method} ${op.path}`);
860
+ }
861
+ console.log(
862
+ ` [${sf.severity}] ${sf.check} — ${sf.reason} (${sf.count}/${sf.applicable} operations)`,
863
+ );
864
+ console.log(` → ${sf.fix_hint}`);
865
+ } else {
866
+ // missing_declaration / no_detector / other — no affected_operations
867
+ // to enumerate; surfaces as a single info row + fix hint.
868
+ const tag = sf.kind === "no_detector"
869
+ ? `${sf.count === 0 ? "0 cases" : `${sf.count} cases`} / ${sf.applicable} applicable ops`
870
+ : `${sf.count}/${sf.applicable} cases`;
871
+ console.log(` [${sf.severity}] ${sf.check} — ${sf.reason} (${tag})`);
872
+ console.log(` → ${sf.fix_hint}`);
873
+ }
874
+ }
875
+ }
876
+ // ARV-283 AC#4: suppressed findings stay in the ndjson audit-trail
877
+ // but are hidden from the human summary unless `--show-suppressed`
878
+ // is passed. They never count toward CI gates regardless.
879
+ const activeFindings = result.data.findings.filter((f) => !f.suppressed_by);
880
+ const suppressedFindings = result.data.findings.filter((f) => f.suppressed_by);
881
+
882
+ // Per-op findings — skip those already covered by a status_drift
883
+ // rollup unless --verbose was passed.
620
884
  if (opts.verbose) {
621
- for (const f of result.data.findings) {
885
+ for (const f of activeFindings) {
622
886
  console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
623
887
  }
624
888
  } else {
625
- const groups = new Map<string, { severity: string; check: string; status: number; ops: Set<string>; sample: string }>();
626
- for (const f of result.data.findings) {
627
- const status = f.response_summary?.status ?? 0;
628
- const key = `${f.severity}|${f.check}|${status}`;
629
- const opKey = `${f.operation.method} ${f.operation.path}`;
630
- let g = groups.get(key);
631
- if (!g) {
632
- g = { severity: f.severity, check: f.check, status, ops: new Set(), sample: f.message };
633
- groups.set(key, g);
634
- }
635
- g.ops.add(opKey);
889
+ // ARV-18: dedup identical findings on the SAME op (multiple cases
890
+ // hitting the same gap) before printing. Spec-rollup above already
891
+ // handled the across-op clusters; this collapses the within-op
892
+ // duplicates so the human summary stays readable even when boundary
893
+ // mutations each trip the same status.
894
+ const seen = new Set<string>();
895
+ for (const f of activeFindings) {
896
+ const opKey = `${f.check}|${f.operation.method} ${f.operation.path}`;
897
+ if (rolledUpOps.has(opKey)) continue;
898
+ const dedupKey = `${f.severity}|${opKey}|${f.response_summary?.status ?? 0}|${f.message}`;
899
+ if (seen.has(dedupKey)) continue;
900
+ seen.add(dedupKey);
901
+ console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
636
902
  }
637
- for (const g of groups.values()) {
638
- if (g.ops.size <= 1) {
639
- const op = [...g.ops][0] ?? "(unknown op)";
640
- console.log(` [${g.severity}] ${g.check} ${op} ${g.sample}`);
641
- } else {
642
- const stem = g.status > 0
643
- ? `Status ${g.status} not declared / unexpected`
644
- : g.sample.replace(/ for [A-Z]+ .+$/, "");
645
- console.log(` [${g.severity}] ${g.check} — ${stem} (${g.ops.size} operation${g.ops.size === 1 ? "" : "s"} affected; --verbose for per-op detail)`);
646
- }
903
+ }
904
+
905
+ if (opts.showSuppressed && suppressedFindings.length > 0) {
906
+ console.log(`\nSuppressed (${suppressedFindings.length}, not counted in summary):`);
907
+ for (const f of suppressedFindings) {
908
+ const sb = f.suppressed_by!;
909
+ console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} ${f.message}`);
910
+ console.log(` ↳ suppressed by ${sb.source}#${sb.rule_index}: ${sb.reason}`);
647
911
  }
648
912
  }
649
913
  }
650
914
  // Exit-code rule: 0 when no HIGH/CRITICAL findings, 1 otherwise. LOW/MEDIUM
651
915
  // findings are reported but don't gate CI by default — agents that want
652
916
  // strict gating can post-process the JSON envelope.
917
+ //
918
+ // ARV-308: --no-fail-on-findings / --advisory keeps exit 0 even with
919
+ // HIGH/CRITICAL findings, mirroring `zond run --no-fail-on-failures`, so
920
+ // an orchestrator can tell "found drift" (exit 0, findings in envelope)
921
+ // from "command failed" (exit 2). The stderr tail still names the count.
922
+ const advisory = opts.advisory === true || opts.failOnFindings === false;
923
+ // ARV-320: this used to skip on ndjson under the assumption that "stderr
924
+ // already carried the summary just above" — false for ndjson, which takes
925
+ // a separate branch that only ever writes "NDJSON report written to
926
+ // <path>" to stderr. Under `--report ndjson` + `set -e` in CI, that made
927
+ // exit 1 look unexplained (report-zond friction on the 2026-07-02 Stripe
928
+ // run: "this step will 'fail' silently with valid data sitting right
929
+ // there"). Always write the reason to stderr — it's stderr, not stdout,
930
+ // so ndjson's stdout-discipline (AC#5) is untouched.
931
+ if (result.high_or_critical > 0) {
932
+ const suffix = advisory
933
+ ? " — advisory mode, exiting 0 (findings are in the envelope)"
934
+ : " — exiting with code 1 (pass --no-fail-on-findings / --advisory to suppress, e.g. for advisory runs)";
935
+ process.stderr.write(
936
+ `zond: ${result.high_or_critical} HIGH/CRITICAL finding(s)${suffix}.\n`,
937
+ );
938
+ }
653
939
  if (ndjsonFd !== undefined) closeSync(ndjsonFd);
654
- process.exit(result.high_or_critical > 0 ? 1 : 0);
940
+ removeNdjsonSigHandlers?.();
941
+ process.exit(result.high_or_critical > 0 && !advisory ? 1 : 0);
655
942
  } catch (err) {
656
943
  if (ndjsonFd !== undefined) {
657
944
  try { closeSync(ndjsonFd); } catch { /* fd may already be invalid */ }
658
945
  }
946
+ removeNdjsonSigHandlers?.();
659
947
  const msg = err instanceof Error ? err.message : String(err);
660
948
  if (json) printJson(jsonError("checks run", [msg]));
661
949
  else printError(msg);
@@ -677,7 +965,7 @@ function defineRun(parent: Command): void {
677
965
  .option("--api <name>", "Use the registered API's spec + .env.yaml")
678
966
  .option("--spec <path>", "Explicit OpenAPI spec path (overrides --api)")
679
967
  .option("--base-url <url>", "Base URL for requests (overrides --api env file)")
680
- .option("--check <ids...>", "Only run these checks (comma-separated or repeated)")
968
+ .option("--check <ids...>", "Only run these checks (comma-separated or repeated). 'stateful' expands to the state-machine set: use_after_free, ensure_resource_availability, cross_call_references, idempotency_replay, pagination_invariants, lifecycle_transitions, cursor_boundary_fuzzing — NOT ignored_auth/open_cors_on_sensitive (run those by explicit id)")
681
969
  .option("--exclude-check <ids...>", "Skip these checks (comma-separated or repeated)")
682
970
  .option("--timeout <ms>", "Per-request timeout in ms", (v) => Number.parseInt(v, 10))
683
971
  .option("--db <path>", "SQLite path (for --api lookup)")
@@ -741,9 +1029,37 @@ function defineRun(parent: Command): void {
741
1029
  )
742
1030
  .option(
743
1031
  "--max-requests <n>",
744
- "ARV-227: hard cap on outbound HTTP requests for the whole run (per-response + stateful share the same budget). Once reached, remaining cases short-circuit with `max-requests-cap-reached` in summary.skipped_outcomes. Use to keep coverage runs against large specs (github, kubernetes) bounded.",
1032
+ "ARV-227: hard cap on outbound HTTP requests for the whole run (per-response + stateful share the same budget). Once reached, remaining cases short-circuit with `max-requests-cap-reached` in summary.skipped_outcomes. Always wins over the --budget tier cap.",
1033
+ (v) => Number.parseInt(v, 10),
1034
+ )
1035
+ .option(
1036
+ "--max-ops <n>",
1037
+ "ARV-342: cap this run to N operations (deterministic op-window). Pair with --skip-ops to sweep a large spec in bounded, resumable slices that each finish in a short budget. A window whose summary.operations < N is the last slice.",
1038
+ (v) => Number.parseInt(v, 10),
1039
+ )
1040
+ .option(
1041
+ "--skip-ops <n>",
1042
+ "ARV-342: skip the first N operations (post-filter, deterministic order) before applying --max-ops. Resume token for windowed sweeps: skip 0, skip 50, skip 100, …",
745
1043
  (v) => Number.parseInt(v, 10),
746
1044
  )
1045
+ .option(
1046
+ "--budget <tier>",
1047
+ "ARV-292: adaptive cap and stateful gating tier. `quick` (cap 50, skip stateful) → ~60-sec gate. `standard` (cap 500, all checks). `full` (uncapped). Omitted ⇒ legacy uncapped behaviour. --max-requests always overrides the tier cap; `--check stateful` opts back into stateful even under `quick`.",
1048
+ )
1049
+ .option(
1050
+ "--show-suppressed",
1051
+ "Show findings suppressed by the deterministic broken-baseline guard in the text summary (with their suppressed_by trace). Suppressed findings stay in the ndjson/JSON audit-trail regardless of this flag and never count toward CI exit codes.",
1052
+ )
1053
+ .option(
1054
+ "--no-fail-on-findings",
1055
+ "ARV-308: keep exit code 0 even when HIGH/CRITICAL findings exist (advisory runs). Mirrors `zond run --no-fail-on-failures`. Lets an orchestrator distinguish 'found drift' (exit 0) from 'command failed' (exit 2). Default: exit 1 on any HIGH/CRITICAL finding.",
1056
+ )
1057
+ .option(
1058
+ "--advisory",
1059
+ "ARV-308: alias for --no-fail-on-findings.",
1060
+ )
1061
+ .option("--safe", SAFE_HELP)
1062
+ .option("--live", LIVE_HELP)
747
1063
  .action(checksRunAction);
748
1064
  }
749
1065
 
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { loadOrphans, markRemoved } from "../../core/probe/orphan-tracker.ts";
11
11
  import type { OrphanRecord } from "../../core/probe/orphan-tracker.ts";
12
+ import { encodePathForRepro } from "../../core/probe/shared.ts";
12
13
  import { executeRequest } from "../../core/runner/http-client.ts";
13
14
  import { loadEnvironment, loadEnvMeta } from "../../core/parser/variables.ts";
14
15
  import { resolveTimeoutMs } from "../../core/workspace/config.ts";
@@ -41,37 +42,10 @@ export interface CleanupOptions {
41
42
  * raw `\r`, `\n`, spaces, etc. (e.g. label name `zond-safe\rX-Zond: yes`),
42
43
  * which makes the DELETE URL malformed: the API gets a request line with an
43
44
  * embedded CR and silently splits or routes elsewhere → 404 → record stays
44
- * in the queue and the resource leaks.
45
- *
46
- * Encode unsafe characters per path-segment while preserving anything that
47
- * is already percent-encoded. Slashes (segment separators), the unreserved
48
- * set, and a conservative slice of sub-delims are kept verbatim.
45
+ * in the queue and the resource leaks. Encoding is shared with the
46
+ * security-probe digest via `core/probe/shared.ts#encodePathForRepro`.
49
47
  */
50
- export function encodeOrphanPath(deletePath: string): string {
51
- const SAFE = /[A-Za-z0-9._~!$&'()*+,;=:@-]/;
52
- return deletePath
53
- .split("/")
54
- .map((segment) => {
55
- if (segment.length === 0) return segment;
56
- let out = "";
57
- for (let i = 0; i < segment.length; i++) {
58
- const ch = segment.charAt(i);
59
- // Preserve existing percent escapes (`%XX`) — probe pre-encoded.
60
- if (ch === "%" && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
61
- out += segment.slice(i, i + 3);
62
- i += 2;
63
- continue;
64
- }
65
- if (SAFE.test(ch)) {
66
- out += ch;
67
- } else {
68
- out += encodeURIComponent(ch);
69
- }
70
- }
71
- return out;
72
- })
73
- .join("/");
74
- }
48
+ export const encodeOrphanPath = encodePathForRepro;
75
49
 
76
50
  export async function cleanupCommand(opts: CleanupOptions): Promise<number> {
77
51
  if (!opts.orphans) {