@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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `${VAR}` / `${VAR:-default}` substitution for `.env.yaml` (TASK-169, m-10).
3
+ *
4
+ * Lets a workspace commit `.env.yaml` without bare secrets:
5
+ *
6
+ * auth_token: "${MYAPI_AUTH_TOKEN}"
7
+ * base_url: "${MYAPI_BASE_URL:-https://api.example.com}"
8
+ *
9
+ * Rules:
10
+ * - `${VAR}` → process.env.VAR (throws if missing).
11
+ * - `${VAR:-default}` → process.env.VAR ?? default. The default may
12
+ * contain `:` (everything after `:-` up to the closing `}` is the
13
+ * default).
14
+ * - `\${LITERAL}` → literal `${LITERAL}` (the backslash is
15
+ * stripped). Matches the `dotenv-expand` / docker-compose convention.
16
+ * - One level of resolution only — values pulled from env are NOT
17
+ * re-scanned for further `${...}` (cycle-risk).
18
+ * - Variable names matching /TOKEN|SECRET|PASSWORD|KEY|DSN/i are NOT
19
+ * auto-registered with the redaction registry. Auto-registration is
20
+ * opt-in via `@secret:` (TASK-170). We do print a one-line warning
21
+ * suggesting the user mark them as a secret, but only the first time.
22
+ */
23
+
24
+ const ENV_REF_RE = /(\\?)\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
25
+ const SUSPICIOUS_NAME_RE = /TOKEN|SECRET|PASSWORD|API_KEY|^KEY$|_KEY$|DSN/i;
26
+
27
+ export interface EnvInterpolationContext {
28
+ /** Absolute path of the file we're resolving — used for error messages. */
29
+ filePath: string;
30
+ /** YAML key whose value contains the reference — used for error messages. */
31
+ key: string;
32
+ /** Source of variables; defaults to `process.env`. Override in tests. */
33
+ env?: Record<string, string | undefined>;
34
+ /** Sink for human-facing warnings. Default: stderr write. */
35
+ warn?: (msg: string) => void;
36
+ }
37
+
38
+ /** Module-level set of variable names we've already warned about, so the
39
+ * same `${MYAPI_AUTH_TOKEN}` reference doesn't yell once per env file. */
40
+ const warned = new Set<string>();
41
+
42
+ export function _resetEnvInterpolationWarnings(): void {
43
+ warned.clear();
44
+ }
45
+
46
+ /**
47
+ * Substitute every `${VAR}` / `${VAR:-default}` in `text`. Returns the
48
+ * fully-resolved string. Throws `Error` when an unresolved reference has
49
+ * no default.
50
+ */
51
+ export function interpolateEnvRefs(text: string, ctx: EnvInterpolationContext): string {
52
+ if (typeof text !== "string" || text.length === 0) return text;
53
+ if (text.indexOf("${") === -1 && text.indexOf("\\$") === -1) return text;
54
+ const env = ctx.env ?? (process.env as Record<string, string | undefined>);
55
+ const warn = ctx.warn ?? ((m: string) => process.stderr.write(m + "\n"));
56
+
57
+ return text.replace(ENV_REF_RE, (full, escape: string, name: string, def: string | undefined) => {
58
+ if (escape === "\\") {
59
+ // Escaped reference → strip the backslash, keep the literal.
60
+ return full.slice(1);
61
+ }
62
+ const value = env[name];
63
+ if (value === undefined || value === "") {
64
+ if (def !== undefined) {
65
+ return def;
66
+ }
67
+ throw new Error(
68
+ `Environment variable \${${name}} is not set (referenced from "${ctx.filePath}", key "${ctx.key}"). ` +
69
+ `Provide it via your shell, CI secret, or use the \${${name}:-<default>} form to give it a fallback.`,
70
+ );
71
+ }
72
+ if (SUSPICIOUS_NAME_RE.test(name) && !warned.has(name)) {
73
+ warned.add(name);
74
+ warn(
75
+ `[zond] ${ctx.filePath}: variable \${${name}} looks like a secret. ` +
76
+ `Consider mapping it through @secret:${ctx.key} (TASK-170) so it is redacted in artifacts.`,
77
+ );
78
+ }
79
+ return value;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Apply interpolation to every string value in a flat env object. Other
85
+ * value types (numbers, booleans, nulls coming back from YAML) are
86
+ * stringified untouched, matching the existing loader behaviour.
87
+ */
88
+ export function interpolateEnvObject(
89
+ obj: Record<string, unknown>,
90
+ filePath: string,
91
+ env?: Record<string, string | undefined>,
92
+ ): Record<string, string> {
93
+ const out: Record<string, string> = {};
94
+ for (const [k, v] of Object.entries(obj)) {
95
+ if (typeof v === "string") {
96
+ out[k] = interpolateEnvRefs(v, { filePath, key: k, env });
97
+ } else {
98
+ out[k] = String(v);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const SUSPICIOUS_ENV_NAME_RE = SUSPICIOUS_NAME_RE;
@@ -1,4 +1,6 @@
1
1
  import type { TestSuite } from "./types.ts";
2
+ import { compileOperationFilter } from "../selectors/operation-filter.ts";
3
+ import type { EndpointInfo } from "../generator/types.ts";
2
4
 
3
5
  /**
4
6
  * Filter suites by tags (OR logic, case-insensitive).
@@ -38,3 +40,58 @@ export function filterSuitesByMethod(suites: TestSuite[], method: string): TestS
38
40
  }));
39
41
  return filtered.filter(s => s.tests.length > 0);
40
42
  }
43
+
44
+ export interface SuiteFilterResult {
45
+ suites: TestSuite[];
46
+ errors: string[];
47
+ }
48
+
49
+ /**
50
+ * ARV-25: parity with `zond generate`/`zond checks run` — apply the unified
51
+ * `--include`/`--exclude` selector grammar (path/method/tag/operation-id)
52
+ * to a list of test suites. Step-level selectors (path, method, operation-id)
53
+ * filter steps within a suite; suite-level selectors (tag) borrow each
54
+ * step's parent suite tags. A suite drops out once it has no steps left.
55
+ *
56
+ * Reuses `compileOperationFilter` so semantics match generate/checks 1:1
57
+ * (multiple --include combine with OR; --exclude evaluated after includes).
58
+ *
59
+ * `operation-id` matches against `step.source?.endpoint` ("METHOD /path"),
60
+ * which is what the generator records; tests authored manually without
61
+ * `source.endpoint` simply never match operation-id selectors.
62
+ */
63
+ export function filterSuitesByOperationFilter(
64
+ suites: TestSuite[],
65
+ includes: string[],
66
+ excludes: string[],
67
+ ): SuiteFilterResult {
68
+ if (includes.length === 0 && excludes.length === 0) {
69
+ return { suites, errors: [] };
70
+ }
71
+ const compiled = compileOperationFilter({ includes, excludes });
72
+ if (compiled.errors.length > 0) {
73
+ return { suites: [], errors: compiled.errors };
74
+ }
75
+ const filtered = suites.map(suite => ({
76
+ ...suite,
77
+ tests: suite.tests.filter(step => compiled.filter(stepToEndpoint(suite, step))),
78
+ }));
79
+ return { suites: filtered.filter(s => s.tests.length > 0), errors: [] };
80
+ }
81
+
82
+ function stepToEndpoint(suite: TestSuite, step: TestSuite["tests"][number]): EndpointInfo {
83
+ const sourceEndpoint = typeof step.source?.endpoint === "string" ? step.source.endpoint : undefined;
84
+ return {
85
+ path: step.path,
86
+ method: step.method,
87
+ operationId: sourceEndpoint,
88
+ summary: undefined,
89
+ tags: suite.tags ?? [],
90
+ parameters: [],
91
+ requestBodySchema: undefined,
92
+ requestBodyContentType: undefined,
93
+ responseContentTypes: [],
94
+ responses: [],
95
+ security: [],
96
+ };
97
+ }
@@ -1,7 +1,11 @@
1
1
  import { z } from "zod";
2
- import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach, MultipartField } from "./types.ts";
2
+ import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach, MultipartField, SourceMetadata } from "./types.ts";
3
3
 
4
- const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
4
+ // ARV-223 (R16/F28): include OPTIONS / HEAD / TRACE so probe-method generated
5
+ // suites (which emit one step per missing-method per path) parse and run.
6
+ // Without this, `zond probe static --emit-tests → zond run` breaks end-to-end
7
+ // on every API where these methods aren't already declared (= almost always).
8
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"] as const;
5
9
 
6
10
  function extractMethodAndPath(raw: unknown): unknown {
7
11
  if (typeof raw !== "object" || raw === null) return raw;
@@ -92,7 +96,7 @@ const AssertionRuleSchemaInner: z.ZodType<AssertionRule> = z.preprocess(
92
96
  },
93
97
  z.object({
94
98
  capture: z.string().optional(),
95
- type: z.enum(["string", "integer", "number", "boolean", "array", "object"]).optional(),
99
+ type: z.enum(["string", "integer", "number", "boolean", "array", "object", "null"]).optional(),
96
100
  equals: z.unknown().optional(),
97
101
  not_equals: z.unknown().optional(),
98
102
  contains: z.string().optional(),
@@ -120,6 +124,44 @@ const TestStepExpectSchema: z.ZodType<TestStepExpect> = z.preprocess(
120
124
  (val) => {
121
125
  if (typeof val !== "object" || val === null) return val;
122
126
  const obj = val as Record<string, unknown>;
127
+ // Reject `expect.capture: {...}` — non-canonical syntax some users
128
+ // reach for. zond captures live INSIDE body-rules
129
+ // (`body: { "path.to.field": { capture: var_name } }`); a top-level
130
+ // `capture:` block inside `expect:` is silently dropped, leaving the
131
+ // test green with no captured values. Throw with a clear pointer.
132
+ // (TASK-247)
133
+ if ("capture" in obj && typeof obj.capture === "object" && obj.capture !== null && !Array.isArray(obj.capture)) {
134
+ throw new Error(
135
+ `'expect.capture: {...}' is not a valid step shape. Captures are defined per-field: ` +
136
+ `\`expect.body: { "<path>": { capture: <var_name> } }\`. ` +
137
+ `Top-level 'capture' inside 'expect' is silently ignored — the test would pass with no captured values.`,
138
+ );
139
+ }
140
+ // expect.status: spot-message for common wrong shapes.
141
+ // Schema accepts `number | number[]`. Users reaching from other tools often
142
+ // write `oneOf: [...]`, `any: [...]`, a string `"200"`, or an array
143
+ // containing strings. The raw zod-issue path (`tests.N.expect.status.0`)
144
+ // is hard to read — surface a single-line hint here. (TASK-249, feedback-13#F1)
145
+ if ("status" in obj && obj.status !== undefined && obj.status !== null) {
146
+ const s = obj.status;
147
+ const STATUS_HINT =
148
+ "expect.status: use a number (200), an array of numbers ([200, 404]), or omit. " +
149
+ "oneOf/any/anyOf are not supported.";
150
+ if (typeof s === "object" && !Array.isArray(s)) {
151
+ const keys = Object.keys(s as Record<string, unknown>);
152
+ const wrong = keys.find((k) => ["oneOf", "anyOf", "any", "in", "one_of"].includes(k));
153
+ if (wrong) {
154
+ throw new Error(`'expect.status' got '${wrong}: [...]' — ${STATUS_HINT}`);
155
+ }
156
+ throw new Error(`'expect.status' got an object — ${STATUS_HINT}`);
157
+ }
158
+ if (typeof s === "string") {
159
+ throw new Error(`'expect.status' got string "${s}" — ${STATUS_HINT}`);
160
+ }
161
+ if (Array.isArray(s) && s.some((v) => typeof v !== "number")) {
162
+ throw new Error(`'expect.status' array must contain only numbers — ${STATUS_HINT}`);
163
+ }
164
+ }
123
165
  // body: null → remove it
124
166
  if (obj.body === null) {
125
167
  const { body: _, ...rest } = obj;
@@ -158,12 +200,57 @@ const MultipartFileFieldSchema = z.object({
158
200
 
159
201
  const MultipartFieldSchema: z.ZodType<MultipartField> = z.union([z.string(), MultipartFileFieldSchema]);
160
202
 
203
+ // Provenance metadata: passthrough — все поля optional, неизвестные пропускаем без warning
204
+ const SourceMetadataSchema: z.ZodType<SourceMetadata> = z.object({
205
+ type: z.enum(["openapi-generated", "manual", "probe-suite"]).optional(),
206
+ spec: z.string().optional(),
207
+ generator: z.string().optional(),
208
+ generated_at: z.string().optional(),
209
+ endpoint: z.string().optional(),
210
+ response_branch: z.string().optional(),
211
+ schema_pointer: z.string().optional(),
212
+ }).passthrough() as z.ZodType<SourceMetadata>;
213
+
214
+ const KNOWN_STEP_KEYS = new Set([
215
+ "name", "source", "method", "path", "headers",
216
+ "json", "form", "multipart", "query", "expect",
217
+ "skip_if", "retry_until", "for_each", "set", "always",
218
+ // raw HTTP method keys are folded into method/path by extractMethodAndPath
219
+ ...HTTP_METHODS,
220
+ ]);
221
+
222
+ // Common typo / wrong-name body keys we detect explicitly to emit an
223
+ // actionable error instead of silently dropping. Real APIs reject the empty
224
+ // POST that follows, but the user spends 10+ minutes debugging — this hint
225
+ // turns it into a one-line fix. (TASK-244)
226
+ const BODY_KEY_HINTS: Record<string, string> = {
227
+ body: "json (for application/json), form (urlencoded), or multipart (file upload)",
228
+ data: "json (for application/json) or form (urlencoded)",
229
+ payload: "json",
230
+ // TASK-257: previous hint pointed only at `form:` which is x-www-form-urlencoded
231
+ // and useless for file uploads. Surface `multipart:` explicitly so users with
232
+ // file-upload endpoints (file-upload endpoints, etc.) find it.
233
+ raw: "json for raw JSON, multipart: { field: { file: <path> } } for file upload, or form for urlencoded — raw bodies are not parsed",
234
+ };
235
+
161
236
  const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
162
237
  (raw) => {
163
238
  const obj = extractMethodAndPath(raw);
164
- // Make expect optional for set-only steps
165
239
  if (typeof obj === "object" && obj !== null) {
166
240
  const o = obj as Record<string, unknown>;
241
+
242
+ // Reject silently-dropped body-shaped keys with a clear suggestion.
243
+ for (const [bad, hint] of Object.entries(BODY_KEY_HINTS)) {
244
+ if (bad in o) {
245
+ const stepName = typeof o.name === "string" ? ` in step "${o.name}"` : "";
246
+ throw new Error(
247
+ `Unknown step key '${bad}'${stepName}. Did you mean '${hint}'? ` +
248
+ `(zond does not recognize '${bad}:' and would silently drop the body)`,
249
+ );
250
+ }
251
+ }
252
+
253
+ // Make expect optional for set-only steps
167
254
  if (o.set && !o.expect) {
168
255
  o.expect = {};
169
256
  }
@@ -172,6 +259,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
172
259
  },
173
260
  z.object({
174
261
  name: z.string(),
262
+ source: SourceMetadataSchema.optional(),
175
263
  method: z.enum(HTTP_METHODS),
176
264
  path: z.string(),
177
265
  headers: z.record(z.string(), z.string()).optional(),
@@ -184,6 +272,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
184
272
  retry_until: RetryUntilSchema.optional(),
185
273
  for_each: ForEachSchema.optional(),
186
274
  set: z.record(z.string(), z.unknown()).optional(),
275
+ always: z.boolean().optional(),
187
276
  }),
188
277
  ) as z.ZodType<TestStep>;
189
278
 
@@ -218,8 +307,10 @@ const TestSuiteSchema = z.preprocess(
218
307
  description: z.string().optional(),
219
308
  setup: z.boolean().optional(),
220
309
  tags: z.array(z.string()).optional(),
310
+ source: SourceMetadataSchema.optional(),
221
311
  base_url: z.string().optional(),
222
312
  headers: z.record(z.string(), z.string()).optional(),
313
+ parameterize: z.record(z.string(), z.array(z.unknown()).min(1)).optional(),
223
314
  config: SuiteConfigSchema,
224
315
  tests: z.array(TestStepSchema).min(1),
225
316
  }),
@@ -229,4 +320,40 @@ export function validateSuite(raw: unknown): TestSuite {
229
320
  return TestSuiteSchema.parse(raw) as TestSuite;
230
321
  }
231
322
 
232
- export { TestSuiteSchema, TestStepSchema, AssertionRuleSchema, ASSERTION_KEYS };
323
+ /** Render a zod path array (`["tests", 0, "expect", "status"]`) as
324
+ * `tests[0].expect.status`. Numeric segments become bracket-indices, string
325
+ * segments dot-join. */
326
+ function pathToHuman(path: ReadonlyArray<string | number>): string {
327
+ let out = "";
328
+ for (const seg of path) {
329
+ if (typeof seg === "number") out += `[${seg}]`;
330
+ else out += out ? `.${seg}` : seg;
331
+ }
332
+ return out || "(root)";
333
+ }
334
+
335
+ /**
336
+ * Format a {@link z.ZodError} as a compact, multi-line, human-readable list:
337
+ *
338
+ * N validation issue(s):
339
+ * <path>: <message>
340
+ * ...
341
+ *
342
+ * The default `ZodError.message` is a JSON dump of the full issue list with
343
+ * internal field names (`_def`, deeply numeric paths, "Invalid input" prefix).
344
+ * The wrapper that callers used to surface ("Validation error in <file>:
345
+ * [{...}]") was unreadable for tester users — they had to mentally parse the
346
+ * stack to find the real path. (TASK-249)
347
+ */
348
+ export function formatZodError(err: z.ZodError): string {
349
+ const lines = err.issues.map((i) => {
350
+ const path = pathToHuman(i.path as ReadonlyArray<string | number>);
351
+ // zod v4 messages are already readable; strip the redundant "Invalid input: "
352
+ // prefix that adds noise without info.
353
+ const msg = i.message.replace(/^Invalid input:\s*/, "");
354
+ return ` ${path}: ${msg}`;
355
+ });
356
+ const header = `${err.issues.length} validation issue${err.issues.length === 1 ? "" : "s"}:`;
357
+ return `${header}\n${lines.join("\n")}`;
358
+ }
359
+
@@ -1,8 +1,8 @@
1
- export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
1
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD" | "TRACE";
2
2
 
3
3
  export interface AssertionRule {
4
4
  capture?: string;
5
- type?: "string" | "integer" | "number" | "boolean" | "array" | "object";
5
+ type?: "string" | "integer" | "number" | "boolean" | "array" | "object" | "null";
6
6
  equals?: unknown;
7
7
  not_equals?: unknown;
8
8
  contains?: string;
@@ -41,6 +41,22 @@ export interface ForEach {
41
41
  in: unknown;
42
42
  }
43
43
 
44
+ /**
45
+ * Provenance metadata: «откуда этот test/suite». Optional, не участвует в
46
+ * matching/dedup/validation. Suite-level задаёт общие поля; step-level
47
+ * наследует через shallow merge `{ ...suite.source, ...step.source }`.
48
+ */
49
+ export interface SourceMetadata {
50
+ type?: "openapi-generated" | "manual" | "probe-suite";
51
+ spec?: string;
52
+ generator?: string;
53
+ generated_at?: string;
54
+ endpoint?: string;
55
+ response_branch?: string;
56
+ schema_pointer?: string;
57
+ [key: string]: unknown;
58
+ }
59
+
44
60
  export interface MultipartFileField {
45
61
  file: string;
46
62
  filename?: string;
@@ -51,6 +67,7 @@ export type MultipartField = string | MultipartFileField;
51
67
 
52
68
  export interface TestStep {
53
69
  name: string;
70
+ source?: SourceMetadata;
54
71
  method: HttpMethod;
55
72
  path: string;
56
73
  headers?: Record<string, string>;
@@ -63,6 +80,12 @@ export interface TestStep {
63
80
  retry_until?: RetryUntil;
64
81
  for_each?: ForEach;
65
82
  set?: Record<string, unknown>;
83
+ /**
84
+ * Run this step even when prior steps in the suite have failed assertions
85
+ * (so their captures are "tainted"). Designed for cleanup steps. Still
86
+ * skips if a referenced capture is genuinely missing from a response.
87
+ */
88
+ always?: boolean;
66
89
  }
67
90
 
68
91
  export interface SuiteConfig {
@@ -79,8 +102,12 @@ export interface TestSuite {
79
102
  /** If true, this suite runs before all regular suites and its captures are shared into their env */
80
103
  setup?: boolean;
81
104
  tags?: string[];
105
+ source?: SourceMetadata;
82
106
  base_url?: string;
83
107
  headers?: Record<string, string>;
108
+ /** Cross-product parameterisation: each key contributes one variable
109
+ * binding per array entry. Suite body runs once per combination. */
110
+ parameterize?: Record<string, unknown[]>;
84
111
  config: SuiteConfig;
85
112
  tests: TestStep[];
86
113
  /** Absolute path to the source file, set by yaml-parser */
Binary file
@@ -1,9 +1,55 @@
1
1
  import { Glob } from "bun";
2
2
  import { resolve } from "node:path";
3
- import { validateSuite } from "./schema.ts";
3
+ import YAML from "yaml";
4
+ import { z } from "zod";
5
+ import { validateSuite, formatZodError } from "./schema.ts";
4
6
  import type { TestSuite } from "./types.ts";
5
7
 
6
- export async function parseFile(filePath: string): Promise<TestSuite> {
8
+ export interface ParseOptions {
9
+ /** Surface raw `ZodError.message` (the JSON-formatted issue stack) instead
10
+ * of the human-friendly summary. Useful for filing zod bugs / debugging the
11
+ * schema itself; default output is human-readable. (TASK-249) */
12
+ verbose?: boolean;
13
+ }
14
+
15
+ /** Convert a 0-based byte offset into a 1-based (line, col) position. */
16
+ function offsetToLineCol(text: string, offset: number): { line: number; col: number } {
17
+ let line = 1;
18
+ let col = 1;
19
+ for (let i = 0; i < offset && i < text.length; i++) {
20
+ if (text.charCodeAt(i) === 0x0a) {
21
+ line++;
22
+ col = 1;
23
+ } else {
24
+ col++;
25
+ }
26
+ }
27
+ return { line, col };
28
+ }
29
+
30
+ /**
31
+ * Format a YAML parse error as `file:line:col: <reason>` plus a snippet with
32
+ * a column pointer. Bun.YAML's SyntaxError exposes JS stack coordinates, not
33
+ * YAML positions, so on parse failure we re-parse with eemeli/yaml (which
34
+ * provides accurate `linePos`) just for diagnostics.
35
+ *
36
+ * Exported for tests.
37
+ */
38
+ export function formatYamlParseError(filePath: string, text: string, primary: Error): Error {
39
+ const doc = YAML.parseDocument(text);
40
+ const e = doc.errors[0];
41
+ if (e?.linePos?.[0]) {
42
+ const { line, col } = e.linePos[0];
43
+ // eemeli's message reads "<reason> at line X, column Y:\n\n<snippet>".
44
+ // Strip the "at line ..." part since we surface line:col in the prefix.
45
+ const cleaned = e.message.replace(/\s+at line \d+, column \d+:/, ":");
46
+ return new Error(`Invalid YAML in ${filePath}:${line}:${col}: ${cleaned}`);
47
+ }
48
+ // eemeli accepted but Bun rejected — fall back to original message.
49
+ return new Error(`Invalid YAML in ${filePath}: ${primary.message}`);
50
+ }
51
+
52
+ export async function parseFile(filePath: string, opts: ParseOptions = {}): Promise<TestSuite> {
7
53
  let text: string;
8
54
  try {
9
55
  text = await Bun.file(filePath).text();
@@ -11,11 +57,22 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
11
57
  throw new Error(`Failed to read file ${filePath}: ${(err as Error).message}`);
12
58
  }
13
59
 
60
+ // Both Bun.YAML and eemeli/yaml accept NUL bytes silently, but they corrupt
61
+ // downstream consumers (sqlite TEXT, JSON, terminals). Surface explicitly.
62
+ const nulIdx = text.indexOf("\x00");
63
+ if (nulIdx >= 0) {
64
+ const { line, col } = offsetToLineCol(text, nulIdx);
65
+ throw new Error(
66
+ `Invalid YAML in ${filePath}:${line}:${col}: NUL byte (\\x00) in source — ` +
67
+ `if you need a NUL in a request body, use the {{$nullByte}} generator instead of inlining the byte`
68
+ );
69
+ }
70
+
14
71
  let raw: unknown;
15
72
  try {
16
73
  raw = Bun.YAML.parse(text);
17
74
  } catch (err) {
18
- throw new Error(`Invalid YAML in ${filePath}: ${(err as Error).message}`);
75
+ throw formatYamlParseError(filePath, text, err as Error);
19
76
  }
20
77
 
21
78
  try {
@@ -23,22 +80,40 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
23
80
  suite.filePath = resolve(filePath);
24
81
  return suite;
25
82
  } catch (err) {
83
+ if (err instanceof z.ZodError && !opts.verbose) {
84
+ throw new Error(`Validation error in ${filePath}:\n${formatZodError(err)}`);
85
+ }
26
86
  throw new Error(`Validation error in ${filePath}: ${(err as Error).message}`);
27
87
  }
28
88
  }
29
89
 
30
- export async function parseDirectory(dirPath: string): Promise<TestSuite[]> {
90
+ /**
91
+ * Files that live alongside test suites but aren't suites themselves. The
92
+ * yaml-parser scans recursively from the workspace root, so picking these up
93
+ * would surface spurious "Validation error: missing field name" noise.
94
+ */
95
+ function isNonSuiteYaml(file: string): boolean {
96
+ if (file.match(/\.env(\..+)?\.ya?ml$/)) return true;
97
+ // Workspace marker — present at the root of every zond workspace.
98
+ if (file === "zond.config.yml" || file === "zond.config.yaml") return true;
99
+ // Per-API artifact files written by `zond add api` / `zond refresh-api`.
100
+ // Match the basename so it works for files at any depth (apis/<name>/...).
101
+ const basename = file.split("/").pop() ?? file;
102
+ if (/^\.api-[a-z0-9-]+\.ya?ml$/i.test(basename)) return true;
103
+ return false;
104
+ }
105
+
106
+ export async function parseDirectory(dirPath: string, opts: ParseOptions = {}): Promise<TestSuite[]> {
31
107
  const glob = new Glob("**/*.{yaml,yml}");
32
108
  const suites: TestSuite[] = [];
33
109
 
34
110
  for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
35
- // Skip environment files
36
- if (file.match(/\.env(\..+)?\.yaml$/) || file.match(/\.env(\..+)?\.yml$/)) {
111
+ if (isNonSuiteYaml(file)) {
37
112
  continue;
38
113
  }
39
114
  const fullPath = `${dirPath}/${file}`;
40
115
  try {
41
- suites.push(await parseFile(fullPath));
116
+ suites.push(await parseFile(fullPath, opts));
42
117
  } catch {
43
118
  // Skip files that fail to parse (e.g. invalid AI-generated YAML)
44
119
  // so one bad file doesn't block the entire directory
@@ -53,18 +128,18 @@ export interface ParseDirectoryResult {
53
128
  errors: { file: string; error: string }[];
54
129
  }
55
130
 
56
- export async function parseDirectorySafe(dirPath: string): Promise<ParseDirectoryResult> {
131
+ export async function parseDirectorySafe(dirPath: string, opts: ParseOptions = {}): Promise<ParseDirectoryResult> {
57
132
  const glob = new Glob("**/*.{yaml,yml}");
58
133
  const suites: TestSuite[] = [];
59
134
  const errors: { file: string; error: string }[] = [];
60
135
 
61
136
  for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
62
- if (file.match(/\.env(\..+)?\.yaml$/) || file.match(/\.env(\..+)?\.yml$/)) {
137
+ if (isNonSuiteYaml(file)) {
63
138
  continue;
64
139
  }
65
140
  const fullPath = `${dirPath}/${file}`;
66
141
  try {
67
- suites.push(await parseFile(fullPath));
142
+ suites.push(await parseFile(fullPath, opts));
68
143
  } catch (err) {
69
144
  errors.push({ file, error: (err as Error).message });
70
145
  }
@@ -73,14 +148,34 @@ export async function parseDirectorySafe(dirPath: string): Promise<ParseDirector
73
148
  return { suites, errors };
74
149
  }
75
150
 
76
- export async function parse(path: string): Promise<TestSuite[]> {
151
+ export async function parse(path: string, opts: ParseOptions = {}): Promise<TestSuite[]> {
77
152
  const file = Bun.file(path);
78
153
  const exists = await file.exists();
79
154
 
80
155
  if (exists) {
81
- return [await parseFile(path)];
156
+ return [await parseFile(path, opts)];
82
157
  }
83
158
 
84
159
  // Not a file, try as directory
85
- return parseDirectory(path);
160
+ return parseDirectory(path, opts);
161
+ }
162
+
163
+ /**
164
+ * Like {@link parse}, but never silently drops files. Returns both successfully
165
+ * parsed suites and per-file parse errors so callers (run, validate, tag-filter)
166
+ * can surface failures instead of pretending the file did not exist.
167
+ */
168
+ export async function parseSafe(path: string, opts: ParseOptions = {}): Promise<ParseDirectoryResult> {
169
+ const file = Bun.file(path);
170
+ const exists = await file.exists();
171
+
172
+ if (exists) {
173
+ try {
174
+ return { suites: [await parseFile(path, opts)], errors: [] };
175
+ } catch (err) {
176
+ return { suites: [], errors: [{ file: path, error: (err as Error).message }] };
177
+ }
178
+ }
179
+
180
+ return parseDirectorySafe(path, opts);
86
181
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Probe registry bootstrap (m-17 / ARV-49).
3
+ *
4
+ * Called once from the CLI program init. Imports each Probe class and
5
+ * runs `registerProbe`, which validates the contract from `types.ts`
6
+ * and throws if a slot is missing. Boot-time failure is louder than
7
+ * runtime — adding a new probe class without --dry-run / --report
8
+ * support won't ship; that's the whole point of the m-17 contract.
9
+ *
10
+ * Idempotent: repeated calls are no-ops (matters for unit tests that
11
+ * run the bootstrap multiple times).
12
+ */
13
+ import { listProbes, registerProbe } from "./registry.ts";
14
+ import { SecurityProbe } from "./security-probe-class.ts";
15
+ import { MassAssignmentProbe } from "./mass-assignment-probe-class.ts";
16
+ import { StaticProbe } from "./static-probe-class.ts";
17
+
18
+ let bootstrapped = false;
19
+
20
+ export function bootstrapProbes(): void {
21
+ if (bootstrapped) return;
22
+ if (listProbes().length === 0) {
23
+ registerProbe(new StaticProbe());
24
+ registerProbe(new MassAssignmentProbe());
25
+ registerProbe(new SecurityProbe());
26
+ }
27
+ bootstrapped = true;
28
+ }
29
+
30
+ /** Test helper — resets the singleton so the next `bootstrapProbes()`
31
+ * re-registers from scratch. Pair with `clearProbes()` from registry. */
32
+ export function resetBootstrap(): void {
33
+ bootstrapped = false;
34
+ }