@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,13 +1,53 @@
1
1
  import type { OpenAPIV3 } from "openapi-types";
2
2
  import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
3
3
  import type { RawSuite, RawStep } from "./serializer.ts";
4
+ import type { SourceMetadata } from "../parser/types.ts";
4
5
  import { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
5
6
  import { groupEndpointsByTag } from "./chunker.ts";
7
+ import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
8
+ import { flattenToFormFields } from "../runner/form-encode.ts";
6
9
 
7
10
  // ──────────────────────────────────────────────
8
11
  // Helpers
9
12
  // ──────────────────────────────────────────────
10
13
 
14
+ /**
15
+ * Singularize an English plural noun for use in suite names and capture
16
+ * variables. Handles the cases that matter for typical OpenAPI resource
17
+ * names — `properties → property`, `addresses → address`, `boxes → box`,
18
+ * `users → user`. Words that don't match any rule are returned unchanged
19
+ * (so already-singular `series`, `news`, `data`, etc. survive).
20
+ */
21
+ export function singularizeResource(word: string): string {
22
+ if (word.length > 3 && /ies$/i.test(word)) return word.slice(0, -3) + "y";
23
+ // ARV-100 (F5): the inner alternative was `s` — but a single trailing `s`
24
+ // catches every regular plural whose stem ends in any vowel + `s` (e.g.
25
+ // `releases`, `phases`, `houses`), and `slice(-2)` then chops "es" instead
26
+ // of just "s". The result was `releas_id` / `phas_id` capture vars that
27
+ // matched nothing on the manifest side. Restrict the rule to the genuine
28
+ // sibilant double — `ss` — so `addresses → address` keeps working without
29
+ // dragging single-s plurals along.
30
+ if (word.length > 3 && /(ch|sh|x|ss|z)es$/i.test(word)) return word.slice(0, -2);
31
+ if (word.length > 1 && /[^s]s$/i.test(word)) return word.slice(0, -1);
32
+ return word;
33
+ }
34
+
35
+ /**
36
+ * Build a `<resource>_id` capture/var name. Strips dashes so the result is
37
+ * a safe template variable identifier — `contact-properties` becomes
38
+ * `contact_property_id` rather than `contact-propertie_id` (TASK-214).
39
+ *
40
+ * ARV-100 (F5): always lowercase. Path-params/headers in fixtures-builder
41
+ * are normalised to lowercase (line 157), so capture vars must match — a
42
+ * `Groups` resource would otherwise produce `Group_id` while path-params on
43
+ * the same endpoint produce `group_id`, splitting the `{{var}}` namespace
44
+ * and triggering "Undefined variables" in `zond run`.
45
+ */
46
+ export function resourceVar(resource: string, suffix: string): string {
47
+ const singular = singularizeResource(resource);
48
+ return `${singular.replace(/[^a-zA-Z0-9]+/g, "_")}_${suffix}`.toLowerCase();
49
+ }
50
+
11
51
  /** Convert OpenAPI path params {param} to test interpolation {{param}} */
12
52
  function convertPath(path: string): string {
13
53
  return path.replace(/\{([^}]+)\}/g, "{{$1}}");
@@ -73,11 +113,32 @@ function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefin
73
113
  );
74
114
  }
75
115
 
116
+ /**
117
+ * Pick the success status the test should assert.
118
+ *
119
+ * Order:
120
+ * 1. First 2xx declared in the spec (most authoritative).
121
+ * 2. Method-aware default when the spec lists only non-2xx responses or none
122
+ * at all (many OpenAPI specs is silent for several mutating endpoints — the
123
+ * actual runtime returns 201/204, while the old default of 200 caused
124
+ * tests to fail at runtime). We never assert a 4xx/5xx as the success
125
+ * status — that would generate guaranteed-failing tests.
126
+ */
76
127
  function getExpectedStatus(ep: EndpointInfo): number {
77
128
  const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
78
129
  if (success) return success.statusCode;
79
- if (ep.responses.length > 0) return ep.responses[0]!.statusCode;
80
- return 200;
130
+ return defaultStatusByMethod(ep.method);
131
+ }
132
+
133
+ function defaultStatusByMethod(method: string): number {
134
+ switch (method.toUpperCase()) {
135
+ case "POST":
136
+ return 201;
137
+ case "DELETE":
138
+ return 204;
139
+ default:
140
+ return 200;
141
+ }
81
142
  }
