@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
@@ -15,6 +15,8 @@ import type { OpenAPIV3 } from "openapi-types";
15
15
 
16
16
  import { extractEndpoints, readOpenApiSpec } from "../generator/index.ts";
17
17
  import { detectCrudGroups } from "../generator/suite-generator.ts";
18
+ import { buildApiResourceMap } from "../generator/resources-builder.ts";
19
+ import type { CrudGroup } from "../generator/types.ts";
18
20
  import type { EndpointInfo } from "../generator/types.ts";
19
21
  import { generateFromSchema } from "../generator/data-factory.ts";
20
22
  import {
@@ -41,16 +43,57 @@ import { nowIso, type NdjsonEvent } from "../reporter/ndjson.ts";
41
43
  import { runPool } from "../runner/async-pool.ts";
42
44
  import type { RateLimiter } from "../runner/rate-limiter.ts";
43
45
  import { recommendForCheck } from "./recommended-action.ts";
46
+ import { gapKey } from "../workspace/fixture-gaps.ts";
47
+ import { endpointSkipsCheck, reasonForSkip } from "./zond-extensions.ts";
44
48
  import {
45
49
  emptySummary,
50
+ groupSkippedOutcomes,
46
51
  type CaseKind,
47
52
  type Check,
48
53
  type CheckCase,
49
54
  type CheckFinding,
50
55
  type CheckRunData,
51
56
  type CheckRunSummary,
57
+ type SpecFinding,
52
58
  } from "./types.ts";
59
+ import type { Severity } from "../severity/index.ts";
53
60
  import { categoryFor } from "../severity/category.ts";
61
+ import {
62
+ computeSpecFindings,
63
+ applyBrokenBaselineGuard,
64
+ type PerCheckObservations,
65
+ } from "./spec-findings.ts";
66
+
67
+ /**
68
+ * ARV-265: per-HTTP-case audit envelope. One emitted per request the
69
+ * checks runner actually dispatches (or attempted to dispatch — the
70
+ * `error` field is set for transport-layer failures and skipped cases).
71
+ *
72
+ * - `phase`: which sub-loop emitted the case. Lets the persistence
73
+ * adapter group them under `suite_name = "checks/<phase>"`.
74
+ * - `kind`: per-response CaseKind ("positive" / "negative_data" / …)
75
+ * when phase === "response". For stateful checks it is the
76
+ * check id (e.g. "ignored_auth", "crud_lifecycle"), since
77
+ * one stateful check owns the whole sub-chain.
78
+ * - `verdict`: best-effort outcome — "pass"/"fail" mirror the per-check
79
+ * verdict (passes are bucketed when ALL checks on the case
80
+ * passed); "error" = network/transport failure; "skip" =
81
+ * pre-cap skips (max-requests budget exhausted).
82
+ * - `checkId`: the canonical check id for the case. For per-response
83
+ * cases without a single owning check, this is the
84
+ * first-fired check id; for stateful, it's the check itself.
85
+ */
86
+ export interface ChecksCaseEvent {
87
+ phase: "response" | "stateful_auth" | "stateful_crud";
88
+ checkId: string;
89
+ kind: string;
90
+ operation: { method: string; path: string; operationId?: string };
91
+ request: HttpRequest;
92
+ response?: HttpResponse;
93
+ durationMs: number;
94
+ verdict: "pass" | "fail" | "error" | "skip";
95
+ error?: string;
96
+ }
54
97
 
55
98
  export interface RunChecksOptions {
56
99
  specPath: string;
@@ -86,6 +129,12 @@ export interface RunChecksOptions {
86
129
  * buffering until the run finishes). Must not throw — exceptions are
87
130
  * the caller's responsibility (the runner doesn't catch). */
88
131
  onEvent?: (event: NdjsonEvent) => void;
132
+ /** ARV-265 — fires once per HTTP case actually dispatched (both
133
+ * per-response and stateful phases). Lets the CLI surface every touch
134
+ * into `runs`/`results` so `audit-coverage` can attribute it back.
135
+ * Network-failure cases also fire (with `error` set) so the audit metric
136
+ * counts attempts as well as successes. Must not throw. */
137
+ onCase?: (event: ChecksCaseEvent) => void;
89
138
  /** ARV-8 — bounded async-pool concurrency at the *operation* level.
90
139
  * `1` (default) = sequential, identical to the pre-ARV-8 behaviour.
91
140
  * Cases within an operation always run sequentially regardless of
@@ -121,6 +170,22 @@ export interface RunChecksOptions {
121
170
  * vars are filled. Keyed by path-param name (e.g. `issue_id`); falls back
122
171
  * to the legacy schema-driven placeholder when the name isn't in the map. */
123
172
  pathVars?: Record<string, string>;
173
+ /** ARV-324: operations `.fixture-gaps.yaml` already confirmed as a
174
+ * known-empty/inaccessible resource (keyed by `"METHOD /path"` via
175
+ * `gapKey()`). A finding on one of these operations gets
176
+ * `recommended_action: fix_fixture` instead of `report_backend_bug` —
177
+ * it's a known gap in our own test data, not new backend evidence. */
178
+ fixtureGaps?: Set<string>;
179
+ /** ARV-342: operation-window. `skipOps` drops the first N operations
180
+ * (post-filter, in the deterministic extraction order); `maxOps` caps
181
+ * the window to N operations. Together they let a caller sweep a huge
182
+ * spec in bounded, resumable slices (skip 0/max 50, skip 50/max 50, …)
183
+ * that each finish inside a short run budget — the fix for
184
+ * "587-op sweep SIGTERM-killed at 15%". Pure deterministic slicing of
185
+ * the sorted op list: same skip/max ⇒ same window, no adaptive pacing.
186
+ * A window's `summary.operations` < `maxOps` signals the last slice. */
187
+ skipOps?: number;
188
+ maxOps?: number;
124
189
  /** ARV-227: hard cap on outbound HTTP requests for the entire run.
125
190
  * Once `used >= limit`, every subsequent case short-circuits and the
126
191
  * summary surfaces the cap via `summary.skipped_outcomes
@@ -128,6 +193,24 @@ export interface RunChecksOptions {
128
193
  * the same budget so a cap of 100 means 100 requests total across
129
194
  * per-response + stateful, not per-phase. Undefined ⇒ uncapped. */
130
195
  maxRequests?: number;
196
+ /** ARV-292: skip the stateful (CRUD + auth) phase entirely. Driven by
197
+ * `--budget quick` from the CLI. Skipped stateful ids are surfaced
198
+ * in `summary.skipped_outcomes` so the user sees what was dropped. */
199
+ skipStateful?: boolean;
200
+ /** ARV-328: fired after each operation finishes the response phase —
201
+ * lets the CLI print a throttled progress line during long coverage
202
+ * runs. `done`/`total` are operations (case count isn't known up
203
+ * front); `cases` is HTTP cases executed so far.
204
+ * ponytail: response phase only — stateful phase is short by
205
+ * comparison; extend there if it ever dominates wall-clock. */
206
+ onProgress?: (progress: { done: number; total: number; cases: number }) => void;
207
+ /** ARV-299: safe mode (CLI default). Mirrors `audit --safe`: no
208
+ * destructive traffic. The stateful CRUD phase builds groups from
209
+ * read-only ops only, so create/update/delete chains
210
+ * (ensure_resource_availability, use_after_free) self-skip instead of
211
+ * POSTing real resources. Read-only stateful checks (pagination,
212
+ * observation-mode lifecycle) still run. `--live` sets this false. */
213
+ safe?: boolean;
131
214
  }
