@kirrosh/zond 0.21.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 +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  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 +55 -6
  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 +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  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 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  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 -21
  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
@@ -1,25 +1,75 @@
1
1
  import { dirname } from "path";
2
2
  import { stat } from "node:fs/promises";
3
- import { parse } from "../../core/parser/yaml-parser.ts";
4
- import { loadEnvironment } from "../../core/parser/variables.ts";
5
- import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
6
- import { runSuite } from "../../core/runner/executor.ts";
7
- import { getReporter } from "../../core/reporter/index.ts";
3
+ import { parseSafe } from "../../core/parser/yaml-parser.ts";
4
+ import { loadEnvironment, loadEnvMeta, loadEnvFile } from "../../core/parser/variables.ts";
5
+ import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod, filterSuitesByOperationFilter } from "../../core/parser/filter.ts";
6
+ import { preflightCheckVars, formatMissingVarLine, summarizeMissingVars } from "../../core/runner/preflight-vars.ts";
7
+ import { runSuite, expandParameterize } from "../../core/runner/executor.ts";
8
+ import {
9
+ ProgressTracker,
10
+ formatProgressLine,
11
+ PROGRESS_INTERVAL_MS,
12
+ PROGRESS_QUIET_MS,
13
+ } from "../../core/runner/progress-tracker.ts";
14
+ import { createSchemaValidator } from "../../core/runner/schema-validator.ts";
15
+ import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
16
+ import { createRateLimiter, createAdaptiveRateLimiter } from "../../core/runner/rate-limiter.ts";
17
+ import { getReporter, generateJsonReport, generateJunitXml } from "../../core/reporter/index.ts";
8
18
  import type { ReporterName } from "../../core/reporter/types.ts";
19
+ import { resolveOutput, OutputSpecError, type OutputSpec } from "../../core/output/index.ts";
20
+ import { writeFile, mkdir } from "node:fs/promises";
21
+ import { dirname as pathDirname, resolve as pathResolve } from "node:path";
9
22
  import type { TestSuite } from "../../core/parser/types.ts";
10
23
  import type { TestRunResult } from "../../core/runner/types.ts";
11
24
  import { printError, printWarning } from "../output.ts";
12
25
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
13
26
  import { getDb } from "../../db/schema.ts";
14
27
  import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
15
- import { AUTH_PATH_RE } from "../../core/runner/execute-run.ts";
28
+ import { AUTH_PATH_RE } from "../../core/runner/auth-path.ts";
29
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
30
+ import { findWorkspaceRoot } from "../../core/workspace/root.ts";
31
+ import { existsSync } from "node:fs";
32
+ import { buildSpecPointer } from "../../core/diagnostics/spec-pointer.ts";
33
+ import { detectStatusDrifts, formatDriftPlan, applyDriftsToTests, appendToleratedDrifts } from "../../core/runner/learn-drift.ts";
34
+ import { detectCiContext } from "../../core/runner/ci-context.ts";
35
+ import { detectRunKind } from "../../core/runner/run-kind.ts";
36
+ import { resolveRateLimit } from "../../core/workspace/config.ts";
37
+
38
+ /**
39
+ * ARV-117 (m-19): OutputSpec for `zond run`. Three formats; console is
40
+ * the default (stdout, rich per-suite stream); `json` / `junit` default
41
+ * to stdout but accept `--output <path>` to redirect. None of the
42
+ * formats are envelope-wrapped — `--report json` is a test-run report
43
+ * (per-suite breakdown), NOT the `{ok, data, errors}` envelope that
44
+ * other commands emit for `--json`. That distinction is intentional
45
+ * (TASK-134) and remains enforced by the explicit absence of `--json`
46
+ * on this command.
47
+ */
48
+ export const RUN_OUTPUT_SPEC: OutputSpec<unknown> = {
49
+ command: "run",
50
+ defaultFormat: "console",
51
+ formats: {
52
+ console: { defaultChannel: "stdout", description: "Rich per-suite stream (default)" },
53
+ json: { defaultChannel: "stdout", description: "Per-test JSON breakdown (`generateJsonReport`)" },
54
+ junit: { defaultChannel: "stdout", description: "JUnit XML — CI consumption" },
55
+ },
56
+ };
16
57
 
