@kirrosh/zond 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +55 -6
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -21
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -1,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}}");
@@ -28,6 +68,30 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
28
68
  });
29
69
  }
30
70
 
71
+ /**
72
+ * For negative-smoke suites: replace path params with guaranteed-non-existent values.
73
+ * Picks a value that's syntactically valid for the param's type/format but very
74
+ * unlikely to match a real resource (zero-UUID, very large int, sentinel string).
75
+ */
76
+ function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
77
+ if (!schema) return "nonexistent_id_zzzzzz";
78
+ if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
79
+ if (schema.type === "integer" || schema.type === "number") return "999999999";
80
+ return "nonexistent_id_zzzzzz";
81
+ }
82
+
83
+ function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
84
+ return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
85
+ const param = ep.parameters.find(p => p.name === name && p.in === "path");
86
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
87
+ return getNonexistentSeed(schema);
88
+ });
89
+ }
90
+
91
+ function endpointHasPathParams(ep: EndpointInfo): boolean {
92
+ return ep.parameters.some(p => p.in === "path");
93
+ }
94
+
31
95
  function slugify(s: string): string {
32
96
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
33
97
  }
@@ -49,11 +113,32 @@ function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefin
49
113
  );
50
114
  }
51
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
+ */
52
127
  function getExpectedStatus(ep: EndpointInfo): number {
53
128
  const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
54
129
  if (success) return success.statusCode;
55
- if (ep.responses.length > 0) return ep.responses[0]!.statusCode;
56
- 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
+ }
57
142
  }
58
143
 
59
144
  function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
@@ -81,7 +166,7 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
81
166
  }
82
167
 
83
168
  /** Derive a variable name for a security scheme's token */
84
- function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
169
+ export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
85
170
  // Count how many bearer-like schemes exist
86
171
  const bearerSchemes = allSchemes.filter(s =>
87
172
  (s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
@@ -98,29 +183,7 @@ function getAuthHeaders(
98
183
  ep: EndpointInfo,
99
184
  schemes: SecuritySchemeInfo[],
100
185
  ): Record<string, string> | undefined {
101
- if (ep.security.length === 0) return undefined;
102
-
103
- for (const secName of ep.security) {
104
- const scheme = schemes.find(s => s.name === secName);
105
- if (!scheme) continue;
106
-
107
- if (scheme.type === "http") {
108
- if (scheme.scheme === "bearer" || !scheme.scheme) {
109
- return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
110
- }
111
- if (scheme.scheme === "basic") {
112
- return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
113
- }
114
- }
115
- if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
116
- if (scheme.apiKeyName === "Authorization") {
117
- return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
118
- }
119
- return { [scheme.apiKeyName]: "{{api_key}}" };
120
- }
121
- }
122
-
123
- return undefined;
186
+ return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
124
187
  }
125
188
 
126
189
  function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
@@ -148,23 +211,88 @@ function getSuiteHeaders(
148
211
 
149
212
  const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
150
213
  const first = headerSets[0];
151
- 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
+ }
152
231
 
153
232
  const firstJson = JSON.stringify(first);
154
233
  const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
155
234
  return allSame ? first : undefined;
156
235
  }
157
236
 