132
215
 
133
216
  export interface RunChecksResult {
@@ -165,6 +248,48 @@ function fillPathParams(
165
248
  });
166
249
  }
167
250
 
251
+ /** ARV-344: a REQUIRED path param with no real fixture value — `fillPathParams`
252
+ * would substitute a synthetic placeholder ("x"). Harmless for GET/HEAD/DELETE
253
+ * existence probes (ARV-141's deterministic synthetic 404), but firing a
254
+ * POST/PUT/PATCH create/update against a nonexistent parent just manufactures
255
+ * noise findings (network_error / phantom 404) and burns rate budget. Callers
256
+ * skip mutating ops in that state and bucket the reason as a fixture gap. */
257
+ export function hasUnresolvedRequiredPathParam(op: EndpointInfo, pathVars?: Record<string, string>): boolean {
258
+ for (const p of op.parameters) {
259
+ const pp = p as OpenAPIV3.ParameterObject;
260
+ if (pp.in !== "path" || pp.required !== true) continue;
261
+ const v = pathVars?.[pp.name];
262
+ if (!(typeof v === "string" && v.length > 0)) return true;
263
+ }
264
+ return false;
265
+ }
266
+
267
+ /** ARV-353: transient connection resets under high windowed concurrency
268
+ * (ECONNRESET / socket hang up / EPIPE) are transport flake, not an API
269
+ * defect. Retry a small fixed number of times before letting the error become
270
+ * a `network_error` finding, so flake is distinguished from a real
271
+ * `fix_network_config` signal. Deterministic: fixed retry count, only on
272
+ * reset-shaped errors (a real DNS/refused/timeout error is re-thrown at once). */
273
+ const TRANSIENT_RESET_RE = /ECONNRESET|socket hang up|socket closed|connection reset|EPIPE|read ECONN/i;
274
+
275
+ export async function executeWithResetRetry(
276
+ req: HttpRequest,
277
+ timeout: number,
278
+ retries = 2,
279
+ exec: (r: HttpRequest, o: { timeout: number }) => Promise<HttpResponse> = executeRequest,
280
+ ): Promise<HttpResponse> {
281
+ let lastErr: unknown;
282
+ for (let attempt = 0; attempt <= retries; attempt++) {
283
+ try {
284
+ return await exec(req, { timeout });
285
+ } catch (err) {
286
+ lastErr = err;
287
+ if (!TRANSIENT_RESET_RE.test((err as Error).message ?? "")) throw err;
288
+ }
289
+ }
290
+ throw lastErr;
291
+ }
292
+
168
293
  function requiredHeaders(op: EndpointInfo): OpenAPIV3.ParameterObject[] {
169
294
  return op.parameters.filter(
170
295
  (p) => (p as OpenAPIV3.ParameterObject).in === "header"
@@ -466,10 +591,22 @@ function summarizeResponse(resp: HttpResponse): { status: number; content_type?:
466
591
  return { status: resp.status, content_type: ct };
467
592
  }
468
593
 
594
+ /** ARV-319: `CrudGroup.create`/`.list`/`.read`/`.update`/`.delete` are ALL
595
+ * optional — a group can be update/delete-only (no create, no read), which
596
+ * is common on wide specs like Stripe. Pick whichever canonical operation
597
+ * exists, in create > list > read > update > delete order, for use as a
598
+ * representative op (finding attribution, x-zond-skip lookup, ndjson
599
+ * event). Returns undefined only if the group somehow has none of the
600
+ * five — callers must not assume non-null. */
601
+ function representativeOp(g: CrudGroup): EndpointInfo | undefined {
602
+ return g.create ?? g.list ?? g.read ?? g.update ?? g.delete;
603
+ }
604
+
469
605
  /** Build a finding, push it into the per-op buffer, and stream the
470
606
  * ARV-10 NDJSON event. Summary aggregation moved out — the caller
471
607
  * merges per-op buffers in input order so workers > 1 doesn't have to
472
- * contend on a shared `summary` object. */
608
+ * contend on a shared `summary` object. The finding carries raw
609
+ * evidence + its built-in severity default; the agent re-severitizes. */
473
610
  function recordFinding(
474
611
  out: CheckFinding[],
475
612
  check: Check,
@@ -478,16 +615,25 @@ function recordFinding(
478
615
  message: string,
479
616
  evidence: Record<string, unknown> | undefined,
480
617
  onEvent: ((event: NdjsonEvent) => void) | undefined,
618
+ /** ARV-284: per-finding severity from CheckOutcome — overrides
619
+ * the check's natural tier when the check wants to dispatch by
620
+ * evidence (e.g. negative_data_rejection LOW for additionalProperties,
621
+ * HIGH for 5xx response). */
622
+ outcomeSeverity?: Severity,
623
+ /** ARV-324: known-gap index, see `RunChecksOptions.fixtureGaps`. */
624
+ fixtureGaps?: Set<string>,
481
625
  ): void {
626
+ const unresolvedFixture = fixtureGaps?.has(gapKey(c.operation.method, c.operation.path)) ?? false;
627
+ const action = recommendForCheck(check.id, resp.status, unresolvedFixture);
482
628
  const finding: CheckFinding = {
483
629
  check: check.id,
484
- severity: check.severity,
630
+ severity: outcomeSeverity ?? check.severity,
485
631
  operation: { path: c.operation.path, method: c.operation.method, operationId: c.operation.operationId },
486
632
  request_signature: `${c.request.method} ${c.request.url}`,
487
633
  response_summary: summarizeResponse(resp),
488
634
  message,
489
635
  evidence,
490
- recommended_action: recommendForCheck(check.id, resp.status),
636
+ recommended_action: action,
491
637
  };
492
638
  out.push(finding);
493
639
  if (onEvent) onEvent({ type: "finding", ts: nowIso(), check: check.id, finding });
@@ -496,7 +642,13 @@ function recordFinding(
496
642
  export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult> {
497
643
  const doc = await readOpenApiSpec(opts.specPath);
498
644
  const allOps = extractEndpoints(doc);
499
- const ops = opts.operationFilter ? allOps.filter(opts.operationFilter) : allOps;
645
+ const filteredOps = opts.operationFilter ? allOps.filter(opts.operationFilter) : allOps;
646
+ // ARV-342: deterministic operation-window. Slice the post-filter op
647
+ // list so a caller can sweep a large spec in bounded, resumable slices.
648
+ const skipOps = Math.max(0, opts.skipOps ?? 0);
649
+ const ops = opts.maxOps !== undefined && opts.maxOps >= 0
650
+ ? filteredOps.slice(skipOps, skipOps + opts.maxOps)
651
+ : filteredOps.slice(skipOps);
500
652
  const buckets = bucketEndpointsByPath(allOps);
501
653
  const schemaValidator: SchemaValidator = createSchemaValidator(doc);
502
654
 
@@ -562,12 +714,31 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
562
714
  cases: number;
563
715
  /** ARV-26: skip-outcome counts keyed by `"<check_id>: <reason>"`. */
564
716
  skipped: Record<string, number>;
717
+ /** ARV-60: check ids that returned `applies(op) === true` for this
718
+ * operation. Counted at merge-time to derive each check's
719
+ * applicable-operation population for spec_findings rollup. One
720
+ * entry per check per op (deduplicated within the op). */
721
+ applicableChecks: string[];
722
+ /** ARV-60: per-check case count for this op (passed + failed +
723
+ * skipped). Summed at merge-time. */
724
+ casesByCheck: Record<string, number>;
725
+ /** ARV-307: positive (expected-success) probe responses observed on
726
+ * this op — feeds the run-level broken-baseline guard. */
727
+ positiveTotal: number;
728
+ positiveTwoxx: number;
565
729
  }
566
730
 
567
731
  async function processOperation(op: EndpointInfo): Promise<OpReport> {
568
732
  const localFindings: CheckFinding[] = [];
569
733
  let localCases = 0;
734
+ let localPositiveTotal = 0;
735
+ let localPositiveTwoxx = 0;
570
736
  const localSkipped: Record<string, number> = {};
737
+ // ARV-60: per-check observations for this op — `applies()` membership
738
+ // and case-count. Deduplicated within the op (one applies-vote per
739
+ // check regardless of how many case kinds we generate against it).
740
+ const localApplicable = new Set<string>();
741
+ const localCasesByCheck: Record<string, number> = {};
571
742
  if (opts.onEvent) {
572
743
  opts.onEvent({
573
744
  type: "check_start",
@@ -606,6 +777,34 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
606
777
  cases.push(...buildUnsupportedMethod(op, declared, opts.baseUrl));
607
778
  }
608
779
 
780
+ // ARV-344: don't dispatch a mutation (POST/PUT/PATCH) whose required path
781
+ // param is unresolved — it targets a synthetic parent id ("/accounts/x/…")
782
+ // and only manufactures noise. Skip the op's cases and bucket the reason as
783
+ // a fixture gap (GET/HEAD/DELETE keep the ARV-141 synthetic-404 probe).
784
+ {
785
+ const m = op.method.toUpperCase();
786
+ const isMutation = m === "POST" || m === "PUT" || m === "PATCH";
787
+ if (isMutation && cases.length > 0 && hasUnresolvedRequiredPathParam(op, opts.pathVars)) {
788
+ const reason = "unresolved required path param (fixture gap) — mutation against a synthetic parent id skipped";
789
+ for (const built of cases) {
790
+ localSkipped[`${built.case.kind}: ${reason}`] = (localSkipped[`${built.case.kind}: ${reason}`] ?? 0) + 1;
791
+ if (opts.onCase) {
792
+ opts.onCase({
793
+ phase: "response",
794
+ checkId: built.case.kind,
795
+ kind: built.case.kind,
796
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
797
+ request: built.req,
798
+ durationMs: 0,
799
+ verdict: "skip",
800
+ error: reason,
801
+ });
802
+ }
803
+ }
804
+ cases.length = 0;
805
+ }
806
+ }
807
+
609
808
  for (const built of cases) {
610
809
  if (!caseMatchesMode(built.case.mode, mode)) continue;
611
810
  if (opts.authHeaders) injectAuthHeadersIntoCase(built, opts.authHeaders);
@@ -615,6 +814,18 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
615
814
  if (!reserveRequest(requestBudget)) {
616
815
  localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] =
617
816
  (localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] ?? 0) + 1;
817
+ if (opts.onCase) {
818
+ opts.onCase({
819
+ phase: "response",
820
+ checkId: built.case.kind,
821
+ kind: built.case.kind,
822
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
823
+ request: built.req,
824
+ durationMs: 0,
825
+ verdict: "skip",
826
+ error: MAX_REQUESTS_SKIP_REASON,
827
+ });
828
+ }
618
829
  continue;
619
830
  }
620
831
  // ARV-8: gate the request through the rate-limiter (no-op when
@@ -624,7 +835,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
624
835
  if (opts.rateLimiter) await opts.rateLimiter.acquire();
625
836
  let httpResp: HttpResponse;
626
837
  try {
627
- httpResp = await executeRequest(built.req, { timeout: opts.timeoutMs ?? 30000 });
838
+ httpResp = await executeWithResetRetry(built.req, opts.timeoutMs ?? 30000);
628
839
  } catch (err) {
629
840
  const finding: CheckFinding = {
630
841
  check: "network_error",
@@ -637,19 +848,56 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
637
848
  };
638
849
  localFindings.push(finding);
639
850
  if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: "network_error", finding });
851
+ if (opts.onCase) {
852
+ opts.onCase({
853
+ phase: "response",
854
+ checkId: "network_error",
855
+ kind: built.case.kind,
856
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
857
+ request: built.req,
858
+ durationMs: 0,
859
+ verdict: "error",
860
+ error: (err as Error).message,
861
+ });
862
+ }
640
863
  continue;
641
864
  }
642
865
 
643
866
  localCases += 1;
867
+ // ARV-307: tally the positive-probe baseline. Only the positive case
868
+ // kind is "expected to succeed" — negative/boundary cases legitimately
869
+ // 4xx, so they must not count toward the broken-baseline ratio.
870
+ if (built.case.kind === "positive") {
871
+ localPositiveTotal += 1;
872
+ if (httpResp.status >= 200 && httpResp.status < 300) localPositiveTwoxx += 1;
873
+ }
644
874
  const checkResp = {
645
875
  status: httpResp.status,
646
876
  headers: httpResp.headers,
647
877
  body: httpResp.body_parsed ?? httpResp.body,
648
878
  duration_ms: httpResp.duration_ms,
649
879
  };
880
+ // ARV-265: accumulate per-case verdict. The case is "fail" if any
881
+ // applicable check on it failed, "pass" if at least one ran and all
882
+ // passed, "skip" if every check was skipped. Owning check id is the
883
+ // first one that returned a verdict — used as a hint for triage.
884
+ let caseVerdict: "pass" | "fail" | "skip" = "skip";
885
+ let caseCheckId: string = built.case.kind;
650
886
  for (const check of selection.selected) {
651
887
  if (!checkKinds(check).includes(built.case.kind)) continue;
652
888
  if (!check.applies(op)) continue;
889
+ // ARV-189: per-operation `x-zond-skip` / `x-zond-public` opt-out.
890
+ // Fires AFTER applies() so the applicability count still reflects
891
+ // the universe of checks that *would* have run; the spec-level
892
+ // skip is surfaced through the skipped-outcomes summary instead.
893
+ if (endpointSkipsCheck(op, check.id)) {
894
+ const key = `${check.id}: ${reasonForSkip(op, check.id)}`;
895
+ localSkipped[key] = (localSkipped[key] ?? 0) + 1;
896
+ continue;
897
+ }
898
+ // ARV-60: applies()=true → bump applicability + cases-by-check.
899
+ localApplicable.add(check.id);
900
+ localCasesByCheck[check.id] = (localCasesByCheck[check.id] ?? 0) + 1;
653
901
  const outcome = check.run({
654
902
  case: built.case,
655
903
  response: checkResp,
@@ -658,7 +906,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
658
906
  options: checkRuntimeOptions,
659
907
  });
660
908
  if (outcome.kind === "fail") {
661
- recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent);
909
+ recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent, outcome.severity, opts.fixtureGaps);
662
910
  }
663
911
  if (outcome.kind === "skip") {
664
912
  // ARV-26: bucket skips by check+reason so the summary can surface
@@ -671,35 +919,100 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
671
919
  type: "check_result",
672
920
  ts: nowIso(),
673
921
  check: check.id,
922
+ // ARV-351: emit the SAME severity the finding carries, not the
923
+ // static declared default — else one case reads `high` on its
924
+ // check_result and `low` on its finding (open_cors_on_sensitive).
925
+ severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
674
926
  verdict: outcome.kind,
675
927
  operation: { path: op.path, method: op.method, operationId: op.operationId },
676
928
  request_signature: `${built.case.request.method} ${built.case.request.url}`,
677
929
  response: summarizeResponse(httpResp),
678
930
  });
679
931
  }
932
+ // ARV-265: a case fails as soon as one check on it fails.
933
+ if (outcome.kind === "fail") {
934
+ caseVerdict = "fail";
935
+ if (caseCheckId === built.case.kind) caseCheckId = check.id;
936
+ } else if (outcome.kind === "pass" && caseVerdict !== "fail") {
937
+ caseVerdict = "pass";
938
+ if (caseCheckId === built.case.kind) caseCheckId = check.id;
939
+ }
940
+ }
941
+ // ARV-265: one audit row per case, regardless of how many checks ran.
942
+ if (opts.onCase) {
943
+ opts.onCase({
944
+ phase: "response",
945
+ checkId: caseCheckId,
946
+ kind: built.case.kind,
947
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
948
+ request: built.req,
949
+ response: httpResp,
950
+ durationMs: httpResp.duration_ms,
951
+ verdict: caseVerdict,
952
+ });
680
953
  }
681
954
  }
