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