@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -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 +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -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 +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -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 +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -1,16 +1,63 @@
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 { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
4
+ import type { SourceMetadata } from "../parser/types.ts";
5
+ import { generateFromSchema, generateMultipartFromSchema, isFkFixtureField, canonicalVarName, effectiveObjectShape } 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
 
11
- /** Convert OpenAPI path params {param} to test interpolation {{param}} */
12
- function convertPath(path: string): string {
13
- return path.replace(/\{([^}]+)\}/g, "{{$1}}");
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
+
51
+ /** Convert OpenAPI path params {param} to test interpolation {{param}}.
52
+ * When `ambiguous` is given (ARV-369), a param name reused across
53
+ * distinct resources becomes a resource-scoped var (`{{templates_code}}`)
54
+ * instead of the raw name, so `.env.yaml` can hold distinct values per
55
+ * resource instead of one value silently applying everywhere. */
56
+ function convertPath(path: string, ambiguous?: Set<string>): string {
57
+ if (!ambiguous || ambiguous.size === 0) {
58
+ return path.replace(/\{([^}]+)\}/g, "{{$1}}");
59
+ }
60
+ return path.replace(/\{([^}]+)\}/g, (_, name) => `{{${fixtureVarNameForPathParam(path, name, ambiguous)}}}`);
14
61
  }
15
62
 
16
63
  /**
@@ -56,6 +103,75 @@ function slugify(s: string): string {
56
103
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
57
104
  }
58
105
 
106
+ /** Strip trailing API-version path segments (`v1`, `v30`, `version2`) so
107
+ * resource-name derivation doesn't mistake a version marker for the
108
+ * resource itself on APIs shaped `/api/<resource>/v{N}` (ARV-372). */
109
+ export function stripTrailingVersionSegments(segments: string[]): string[] {
110
+ const out = [...segments];
111
+ while (out.length > 1 && /^v(ersion)?\d+$/i.test(out[out.length - 1]!)) {
112
+ out.pop();
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Owning collection for a path-param: the last non-version path segment
118
+ * before its `{name}` placeholder. Used to disambiguate a path-param name
119
+ * (`code`, `id`, `key`) that's reused across genuinely distinct resources
120
+ * (ARV-369) — e.g. `/api/templates/v30/{code}` → "templates" vs
121
+ * `/api/macros/v30/{code}` → "macros". */
122
+ // ARV-376: read-by-id accessor markers — kept in sync with the set in
123
+ // path-param-disambig.ts. `/business-segment20/byid/{id}` is owned by
124
+ // `business-segment20`, not the `byid` accessor verb.
125
+ const ACCESSOR_MARKER_SEGS = new Set(["byid", "by-id", "by_id"]);
126
+
127
+ export function owningCollectionForPathParam(path: string, paramName: string): string | null {
128
+ const marker = `{${paramName}}`;
129
+ const idx = path.indexOf(marker);
130
+ if (idx === -1) return null;
131
+ const segments = stripTrailingVersionSegments(path.slice(0, idx).split("/").filter(Boolean));
132
+ // Skip trailing accessor markers so the owner is the collection, not the
133
+ // `byid`/`by-code` verb — otherwise a param behind `/byid/{id}` and its
134
+ // sibling `DELETE /{id}` disagree on owner and get double-scoped.
135
+ let end = segments.length - 1;
136
+ while (end >= 0 && ACCESSOR_MARKER_SEGS.has(segments[end]!.toLowerCase())) end--;
137
+ return end >= 0 ? segments[end]! : null;
138
+ }
139
+
140
+ /** Path-param names reused by more than one distinct owning collection
141
+ * across the whole spec — these need a resource-scoped fixture var name
142
+ * (`templates_code`) instead of the raw name (`code`), otherwise one
143
+ * `.env.yaml` value silently applies to unrelated resources (ARV-369). */
144
+ export function computeAmbiguousPathParams(endpoints: EndpointInfo[]): Set<string> {
145
+ const owners = new Map<string, Set<string>>();
146
+ for (const ep of endpoints) {
147
+ for (const p of ep.parameters) {
148
+ if (p.in !== "path" || p.required === false) continue;
149
+ const owner = owningCollectionForPathParam(ep.path, p.name) ?? "";
150
+ if (!owners.has(p.name)) owners.set(p.name, new Set());
151
+ owners.get(p.name)!.add(owner);
152
+ }
153
+ }
154
+ const ambiguous = new Set<string>();
155
+ for (const [name, ownerSet] of owners) {
156
+ if (ownerSet.size > 1) ambiguous.add(name);
157
+ }
158
+ return ambiguous;
159
+ }
160
+
161
+ /** Resource-scoped fixture var name for a path-param, when ambiguous;
162
+ * otherwise the raw param name (single-owner APIs — the common case —
163
+ * keep producing the same var names as before ARV-369). */
164
+ export function fixtureVarNameForPathParam(
165
+ path: string,
166
+ paramName: string,
167
+ ambiguous: Set<string>,
168
+ ): string {
169
+ if (!ambiguous.has(paramName)) return paramName;
170
+ const owner = owningCollectionForPathParam(path, paramName);
171
+ if (!owner) return paramName;
172
+ return `${owner.replace(/[^a-zA-Z0-9]+/g, "_")}_${paramName}`;
173
+ }
174
+
59
175
  function escapeRegex(s: string): string {
60
176
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
177
  }
@@ -73,11 +189,32 @@ function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefin
73
189
  );
74
190
  }
