@kirrosh/zond 0.23.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 (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -1,44 +1,18 @@
1
1
  /**
2
- * Failure classification and diagnostic hints.
3
- * Extracted from query-db.ts for reuse in Web UI.
2
+ * Failure classification closed-enum `recommended_action`.
3
+ * ARV-338: prose hints (statusHint/envHint/softDeleteHint/schemaHint,
4
+ * env_issue clustering, auth_hint, agent_directive) removed — the agent
5
+ * triages by enum + raw evidence, not by heuristic guess-text.
4
6
  */
5
7
 
6
8
  import { classify } from "../classifier/recommended-action.ts";
7
9
 
8
- export function statusHint(status: number | null | undefined): string | null {
9
- if (!status) return null;
10
- if (status >= 500) return "Server-side error — inspect response_body for errorMessage/errorDetail; likely a backend bug";
11
- if (status === 401 || status === 403) return "Auth failure — check auth_token/api_key in .env.yaml";
12
- if (status === 404) return "Resource not found — verify the path and ID";
13
- if (status === 400 || status === 422) return "Validation error — check request body fields match the schema";
14
- if (status === 429) return "Rate limited — too many requests. Consider consolidating auth/login steps or adding delays between suites";
15
- return null;
16
- }
17
-
18
10
  export function classifyFailure(status: string, responseStatus: number | null): "api_error" | "assertion_failed" | "network_error" {
19
11
  if (status === "error" && (responseStatus === null || responseStatus < 500)) return "network_error";
20
12
  if (responseStatus !== null && responseStatus >= 500) return "api_error";
21
13
  return "assertion_failed";
22
14
  }
23
15
 
24
- export function envHint(url: string | null, errorMessage: string | null, envFilePath?: string): string | null {
25
- const envFile = envFilePath ? envFilePath : ".env.yaml in your API directory";
26
-
27
- if (url && /\{\{[^}]+\}\}/.test(url)) {
28
- return `URL contains unresolved variable: "${url}" — variable name may not match the key in ${envFile}`;
29
- }
30
- if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
31
- return `base_url is not set or empty — URL resolved to "${url}". Add base_url to ${envFile}`;
32
- }
33
- if (errorMessage?.includes("base_url is not configured")) {
34
- return `base_url is missing or empty. Add base_url: https://your-api.com to ${envFile}`;
35
- }
36
- if (errorMessage?.includes("URL is invalid") || errorMessage?.includes("Failed to parse URL")) {
37
- return `URL is malformed — likely base_url is empty or invalid. Check base_url in ${envFile}`;
38
- }
39
- return null;
40
- }
41
-
42
16
  export type RecommendedAction =
43
17
  | "report_backend_bug"
44
18
  | "fix_auth_config"
