@kirrosh/zond 0.22.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,1461 @@
1
+ /**
2
+ * Pipeline that turns an OpenAPI spec into `CheckFinding`s by:
3
+ * 1. enumerating operations,
4
+ * 2. for each op, generating one case per *requested* probe kind
5
+ * (positive / missing_required_header / unsupported_method),
6
+ * 3. sending each request,
7
+ * 4. running every active check whose `caseKinds` includes the kind.
8
+ *
9
+ * The runner only generates kinds an active check actually needs — so
10
+ * a `--check not_a_server_error` run never sends the extra probe
11
+ * requests; a `--check unsupported_method` run sends only the method
12
+ * probe, etc.
13
+ */
14
+ import type { OpenAPIV3 } from "openapi-types";
15
+
16
+ import { extractEndpoints, readOpenApiSpec } from "../generator/index.ts";
17
+ import { detectCrudGroups } from "../generator/suite-generator.ts";
18
+ import { buildApiResourceMap } from "../generator/resources-builder.ts";
19
+ import type { CrudGroup } from "../generator/types.ts";
20
+ import type { EndpointInfo } from "../generator/types.ts";
21
+ import { generateFromSchema } from "../generator/data-factory.ts";
22
+ import {
23
+ enumerateBoundaryCases,
24
+ enumerateParamBoundaryCases,
25
+ type ParamCoverageCase,
26
+ } from "../generator/coverage-phase.ts";
27
+ import { executeRequest } from "../runner/http-client.ts";
28
+ import { reserveRequest, MAX_REQUESTS_SKIP_REASON, type RequestBudget } from "../runner/executor.ts";
29
+ import { createSchemaValidator, type SchemaValidator } from "../runner/schema-validator.ts";
30
+ import type { HttpRequest, HttpResponse } from "../runner/types.ts";
31
+ import {
32
+ ALL_METHODS,
33
+ bucketEndpointsByPath,
34
+ pathWithMethodPlaceholders,
35
+ } from "../probe/method-shared.ts";
36
+
37
+ import "./checks/index.ts"; // side-effect: register builtins
38
+ import { selectChecks, type SelectionResult } from "./registry.ts";
39
+ import { listStatefulChecks, makeHarness } from "./stateful.ts";
40
+ import { caseMatchesMode, filterChecksByMode, type Mode } from "./mode.ts";
41
+ import { buildNegativeBody } from "./checks/_negative_mutator.ts";
42
+ import { nowIso, type NdjsonEvent } from "../reporter/ndjson.ts";
43
+ import { runPool } from "../runner/async-pool.ts";
44
+ import type { RateLimiter } from "../runner/rate-limiter.ts";
45
+ import { recommendForCheck } from "./recommended-action.ts";
46
+ import { gapKey } from "../workspace/fixture-gaps.ts";
47
+ import { endpointSkipsCheck, reasonForSkip } from "./zond-extensions.ts";
48
+ import {
49
+ emptySummary,
50
+ groupSkippedOutcomes,
51
+ type CaseKind,
52
+ type Check,
53
+ type CheckCase,
54
+ type CheckFinding,
55
+ type CheckRunData,
56
+ type CheckRunSummary,
57
+ type SpecFinding,
58
+ } from "./types.ts";
59
+ import type { Severity } from "../severity/index.ts";
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
+ }
97
+
98
+ export interface RunChecksOptions {
99
+ specPath: string;
100
+ baseUrl: string;
101
+ include?: string[];
102
+ exclude?: string[];
103
+ timeoutMs?: number;
104
+ /** Limit the operation set — used by `--include`/`--exclude` regex
105
+ * filtering in ARV-9. ARV-1 only exposes the hook. */
106
+ operationFilter?: (op: EndpointInfo) => boolean;
107
+ /** ARV-3 — auth headers fed to stateful security checks. CLI lifts
108
+ * these from `--auth-header` flags and/or the api's `.env.yaml`. */
109
+ authHeaders?: Record<string, string>;
110
+ /** ARV-3 AC #6 — when true, security checks return skip with a
111
+ * warning. The CLI surfaces this as `--bootstrap-cleanup-failed`. */
112
+ bootstrapCleanupFailed?: boolean;
113
+ /** ARV-6 — `examples` (current default: one positive + the
114
+ * single-site negative mutator) vs `coverage` (deterministic
115
+ * boundary-value enumeration over the body schema) vs `all` (both).
116
+ * Coverage cases carry `meta.boundary` and `meta.phase = "coverage"`
117
+ * for the SARIF reporter and reproducer hints. */
118
+ phase?: "examples" | "coverage" | "all";
119
+ /** ARV-6 AC #5 — gate the NUL byte (\x00) in string boundaries.
120
+ * Off by default because some HTTP/JSON stacks panic on it. */
121
+ allowX00?: boolean;
122
+ /** ARV-7 — `positive` (contract verification only), `negative`
123
+ * (malicious input only), `all` (default — both). Drops both checks
124
+ * and cases that don't belong to the requested mode. */
125
+ mode?: Mode;
126
+ /** ARV-10 — synchronous streaming hook. Fires per
127
+ * `check_start` / `check_result` / `finding` / `summary` event so the
128
+ * NDJSON reporter can flush each line as it happens (instead of
129
+ * buffering until the run finishes). Must not throw — exceptions are
130
+ * the caller's responsibility (the runner doesn't catch). */
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;
138
+ /** ARV-8 — bounded async-pool concurrency at the *operation* level.
139
+ * `1` (default) = sequential, identical to the pre-ARV-8 behaviour.
140
+ * Cases within an operation always run sequentially regardless of
141
+ * this — share state (e.g. CRUD chains) lives at op-level, not
142
+ * case-level, so case-parallelism would corrupt it. */
143
+ workers?: number;
144
+ /** ARV-8 — gate every outbound HTTP request through the limiter so
145
+ * bursts of parallel workers respect a global RPS budget (also
146
+ * reacts to RateLimit-* headers via `note()`). */
147
+ rateLimiter?: RateLimiter;
148
+ /** ARV-179: opt-in strict-405 semantics for `unsupported_method`.
149
+ * Off by default — see `CheckRuntimeOptions.strict405` for rationale. */
150
+ strict405?: boolean;
151
+ /** ARV-181: opt-in strict-401 semantics for `ignored_auth`. Off by
152
+ * default — see `CheckRuntimeOptions.strict401` for rationale. */
153
+ strict401?: boolean;
154
+ /** ARV-169 (m-20): per-resource overrides for stateful checks
155
+ * (cross-call drift today; idempotency/pagination/lifecycle next).
156
+ * CLI loads them from `.api-resources.yaml` + `.api-resources.local.yaml`
157
+ * and hands them in; tests pass a literal Map. Optional — undefined
158
+ * ⇒ each probe uses its built-in defaults. */
159
+ resourceConfigs?: Map<string, {
160
+ readbackDiff?: import("../generator/resources-builder.ts").ReadbackDiffConfig;
161
+ idempotency?: import("../generator/resources-builder.ts").IdempotencyConfig;
162
+ pagination?: import("../generator/resources-builder.ts").PaginationConfig;
163
+ lifecycle?: import("../generator/resources-builder.ts").LifecycleConfig;
164
+ }>;
165
+ /** ARV-141: substitute real fixture values into path-param placeholders so
166
+ * the deterministic synthetic 404 (`/issues/x`) becomes a real-id 200/422
167
+ * whenever `.env.yaml` actually has a fixture. This makes `checks run`
168
+ * reactive to fixture-pack growth — without it, two runs against the same
169
+ * spec emit pixel-identical findings/skip counts regardless of how many
170
+ * vars are filled. Keyed by path-param name (e.g. `issue_id`); falls back
171
+ * to the legacy schema-driven placeholder when the name isn't in the map. */
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;
189
+ /** ARV-227: hard cap on outbound HTTP requests for the entire run.
190
+ * Once `used >= limit`, every subsequent case short-circuits and the
191
+ * summary surfaces the cap via `summary.skipped_outcomes
192
+ * ["max-requests-cap-reached"]`. Stateful-phase sends count toward
193
+ * the same budget so a cap of 100 means 100 requests total across
194
+ * per-response + stateful, not per-phase. Undefined ⇒ uncapped. */
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;
214
+ }
215
+
216
+ export interface RunChecksResult {
217
+ data: CheckRunData;
218
+ selection: SelectionResult;
219
+ /** HIGH/CRITICAL findings count — drives the exit code. */
220
+ high_or_critical: number;
221
+ }
222
+
223
+ function placeholderForParam(p: OpenAPIV3.ParameterObject): string {
224
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
225
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
226
+ if (schema?.type === "integer" || schema?.type === "number") return "1";
227
+ return "x";
228
+ }
229
+
230
+ function fillPathParams(
231
+ path: string,
232
+ op: EndpointInfo,
233
+ pathVars?: Record<string, string>,
234
+ ): string {
235
+ return path.replace(/\{([^}]+)\}/g, (_, name) => {
236
+ // ARV-141: real fixture wins over schema-derived placeholder.
237
+ const real = pathVars?.[name];
238
+ if (typeof real === "string" && real.length > 0) {
239
+ return encodeURIComponent(real);
240
+ }
241
+ const match = op.parameters.find(
242
+ (p) => (p as OpenAPIV3.ParameterObject).in === "path"
243
+ && (p as OpenAPIV3.ParameterObject).name === name,
244
+ );
245
+ return match
246
+ ? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
247
+ : "1";
248
+ });
249
+ }
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
+
293
+ function requiredHeaders(op: EndpointInfo): OpenAPIV3.ParameterObject[] {
294
+ return op.parameters.filter(
295
+ (p) => (p as OpenAPIV3.ParameterObject).in === "header"
296
+ && (p as OpenAPIV3.ParameterObject).required === true,
297
+ ) as OpenAPIV3.ParameterObject[];
298
+ }
299
+
300
+ function buildBaseHeaders(op: EndpointInfo, opts: { withRequired: boolean }): Record<string, string> {
301
+ const headers: Record<string, string> = { Accept: "application/json" };
302
+ if (opts.withRequired) {
303
+ for (const h of requiredHeaders(op)) {
304
+ headers[h.name] = "x";
305
+ }
306
+ }
307
+ if (op.requestBodySchema && op.method.toUpperCase() !== "GET" && op.method.toUpperCase() !== "DELETE") {
308
+ headers["Content-Type"] = op.requestBodyContentType ?? "application/json";
309
+ }
310
+ return headers;
311
+ }
312
+
313
+ function buildBody(op: EndpointInfo): string | undefined {
314
+ if (!op.requestBodySchema) return undefined;
315
+ const m = op.method.toUpperCase();
316
+ if (m === "GET" || m === "DELETE") return undefined;
317
+ return JSON.stringify(generateFromSchema(op.requestBodySchema));
318
+ }
319
+
320
+ interface BuiltCase {
321
+ req: HttpRequest;
322
+ case: CheckCase;
323
+ }
324
+
325
+ function buildPositive(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase {
326
+ const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
327
+ const headers = buildBaseHeaders(op, { withRequired: true });
328
+ const body = buildBody(op);
329
+ const req: HttpRequest = { method: op.method.toUpperCase(), url, headers, body };
330
+ const c: CheckCase = {
331
+ operation: op,
332
+ request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
333
+ mode: "positive",
334
+ kind: "positive",
335
+ };
336
+ return { req, case: c };
337
+ }
338
+
339
+ /** ARV-184: emit one BuiltCase per required header — drop that header
340
+ * in isolation so `missing_required_header` can identify *which* one
341
+ * the server fails to enforce. Pre-fix this emitted just the first
342
+ * required header (`required[0]`), which on Stripe-style specs with
343
+ * multiple per-op headers (Stripe-Version, Stripe-Account, ...) gave
344
+ * ≤1 finding per op vs schemathesis V4 ~42 in the same overlap. */
345
+ function buildMissingHeader(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase[] {
346
+ const required = requiredHeaders(op);
347
+ if (required.length === 0) return [];
348
+ const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
349
+ const body = buildBody(op);
350
+ const method = op.method.toUpperCase();
351
+ return required.map((header) => {
352
+ const headers = buildBaseHeaders(op, { withRequired: true });
353
+ delete headers[header.name];
354
+ const req: HttpRequest = { method, url, headers, body };
355
+ return {
356
+ req,
357
+ case: {
358
+ operation: op,
359
+ request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
360
+ mode: "negative" as const,
361
+ kind: "missing_required_header" as const,
362
+ meta: { dropped_header: header.name },
363
+ },
364
+ };
365
+ });
366
+ }
367
+
368
+ /** ARV-6: emit one BuiltCase per (field × boundary) over the body schema.
369
+ * Valid boundaries ride as `kind: "positive"` so positive_data_acceptance
370
+ * evaluates them; invalid boundaries ride as `kind: "negative_data"` so
371
+ * negative_data_rejection evaluates them. Both carry `meta.boundary` and
372
+ * `meta.phase: "coverage"` so the finding surfaces *which* boundary the
373
+ * server tripped on. */
374
+ function buildCoverageCases(
375
+ op: EndpointInfo,
376
+ baseUrl: string,
377
+ opts: { allowX00?: boolean; pathVars?: Record<string, string> },
378
+ ): BuiltCase[] {
379
+ if (!op.requestBodySchema) return [];
380
+ const m = op.method.toUpperCase();
381
+ if (m === "GET" || m === "DELETE") return [];
382
+ const cases = enumerateBoundaryCases(op.requestBodySchema, { allowX00: opts.allowX00 });
383
+ const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, opts.pathVars)}`;
384
+ const headers = buildBaseHeaders(op, { withRequired: true });
385
+ const out: BuiltCase[] = [];
386
+ for (const cc of cases) {
387
+ const body = JSON.stringify(cc.body);
388
+ const req: HttpRequest = { method: m, url, headers, body };
389
+ const kind: CaseKind = cc.valid ? "positive" : "negative_data";
390
+ out.push({
391
+ req,
392
+ case: {
393
+ operation: op,
394
+ request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
395
+ mode: cc.valid ? "positive" : "negative",
396
+ kind,
397
+ meta: {
398
+ phase: "coverage",
399
+ boundary: cc.boundary,
400
+ field_path: cc.field_path,
401
+ mutation: "boundary",
402
+ },
403
+ },
404
+ });
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /** ARV-180: build a URL whose path/query parameters reflect a coverage
410
+ * mutation. The positive baseline fills every param with a valid
411
+ * shape; this helper takes that baseline and swaps the named param
412
+ * with the mutation value (or drops it, for `drop-required-query`).
413
+ * Path mutations rewrite the placeholder for the named param only —
414
+ * all other path-vars keep their valid baseline values, so the URL
415
+ * still reaches the routing layer. */
416
+ function buildParamMutatedUrl(
417
+ baseUrl: string,
418
+ op: EndpointInfo,
419
+ mut: ParamCoverageCase,
420
+ pathVars: Record<string, string> | undefined,
421
+ ): string {
422
+ // Start with the valid baseline path (placeholders filled).
423
+ let pathStr = op.path;
424
+ if (mut.location === "path") {
425
+ // Rewrite only the targeted placeholder; everything else gets the
426
+ // valid baseline (path-vars > schema-derived placeholder).
427
+ pathStr = op.path.replace(/\{([^}]+)\}/g, (_, name) => {
428
+ if (name === mut.paramName) return encodeURIComponent(String(mut.value));
429
+ const real = pathVars?.[name];
430
+ if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
431
+ const match = op.parameters.find(
432
+ (p) => (p as OpenAPIV3.ParameterObject).in === "path"
433
+ && (p as OpenAPIV3.ParameterObject).name === name,
434
+ );
435
+ return match
436
+ ? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
437
+ : "1";
438
+ });
439
+ } else {
440
+ pathStr = fillPathParams(op.path, op, pathVars);
441
+ }
442
+ let url = `${baseUrl.replace(/\/+$/, "")}${pathStr}`;
443
+ if (mut.location === "query") {
444
+ const qp = new URLSearchParams();
445
+ // Seed required query params with valid baseline values so the
446
+ // mutation is single-site.
447
+ for (const p of op.parameters) {
448
+ const pp = p as OpenAPIV3.ParameterObject;
449
+ if (pp.in !== "query") continue;
450
+ if (pp.name === mut.paramName) {
451
+ if (mut.scenario === "drop-required-query") continue; // drop
452
+ qp.append(pp.name, String(mut.value));
453
+ continue;
454
+ }
455
+ if (pp.required === true) {
456
+ qp.append(pp.name, placeholderForParam(pp));
457
+ }
458
+ }
459
+ const qs = qp.toString();
460
+ if (qs.length > 0) url += `?${qs}`;
461
+ }
462
+ return url;
463
+ }
464
+
465
+ /** ARV-180: emit one BuiltCase per (param × scenario) for the
466
+ * operation. All cases ride as `kind: "negative_data"` so
467
+ * `negative_data_rejection` evaluates "did the server reject?", and
468
+ * `status_code_conformance` (now declares `negative_data` in its
469
+ * caseKinds) evaluates "is the resulting status code documented?".
470
+ * This is the cheap-fix gap for `status_code_conformance` on
471
+ * GET-heavy APIs where the body-coverage walker emits zero cases. */
472
+ function buildParamCoverageCases(
473
+ op: EndpointInfo,
474
+ baseUrl: string,
475
+ opts: { allowX00?: boolean; pathVars?: Record<string, string> },
476
+ ): BuiltCase[] {
477
+ const params = op.parameters as OpenAPIV3.ParameterObject[];
478
+ const mutations = enumerateParamBoundaryCases(params, { allowX00: opts.allowX00 });
479
+ if (mutations.length === 0) return [];
480
+ const m = op.method.toUpperCase();
481
+ const headers = buildBaseHeaders(op, { withRequired: true });
482
+ const body = buildBody(op);
483
+ const out: BuiltCase[] = [];
484
+ for (const mut of mutations) {
485
+ const url = buildParamMutatedUrl(baseUrl, op, mut, opts.pathVars);
486
+ const req: HttpRequest = { method: m, url, headers, body };
487
+ out.push({
488
+ req,
489
+ case: {
490
+ operation: op,
491
+ request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
492
+ mode: "negative",
493
+ kind: "negative_data",
494
+ meta: {
495
+ phase: "coverage",
496
+ param_scenario: mut.scenario,
497
+ param_name: mut.paramName,
498
+ param_location: mut.location,
499
+ mutation: "param-boundary",
500
+ },
501
+ },
502
+ });
503
+ }
504
+ return out;
505
+ }
506
+
507
+ function buildNegativeData(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase | null {
508
+ if (!op.requestBodySchema) return null;
509
+ const m = op.method.toUpperCase();
510
+ if (m === "GET" || m === "DELETE") return null;
511
+ const mutated = buildNegativeBody(op.requestBodySchema);
512
+ if (!mutated) return null;
513
+ const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
514
+ const headers = buildBaseHeaders(op, { withRequired: true });
515
+ const body = JSON.stringify(mutated.body);
516
+ const req: HttpRequest = { method: m, url, headers, body };
517
+ const c: CheckCase = {
518
+ operation: op,
519
+ request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
520
+ mode: "negative",
521
+ kind: "negative_data",
522
+ meta: { ...mutated.meta },
523
+ };
524
+ return { req, case: c };
525
+ }
526
+
527
+ /** For `unsupported_method` we send every method that isn't declared on
528
+ * the *path bucket*. ARV-179: pre-fix this emitted just `missing[0]`,
529
+ * which produced ≈1 finding per path on real APIs (vs schemathesis's
530
+ * per-method enumeration that finds 100+ on the same target). The
531
+ * check itself coalesces results per-(path, undeclared-method) pair,
532
+ * so a path with 4 missing methods yields up to 4 findings. The
533
+ * per-path "one owner" rule still applies — only the owner-op emits
534
+ * the bucket — so we don't double-count on multi-method paths. */
535
+ function buildUnsupportedMethod(
536
+ op: EndpointInfo,
537
+ declaredOnPath: Set<string>,
538
+ baseUrl: string,
539
+ ): BuiltCase[] {
540
+ const declaredUpper = new Set(Array.from(declaredOnPath, (m) => m.toUpperCase()));
541
+ const missing = ALL_METHODS.filter((m) => !declaredUpper.has(m));
542
+ if (missing.length === 0) return [];
543
+ const concretePath = pathWithMethodPlaceholders(op.path, op.parameters);
544
+ const url = `${baseUrl.replace(/\/+$/, "")}${concretePath}`;
545
+ return missing.map((method) => {
546
+ const headers: Record<string, string> = { Accept: "application/json" };
547
+ let body: string | undefined;
548
+ if (method === "POST" || method === "PUT" || method === "PATCH") {
549
+ headers["Content-Type"] = "application/json";
550
+ body = "{}";
551
+ }
552
+ const req: HttpRequest = { method, url, headers, body };
553
+ const c: CheckCase = {
554
+ operation: op,
555
+ request: { method, url, headers, body },
556
+ mode: "negative",
557
+ kind: "unsupported_method",
558
+ meta: { undeclared_method: method },
559
+ };
560
+ return { req, case: c };
561
+ });
562
+ }
563
+
564
+ function checkKinds(c: Check): CaseKind[] {
565
+ return c.caseKinds ?? ["positive"];
566
+ }
567
+
568
+ /** ARV-61 (feedback round-01 / F1): inject auth headers into a response-phase
569
+ * case so depth-checks pierce the auth-wall on real APIs. Case-specific
570
+ * headers win (case-insensitive). `missing_required_header` deliberately
571
+ * drops one header — if the dropped one matches an auth header, skip the
572
+ * injection for that key so the probe stays meaningful. */
573
+ function injectAuthHeadersIntoCase(built: BuiltCase, authHeaders: Record<string, string>): void {
574
+ if (!authHeaders || Object.keys(authHeaders).length === 0) return;
575
+ const existing = new Set(Object.keys(built.req.headers).map((k) => k.toLowerCase()));
576
+ const droppedLower =
577
+ built.case.kind === "missing_required_header" && typeof built.case.meta?.dropped_header === "string"
578
+ ? (built.case.meta.dropped_header as string).toLowerCase()
579
+ : null;
580
+ for (const [name, value] of Object.entries(authHeaders)) {
581
+ const lower = name.toLowerCase();
582
+ if (existing.has(lower)) continue;
583
+ if (droppedLower === lower) continue;
584
+ built.req.headers[name] = value;
585
+ built.case.request.headers[name] = value;
586
+ }
587
+ }
588
+
589
+ function summarizeResponse(resp: HttpResponse): { status: number; content_type?: string } {
590
+ const ct = resp.headers["content-type"] ?? resp.headers["Content-Type"];
591
+ return { status: resp.status, content_type: ct };
592
+ }
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
+
605
+ /** Build a finding, push it into the per-op buffer, and stream the
606
+ * ARV-10 NDJSON event. Summary aggregation moved out — the caller
607
+ * merges per-op buffers in input order so workers > 1 doesn't have to
608
+ * contend on a shared `summary` object. The finding carries raw
609
+ * evidence + its built-in severity default; the agent re-severitizes. */
610
+ function recordFinding(
611
+ out: CheckFinding[],
612
+ check: Check,
613
+ c: CheckCase,
614
+ resp: HttpResponse,
615
+ message: string,
616
+ evidence: Record<string, unknown> | undefined,
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>,
625
+ ): void {
626
+ const unresolvedFixture = fixtureGaps?.has(gapKey(c.operation.method, c.operation.path)) ?? false;
627
+ const action = recommendForCheck(check.id, resp.status, unresolvedFixture);
628
+ const finding: CheckFinding = {
629
+ check: check.id,
630
+ severity: outcomeSeverity ?? check.severity,
631
+ operation: { path: c.operation.path, method: c.operation.method, operationId: c.operation.operationId },
632
+ request_signature: `${c.request.method} ${c.request.url}`,
633
+ response_summary: summarizeResponse(resp),
634
+ message,
635
+ evidence,
636
+ recommended_action: action,
637
+ };
638
+ out.push(finding);
639
+ if (onEvent) onEvent({ type: "finding", ts: nowIso(), check: check.id, finding });
640
+ }
641
+
642
+ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult> {
643
+ const doc = await readOpenApiSpec(opts.specPath);
644
+ const allOps = extractEndpoints(doc);
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);
652
+ const buckets = bucketEndpointsByPath(allOps);
653
+ const schemaValidator: SchemaValidator = createSchemaValidator(doc);
654
+
655
+ const mode: Mode = opts.mode ?? "all";
656
+ const rawSelection = selectChecks({ include: opts.include, exclude: opts.exclude });
657
+ // ARV-7: drop checks the active mode doesn't care about — `selection`
658
+ // is what the runner sends to checks; `rawSelection` is what the user
659
+ // *asked for* (kept on the result so warnings still surface unknown ids).
660
+ // feedback-04#F1: stateful checks (ignored_auth, use_after_free,
661
+ // ensure_resource_availability) live in a separate registry but are
662
+ // accepted by `--check`; selectChecks doesn't know about them and would
663
+ // flag the ids as "unknown". Strip those out so the user only sees
664
+ // warnings for ids that are truly absent from `zond checks list`.
665
+ const statefulIds = new Set(listStatefulChecks().map((c) => c.id));
666
+ const selection: SelectionResult = {
667
+ selected: filterChecksByMode(rawSelection.selected, mode),
668
+ unknown: rawSelection.unknown.filter((id) => !statefulIds.has(id)),
669
+ };
670
+ const summary = emptySummary();
671
+ summary.operations = ops.length;
672
+ summary.checks_run = selection.selected.length;
673
+
674
+ // What probe kinds are demanded by the active set this run? Skip
675
+ // generating cases for kinds nobody asked for.
676
+ const neededKinds = new Set<CaseKind>();
677
+ for (const c of selection.selected) for (const k of checkKinds(c)) neededKinds.add(k);
678
+
679
+ // ARV-8: pre-compute the path → "first op" assignment for the
680
+ // unsupported_method probe. The pre-ARV-8 code did this lazily inside
681
+ // the op loop (one shared Set, mutate-on-visit) — that race-conditions
682
+ // when ops are processed in parallel (two workers on the same path
683
+ // would each emit a probe). Resolving it up-front keeps "one probe
684
+ // per path" deterministic regardless of `--workers`.
685
+ const unsupportedMethodOwner = new Map<string, EndpointInfo>();
686
+ if (neededKinds.has("unsupported_method")) {
687
+ for (const op of ops) {
688
+ if (!unsupportedMethodOwner.has(op.path)) unsupportedMethodOwner.set(op.path, op);
689
+ }
690
+ }
691
+
692
+ const checkRuntimeOptions = {
693
+ strict405: opts.strict405 === true,
694
+ strict401: opts.strict401 === true,
695
+ };
696
+
697
+ // ARV-227: shared budget across per-response + stateful phases.
698
+ // Mutated in-place by `reserveRequest`; safe under our worker model
699
+ // because JS is single-threaded between awaits.
700
+ const requestBudget: RequestBudget | undefined =
701
+ opts.maxRequests !== undefined && opts.maxRequests > 0
702
+ ? { limit: opts.maxRequests, used: 0 }
703
+ : undefined;
704
+
705
+ const phase = opts.phase ?? "examples";
706
+ const wantsExamples = phase === "examples" || phase === "all";
707
+ const wantsCoverage = phase === "coverage" || phase === "all";
708
+
709
+ /** Per-op result — workers push these and the main thread merges them
710
+ * in input order so `findings[]` and `summary.cases` don't depend on
711
+ * worker scheduling (matters for snapshot tests + reproducibility). */
712
+ interface OpReport {
713
+ findings: CheckFinding[];
714
+ cases: number;
715
+ /** ARV-26: skip-outcome counts keyed by `"<check_id>: <reason>"`. */
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;
729
+ }
730
+
731
+ async function processOperation(op: EndpointInfo): Promise<OpReport> {
732
+ const localFindings: CheckFinding[] = [];
733
+ let localCases = 0;
734
+ let localPositiveTotal = 0;
735
+ let localPositiveTwoxx = 0;
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> = {};
742
+ if (opts.onEvent) {
743
+ opts.onEvent({
744
+ type: "check_start",
745
+ ts: nowIso(),
746
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
747
+ });
748
+ }
749
+ const cases: BuiltCase[] = [];
750
+ if (wantsExamples && neededKinds.has("positive")) cases.push(buildPositive(op, opts.baseUrl, opts.pathVars));
751
+ if (neededKinds.has("missing_required_header")) {
752
+ cases.push(...buildMissingHeader(op, opts.baseUrl, opts.pathVars));
753
+ }
754
+ if (wantsExamples && neededKinds.has("negative_data")) {
755
+ const c = buildNegativeData(op, opts.baseUrl, opts.pathVars);
756
+ if (c) cases.push(c);
757
+ }
758
+ if (wantsCoverage && (neededKinds.has("negative_data") || neededKinds.has("positive"))) {
759
+ const boundary = buildCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars });
760
+ for (const b of boundary) {
761
+ if (neededKinds.has(b.case.kind)) cases.push(b);
762
+ }
763
+ // ARV-180: param-axis coverage. Emits negative_data cases for
764
+ // path/query parameter mutations (drop-required-query, wrong-type,
765
+ // invalid-format, invalid-enum, boundary violations). On GET-heavy
766
+ // APIs the body-axis walker above emits zero cases, so this is the
767
+ // only coverage signal for `status_code_conformance` and
768
+ // `negative_data_rejection` on those operations.
769
+ if (neededKinds.has("negative_data")) {
770
+ for (const b of buildParamCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars })) {
771
+ cases.push(b);
772
+ }
773
+ }
774
+ }
775
+ if (unsupportedMethodOwner.get(op.path) === op) {
776
+ const declared = buckets.get(op.path)?.declared ?? new Set([op.method.toUpperCase()]);
777
+ cases.push(...buildUnsupportedMethod(op, declared, opts.baseUrl));
778
+ }
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
+
808
+ for (const built of cases) {
809
+ if (!caseMatchesMode(built.case.mode, mode)) continue;
810
+ if (opts.authHeaders) injectAuthHeadersIntoCase(built, opts.authHeaders);
811
+ // ARV-227: stop dispatching new HTTP requests once the cap is
812
+ // reached. Bucket the skip so the summary surfaces it, then keep
813
+ // looping so we still tally the would-have-run count for the user.
814
+ if (!reserveRequest(requestBudget)) {
815
+ localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] =
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
+ }
829
+ continue;
830
+ }
831
+ // ARV-8: gate the request through the rate-limiter (no-op when
832
+ // none configured). Acquire happens *inside* the worker so a pool
833
+ // of N workers can't leak more requests/sec than the limiter
834
+ // allows.
835
+ if (opts.rateLimiter) await opts.rateLimiter.acquire();
836
+ let httpResp: HttpResponse;
837
+ try {
838
+ httpResp = await executeWithResetRetry(built.req, opts.timeoutMs ?? 30000);
839
+ } catch (err) {
840
+ const finding: CheckFinding = {
841
+ check: "network_error",
842
+ severity: "medium",
843
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
844
+ request_signature: `${built.req.method} ${built.req.url}`,
845
+ response_summary: { status: 0 },
846
+ message: `Network error: ${(err as Error).message}`,
847
+ recommended_action: recommendForCheck("network_error", 0),
848
+ };
849
+ localFindings.push(finding);
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
+ }
863
+ continue;
864
+ }
865
+
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
+ }
874
+ const checkResp = {
875
+ status: httpResp.status,
876
+ headers: httpResp.headers,
877
+ body: httpResp.body_parsed ?? httpResp.body,
878
+ duration_ms: httpResp.duration_ms,
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;
886
+ for (const check of selection.selected) {
887
+ if (!checkKinds(check).includes(built.case.kind)) continue;
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;
901
+ const outcome = check.run({
902
+ case: built.case,
903
+ response: checkResp,
904
+ schemaValidator,
905
+ doc,
906
+ options: checkRuntimeOptions,
907
+ });
908
+ if (outcome.kind === "fail") {
909
+ recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent, outcome.severity, opts.fixtureGaps);
910
+ }
911
+ if (outcome.kind === "skip") {
912
+ // ARV-26: bucket skips by check+reason so the summary can surface
913
+ // "0 findings BUT 2 skipped (no JSON Schema on this branch)".
914
+ const key = `${check.id}: ${outcome.reason ?? "unspecified"}`;
915
+ localSkipped[key] = (localSkipped[key] ?? 0) + 1;
916
+ }
917
+ if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail")) {
918
+ opts.onEvent({
919
+ type: "check_result",
920
+ ts: nowIso(),
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,
926
+ verdict: outcome.kind,
927
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
928
+ request_signature: `${built.case.request.method} ${built.case.request.url}`,
929
+ response: summarizeResponse(httpResp),
930
+ });
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
+ });
953
+ }
954
+ }
955
+ return {
956
+ findings: localFindings,
957
+ cases: localCases,
958
+ skipped: localSkipped,
959
+ applicableChecks: [...localApplicable],
960
+ casesByCheck: localCasesByCheck,
961
+ positiveTotal: localPositiveTotal,
962
+ positiveTwoxx: localPositiveTwoxx,
963
+ };
964
+ }
965
+
966
+ // ARV-8: parallelize the op-loop. workers=1 (default) preserves the
967
+ // sequential code path inside runPool — same microtask interleaving as
968
+ // before, AC #4 backward-compat.
969
+ const workers = opts.workers ?? 1;
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
+ });
980
+
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;
991
+ for (const report of opReports) {
992
+ summary.cases += report.cases;
993
+ positiveTotal += report.positiveTotal;
994
+ positiveTwoxx += report.positiveTwoxx;
995
+ for (const [key, n] of Object.entries(report.skipped)) {
996
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + n;
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
+ }
1004
+ for (const f of report.findings) {
1005
+ // ARV-251: stamp finding category from check id if not already
1006
+ // present. Probes carry their own category; checks derive it
1007
+ // from the check id. The bucket increment is the same code path.
1008
+ if (!f.category) f.category = categoryFor(f.check);
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
+ }
1016
+ summary.findings += 1;
1017
+ summary.by_severity[f.severity] += 1;
1018
+ summary.by_category[f.category] += 1;
1019
+ }
1020
+ }
1021
+
1022
+ // ── Stateful phase (ARV-3) ─────────────────────────────────────────
1023
+ // Stateful checks share the same --check / --exclude-check filters as
1024
+ // the response-phase ones. We honour `selection` ids and only run a
1025
+ // stateful check whose id was either explicitly included or not
1026
+ // explicitly excluded.
1027
+ const includeSet = opts.include && opts.include.length > 0 ? new Set(opts.include) : null;
1028
+ const excludeSet = new Set(opts.exclude ?? []);
1029
+ const activeStateful = filterChecksByMode(
1030
+ listStatefulChecks().filter((c) => {
1031
+ if (excludeSet.has(c.id)) return false;
1032
+ if (includeSet && !includeSet.has(c.id)) return false;
1033
+ return true;
1034
+ }),
1035
+ mode,
1036
+ );
1037
+
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, {
1045
+ authHeaders: opts.authHeaders,
1046
+ bootstrapCleanupFailed: opts.bootstrapCleanupFailed,
1047
+ timeoutMs: opts.timeoutMs,
1048
+ // ARV-181: stateful checks (ignored_auth) need the same
1049
+ // fixture-driven path-var substitution that ARV-141 wired into
1050
+ // the per-response runner — without this the synthetic baseline
1051
+ // lands on literal `/{event_id}` and the broken-baseline guard
1052
+ // skips the whole op.
1053
+ pathVars: opts.pathVars,
1054
+ options: checkRuntimeOptions,
1055
+ resourceConfigs: opts.resourceConfigs,
1056
+ // ARV-227: same budget instance as the per-response phase so a
1057
+ // cap of N applies to the whole run, not per-phase.
1058
+ requestBudget,
1059
+ });
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
+ : [];
1116
+ summary.checks_run += activeStateful.length;
1117
+
1118
+ // ARV-8: parallelize auth-phase ops and crud-phase groups via the
1119
+ // same pool. CRUD-chain integrity stays intact because the *check*
1120
+ // owns its own sequential within-chain logic — the pool only runs
1121
+ // *independent* groups in parallel.
1122
+ const statefulWorkers = opts.workers ?? 1;
1123
+ const collected: CheckFinding[] = [];
1124
+ function pushStateful(f: CheckFinding): void {
1125
+ if (!f.category) f.category = categoryFor(f.check);
1126
+ collected.push(f);
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
+ }
1134
+ if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: f.check, finding: f });
1135
+ }
1136
+ for (const check of activeStateful) {
1137
+ if (check.phase === "auth") {
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);
1154
+ // ARV-154: track per-op cases + skip reasons for the stateful auth
1155
+ // path. Previously this loop only forwarded `fail` outcomes; runs
1156
+ // like `--check ignored_auth` on a fully-protected API where every
1157
+ // baseline passes returned `{operations: 48, cases: 0, findings: 0}`
1158
+ // with no skipped_outcomes, making the check look broken when it
1159
+ // was actually working (no auth bypass found). Mirror the
1160
+ // observability of the non-stateful path: count attempted cases
1161
+ // and bucket skip reasons by `<check>: <reason>`.
1162
+ type StatefulOutcome =
1163
+ | { kind: "fail"; finding: CheckFinding }
1164
+ | { kind: "skip"; reason: string }
1165
+ | { kind: "pass" };
1166
+ const authHarness = harnessFor(check.id, "stateful_auth");
1167
+ const opReports = await runPool<typeof applicable[number], StatefulOutcome>(
1168
+ applicable,
1169
+ statefulWorkers,
1170
+ async (op): Promise<StatefulOutcome> => {
1171
+ let outcome;
1172
+ try {
1173
+ outcome = await check.run(op, authHarness);
1174
+ } catch (err) {
1175
+ outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
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
+ }
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.
1198
+ const finding: CheckFinding = {
1199
+ check: check.id,
1200
+ severity: outcome.severity ?? check.severity,
1201
+ operation: { path: op.path, method: op.method, operationId: op.operationId },
1202
+ request_signature: `${op.method.toUpperCase()} ${op.path}`,
1203
+ response_summary: { status: outcome.responseStatus ?? 0 },
1204
+ message: outcome.message,
1205
+ evidence: outcome.evidence,
1206
+ recommended_action: recommendForCheck(check.id),
1207
+ };
1208
+ return { kind: "fail", finding };
1209
+ }
1210
+ if (outcome.kind === "skip") {
1211
+ return { kind: "skip", reason: outcome.reason ?? "unspecified" };
1212
+ }
1213
+ return { kind: "pass" };
1214
+ });
1215
+ for (const o of opReports) {
1216
+ summary.cases += 1;
1217
+ perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
1218
+ if (o.kind === "fail") pushStateful(o.finding);
1219
+ else if (o.kind === "skip") {
1220
+ const key = `${check.id}: ${o.reason}`;
1221
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
1222
+ }
1223
+ }
1224
+ } else {
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);
1243
+ // ARV-154: mirror the auth-phase observability — count CRUD groups
1244
+ // attempted and record skip reasons, not just failures.
1245
+ type StatefulOutcome =
1246
+ | { kind: "fail"; finding: CheckFinding }
1247
+ | { kind: "skip"; reason: string }
1248
+ | { kind: "pass" };
1249
+ const crudHarness = harnessFor(check.id, "stateful_crud");
1250
+ const groupReports = await runPool<typeof applicable[number], StatefulOutcome>(
1251
+ applicable,
1252
+ statefulWorkers,
1253
+ async (group): Promise<StatefulOutcome> => {
1254
+ let outcome;
1255
+ try {
1256
+ outcome = await check.run(group, crudHarness);
1257
+ } catch (err) {
1258
+ outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
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
+ }
1284
+ if (outcome.kind === "fail") {
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.
1299
+ const finding: CheckFinding = {
1300
+ check: check.id,
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 },
1305
+ message: outcome.message,
1306
+ evidence: outcome.evidence,
1307
+ recommended_action: recommendForCheck(check.id),
1308
+ };
1309
+ return { kind: "fail", finding };
1310
+ }
1311
+ if (outcome.kind === "skip") {
1312
+ return { kind: "skip", reason: outcome.reason ?? "unspecified" };
1313
+ }
1314
+ return { kind: "pass" };
1315
+ });
1316
+ for (const o of groupReports) {
1317
+ summary.cases += 1;
1318
+ perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
1319
+ if (o.kind === "fail") pushStateful(o.finding);
1320
+ else if (o.kind === "skip") {
1321
+ const key = `${check.id}: ${o.reason}`;
1322
+ summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
1323
+ }
1324
+ }
1325
+ }
1326
+ }
1327
+ findings.push(...collected);
1328
+ }
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
+
1356
+ const highOrCritical = findings.filter(
1357
+ (f) => f.severity === "high" || f.severity === "critical",
1358
+ ).length;
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
+
1399
+ // ARV-10: terminal event so downstream consumers know the run wrapped
1400
+ // (vs. the producer crashing). Mirrors what the JSON envelope's
1401
+ // `summary` field carries, just delivered as the final NDJSON line.
1402
+ if (opts.onEvent) opts.onEvent({ type: "summary", ts: nowIso(), summary });
1403
+
1404
+ return {
1405
+ data: { findings, summary, spec_findings },
1406
+ selection,
1407
+ high_or_critical: highOrCritical,
1408
+ };
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
+ }