75
191
 
192
+ /**
193
+ * Pick the success status the test should assert.
194
+ *
195
+ * Order:
196
+ * 1. First 2xx declared in the spec (most authoritative).
197
+ * 2. Method-aware default when the spec lists only non-2xx responses or none
198
+ * at all (many OpenAPI specs is silent for several mutating endpoints — the
199
+ * actual runtime returns 201/204, while the old default of 200 caused
200
+ * tests to fail at runtime). We never assert a 4xx/5xx as the success
201
+ * status — that would generate guaranteed-failing tests.
202
+ */
76
203
  function getExpectedStatus(ep: EndpointInfo): number {
77
204
  const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
78
205
  if (success) return success.statusCode;
79
- if (ep.responses.length > 0) return ep.responses[0]!.statusCode;
80
- return 200;
206
+ return defaultStatusByMethod(ep.method);
207
+ }
208
+
209
+ function defaultStatusByMethod(method: string): number {
210
+ switch (method.toUpperCase()) {
211
+ case "POST":
212
+ return 201;
213
+ case "DELETE":
214
+ return 204;
215
+ default:
216
+ return 200;
217
+ }
81
218
  }
82
219
 
83
220
  function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
@@ -105,7 +242,7 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
105
242
  }
106
243
 
107
244
  /** Derive a variable name for a security scheme's token */
108
- function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
245
+ export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
109
246
  // Count how many bearer-like schemes exist