@@ -136,181 +110,3 @@ export function isGeneratedTest(
136
110
  if (typeof suite_file === "string" && /(^|\/)apis\/[^/]+\/tests\//.test(suite_file)) return true;
137
111
  return false;
138
112
  }
139
-
140
- export function envCategory(hint: string | undefined): string | null {
141
- if (!hint) return null;
142
- if (hint.includes("base_url is not set") || hint.includes("base_url is missing") || hint.includes("base_url is not configured")) return "base_url_missing";
143
- if (hint.includes("unresolved variable")) return "unresolved_variable";
144
- if (hint.includes("URL is malformed")) return "url_malformed";
145
- return null;
146
- }
147
-
148
- export function schemaHint(
149
- failureType: string,
150
- responseStatus: number | null | undefined,
151
- ): string | null {
152
- if (failureType === "assertion_failed" || responseStatus === 400 || responseStatus === 422) {
153
- return "Use describe_endpoint(specPath, method, path) to verify expected request/response schema";
154
- }
155
- return null;
156
- }
157
-
158
- export function softDeleteHint(
159
- actualStatus: number | null | undefined,
160
- requestMethod: string | null | undefined,
161
- responseBody: unknown,
162
- ): string | null {
163
- if (actualStatus !== 200 || requestMethod?.toUpperCase() !== "GET") return null;
164
- if (responseBody && typeof responseBody === "object") {
165
- const hasStatusField =
166
- "status" in (responseBody as object) ||
167
- "state" in (responseBody as object) ||
168
- "deleted" in (responseBody as object) ||
169
- "is_deleted" in (responseBody as object);
170
- if (hasStatusField) {
171
- return 'GET returned 200 with a status/state field after DELETE — likely soft delete. Update the test: remove the "Verify deleted → 404" step and instead assert the status field value (e.g. status: "cancelled")';
172
- }
173
- }
174
- return null;
175
- }
176
-
177
- export function computeSharedEnvIssue(
178
- failures: Array<{ hint?: string }>,
179
- envFilePath?: string,
180
- ): string | null {
181
- const categories = new Set(failures.map(f => envCategory(f.hint)).filter(Boolean));
182
- if (categories.size !== 1) return null;
183
-
184
- const envFile = envFilePath ?? ".env.yaml";
185
- if (categories.has("base_url_missing")) {
186
- return `All failures: base_url is not set — add base_url to ${envFile}`;
187
- }
188
- if (categories.has("unresolved_variable")) {
189
- return `All failures: some variables are not substituted — check variable names in ${envFile}`;
190
- }
191
- // url_malformed
192
- return [...failures.map(f => f.hint).filter(Boolean)][0] ?? null;
193
- }
194
-
195
- // ── TASK-98: per-suite env clustering ──────────────────────────────────────
196
- //
197
- // Round-3 review showed that the all-or-nothing run-level detector misses
198
- // real env_issue scenarios: a single suite needs `{{stripe_key}}`, a webhook
199
- // host is unreachable for one suite only, an auth token expires part-way
200
- // through. Cluster classification — group by suite, flag a suite when ≥80%
201
- // of its non-5xx failures share an env-symptom — closes that gap without
202
- // laundering 5xx (real backend bugs) into env_issue.
203
- export type EnvSymptom = "missing_var" | "base_url" | "url_malformed" | "auth_expired";
204
-
205
- function envSymptomOf(failure: {
206
- hint?: string;
207
- failure_type: string;
208
- response_status: number | null;
209
- }): EnvSymptom | null {
210
- if (failure.failure_type === "api_error") return null; // 5xx never counted
211
- const cat = envCategory(failure.hint);
212
- if (cat === "unresolved_variable") return "missing_var";
213
- if (cat === "base_url_missing") return "base_url";
214
- if (cat === "url_malformed") return "url_malformed";
215
- if (failure.response_status === 401 || failure.response_status === 403) return "auth_expired";
216
- return null;
217
- }
218
-
219
- export interface EnvIssue {
220
- /** Human-readable summary; used by reporters and shown to the user. */
221
- message: string;
222
- /** "run" when the issue spans most/all suites; "suite:<name>" when localized. */
223
- scope: "run" | `suite:${string}`;
224
- /** Suites the env_issue covers — one entry for suite scope, ≥2 for run scope. */
225
- affected_suites: string[];
226
- /** Histogram of root-cause symptoms across affected failures. */
227
- symptoms: Partial<Record<EnvSymptom, number>>;
228
- }
229
-
230
- /**
231
- * Cluster non-5xx failures by suite and return per-suite env clusters that
232
- * meet the env-symptom threshold (default ≥80% AND ≥2 failures). 5xx are
233
- * excluded so backend bugs cannot be reclassified as env issues.
234
- */
235
- export function clusterEnvIssues(
236
- failures: Array<{
237
- suite_name: string;
238
- hint?: string;
239
- failure_type: string;
240
- response_status: number | null;
241
- }>,
242
- threshold = 0.8,
243
- ): Array<{ suite: string; symptoms: Partial<Record<EnvSymptom, number>>; total: number }> {
244
- const bySuite = new Map<string, typeof failures>();
245
- for (const f of failures) {
246
- if (f.failure_type === "api_error") continue;
247
- const list = bySuite.get(f.suite_name) ?? [];
248
- list.push(f);
249
- bySuite.set(f.suite_name, list);
250
- }
251
- const clusters: Array<{ suite: string; symptoms: Partial<Record<EnvSymptom, number>>; total: number }> = [];
252
- for (const [suite, items] of bySuite) {
253
- if (items.length === 0) continue;
254
- const symptoms: Partial<Record<EnvSymptom, number>> = {};
255
- let envCount = 0;
256
- for (const f of items) {
257
- const s = envSymptomOf(f);
258
- if (s) {
259
- symptoms[s] = (symptoms[s] ?? 0) + 1;
260
- envCount++;
261
- }
262
- }
263
- if (envCount / items.length >= threshold && envCount >= 1) {
264
- clusters.push({ suite, symptoms, total: items.length });
265
- }
266
- }
267
- return clusters;
268
- }
269
-
270
- function formatSymptoms(symptoms: Partial<Record<EnvSymptom, number>>): string {
271
- const parts: string[] = [];
272
- for (const k of ["missing_var", "base_url", "url_malformed", "auth_expired"] as EnvSymptom[]) {
273
- const n = symptoms[k];
274
- if (n) parts.push(`${k}=${n}`);
275
- }
276
- return parts.join(", ");
277
- }
278
-
279
- /**
280
- * Build an EnvIssue envelope from clustered failures. Returns null when no
281
- * cluster exceeded the threshold. When exactly one suite is affected, scope
282
- * is `suite:<name>`; ≥2 suites collapse into a `run` scope aggregator.
283
- */
284
- export function buildEnvIssue(
285
- clusters: Array<{ suite: string; symptoms: Partial<Record<EnvSymptom, number>>; total: number }>,
286
- envFilePath?: string,
287
- ): EnvIssue | null {
288
- if (clusters.length === 0) return null;
289
- const envFile = envFilePath ?? ".env.yaml";
290
-
291
- const merged: Partial<Record<EnvSymptom, number>> = {};
292
- for (const c of clusters) {
293
- for (const [k, v] of Object.entries(c.symptoms)) {
294
- merged[k as EnvSymptom] = (merged[k as EnvSymptom] ?? 0) + (v ?? 0);
295
- }
296
- }
297
- const affected_suites = clusters.map(c => c.suite).sort();
298
-
299
- if (clusters.length === 1) {
300
- const c = clusters[0]!;
301
- const breakdown = formatSymptoms(c.symptoms);
302
- return {
303
- message: `Suite "${c.suite}" looks env-broken (${breakdown}) — check ${envFile}`,
304
- scope: `suite:${c.suite}`,
305
- affected_suites,
306
- symptoms: merged,
307
- };
308
- }
309
- const breakdown = formatSymptoms(merged);
310
- return {
311
- message: `${clusters.length} suites look env-broken (${breakdown}) — check ${envFile}`,
312
- scope: "run",
313
- affected_suites,
314
- symptoms: merged,
315
- };
316
- }
@@ -2,9 +2,8 @@
2
2
  * TASK-29: actionable "Suggested fixes" surfaces for `zond db diagnose`.
3
3
  *
4
4
  * The base diagnose envelope already classifies failures and wires
5
- * agent_directive / recommended_action / env_issue. This layer adds two
6
- * concrete, fixable signals that the LLM agent can act on without a second
7
- * round-trip:
5
+ * recommended_action. This layer adds two concrete, fixable signals that
6
+ * the LLM agent can act on without a second round-trip:
8
7
  *
9
8
  * 1. Placeholder path-params on 404s — when a 404 hits a URL that still
10
9
  * contains literal example/placeholder values (`example`, all-zeros
@@ -16,14 +16,7 @@ export interface ChunkPlan {
16
16
  * usable first segment (e.g. `/`) keep the legacy `untagged` key.
17
17
  */
18
18
  export function groupEndpointsByTag(endpoints: EndpointInfo[]): Map<string, EndpointInfo[]> {
19
- const groups = new Map<string, EndpointInfo[]>();
20
- for (const ep of endpoints) {
21
- const key = ep.tags[0] ?? resourceKeyFromPath(ep.path);
22
- const list = groups.get(key);
23
- if (list) list.push(ep);
24
- else groups.set(key, [ep]);
25
- }
26
- return groups;
19
+ return Map.groupBy(endpoints, (ep) => ep.tags[0] ?? resourceKeyFromPath(ep.path));
27
20
  }
28
21
 
29
22
  /** Extract the first non-templated path segment for tagless fallback. */
@@ -70,17 +70,15 @@ export function generateFromSchema(
70
70
  // the API 422s with "Missing <required-by-other-variant>".
71
71
  if (schema.oneOf) {
72
72
  const variants = schema.oneOf as OpenAPIV3.SchemaObject[];
73
- const picked = pickDiscriminatorVariant(variants, schema.discriminator?.propertyName)
74
- ?? pickPreferredVariant(variants);
73
+ const picked = pickBestVariant(variants, schema.discriminator);
75
74
  const result = recurse(picked, propertyName);
76
- return stampDiscriminator(result, picked, schema.discriminator?.propertyName);
75
+ return stampDiscriminator(result, picked, schema.discriminator);
77
76
  }
78
77
  if (schema.anyOf) {
79
78
  const variants = schema.anyOf as OpenAPIV3.SchemaObject[];
80
- const picked = pickDiscriminatorVariant(variants, schema.discriminator?.propertyName)
81
- ?? pickPreferredVariant(variants);
79
+ const picked = pickBestVariant(variants, schema.discriminator);
82
80
  const result = recurse(picked, propertyName);
83
- return stampDiscriminator(result, picked, schema.discriminator?.propertyName);
81
+ return stampDiscriminator(result, picked, schema.discriminator);
84
82
  }
85
83
 
86
84
  // enum: first value (always valid for the API contract)
@@ -174,13 +172,29 @@ export function generateFromSchema(
174
172
  /** Fields the client must not send in a request body: explicit `readOnly: true`,
175
173
  * or the literal name `id`. The latter is a heuristic for under-specified specs
176
174
  * (common in in-house APIs) that don't mark the server-assigned id readOnly
177
- * but still 4xx on it being present. */
175
+ * but still 4xx on it being present.
176
+ *
177
+ * Also strips Stripe-style `expand` meta-param when it's declared as a string
178
+ * array — Stripe doesn't mark it `readOnly` but rejects synthetic random
179
+ * values with 400 "This property cannot be expanded (<random>)", which kills
180
+ * 50+ baseline POSTs in mass-assignment / stateful checks on Stripe specs.
181
+ * The shape check (array of strings on a request body) keeps false-positives
182
+ * low: APIs using `expand` for real payload fields would normally use an
183
+ * object/enum, not a free-string array. */
178
184
  function shouldSkipForRequest(name: string, schema: OpenAPIV3.SchemaObject): boolean {
179
185
  if (schema.readOnly === true) return true;
180
186
  if (name === "id") return true;
187
+ if (name === "expand" && isStringArray(schema)) return true;
181
188
  return false;
182
189
  }
183
190
 
191
+ function isStringArray(schema: OpenAPIV3.SchemaObject): boolean {
192
+ if (schema.type !== "array") return false;
193
+ const items = schema.items as OpenAPIV3.SchemaObject | undefined;
194
+ if (!items) return false;
195
+ return items.type === "string";
196
+ }
197
+
184
198
  /** When recursion hits the depth cap, return a type-appropriate placeholder
185
199
  * rather than `{}` — `{}` for `array<string>` produces `[{}]` which 422s on
186
200
  * every realistic API. */
@@ -199,67 +213,113 @@ function depthLimitDefault(schema: OpenAPIV3.SchemaObject, name?: string): unkno
199
213
  }
200
214
  }
201
215
 
202
- /** ARV-78 (F25): when a parent oneOf/anyOf carries `discriminator.propertyName`,
203
- * pick the variant whose discriminator property has a single-value enum or
204
- * const so its identity is unambiguous. Returns undefined when nothing
205
- * qualifies — caller falls back to pickPreferredVariant. */
206
- function pickDiscriminatorVariant(
216
+ /** ARV-135 (m-21): score-based oneOf/anyOf variant selection.
217
+ *
218
+ * Picks the variant most likely to produce a body the server accepts:
219
+ *
220
+ * 1. Drop `type: "null"` shorthands unless they're the only choice
221
+ * (OpenAPI 3.1 nullable spelling).
222
+ * 2. Prefer the variant with the fewest UNRESOLVABLE required fields
223
+ * — i.e. required keys that have no entry in `properties`, which
224
+ * the generator can't synthesise. The historical bug was picking
225
+ * the FIRST variant matching the discriminator filter even when a
226
+ * sibling variant was demonstrably more complete; this leaves
227
+ * `required: [config, event_name]` partially-filled and the API
228
+ * 422s with "Missing config, event_name" (F24/Resend automations).
229
+ * 3. Among ties, prefer object-typed variants over primitives
230
+ * (TASK-222: `Array<{id}>|Array<string>` should pick the object).
231
+ * 4. Prefer the variant with more declared properties (richer surface
232
+ * is closer to what real callers send).
233
+ * 5. When a `discriminator.propertyName` is present, treat variants
234
+ * that carry a single-value enum/const for that property as more
235
+ * authoritative — they tie-break ahead of variants without one.
236
+ *
237
+ * The discriminator's `mapping` is used to derive the stamped value
238
+ * even when the picked variant lacks an inline enum/const: typical
239
+ * specs (Stripe, Linear) declare mapping centrally and omit the value
240
+ * on each variant.
241
+ */
242
+ function pickBestVariant(
207
243
  variants: OpenAPIV3.SchemaObject[],
208
- propertyName: string | undefined,
209
- ): OpenAPIV3.SchemaObject | undefined {
210
- if (!propertyName) return undefined;
211
- for (const v of variants) {
212
- const prop = v.properties?.[propertyName] as OpenAPIV3.SchemaObject | undefined;
213
- if (!prop) continue;
244
+ discriminator: OpenAPIV3.DiscriminatorObject | undefined,
245
+ ): OpenAPIV3.SchemaObject {
246
+ const isNull = (s: OpenAPIV3.SchemaObject) => (s as { type?: unknown }).type === "null";
247
+ const nonNull = variants.filter(v => !isNull(v));
248
+ const pool = nonNull.length > 0 ? nonNull : variants;
249
+
250
+ const discriminatorKey = discriminator?.propertyName;
251
+ const hasDiscriminatorEnum = (v: OpenAPIV3.SchemaObject): boolean => {
252
+ if (!discriminatorKey) return false;
253
+ const prop = v.properties?.[discriminatorKey] as OpenAPIV3.SchemaObject | undefined;
254
+ if (!prop) return false;
214
255
  const en = (prop as { enum?: unknown[] }).enum;
215
256
  const cn = (prop as { const?: unknown }).const;
216
- if (Array.isArray(en) && en.length === 1) return v;
217
- if (cn !== undefined && cn !== null) return v;
218
- }
219
- return undefined;
257
+ return (Array.isArray(en) && en.length === 1) || (cn !== undefined && cn !== null);
258
+ };
259
+
260
+ const score = (v: OpenAPIV3.SchemaObject) => {
261
+ const req = (v.required ?? []) as string[];
262
+ const props = v.properties ?? {};
263
+ const unresolvable = req.filter(r => !(r in props)).length;
264
+ const propCount = Object.keys(props).length;
265
+ const isObjectWithProps = v.type === "object" && propCount > 0;
266
+ return {
267
+ unresolvable,
268
+ hasDiscriminator: hasDiscriminatorEnum(v) ? 1 : 0,
269
+ isObjectWithProps: isObjectWithProps ? 1 : 0,
270
+ propCount,
271
+ };
272
+ };
273
+
274
+ // Sort: fewer unresolvable → has discriminator → object-with-props → more props.
275
+ // Sort is stable, so original spec order breaks remaining ties.
276
+ const ranked = [...pool].sort((a, b) => {
277
+ const sa = score(a);
278
+ const sb = score(b);
279
+ if (sa.unresolvable !== sb.unresolvable) return sa.unresolvable - sb.unresolvable;
280
+ if (sa.hasDiscriminator !== sb.hasDiscriminator) return sb.hasDiscriminator - sa.hasDiscriminator;
281
+ if (sa.isObjectWithProps !== sb.isObjectWithProps) return sb.isObjectWithProps - sa.isObjectWithProps;
282
+ return sb.propCount - sa.propCount;
283
+ });
284
+
285
+ return ranked[0]!;
220
286
  }
221
287
 
222
288
  /** Stamp the discriminator key onto a generated object. Without this the
223
289
  * variant choice is "anonymous" from the body's point of view — APIs that
224
290
  * switch on `type` reject the request even when every other field is
225
- * perfect. No-op when the propertyName is missing or the variant lacks an
226
- * enum/const for that property. */
291
+ * perfect.
292
+ *
293
+ * ARV-135: now also honours `discriminator.mapping` — when the picked
294
+ * variant has no inline enum/const for the discriminator property, fall
295
+ * back to the first mapping key. Specs that declare polymorphism via
296
+ * central mapping (rather than inline `enum: ["x"]` on each variant)
297
+ * previously left the body un-stamped. */
227
298
  function stampDiscriminator(
228
299
  result: unknown,
229
300
  variant: OpenAPIV3.SchemaObject,
230
- propertyName: string | undefined,
301
+ discriminator: OpenAPIV3.DiscriminatorObject | undefined,
231
302
  ): unknown {
303
+ const propertyName = discriminator?.propertyName;
232
304
  if (!propertyName) return result;
233
305
  if (!result || typeof result !== "object" || Array.isArray(result)) return result;
234
- const prop = variant.properties?.[propertyName] as OpenAPIV3.SchemaObject | undefined;
235
- if (!prop) return result;
236
- const en = (prop as { enum?: unknown[] }).enum;
237
- const cn = (prop as { const?: unknown }).const;
238
306
  let stamp: unknown;
239
- if (Array.isArray(en) && en.length === 1) stamp = en[0];
240
- else if (cn !== undefined && cn !== null) stamp = cn;
241
- else return result;
307
+ const prop = variant.properties?.[propertyName] as OpenAPIV3.SchemaObject | undefined;
308
+ if (prop) {
309
+ const en = (prop as { enum?: unknown[] }).enum;
310
+ const cn = (prop as { const?: unknown }).const;
311
+ if (Array.isArray(en) && en.length === 1) stamp = en[0];
312
+ else if (cn !== undefined && cn !== null) stamp = cn;
313
+ }
314
+ if (stamp === undefined && discriminator?.mapping) {
315
+ const keys = Object.keys(discriminator.mapping);
316
+ if (keys.length > 0) stamp = keys[0];
317
+ }
318
+ if (stamp === undefined) return result;
242
319
  (result as Record<string, unknown>)[propertyName] = stamp;
243
320
  return result;
244
321
  }
245
322
 
246
- /** Prefer the most data-shape-informative variant from a oneOf/anyOf list:
247
- * object-with-properties > non-null > first. Skips `type: "null"` entries
248
- * introduced by 3.1 nullable shorthand. */
249
- function pickPreferredVariant(variants: OpenAPIV3.SchemaObject[]): OpenAPIV3.SchemaObject {
250
- const isNull = (s: OpenAPIV3.SchemaObject) =>
251
- (s as { type?: unknown }).type === "null";
252
- const nonNull = variants.filter(v => !isNull(v));
253
- const pool = nonNull.length > 0 ? nonNull : variants;
254
-
255
- const objectWithProps = pool.find(
256
- v => v.type === "object" && v.properties && Object.keys(v.properties).length > 0,
257
- );
258
- if (objectWithProps) return objectWithProps;
259
-
260
- return pool[0]!;
261
- }
262
-
263
323
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
264
324
 
265
325
  /** Names that strongly imply an email field. Kept in sync with the email
@@ -269,7 +329,9 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
269
329
  * email mapping. */
270
330
  function isEmailContextName(name?: string): boolean {
271
331
  if (!name) return false;
272
- const lower = name.toLowerCase();
332
+ // Canonical snake_case so camelCase (`replyTo`, `userEmail`) matches the
333
+ // same rules as `reply_to` / `user_email`.
334
+ const lower = canonicalVarName(name);
273
335
  return (
274
336
  lower === "email" ||
275
337
  lower === "from" ||
@@ -279,9 +341,7 @@ function isEmailContextName(name?: string): boolean {
279
341
  lower === "sender" ||
280
342
  lower === "recipient" ||
281
343
  lower === "reply_to" ||
282
- lower === "replyto" ||
283
344
  lower.endsWith("_email") ||
284
- lower.endsWith("Email") ||
285
345
  lower.endsWith("_reply_to") ||
286
346
  lower.endsWith("_from") ||
287
347
  lower.endsWith("_to") ||
@@ -398,7 +458,7 @@ export function classifyFieldSource(
398
458
  return "heuristic:domain-from-description";
399
459
  }
400
460
  if (propertyName) {
401
- const lower = propertyName.toLowerCase();
461
+ const lower = canonicalVarName(propertyName);
402
462
  if (lower === "slug" || lower.endsWith("_slug")) return "heuristic:slug";
403
463
  if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) return "heuristic:domain";
404
464
  if (lower === "platform") return "heuristic:platform";
@@ -412,13 +472,13 @@ export function classifyFieldSource(
412
472
  if (
413
473
  lower === "email" || lower === "from" || lower === "to" || lower === "cc" ||
414
474
  lower === "bcc" || lower === "sender" || lower === "recipient" ||
415
- lower === "reply_to" || lower === "replyto" ||
416
- lower.endsWith("_email") || lower.endsWith("Email") ||
475
+ lower === "reply_to" ||
476
+ lower.endsWith("_email") ||
417
477
  lower.endsWith("_reply_to") || lower.endsWith("_from") ||
418
478
  lower.endsWith("_to") || lower.endsWith("_cc") || lower.endsWith("_bcc")
419
479
  ) return "heuristic:email";
420
480
  if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) return "heuristic:id";
421
- if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) return "heuristic:name";
481
+ if (lower === "name" || lower.endsWith("_name")) return "heuristic:name";
422
482
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") return "heuristic:url";
423
483
  if (lower === "password" || lower.endsWith("_password")) return "heuristic:password";
424
484
  if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) return "heuristic:phone";
@@ -436,6 +496,83 @@ export function classifyFieldSource(
436
496
  return "default";
437
497
  }
438
498
 
499
+ /**
500
+ * ARV-138: canonicalise a body field name to a manifest/fixture var name.
501
+ * Converts camelCase → snake_case + lowercase so spec body fields
502
+ * (`issueId`, `sequenceTypeCode`) collapse onto the same var as the
503
+ * path-param spelling. Idempotent on already-snake_case input. The HTTP
504
+ * request still sends the raw field name — only the {{var}} namespace is
505
+ * normalised. Lives here (leaf module) so both fixtures-builder and
506
+ * suite-generator can share it without an import cycle.
507
+ */
508
+ export function canonicalVarName(name: string): string {
509
+ return name
510
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
511
+ .replace(/[^a-zA-Z0-9]+/g, "_")
512
+ .toLowerCase();
513
+ }
514
+
515
+ const FK_ID_SUFFIX = /(?:_id|Id|_uuid)$/;
516
+ const CODE_REF_SUFFIX = /(?:_code|Code)$/;
517
+
518
+ /**
519
+ * ARV-45: a required request-body field that is a foreign-key / closed-
520
+ * vocabulary reference the generator can't synthesise a valid value for —
521
+ * so the generated test should reference a `{{fixture}}` the user fills in
522
+ * `.env.yaml`, not random junk that 400s and kills the CRUD chain at step 1.
523
+ *
524
+ * - `*_id` / `*Id` / `*_uuid`: always an FK. `generateFromSchema` emits
525
+ * `{{$uuid}}` here — a random id that 404/422s on a live API.
526
+ * - `*_code` / `*Code`: a reference code ONLY when no heuristic resolves
527
+ * it. `countryCode`/`currencyCode`/`mcc`… already map to real literals
528
+ * via `guessStringPlaceholder`, so they're excluded; the domain codes
529
+ * that motivated this (`sequenceTypeCode`, `templateGroupCode`) fall
530
+ * through to `{{$randomString}}` → 400.
531
+ *
532
+ * Enum/format/example-backed fields are never FK fixtures — the spec
533
+ * already told us a valid value.
534
+ */
535
+ export function isFkFixtureField(name: string, schema: OpenAPIV3.SchemaObject): boolean {
536
+ if (schema.enum && schema.enum.length > 0) return false;
537
+ if (pickExampleValue(schema) !== undefined) return false;
538
+ if (FK_ID_SUFFIX.test(name)) return true;
539
+ if (CODE_REF_SUFFIX.test(name)) {
540
+ return generateFromSchema(schema, name) === "{{$randomString}}";
541
+ }
542
+ return false;
543
+ }
544
+
545
+ /**
546
+ * Flatten a request-body schema to its effective `{properties, required}`,
547
+ * merging `allOf` branches. .NET / Swagger-gen specs wrap almost every model
548
+ * in `allOf: [{...}]` (inheritance), so a direct `schema.properties` read sees
549
+ * nothing — `generateFromSchema` already merges allOf when producing the body,
550
+ * and FK wiring / the manifest must resolve the same shape or they silently
551
+ * miss every FK field on these specs. Recurses through nested allOf; ignores
552
+ * oneOf/anyOf (the generator picks one variant there, out of scope for FK
553
+ * wiring until a real spec needs it).
554
+ */
555
+ export function effectiveObjectShape(
556
+ schema: OpenAPIV3.SchemaObject,
557
+ ): { properties: Record<string, OpenAPIV3.SchemaObject>; required: Set<string> } {
558
+ const properties: Record<string, OpenAPIV3.SchemaObject> = {};
559
+ const required = new Set<string>();
560
+ const visit = (s: OpenAPIV3.SchemaObject | undefined) => {
561
+ if (!s || typeof s !== "object") return;
562
+ if (Array.isArray(s.allOf)) {
563
+ for (const sub of s.allOf) visit(sub as OpenAPIV3.SchemaObject);
564
+ }
565
+ if (s.properties) {
566
+ for (const [k, v] of Object.entries(s.properties)) {
567
+ properties[k] = v as OpenAPIV3.SchemaObject;
568
+ }
569
+ }
570
+ if (Array.isArray(s.required)) for (const r of s.required) required.add(r);
571
+ };
572
+ visit(schema);
573
+ return { properties, required };
574
+ }
575
+
439
576
  /**
440
577
  * Map an OpenAPI `format` value to a zond generator placeholder. Returns
441
578
  * undefined when the format is unknown or absent so callers can fall back
@@ -533,9 +670,12 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
533
670
  return "{{$randomDomain}}";
534
671
  }
535
672
 
536
- // Name-based heuristics
673
+ // Name-based heuristics. Match on the canonical snake_case form so
674
+ // camelCase fields (`countryCode`, `firstName`, `userEmail`) hit the same
675
+ // rules as their snake_case spelling — otherwise they fall through to
676
+ // {{$randomString}} and 400 on closed-vocab/format-strict APIs.
537
677
  if (name) {
538
- const lower = name.toLowerCase();
678
+ const lower = canonicalVarName(name);
539
679
  if (lower === "slug" || lower.endsWith("_slug")) {
540
680
  return "{{$randomSlug}}";
541
681
  }
@@ -575,9 +715,7 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
575
715
  lower === "sender" ||
576
716
  lower === "recipient" ||
577
717
  lower === "reply_to" ||
578
- lower === "replyto" ||
579
718
  lower.endsWith("_email") ||
580
- lower.endsWith("Email") ||
581
719
  lower.endsWith("_reply_to") ||
582
720
  lower.endsWith("_from") ||
583
721
  lower.endsWith("_to") ||
@@ -589,7 +727,7 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
589
727
  if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
590
728
  return "{{$uuid}}";
591
729
  }
592
- if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
730
+ if (lower === "name" || lower.endsWith("_name")) {
593
731
  return "{{$randomName}}";
594
732
  }
595
733
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {