@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
@@ -1,24 +1,106 @@
1
1
  import { resolve, dirname, basename } from "node:path";
2
- import type { TestSuite, TestStep, Environment } from "../parser/types.ts";
2
+ import type { TestSuite, TestStep, Environment, SourceMetadata, AssertionRule } from "../parser/types.ts";
3
3
  import { substituteString, substituteStep, substituteDeep, extractVariableReferences } from "../parser/variables.ts";
4
- import type { TestRunResult, StepResult, HttpRequest } from "./types.ts";
4
+ import type { TestRunResult, StepResult, HttpRequest, AssertionResult } from "./types.ts";
5
5
  import { executeRequest, type FetchOptions } from "./http-client.ts";
6
6
  import type { RateLimiter } from "./rate-limiter.ts";
7
- import { checkAssertions, extractCaptures } from "./assertions.ts";
7
+ import { checkAssertions, extractCaptures, findMissedCaptures } from "./assertions.ts";
8
8
  import { evaluateExpr } from "./expr-eval.ts";
9
9
  import { applyTransform } from "./transforms.ts";
10
+ import type { SchemaValidator } from "./schema-validator.ts";
11
+ import { classifyFailure } from "../diagnostics/failure-class.ts";
12
+ import { buildUrl } from "../util/url.ts";
13
+
14
+ /** Shallow-merge suite-level и step-level provenance. Step перекрывает suite. */
15
+ function mergeProvenance(
16
+ suiteSrc?: SourceMetadata,
17
+ stepSrc?: SourceMetadata,
18
+ ): SourceMetadata | null {
19
+ if (!suiteSrc && !stepSrc) return null;
20
+ return { ...(suiteSrc ?? {}), ...(stepSrc ?? {}) };
21
+ }
10
22
 
11
- function buildUrl(baseUrl: string | undefined, path: string, query?: Record<string, string>): string {
12
- let url = baseUrl ? `${baseUrl.replace(/\/+$/, "")}${path}` : path;
13
- if (query && Object.keys(query).length > 0) {
14
- const params = new URLSearchParams(query);
15
- url += `?${params.toString()}`;
23
+ /** ARV-157: build the top-level `schema_validation` summary for a step that
24
+ * was run with `--validate-schema`. Mirrors the shape `zond request
25
+ * --validate-schema` already produces (see src/cli/commands/request.ts);
26
+ * consumers can `jq '.steps[] | .schema_validation'` instead of digging
27
+ * into `assertions[] | select(.kind=="schema")`.
28
+ *
29
+ * Returns undefined when the validator wasn't attached or the response had
30
+ * no parseable JSON body — same precondition as `assertions.push(...)` at
31
+ * the call site, so the summary is present iff schema actually ran. */
32
+ function buildSchemaValidationSummary(
33
+ validator: SchemaValidator,
34
+ method: string,
35
+ path: string,
36
+ status: number,
37
+ schemaAssertions: AssertionResult[],
38
+ ): StepResult["schema_validation"] {
39
+ const ins = validator.inspect(method, path, status);
40
+ if (!ins.matchedEndpoint) {
41
+ return { result: "no-endpoint", matched_endpoint: null, matched_response_status: null, error_count: 0 };
42
+ }
43
+ if (!ins.hasJsonSchema) {
44
+ return {
45
+ result: "no-schema",
46
+ matched_endpoint: ins.matchedEndpoint,
47
+ matched_response_status: ins.matchedResponseStatus,
48
+ error_count: 0,
49
+ };
16
50
  }
17
- return url;
51
+ const failed = schemaAssertions.filter((a) => !a.passed).length;
52
+ return {
53
+ result: failed === 0 ? "PASS" : "FAIL",
54
+ matched_endpoint: ins.matchedEndpoint,
55
+ matched_response_status: ins.matchedResponseStatus,
56
+ error_count: failed,
57
+ };
18
58
  }
19
59
 
20
- function makeSkippedResult(stepName: string, reason: string): StepResult {
21
- return {
60
+ /** TASK-256: turn each missed-capture (path didn't resolve in response)
61
+ * into an auxiliary failed assertion. The step then fails loudly with
62
+ * "capture <var>: path '<path>' not found in body" instead of producing
63
+ * silent `captures: {}` that the user only notices when the next step in a
64
+ * CRUD chain skips with `Depends on missing capture`. */
65
+ function buildMissedCaptureAssertions(
66
+ misses: ReturnType<typeof findMissedCaptures>,
67
+ ): AssertionResult[] {
68
+ return misses.map((m) => ({
69
+ field: `capture ${m.var}`,
70
+ rule: m.source === "body" ? "body-path-exists" : "header-exists",
71
+ passed: false,
72
+ actual: undefined,
73
+ expected: `${m.source} '${m.path}' present`,
74
+ kind: "auxiliary",
75
+ }));
76
+ }
77
+
78
+ function collectChainCaptures(tests: TestStep[]): Set<string> {
79
+ const out = new Set<string>();
80
+ const visit = (rules: Record<string, AssertionRule> | undefined) => {
81
+ if (!rules) return;
82
+ for (const r of Object.values(rules)) {
83
+ if (r.capture) out.add(r.capture);
84
+ if (r.each) visit(r.each);
85
+ if (r.contains_item) visit(r.contains_item);
86
+ }
87
+ };
88
+ for (const step of tests) visit(step.expect?.body);
89
+ return out;
90
+ }
91
+
92
+ function emptyVarSkipReason(varName: string, chainCaptures: Set<string>): string {
93
+ return chainCaptures.has(varName)
94
+ ? `chain capture {{${varName}}} unbound (upstream step did not run or did not capture it)`
95
+ : `required fixture {{${varName}}} is empty`;
96
+ }
97
+
98
+ function makeSkippedResult(
99
+ stepName: string,
100
+ reason: string,
101
+ opts?: { cascade?: { missingCapture: string } },
102
+ ): StepResult {
103
+ const result: StepResult = {
22
104
  name: stepName,
23
105
  status: "skip",
24
106
  duration_ms: 0,
@@ -27,6 +109,11 @@ function makeSkippedResult(stepName: string, reason: string): StepResult {
27
109
  captures: {},
28
110
  error: reason,
29
111
  };
112
+ if (opts?.cascade) {
113
+ result.failure_class = "cascade";
114
+ result.failure_class_reason = `Upstream capture not produced: ${opts.cascade.missingCapture}`;
115
+ }
116
+ return result;
30
117
  }
31
118
 
32
119
  /** Interpolate {{var}} placeholders inside a test/step name. Falls back to
@@ -67,8 +154,46 @@ export function expandParameterize(params?: Record<string, unknown[]>): Record<s
67
154
 
68
155
  export interface RunSuiteOptions {
69
156
  rateLimiter?: RateLimiter;
157
+ /** Optional OpenAPI response-schema validator. When provided, every step's
158
+ * parsed JSON body is validated against the matching schema; failures are
159
+ * appended to the step's `assertions`. */
160
+ schemaValidator?: SchemaValidator;
161
+ /** TASK-144: per-step network-retry budget used by http-client for
162
+ * ECONNRESET / EPIPE / `socket hang up` / `fetch failed` / abort cases.
163
+ * Set by `zond run --retry-on-network <N>`. HTTP statuses are not retried
164
+ * by this path. */
165
+ networkRetries?: number;
166
+ /** ARV-249: shared HTTP-request budget across all parallel suites. When
167
+ * `used >= limit`, remaining steps short-circuit to `skip` with reason
168
+ * `max-requests-cap-reached`. Each `retry_until` attempt counts as one
169
+ * request; dry-run and set-only steps do not consume the budget. */
170
+ requestBudget?: RequestBudget;
171
+ /** ARV-249: invoked after every step completes (pass/fail/skip/error) so
172
+ * the CLI can render a periodic progress line without each suite
173
+ * knowing how many siblings are running in parallel. */
174
+ onStepDone?: (step: StepResult) => void;
70
175
  }
71
176
 
177
+ /** ARV-249: shared `--max-requests` budget. Mutated in-place by every
178
+ * parallel `runSuite` call — single-threaded JS makes the
179
+ * check-then-increment race-free. `limit === Infinity` means "uncapped"
180
+ * (the default). */
181
+ export interface RequestBudget {
182
+ limit: number;
183
+ used: number;
184
+ }
185
+
186
+ /** Try to reserve one HTTP slot from the shared budget. Returns true if
187
+ * the caller may proceed, false if the cap has been reached. */
188
+ export function reserveRequest(budget: RequestBudget | undefined): boolean {
189
+ if (!budget) return true;
190
+ if (budget.used >= budget.limit) return false;
191
+ budget.used += 1;
192
+ return true;
193
+ }
194
+
195
+ export const MAX_REQUESTS_SKIP_REASON = "max-requests-cap-reached";
196
+
72
197
  export async function runSuite(
73
198
  suite: TestSuite,
74
199
  env: Environment = {},
@@ -78,14 +203,37 @@ export async function runSuite(
78
203
  const startedAt = new Date().toISOString();
79
204
  const steps: StepResult[] = [];
80
205
 
206
+ /** Push a step result, attaching provenance + failure classification. */
207
+ const pushStep = (result: StepResult, currentStep?: TestStep): void => {
208
+ const merged = mergeProvenance(suite.source, currentStep?.source);
209
+ if (merged !== null) result.provenance = merged;
210
+ const classification = classifyFailure(result);
211
+ if (classification) {
212
+ result.failure_class = classification.failure_class;
213
+ result.failure_class_reason = classification.failure_class_reason;
214
+ }
215
+ steps.push(result);
216
+ if (options.onStepDone) options.onStepDone(result);
217
+ };
218
+
81
219
  const fetchOptions: Partial<FetchOptions> = {
82
220
  timeout: suite.config.timeout,
83
221
  retries: suite.config.retries,
84
222
  retry_delay: suite.config.retry_delay,
85
223
  follow_redirects: suite.config.follow_redirects,
86
224
  rate_limiter: options.rateLimiter,
225
+ ...(options.networkRetries !== undefined ? { network_retries: options.networkRetries } : {}),
87
226
  };
88
227
 
228
+ // Names of every variable a step in this suite tries to capture from a
229
+ // response (expect.body.<field>.capture: <name>). When a later step
230
+ // references one of these and the value is empty — under --dry-run, or
231
+ // because the capturing step was skipped — the missing var is a chain
232
+ // capture, NOT a fixture in .env.yaml. Distinguishing them in the skip
233
+ // message stops users from chasing fixture seeding for vars that
234
+ // shouldn't live in .env.yaml at all.
235
+ const chainCaptures = collectChainCaptures(suite.tests);
236
+
89
237
  // parameterize cross-product → N iterations of the suite body.
90
238
  // Captures and tainted/missing sets are reset per iteration so that
91
239
  // values from one binding never leak into the next.
@@ -144,14 +292,14 @@ export async function runSuite(
144
292
  const substituted = substituteDeep(rawDirective, variables);
145
293
  variables[key] = applyTransform(substituted);
146
294
  }
147
- steps.push({
295
+ pushStep({
148
296
  name: interpolateName(step.name, variables),
149
297
  status: "pass",
150
298
  duration_ms: 0,
151
299
  request: { method: "", url: "", headers: {} },
152
300
  assertions: [],
153
301
  captures: {},
154
- });
302
+ }, step);
155
303
  continue;
156
304
  }
157
305
 
@@ -160,13 +308,27 @@ export async function runSuite(
160
308
  const referencedVars = extractVariableReferences(step);
161
309
  const missing = referencedVars.find((v) => missingCaptures.has(v));
162
310
  if (missing) {
163
- steps.push(makeSkippedResult(interpolateName(step.name, variables), `Depends on missing capture: ${missing}`));
311
+ pushStep(
312
+ makeSkippedResult(
313
+ interpolateName(step.name, variables),
314
+ `Depends on missing capture: ${missing}`,
315
+ { cascade: { missingCapture: missing } },
316
+ ),
317
+ step,
318
+ );
164
319
  continue;
165
320
  }
166
321
  if (!step.always) {
167
322
  const tainted = referencedVars.find((v) => taintedCaptures.has(v));
168
323
  if (tainted) {
169
- steps.push(makeSkippedResult(interpolateName(step.name, variables), `Depends on tainted capture: ${tainted} (use always: true on cleanup steps)`));
324
+ pushStep(
325
+ makeSkippedResult(
326
+ interpolateName(step.name, variables),
327
+ `Depends on tainted capture: ${tainted} (use always: true on cleanup steps)`,
328
+ { cascade: { missingCapture: tainted } },
329
+ ),
330
+ step,
331
+ );
170
332
  continue;
171
333
  }
172
334
  }
@@ -175,7 +337,11 @@ export async function runSuite(
175
337
  if (step.skip_if) {
176
338
  const exprAfterSubst = String(substituteString(step.skip_if, variables));
177
339
  if (evaluateExpr(exprAfterSubst)) {
178
- steps.push(makeSkippedResult(interpolateName(step.name, variables), `Skipped: ${step.skip_if}`));
340
+ const varMatch = step.skip_if.match(/\{\{([^{}]+)\}\}/);
341
+ const skipMsg = varMatch
342
+ ? emptyVarSkipReason(varMatch[1]!.trim(), chainCaptures)
343
+ : step.skip_if;
344
+ pushStep(makeSkippedResult(interpolateName(step.name, variables), skipMsg), step);
179
345
  continue;
180
346
  }
181
347
  }
@@ -197,7 +363,7 @@ export async function runSuite(
197
363
  resolvedSuiteHeaders = suite.headers ? substituteDeep(suite.headers, variables) : undefined;
198
364
  } catch (err) {
199
365
  const errorMsg = err instanceof Error ? err.message : String(err);
200
- steps.push({
366
+ pushStep({
201
367
  name: interpolateName(step.name, variables),
202
368
  status: "error",
203
369
  duration_ms: 0,
@@ -205,7 +371,7 @@ export async function runSuite(
205
371
  assertions: [],
206
372
  captures: {},
207
373
  error: errorMsg,
208
- });
374
+ }, step);
209
375
  // Substitution never produced a request → capture truly missing.
210
376
  if (step.expect.body) {
211
377
  for (const rule of Object.values(step.expect.body)) {
@@ -214,6 +380,25 @@ export async function runSuite(
214
380
  }
215
381
  continue;
216
382
  }
383
+ // Skip if any path-variable in the template resolved to empty — an empty
384
+ // path segment produces URLs like /repos//commits/ which always 404/500.
385
+ // The explicit skip_if guard only covers the first param (TASK-237);
386
+ // this catches all others.
387
+ {
388
+ let emptyVar: string | null = null;
389
+ for (const m of step.path.matchAll(/\{\{([^{}]+)\}\}/g)) {
390
+ const varName = m[1]!.trim();
391
+ const val = variables[varName];
392
+ if (val === "" || val === null || val === undefined) { emptyVar = varName; break; }
393
+ }
394
+ if (emptyVar) {
395
+ pushStep(makeSkippedResult(
396
+ interpolateName(step.name, variables),
397
+ emptyVarSkipReason(emptyVar, chainCaptures),
398
+ ), step);
399
+ continue;
400
+ }
401
+ }
217
402
  const url = buildUrl(resolvedBaseUrl, resolved.path, resolved.query);
218
403
  const headers: Record<string, string> = { ...resolvedSuiteHeaders, ...resolved.headers };
219
404
  let body: string | undefined;
@@ -249,7 +434,7 @@ export async function runSuite(
249
434
 
250
435
  // Validate absolute URL before attempting fetch
251
436
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
252
- steps.push({
437
+ pushStep({
253
438
  name: interpolateName(step.name, variables),
254
439
  status: "error",
255
440
  duration_ms: 0,
@@ -257,7 +442,7 @@ export async function runSuite(
257
442
  assertions: [],
258
443
  captures: {},
259
444
  error: `base_url is not configured — URL resolved to a relative path: "${url}". Set base_url in .env.yaml`,
260
- });
445
+ }, step);
261
446
  if (step.expect.body) {
262
447
  for (const rule of Object.values(step.expect.body)) {
263
448
  if (rule.capture) missingCaptures.add(rule.capture);
@@ -270,7 +455,7 @@ export async function runSuite(
270
455
  const bodyPreview = formData
271
456
  ? ` [multipart: ${[...formData.keys()].length} field(s)]`
272
457
  : body ? ` ${body.slice(0, 200)}` : "";
273
- steps.push({
458
+ pushStep({
274
459
  name: interpolateName(step.name, variables),
275
460
  status: "pass",
276
461
  duration_ms: 0,
@@ -278,7 +463,7 @@ export async function runSuite(
278
463
  assertions: [],
279
464
  captures: {},
280
465
  error: `[DRY RUN] ${resolved.method} ${url}${bodyPreview}`,
281
- });
466
+ }, step);
282
467
  continue;
283
468
  }
284
469
 
@@ -287,10 +472,31 @@ export async function runSuite(
287
472
  const rt = step.retry_until;
288
473
  let lastStepResult: StepResult | undefined;
289
474
  for (let attempt = 0; attempt < rt.max_attempts; attempt++) {
475
+ if (!reserveRequest(options.requestBudget)) {
476
+ lastStepResult = makeSkippedResult(
477
+ interpolateName(step.name, variables),
478
+ MAX_REQUESTS_SKIP_REASON,
479
+ );
480
+ break;
481
+ }
290
482
  try {
291
483
  const response = await executeRequest(request, fetchOptions);
292
484
  const captures = extractCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
485
+ const missedCaps = findMissedCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
293
486
  const assertions = checkAssertions(resolved.expect, response);
487
+ assertions.push(...buildMissedCaptureAssertions(missedCaps));
488
+ let schemaValidationSummary: StepResult["schema_validation"] | undefined;
489
+ if (options.schemaValidator && response.body_parsed !== undefined) {
490
+ const schemaAssertions = options.schemaValidator.validate(resolved.method, resolved.path, response.status, response.body_parsed);
491
+ assertions.push(...schemaAssertions);
492
+ schemaValidationSummary = buildSchemaValidationSummary(
493
+ options.schemaValidator,
494
+ resolved.method,
495
+ resolved.path,
496
+ response.status,
497
+ schemaAssertions,
498
+ );
499
+ }
294
500
  const allPassed = assertions.every((a) => a.passed);
295
501
 
296
502
  lastStepResult = {
@@ -301,6 +507,10 @@ export async function runSuite(
301
507
  response,
302
508
  assertions,
303
509
  captures,
510
+ ...(response.network_retry_count && response.network_retry_count > 0
511
+ ? { network_retry: response.network_retry_count }
512
+ : {}),
513
+ ...(schemaValidationSummary ? { schema_validation: schemaValidationSummary } : {}),
304
514
  };
305
515
 
306
516
  // Evaluate condition with response context
@@ -331,7 +541,15 @@ export async function runSuite(
331
541
  };
332
542
  }
333
543
  }
334
- if (lastStepResult) steps.push(lastStepResult);
544
+ if (lastStepResult) pushStep(lastStepResult, step);
545
+ continue;
546
+ }
547
+
548
+ if (!reserveRequest(options.requestBudget)) {
549
+ pushStep(makeSkippedResult(
550
+ interpolateName(step.name, variables),
551
+ MAX_REQUESTS_SKIP_REASON,
552
+ ), step);
335
553
  continue;
336
554
  }
337
555
 
@@ -352,10 +570,24 @@ export async function runSuite(
352
570
  }
353
571
 
354
572
  // Run assertions
573
+ const missedCaps = findMissedCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
355
574
  const assertions = checkAssertions(resolved.expect, response);
575
+ assertions.push(...buildMissedCaptureAssertions(missedCaps));
576
+ let schemaValidationSummary: StepResult["schema_validation"] | undefined;
577
+ if (options.schemaValidator && response.body_parsed !== undefined) {
578
+ const schemaAssertions = options.schemaValidator.validate(resolved.method, resolved.path, response.status, response.body_parsed);
579
+ assertions.push(...schemaAssertions);
580
+ schemaValidationSummary = buildSchemaValidationSummary(
581
+ options.schemaValidator,
582
+ resolved.method,
583
+ resolved.path,
584
+ response.status,
585
+ schemaAssertions,
586
+ );
587
+ }
356
588
  const allPassed = assertions.every((a) => a.passed);
357
589
 
358
- steps.push({
590
+ pushStep({
359
591
  name: interpolateName(step.name, variables),
360
592
  status: allPassed ? "pass" : "fail",
361
593
  duration_ms: response.duration_ms,
@@ -363,7 +595,11 @@ export async function runSuite(
363
595
  response,
364
596
  assertions,
365
597
  captures,
366
- });
598
+ ...(response.network_retry_count && response.network_retry_count > 0
599
+ ? { network_retry: response.network_retry_count }
600
+ : {}),
601
+ ...(schemaValidationSummary ? { schema_validation: schemaValidationSummary } : {}),
602
+ }, step);
367
603
 
368
604
  // If step failed, captures that did extract are tainted (value is real
369
605
  // but came from a step whose other assertions failed). Always-steps may
@@ -377,7 +613,7 @@ export async function runSuite(
377
613
  }
378
614
  } catch (err) {
379
615
  const errorMsg = err instanceof Error ? err.message : String(err);
380
- steps.push({
616
+ pushStep({
381
617
  name: interpolateName(step.name, variables),
382
618
  status: "error",
383
619
  duration_ms: 0,
@@ -385,7 +621,7 @@ export async function runSuite(
385
621
  assertions: [],
386
622
  captures: {},
387
623
  error: errorMsg,
388
- });
624
+ }, step);
389
625
 
390
626
  // Network/runtime error → no response → capture truly missing.
391
627
  if (step.expect.body) {
@@ -409,15 +645,8 @@ export async function runSuite(
409
645
  passed: steps.filter((s) => s.status === "pass").length,
410
646
  failed: steps.filter((s) => s.status === "fail").length,
411
647
  skipped: steps.filter((s) => s.status === "skip").length,
648
+ // ARV-318: surface error steps so total = passed+failed+skipped+errored.
649
+ errored: steps.filter((s) => s.status === "error").length,
412
650
  steps,
413
651
  };
414
652
  }
415
-
416
- export async function runSuites(
417
- suites: TestSuite[],
418
- env: Environment = {},
419
- dryRun = false,
420
- options: RunSuiteOptions = {},
421
- ): Promise<TestRunResult[]> {
422
- return Promise.all(suites.map((suite) => runSuite(suite, env, dryRun, options)));
423
- }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ARV-149 / ARV-150: encode a nested JS object as
3
+ * `application/x-www-form-urlencoded` using bracket notation — the
4
+ * canonical Stripe / Rails / PHP convention for nested fields:
5
+ *
6
+ * { address: { line1: "x", line2: "y" }, items: [{ id: 1 }, { id: 2 }] }
7
+ * → address[line1]=x&address[line2]=y&items[0][id]=1&items[1][id]=2
8
+ *
9
+ * Shared between `zond request --form`, the YAML runner's `form:` step,
10
+ * and the mass-assignment probe's form-bodied endpoints. The probe-side
11
+ * adoption (ARV-150) is what restores 265 SKIPPED Stripe endpoints.
12
+ */
13
+ function appendFormParam(params: URLSearchParams, key: string, value: unknown): void {
14
+ if (value === null || value === undefined) return;
15
+ if (Array.isArray(value)) {
16
+ for (let i = 0; i < value.length; i++) appendFormParam(params, `${key}[${i}]`, value[i]);
17
+ } else if (typeof value === "object") {
18
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
19
+ appendFormParam(params, `${key}[${k}]`, v);
20
+ }
21
+ } else {
22
+ params.append(key, String(value));
23
+ }
24
+ }
25
+
26
+ export function encodeFormBody(body: Record<string, unknown>): string {
27
+ const params = new URLSearchParams();
28
+ for (const [k, v] of Object.entries(body)) appendFormParam(params, k, v);
29
+ return params.toString();
30
+ }
31
+
32
+ /** Flatten a nested JS object to a `Record<string, string>` using the same
33
+ * bracket-notation walk as `encodeFormBody`, suitable for the YAML
34
+ * runner's `form:` step (which is typed as `Record<string, string>` and
35
+ * serialised via `URLSearchParams`). */
36
+ export function flattenToFormFields(body: unknown): Record<string, string> {
37
+ if (!body || typeof body !== "object") return {};
38
+ const params = new URLSearchParams();
39
+ for (const [k, v] of Object.entries(body as Record<string, unknown>)) appendFormParam(params, k, v);
40
+ return Object.fromEntries(params.entries());
41
+ }
@@ -1,6 +1,34 @@
1
1
  import type { HttpRequest, HttpResponse } from "./types.ts";
2
2
  import { type RateLimiter, parseRetryAfter, parseRateLimitHeaders } from "./rate-limiter.ts";
3
3
 
4
+ /**
5
+ * ARV-265: module-level audit hook for commands that don't drive their
6
+ * HTTP through the suite runner. Each `executeRequest` call fires the
7
+ * registered recorder with the final outcome (or the swallowed network
8
+ * error if the call exhausted its retry budget). Recorders MUST NOT
9
+ * throw — the http client treats them as fire-and-forget telemetry.
10
+ *
11
+ * Convention: a command sets the recorder before the live work begins,
12
+ * unsets it in a `finally` block. There can be at most one recorder at
13
+ * a time; nesting is a bug (we surface it on stderr but keep the inner
14
+ * recorder so the live command's results take precedence).
15
+ */
16
+ export interface AuditRecord {
17
+ request: HttpRequest;
18
+ response?: HttpResponse;
19
+ durationMs: number;
20
+ error?: string;
21
+ }
22
+
23
+ let _auditRecorder: ((rec: AuditRecord) => void) | null = null;
24
+
25
+ export function setHttpAuditRecorder(recorder: ((rec: AuditRecord) => void) | null): void {
26
+ if (recorder && _auditRecorder) {
27
+ process.stderr.write("zond: nested HTTP audit recorder — overriding the outer scope.\n");
28
+ }
29
+ _auditRecorder = recorder;
30
+ }
31
+
4
32
  export interface FetchOptions {
5
33
  timeout: number;
6
34
  retries: number;
@@ -9,17 +37,69 @@ export interface FetchOptions {
9
37
  rate_limiter?: RateLimiter;
10
38
  rate_limit_retries: number;
11
39
  rate_limit_max_delay_ms: number;
40
+ /** TASK-144: number of network-level retries (ECONNRESET, EPIPE, socket hang
41
+ * up, fetch failed without HTTP response, timeout without response). HTTP
42
+ * status codes are NEVER retried by this path. Exponential backoff with
43
+ * jitter, base = `network_retry_base_ms`. Default 0 (CLI sets it to 1). */
44
+ network_retries: number;
45
+ network_retry_base_ms: number;
46
+ network_retry_max_delay_ms: number;
12
47
  }
13
48
 
14
- export const DEFAULT_FETCH_OPTIONS: FetchOptions = {
49
+ const DEFAULT_FETCH_OPTIONS: FetchOptions = {
15
50
  timeout: 30000,
16
51
  retries: 0,
17
52
  retry_delay: 1000,
18
53
  follow_redirects: true,
19
54
  rate_limit_retries: 5,
20
55
  rate_limit_max_delay_ms: 30000,
56
+ network_retries: 0,
57
+ network_retry_base_ms: 250,
58
+ network_retry_max_delay_ms: 8000,
21
59
  };
22
60
 
61
+ /**
62
+ * Recognise transient TCP/transport-level errors that warrant a retry. We
63
+ * deliberately do NOT include HTTP status codes — a 5xx is a real response
64
+ * the server chose to send, not a flaky socket. Patterns cover Node/Bun
65
+ * error codes (`ECONNRESET`, `EPIPE`, `ECONNREFUSED`, `ETIMEDOUT`,
66
+ * `EAI_AGAIN`), the WHATWG `fetch failed` wrapper Bun throws, classic
67
+ * `socket hang up`, and `AbortError` raised by our own timeout watchdog.
68
+ */
69
+ export function isTransientNetworkError(err: unknown): boolean {
70
+ if (!err) return false;
71
+ const e = err as { code?: string; cause?: unknown; name?: string; message?: string };
72
+ const code = e.code ?? (e.cause as { code?: string } | undefined)?.code;
73
+ if (code) {
74
+ if (
75
+ code === "ECONNRESET" ||
76
+ code === "EPIPE" ||
77
+ code === "ECONNREFUSED" ||
78
+ code === "ETIMEDOUT" ||
79
+ code === "EAI_AGAIN" ||
80
+ code === "ENOTFOUND" ||
81
+ code === "ENETUNREACH"
82
+ ) {
83
+ return true;
84
+ }
85
+ }
86
+ const msg = (e.message ?? String(err)).toLowerCase();
87
+ if (e.name === "AbortError" || msg.includes("aborted")) return true;
88
+ if (msg.includes("socket hang up")) return true;
89
+ if (msg.includes("fetch failed")) return true;
90
+ if (msg.includes("connection reset") || msg.includes("econnreset")) return true;
91
+ if (msg.includes("epipe")) return true;
92
+ if (msg.includes("network error")) return true;
93
+ return false;
94
+ }
95
+
96
+ /** Exponential backoff with full jitter (AWS-style): pick uniformly in
97
+ * [0, min(cap, base * 2^attempt)). Returns ms. */
98
+ export function networkBackoffMs(attempt: number, baseMs: number, capMs: number): number {
99
+ const exp = Math.min(capMs, baseMs * 2 ** attempt);
100
+ return Math.floor(Math.random() * exp);
101
+ }
102
+
23
103
  export async function executeRequest(
24
104
  request: HttpRequest,
25
105
  options?: Partial<FetchOptions>,
@@ -27,6 +107,7 @@ export async function executeRequest(
27
107
  const opts = { ...DEFAULT_FETCH_OPTIONS, ...options };
28
108
  let lastError: Error | undefined;
29
109
  let networkAttempt = 0;
110
+ let networkRetryCount = 0;
30
111
  let rate429Attempt = 0;
31
112
 
32
113
  while (true) {
@@ -100,14 +181,43 @@ export async function executeRequest(
100
181
  }
101
182
  }
102
183
 
103
- return { status: response.status, headers, body: bodyText, body_parsed, duration_ms };
184
+ const httpResp: HttpResponse = {
185
+ status: response.status,
186
+ headers,
187
+ body: bodyText,
188
+ body_parsed,
189
+ duration_ms,
190
+ network_retry_count: networkRetryCount,
191
+ };
192
+ if (_auditRecorder) {
193
+ try { _auditRecorder({ request, response: httpResp, durationMs: duration_ms }); }
194
+ catch { /* recorder is fire-and-forget */ }
195
+ }
196
+ return httpResp;
104
197
  } catch (err) {
105
198
  lastError = err instanceof Error ? err : new Error(String(err));
199
+ const isNet = isTransientNetworkError(lastError);
200
+ // TASK-144 path: dedicated network-retry budget with exp+jitter backoff.
201
+ if (isNet && networkRetryCount < opts.network_retries) {
202
+ const wait = networkBackoffMs(
203
+ networkRetryCount,
204
+ opts.network_retry_base_ms,
205
+ opts.network_retry_max_delay_ms,
206
+ );
207
+ networkRetryCount++;
208
+ await Bun.sleep(wait);
209
+ continue;
210
+ }
211
+ // Legacy linear path (yaml suite.config.retries).
106
212
  if (networkAttempt < opts.retries) {
107
213
  networkAttempt++;
108
214
  await Bun.sleep(opts.retry_delay);
109
215
  continue;
110
216
  }
217
+ if (_auditRecorder) {
218
+ try { _auditRecorder({ request, durationMs: 0, error: lastError.message }); }
219
+ catch { /* recorder is fire-and-forget */ }
220
+ }
111
221
  throw lastError;
112
222
  }
113
223
  }