158
- /** Find the best field to capture from POST response (for CRUD chains) */
159
- 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 {
160
268
  const schema = getSuccessSchema(ep);
161
- if (schema?.properties) {
162
- if ("id" in schema.properties) return "id";
163
- for (const [name, propSchema] of Object.entries(schema.properties)) {
164
- const s = propSchema as OpenAPIV3.SchemaObject;
165
- 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;
166
287
  }
167
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
+
168
296
  return "id";
169
297
  }
170
298
 
@@ -176,6 +304,46 @@ function isAuthEndpoint(ep: EndpointInfo): boolean {
176
304
  return false;
177
305
  }
178
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
+
179
347
  // ──────────────────────────────────────────────
180
348
  // Public API
181
349
  // ──────────────────────────────────────────────
@@ -191,6 +359,7 @@ export function generateStep(
191
359
 
192
360
  const step: RawStep = {
193
361
  name,
362
+ source: buildStepSource(ep),
194
363
  [method]: path,
195
364
  expect: {
196
365
  status: getExpectedStatus(ep),
@@ -205,6 +374,12 @@ export function generateStep(
205
374
  if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
206
375
  if (ep.requestBodyContentType === "multipart/form-data") {
207
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));
208
383
  } else {
209
384
  step.json = generateFromSchema(ep.requestBodySchema);
210
385
  }
@@ -223,33 +398,163 @@ export function generateStep(
223
398
  return step;
224
399
  }
225
400
 
226
- /** 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. */
227
438
  export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
439
+ return detectCrudGroupsWithDiagnostics(endpoints).groups;
440
+ }
441
+
442
+ export function detectCrudGroupsWithDiagnostics(
443
+ endpoints: EndpointInfo[],
444
+ ): DetectCrudResult {
228
445
  const groups: CrudGroup[] = [];
229
- 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
+ );
230
450
 
231
451
  for (const createEp of postEndpoints) {
232
- const basePath = createEp.path;
452
+ const basePath = stripTrailingSlash(createEp.path);
453
+ const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
233
454
 
234
- // Find item endpoints: basePath/{param}
235
- const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}$`);
236
- const itemEndpoints = endpoints.filter(ep => !ep.deprecated && itemPattern.test(ep.path));
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
+ );
237
462
 
238
- if (itemEndpoints.length === 0) continue;
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
+ }
239
497
 
240
- const itemPath = itemEndpoints[0]!.path;
241
- const idMatch = itemPath.match(/\{([^}]+)\}$/);
242
- if (!idMatch) continue;
243
- const idParam = idMatch[1]!;
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
+ );
244
510
 
245
- const read = itemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
246
- if (!read) continue; // Minimum: POST + GET/{id}
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
+ };
247
522
 
248
- const update = itemEndpoints.find(ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()));
249
- const del = itemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
250
- const list = endpoints.find(ep => ep.method.toUpperCase() === "GET" && ep.path === basePath && !ep.deprecated);
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
+ }
251
538
 
252
- const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
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]!;
547
+
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);
253
558
 
254
559
  groups.push({
255
560
  resource,
@@ -264,7 +569,7 @@ export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
264
569
  });
265
570
  }
266
571
 
267
- return groups;
572
+ return { groups, diagnostics };
268
573
  }
269
574
 
270
575
  /** Generate a CRUD chain suite from a CrudGroup */
@@ -272,8 +577,16 @@ export function generateCrudSuite(
272
577
  group: CrudGroup,
273
578
  securitySchemes: SecuritySchemeInfo[],
274
579
  ): RawSuite {
275
- const captureField = group.create ? getCaptureField(group.create) : "id";
276
- 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");
277
590
  const tests: RawStep[] = [];
278
591
 
279
592
  const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
@@ -298,7 +611,8 @@ export function generateCrudSuite(
298
611
  // 2. Read created
299
612
  if (group.read) {
300
613
  const step: RawStep = {
301
- 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),
302
616
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
303
617
  expect: {
304
618
  status: getExpectedStatus(group.read),
@@ -312,12 +626,13 @@ export function generateCrudSuite(
312
626
  if (group.update) {
313
627
  const method = group.update.method.toUpperCase();
314
628
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
315
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
629
+ const etagVar = resourceVar(group.resource, "etag");
316
630
 
317
631
  // If endpoint requires ETag (optimistic locking), capture it from a GET step first
318
632
  if (group.update.requiresEtag && group.read) {
319
633
  tests.push({
320
- name: `Get ETag before update ${group.resource.replace(/s$/, "")}`,
634
+ name: `Get ETag before update ${singularizeResource(group.resource)}`,
635
+ source: buildStepSource(group.read),
321
636
  GET: itemPath,
322
637
  expect: {
323
638
  status: getExpectedStatus(group.read),
@@ -327,7 +642,8 @@ export function generateCrudSuite(
327
642
  }
328
643
 
329
644
  const step: RawStep = {
330
- name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
645
+ name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
646
+ source: buildStepSource(group.update),
331
647
  [method]: itemPath,
332
648
  expect: {
333
649
  status: getExpectedStatus(group.update),
@@ -345,13 +661,14 @@ export function generateCrudSuite(
345
661
  // 4. Delete
346
662
  if (group.delete) {
347
663
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
348
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
664
+ const etagVar = resourceVar(group.resource, "etag");
349
665
 
350
666
  // If delete requires ETag and update didn't already capture it, add a GET step
351
667
  const updateAlreadyCapturedEtag = group.update?.requiresEtag;
352
668
  if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
353
669
  tests.push({
354
- name: `Get ETag before delete ${group.resource.replace(/s$/, "")}`,
670
+ name: `Get ETag before delete ${singularizeResource(group.resource)}`,
671
+ source: buildStepSource(group.read),
355
672
  GET: itemPath,
356
673
  expect: {
357
674
  status: getExpectedStatus(group.read),
@@ -360,9 +677,12 @@ export function generateCrudSuite(
360
677
  });
361
678
  }
362
679
 
680
+ // T44: cleanup must run even if earlier assertions failed (tainted captures)
363
681
  const step: RawStep = {
364
- name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
682
+ name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
683
+ source: buildStepSource(group.delete),
365
684
  DELETE: itemPath,
685
+ always: true,
366
686
  expect: {
367
687
  status: getExpectedStatus(group.delete),
368
688
  },
@@ -372,11 +692,13 @@ export function generateCrudSuite(
372
692
  }
373
693
  tests.push(step);
374
694
 
375
- // 5. Verify deleted
695
+ // 5. Verify deleted — also always, so we confirm cleanup happened
376
696
  if (group.read) {
377
697
  tests.push({
378
- name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
698
+ name: `Verify ${singularizeResource(group.resource)} deleted`,
699
+ source: buildStepSource(group.read, 404),
379
700
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
701
+ always: true,
380
702
  expect: {
381
703
  status: 404,
382
704
  },
@@ -384,9 +706,13 @@ export function generateCrudSuite(
384
706
  }
385
707
  }
386
708
 
709
+ // T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
710
+ // in its starting state (ephemeral); without DELETE it leaves residual data.
711
+ const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
712
+
387
713
  const suite: RawSuite = {
388
714
  name: `${group.resource}-crud`,
389
- tags: ["crud"],
715
+ tags: ["crud", cleanupTag],
390
716
  fileStem: `crud-${slugify(group.resource)}`,
391
717
  base_url: "{{base_url}}",
392
718
  tests,
@@ -563,7 +889,7 @@ function generateConsistentAuthSuite(
563
889
  }
564
890
 
565
891
  /** Generate 1-2 minimal tests for quick connectivity and auth validation */
566
- export function generateSanitySuite(opts: {
892
+ function generateSanitySuite(opts: {
567
893
  authEndpoints: EndpointInfo[];
568
894
  nonAuthGetEndpoints: EndpointInfo[];
569
895
  securitySchemes: SecuritySchemeInfo[];
@@ -600,11 +926,22 @@ export function generateSanitySuite(opts: {
600
926
  export function generateSuites(opts: {
601
927
  endpoints: EndpointInfo[];
602
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;
603
938
  }): RawSuite[] {
604
- const { endpoints, securitySchemes } = opts;
939
+ const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
940
+ _suiteDefaultAuthVar = defaultAuthVar ?? null;
605
941
 
606
- // Filter deprecated
607
- 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);
608
945
 
609
946
  // Separate auth endpoints
610
947
  const authEndpoints = active.filter(isAuthEndpoint);
@@ -634,22 +971,81 @@ export function generateSuites(opts: {
634
971
  for (const [tag, tagEndpoints] of byTag) {
635
972
  const tagSlug = slugify(tag) || "api";
636
973
 
637
- // GET endpoints → smoke suite (use seed values for path params — no capture context)
974
+ // GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
638
975
  const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
639
- if (getEndpoints.length > 0) {
640
- const tests = getEndpoints.map(ep => {
976
+ const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
977
+ const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
978
+
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 => {
641
987
  const step = generateStep(ep, securitySchemes);
642
- // Replace path param placeholders with seed values so the suite runs out of the box
643
988
  const seededPath = convertPathWithSeeds(ep.path, ep);
644
989
  (step as any)[ep.method.toUpperCase()] = seededPath;
645
990
  return step;
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"];
1013
+
1014
+ const suite: RawSuite = {
1015
+ name: `${tagSlug}-smoke-positive`,
1016
+ tags,
1017
+ fileStem: `smoke-${tagSlug}-positive`,
1018
+ base_url: "{{base_url}}",
1019
+ tests: positiveTests,
1020
+ };
1021
+
1022
+ if (headers) {
1023
+ suite.headers = headers;
1024
+ for (const t of positiveTests) {
1025
+ if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
1026
+ delete (t as any).headers;
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ suites.push(suite);
1032
+ }
1033
+
1034
+ // Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
1035
+ if (pathParamGets.length > 0) {
1036
+ const tests = pathParamGets.map(ep => {
1037
+ const step = generateStep(ep, securitySchemes);
1038
+ (step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
1039
+ // Negative path: resource doesn't exist. Drop body assertions (response shape varies).
1040
+ step.expect = { status: [400, 404, 422] };
1041
+ return step;
646
1042
  });
647
- const headers = getSuiteHeaders(getEndpoints, securitySchemes);
1043
+ const headers = getSuiteHeaders(pathParamGets, securitySchemes);
648
1044
 
649
1045
  const suite: RawSuite = {
650
- name: `${tagSlug}-smoke`,
651
- tags: ["smoke"],
652
- fileStem: `smoke-${tagSlug}`,
1046
+ name: `${tagSlug}-smoke-negative`,
1047
+ tags: ["smoke", "negative"],
1048
+ fileStem: `smoke-${tagSlug}-negative`,
653
1049
  base_url: "{{base_url}}",
654
1050
  tests,
655
1051
  };
@@ -737,5 +1133,16 @@ export function generateSuites(opts: {
737
1133
  const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
738
1134
  const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
739
1135
 
740
- 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;
741
1148
  }