@kirrosh/zond 0.26.0 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1142
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,1301 +0,0 @@
1
- import type { OpenAPIV3 } from "openapi-types";
2
- import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
3
- import type { RawSuite, RawStep } from "./serializer.ts";
4
- import type { SourceMetadata } from "../parser/types.ts";
5
- import { generateFromSchema, generateMultipartFromSchema, isFkFixtureField, canonicalVarName, effectiveObjectShape } from "./data-factory.ts";
6
- import { groupEndpointsByTag } from "./chunker.ts";
7
- import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
8
- import { flattenToFormFields } from "../runner/form-encode.ts";
9
-
10
- // ──────────────────────────────────────────────
11
- // Helpers
12
- // ──────────────────────────────────────────────
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
-
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)}}}`);
61
- }
62
-
63
- /**
64
- * Convert path params to seed values for smoke suites (no capture context).
65
- * Uses the parameter's example/default from the spec, or falls back to
66
- * {{placeholder}} form so the user fills them in via .env.yaml.
67
- */
68
- function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
69
- return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
70
- const param = ep.parameters.find(p => p.name === name && p.in === "path");
71
- const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
72
- const example = (param as any)?.example ?? schema?.example ?? schema?.default;
73
- if (example !== undefined) return String(example);
74
- return `{{${name}}}`;
75
- });
76
- }
77
-
78
- /**
79
- * For negative-smoke suites: replace path params with guaranteed-non-existent values.
80
- * Picks a value that's syntactically valid for the param's type/format but very
81
- * unlikely to match a real resource (zero-UUID, very large int, sentinel string).
82
- */
83
- function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
84
- if (!schema) return "nonexistent_id_zzzzzz";
85
- if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
86
- if (schema.type === "integer" || schema.type === "number") return "999999999";
87
- return "nonexistent_id_zzzzzz";
88
- }
89
-
90
- function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
91
- return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
92
- const param = ep.parameters.find(p => p.name === name && p.in === "path");
93
- const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
94
- return getNonexistentSeed(schema);
95
- });
96
- }
97
-
98
- function endpointHasPathParams(ep: EndpointInfo): boolean {
99
- return ep.parameters.some(p => p.in === "path");
100
- }
101
-
102
- function slugify(s: string): string {
103
- return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
104
- }
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
-
175
- function escapeRegex(s: string): string {
176
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
177
- }
178
-
179
- const HEALTHCHECK_PATH_RE = /\/(health|healthz|ping|status|ready|readiness|liveness|alive)\b/i;
180
- const RESET_PATH_RE = /\/(reset|flush|purge|truncate|wipe|clear-data|factory-reset)\b/i;
181
- const LOGOUT_PATH_RE = /\/(logout|signout|invalidate|revoke)\b/i;
182
- const SHORT_PATH_RE = /^\/[a-z0-9-]*$/i; // matches /, /api, /v1, etc.
183
-
184
- function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefined {
185
- return (
186
- gets.find(ep => HEALTHCHECK_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path")) ??
187
- gets.find(ep => SHORT_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path") && ep.security.length === 0) ??
188
- gets.find(ep => !ep.parameters.some(p => p.in === "path") && ep.security.length === 0)
189
- );
190
- }
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
- */
203
- function getExpectedStatus(ep: EndpointInfo): number {
204
- const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
205
- if (success) return success.statusCode;
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
- }
218
- }
219
-
220
- function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
221
- return ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300)?.schema;
222
- }
223
-
224
- function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, string>> | undefined {
225
- const schema = getSuccessSchema(ep);
226
- if (!schema) return undefined;
227
-
228
- if (schema.type === "array") {
229
- return { _body: { type: "array" } };
230
- }
231
-
232
- if (schema.properties) {
233
- const assertions: Record<string, Record<string, string>> = {};
234
- const props = Object.keys(schema.properties).slice(0, 5);
235
- for (const prop of props) {
236
- assertions[prop] = { exists: "true" };
237
- }
238
- return Object.keys(assertions).length > 0 ? assertions : undefined;
239
- }
240
-
241
- return undefined;
242
- }
243
-
244
- /** Derive a variable name for a security scheme's token */
245
- export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
246
- // Count how many bearer-like schemes exist
247
- const bearerSchemes = allSchemes.filter(s =>
248
- (s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
249
- (s.type === "apiKey" && s.in === "header" && s.apiKeyName === "Authorization")
250
- );
251
- // If only one bearer scheme → keep generic auth_token for backward compat
252
- if (bearerSchemes.length <= 1) return "auth_token";
253
- // Multiple → derive from scheme name (e.g. "platformAuth" → "platform_auth_token")
254
- const slug = scheme.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_token$|_auth$/, "");
255
- return `${slug}_token`;
256
- }
257
-
258
- function getAuthHeaders(
259
- ep: EndpointInfo,
260
- schemes: SecuritySchemeInfo[],
261
- ): Record<string, string> | undefined {
262
- return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
263
- }
264
-
265
- function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
266
- const queryParams = ep.parameters.filter(p => p.in === "query" && p.required);
267
- if (queryParams.length === 0) return undefined;
268
-
269
- const query: Record<string, string> = {};
270
- for (const p of queryParams) {
271
- if (p.schema) {
272
- const val = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
273
- query[p.name] = typeof val === "object" ? JSON.stringify(val) : String(val);
274
- } else {
275
- query[p.name] = "{{$randomString}}";
276
- }
277
- }
278
- return query;
279
- }
280
-
281
- /** Check if all endpoints share the same auth headers → suite-level */
282
- function getSuiteHeaders(
283
- endpoints: EndpointInfo[],
284
- schemes: SecuritySchemeInfo[],
285
- ): Record<string, string> | undefined {
286
- if (endpoints.length === 0) return undefined;
287
-
288
- const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
289
- const first = headerSets[0];
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
- }
307
-
308
- const firstJson = JSON.stringify(first);
309
- const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
310
- return allSame ? first : undefined;
311
- }
312
-
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 {
351
- const schema = getSuccessSchema(ep);
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;
370
- }
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
-
379
- return "id";
380
- }
381
-
382
- const AUTH_PATH_PATTERNS = /\/(auth|login|signin|signup|register|token|oauth)\b/i;
383
-
384
- function isAuthEndpoint(ep: EndpointInfo): boolean {
385
- if (AUTH_PATH_PATTERNS.test(ep.path)) return true;
386
- if (ep.tags?.some(t => /^auth/i.test(t))) return true;
387
- return false;
388
- }
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
-
430
- // ──────────────────────────────────────────────
431
- // Public API
432
- // ──────────────────────────────────────────────
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
-
470
- /** Generate a single test step from an EndpointInfo */
471
- export function generateStep(
472
- ep: EndpointInfo,
473
- securitySchemes: SecuritySchemeInfo[],
474
- ): RawStep {
475
- const method = ep.method.toUpperCase();
476
- const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
477
- const path = convertPath(ep.path, _ambiguousPathParams);
478
-
479
- const step: RawStep = {
480
- name,
481
- source: buildStepSource(ep),
482
- [method]: path,
483
- expect: {
484
- status: getExpectedStatus(ep),
485
- },
486
- };
487
-
488
- const authHeaders = getAuthHeaders(ep, securitySchemes);
489
- if (authHeaders) {
490
- step.headers = authHeaders;
491
- }
492
-
493
- if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
494
- if (ep.requestBodyContentType === "multipart/form-data") {
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));
502
- } else {
503
- step.json = generateBody(ep.requestBodySchema);
504
- }
505
- }
506
-
507
- const query = getRequiredQueryParams(ep);
508
- if (query) {
509
- step.query = query;
510
- }
511
-
512
- const body = getBodyAssertions(ep);
513
- if (body) {
514
- step.expect.body = body;
515
- }
516
-
517
- return step;
518
- }
519
-
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. */
557
- export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
558
- return detectCrudGroupsWithDiagnostics(endpoints).groups;
559
- }
560
-
561
- export function detectCrudGroupsWithDiagnostics(
562
- endpoints: EndpointInfo[],
563
- ): DetectCrudResult {
564
- const groups: CrudGroup[] = [];
565
- const diagnostics: CrudDetectionDiagnostic[] = [];
566
- const postEndpoints = endpoints.filter(
567
- ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
568
- );
569
-
570
- for (const createEp of postEndpoints) {
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
- );
587
-
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
- }
622
-
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
- );
639
-
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
- };
651
-
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
- }
667
-
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]!;
676
-
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);
687
-
688
- groups.push({
689
- resource,
690
- basePath,
691
- itemPath,
692
- idParam,
693
- create: createEp,
694
- list,
695
- read,
696
- update,
697
- delete: del,
698
- });
699
- }
700
-
701
- return { groups, diagnostics };
702
- }
703
-
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
-
721
- export function generateCrudSuite(
722
- group: CrudGroup,
723
- securitySchemes: SecuritySchemeInfo[],
724
- ): RawSuite {
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");
739
- const tests: RawStep[] = [];
740
-
741
- const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
742
- const suiteHeaders = getSuiteHeaders(allEps, securitySchemes);
743
-
744
- // 0. List all (before create, to verify collection exists)
745
- if (group.list) {
746
- const step = generateStep(group.list, securitySchemes);
747
- if (suiteHeaders) delete (step as any).headers;
748
- tests.push(step);
749
- }
750
-
751
- // 1. Create
752
- if (group.create) {
753
- const step = generateStep(group.create, securitySchemes);
754
- if (!step.expect.body) step.expect.body = {};
755
- step.expect.body[captureField] = { capture: captureVar };
756
- if (suiteHeaders) delete (step as any).headers;
757
- tests.push(step);
758
- }
759
-
760
- // 2. Read created
761
- if (group.read) {
762
- const step: RawStep = {
763
- name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
764
- source: buildStepSource(group.read),
765
- GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
766
- expect: {
767
- status: getExpectedStatus(group.read),
768
- body: getBodyAssertions(group.read),
769
- },
770
- };
771
- tests.push(step);
772
- }
773
-
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) {
777
- const method = group.update.method.toUpperCase();
778
- const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
779
- const etagVar = resourceVar(group.resource, "etag");
780
-
781
- // If endpoint requires ETag (optimistic locking), capture it from a GET step first
782
- if (group.update.requiresEtag && group.read) {
783
- tests.push({
784
- name: `Get ETag before update ${singularizeResource(group.resource)}`,
785
- source: buildStepSource(group.read),
786
- GET: itemPath,
787
- expect: {
788
- status: getExpectedStatus(group.read),
789
- headers: { ETag: { capture: etagVar } },
790
- },
791
- });
792
- }
793
-
794
- const step: RawStep = {
795
- name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
796
- source: buildStepSource(group.update),
797
- [method]: itemPath,
798
- expect: {
799
- status: getExpectedStatus(group.update),
800
- },
801
- };
802
- if (group.update.requiresEtag) {
803
- step.headers = { "If-Match": `"{{${etagVar}}}"` };
804
- }
805
- if (group.update.requestBodySchema) {
806
- step.json = generateBody(group.update.requestBodySchema);
807
- }
808
- tests.push(step);
809
- }
810
-
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) {
814
- const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
815
- const etagVar = resourceVar(group.resource, "etag");
816
-
817
- // If delete requires ETag and update didn't already capture it, add a GET step
818
- const updateAlreadyCapturedEtag = group.update?.requiresEtag;
819
- if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
820
- tests.push({
821
- name: `Get ETag before delete ${singularizeResource(group.resource)}`,
822
- source: buildStepSource(group.read),
823
- GET: itemPath,
824
- expect: {
825
- status: getExpectedStatus(group.read),
826
- headers: { ETag: { capture: etagVar } },
827
- },
828
- });
829
- }
830
-
831
- // T44: cleanup must run even if earlier assertions failed (tainted captures)
832
- const step: RawStep = {
833
- name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
834
- source: buildStepSource(group.delete),
835
- DELETE: itemPath,
836
- always: true,
837
- expect: {
838
- status: getExpectedStatus(group.delete),
839
- },
840
- };
841
- if (group.delete.requiresEtag) {
842
- step.headers = { "If-Match": `"{{${etagVar}}}"` };
843
- }
844
- tests.push(step);
845
-
846
- // 5. Verify deleted — also always, so we confirm cleanup happened
847
- if (group.read) {
848
- tests.push({
849
- name: `Verify ${singularizeResource(group.resource)} deleted`,
850
- source: buildStepSource(group.read, 404),
851
- GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
852
- always: true,
853
- expect: {
854
- status: 404,
855
- },
856
- });
857
- }
858
- }
859
-
860
- // T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
861
- // in its starting state (ephemeral); without DELETE it leaves residual data.
862
- const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
863
-
864
- const suite: RawSuite = {
865
- name: `${group.resource}-crud`,
866
- tags: ["crud", cleanupTag],
867
- fileStem: `crud-${slugify(group.resource)}`,
868
- base_url: "{{base_url}}",
869
- tests,
870
- };
871
-
872
- if (suiteHeaders) {
873
- suite.headers = suiteHeaders;
874
- }
875
-
876
- return suite;
877
- }
878
-
879
- /** Find unresolved template variables in a suite (excluding known globals, captured vars, and env keys) */
880
- export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>, extraKnown?: Set<string>): string[] {
881
- const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
882
- if (envKeys) for (const k of envKeys) KNOWN.add(k);
883
- if (extraKnown) for (const k of extraKnown) KNOWN.add(k);
884
- const captured = new Set<string>();
885
- for (const step of suite.tests) {
886
- if (step.expect?.body) {
887
- for (const val of Object.values(step.expect.body)) {
888
- if (val && typeof val === "object" && "capture" in val) captured.add((val as any).capture);
889
- }
890
- }
891
- }
892
- const vars = new Set<string>();
893
- const scan = (obj: unknown) => {
894
- if (typeof obj === "string") {
895
- for (const m of obj.matchAll(/\{\{([^$}][^}]*)\}\}/g)) {
896
- if (!KNOWN.has(m[1]!) && !captured.has(m[1]!)) vars.add(m[1]!);
897
- }
898
- } else if (obj && typeof obj === "object") {
899
- for (const v of Object.values(obj)) scan(v);
900
- }
901
- };
902
- scan(suite);
903
- return [...vars];
904
- }
905
-
906
- /** Check if a schema has a specific field name (case-insensitive) */
907
- function schemaHasField(schema: OpenAPIV3.SchemaObject | undefined, ...names: string[]): boolean {
908
- if (!schema?.properties) return false;
909
- const keys = Object.keys(schema.properties).map(k => k.toLowerCase());
910
- return names.some(n => keys.includes(n.toLowerCase()));
911
- }
912
-
913
- /** Generate auth suite with register+login consistency */
914
- export function generateAuthSuite(
915
- authEndpoints: EndpointInfo[],
916
- securitySchemes: SecuritySchemeInfo[],
917
- ): RawSuite {
918
- // Detect register → login pair
919
- const registerEp = authEndpoints.find(ep =>
920
- /\/(register|signup)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST"
921
- );
922
- const loginEp = authEndpoints.find(ep =>
923
- ep !== registerEp &&
924
- /\/(login|signin|auth)\b/i.test(ep.path) &&
925
- ep.method.toUpperCase() === "POST"
926
- );
927
-
928
- const hasCredentialPair = registerEp && loginEp &&
929
- schemaHasField(registerEp.requestBodySchema, "email", "username") &&
930
- schemaHasField(registerEp.requestBodySchema, "password") &&
931
- schemaHasField(loginEp.requestBodySchema, "email", "username") &&
932
- schemaHasField(loginEp.requestBodySchema, "password");
933
-
934
- if (hasCredentialPair) {
935
- return generateConsistentAuthSuite(registerEp, loginEp, authEndpoints, securitySchemes);
936
- }
937
-
938
- // Fallback: plain auth suite — exclude logout/revoke endpoints from setup suite
939
- const nonLogoutEndpoints = authEndpoints.filter(ep => !LOGOUT_PATH_RE.test(ep.path));
940
- const tests = nonLogoutEndpoints.map(ep => generateStep(ep, securitySchemes));
941
- const headers = getSuiteHeaders(nonLogoutEndpoints, securitySchemes);
942
-
943
- const suite: RawSuite = {
944
- name: "auth",
945
- setup: true,
946
- tags: ["auth"],
947
- fileStem: "auth",
948
- base_url: "{{base_url}}",
949
- tests,
950
- };
951
-
952
- if (headers) {
953
- suite.headers = headers;
954
- for (const t of tests) {
955
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
956
- delete (t as any).headers;
957
- }
958
- }
959
- }
960
-
961
- return suite;
962
- }
963
-
964
- /** Generate auth suite with consistent register → login credentials */
965
- function generateConsistentAuthSuite(
966
- registerEp: EndpointInfo,
967
- loginEp: EndpointInfo,
968
- allAuthEndpoints: EndpointInfo[],
969
- securitySchemes: SecuritySchemeInfo[],
970
- ): RawSuite {
971
- const tests: RawStep[] = [];
972
-
973
- // Determine credential field: "email" or "username"
974
- const useEmail = schemaHasField(registerEp.requestBodySchema, "email");
975
- const credField = useEmail ? "email" : "username";
976
- const credValue = useEmail ? "test_{{$timestamp}}@test.com" : "testuser_{{$timestamp}}";
977
-
978
- // 0. Set shared credentials
979
- const setStep: RawStep = {
980
- name: "Set test credentials",
981
- set: {
982
- [`test_${credField}`]: credValue,
983
- test_password: "TestPass123!",
984
- },
985
- expect: {},
986
- } as RawStep;
987
- tests.push(setStep);
988
-
989
- // 1. Register step — replace credential fields with shared vars
990
- const registerStep = generateStep(registerEp, securitySchemes);
991
- if (registerStep.json && typeof registerStep.json === "object") {
992
- const json = registerStep.json as Record<string, unknown>;
993
- if (credField in json) json[credField] = `{{test_${credField}}}`;
994
- if ("password" in json) json.password = "{{test_password}}";
995
- }
996
- tests.push(registerStep);
997
-
998
- // 2. Login step — reuse same credentials + capture token
999
- const loginStep = generateStep(loginEp, securitySchemes);
1000
- if (loginStep.json && typeof loginStep.json === "object") {
1001
- const json = loginStep.json as Record<string, unknown>;
1002
- if (credField in json) json[credField] = `{{test_${credField}}}`;
1003
- if ("password" in json) json.password = "{{test_password}}";
1004
- }
1005
- // Try to capture auth token from login response
1006
- const loginSchema = getSuccessSchema(loginEp);
1007
- if (loginSchema?.properties) {
1008
- const tokenField = Object.keys(loginSchema.properties).find(k =>
1009
- /token|access_token|accessToken|jwt/i.test(k)
1010
- );
1011
- if (tokenField) {
1012
- // Determine the capture variable name based on the login endpoint's security scheme
1013
- const loginScheme = loginEp.security.length > 0
1014
- ? securitySchemes.find(s => s.name === loginEp.security[0])
1015
- : undefined;
1016
- const captureVar = loginScheme ? schemeVarName(loginScheme, securitySchemes) : "auth_token";
1017
- if (!loginStep.expect.body) loginStep.expect.body = {};
1018
- loginStep.expect.body[tokenField] = { capture: captureVar };
1019
- }
1020
- }
1021
- tests.push(loginStep);
1022
-
1023
- // 3. Any remaining auth endpoints (not register/login, not logout)
1024
- // Logout/revoke endpoints must NOT be in a setup suite — they invalidate the token
1025
- const others = allAuthEndpoints.filter(ep =>
1026
- ep !== registerEp && ep !== loginEp && !LOGOUT_PATH_RE.test(ep.path)
1027
- );
1028
- for (const ep of others) {
1029
- tests.push(generateStep(ep, securitySchemes));
1030
- }
1031
-
1032
- return {
1033
- name: "auth",
1034
- setup: true,
1035
- tags: ["auth"],
1036
- fileStem: "auth",
1037
- base_url: "{{base_url}}",
1038
- tests,
1039
- };
1040
- }
1041
-
1042
- /** Generate 1-2 minimal tests for quick connectivity and auth validation */
1043
- function generateSanitySuite(opts: {
1044
- authEndpoints: EndpointInfo[];
1045
- nonAuthGetEndpoints: EndpointInfo[];
1046
- securitySchemes: SecuritySchemeInfo[];
1047
- }): RawSuite | null {
1048
- const { authEndpoints, nonAuthGetEndpoints, securitySchemes } = opts;
1049
- const tests: RawStep[] = [];
1050
-
1051
- // Priority 1: auth login/token endpoint
1052
- if (authEndpoints.length > 0) {
1053
- const loginEp =
1054
- authEndpoints.find(ep => /\/(login|signin|token)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST") ??
1055
- authEndpoints[0]!;
1056
- tests.push(generateStep(loginEp, securitySchemes));
1057
- }
1058
-
1059
- // Priority 2: healthcheck or first simple GET with no path params
1060
- const healthEp = selectHealthcheckEndpoint(nonAuthGetEndpoints);
1061
- if (healthEp) {
1062
- tests.push(generateStep(healthEp, securitySchemes));
1063
- }
1064
-
1065
- if (tests.length === 0) return null;
1066
-
1067
- return {
1068
- name: "sanity",
1069
- tags: ["sanity"],
1070
- fileStem: "sanity",
1071
- base_url: "{{base_url}}",
1072
- tests: tests.slice(0, 2),
1073
- };
1074
- }
1075
-
1076
- /** Main entry point: generate all suites from endpoints */
1077
- export function generateSuites(opts: {
1078
- endpoints: EndpointInfo[];
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;
1089
- }): RawSuite[] {
1090
- const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
1091
- _suiteDefaultAuthVar = defaultAuthVar ?? null;
1092
- _ambiguousPathParams = computeAmbiguousPathParams(endpoints);
1093
-
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);
1097
-
1098
- // Separate auth endpoints
1099
- const authEndpoints = active.filter(isAuthEndpoint);
1100
- const nonAuth = active.filter(ep => !isAuthEndpoint(ep));
1101
-
1102
- // 1. Detect CRUD groups
1103
- const crudGroups = detectCrudGroups(nonAuth);
1104
-
1105
- // Collect endpoints consumed by CRUD groups
1106
- const crudEndpointKeys = new Set<string>();
1107
- for (const g of crudGroups) {
1108
- if (g.create) crudEndpointKeys.add(`${g.create.method.toUpperCase()} ${g.create.path}`);
1109
- if (g.list) crudEndpointKeys.add(`${g.list.method.toUpperCase()} ${g.list.path}`);
1110
- if (g.read) crudEndpointKeys.add(`${g.read.method.toUpperCase()} ${g.read.path}`);
1111
- if (g.update) crudEndpointKeys.add(`${g.update.method.toUpperCase()} ${g.update.path}`);
1112
- if (g.delete) crudEndpointKeys.add(`${g.delete.method.toUpperCase()} ${g.delete.path}`);
1113
- }
1114
-
1115
- // Remaining endpoints (not in any CRUD group, not auth)
1116
- const remaining = nonAuth.filter(ep => !crudEndpointKeys.has(`${ep.method.toUpperCase()} ${ep.path}`));
1117
-
1118
- const suites: RawSuite[] = [];
1119
-
1120
- // 2. Group remaining by tag → smoke + smoke-unsafe
1121
- const byTag = groupEndpointsByTag(remaining);
1122
-
1123
- for (const [tag, tagEndpoints] of byTag) {
1124
- const tagSlug = slugify(tag) || "api";
1125
-
1126
- // GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
1127
- const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
1128
- const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
1129
- const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
1130
-
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 => {
1139
- const step = generateStep(ep, securitySchemes);
1140
- const seededPath = convertPathWithSeeds(ep.path, ep);
1141
- (step as any)[ep.method.toUpperCase()] = seededPath;
1142
- return step;
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"];
1165
-
1166
- const suite: RawSuite = {
1167
- name: `${tagSlug}-smoke-positive`,
1168
- tags,
1169
- fileStem: `smoke-${tagSlug}-positive`,
1170
- base_url: "{{base_url}}",
1171
- tests: positiveTests,
1172
- };
1173
-
1174
- if (headers) {
1175
- suite.headers = headers;
1176
- for (const t of positiveTests) {
1177
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
1178
- delete (t as any).headers;
1179
- }
1180
- }
1181
- }
1182
-
1183
- suites.push(suite);
1184
- }
1185
-
1186
- // Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
1187
- if (pathParamGets.length > 0) {
1188
- const tests = pathParamGets.map(ep => {
1189
- const step = generateStep(ep, securitySchemes);
1190
- (step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
1191
- // Negative path: resource doesn't exist. Drop body assertions (response shape varies).
1192
- step.expect = { status: [400, 404, 422] };
1193
- return step;
1194
- });
1195
- const headers = getSuiteHeaders(pathParamGets, securitySchemes);
1196
-
1197
- const suite: RawSuite = {
1198
- name: `${tagSlug}-smoke-negative`,
1199
- tags: ["smoke", "negative"],
1200
- fileStem: `smoke-${tagSlug}-negative`,
1201
- base_url: "{{base_url}}",
1202
- tests,
1203
- };
1204
-
1205
- if (headers) {
1206
- suite.headers = headers;
1207
- for (const t of tests) {
1208
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
1209
- delete (t as any).headers;
1210
- }
1211
- }
1212
- }
1213
-
1214
- suites.push(suite);
1215
- }
1216
-
1217
- // Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
1218
- const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
1219
- const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
1220
- const unsafeEndpoints = nonGetEndpoints.filter(ep => !RESET_PATH_RE.test(ep.path));
1221
-
1222
- // Reset/system endpoints → [system, reset] suite (never run as part of smoke)
1223
- if (resetEndpoints.length > 0) {
1224
- const tests = resetEndpoints.map(ep => generateStep(ep, securitySchemes));
1225
- const headers = getSuiteHeaders(resetEndpoints, securitySchemes);
1226
-
1227
- const suite: RawSuite = {
1228
- name: `${tagSlug}-system`,
1229
- tags: ["system", "reset"],
1230
- fileStem: `system-${tagSlug}`,
1231
- base_url: "{{base_url}}",
1232
- tests,
1233
- };
1234
-
1235
- if (headers) {
1236
- suite.headers = headers;
1237
- for (const t of tests) {
1238
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
1239
- delete (t as any).headers;
1240
- }
1241
- }
1242
- }
1243
-
1244
- suites.push(suite);
1245
- }
1246
-
1247
- // Remaining non-GET endpoints → smoke-unsafe suite
1248
- if (unsafeEndpoints.length > 0) {
1249
- const tests = unsafeEndpoints.map(ep => generateStep(ep, securitySchemes));
1250
- const headers = getSuiteHeaders(unsafeEndpoints, securitySchemes);
1251
-
1252
- const suite: RawSuite = {
1253
- name: `${tagSlug}-smoke-unsafe`,
1254
- tags: ["smoke", "unsafe"],
1255
- fileStem: `smoke-${tagSlug}-unsafe`,
1256
- base_url: "{{base_url}}",
1257
- tests,
1258
- };
1259
-
1260
- if (headers) {
1261
- suite.headers = headers;
1262
- for (const t of tests) {
1263
- if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
1264
- delete (t as any).headers;
1265
- }
1266
- }
1267
- }
1268
-
1269
- suites.push(suite);
1270
- }
1271
- }
1272
-
1273
- // 3. CRUD suites
1274
- for (const group of crudGroups) {
1275
- suites.push(generateCrudSuite(group, securitySchemes));
1276
- }
1277
-
1278
- // 4. Auth suite (separate — requires real credentials)
1279
- if (authEndpoints.length > 0) {
1280
- const suite = generateAuthSuite(authEndpoints, securitySchemes);
1281
- suites.push(suite);
1282
- }
1283
-
1284
- // 5. Sanity suite (prepend — 1-2 tests for quick connectivity/auth check)
1285
- const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
1286
- const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
1287
-
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;
1301
- }