@kirrosh/zond 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -1,24 +1,68 @@
1
1
  import { dirname } from "path";
2
2
  import { stat } from "node:fs/promises";
3
- import { parse } from "../../core/parser/yaml-parser.ts";
3
+ import { parseSafe } from "../../core/parser/yaml-parser.ts";
4
4
  import { loadEnvironment, loadEnvMeta, loadEnvFile } from "../../core/parser/variables.ts";
5
- import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
6
- import { runSuite } from "../../core/runner/executor.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";
7
16
  import { createRateLimiter, createAdaptiveRateLimiter } from "../../core/runner/rate-limiter.ts";
8
17
  import { getReporter, generateJsonReport, generateJunitXml } from "../../core/reporter/index.ts";
9
18
  import type { ReporterName } from "../../core/reporter/types.ts";
19
+ import { resolveOutput, OutputSpecError, type OutputSpec } from "../../core/output/index.ts";
10
20
  import { writeFile, mkdir } from "node:fs/promises";
11
- import { dirname as pathDirname, isAbsolute, resolve as pathResolve } from "node:path";
21
+ import { dirname as pathDirname, resolve as pathResolve } from "node:path";
12
22
  import type { TestSuite } from "../../core/parser/types.ts";
13
23
  import type { TestRunResult } from "../../core/runner/types.ts";
14
24
  import { printError, printWarning } from "../output.ts";
15
25
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
16
26
  import { getDb } from "../../db/schema.ts";
17
27
  import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
18
- 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
+ };
19
57
 
20
58
  export interface RunOptions {
21
- 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[];
22
66
  env?: string;
23
67
  report: ReporterName;
24
68
  timeout?: number;
@@ -33,34 +77,159 @@ export interface RunOptions {
33
77
  tag?: string[];
34
78
  excludeTag?: string[];
35
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[];
36
85
  envVars?: string[];
86
+ /** Hard-fail (exit 2) on undefined {{var}} references instead of warning. */
87
+ strictVars?: boolean;
37
88
  dryRun?: boolean;
38
89
  json?: boolean;
39
- /** Write the report to a file instead of stdout. */
40
- reportOut?: string;
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;
41
146
  }
42
147
 
43
148
  export async function runCommand(options: RunOptions): Promise<number> {
44
- // 1. Parse test files
45
- let suites: TestSuite[];
46
- try {
47
- suites = await parse(options.path);
48
- } catch (err) {
49
- 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`);
50
156
  return 2;
51
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
+ }
52
184
 
53
185
  if (suites.length === 0) {
54
- 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}`);
55
192
  return 0;
56
193
  }
57
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
+
58
217
  // 1b. Tag filter
59
218
  if (options.tag && options.tag.length > 0) {
219
+ const availableTags = collectAvailableTags(suites);
60
220
  suites = filterSuitesByTags(suites, options.tag);
61
221
  if (suites.length === 0) {
62
- printWarning("No suites match the specified tags");
63
- 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;
64
233
  }
65
234
  }
66
235
 
@@ -68,8 +237,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
68
237
  if (options.excludeTag && options.excludeTag.length > 0) {
69
238
  suites = excludeSuitesByTags(suites, options.excludeTag);
70
239
  if (suites.length === 0) {
71
- printWarning("All suites excluded by --exclude-tag");
72
- return 0;
240
+ printError(`All suites excluded by --exclude-tag [${options.excludeTag.join(", ")}]`);
241
+ return 1;
73
242
  }
74
243
  }
75
244
 
@@ -77,8 +246,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
77
246
  if (options.method) {
78
247
  suites = filterSuitesByMethod(suites, options.method);
79
248
  if (suites.length === 0) {
80
- printWarning(`No tests found with method ${options.method.toUpperCase()}`);
81
- return 0;
249
+ printError(`No tests found with method ${options.method.toUpperCase()}`);
250
+ return 1;
82
251
  }
83
252
  }
84
253
 
@@ -100,13 +269,13 @@ export async function runCommand(options: RunOptions): Promise<number> {
100
269
 
101
270
  // 2. Load environment (resolve collection for scoped envs)
102
271
  // Use path itself as searchDir if it's a directory; dirname() on a dir path gives the parent
103
- const pathStat = await stat(options.path).catch(() => null);
104
- 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);
105
274
  let collectionForEnv: { id: number } | null = null;
106
275
  if (!options.noDb) {
107
276
  try {
108
277
  getDb(options.dbPath);
109
- collectionForEnv = findCollectionByTestPath(options.path);
278
+ collectionForEnv = findCollectionByTestPath(primaryPath);
110
279
  } catch { /* DB not available — OK */ }
111
280
  }
112
281
 
@@ -142,6 +311,10 @@ export async function runCommand(options: RunOptions): Promise<number> {
142
311
  }
143
312
  }
144
313
 
314
+ if (options.sessionId && !options.json) {
315
+ process.stderr.write(`zond: session ${options.sessionId} (run will be grouped)\n`);
316
+ }
317
+
145
318
  // Inject CLI auth token — overrides env file value
146
319
  if (options.authToken) {
147
320
  env.auth_token = options.authToken;
@@ -169,18 +342,166 @@ export async function runCommand(options: RunOptions): Promise<number> {
169
342
  }
170
343
  }
171
344
 
172
- // 3b. Resolve rate limit: CLI flag > .env.yaml `rateLimit:` field
173
- let rateLimit: number | "auto" | undefined = options.rateLimit;
174
- if (rateLimit === undefined) {
175
- try {
176
- const envMeta = await loadEnvMeta(options.env, searchDir);
177
- rateLimit = envMeta.rateLimit;
178
- } catch { /* meta load failure is non-fatal */ }
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();
179
366
  }