110
247
  const bearerSchemes = allSchemes.filter(s =>
111
248
  (s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
@@ -122,29 +259,7 @@ function getAuthHeaders(
122
259
  ep: EndpointInfo,
123
260
  schemes: SecuritySchemeInfo[],
124
261
  ): 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;
262
+ return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
148
263
  }
149
264
 
150
265
  function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
@@ -172,23 +287,95 @@ function getSuiteHeaders(
172
287
 
173
288
  const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
174
289
  const first = headerSets[0];
175
- if (!first) return undefined;
290
+ if (!first) {
291
+ // ARV-212 (R13/F16): spec has no securitySchemes (GitHub publishes its
292
+ // OpenAPI this way) so per-endpoint auth-header derivation returns
293
+ // undefined for every step. When the API workspace nonetheless wires
294
+ // `auth_token` end-to-end (ARV-201 seeds it in .env.yaml on bare specs,
295
+ // and zond request / runner auto-attach Authorization: Bearer when
296
+ // auth_token is present), generated suites should not silently go
297
+ // unauth — that bricks them on the first rate-limited 60 requests.
298
+ // Fall back to a generic Bearer header at the suite level. The header
299
+ // is harmless when .secrets.yaml.auth_token is empty (zond runner
300
+ // still substitutes `{{auth_token}}` to an empty string, just like
301
+ // before; the server then 401s — same outcome as today).
302
+ if (schemes.length === 0 && _suiteDefaultAuthVar !== null) {
303
+ return { Authorization: `Bearer {{${_suiteDefaultAuthVar}}}` };
304
+ }
305
+ return undefined;
306
+ }
176
307
 
177
308
  const firstJson = JSON.stringify(first);
178
309
  const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
179
310
  return allSame ? first : undefined;
180
311
  }
181
312
 
182
- /** Find the best field to capture from POST response (for CRUD chains) */
183
- function getCaptureField(ep: EndpointInfo): string {
313
+ // ARV-212 (R13/F16): generator-level "the caller wired auth_token in
314
+ // .env.yaml even though the spec has no securitySchemes" hint. Set at the
315
+ // top of generateSuites and consulted by getSuiteHeaders / generateCrudSuite
316
+ // / generateSanitySuite. Module-scoped to avoid threading through ~7 call
317
+ // sites. Always reset to null at the end of generateSuites so the helper
318
+ // stays stateless from the caller's perspective.
319
+ let _suiteDefaultAuthVar: string | null = null;
320
+
321
+ /** Same module-scoping rationale as `_suiteDefaultAuthVar` above: computed
322
+ * once from the full endpoint list at the top of generateSuites (ARV-369
323
+ * needs the whole spec to detect a param-name collision), consulted by
324
+ * `generateStep`'s path conversion. Reset to empty at the end of
325
+ * generateSuites. */
326
+ let _ambiguousPathParams: Set<string> = new Set();
327
+
328
+ /** Common id-like field names looked up after `id` itself.
329
+ * TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
330
+ * or `name` instead of an `id` field on create responses. Without these,
331
+ * CRUD chains fall back to capturing `"id"` from a body that doesn't have
332
+ * one, breaking the `{id}` substitution in follow-up reads. */
333
+ const ID_LIKE_NAMES = ["slug", "uuid", "key", "version", "name"];
334
+
335
+ /** Find the best field to capture from POST response (for CRUD chains).
336
+ *
337
+ * Priority:
338
+ * 1. Field whose name matches the path-param (e.g. `{rule_id}` → `rule_id`
339
+ * or `{slug}` → `slug`). The path-param name is the strongest hint —
340
+ * whatever the response calls "the id of this resource" is what gets
341
+ * interpolated back into the read/update/delete URLs.
342
+ * 2. `id` (most common case).
343
+ * 3. Conventional id-like names: `slug`, `uuid`, `key`, `version`, `name`
344
+ * — but only if they are typed as a string (avoids capturing a `name`
345
+ * object on resources that nest metadata).
346
+ * 4. Any field with `type: integer` or `format: uuid`.
347
+ * 5. Fallback: `"id"` (the YAML capture will simply be empty if absent —
348
+ * the runner already handles this gracefully).
349
+ */
350
+ function getCaptureField(ep: EndpointInfo, idParam?: string): string {
184
351
  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;
352
+ const props = schema?.properties;
353
+ if (!props) return "id";
354
+
355
+ // 1. Path-param name match.
356
+ if (idParam) {
357
+ if (idParam in props) return idParam;
358
+ // Also try the conventional `<resource>_id` ↔ `id` swap.
359
+ if (idParam.endsWith("_id") && "id" in props) return "id";
360
+ }
361
+
362
+ // 2. Plain `id`.
363
+ if ("id" in props) return "id";
364
+
365
+ // 3. Conventional id-like names (string-typed only).
366
+ for (const candidate of ID_LIKE_NAMES) {
367
+ if (candidate in props) {
368
+ const s = props[candidate] as OpenAPIV3.SchemaObject;
369
+ if (s.type === "string") return candidate;
190
370
  }
191
371
  }
372
+
373
+ // 4. Any integer or uuid-shaped field.
374
+ for (const [name, propSchema] of Object.entries(props)) {
375
+ const s = propSchema as OpenAPIV3.SchemaObject;
376
+ if (s.type === "integer" || s.format === "uuid") return name;
377
+ }
378
+
192
379
  return "id";
193
380
  }
194
381
 
@@ -200,10 +387,86 @@ function isAuthEndpoint(ep: EndpointInfo): boolean {
200
387
  return false;
201
388
  }
202
389
 
390
+ // ──────────────────────────────────────────────
391
+ // Provenance helpers
392
+ // ──────────────────────────────────────────────
393
+
394
+ function escapeJsonPointerSegment(s: string): string {
395
+ return s.replace(/~/g, "~0").replace(/\//g, "~1");
396
+ }
397
+
398
+ function pickPrimaryStatus(status: number | number[]): number {
399
+ return Array.isArray(status) ? (status[0] ?? 200) : status;
400
+ }
401
+
402
+ /** Build step-level provenance for an endpoint + chosen response status. */
403
+ export function buildStepSource(
404
+ ep: EndpointInfo,
405
+ statusOverride?: number | number[],
406
+ ): SourceMetadata {
407
+ const method = ep.method.toUpperCase();
408
+ const status = statusOverride ?? getExpectedStatus(ep);
409
+ const primary = pickPrimaryStatus(status);
410
+ const responseBranch = Array.isArray(status) ? status.map(String).join("|") : String(status);
411
+ const escapedPath = escapeJsonPointerSegment(ep.path);
412
+ return {
413
+ endpoint: `${method} ${ep.path}`,
414
+ response_branch: responseBranch,
415
+ schema_pointer: `#/paths/${escapedPath}/${method.toLowerCase()}/responses/${primary}`,
416
+ };
417
+ }
418
+
419
+ /** Build suite-level provenance for an openapi-generated suite. */
420
+ export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | undefined {
421
+ if (!specPath) return undefined;
422
+ return {
423
+ type: "openapi-generated",
424
+ spec: specPath,
425
+ generator: "zond-generate",
426
+ generated_at: new Date().toISOString(),
427
+ };
428
+ }
429
+
203
430
  // ──────────────────────────────────────────────
204
431
  // Public API
205
432
  // ──────────────────────────────────────────────
206
433
 
434
+ /**
435
+ * ARV-45: swap FK-shaped required body fields for `{{fixture}}` references so
436
+ * their values come from `.env.yaml` instead of `{{$randomString}}`/`{{$uuid}}`
437
+ * junk that 400s and kills the CRUD chain at step 1. Field names that motivated
438
+ * this — `sequenceTypeCode`, `templateGroupCode` — are closed-vocab codes the
439
+ * generator can't guess. The var name is canonicalised (`sequence_type_code`)
440
+ * to match the manifest entry (fixtures-builder step 5); the HTTP body key
441
+ * keeps the raw spec spelling. Recurses into nested objects for nested FKs.
442
+ *
443
+ * Keep the detection predicate (`isFkFixtureField`) in lockstep with the
444
+ * manifest builder — every var the tests reference must appear in the manifest
445
+ * (decision-7).
446
+ */
447
+ export function wireBodyFkRefs(schema: OpenAPIV3.SchemaObject, body: unknown): unknown {
448
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
449
+ // `effectiveObjectShape` merges allOf so allOf-wrapped bodies (the .NET/
450
+ // Swagger norm) resolve their properties — a direct schema.properties read
451
+ // misses every FK on those specs.
452
+ const { properties, required } = effectiveObjectShape(schema);
453
+ const out = body as Record<string, unknown>;
454
+ for (const [name, propSchema] of Object.entries(properties)) {
455
+ if (!(name in out)) continue;
456
+ if (required.has(name) && isFkFixtureField(name, propSchema)) {
457
+ out[name] = `{{${canonicalVarName(name)}}}`;
458
+ } else if (out[name] && typeof out[name] === "object" && !Array.isArray(out[name])) {
459
+ out[name] = wireBodyFkRefs(propSchema, out[name]);
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ /** Generate a request body from a schema with FK fields wired to fixtures. */
466
+ function generateBody(schema: OpenAPIV3.SchemaObject): unknown {
467
+ return wireBodyFkRefs(schema, generateFromSchema(schema));
468
+ }
469
+
207
470
  /** Generate a single test step from an EndpointInfo */
208
471
  export function generateStep(
209
472
  ep: EndpointInfo,
@@ -211,10 +474,11 @@ export function generateStep(
211
474
  ): RawStep {
212
475
  const method = ep.method.toUpperCase();
213
476
  const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
214
- const path = convertPath(ep.path);
477
+ const path = convertPath(ep.path, _ambiguousPathParams);
215
478
 
216
479
  const step: RawStep = {
217
480
  name,
481
+ source: buildStepSource(ep),
218
482
  [method]: path,
219
483
  expect: {
220
484
  status: getExpectedStatus(ep),
@@ -229,8 +493,14 @@ export function generateStep(
229
493
  if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
230
494
  if (ep.requestBodyContentType === "multipart/form-data") {
231
495
  step.multipart = generateMultipartFromSchema(ep.requestBodySchema);
496
+ } else if (ep.requestBodyContentType === "application/x-www-form-urlencoded") {
497
+ // ARV-149: form-encoded endpoints (Stripe v1 et al.) — emit `form:` so
498
+ // the runner posts URL-encoded bodies with bracket notation. Without
499
+ // this, generate baked `json:` blocks and every POST 400'd with
500
+ // "wrong content type".
501
+ step.form = flattenToFormFields(generateBody(ep.requestBodySchema));
232
502
  } else {
233
- step.json = generateFromSchema(ep.requestBodySchema);
503
+ step.json = generateBody(ep.requestBodySchema);
234
504
  }
235
505
  }
236
506
 
@@ -247,33 +517,173 @@ export function generateStep(
247
517
  return step;
248
518
  }
249
519
 
250
- /** Detect CRUD groups from a list of endpoints */
520
+ /** Strip a single trailing slash for comparison purposes. We never rewrite
521
+ * endpoint paths in the spec — we just normalise the matching regex so
522
+ * `POST /alerts/` + `GET /alerts/{id}/` lines up the same as the no-slash
523
+ * variant. */
524
+ function stripTrailingSlash(p: string): string {
525
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
526
+ }
527
+
528
+ /** Per-resource diagnostic record used by `zond generate --explain`.
529
+ * Captures every POST candidate the detector considered and the verdict
530
+ * with a human reason — so users can see "I have a CRUD-looking pair, why
531
+ * didn't generate emit a chain?" without grepping the spec. */
532
+ export interface CrudDetectionDiagnostic {
533
+ resource: string;
534
+ basePath: string;
535
+ postPath: string;
536
+ hasGetById: boolean;
537
+ hasUpdate: boolean;
538
+ hasDelete: boolean;
539
+ hasList: boolean;
540
+ verdict: "chain" | "skipped";
541
+ reason: string;
542
+ }
543
+
544
+ export interface DetectCrudResult {
545
+ groups: CrudGroup[];
546
+ diagnostics: CrudDetectionDiagnostic[];
547
+ }
548
+
549
+ /** Detect CRUD groups from a list of endpoints.
550
+ *
551
+ * Match logic (TASK-139):
552
+ * - basePath = POST endpoint's path with any trailing slash trimmed.
553
+ * - item path = `<basePath>/{param}` with optional trailing slash.
554
+ * This catches common SaaS-style `POST /alert-rules/` + `GET /alert-rules/{id}/`
555
+ * pairs that previously fell through because the regex required the same
556
+ * slash form on both. */
251
557
  export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
558
+ return detectCrudGroupsWithDiagnostics(endpoints).groups;
559
+ }
560
+
561
+ export function detectCrudGroupsWithDiagnostics(
562
+ endpoints: EndpointInfo[],
563
+ ): DetectCrudResult {
252
564
  const groups: CrudGroup[] = [];
253
- const postEndpoints = endpoints.filter(ep => ep.method.toUpperCase() === "POST" && !ep.deprecated);
565
+ const diagnostics: CrudDetectionDiagnostic[] = [];
566
+ const postEndpoints = endpoints.filter(
567
+ ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
568
+ );
254
569
 
255
570
  for (const createEp of postEndpoints) {
256
- const basePath = createEp.path;
571
+ const basePath = stripTrailingSlash(createEp.path);
572
+ // ARV-372: skip trailing version segments (`v1`, `v30`) before taking the
573
+ // last segment as the resource name — otherwise every collection on a
574
+ // spec shaped `/api/<resource>/v{N}` (a common convention) resolves to
575
+ // the SAME resource name ("v30"), and multiple CRUD suites collide on
576
+ // the same `crud-<resource>.yaml` output filename, silently overwriting
577
+ // each other.
578
+ const resource = stripTrailingVersionSegments(basePath.split("/").filter(Boolean)).pop() ?? "resource";
579
+
580
+ // Match `<basePath>/{param}` with optional trailing slash. Tolerates
581
+ // both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
582
+ // `GET /alerts/{id}/`, which some real-world specs mix.
583
+ const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}/?$`);
584
+ const itemEndpoints = endpoints.filter(
585
+ ep => !ep.deprecated && itemPattern.test(ep.path),
586
+ );
257
587
 
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));
588
+ // Fallback for "subdomain"/nested-item routing (common SaaS-style):
589
+ // create lives under one root (`/api/0/organizations/{org}/teams/`)
590
+ // but item-path lives under another (`/api/0/teams/{org}/{team}/`).
591
+ // The strict basePath/{id} regex misses these. Match instead by:
592
+ // 1. shared OpenAPI tag with the create operation,
593
+ // 2. terminal {param} matching the singular form of the resource
594
+ // (`{team}` / `{team_id}` / `{team_id_or_slug}`).
595
+ let resolvedItemEndpoints = itemEndpoints;
596
+ if (resolvedItemEndpoints.length === 0) {
597
+ const singular = singularizeResource(resource).toLowerCase();
598
+ const itemTerminalRe = /\{([^}]+)\}\/?$/;
599
+ const matchesResourceParam = (p: string) => {
600
+ const m = p.match(itemTerminalRe);
601
+ if (!m) return false;
602
+ const param = m[1]!.toLowerCase();
603
+ return (
604
+ param === singular ||
605
+ param === `${singular}_id` ||
606
+ param === `${singular}_id_or_slug` ||
607
+ param === `${singular}_slug`
608
+ );
609
+ };
610
+ const createTags = new Set(createEp.tags ?? []);
611
+ const sharedTag = (ep: EndpointInfo) =>
612
+ (ep.tags ?? []).some(t => createTags.has(t));
613
+
614
+ resolvedItemEndpoints = endpoints.filter(
615
+ ep =>
616
+ !ep.deprecated &&
617
+ ep.path !== createEp.path &&
618
+ matchesResourceParam(ep.path) &&
619
+ sharedTag(ep),
620
+ );
621
+ }
261
622
 
262
- if (itemEndpoints.length === 0) continue;
623
+ const read = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
624
+ const update = resolvedItemEndpoints.find(
625
+ ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
626
+ );
627
+ const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
628
+ // List endpoint matches with the same trailing-slash tolerance. ARV-376:
629
+ // also accept a `/list`, `/search`, `/find` verb suffix on the base path
630
+ // (`GET /api/deal-kind20/list`) — common in RPC-flavoured REST specs
631
+ // where the bare collection path has no GET.
632
+ const listStems = [basePath, `${basePath}/list`, `${basePath}/search`, `${basePath}/find`];
633
+ const list = endpoints.find(
634
+ ep =>
635
+ ep.method.toUpperCase() === "GET" &&
636
+ listStems.includes(stripTrailingSlash(ep.path)) &&
637
+ !ep.deprecated,
638
+ );
263
639
 
264
- const itemPath = itemEndpoints[0]!.path;
265
- const idMatch = itemPath.match(/\{([^}]+)\}$/);
266
- if (!idMatch) continue;
267
- const idParam = idMatch[1]!;
640
+ const diag: CrudDetectionDiagnostic = {
641
+ resource,
642
+ basePath,
643
+ postPath: createEp.path,
644
+ hasGetById: !!read,
645
+ hasUpdate: !!update,
646
+ hasDelete: !!del,
647
+ hasList: !!list,
648
+ verdict: "skipped",
649
+ reason: "",
650
+ };
268
651
 
269
- const read = itemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
270
- if (!read) continue; // Minimum: POST + GET/{id}
652
+ if (resolvedItemEndpoints.length === 0) {
653
+ diag.reason = `no item endpoint matching ${basePath}/{...}`;
654
+ diagnostics.push(diag);
655
+ continue;
656
+ }
657
+ // TASK-260: accept headless chains — POST + (GET/PUT/PATCH/DELETE on /{id}).
658
+ // Resources with no GET-by-id (e.g. external-teams, some user-binding endpoints)
659
+ // were previously skipped entirely, even though POST captures the ID and PUT/DELETE
660
+ // can drive the chain on their own. The Read/Verify steps in the suite generator
661
+ // are already conditional on `group.read`, so headless chains generate cleanly.
662
+ if (!read && !update && !del) {
663
+ diag.reason = "item endpoint exists but no GET/PUT/PATCH/DELETE on /{id}";
664
+ diagnostics.push(diag);
665
+ continue;
666
+ }
271
667
 
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);
668
+ const itemPath = resolvedItemEndpoints[0]!.path;
669
+ const idMatch = itemPath.match(/\{([^}]+)\}\/?$/);
670
+ if (!idMatch) {
671
+ diag.reason = "item path has no terminal {param}";
672
+ diagnostics.push(diag);
673
+ continue;
674
+ }
675
+ const idParam = idMatch[1]!;
275
676
 
276
- const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
677
+ diag.verdict = "chain";
678
+ if (read) {
679
+ diag.reason = "POST + GET/{id} matched";
680
+ } else {
681
+ // TASK-260: explicit headless reason so `--explain` differentiates the
682
+ // two chain shapes — useful for debugging fixture flow.
683
+ const partner = update ? `${update.method.toUpperCase()}/{id}` : `DELETE/{id}`;
684
+ diag.reason = `POST + ${partner} matched (headless: no GET-by-id)`;
685
+ }
686
+ diagnostics.push(diag);
277
687
 
278
688
  groups.push({
279
689
  resource,
@@ -288,16 +698,44 @@ export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
288
698
  });
289
699
  }
290
700
 
291
- return groups;
701
+ return { groups, diagnostics };
292
702
  }
293
703
 
294
704
  /** Generate a CRUD chain suite from a CrudGroup */
705
+ /** ARV-368: does the create's success response actually carry the capture
706
+ * field? If not — a 204 no-body create, or a response schema without the id —
707
+ * the runtime capture is empty and `{{captureVar}}` falls back to the
708
+ * read-fixture of the SAME name (ARV-137 deliberately shares it). A PUT/DELETE
709
+ * then targets a *pre-existing* resource whose id the user harvested for read
710
+ * coverage — silent data-loss. Gate mutating chain steps on this so the suite
711
+ * can only ever update/delete what it actually captured, never live data. */
712
+ function createCapturesId(
713
+ create: EndpointInfo | undefined,
714
+ captureField: string,
715
+ ): boolean {
716
+ if (!create) return false;
717
+ const props = getSuccessSchema(create)?.properties;
718
+ return !!props && captureField in props;
719
+ }
720
+
295
721
  export function generateCrudSuite(
296
722
  group: CrudGroup,
297
723
  securitySchemes: SecuritySchemeInfo[],
298
724
  ): RawSuite {
299
- const captureField = group.create ? getCaptureField(group.create) : "id";
300
- const captureVar = `${group.resource.replace(/s$/, "")}_id`;
725
+ const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
726
+ // ARV-368: only chain PUT/DELETE when the create response yields the id we'd
727
+ // capture. Otherwise the capture is empty at runtime and the mutating step
728
+ // falls back to the shared read-fixture → deletes/overwrites pre-existing data.
729
+ const canChainMutations = createCapturesId(group.create, captureField);
730
+ // ARV-137: use the spec's path-param name as the capture var. Previously
731
+ // we synthesised `<resource>_id` via `resourceVar(...)`, which produced
732
+ // phantom manifest dupes whenever the spec named the path-param anything
733
+ // other than `<resource>_id` (e.g. `monitor_id_or_slug`, `version`, or
734
+ // collection-stem mismatches like resource=`saved`/idParam=`query_id`).
735
+ // Aligning on `group.idParam` keeps tests, manifest, and spec consistent.
736
+ // Fallback to `resourceVar` only when the group has no idParam (defensive
737
+ // — shouldn't happen for any group with a read/update/delete endpoint).
738
+ const captureVar = group.idParam || resourceVar(group.resource, "id");
301
739
  const tests: RawStep[] = [];
302
740
 
303
741
  const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
@@ -322,7 +760,8 @@ export function generateCrudSuite(
322
760
  // 2. Read created
323
761
  if (group.read) {
324
762
  const step: RawStep = {
325
- name: group.read.operationId ?? `Read created ${group.resource.replace(/s$/, "")}`,
763
+ name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
764
+ source: buildStepSource(group.read),
326
765
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
327
766
  expect: {
328
767
  status: getExpectedStatus(group.read),
@@ -332,16 +771,18 @@ export function generateCrudSuite(
332
771
  tests.push(step);
333
772
  }
334
773
 
335
- // 3. Update
336
- if (group.update) {
774
+ // 3. Update (ARV-368: only if the chain self-captures its id — else the
775
+ // fixture-fallback would overwrite a pre-existing resource)
776
+ if (group.update && canChainMutations) {
337
777
  const method = group.update.method.toUpperCase();
338
778
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
339
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
779
+ const etagVar = resourceVar(group.resource, "etag");
340
780
 
341
781
  // If endpoint requires ETag (optimistic locking), capture it from a GET step first
342
782
  if (group.update.requiresEtag && group.read) {
343
783
  tests.push({
344
- name: `Get ETag before update ${group.resource.replace(/s$/, "")}`,
784
+ name: `Get ETag before update ${singularizeResource(group.resource)}`,
785
+ source: buildStepSource(group.read),
345
786
  GET: itemPath,
346
787
  expect: {
347
788
  status: getExpectedStatus(group.read),
@@ -351,7 +792,8 @@ export function generateCrudSuite(
351
792
  }
352
793
 
353
794
  const step: RawStep = {
354
- name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
795
+ name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
796
+ source: buildStepSource(group.update),
355
797
  [method]: itemPath,
356
798
  expect: {
357
799
  status: getExpectedStatus(group.update),
@@ -361,21 +803,23 @@ export function generateCrudSuite(
361
803
  step.headers = { "If-Match": `"{{${etagVar}}}"` };
362
804
  }
363
805
  if (group.update.requestBodySchema) {
364
- step.json = generateFromSchema(group.update.requestBodySchema);
806
+ step.json = generateBody(group.update.requestBodySchema);
365
807
  }
366
808
  tests.push(step);
367
809
  }
368
810
 
369
- // 4. Delete
370
- if (group.delete) {
811
+ // 4. Delete (ARV-368: only if the chain self-captures its id — else the
812
+ // fixture-fallback would DELETE a pre-existing resource → data-loss)
813
+ if (group.delete && canChainMutations) {
371
814
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
372
- const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
815
+ const etagVar = resourceVar(group.resource, "etag");
373
816
 
374
817
  // If delete requires ETag and update didn't already capture it, add a GET step
375
818
  const updateAlreadyCapturedEtag = group.update?.requiresEtag;
376
819
  if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
377
820
  tests.push({
378
- name: `Get ETag before delete ${group.resource.replace(/s$/, "")}`,
821
+ name: `Get ETag before delete ${singularizeResource(group.resource)}`,
822
+ source: buildStepSource(group.read),
379
823
  GET: itemPath,
380
824
  expect: {
381
825
  status: getExpectedStatus(group.read),
@@ -386,7 +830,8 @@ export function generateCrudSuite(
386
830
 
387
831
  // T44: cleanup must run even if earlier assertions failed (tainted captures)
388
832
  const step: RawStep = {
389
- name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
833
+ name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
834
+ source: buildStepSource(group.delete),
390
835
  DELETE: itemPath,
391
836
  always: true,
392
837
  expect: {
@@ -401,7 +846,8 @@ export function generateCrudSuite(
401
846
  // 5. Verify deleted — also always, so we confirm cleanup happened
402
847
  if (group.read) {
403
848
  tests.push({
404
- name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
849
+ name: `Verify ${singularizeResource(group.resource)} deleted`,
850
+ source: buildStepSource(group.read, 404),
405
851
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
406
852
  always: true,
407
853
  expect: {
@@ -594,7 +1040,7 @@ function generateConsistentAuthSuite(
594
1040
  }
595
1041
 
596
1042
  /** Generate 1-2 minimal tests for quick connectivity and auth validation */
597
- export function generateSanitySuite(opts: {
1043
+ function generateSanitySuite(opts: {
598
1044
  authEndpoints: EndpointInfo[];
599
1045
  nonAuthGetEndpoints: EndpointInfo[];
600
1046
  securitySchemes: SecuritySchemeInfo[];
@@ -631,11 +1077,23 @@ export function generateSanitySuite(opts: {
631
1077
  export function generateSuites(opts: {
632
1078
  endpoints: EndpointInfo[];
633
1079
  securitySchemes: SecuritySchemeInfo[];
1080
+ /** Path to OpenAPI spec, recorded in suite-level provenance. */
1081
+ specPath?: string;
1082
+ /** When true, deprecated endpoints are included instead of filtered out. */
1083
+ includeDeprecated?: boolean;
1084
+ /** ARV-212 (R13/F16): inject `Authorization: Bearer {{<varName>}}` at the
1085
+ * suite level when the spec declares no securitySchemes but the workspace
1086
+ * .env.yaml carries this auth-token variable. Lets generated suites talk
1087
+ * to bare-spec APIs (GitHub) without going unauth. */
1088
+ defaultAuthVar?: string;
634
1089
  }): RawSuite[] {
635
- const { endpoints, securitySchemes } = opts;
1090
+ const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
1091
+ _suiteDefaultAuthVar = defaultAuthVar ?? null;
1092
+ _ambiguousPathParams = computeAmbiguousPathParams(endpoints);
636
1093
 
637
- // Filter deprecated
638
- const active = endpoints.filter(ep => !ep.deprecated);
1094
+ // Filter deprecated unless caller opted in. The list of skipped paths is
1095
+ // exposed separately via `getSkippedDeprecated` for stdout reporting.
1096
+ const active = includeDeprecated ? endpoints : endpoints.filter(ep => !ep.deprecated);
639
1097
 
640
1098
  // Separate auth endpoints
641
1099
  const authEndpoints = active.filter(isAuthEndpoint);
@@ -670,27 +1128,52 @@ export function generateSuites(opts: {
670
1128
  const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
671
1129
  const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
672
1130
 
673
- // Regular smoke: paramless GETs (e.g. list endpoints, health checks)
674
- if (paramlessGets.length > 0) {
675
- const tests = paramlessGets.map(ep => {
1131
+ // Positive smoke: paramless GETs (no env needed) + path-param GETs
1132
+ // (with skip_if guards). TASK-240 — unified naming convention:
1133
+ // always emit `smoke-<tag>-positive.yaml`, never the bare
1134
+ // `smoke-<tag>.yaml`, so file listings don't have to explain why a
1135
+ // tag has only `-negative` (e.g. a vendor-specific tag) or why two
1136
+ // siblings differ in suffix shape.
1137
+ const positiveTests = [
1138
+ ...paramlessGets.map(ep => {
676
1139
  const step = generateStep(ep, securitySchemes);
677
1140
  const seededPath = convertPathWithSeeds(ep.path, ep);
678
1141
  (step as any)[ep.method.toUpperCase()] = seededPath;
679
1142
  return step;
680
- });
681
- const headers = getSuiteHeaders(paramlessGets, securitySchemes);
1143
+ }),
1144
+ ...pathParamGets.map(ep => {
1145
+ const step = generateStep(ep, securitySchemes);
1146
+ // Path stays as {{param}} so user-provided env values flow in.
1147
+ // skip_if guards an unset path-param without skipping paramless
1148
+ // siblings that don't need a fixture.
1149
+ const firstPathParam = ep.parameters.find(p => p.in === "path");
1150
+ if (firstPathParam) {
1151
+ step.skip_if = `{{${firstPathParam.name}}} ==`;
1152
+ }
1153
+ return step;
1154
+ }),
1155
+ ];
1156
+
1157
+ if (positiveTests.length > 0) {
1158
+ const positiveEndpoints = [...paramlessGets, ...pathParamGets];
1159
+ const headers = getSuiteHeaders(positiveEndpoints, securitySchemes);
1160
+ // needs-id only when at least one test depends on a path-param
1161
+ // fixture — coverage downgrades these suites when env is empty.
1162
+ const tags = pathParamGets.length > 0
1163
+ ? ["smoke", "positive", "needs-id"]
1164
+ : ["smoke", "positive"];
682
1165
 
683
1166
  const suite: RawSuite = {
684
- name: `${tagSlug}-smoke`,
685
- tags: ["smoke"],
686
- fileStem: `smoke-${tagSlug}`,
1167
+ name: `${tagSlug}-smoke-positive`,
1168
+ tags,
1169
+ fileStem: `smoke-${tagSlug}-positive`,
687
1170
  base_url: "{{base_url}}",
688
- tests,
1171
+ tests: positiveTests,
689
1172
  };
690
1173
 
691
1174
  if (headers) {
692
1175
  suite.headers = headers;
693
- for (const t of tests) {
1176
+ for (const t of positiveTests) {
694
1177
  if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
695
1178
  delete (t as any).headers;
696
1179
  }
@@ -731,40 +1214,6 @@ export function generateSuites(opts: {
731
1214
  suites.push(suite);
732
1215
  }
733
1216
 
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
1217
  // Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
769
1218
  const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
770
1219
  const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
@@ -836,5 +1285,17 @@ export function generateSuites(opts: {
836
1285
  const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
837
1286
  const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
838
1287
 
839
- return sanitySuite ? [sanitySuite, ...suites] : suites;
1288
+ const allSuites = sanitySuite ? [sanitySuite, ...suites] : suites;
1289
+
1290
+ // Stamp suite-level provenance when a spec path is known.
1291
+ const suiteSrc = buildOpenApiSuiteSource(specPath);
1292
+ if (suiteSrc) {
1293
+ for (const s of allSuites) {
1294
+ s.source = suiteSrc;
1295
+ }
1296
+ }
1297
+
1298
+ _suiteDefaultAuthVar = null; // ARV-212
1299
+ _ambiguousPathParams = new Set(); // ARV-369
1300
+ return allSuites;
840
1301
  }