17
58
  export interface RunOptions {
18
- path: string;
59
+ /**
60
+ * One or more paths to a YAML file or a directory of YAML files.
61
+ * Multi-path: shell-glob expansion (`zond run tests/*.yaml`) — suites from
62
+ * every path are merged into a single run. The first path is used as the
63
+ * anchor for env-file resolution and DB collection lookup.
64
+ */
65
+ paths: string[];
19
66
  env?: string;
20
67
  report: ReporterName;
21
68
  timeout?: number;
69
+ rateLimit?: number | "auto";
22
70
  bail: boolean;
71
+ /** Run regular suites sequentially (one after another) instead of in parallel. */
72
+ sequential?: boolean;
23
73
  noDb?: boolean;
24
74
  dbPath?: string;
25
75
  authToken?: string;
@@ -27,32 +77,159 @@ export interface RunOptions {
27
77
  tag?: string[];
28
78
  excludeTag?: string[];
29
79
  method?: string;
80
+ /** ARV-25: parity with `zond generate`/`zond checks run` — selector
81
+ * grammar `<path|method|tag|operation-id>:<value>`, repeatable, OR. */
82
+ include?: string[];
83
+ /** ARV-25: same grammar as `include`; evaluated after includes. */
84
+ exclude?: string[];
30
85
  envVars?: string[];
86
+ /** Hard-fail (exit 2) on undefined {{var}} references instead of warning. */
87
+ strictVars?: boolean;
31
88
  dryRun?: boolean;
32
89
  json?: boolean;
90
+ /** ARV-117: write the report to a file instead of stdout. Replaces
91
+ * the legacy `--report-out` flag (no alias — see m-19 lesson §E).
92
+ * Resolved through `core/output`'s OutputSpec policy. */
93
+ output?: string;
94
+ /** Validate every JSON response against the OpenAPI response schema. */
95
+ validateSchema?: boolean;
96
+ /** Explicit OpenAPI spec path/URL (overrides collection.openapi_spec). */
97
+ specPath?: string;
98
+ /** ARV-44: --api <name> passed alongside paths — used as the lookup-by-name
99
+ * fallback when test-path lookup in DB doesn't yield a spec. Parity with
100
+ * ARV-33 (probe mass-assignment / probe security). */
101
+ apiName?: string;
102
+ /** Group this run under a session id (multi-run campaigns). */
103
+ sessionId?: string;
104
+ /** TASK-144: per-step retry budget for transient network errors
105
+ * (ECONNRESET / EPIPE / socket hang up / fetch failed / abort).
106
+ * HTTP statuses are not retried by this path. Default 1, 0 disables. */
107
+ retryOnNetwork?: number;
108
+ /** TASK-282: detect "passing-test-but-wrong-status" drift and print a plan.
109
+ * Implies --validate-schema (requires a spec) so we only flag drift when
110
+ * the body matches the OpenAPI schema — the case where retrying with the
111
+ * observed status would produce a green test. */
112
+ learn?: boolean;
113
+ /** TASK-282: actually mutate files instead of printing the plan. Requires
114
+ * `learn: true` and a `learnTarget`. */
115
+ learnApply?: boolean;
116
+ /** TASK-282: where to record the drift — rewrite YAML (`test`) or append to
117
+ * apis/<name>/tolerated-drifts.yaml (`drifts`). */
118
+ learnTarget?: "test" | "drifts";
119
+ /** TASK-265: console reporter emits only the grand-total summary line. */
120
+ quiet?: boolean;
121
+ /** ARV-72 (feedback round-02 / F14): default true. Set to false (via
122
+ * --no-fail-on-failures) to keep exit code 0 even when steps failed —
123
+ * useful for advisory runs (audit pre-pass, surface discovery) where
124
+ * CI shouldn't break on a single test red. */
125
+ failOnFailures?: boolean;
126
+ /** ARV-249: hard cap on outgoing HTTP requests across the whole run.
127
+ * Once reached, remaining steps short-circuit to `skip` with reason
128
+ * `max-requests-cap-reached`. Useful for sampling huge probe-suite
129
+ * runs and for CI time-boxing. Each `retry_until` attempt counts as
130
+ * one request. */
131
+ maxRequests?: number;
132
+ }
133
+
134
+ /** ARV-249: rough up-front estimate of the total step count for the
135
+ * progress reporter. Walks the parameterize cross-product so a 3×4 grid
136
+ * on a 50-step suite reports as 600. for_each is dynamic (depends on
137
+ * upstream captures) and not counted — the percentage will overshoot a
138
+ * little on for_each-heavy suites; acceptable for an ETA. */
139
+ function estimateTotalSteps(suites: TestSuite[]): number {
140
+ let total = 0;
141
+ for (const suite of suites) {
142
+ const iters = expandParameterize(suite.parameterize).length || 1;
143
+ total += suite.tests.length * iters;
144
+ }
145
+ return total;
33
146
  }
34
147
 
35
148
  export async function runCommand(options: RunOptions): Promise<number> {
36
- // 1. Parse test files
37
- let suites: TestSuite[];
38
- try {
39
- suites = await parse(options.path);
40
- } catch (err) {
41
- printError(err instanceof Error ? err.message : String(err));
149
+ if (options.paths.length === 0) {
150
+ printError("No path given");
151
+ return 2;
152
+ }
153
+ const emptyPaths = options.paths.filter((p) => typeof p !== "string" || p.trim().length === 0);
154
+ if (emptyPaths.length > 0) {
155
+ printError(`Empty path argument (got ${emptyPaths.length} blank entr${emptyPaths.length === 1 ? "y" : "ies"}) — pass a non-empty file or directory path`);
42
156
  return 2;
43
157
  }
158
+ // ARV-39: strip leading/trailing whitespace from path args so a stray space
159
+ // from copy-paste / shell history doesn't produce an ENOENT that quotes
160
+ // the rogue character. Mutating options.paths so downstream lookups
161
+ // (collection by test_path, env-file search) see the cleaned form.
162
+ options.paths = options.paths.map((p) => p.trim());
163
+ const primaryPath = options.paths[0]!;
164
+
165
+ // 1. Parse test files from every input path (collect parse errors instead
166
+ // of silently skipping). Suites from all paths are merged into one run.
167
+ let suites: TestSuite[] = [];
168
+ const parseErrors: { file: string; error: string }[] = [];
169
+ for (const p of options.paths) {
170
+ try {
171
+ const parsed = await parseSafe(p);
172
+ suites.push(...parsed.suites);
173
+ parseErrors.push(...parsed.errors);
174
+ } catch (err) {
175
+ const raw = err instanceof Error ? err.message : String(err);
176
+ printError(formatPathError(p, raw));
177
+ return 2;
178
+ }
179
+ }
180
+
181
+ for (const pe of parseErrors) {
182
+ printWarning(`Skipped ${pe.file}: ${pe.error}`);
183
+ }
44
184
 
45
185
  if (suites.length === 0) {
46
- printWarning(`No test files found in ${options.path}`);
186
+ const pathList = options.paths.join(", ");
187
+ if (parseErrors.length > 0) {
188
+ printError(`All ${parseErrors.length} test file(s) in ${pathList} failed to parse`);
189
+ return 2;
190
+ }
191
+ printWarning(`No test files found in ${pathList}`);
47
192
  return 0;
48
193
  }
49
194
 
195
+ // ARV-37: when a selector matches zero suites (typo'd --tag, dead --include
196
+ // pattern, etc.), exit non-zero. Previous fail-open let CI builds go green
197
+ // for `--tag smok` instead of `smoke`. For --tag we also surface the tags
198
+ // actually available so the user can correct without re-reading help.
199
+ // 1b0. ARV-25: unified --include/--exclude filter (parity with generate/checks).
200
+ // Applied before tag/method filters so it can narrow the scope first.
201
+ if ((options.include && options.include.length > 0) || (options.exclude && options.exclude.length > 0)) {
202
+ const result = filterSuitesByOperationFilter(suites, options.include ?? [], options.exclude ?? []);
203
+ if (result.errors.length > 0) {
204
+ for (const err of result.errors) printError(err);
205
+ return 2;
206
+ }
207
+ suites = result.suites;
208
+ if (suites.length === 0) {
209
+ const parts: string[] = [];
210
+ if (options.include?.length) parts.push(`--include [${options.include.join(", ")}]`);
211
+ if (options.exclude?.length) parts.push(`--exclude [${options.exclude.join(", ")}]`);
212
+ printError(`No tests match ${parts.join(" / ")}`);
213
+ return 1;
214
+ }
215
+ }
216
+
50
217
  // 1b. Tag filter
51
218
  if (options.tag && options.tag.length > 0) {
219
+ const availableTags = collectAvailableTags(suites);
52
220
  suites = filterSuitesByTags(suites, options.tag);
53
221
  if (suites.length === 0) {
54
- printWarning("No suites match the specified tags");
55
- return 0;
222
+ const tagHint = availableTags.length > 0
223
+ ? ` Available tags: ${availableTags.join(", ")}.`
224
+ : " (loaded suites declare no tags.)";
225
+ if (parseErrors.length > 0) {
226
+ printError(
227
+ `No suites match tags [${options.tag.join(", ")}] — but ${parseErrors.length} file(s) failed to parse (see warnings above). Fix parse errors and retry.${tagHint}`
228
+ );
229
+ return 1;
230
+ }
231
+ printError(`No suites match tags [${options.tag.join(", ")}].${tagHint}`);
232
+ return 1;
56
233
  }
57
234
  }
58
235
 
@@ -60,8 +237,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
60
237
  if (options.excludeTag && options.excludeTag.length > 0) {
61
238
  suites = excludeSuitesByTags(suites, options.excludeTag);
62
239
  if (suites.length === 0) {
63
- printWarning("All suites excluded by --exclude-tag");
64
- return 0;
240
+ printError(`All suites excluded by --exclude-tag [${options.excludeTag.join(", ")}]`);
241
+ return 1;
65
242
  }
66
243
  }
67
244
 
@@ -69,8 +246,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
69
246
  if (options.method) {
70
247
  suites = filterSuitesByMethod(suites, options.method);
71
248
  if (suites.length === 0) {
72
- printWarning(`No tests found with method ${options.method.toUpperCase()}`);
73
- return 0;
249
+ printError(`No tests found with method ${options.method.toUpperCase()}`);
250
+ return 1;
74
251
  }
75
252
  }
76
253
 
@@ -92,13 +269,13 @@ export async function runCommand(options: RunOptions): Promise<number> {
92
269
 
93
270
  // 2. Load environment (resolve collection for scoped envs)
94
271
  // Use path itself as searchDir if it's a directory; dirname() on a dir path gives the parent
95
- const pathStat = await stat(options.path).catch(() => null);
96
- const searchDir = pathStat?.isDirectory() ? options.path : dirname(options.path);
272
+ const pathStat = await stat(primaryPath).catch(() => null);
273
+ const searchDir = pathStat?.isDirectory() ? primaryPath : dirname(primaryPath);
97
274
  let collectionForEnv: { id: number } | null = null;
98
275
  if (!options.noDb) {
99
276
  try {
100
277
  getDb(options.dbPath);
101
- collectionForEnv = findCollectionByTestPath(options.path);
278
+ collectionForEnv = findCollectionByTestPath(primaryPath);
102
279
  } catch { /* DB not available — OK */ }
103
280
  }
104
281
 
@@ -110,6 +287,34 @@ export async function runCommand(options: RunOptions): Promise<number> {
110
287
  return 2;
111
288
  }
112
289
 
290
+ // Auto-load ./.env.yaml from cwd when --env not given and the searchDir env
291
+ // file produced nothing. Useful when running with absolute test paths from
292
+ // a collection cwd (e.g. APPLY agents in the auto-loop).
293
+ if (!options.env && Object.keys(env).length === 0) {
294
+ const cwd = process.cwd();
295
+ const cwdEnvPath = `${cwd}/.env.yaml`;
296
+ // Avoid double-load if cwd is already covered by searchDir or its parent
297
+ const alreadyCovered = cwd === searchDir || cwd === dirname(searchDir);
298
+ if (!alreadyCovered) {
299
+ try {
300
+ const cwdVars = await loadEnvFile(cwdEnvPath);
301
+ if (cwdVars && Object.keys(cwdVars).length > 0) {
302
+ env = { ...cwdVars };
303
+ if (!options.json) {
304
+ process.stderr.write(`zond: using ./.env.yaml (cwd fallback)\n`);
305
+ }
306
+ }
307
+ } catch (err) {
308
+ printError(`Failed to load environment: ${(err as Error).message}`);
309
+ return 2;
310
+ }
311
+ }
312
+ }
313
+
314
+ if (options.sessionId && !options.json) {
315
+ process.stderr.write(`zond: session ${options.sessionId} (run will be grouped)\n`);
316
+ }
317
+
113
318
  // Inject CLI auth token — overrides env file value
114
319
  if (options.authToken) {
115
320
  env.auth_token = options.authToken;
@@ -137,6 +342,167 @@ export async function runCommand(options: RunOptions): Promise<number> {
137
342
  }
138
343
  }
139
344
 
345
+ // 3b. Resolve rate limit: CLI flag > .env.yaml `rateLimit:` > workspace
346
+ // `defaults.rate_limit` (TASK-301) > undefined.
347
+ let envRateLimit: number | "auto" | undefined;
348
+ try {
349
+ envRateLimit = (await loadEnvMeta(options.env, searchDir)).rateLimit;
350
+ } catch { /* meta load failure is non-fatal */ }
351
+ const rateLimit = resolveRateLimit(options.rateLimit, envRateLimit);
352
+ // ARV-64 (feedback round-01 / F4): when no rate-limit was configured
353
+ // explicitly, default to an adaptive limiter. Adaptive is a no-op until
354
+ // a response carries RateLimit-* headers (RFC 9568) — in which case it
355
+ // learns the policy and throttles subsequent requests so a burst can't
356
+ // blow through small windows like small windows (e.g. 5 req/s). Without this default
357
+ // `zond run` ignored server-published rate-limit headers entirely and
358
+ // 22% of a typical sweep landed in 429.
359
+ let rateLimiter: ReturnType<typeof createAdaptiveRateLimiter> | undefined;
360
+ if (rateLimit === "auto") {
361
+ rateLimiter = createAdaptiveRateLimiter();
362
+ } else if (rateLimit !== undefined) {
363
+ rateLimiter = createRateLimiter(rateLimit);
364
+ } else {
365
+ rateLimiter = createAdaptiveRateLimiter();
366
+ }
367
+
368
+ // 3c. Resolve OpenAPI spec. Explicit --spec wins; otherwise fall back to the
369
+ // collection record. The doc is reused for --validate-schema (TASK-50) and
370
+ // for spec_pointer/spec_excerpt frozen evidence (TASK-102).
371
+ let schemaValidator: ReturnType<typeof createSchemaValidator> | undefined;
372
+ let openApiDoc: unknown | undefined;
373
+ {
374
+ let specPath = options.specPath;
375
+ if (!specPath) {
376
+ try {
377
+ const collection = findCollectionByTestPath(primaryPath);
378
+ if (collection?.openapi_spec) specPath = resolveCollectionSpec(collection.openapi_spec);
379
+ } catch { /* DB not available — fall through */ }
380
+ }
381
+ // ARV-44: parity with ARV-33 (probe mass-assignment / security). When the
382
+ // user passed --api <name> together with a path, the test-path lookup
383
+ // above may miss (path normalisation, alias, --all merge). Fall back to
384
+ // lookup-by-name, then to apis/<name>/spec.json on disk.
385
+ if (!specPath && options.apiName) {
386
+ try {
387
+ const byName = resolveApiCollection(options.apiName, options.dbPath);
388
+ if (!("error" in byName) && byName.spec) specPath = byName.spec;
389
+ } catch { /* fall through to disk probe */ }
390
+ if (!specPath) {
391
+ try {
392
+ const ws = findWorkspaceRoot();
393
+ const onDisk = pathResolve(ws.root, "apis", options.apiName, "spec.json");
394
+ if (existsSync(onDisk)) specPath = onDisk;
395
+ } catch { /* workspace not initialised — give up silently */ }
396
+ }
397
+ }
398
+ // ARV-209 (R12/F11): when --validate-schema is requested but no --api was
399
+ // passed (explicit suite path suppresses current-api fallback at the
400
+ // argv-parse level), still try to derive the spec from the test path
401
+ // pattern `apis/<name>/tests/...`. SKILL.md DEPTH-PASS examples use that
402
+ // exact shape and expect the spec to resolve automatically.
403
+ if (!specPath && primaryPath) {
404
+ const m = primaryPath.replace(/\\/g, "/").match(/(?:^|\/)apis\/([^\/]+)\/tests(?:\/|$)/);
405
+ if (m) {
406
+ const apiName = m[1]!;
407
+ try {
408
+ const byName = resolveApiCollection(apiName, options.dbPath);
409
+ if (!("error" in byName) && byName.spec) specPath = byName.spec;
410
+ } catch { /* fall through to disk probe */ }
411
+ if (!specPath) {
412
+ try {
413
+ const ws = findWorkspaceRoot();
414
+ const onDisk = pathResolve(ws.root, "apis", apiName, "spec.json");
415
+ if (existsSync(onDisk)) specPath = onDisk;
416
+ } catch { /* give up silently */ }
417
+ }
418
+ }
419
+ }
420
+ if (specPath) {
421
+ try {
422
+ openApiDoc = await readOpenApiSpec(specPath);
423
+ } catch (err) {
424
+ if (options.validateSchema) {
425
+ printError(`Failed to load OpenAPI spec '${specPath}': ${(err as Error).message}`);
426
+ return 2;
427
+ }
428
+ // spec_pointer is best-effort — non-fatal when spec can't be loaded.
429
+ printWarning(`Failed to load OpenAPI spec '${specPath}' for spec_pointer evidence: ${(err as Error).message}`);
430
+ }
431
+ }
432
+ // TASK-282: --learn requires schema validation evidence (status mismatch
433
+ // is only a "drift" if the body actually matches the OpenAPI contract —
434
+ // otherwise we'd silently encourage masking a real schema bug).
435
+ const needsSchema = options.validateSchema || options.learn;
436
+ if (needsSchema) {
437
+ if (!openApiDoc) {
438
+ const flag = options.learn ? "--learn" : "--validate-schema";
439
+ printError(
440
+ `${flag} requires --spec <path|url> or a collection with openapi_spec set. ` +
441
+ `Pass \`--api <name>\` (resolves apis/<name>/spec.json) or add \`--spec apis/<name>/spec.json\` explicitly.`,
442
+ );
443
+ return 2;
444
+ }
445
+ schemaValidator = createSchemaValidator(openApiDoc as Parameters<typeof createSchemaValidator>[0]);
446
+ }
447
+ }
448
+
449
+ // TASK-282: validate --learn flag combinations early (before run).
450
+ if (options.learnApply && !options.learn) {
451
+ printError("--learn-apply requires --learn");
452
+ return 2;
453
+ }
454
+ if (options.learnApply && !options.learnTarget) {
455
+ printError("--learn-apply requires --learn-target=test or --learn-target=drifts");
456
+ return 2;
457
+ }
458
+
459
+ // ARV-249: shared HTTP-request budget. `Infinity` means uncapped.
460
+ const requestBudget = options.maxRequests !== undefined && options.maxRequests > 0
461
+ ? { limit: options.maxRequests, used: 0 }
462
+ : undefined;
463
+
464
+ // ARV-249: progress reporter. Enabled when stderr is a TTY and the user
465
+ // hasn't opted out via --quiet. Suppressed for non-interactive output
466
+ // (CI logs already track timestamps; an extra line every 5s is noise).
467
+ const progressEnabled = !options.quiet
468
+ && !options.dryRun
469
+ && Boolean((process.stderr as { isTTY?: boolean }).isTTY);
470
+ const totalStepsForProgress = progressEnabled
471
+ ? estimateTotalSteps(suites)
472
+ : 0;
473
+ const tracker = progressEnabled
474
+ ? new ProgressTracker(totalStepsForProgress)
475
+ : undefined;
476
+ let lastProgressLineLen = 0;
477
+ const writeProgressLine = (final: boolean): void => {
478
+ if (!tracker) return;
479
+ const snap = tracker.snapshot();
480
+ // Hold off on the first emit until the run has actually been running
481
+ // a while — short test suites should stay silent.
482
+ if (!final && snap.elapsedMs < PROGRESS_QUIET_MS) return;
483
+ const line = formatProgressLine(snap);
484
+ // Overwrite previous line in place (single TTY row, no scroll).
485
+ const pad = " ".repeat(Math.max(0, lastProgressLineLen - line.length));
486
+ process.stderr.write(`\r${line}${pad}${final ? "\n" : ""}`);
487
+ lastProgressLineLen = line.length;
488
+ };
489
+ let progressInterval: ReturnType<typeof setInterval> | undefined;
490
+ if (tracker) {
491
+ progressInterval = setInterval(() => writeProgressLine(false), PROGRESS_INTERVAL_MS);
492
+ // Don't let the timer pin the event loop alive past run completion.
493
+ (progressInterval as { unref?: () => void }).unref?.();
494
+ }
495
+
496
+ const runOpts = {
497
+ rateLimiter,
498
+ schemaValidator,
499
+ networkRetries: options.retryOnNetwork,
500
+ requestBudget,
501
+ onStepDone: tracker
502
+ ? (step: import("../../core/runner/types.ts").StepResult) => tracker.recordStep(step)
503
+ : undefined,
504
+ };
505
+
140
506
  // 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
141
507
  const results: TestRunResult[] = [];
142
508
  const dryRun = options.dryRun === true;
@@ -145,8 +511,19 @@ export async function runCommand(options: RunOptions): Promise<number> {
145
511
  const regularSuites = suites.filter(s => !s.setup);
146
512
  const setupCaptures: Record<string, string> = {};
147
513
 
514
+ // 3d. Pre-flight variable check on setup suites — only `env` is known
515
+ // (their captures don't exist yet).
516
+ {
517
+ const setupHits = preflightCheckVars(setupSuites, env);
518
+ emitMissingVarWarnings(setupHits);
519
+ if (options.strictVars && setupHits.length > 0) {
520
+ printError(`--strict-vars: ${setupHits.length} undefined variable reference(s) in setup suites`);
521
+ return 2;
522
+ }
523
+ }
524
+
148
525
  for (const suite of setupSuites) {
149
- const result = await runSuite(suite, env, dryRun);
526
+ const result = await runSuite(suite, env, dryRun, runOpts);
150
527
  results.push(result);
151
528
  for (const step of result.steps) {
152
529
  for (const [k, v] of Object.entries(step.captures)) {
@@ -157,21 +534,49 @@ export async function runCommand(options: RunOptions): Promise<number> {
157
534
 
158
535
  const enrichedEnv = { ...env, ...setupCaptures };
159
536
 
537
+ // 3e. Pre-flight variable check on regular suites — env + setup captures
538
+ // are known producers; per-suite captures/sets/parameterize handled inside.
539
+ {
540
+ const hits = preflightCheckVars(regularSuites, enrichedEnv);
541
+ emitMissingVarWarnings(hits);
542
+ if (options.strictVars && hits.length > 0) {
543
+ printError(`--strict-vars: ${hits.length} undefined variable reference(s)`);
544
+ return 2;
545
+ }
546
+ }
547
+
160
548
  if (options.bail) {
161
549
  // Sequential with bail at suite level
162
550
  for (const suite of regularSuites) {
163
- const result = await runSuite(suite, enrichedEnv, dryRun);
551
+ const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
164
552
  results.push(result);
165
553
  if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
166
554
  break;
167
555
  }
168
556
  }
557
+ } else if (options.sequential) {
558
+ // Sequential without bail — run suites one by one
559
+ for (const suite of regularSuites) {
560
+ const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
561
+ results.push(result);
562
+ }
169
563
  } else {
170
564
  // Parallel
171
- const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
565
+ const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun, runOpts)));
172
566
  results.push(...all);
173
567
  }
174
568
 
569
+ // ARV-249: stop the progress tracker before any reporter output starts
570
+ // — overlapping carriage returns and report lines garble the terminal.
571
+ if (progressInterval !== undefined) {
572
+ clearInterval(progressInterval);
573
+ // Clear the in-place progress line so it doesn't linger above the report.
574
+ if (lastProgressLineLen > 0) {
575
+ process.stderr.write(`\r${" ".repeat(lastProgressLineLen)}\r`);
576
+ lastProgressLineLen = 0;
577
+ }
578
+ }
579
+
175
580
  // 5. Collect warnings
176
581
  const warnings: string[] = [];
177
582
  const rateLimited = results.flatMap(r => r.steps)
@@ -179,13 +584,119 @@ export async function runCommand(options: RunOptions): Promise<number> {
179
584
  if (rateLimited.length > 0) {
180
585
  warnings.push(`${rateLimited.length} request(s) hit rate limit (429). Consider: consolidating login steps, adding --bail, or using retry_until with delay.`);
181
586
  }
587
+ // ARV-249: surface --max-requests cap when it actually fired.
588
+ if (requestBudget && requestBudget.used >= requestBudget.limit) {
589
+ const cappedSteps = results.flatMap(r => r.steps)
590
+ .filter(s => s.status === "skip" && s.error === "max-requests-cap-reached").length;
591
+ if (cappedSteps > 0) {
592
+ warnings.push(`--max-requests ${requestBudget.limit} cap reached; ${cappedSteps} subsequent step(s) skipped. Raise the cap or narrow the suite to cover them.`);
593
+ }
594
+ }
182
595
 
183
- // 5b. Report
596
+ // 5b. Report — ARV-117: route through core/output's OutputSpec so
597
+ // `--report <format>` + `--output <path>` follow the same policy as
598
+ // every other command. `--output` (was `--report-out`) is honoured for
599
+ // any format; with `console` format it falls back to JSON in the file
600
+ // (most useful), matching prior behaviour.
601
+ let resolvedOutput;
602
+ try {
603
+ resolvedOutput = resolveOutput(RUN_OUTPUT_SPEC, {
604
+ report: options.report,
605
+ output: options.output,
606
+ });
607
+ } catch (err) {
608
+ if (err instanceof OutputSpecError) {
609
+ printError(err.message);
610
+ return 2;
611
+ }
612
+ throw err;
613
+ }
184
614
  if (!options.json) {
185
- const reporter = getReporter(options.report);
186
- reporter.report(results);
187
- for (const w of warnings) {
188
- printWarning(w);
615
+ if (resolvedOutput.channel === "file") {
616
+ const outPath = resolvedOutput.path!;
617
+ let content: string;
618
+ let label: string;
619
+ switch (resolvedOutput.format) {
620
+ case "junit":
621
+ content = generateJunitXml(results);
622
+ label = "JUnit XML";
623
+ break;
624
+ case "json":
625
+ default: // "console" — fall back to JSON in the file (most useful)
626
+ content = generateJsonReport(results);
627
+ label = "JSON";
628
+ break;
629
+ }
630
+ try {
631
+ await mkdir(pathDirname(outPath), { recursive: true });
632
+ await writeFile(outPath, content, "utf-8");
633
+ process.stderr.write(`zond: ${label} report written to ${outPath}\n`);
634
+ } catch (err) {
635
+ printError(`Failed to write --output file ${outPath}: ${(err as Error).message}`);
636
+ return 2;
637
+ }
638
+ for (const w of warnings) {
639
+ printWarning(w);
640
+ }
641
+ } else {
642
+ const reporter = getReporter(options.report);
643
+ reporter.report(results, options.quiet ? { quiet: true } : undefined);
644
+ // TASK-265: --quiet drops the warnings tail too — they are non-essential
645
+ // run hints (deprecation, timing). Errors still reach stderr via the
646
+ // dedicated path; --strict-vars still aborts on undefined refs before
647
+ // we get here.
648
+ if (!options.quiet) {
649
+ for (const w of warnings) {
650
+ printWarning(w);
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ // 5b. Resolve spec_pointer + spec_excerpt for steps with provenance.
657
+ // Frozen evidence: pointer and excerpt are computed once, against the spec
658
+ // doc loaded at run time, and saved into the DB so later spec edits don't
659
+ // rewrite history.
660
+ if (openApiDoc) {
661
+ for (const r of results) {
662
+ for (const s of r.steps) {
663
+ if (!s.provenance) continue;
664
+ const ptr = buildSpecPointer(s.provenance, openApiDoc);
665
+ if (ptr) {
666
+ s.spec_pointer = ptr.pointer;
667
+ s.spec_excerpt = ptr.excerpt;
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ // 5c. TASK-282: --learn — surface or apply status-code drift.
674
+ if (options.learn) {
675
+ const drifts = detectStatusDrifts(results, { schemaValidatorAttached: schemaValidator !== undefined });
676
+ if (!options.learnApply) {
677
+ process.stderr.write(formatDriftPlan(drifts));
678
+ } else if (drifts.length === 0) {
679
+ process.stderr.write("zond: --learn-apply: no drift to apply\n");
680
+ } else if (options.learnTarget === "test") {
681
+ const applied = await applyDriftsToTests(drifts);
682
+ process.stderr.write(`zond: --learn-apply --learn-target=test: rewrote ${applied.updated} step(s)\n`);
683
+ for (const e of applied.errors) {
684
+ printWarning(`learn-apply: ${e.suite_file} step "${e.step_name}": ${e.reason}`);
685
+ }
686
+ } else if (options.learnTarget === "drifts") {
687
+ // Resolve apis/<name>/ from the primary path's collection record.
688
+ let apiDir: string | undefined;
689
+ try {
690
+ const collection = findCollectionByTestPath(primaryPath);
691
+ if (collection?.base_dir) apiDir = collection.base_dir;
692
+ else if (collection?.test_path) apiDir = dirname(collection.test_path);
693
+ } catch { /* DB unavailable */ }
694
+ if (!apiDir) {
695
+ printError("--learn-target=drifts: cannot resolve apis/<name>/ — collection not registered (run `zond add api <name>`)");
696
+ return 2;
697
+ }
698
+ const written = await appendToleratedDrifts(apiDir, drifts);
699
+ process.stderr.write(`zond: --learn-apply --learn-target=drifts: wrote ${written.written} entry(ies) to ${written.file}\n`);
189
700
  }
190
701
  }
191
702
 
@@ -194,11 +705,35 @@ export async function runCommand(options: RunOptions): Promise<number> {
194
705
  if (!options.noDb) {
195
706
  try {
196
707
  getDb(options.dbPath);
197
- const collection = findCollectionByTestPath(options.path);
708
+ const collection = findCollectionByTestPath(primaryPath);
709
+ // TASK-274: capture suite-level tags executed in this run (plus any
710
+ // explicit --tag filter). Stored on the run row so
711
+ // `coverage --union tag:<x>` can later select runs by tag.
712
+ const tagSet = new Set<string>();
713
+ for (const s of suites) {
714
+ for (const t of s.tags ?? []) tagSet.add(t);
715
+ }
716
+ for (const t of options.tag ?? []) tagSet.add(t);
717
+ const tags = [...tagSet].sort();
718
+ // TASK-116: stamp CI context on the run row when running under a
719
+ // detected CI environment (or when the user passed an explicit
720
+ // `--trigger ci`). Manual runs default to trigger=manual with no
721
+ // commit/branch — preserving prior behaviour.
722
+ const ci = detectCiContext();
723
+ // ARV-55: classify the run by what kinds of suite files it executed
724
+ // *before* INSERT, so coverage's default query becomes a simple
725
+ // run_kind='regular' compare instead of a per-result regex scan.
726
+ const runKind = detectRunKind(suites.map((s) => s.filePath ?? null));
198
727
  savedRunId = createRun({
199
728
  started_at: results[0]?.started_at ?? new Date().toISOString(),
200
729
  environment: options.env,
201
730
  collection_id: collection?.id,
731
+ session_id: options.sessionId,
732
+ trigger: ci.trigger,
733
+ commit_sha: ci.commit_sha ?? undefined,
734
+ branch: ci.branch ?? undefined,
735
+ ...(tags.length > 0 ? { tags } : {}),
736
+ run_kind: runKind,
202
737
  });
203
738
  finalizeRun(savedRunId, results);
204
739
  saveResults(savedRunId, results);
@@ -216,6 +751,23 @@ export async function runCommand(options: RunOptions): Promise<number> {
216
751
  }
217
752
  const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
218
753
 
754
+ // ARV-105 (F10): a suite where every step was skipped (e.g. probe-emitted
755
+ // regression suite that needs an unfilled capture-chain var) reports
756
+ // total>0, passed=0, failed=0, skipped=total. Without surfacing this,
757
+ // CI sees "0 failed" and the run looks green even though nothing was
758
+ // actually tested. Compute the list once and expose it in both the
759
+ // JSON envelope and the stderr tail.
760
+ const allSkippedSuites = results
761
+ .filter(r => r.total > 0 && r.passed === 0 && r.failed === 0 && r.skipped === r.total)
762
+ .map(r => ({
763
+ suite: r.suite_name,
764
+ ...(r.suite_file ? { file: r.suite_file } : {}),
765
+ total: r.total,
766
+ // Sample skip reason from the first step — usually "missing variable
767
+ // {{X}}" or similar. Helps the operator route to fixtures vs spec.
768
+ first_skip_reason: r.steps[0]?.error ?? null,
769
+ }));
770
+
219
771
  if (options.json) {
220
772
  const total = results.reduce((s, r) => s + r.total, 0);
221
773
  const passed = results.reduce((s, r) => s + r.passed, 0);
@@ -226,11 +778,337 @@ export async function runCommand(options: RunOptions): Promise<number> {
226
778
  test: s.name,
227
779
  ...(r.suite_file ? { file: r.suite_file } : {}),
228
780
  status: s.status,
781
+ ...(typeof s.response?.status === "number" ? { http_status: s.response.status } : {}),
782
+ ...(typeof s.response?.status === "number" && s.response.status >= 500 && s.response.status < 600 ? { is_5xx: true } : {}),
229
783
  error: s.error,
784
+ ...(s.failure_class ? { failure_class: s.failure_class, failure_class_reason: s.failure_class_reason } : {}),
785
+ ...(s.provenance ? { provenance: s.provenance } : {}),
786
+ ...(s.spec_pointer ? { spec_pointer: s.spec_pointer, spec_excerpt: s.spec_excerpt } : {}),
787
+ ...(s.network_retry ? { network_retry: s.network_retry } : {}),
230
788
  }))
231
789
  );
232
- printJson(jsonOk("run", { summary: { total, passed, failed }, failures, warnings, runId: savedRunId }));
790
+ const fiveXx = failures.filter(f => f.is_5xx).length;
791
+ printJson(jsonOk("run", {
792
+ summary: { total, passed, failed, fiveXx, allSkippedSuites: allSkippedSuites.length },
793
+ failures,
794
+ ...(allSkippedSuites.length > 0 ? { all_skipped_suites: allSkippedSuites } : {}),
795
+ warnings,
796
+ runId: savedRunId,
797
+ }));
798
+ }
799
+
800
+ // ARV-105 (F10): non-json — name the all-skipped suites on stderr so a
801
+ // tail-eyeballing operator notices the visibility-pitfall. Doesn't gate
802
+ // exit code (skipping isn't a failure) but is loud enough that a green
803
+ // "0 failed" can no longer hide a regression suite that ran zero steps.
804
+ if (allSkippedSuites.length > 0 && !options.json) {
805
+ process.stderr.write(`zond: ${allSkippedSuites.length} suite(s) ran with every step skipped (no test executed — likely missing fixtures or capture-chain ids).\n`);
806
+ for (const s of allSkippedSuites) {
807
+ const reason = s.first_skip_reason ? ` — ${s.first_skip_reason}` : "";
808
+ process.stderr.write(` - ${s.suite}${s.file ? ` (${s.file})` : ""}: ${s.total} step(s) skipped${reason}\n`);
809
+ }
810
+ }
811
+
812
+ // ARV-162 (round-08 F19): when suites failed parse-time validation
813
+ // (`zond check tests` reject — e.g. form value emitted unquoted, parsed
814
+ // as int), per-file warnings already went to stderr at the top of the
815
+ // run, but they easily get lost in long output. Add a loud trailing
816
+ // summary so "47/68 ran" doesn't silently hide 21 invalid files.
817
+ if (parseErrors.length > 0 && !options.json) {
818
+ process.stderr.write(
819
+ `zond: ${parseErrors.length} test file(s) skipped due to validation errors — ` +
820
+ `run \`zond check tests <path>\` to see why. Coverage numbers below exclude these files.\n`,
821
+ );
822
+ }
823
+
824
+ // ARV-72 (feedback round-02 / F14): make the exit-code → failures
825
+ // mapping visible. Tester reported "545 failed, exit_code=0" which is
826
+ // not what this function returns — but the symptom is real: the reader
827
+ // can't tell whether the surrounding shell or a wrapper script swallowed
828
+ // the non-zero exit. Print a one-line tail to stderr that names the
829
+ // exit code so wrapper scripts that hide it become obvious. Skipped in
830
+ // --json so the JSON envelope stays alone on stdout (this is stderr
831
+ // anyway, but skip avoids confusing parsers that capture both streams).
832
+ if (hasFailures && !options.json) {
833
+ const total = results.reduce((s, r) => s + r.failed, 0);
834
+ process.stderr.write(`zond: ${total} test step(s) failed — exiting with code 1 (pass --no-fail-on-failures to suppress, e.g. for advisory runs).\n`);
233
835
  }
234
836
 
837
+ if (hasFailures && options.failOnFailures === false) {
838
+ return 0;
839
+ }
235
840
  return hasFailures ? 1 : 0;
236
841
  }
842
+
843
+ import type { Command } from "commander";
844
+ import { Option } from "commander";
845
+ import { resolveApiCollection } from "../resolve.ts";
846
+ import { collect, flatSplit, parseNonNegativeInt, parsePositiveInt, parseRateLimit, parseReporter } from "../argv.ts";
847
+ import { resolveSessionId } from "../../core/context/session.ts";
848
+ import { getApi } from "../util/api-context.ts";
849
+ import { readdirSync, statSync } from "node:fs";
850
+ import { join } from "node:path";
851
+
852
+ /**
853
+ * TASK-248: when 4+ hits collapse them into one summary line per unique
854
+ * variable name. Below threshold the per-(suite,step) form still helps
855
+ * users locate the missing reference.
856
+ */
857
+ function emitMissingVarWarnings(hits: import("../../core/runner/preflight-vars.ts").MissingVarHit[]): void {
858
+ if (hits.length === 0) return;
859
+ if (hits.length < 4) {
860
+ for (const hit of hits) printWarning(formatMissingVarLine(hit));
861
+ return;
862
+ }
863
+ for (const line of summarizeMissingVars(hits)) printWarning(line);
864
+ }
865
+
866
+ /**
867
+ * ARV-39: rewrite an ENOENT bubbling up from `parseSafe` so the user sees
868
+ * a clean path quote and an actionable hint. Bun's raw glob/open error
869
+ * quotes the path with a trailing space ("'foo.yaml '"), which makes users
870
+ * think the path itself has a typo. We strip the noise and, when the file's
871
+ * parent directory exists, suggest either the dir form (`apis/<api>/tests`)
872
+ * or list its YAML siblings so the right invocation is discoverable.
873
+ */
874
+ function formatPathError(path: string, raw: string): string {
875
+ const isEnoent = /ENOENT/i.test(raw) || /no such file or directory/i.test(raw);
876
+ if (!isEnoent) return raw;
877
+ const cleanPath = path.trim();
878
+ const parent = pathDirname(cleanPath);
879
+ const lines: string[] = [`Path not found: ${cleanPath}`];
880
+ // Parent directory exists → list known YAML suites to make the right
881
+ // invocation obvious. Parent missing → just say so; nothing useful to add.
882
+ try {
883
+ if (parent && existsSync(parent) && statSync(parent).isDirectory()) {
884
+ const siblings = readdirSync(parent)
885
+ .filter((f) => /\.ya?ml$/i.test(f))
886
+ .sort();
887
+ if (siblings.length === 0) {
888
+ lines.push(`(${parent}/ has no YAML files — did you forget to run \`zond generate\`?)`);
889
+ } else {
890
+ // Prefer suggesting the directory itself — that's the parity command
891
+ // most users actually want ("run all suites under apis/<api>/tests").
892
+ lines.push(`Did you mean \`zond run ${parent}\` (runs ${siblings.length} suite${siblings.length === 1 ? "" : "s"})?`);
893
+ const preview = siblings.slice(0, 6).map((s) => ` - ${parent}/${s}`).join("\n");
894
+ const more = siblings.length > 6 ? `\n … ${siblings.length - 6} more` : "";
895
+ lines.push(`Known suites in ${parent}/:\n${preview}${more}`);
896
+ }
897
+ }
898
+ } catch { /* readdir / stat failed — fall through with the basic message */ }
899
+ return lines.join("\n");
900
+ }
901
+
902
+ /**
903
+ * ARV-37: list distinct tags across loaded suites so a fail-loud `--tag`
904
+ * mismatch can suggest the user-facing values without forcing them into
905
+ * `--help`. Order is alphabetical for stable output across runs.
906
+ */
907
+ function collectAvailableTags(suites: { tags?: string[] }[]): string[] {
908
+ const seen = new Set<string>();
909
+ for (const s of suites) {
910
+ if (!s.tags) continue;
911
+ for (const t of s.tags) {
912
+ const trimmed = t.trim();
913
+ if (trimmed) seen.add(trimmed);
914
+ }
915
+ }
916
+ return [...seen].sort();
917
+ }
918
+
919
+ /**
920
+ * TASK-116: discover every `apis/<name>/tests/` directory in the workspace
921
+ * for `zond run --all`. Skips entries without a tests/ subdir (some APIs
922
+ * may have probes only). Returns absolute paths so the run command resolves
923
+ * env files correctly even when invoked from a subdirectory.
924
+ */
925
+ function discoverWorkspaceTestPaths(): { paths: string[] } | { error: string } {
926
+ let root: string;
927
+ try {
928
+ const ws = findWorkspaceRoot();
929
+ if (ws.fromFallback) {
930
+ return { error: "--all requires a workspace marker (zond.config.yml). Run `zond init` first." };
931
+ }
932
+ root = ws.root;
933
+ } catch (err) {
934
+ return { error: `Failed to locate workspace root: ${(err as Error).message}` };
935
+ }
936
+
937
+ const apisDir = join(root, "apis");
938
+ if (!existsSync(apisDir)) return { paths: [] };
939
+
940
+ const out: string[] = [];
941
+ for (const entry of readdirSync(apisDir, { withFileTypes: true })) {
942
+ if (!entry.isDirectory()) continue;
943
+ const testsDir = join(apisDir, entry.name, "tests");
944
+ if (!existsSync(testsDir)) continue;
945
+ try {
946
+ if (statSync(testsDir).isDirectory()) out.push(testsDir);
947
+ } catch { /* skip unreadable */ }
948
+ }
949
+ return { paths: out.sort() };
950
+ }
951
+
952
+ export function registerRun(program: Command): void {
953
+ program
954
+ .command("run [paths...]")
955
+ .description(
956
+ "Run API tests. Accepts one or more file/dir paths (shell-glob friendly: zond run tests/*.yaml). " +
957
+ "TASK-134: this command does NOT accept `--json` (which would collide with `--report json`). " +
958
+ "For JSON-envelope-style output use `--report json` (per-test breakdown) or `--report junit`. " +
959
+ "Other commands (`request`, `coverage`, `db diagnose`, etc.) DO accept `--json` — there it returns " +
960
+ "a small `{ok, data, errors}` envelope. The two flags are intentionally distinct: " +
961
+ "`run --report json` is a test-run report, `<cmd> --json` is a query-result envelope.",
962
+ )
963
+ .option("--env <name>", "Use environment file (.env.<name>.yaml)")
964
+ .option("--api <name>", "Use API collection (resolves test path automatically)")
965
+ .addOption(
966
+ new Option("--report <format>", "Output format")
967
+ .choices(["console", "json", "junit"])
968
+ .default("console")
969
+ .argParser(parseReporter),
970
+ )
971
+ .option("--timeout <ms>", "Override request timeout", parsePositiveInt("--timeout"))
972
+ .option("--rate-limit <N|auto>", "Throttle requests to at most N per second, or `auto` to adapt from ratelimit-* response headers. Default: adaptive (ARV-64) — no-op until the server publishes RateLimit-* headers, then paces requests automatically. Pass a number for hard caps; `off` is not supported (set --workers 1 + no flag for sequential).", parseRateLimit)
973
+ .option("--bail", "Stop on first suite failure")
974
+ .option("--sequential", "Run regular suites one after another instead of in parallel (opt-out of Promise.all)")
975
+ .option("--all", "TASK-116: discover every apis/<name>/tests/ directory in the workspace and merge them into a single run row (one runs.id per CI invocation, even with multiple registered APIs). Implies CI-style aggregation; pairs with auto-detected commit_sha / branch / trigger=ci.")
976
+ .option("--no-db", "Do not save results to .zond/zond.db")
977
+ .option("--db <path>", "Path to SQLite database file (default: .zond/zond.db)")
978
+ .option("--auth-token <token>", "Auth token injected as {{auth_token}} variable")
979
+ .option("--safe", "Run only GET tests (read-only, safe mode)")
980
+ .option("--tag <tag>", "Filter suites by tag (repeatable, comma-separated)", collect, [])
981
+ .option("--exclude-tag <tag>", "Exclude suites by tag (repeatable, comma-separated)", collect, [])
982
+ .option("--method <method>", "Filter tests by HTTP method (e.g. GET, POST)")
983
+ .option(
984
+ "--include <spec...>",
985
+ "ARV-25: keep only steps matching <selector>:<value> (path|method|tag|operation-id). Same grammar as `zond generate` / `zond checks run`. Repeatable, combines with OR.",
986
+ )
987
+ .option(
988
+ "--exclude <spec...>",
989
+ "ARV-25: drop steps matching <selector>:<value>. Same grammar as --include. Excludes evaluated after includes.",
990
+ )
991
+ .option("--env-var <KEY=VALUE>", "Inject env variable (repeatable, overrides env file)", collect, [])
992
+ .option("--strict-vars", "Hard-fail (exit 2) when a {{var}} reference has no producer (default: warn and continue)")
993
+ .option("--dry-run", "Show requests without sending them (exit code always 0)")
994
+ .option(
995
+ "--quiet",
996
+ "TASK-265: emit only the grand-total summary line — drops per-suite/per-test detail and warning footers. Exit code (0/1) still differentiates pass/fail. For CI logs and watcher loops where step-level output is noise.",
997
+ )
998
+ .option("--output <file>", "ARV-117: write the report to a file instead of stdout. Replaces the legacy --report-out flag. With --report console, falls back to JSON in the file (machine-parseable). Path is resolved relative to cwd; parent directories are auto-created.")
999
+ .option("--validate-schema", "Validate JSON responses against the OpenAPI schema (recommended for CRUD runs — catches contract drift like date-format and enum mismatches; requires --spec or a collection with openapi_spec set)")
1000
+ .option("--spec <path>", "Path or URL to OpenAPI spec used for --validate-schema (overrides the collection's openapi_spec)")
1001
+ .option("--session-id <id>", "Group this run under a session. Resolution order: --session-id flag > ZOND_SESSION_ID env > .zond/current-session file (set by 'zond session start')")
1002
+ .option("--learn", "TASK-282: detect status-code drift (passing-test-but-wrong-status). Implies --validate-schema. Without --learn-apply prints the plan; combine with --learn-apply --learn-target=test|drifts to mutate.")
1003
+ .option("--learn-apply", "Apply the drift plan instead of printing it. Requires --learn and --learn-target.")
1004
+ .addOption(
1005
+ new Option("--learn-target <where>", "What to mutate when --learn-apply is set: rewrite expect.status in YAML (test) or append to apis/<name>/tolerated-drifts.yaml (drifts)")
1006
+ .choices(["test", "drifts"]),
1007
+ )
1008
+ .option(
1009
+ "--retry-on-network <N>",
1010
+ "Auto-retry on transient network errors (ECONNRESET, EPIPE, socket hang up, fetch failed, timeout) — HTTP status codes (incl. 5xx) are NOT retried. Exponential backoff with jitter, base 250ms. Default 1, 0 disables.",
1011
+ parseNonNegativeInt("--retry-on-network"),
1012
+ 1,
1013
+ )
1014
+ .option(
1015
+ "--no-fail-on-failures",
1016
+ "ARV-72: keep exit code 0 even when steps failed (advisory runs). Default: exit 1 on any failure. The stderr tail still names the count for visibility.",
1017
+ )
1018
+ .option(
1019
+ "--max-requests <N>",
1020
+ "ARV-249: hard cap on outgoing HTTP requests across the whole run. Once reached, remaining steps short-circuit to `skip` with reason `max-requests-cap-reached`. Each retry_until attempt counts as one request. Useful for sampling huge probe-suite runs and for CI time-boxing.",
1021
+ parsePositiveInt("--max-requests"),
1022
+ )
1023
+ .action(async (pathArgs: string[] | undefined, opts, cmd: Command) => {
1024
+ let paths = pathArgs ?? [];
1025
+ // ARV-53: explicit paths or --all suppress the current-API fallback —
1026
+ // `run path/to/test.yaml` should never silently pick up `.zond/current-api`.
1027
+ // Otherwise resolve via cli/util/api-context.ts.
1028
+ const apiFlag = (paths.length > 0 || opts.all === true)
1029
+ ? (opts.api as string | undefined)
1030
+ : getApi(cmd, opts);
1031
+ const dbPath = typeof opts.db === "string" ? opts.db : undefined;
1032
+
1033
+ // TASK-116: --all expands to every apis/<name>/tests/ directory in the
1034
+ // workspace, merging them into a single run row.
1035
+ if (opts.all === true) {
1036
+ const discovered = discoverWorkspaceTestPaths();
1037
+ if ("error" in discovered) {
1038
+ printError(discovered.error);
1039
+ process.exitCode = 2;
1040
+ return;
1041
+ }
1042
+ if (discovered.paths.length === 0) {
1043
+ printError("--all found no apis/<name>/tests/ directories in the workspace. Run `zond add api <name>` and `zond generate` first.");
1044
+ process.exitCode = 1;
1045
+ return;
1046
+ }
1047
+ paths = discovered.paths;
1048
+ }
1049
+
1050
+ if (paths.length === 0 && apiFlag) {
1051
+ const resolved = resolveApiCollection(apiFlag, dbPath);
1052
+ if ("error" in resolved) {
1053
+ printError(resolved.error);
1054
+ process.exitCode = resolved.error.startsWith("Failed") ? 2 : 1;
1055
+ return;
1056
+ }
1057
+ if (!resolved.testPath) {
1058
+ printError(`API '${apiFlag}' has no test_path`);
1059
+ process.exitCode = 1;
1060
+ return;
1061
+ }
1062
+ paths = [resolved.testPath];
1063
+ }
1064
+ if (paths.length === 0) {
1065
+ printError("No path given and no current API set; run `zond use <api>`, set ZOND_API, pass --api <name>, or pass path explicitly");
1066
+ process.exitCode = 2;
1067
+ return;
1068
+ }
1069
+
1070
+ const tags = flatSplit(opts.tag);
1071
+ const excludeTags = flatSplit(opts.excludeTag);
1072
+ const envVars = (opts.envVar as string[] | undefined)?.length ? (opts.envVar as string[]) : undefined;
1073
+ const includeSpecs = (opts.include as string[] | undefined)?.length ? (opts.include as string[]) : undefined;
1074
+ const excludeSpecs = (opts.exclude as string[] | undefined)?.length ? (opts.exclude as string[]) : undefined;
1075
+
1076
+ process.exitCode = await runCommand({
1077
+ paths,
1078
+ env: opts.env,
1079
+ report: opts.report as ReporterName,
1080
+ timeout: opts.timeout,
1081
+ rateLimit: opts.rateLimit,
1082
+ bail: opts.bail === true,
1083
+ sequential: opts.sequential === true,
1084
+ noDb: opts.db === false,
1085
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
1086
+ authToken: opts.authToken,
1087
+ safe: opts.safe === true,
1088
+ tag: tags,
1089
+ excludeTag: excludeTags,
1090
+ method: opts.method,
1091
+ include: includeSpecs,
1092
+ exclude: excludeSpecs,
1093
+ envVars,
1094
+ strictVars: opts.strictVars === true,
1095
+ dryRun: opts.dryRun === true,
1096
+ quiet: opts.quiet === true,
1097
+ failOnFailures: opts.failOnFailures !== false,
1098
+ output: typeof opts.output === "string" ? opts.output : undefined,
1099
+ validateSchema: opts.validateSchema === true,
1100
+ specPath: typeof opts.spec === "string" ? opts.spec : undefined,
1101
+ apiName: apiFlag,
1102
+ sessionId: resolveSessionId({
1103
+ flag: typeof opts.sessionId === "string" ? opts.sessionId : null,
1104
+ env: process.env.ZOND_SESSION_ID ?? null,
1105
+ }) ?? undefined,
1106
+ json: false,
1107
+ retryOnNetwork: typeof opts.retryOnNetwork === "number" ? opts.retryOnNetwork : 1,
1108
+ learn: opts.learn === true,
1109
+ learnApply: opts.learnApply === true,
1110
+ learnTarget: opts.learnTarget as "test" | "drifts" | undefined,
1111
+ maxRequests: typeof opts.maxRequests === "number" ? opts.maxRequests : undefined,
1112
+ });
1113
+ });
1114
+ }