180
- const rateLimiter = rateLimit === "auto"
181
- ? createAdaptiveRateLimiter()
182
- : createRateLimiter(rateLimit);
183
- const runOpts = { rateLimiter };
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
+ };
184
505
 
185
506
  // 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
186
507
  const results: TestRunResult[] = [];
@@ -190,6 +511,17 @@ export async function runCommand(options: RunOptions): Promise<number> {
190
511
  const regularSuites = suites.filter(s => !s.setup);
191
512
  const setupCaptures: Record<string, string> = {};
192
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
+
193
525
  for (const suite of setupSuites) {
194
526
  const result = await runSuite(suite, env, dryRun, runOpts);
195
527
  results.push(result);
@@ -202,6 +534,17 @@ export async function runCommand(options: RunOptions): Promise<number> {
202
534
 
203
535
  const enrichedEnv = { ...env, ...setupCaptures };
204
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
+
205
548
  if (options.bail) {
206
549
  // Sequential with bail at suite level
207
550
  for (const suite of regularSuites) {
@@ -223,6 +566,17 @@ export async function runCommand(options: RunOptions): Promise<number> {
223
566
  results.push(...all);
224
567
  }
225
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
+
226
580
  // 5. Collect warnings
227
581
  const warnings: string[] = [];
228
582
  const rateLimited = results.flatMap(r => r.steps)
@@ -230,26 +584,44 @@ export async function runCommand(options: RunOptions): Promise<number> {
230
584
  if (rateLimited.length > 0) {
231
585
  warnings.push(`${rateLimited.length} request(s) hit rate limit (429). Consider: consolidating login steps, adding --bail, or using retry_until with delay.`);
232
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
+ }
233
595
 
234
- // 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
+ }
235
614
  if (!options.json) {
236
- if (options.reportOut) {
237
- // Write report to a file via fs (bypass stdout). Console reporter falls
238
- // back to a single-line summary on stdout; json/junit produce no stdout.
239
- const outPath = isAbsolute(options.reportOut)
240
- ? options.reportOut
241
- : pathResolve(process.cwd(), options.reportOut);
615
+ if (resolvedOutput.channel === "file") {
616
+ const outPath = resolvedOutput.path!;
242
617
  let content: string;
243
618
  let label: string;
244
- switch (options.report) {
245
- case "json":
246
- content = generateJsonReport(results);
247
- label = "JSON";
248
- break;
619
+ switch (resolvedOutput.format) {
249
620
  case "junit":
250
621
  content = generateJunitXml(results);
251
622
  label = "JUnit XML";
252
623
  break;
624
+ case "json":
253
625
  default: // "console" — fall back to JSON in the file (most useful)
254
626
  content = generateJsonReport(results);
255
627
  label = "JSON";
@@ -260,7 +632,7 @@ export async function runCommand(options: RunOptions): Promise<number> {
260
632
  await writeFile(outPath, content, "utf-8");
261
633
  process.stderr.write(`zond: ${label} report written to ${outPath}\n`);
262
634
  } catch (err) {
263
- printError(`Failed to write --report-out file ${outPath}: ${(err as Error).message}`);
635
+ printError(`Failed to write --output file ${outPath}: ${(err as Error).message}`);
264
636
  return 2;
265
637
  }
266
638
  for (const w of warnings) {
@@ -268,23 +640,100 @@ export async function runCommand(options: RunOptions): Promise<number> {
268
640
  }
269
641
  } else {
270
642
  const reporter = getReporter(options.report);
271
- reporter.report(results);
272
- for (const w of warnings) {
273
- printWarning(w);
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
+ }
274
652
  }
275
653
  }
276
654
  }
277
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`);
700
+ }
701
+ }
702
+
278
703
  // 6. Save to DB
279
704
  let savedRunId: number | undefined;
280
705
  if (!options.noDb) {
281
706
  try {
282
707
  getDb(options.dbPath);
283
- 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));
284
727
  savedRunId = createRun({
285
728
  started_at: results[0]?.started_at ?? new Date().toISOString(),
286
729
  environment: options.env,
287
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,
288
737
  });
289
738
  finalizeRun(savedRunId, results);
290
739
  saveResults(savedRunId, results);
@@ -302,6 +751,23 @@ export async function runCommand(options: RunOptions): Promise<number> {
302
751
  }
303
752
  const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
304
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
+
305
771
  if (options.json) {
306
772
  const total = results.reduce((s, r) => s + r.total, 0);
307
773
  const passed = results.reduce((s, r) => s + r.passed, 0);
@@ -315,11 +781,334 @@ export async function runCommand(options: RunOptions): Promise<number> {
315
781
  ...(typeof s.response?.status === "number" ? { http_status: s.response.status } : {}),
316
782
  ...(typeof s.response?.status === "number" && s.response.status >= 500 && s.response.status < 600 ? { is_5xx: true } : {}),
317
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 } : {}),
318
788
  }))
319
789
  );
320
790
  const fiveXx = failures.filter(f => f.is_5xx).length;
321
- printJson(jsonOk("run", { summary: { total, passed, failed, fiveXx }, failures, warnings, runId: savedRunId }));
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
+ }));
322
798
  }
323
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`);
835
+ }
836
+
837
+ if (hasFailures && options.failOnFailures === false) {
838
+ return 0;
839
+ }
324
840
  return hasFailures ? 1 : 0;
325
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
+ }