682
- return { findings: localFindings, cases: localCases, skipped: localSkipped };
955
+ return {
956
+ findings: localFindings,
957
+ cases: localCases,
958
+ skipped: localSkipped,
959
+ applicableChecks: [...localApplicable],
960
+ casesByCheck: localCasesByCheck,
961
+ positiveTotal: localPositiveTotal,
962
+ positiveTwoxx: localPositiveTwoxx,
963
+ };
683
964
  }
684
965
 
685
966
  // ARV-8: parallelize the op-loop. workers=1 (default) preserves the
686
967
  // sequential code path inside runPool — same microtask interleaving as
687
968
  // before, AC #4 backward-compat.
688
969
  const workers = opts.workers ?? 1;
689
- const opReports = await runPool(ops, workers, processOperation);
970
+ // ARV-328: progress accounting rides on op completion.
971
+ let opsDone = 0;
972
+ let casesDone = 0;
973
+ const opReports = await runPool(ops, workers, async (op: EndpointInfo) => {
974
+ const report = await processOperation(op);
975
+ opsDone += 1;
976
+ casesDone += report.cases;
977
+ opts.onProgress?.({ done: opsDone, total: ops.length, cases: casesDone });
978
+ return report;
979
+ });
690
980
 
691
- const findings: CheckFinding[] = [];
981
+ let findings: CheckFinding[] = [];
982
+ /** ARV-60: per-check accumulator for spec_findings rollup. Built from
983
+ * the per-op `applicableChecks` and `casesByCheck` fields each worker
984
+ * returns; skipped is reconstructed from `summary.skipped_outcomes`
985
+ * after the loop. */
986
+ const perCheckApplicable: Map<string, number> = new Map();
987
+ const perCheckCases: Map<string, number> = new Map();
988
+ // ARV-307: run-level positive-probe baseline health.
989
+ let positiveTotal = 0;
990
+ let positiveTwoxx = 0;
692
991
  for (const report of opReports) {
693
992
  summary.cases += report.cases;
993
+ positiveTotal += report.positiveTotal;
994
+ positiveTwoxx += report.positiveTwoxx;
694
995
  for (const [key, n] of Object.entries(report.skipped)) {
695
996
  summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + n;
696
997
  }
998
+ for (const id of report.applicableChecks) {
999
+ perCheckApplicable.set(id, (perCheckApplicable.get(id) ?? 0) + 1);
1000
+ }
1001
+ for (const [id, n] of Object.entries(report.casesByCheck)) {
1002
+ perCheckCases.set(id, (perCheckCases.get(id) ?? 0) + n);
1003
+ }
697
1004
  for (const f of report.findings) {
698
1005
  // ARV-251: stamp finding category from check id if not already
699
1006
  // present. Probes carry their own category; checks derive it
700
1007
  // from the check id. The bucket increment is the same code path.
701
1008
  if (!f.category) f.category = categoryFor(f.check);
702
1009
  findings.push(f);
1010
+ // ARV-283: suppressed findings stay in the buffer (for ndjson
1011
+ // audit-trail) but skip the summary tallies that drive CI gates.
1012
+ if (f.suppressed_by) {
1013
+ summary.suppressed = (summary.suppressed ?? 0) + 1;
1014
+ continue;
1015
+ }
703
1016
  summary.findings += 1;
704
1017
  summary.by_severity[f.severity] += 1;
705
1018
  summary.by_category[f.category] += 1;
@@ -722,8 +1035,13 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
722
1035
  mode,
723
1036
  );
724
1037
 
725
- if (activeStateful.length > 0) {
726
- const harness = makeHarness(opts.baseUrl, doc, {
1038
+ if (opts.skipStateful && activeStateful.length > 0) {
1039
+ const key = `stateful-skipped:budget`;
1040
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + activeStateful.length;
1041
+ }
1042
+
1043
+ if (!opts.skipStateful && activeStateful.length > 0) {
1044
+ const baseHarness = makeHarness(opts.baseUrl, doc, {
727
1045
  authHeaders: opts.authHeaders,
728
1046
  bootstrapCleanupFailed: opts.bootstrapCleanupFailed,
729
1047
  timeoutMs: opts.timeoutMs,
@@ -739,7 +1057,62 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
739
1057
  // cap of N applies to the whole run, not per-phase.
740
1058
  requestBudget,
741
1059
  });
742
- const crudGroups = activeStateful.some((c) => c.phase === "crud") ? detectCrudGroups(allOps) : [];
1060
+ // ARV-265: per-check harness wrapper that fires onCase for every
1061
+ // HTTP call the stateful check performs. The phase tag distinguishes
1062
+ // auth vs crud — used by the persistence adapter to bucket suite names.
1063
+ function harnessFor(checkId: string, phase: "stateful_auth" | "stateful_crud"): typeof baseHarness {
1064
+ if (!opts.onCase) return baseHarness;
1065
+ return {
1066
+ ...baseHarness,
1067
+ send: async (req, sendOpts) => {
1068
+ try {
1069
+ const resp = await baseHarness.send(req, sendOpts);
1070
+ opts.onCase!({
1071
+ phase,
1072
+ checkId,
1073
+ kind: checkId,
1074
+ operation: { method: req.method, path: req.url },
1075
+ request: req,
1076
+ response: resp,
1077
+ durationMs: resp.duration_ms,
1078
+ verdict: "pass",
1079
+ });
1080
+ return resp;
1081
+ } catch (err) {
1082
+ opts.onCase!({
1083
+ phase,
1084
+ checkId,
1085
+ kind: checkId,
1086
+ operation: { method: req.method, path: req.url },
1087
+ request: req,
1088
+ durationMs: 0,
1089
+ verdict: "error",
1090
+ error: (err as Error).message,
1091
+ });
1092
+ throw err;
1093
+ }
1094
+ },
1095
+ };
1096
+ }
1097
+ // ARV-332: build CRUD groups from the *filtered* op set (`ops`), not
1098
+ // `allOps`. Under a read-only scope (`--include method:GET`) the filter
1099
+ // strips POST/PUT/PATCH, so no group carries a `create`/`update` and the
1100
+ // mutating stateful checks (ensure_resource_availability, use_after_free)
1101
+ // self-skip via `applies(g)` — instead of leaking a live POST create
1102
+ // despite the GET-only filter. Read-only stateful checks (pagination
1103
+ // invariants, observation-mode lifecycle) still run on the list/read ops.
1104
+ // ARV-299: in safe mode, feed only read-only ops to the CRUD builder so
1105
+ // no group carries a create/update/delete — same self-skip path ARV-332
1106
+ // uses for `--include method:GET`, but driven by the safe/live toggle.
1107
+ const statefulOps = opts.safe
1108
+ ? ops.filter((o) => {
1109
+ const m = o.method.toUpperCase();
1110
+ return m === "GET" || m === "HEAD";
1111
+ })
1112
+ : ops;
1113
+ const crudGroups = activeStateful.some((c) => c.phase === "crud")
1114
+ ? augmentWithListOnlyGroups(detectCrudGroups(statefulOps), statefulOps)
1115
+ : [];
743
1116
  summary.checks_run += activeStateful.length;
744
1117
 
745
1118
  // ARV-8: parallelize auth-phase ops and crud-phase groups via the
@@ -751,14 +1124,33 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
751
1124
  function pushStateful(f: CheckFinding): void {
752
1125
  if (!f.category) f.category = categoryFor(f.check);
753
1126
  collected.push(f);
754
- summary.findings += 1;
755
- summary.by_severity[f.severity] += 1;
756
- summary.by_category[f.category] += 1;
1127
+ if (f.suppressed_by) {
1128
+ summary.suppressed = (summary.suppressed ?? 0) + 1;
1129
+ } else {
1130
+ summary.findings += 1;
1131
+ summary.by_severity[f.severity] += 1;
1132
+ summary.by_category[f.category] += 1;
1133
+ }
757
1134
  if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: f.check, finding: f });
758
1135
  }
759
1136
  for (const check of activeStateful) {
760
1137
  if (check.phase === "auth") {
761
- const applicable = ops.filter((op) => check.applies(op));
1138
+ const allApplicable = ops.filter((op) => check.applies(op));
1139
+ // ARV-189: spec-level x-zond-skip removes endpoints from the
1140
+ // worker pool entirely. The skip is surfaced via skipped_outcomes
1141
+ // so the operator sees how many ops were spec-suppressed.
1142
+ const applicable: typeof allApplicable = [];
1143
+ for (const op of allApplicable) {
1144
+ if (endpointSkipsCheck(op, check.id)) {
1145
+ const key = `${check.id}: ${reasonForSkip(op, check.id)}`;
1146
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
1147
+ summary.cases += 1;
1148
+ continue;
1149
+ }
1150
+ applicable.push(op);
1151
+ }
1152
+ // ARV-60: track applicability + cases for spec_findings rollup.
1153
+ perCheckApplicable.set(check.id, (perCheckApplicable.get(check.id) ?? 0) + applicable.length);
762
1154
  // ARV-154: track per-op cases + skip reasons for the stateful auth
763
1155
  // path. Previously this loop only forwarded `fail` outcomes; runs
764
1156
  // like `--check ignored_auth` on a fully-protected API where every
@@ -771,23 +1163,44 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
771
1163
  | { kind: "fail"; finding: CheckFinding }
772
1164
  | { kind: "skip"; reason: string }
773
1165
  | { kind: "pass" };
1166
+ const authHarness = harnessFor(check.id, "stateful_auth");
774
1167
  const opReports = await runPool<typeof applicable[number], StatefulOutcome>(
775
1168
  applicable,
776
1169
  statefulWorkers,
777
1170
  async (op): Promise<StatefulOutcome> => {
778
1171
  let outcome;
779
1172
  try {
780
- outcome = await check.run(op, harness);
1173
+ outcome = await check.run(op, authHarness);
781
1174
  } catch (err) {
782
1175
  outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
783
1176
  }
1177
+ // ARV-314: emit check_result for stateful checks too, so the ndjson
1178
+ // event schema is stable regardless of --check selection (the
1179
+ // per-response phase already does this). Without it a consumer keyed
1180
+ // on .type=="check_result" got zero rows from a stateful-only run.
1181
+ if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail")) {
1182
+ opts.onEvent({
1183
+ type: "check_result",
1184
+ ts: nowIso(),
1185
+ check: check.id,
1186
+ // ARV-351: match the finding's severity (see per-response path).
1187
+ severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
1188
+ verdict: outcome.kind,
1189
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
1190
+ request_signature: `${op.method.toUpperCase()} ${op.path}`,
1191
+ response: { status: (outcome.kind === "fail" ? outcome.responseStatus : undefined) ?? 0 },
1192
+ });
1193
+ }
784
1194
  if (outcome.kind === "fail") {
1195
+ // ARV-286 (follow-up ARV-284): respect per-finding severity
1196
+ // returned by stateful check via `outcome.severity` — declared
1197
+ // `check.severity` is the proof-cap baseline.
785
1198
  const finding: CheckFinding = {
786
1199
  check: check.id,
787
- severity: check.severity,
1200
+ severity: outcome.severity ?? check.severity,
788
1201
  operation: { path: op.path, method: op.method, operationId: op.operationId },
789
1202
  request_signature: `${op.method.toUpperCase()} ${op.path}`,
790
- response_summary: { status: 0 },
1203
+ response_summary: { status: outcome.responseStatus ?? 0 },
791
1204
  message: outcome.message,
792
1205
  evidence: outcome.evidence,
793
1206
  recommended_action: recommendForCheck(check.id),
@@ -801,6 +1214,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
801
1214
  });
802
1215
  for (const o of opReports) {
803
1216
  summary.cases += 1;
1217
+ perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
804
1218
  if (o.kind === "fail") pushStateful(o.finding);
805
1219
  else if (o.kind === "skip") {
806
1220
  const key = `${check.id}: ${o.reason}`;
@@ -808,31 +1222,86 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
808
1222
  }
809
1223
  }
810
1224
  } else {
811
- const applicable = crudGroups.filter((g) => check.applies(g));
1225
+ const allApplicable = crudGroups.filter((g) => check.applies(g));
1226
+ // ARV-189: spec-level x-zond-skip on the resource's canonical
1227
+ // endpoint (create > list > read) opts the entire CRUD group
1228
+ // out — `x-zond-skip: [...]` placed on POST /widgets suppresses
1229
+ // every stateful check listed there for the whole widget chain.
1230
+ const applicable: typeof allApplicable = [];
1231
+ for (const g of allApplicable) {
1232
+ const repOp = representativeOp(g);
1233
+ if (repOp && endpointSkipsCheck(repOp, check.id)) {
1234
+ const key = `${check.id}: ${reasonForSkip(repOp, check.id)}`;
1235
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
1236
+ summary.cases += 1;
1237
+ continue;
1238
+ }
1239
+ applicable.push(g);
1240
+ }
1241
+ // ARV-60: track applicability + cases for spec_findings rollup.
1242
+ perCheckApplicable.set(check.id, (perCheckApplicable.get(check.id) ?? 0) + applicable.length);
812
1243
  // ARV-154: mirror the auth-phase observability — count CRUD groups
813
1244
  // attempted and record skip reasons, not just failures.
814
1245
  type StatefulOutcome =
815
1246
  | { kind: "fail"; finding: CheckFinding }
816
1247
  | { kind: "skip"; reason: string }
817
1248
  | { kind: "pass" };
1249
+ const crudHarness = harnessFor(check.id, "stateful_crud");
818
1250
  const groupReports = await runPool<typeof applicable[number], StatefulOutcome>(
819
1251
  applicable,
820
1252
  statefulWorkers,
821
1253
  async (group): Promise<StatefulOutcome> => {
822
1254
  let outcome;
823
1255
  try {
824
- outcome = await check.run(group, harness);
1256
+ outcome = await check.run(group, crudHarness);
825
1257
  } catch (err) {
826
1258
  outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
827
1259
  }
1260
+ // ARV-314: emit check_result for CRUD-stateful checks too (see the
1261
+ // auth loop above) so the ndjson event schema stays stable.
1262
+ // ARV-319: `group.create`/`.read` are BOTH optional (a group can be
1263
+ // update/delete/list-only) — the earlier `group.create ?? group.read!`
1264
+ // non-null assertion crashed with "undefined is not an object" the
1265
+ // first time a check passed/failed on such a group (Stripe has
1266
+ // plenty: update-only or list-only resources). representativeOp()
1267
+ // widens the fallback chain and stays undefined-safe.
1268
+ const groupRepOp = representativeOp(group);
1269
+ const outcomeOp = outcome.kind === "fail" ? outcome.operation : undefined;
1270
+ const evOp = outcomeOp ?? groupRepOp;
1271
+ if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail") && evOp) {
1272
+ opts.onEvent({
1273
+ type: "check_result",
1274
+ ts: nowIso(),
1275
+ check: check.id,
1276
+ // ARV-351: match the finding's severity (see finding below).
1277
+ severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
1278
+ verdict: outcome.kind,
1279
+ operation: evOp,
1280
+ request_signature: `${evOp.method.toUpperCase()} ${evOp.path} (chain)`,
1281
+ response: { status: (outcome.kind === "fail" ? outcome.responseStatus : undefined) ?? 0 },
1282
+ });
1283
+ }
828
1284
  if (outcome.kind === "fail") {
829
- const repOp = group.create ?? group.read!;
1285
+ // ARV-310: prefer the check's explicit operation attribution (e.g.
1286
+ // cursor_boundary_fuzzing → the GET list it probed) over the
1287
+ // group's canonical create/read op. ARV-319: groupRepOp can be
1288
+ // undefined for a group with no create/list/read/update/delete
1289
+ // (shouldn't happen if `check.applies()` gated correctly, but
1290
+ // this path must not crash if it does) — fall back to a
1291
+ // synthetic operation rather than dereferencing undefined.
1292
+ const opFor = outcome.operation ?? (groupRepOp
1293
+ ? { path: groupRepOp.path, method: groupRepOp.method, operationId: groupRepOp.operationId }
1294
+ : { path: group.basePath, method: "UNKNOWN" });
1295
+ // ARV-287/288 (follow-up ARV-284): respect per-finding severity
1296
+ // from stateful CRUD checks (cross_call_references,
1297
+ // pagination_invariants) — declared severity is the proof-cap
1298
+ // baseline.
830
1299
  const finding: CheckFinding = {
831
1300
  check: check.id,
832
- severity: check.severity,
833
- operation: { path: repOp.path, method: repOp.method, operationId: repOp.operationId },
834
- request_signature: `${repOp.method.toUpperCase()} ${repOp.path} (chain)`,
835
- response_summary: { status: 0 },
1301
+ severity: outcome.severity ?? check.severity,
1302
+ operation: opFor,
1303
+ request_signature: `${opFor.method.toUpperCase()} ${opFor.path} (chain)`,
1304
+ response_summary: { status: outcome.responseStatus ?? 0 },
836
1305
  message: outcome.message,
837
1306
  evidence: outcome.evidence,
838
1307
  recommended_action: recommendForCheck(check.id),
@@ -846,6 +1315,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
846
1315
  });
847
1316
  for (const o of groupReports) {
848
1317
  summary.cases += 1;
1318
+ perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
849
1319
  if (o.kind === "fail") pushStateful(o.finding);
850
1320
  else if (o.kind === "skip") {
851
1321
  const key = `${check.id}: ${o.reason}`;
@@ -857,18 +1327,135 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
857
1327
  findings.push(...collected);
858
1328
  }
859
1329
 
1330
+ // ARV-307: run-level broken-baseline guard for the conformance family.
1331
+ // When the positive-probe baseline was degenerate (>90% non-2xx), the
1332
+ // conformance findings are baseline artifacts — replace the per-op pile
1333
+ // with a single broken_baseline spec_finding and decrement the summary
1334
+ // tallies for the removed findings so CI gates and category rollups match.
1335
+ const baselineGuard = applyBrokenBaselineGuard({ findings, positiveTotal, positiveTwoxx });
1336
+ const extraSpecFindings: SpecFinding[] = [];
1337
+ if (baselineGuard.specFinding) {
1338
+ findings = baselineGuard.kept;
1339
+ for (const f of baselineGuard.removed) {
1340
+ summary.findings -= 1;
1341
+ summary.by_severity[f.severity] -= 1;
1342
+ if (f.category) summary.by_category[f.category] -= 1;
1343
+ // ARV-322: the finding event already streamed to NDJSON before the
1344
+ // guard fired, so a consumer counting `type:finding` records would
1345
+ // see one more than `summary.findings`. Route the removal through
1346
+ // `summary.suppressed` (same bucket as ARV-283 calibration
1347
+ // suppressions) so `findings + suppressed` always reconciles with
1348
+ // the stream instead of silently diverging.
1349
+ summary.suppressed = (summary.suppressed ?? 0) + 1;
1350
+ }
1351
+ const reasonKey = `status_code_conformance: broken-baseline (${baselineGuard.removed.length} conformance finding(s) suppressed)`;
1352
+ summary.skipped_outcomes[reasonKey] = (summary.skipped_outcomes[reasonKey] ?? 0) + baselineGuard.removed.length;
1353
+ extraSpecFindings.push(baselineGuard.specFinding);
1354
+ }
1355
+
860
1356
  const highOrCritical = findings.filter(
861
1357
  (f) => f.severity === "high" || f.severity === "critical",
862
1358
  ).length;
863
1359
 
1360
+ // ARV-60: spec-level rollup. Build per-check observations from the
1361
+ // accumulators above + the skipped-outcome buckets keyed by
1362
+ // `<check_id>: <reason>`. Then compute clusters that cross the 80%
1363
+ // threshold so the CLI / JSON envelope / NDJSON stream all agree on
1364
+ // which findings are really "one spec gap × N sites".
1365
+ const perCheck: Map<string, PerCheckObservations> = new Map();
1366
+ const allCheckIds = new Set<string>([
1367
+ ...perCheckApplicable.keys(),
1368
+ ...perCheckCases.keys(),
1369
+ ]);
1370
+ for (const id of allCheckIds) {
1371
+ const skipped: Record<string, number> = {};
1372
+ const prefix = `${id}: `;
1373
+ for (const [key, n] of Object.entries(summary.skipped_outcomes)) {
1374
+ if (key.startsWith(prefix)) skipped[key] = n;
1375
+ }
1376
+ perCheck.set(id, {
1377
+ applicable: perCheckApplicable.get(id) ?? 0,
1378
+ cases: perCheckCases.get(id) ?? 0,
1379
+ skipped,
1380
+ });
1381
+ }
1382
+ // ARV-307: broken-baseline rollup(s) prepend the computed clusters so the
1383
+ // reader sees "your baseline is broken" before any residual per-op rows.
1384
+ const spec_findings = [...extraSpecFindings, ...computeSpecFindings(findings, perCheck)];
1385
+ // ARV-83: build the structured view of `skipped_outcomes` once, after
1386
+ // all per-op/per-group writers have settled. Sorted by descending count
1387
+ // so the most-impactful skip reason lands first.
1388
+ summary.skipped_outcomes_grouped = groupSkippedOutcomes(summary.skipped_outcomes);
1389
+
1390
+ // ARV-60: emit each spec finding as its own NDJSON event before the
1391
+ // terminal summary line, so a streaming consumer sees rollups in the
1392
+ // same order the CLI prints them.
1393
+ if (opts.onEvent) {
1394
+ for (const sf of spec_findings) {
1395
+ opts.onEvent({ type: "spec_finding", ts: nowIso(), check: sf.check, spec_finding: sf });
1396
+ }
1397
+ }
1398
+
864
1399
  // ARV-10: terminal event so downstream consumers know the run wrapped
865
1400
  // (vs. the producer crashing). Mirrors what the JSON envelope's
866
1401
  // `summary` field carries, just delivered as the final NDJSON line.
867
1402
  if (opts.onEvent) opts.onEvent({ type: "summary", ts: nowIso(), summary });
868
1403
 
869
1404
  return {
870
- data: { findings, summary },
1405
+ data: { findings, summary, spec_findings },
871
1406
  selection,
872
1407
  high_or_critical: highOrCritical,
873
1408
  };
874
1409
  }
1410
+
1411
+ /**
1412
+ * ARV-219 follow-up: `detectCrudGroups` only emits groups for resources
1413
+ * with a POST endpoint, so list-only collections (workflow runs, search
1414
+ * results, public lists) never reach the stateful CRUD phase. Several
1415
+ * stateful checks operate on lists alone:
1416
+ * - `pagination_invariants` (page/cursor disjointness)
1417
+ * - `lifecycle_transitions` observation mode (observed ⊆ declared states)
1418
+ *
1419
+ * Synthesize minimal groups for list-only resources surfaced by the
1420
+ * resource-map builder (which already knows how to identify
1421
+ * implicit-list paths via `ownerListPaths`). The synthesized group
1422
+ * carries just `list` + optional `read` — sufficient for the lookups
1423
+ * each list-only check performs. Resources already covered by a real
1424
+ * CRUD group (matched by name) are not duplicated.
1425
+ */
1426
+ function augmentWithListOnlyGroups(crudGroups: CrudGroup[], allOps: EndpointInfo[]): CrudGroup[] {
1427
+ let map;
1428
+ try {
1429
+ map = buildApiResourceMap({ endpoints: allOps, specHash: "transient" });
1430
+ } catch {
1431
+ // Defensive: never fail the run because the resource map couldn't
1432
+ // be built (real CRUD groups still run).
1433
+ return crudGroups;
1434
+ }
1435
+ const existing = new Set(crudGroups.map(g => g.resource));
1436
+ const findEp = (label: string | undefined): EndpointInfo | undefined => {
1437
+ if (!label) return undefined;
1438
+ const idx = label.indexOf(" ");
1439
+ if (idx === -1) return undefined;
1440
+ const method = label.slice(0, idx).toUpperCase();
1441
+ const path = label.slice(idx + 1);
1442
+ return allOps.find(e => e.method.toUpperCase() === method && e.path === path);
1443
+ };
1444
+ const augmented: CrudGroup[] = [];
1445
+ for (const r of map.resources) {
1446
+ if (existing.has(r.resource)) continue;
1447
+ if (!r.endpoints.list) continue;
1448
+ const listEp = findEp(r.endpoints.list);
1449
+ if (!listEp) continue;
1450
+ const readEp = findEp(r.endpoints.read);
1451
+ augmented.push({
1452
+ resource: r.resource,
1453
+ basePath: r.basePath,
1454
+ itemPath: r.itemPath,
1455
+ idParam: r.idParam,
1456
+ list: listEp,
1457
+ ...(readEp ? { read: readEp } : {}),
1458
+ });
1459
+ }
1460
+ return augmented.length > 0 ? [...crudGroups, ...augmented] : crudGroups;
1461
+ }