82
143
 
83
144
  function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
@@ -105,7 +166,7 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
105
166
  }
106
167
 
107
168
  /** Derive a variable name for a security scheme's token */
108
- function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
169
+ export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
109
170
  // Count how many bearer-like schemes exist
110
171
  const bearerSchemes = allSchemes.filter(s =>
111
172
  (s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
@@ -122,29 +183,7 @@ function getAuthHeaders(
122
183
  ep: EndpointInfo,
123
184
  schemes: SecuritySchemeInfo[],
124
185
  ): Record<string, string> | undefined {
125
- if (ep.security.length === 0) return undefined;
126
-
127
- for (const secName of ep.security) {
128
- const scheme = schemes.find(s => s.name === secName);
129
- if (!scheme) continue;
130
-
131
- if (scheme.type === "http") {
132
- if (scheme.scheme === "bearer" || !scheme.scheme) {
133
- return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
134
- }
135
- if (scheme.scheme === "basic") {
136
- return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
137
- }
138
- }
139
- if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
140
- if (scheme.apiKeyName === "Authorization") {
141
- return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
142
- }
143
- return { [scheme.apiKeyName]: "{{api_key}}" };
144
- }
145
- }
146
-
147
- return undefined;
186
+ return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
148
187
  }
149
188
 
150
189
  function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
@@ -172,23 +211,88 @@ function getSuiteHeaders(
172
211
 
173
212
  const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
174
213
  const first = headerSets[0];
175
- if (!first) return undefined;
214
+ if (!first) {
215
+ // ARV-212 (R13/F16): spec has no securitySchemes (GitHub publishes its
216
+ // OpenAPI this way) so per-endpoint auth-header derivation returns
217
+ // undefined for every step. When the API workspace nonetheless wires
218
+ // `auth_token` end-to-end (ARV-201 seeds it in .env.yaml on bare specs,
219
+ // and zond request / runner auto-attach Authorization: Bearer when
220
+ // auth_token is present), generated suites should not silently go
221
+ // unauth — that bricks them on the first rate-limited 60 requests.
222
+ // Fall back to a generic Bearer header at the suite level. The header
223
+ // is harmless when .secrets.yaml.auth_token is empty (zond runner
224
+ // still substitutes `{{auth_token}}` to an empty string, just like
225
+ // before; the server then 401s — same outcome as today).
226
+ if (schemes.length === 0 && _suiteDefaultAuthVar !== null) {
227
+ return { Authorization: `Bearer {{${_suiteDefaultAuthVar}}}` };
228
+ }
229
+ return undefined;
230
+ }
176
231
 
177
232
  const firstJson = JSON.stringify(first);
178
233
  const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
179
234
  return allSame ? first : undefined;
180
235
  }
181
236
 
182
- /** Find the best field to capture from POST response (for CRUD chains) */
183
- function getCaptureField(ep: EndpointInfo): string {
237
+ // ARV-212 (R13/F16): generator-level "the caller wired auth_token in
238
+ // .env.yaml even though the spec has no securitySchemes" hint. Set at the
239
+ // top of generateSuites and consulted by getSuiteHeaders / generateCrudSuite
240
+ // / generateSanitySuite. Module-scoped to avoid threading through ~7 call
241
+ // sites. Always reset to null at the end of generateSuites so the helper
242
+ // stays stateless from the caller's perspective.
243
+ let _suiteDefaultAuthVar: string | null = null;
244
+
245
+ /** Common id-like field names looked up after `id` itself.
246
+ * TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
247
+ * or `name` instead of an `id` field on create responses. Without these,
248
+ * CRUD chains fall back to capturing `"id"` from a body that doesn't have
249
+ * one, breaking the `{id}` substitution in follow-up reads. */
250
+ const ID_LIKE_NAMES = ["slug", "uuid", "key", "version", "name"];
251
+
252
+ /** Find the best field to capture from POST response (for CRUD chains).
253
+ *
254
+ * Priority:
255
+ * 1. Field whose name matches the path-param (e.g. `{rule_id}` → `rule_id`
256
+ * or `{slug}` → `slug`). The path-param name is the strongest hint —
257
+ * whatever the response calls "the id of this resource" is what gets
258
+ * interpolated back into the read/update/delete URLs.
259
+ * 2. `id` (most common case).
260
+ * 3. Conventional id-like names: `slug`, `uuid`, `key`, `version`, `name`
261
+ * — but only if they are typed as a string (avoids capturing a `name`
262
+ * object on resources that nest metadata).
263
+ * 4. Any field with `type: integer` or `format: uuid`.
264
+ * 5. Fallback: `"id"` (the YAML capture will simply be empty if absent —
265
+ * the runner already handles this gracefully).
266
+ */
267
+ function getCaptureField(ep: EndpointInfo, idParam?: string): string {
184
268
  const schema = getSuccessSchema(ep);
185
- if (schema?.properties) {
186
- if ("id" in schema.properties) return "id";
187
- for (const [name, propSchema] of Object.entries(schema.properties)) {
188
- const s = propSchema as OpenAPIV3.SchemaObject;
189
- if (s.type === "integer" || s.format === "uuid") return name;
269
+ const props = schema?.properties;
270
+ if (!props) return "id";
271
+
272
+ // 1. Path-param name match.
273
+ if (idParam) {
274
+ if (idParam in props) return idParam;
275
+ // Also try the conventional `<resource>_id` ↔ `id` swap.
276
+ if (idParam.endsWith("_id") && "id" in props) return "id";
277
+ }
278
+
279
+ // 2. Plain `id`.
280
+ if ("id" in props) return "id";
281
+
282
+ // 3. Conventional id-like names (string-typed only).
283
+ for (const candidate of ID_LIKE_NAMES) {
284
+ if (candidate in props) {
285
+ const s = props[candidate] as OpenAPIV3.SchemaObject;
286
+ if (s.type === "string") return candidate;
190
287
  }
191
288
  }
289
+
290
+ // 4. Any integer or uuid-shaped field.
291
+ for (const [name, propSchema] of Object.entries(props)) {
292
+ const s = propSchema as OpenAPIV3.SchemaObject;
293
+ if (s.type === "integer" || s.format === "uuid") return name;
294
+ }
295
+
192
296
  return "id";
193
297
  }
194
298
 
@@ -200,6 +304,46 @@ function isAuthEndpoint(ep: EndpointInfo): boolean {
200
304
  return false;
201
305
  }
202
306
 
307
+ // ──────────────────────────────────────────────
308
+ // Provenance helpers
309
+ // ──────────────────────────────────────────────
310
+
311
+ function escapeJsonPointerSegment(s: string): string {
312
+ return s.replace(/~/g, "~0").replace(/\//g, "~1");
313
+ }
314
+
315
+ function pickPrimaryStatus(status: number | number[]): number {
316
+ return Array.isArray(status) ? (status[0] ?? 200) : status;
317
+ }
318
+
319
+ /** Build step-level provenance for an endpoint + chosen response status. */
320
+ export function buildStepSource(
321
+ ep: EndpointInfo,
322
+ statusOverride?: number | number[],
323
+ ): SourceMetadata {
324
+ const method = ep.method.toUpperCase();
325
+ const status = statusOverride ?? getExpectedStatus(ep);
326
+ const primary = pickPrimaryStatus(status);
327
+ const responseBranch = Array.isArray(status) ? status.map(String).join("|") : String(status);
328
+ const escapedPath = escapeJsonPointerSegment(ep.path);
329
+ return {
330
+ endpoint: `${method} ${ep.path}`,
331
+ response_branch: responseBranch,
332
+ schema_pointer: `#/paths/${escapedPath}/${method.toLowerCase()}/responses/${primary}`,
333
+ };
334
+ }
335
+
336
+ /** Build suite-level provenance for an openapi-generated suite. */
337
+ export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | undefined {
338
+ if (!specPath) return undefined;
339
+ return {
340
+ type: "openapi-generated",
341
+ spec: specPath,
342
+ generator: "zond-generate",
343
+ generated_at: new Date().toISOString(),
344
+ };
345
+ }
346
+
203
347
  // ──────────────────────────────────────────────
204
348
  // Public API
205
349
  // ──────────────────────────────────────────────
@@ -215,6 +359,7 @@ export function generateStep(
215
359
 
216
360
  const step: RawStep = {
217
361
  name,
362
+ source: buildStepSource(ep),
218
363
  [method]: path,
219
364
  expect: {
220
365
  status: getExpectedStatus(ep),
@@ -229,6 +374,12 @@ export function generateStep(
229
374
  if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
230
375
  if (ep.requestBodyContentType === "multipart/form-data") {
231
376
  step.multipart = generateMultipartFromSchema(ep.requestBodySchema);
377
+ } else if (ep.requestBodyContentType === "application/x-www-form-urlencoded") {
378
+ // ARV-149: form-encoded endpoints (Stripe v1 et al.) — emit `form:` so
379
+ // the runner posts URL-encoded bodies with bracket notation. Without
380
+ // this, generate baked `json:` blocks and every POST 400'd with
381
+ // "wrong content type".
382
+ step.form = flattenToFormFields(generateFromSchema(ep.requestBodySchema));
232
383
  } else {
233
384
  step.json = generateFromSchema(ep.requestBodySchema);
234
385
  }
@@ -247,33 +398,163 @@ export function generateStep(
247
398
  return step;
248
399
  }
249
400
 
250
- /** Detect CRUD groups from a list of endpoints */
401
+ /** Strip a single trailing slash for comparison purposes. We never rewrite
402
+ * endpoint paths in the spec — we just normalise the matching regex so
403
+ * `POST /alerts/` + `GET /alerts/{id}/` lines up the same as the no-slash
404
+ * variant. */
405
+ function stripTrailingSlash(p: string): string {
406
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
407
+ }
408
+
409
+ /** Per-resource diagnostic record used by `zond generate --explain`.
410
+ * Captures every POST candidate the detector considered and the verdict
411
+ * with a human reason — so users can see "I have a CRUD-looking pair, why
412
+ * didn't generate emit a chain?" without grepping the spec. */
413
+ export interface CrudDetectionDiagnostic {
414
+ resource: string;
415
+ basePath: string;
416
+ postPath: string;
417
+ hasGetById: boolean;
418
+ hasUpdate: boolean;
419
+ hasDelete: boolean;
420
+ hasList: boolean;
421
+ verdict: "chain" | "skipped";
422
+ reason: string;
423
+ }
424
+
425
+ export interface DetectCrudResult {
426
+ groups: CrudGroup[];
427
+ diagnostics: CrudDetectionDiagnostic[];
428
+ }
429
+
430
+ /** Detect CRUD groups from a list of endpoints.
431
+ *
432
+ * Match logic (TASK-139):
433
+ * - basePath = POST endpoint's path with any trailing slash trimmed.
434
+ * - item path = `<basePath>/{param}` with optional trailing slash.
435
+ * This catches common SaaS-style `POST /alert-rules/` + `GET /alert-rules/{id}/`
436
+ * pairs that previously fell through because the regex required the same
437
+ * slash form on both. */
251
438
  export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
439
+ return detectCrudGroupsWithDiagnostics(endpoints).groups;
440
+ }
441
+
442
+ export function detectCrudGroupsWithDiagnostics(
443
+ endpoints: EndpointInfo[],
444
+ ): DetectCrudResult {
252
445
  const groups: CrudGroup[] = [];
253
- const postEndpoints = endpoints.filter(ep => ep.method.toUpperCase() === "POST" && !ep.deprecated);
446
+ const diagnostics: CrudDetectionDiagnostic[] = [];
447
+ const postEndpoints = endpoints.filter(
448
+ ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
449
+ );
254
450
 
255
451
  for (const createEp of postEndpoints) {
256
- const basePath = createEp.path;
452
+ const basePath = stripTrailingSlash(createEp.path);
453
+ const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
454
+
455
+ // Match `<basePath>/{param}` with optional trailing slash. Tolerates
456
+ // both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
457
+ // `GET /alerts/{id}/`, which some real-world specs mix.
458
+ const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}/?$`);
459
+ const itemEndpoints = endpoints.filter(
460
+ ep => !ep.deprecated && itemPattern.test(ep.path),
461
+ );
257
462
 
258
- // Find item endpoints: basePath/{param}
259
- const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}$`);
260
- const itemEndpoints = endpoints.filter(ep => !ep.deprecated && itemPattern.test(ep.path));
463
+ // Fallback for "subdomain"/nested-item routing (common SaaS-style):
464
+ // create lives under one root (`/api/0/organizations/{org}/teams/`)
465
+ // but item-path lives under another (`/api/0/teams/{org}/{team}/`).
466
+ // The strict basePath/{id} regex misses these. Match instead by:
467
+ // 1. shared OpenAPI tag with the create operation,
468
+ // 2. terminal {param} matching the singular form of the resource
469
+ // (`{team}` / `{team_id}` / `{team_id_or_slug}`).
470
+ let resolvedItemEndpoints = itemEndpoints;
471
+ if (resolvedItemEndpoints.length === 0) {
472
+ const singular = singularizeResource(resource).toLowerCase();
473
+ const itemTerminalRe = /\{([^}]+)\}\/?$/;
474
+ const matchesResourceParam = (p: string) => {
475
+ const m = p.match(itemTerminalRe);
476
+ if (!m) return false;
477
+ const param = m[1]!.toLowerCase();
478
+ return (
479
+ param === singular ||
480
+ param === `${singular}_id` ||
481
+ param === `${singular}_id_or_slug` ||
482
+ param === `${singular}_slug`
483
+ );
484
+ };
485
+ const createTags = new Set(createEp.tags ?? []);
486
+ const sharedTag = (ep: EndpointInfo) =>
487
+ (ep.tags ?? []).some(t => createTags.has(t));
488
+
489
+ resolvedItemEndpoints = endpoints.filter(
490
+ ep =>
491
+ !ep.deprecated &&
492
+ ep.path !== createEp.path &&
493
+ matchesResourceParam(ep.path) &&
494
+ sharedTag(ep),
495
+ );
496
+ }
261
497
 
262
- if (itemEndpoints.length === 0) continue;
498
+ const read = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
499
+ const update = resolvedItemEndpoints.find(
500
+ ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
501
+ );
502
+ const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
503
+ // List endpoint matches with the same trailing-slash tolerance.
504
+ const list = endpoints.find(
505
+ ep =>
506
+ ep.method.toUpperCase() === "GET" &&
507
+ stripTrailingSlash(ep.path) === basePath &&
508
+ !ep.deprecated,
509
+ );
263
510
 
264
- const itemPath = itemEndpoints[0]!.path;
265
- const idMatch = itemPath.match(/\{([^}]+)\}$/);
266
- if (!idMatch) continue;
267
- const idParam = idMatch[1]!;
511
+ const diag: CrudDetectionDiagnostic = {
512
+ resource,
513
+ basePath,
514
+ postPath: createEp.path,
515
+ hasGetById: !!read,
516
+ hasUpdate: !!update,
517
+ hasDelete: !!del,
518
+ hasList: !!list,
519
+ verdict: "skipped",
520
+ reason: "",
521
+ };
268
522
 
269
- const read = itemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
270
- if (!read) continue; // Minimum: POST + GET/{id}
523
+ if (resolvedItemEndpoints.length === 0) {
524
+ diag.reason = `no item endpoint matching ${basePath}/{...}`;
525
+ diagnostics.push(diag);
526
+ continue;
527
+ }
528
+ // TASK-260: accept headless chains — POST + (GET/PUT/PATCH/DELETE on /{id}).
529
+ // Resources with no GET-by-id (e.g. external-teams, some user-binding endpoints)
530
+ // were previously skipped entirely, even though POST captures the ID and PUT/DELETE
531
+ // can drive the chain on their own. The Read/Verify steps in the suite generator
532
+ // are already conditional on `group.read`, so headless chains generate cleanly.
533
+ if (!read && !update && !del) {
534
+ diag.reason = "item endpoint exists but no GET/PUT/PATCH/DELETE on /{id}";
535
+ diagnostics.push(diag);
536
+ continue;
537
+ }
271
538
 
272
- const update = itemEndpoints.find(ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()));
273
- const del = itemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
274
- const list = endpoints.find(ep => ep.method.toUpperCase() === "GET" && ep.path === basePath && !ep.deprecated);
539
+ const itemPath = resolvedItemEndpoints[0]!.path;
540
+ const idMatch = itemPath.match(/\{([^}]+)\}\/?$/);
541
+ if (!idMatch) {
542
+ diag.reason = "item path has no terminal {param}";
543
+ diagnostics.push(diag);
544
+ continue;
545
+ }
546
+ const idParam = idMatch[1]!;
275
547
 
276
- const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
548
+ diag.verdict = "chain";
549
+ if (read) {
550
+ diag.reason = "POST + GET/{id} matched";
551
+ } else {
552
+ // TASK-260: explicit headless reason so `--explain` differentiates the
553
+ // two chain shapes — useful for debugging fixture flow.
554
+ const partner = update ? `${update.method.toUpperCase()}/{id}` : `DELETE/{id}`;
555
+ diag.reason = `POST + ${partner} matched (headless: no GET-by-id)`;
556
+ }
557
+ diagnostics.push(diag);
277
558
 
278
559
  groups.push({
279
560
  resource,
@@ -288,7 +569,7 @@ export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
288
569
  });
289
570
  }
290
571
 
291
- return groups;
572
+ return { groups, diagnostics };
292
573
  }
293
574
 
294
575
  /** Generate a CRUD chain suite from a CrudGroup */
@@ -296,8 +577,16 @@ export function generateCrudSuite(
296
577
  group: CrudGroup,
297
578
  securitySchemes: SecuritySchemeInfo[],
298
579
  ): RawSuite {
299
- const captureField = group.create ? getCaptureField(group.create) : "id";
300
- const captureVar = `${group.resource.replace(/s$/, "")}_id`;
580
+ const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
581
+ // ARV-137: use the spec's path-param name as the capture var. Previously
582
+ // we synthesised `<resource>_id` via `resourceVar(...)`, which produced
583
+ // phantom manifest dupes whenever the spec named the path-param anything
584
+ // other than `<resource>_id` (e.g. `monitor_id_or_slug`, `version`, or
585
+ // collection-stem mismatches like resource=`saved`/idParam=`query_id`).
586
+ // Aligning on `group.idParam` keeps tests, manifest, and spec consistent.
587
+ // Fallback to `resourceVar` only when the group has no idParam (defensive
588
+ // — shouldn't happen for any group with a read/update/delete endpoint).
589
+ const captureVar = group.idParam || resourceVar(group.resource, "id");
301
590
  const tests: RawStep[] = [];
302
591
 
303
592
  const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
@@ -322,7 +611,8 @@ export function generateCrudSuite(
322
611
  // 2. Read created
323
612
  if (group.read) {
324
613
  const step: RawStep = {
325
- name: group.read.operationId ?? `Read created ${group.resource.replace(/s$/, "")}`,
614
+ name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
615
+ source: buildStepSource(group.read),
326
616
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
327
617
  expect: {
328
618
  status: getExpectedStatus(group.read),
@@ -336,12 +626,13 @@ export function generateCrudSuite(
336
626
  if (group.update) {
337
627
  const method = group.update.method.toUpperCase();
338
628
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
339
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
629
+ const etagVar = resourceVar(group.resource, "etag");
340
630
 
341
631
  // If endpoint requires ETag (optimistic locking), capture it from a GET step first
342
632
  if (group.update.requiresEtag && group.read) {
343
633
  tests.push({
344
- name: `Get ETag before update ${group.resource.replace(/s$/, "")}`,
634
+ name: `Get ETag before update ${singularizeResource(group.resource)}`,
635
+ source: buildStepSource(group.read),
345
636
  GET: itemPath,
346
637
  expect: {
347
638
  status: getExpectedStatus(group.read),
@@ -351,7 +642,8 @@ export function generateCrudSuite(
351
642
  }
352
643
 
353
644
  const step: RawStep = {
354
- name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
645
+ name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
646
+ source: buildStepSource(group.update),
355
647
  [method]: itemPath,
356
648
  expect: {
357
649
  status: getExpectedStatus(group.update),
@@ -369,13 +661,14 @@ export function generateCrudSuite(
369
661
  // 4. Delete
370
662
  if (group.delete) {
371
663
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
372
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
664
+ const etagVar = resourceVar(group.resource, "etag");
373
665
 
374
666
  // If delete requires ETag and update didn't already capture it, add a GET step
375
667
  const updateAlreadyCapturedEtag = group.update?.requiresEtag;
376
668
  if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
377
669
  tests.push({
378
- name: `Get ETag before delete ${group.resource.replace(/s$/, "")}`,
670
+ name: `Get ETag before delete ${singularizeResource(group.resource)}`,
671
+ source: buildStepSource(group.read),
379
672
  GET: itemPath,
380
673
  expect: {
381
674
  status: getExpectedStatus(group.read),
@@ -386,7 +679,8 @@ export function generateCrudSuite(
386
679
 
387
680
  // T44: cleanup must run even if earlier assertions failed (tainted captures)
388
681
  const step: RawStep = {
389
- name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
682
+ name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
683
+ source: buildStepSource(group.delete),
390
684
  DELETE: itemPath,
391
685
  always: true,
392
686
  expect: {
@@ -401,7 +695,8 @@ export function generateCrudSuite(
401
695
  // 5. Verify deleted — also always, so we confirm cleanup happened
402
696
  if (group.read) {
403
697
  tests.push({
404
- name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
698
+ name: `Verify ${singularizeResource(group.resource)} deleted`,
699
+ source: buildStepSource(group.read, 404),
405
700
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
406
701
  always: true,
407
702
  expect: {
@@ -594,7 +889,7 @@ function generateConsistentAuthSuite(
594
889
  }
595
890
 
596
891
  /** Generate 1-2 minimal tests for quick connectivity and auth validation */
597
- export function generateSanitySuite(opts: {
892
+ function generateSanitySuite(opts: {
598
893
  authEndpoints: EndpointInfo[];
599
894
  nonAuthGetEndpoints: EndpointInfo[];
600
895
  securitySchemes: SecuritySchemeInfo[];
@@ -631,11 +926,22 @@ export function generateSanitySuite(opts: {
631
926
  export function generateSuites(opts: {
632
927
  endpoints: EndpointInfo[];
633
928
  securitySchemes: SecuritySchemeInfo[];
929
+ /** Path to OpenAPI spec, recorded in suite-level provenance. */
930
+ specPath?: string;
931
+ /** When true, deprecated endpoints are included instead of filtered out. */
932
+ includeDeprecated?: boolean;
933
+ /** ARV-212 (R13/F16): inject `Authorization: Bearer {{<varName>}}` at the
934
+ * suite level when the spec declares no securitySchemes but the workspace
935
+ * .env.yaml carries this auth-token variable. Lets generated suites talk
936
+ * to bare-spec APIs (GitHub) without going unauth. */
937
+ defaultAuthVar?: string;
634
938
  }): RawSuite[] {
635
- const { endpoints, securitySchemes } = opts;
939
+ const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
940
+ _suiteDefaultAuthVar = defaultAuthVar ?? null;
636
941
 
637
- // Filter deprecated
638
- const active = endpoints.filter(ep => !ep.deprecated);
942
+ // Filter deprecated unless caller opted in. The list of skipped paths is
943
+ // exposed separately via `getSkippedDeprecated` for stdout reporting.
944
+ const active = includeDeprecated ? endpoints : endpoints.filter(ep => !ep.deprecated);
639
945
 
640
946
  // Separate auth endpoints
641
947
  const authEndpoints = active.filter(isAuthEndpoint);
@@ -670,27 +976,52 @@ export function generateSuites(opts: {
670
976
  const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
671
977
  const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
672
978
 
673
- // Regular smoke: paramless GETs (e.g. list endpoints, health checks)
674
- if (paramlessGets.length > 0) {
675
- const tests = paramlessGets.map(ep => {
979
+ // Positive smoke: paramless GETs (no env needed) + path-param GETs
980
+ // (with skip_if guards). TASK-240 — unified naming convention:
981
+ // always emit `smoke-<tag>-positive.yaml`, never the bare
982
+ // `smoke-<tag>.yaml`, so file listings don't have to explain why a
983
+ // tag has only `-negative` (e.g. a vendor-specific tag) or why two
984
+ // siblings differ in suffix shape.
985
+ const positiveTests = [
986
+ ...paramlessGets.map(ep => {
676
987
  const step = generateStep(ep, securitySchemes);
677
988
  const seededPath = convertPathWithSeeds(ep.path, ep);
678
989
  (step as any)[ep.method.toUpperCase()] = seededPath;
679
990
  return step;
680
- });
681
- const headers = getSuiteHeaders(paramlessGets, securitySchemes);
991
+ }),
992
+ ...pathParamGets.map(ep => {
993
+ const step = generateStep(ep, securitySchemes);
994
+ // Path stays as {{param}} so user-provided env values flow in.
995
+ // skip_if guards an unset path-param without skipping paramless
996
+ // siblings that don't need a fixture.
997
+ const firstPathParam = ep.parameters.find(p => p.in === "path");
998
+ if (firstPathParam) {
999
+ step.skip_if = `{{${firstPathParam.name}}} ==`;
1000
+ }
1001
+ return step;
1002
+ }),
1003
+ ];
1004
+
1005
+ if (positiveTests.length > 0) {
1006
+ const positiveEndpoints = [...paramlessGets, ...pathParamGets];
1007
+ const headers = getSuiteHeaders(positiveEndpoints, securitySchemes);
1008
+ // needs-id only when at least one test depends on a path-param
1009
+ // fixture — coverage downgrades these suites when env is empty.
1010
+ const tags = pathParamGets.length > 0
1011
+ ? ["smoke", "positive", "needs-id"]
1012
+ : ["smoke", "positive"];
682
1013
 
683
1014
  const suite: RawSuite = {
684
- name: `${tagSlug}-smoke`,
685
- tags: ["smoke"],
686
- fileStem: `smoke-${tagSlug}`,
1015
+ name: `${tagSlug}-smoke-positive`,
1016
+ tags,
1017
+ fileStem: `smoke-${tagSlug}-positive`,
687
1018
  base_url: "{{base_url}}",
688
- tests,
1019
+ tests: positiveTests,
689
1020
  };
690
1021
 
691
1022
  if (headers) {
692
1023
  suite.headers = headers;
693
- for (const t of tests) {
1024
+ for (const t of positiveTests) {
694
1025
  if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
695
1026
  delete (t as any).headers;
696
1027
  }
@@ -731,40 +1062,6 @@ export function generateSuites(opts: {
731
1062
  suites.push(suite);
732
1063
  }
733
1064
 
734
- // Positive smoke: path-param GETs with {{var}} placeholders + skip_if for unset env
735
- if (pathParamGets.length > 0) {
736
- const tests = pathParamGets.map(ep => {
737
- const step = generateStep(ep, securitySchemes);
738
- // Path stays as {{param}} so user-provided env values flow in
739
- // Pick the first path param for skip_if guard (the resource ID)
740
- const firstPathParam = ep.parameters.find(p => p.in === "path");
741
- if (firstPathParam) {
742
- step.skip_if = `{{${firstPathParam.name}}} ==`;
743
- }
744
- return step;
745
- });
746
- const headers = getSuiteHeaders(pathParamGets, securitySchemes);
747
-
748
- const suite: RawSuite = {
749
- name: `${tagSlug}-smoke-positive`,
750
- tags: ["smoke", "positive", "needs-id"],
751
- fileStem: `smoke-${tagSlug}-positive`,
752
- base_url: "{{base_url}}",
753
- tests,
754
- };
755
-
756
- if (headers) {
757
- suite.headers = headers;
758
- for (const t of tests) {
759
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
760
- delete (t as any).headers;
761
- }
762
- }
763
- }
764
-
765
- suites.push(suite);
766
- }
767
-
768
1065
  // Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
769
1066
  const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
770
1067
  const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
@@ -836,5 +1133,16 @@ export function generateSuites(opts: {
836
1133
  const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
837
1134
  const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
838
1135
 
839
- return sanitySuite ? [sanitySuite, ...suites] : suites;
1136
+ const allSuites = sanitySuite ? [sanitySuite, ...suites] : suites;
1137
+
1138
+ // Stamp suite-level provenance when a spec path is known.
1139
+ const suiteSrc = buildOpenApiSuiteSource(specPath);
1140
+ if (suiteSrc) {
1141
+ for (const s of allSuites) {
1142
+ s.source = suiteSrc;
1143
+ }
1144
+ }
1145
+
1146
+ _suiteDefaultAuthVar = null; // ARV-212
1147
+ return allSuites;
840
1148
  }