@kirrosh/zond 0.22.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -3,18 +3,46 @@ import type { OpenAPIV3 } from "openapi-types";
3
3
  /**
4
4
  * Recursively generates test data from an OpenAPI schema.
5
5
  * Uses heuristic placeholders ({{$...}} generators) where possible.
6
+ *
7
+ * `forRequest` (default true) toggles request-body filters that strip
8
+ * server-assigned fields the client must not send: properties marked
9
+ * `readOnly: true`, and the literal field name `id` at any object level
10
+ * (universally server-assigned in REST). Pass `forRequest: false` to
11
+ * preserve full schema shape for response-side fixtures.
6
12
  */
7
13
  export function generateFromSchema(
8
14
  schema: OpenAPIV3.SchemaObject,
9
15
  propertyName?: string,
10
- _depth = 0,
16
+ opts: { _depth?: number; forRequest?: boolean } = {},
11
17
  ): unknown {
12
- if (_depth > 5) return {};
18
+ const _depth = opts._depth ?? 0;
19
+ const forRequest = opts.forRequest ?? true;
20
+ const recurse = (s: OpenAPIV3.SchemaObject, name?: string) =>
21
+ generateFromSchema(s, name, { _depth: _depth + 1, forRequest });
22
+
23
+ if (_depth > 7) {
24
+ return depthLimitDefault(schema, propertyName);
25
+ }
13
26
 
14
27
  // Highest-priority signal: explicit example from spec.
15
28
  // Beats enum, format, heuristics — the spec author told us what to send.
16
- if (schema.example !== undefined) {
17
- return schema.example;
29
+ // Two exceptions:
30
+ // 1. `null` examples are noise (often nullable: true with no real example) —
31
+ // skip so we fall through to type/format defaults instead of emitting null.
32
+ // 2. UUID-shaped examples on FK-context fields (name ends with `_id` or
33
+ // schema.format === "uuid") are usually copy-pasted from another tenant's
34
+ // spec. Honoring them leaks foreign IDs and guarantees 422 on real APIs;
35
+ // `{{$uuid}}` is at least an honest test placeholder.
36
+ //
37
+ // OpenAPI 3.1 / JSON Schema also allows `examples: [...]` (plural array). When
38
+ // both are present `example` wins; otherwise pick the first non-null entry
39
+ // from `examples` and apply the same FK-UUID guard. `example` (singular) is
40
+ // still the OpenAPI 3.0 form and remains supported.
41
+ const exampleValue = pickExampleValue(schema);
42
+ if (exampleValue !== undefined) {
43
+ if (!isLikelyForeignFKExample(schema, propertyName, exampleValue)) {
44
+ return exampleValue;
45
+ }
18
46
  }
19
47
 
20
48
  // allOf: merge all schemas
@@ -26,15 +54,31 @@ export function generateFromSchema(
26
54
  merged.properties = { ...merged.properties, ...s.properties };
27
55
  }
28
56
  }
29
- return generateFromSchema(merged, propertyName, _depth + 1);
57
+ return recurse(merged, propertyName);
30
58
  }
31
59
 
32
- // oneOf / anyOf: use first variant
60
+ // oneOf / anyOf: pick the most informative variant. Prefer objects with
61
+ // properties over loose primitives — APIs that accept `Array<{id}>|Array<string>`
62
+ // need the object variant, not a string that 422s. Falls back to first
63
+ // non-null entry.
64
+ //
65
+ // ARV-78 (feedback round-04 / F25): when the parent schema declares a
66
+ // `discriminator: { propertyName, mapping? }` (typical OpenAPI 3 polymorphism —
67
+ // /automations.steps with type=trigger|action), pick the variant whose
68
+ // discriminator property carries a const/enum-single value and stamp that
69
+ // value into the result. Without this, generator emits a random variant and
70
+ // the API 422s with "Missing <required-by-other-variant>".
33
71
  if (schema.oneOf) {
34
- return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
72
+ const variants = schema.oneOf as OpenAPIV3.SchemaObject[];
73
+ const picked = pickBestVariant(variants, schema.discriminator);
74
+ const result = recurse(picked, propertyName);
75
+ return stampDiscriminator(result, picked, schema.discriminator);
35
76
  }
36
77
  if (schema.anyOf) {
37
- return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
78
+ const variants = schema.anyOf as OpenAPIV3.SchemaObject[];
79
+ const picked = pickBestVariant(variants, schema.discriminator);
80
+ const result = recurse(picked, propertyName);
81
+ return stampDiscriminator(result, picked, schema.discriminator);
38
82
  }
39
83
 
40
84
  // enum: first value (always valid for the API contract)
@@ -52,10 +96,23 @@ export function generateFromSchema(
52
96
 
53
97
  // OpenAPI 3.1: type can be `["string", "null"]`. Collapse to the first
54
98
  // non-null entry so the switch below routes correctly.
55
- const effectiveType = Array.isArray(schema.type)
99
+ let effectiveType = Array.isArray(schema.type)
56
100
  ? (schema.type as string[]).find(t => t !== "null") as OpenAPIV3.SchemaObject["type"] | undefined
57
101
  : schema.type;
58
102
 
103
+ // ARV-67 (feedback round-01 / F7): schemas in the wild routinely omit
104
+ // `type` on nested-object fields and rely on `properties` / `required`
105
+ // / `items` to convey shape. Without the salvage below, the default
106
+ // branch returns "{{$randomString}}" for a missing-type field — which
107
+ // is what made `prepare-fixtures --seed` send a string for nested
108
+ // objects like `automations.config` / `automations.steps` and earn
109
+ // "Expected object, received string" 422s. Infer the type
110
+ // from structural hints when nothing else gives one.
111
+ if (effectiveType === undefined) {
112
+ if ((schema as { items?: unknown }).items !== undefined) effectiveType = "array";
113
+ else if (schema.properties || Array.isArray(schema.required)) effectiveType = "object";
114
+ }
115
+
59
116
  switch (effectiveType) {
60
117
  case "string":
61
118
  return guessStringPlaceholder(schema, propertyName);
@@ -72,7 +129,7 @@ export function generateFromSchema(
72
129
  case "array": {
73
130
  const arr = schema as OpenAPIV3.ArraySchemaObject;
74
131
  if (arr.items) {
75
- const item = generateFromSchema(arr.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
132
+ const item = recurse(arr.items as OpenAPIV3.SchemaObject, undefined);
76
133
  return [item];
77
134
  }
78
135
  return [];
@@ -84,17 +141,24 @@ export function generateFromSchema(
84
141
  if (schema.properties) {
85
142
  const obj: Record<string, unknown> = {};
86
143
  for (const [key, propSchema] of Object.entries(schema.properties)) {
87
- obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key, _depth + 1);
144
+ const ps = propSchema as OpenAPIV3.SchemaObject;
145
+ if (forRequest && shouldSkipForRequest(key, ps)) continue;
146
+ obj[key] = recurse(ps, key);
88
147
  }
89
148
  return obj;
90
149
  }
91
- // Record type: additionalProperties defines value schema
92
- if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
93
- const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
94
- return { key1: generateFromSchema(valSchema, "key1", _depth + 1), key2: generateFromSchema(valSchema, "key2", _depth + 1) };
95
- }
96
- if (schema.additionalProperties === true) {
97
- return { key1: "value1", key2: "value2" };
150
+ // Record type (additionalProperties only). The historical behavior was
151
+ // to materialize fake `key1`/`key2` entries to make the shape visible.
152
+ // Real APIs reject those — the keys are always domain-specific
153
+ // (filter names, label keys). Emit an empty `{}` instead: it preserves
154
+ // the object type (so type-validators pass) without injecting payloads
155
+ // the server didn't ask for. Callers who need realistic record content
156
+ // should override via fixture-pack/.env.yaml.
157
+ if (
158
+ (schema.additionalProperties && typeof schema.additionalProperties === "object") ||
159
+ schema.additionalProperties === true
160
+ ) {
161
+ return {};
98
162
  }
99
163
  // Bare object with no properties
100
164
  if (effectiveType === "object") {
@@ -105,6 +169,410 @@ export function generateFromSchema(
105
169
  }
106
170
  }
107
171
 
172
+ /** Fields the client must not send in a request body: explicit `readOnly: true`,
173
+ * or the literal name `id`. The latter is a heuristic for under-specified specs
174
+ * (common in in-house APIs) that don't mark the server-assigned id readOnly
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. */
184
+ function shouldSkipForRequest(name: string, schema: OpenAPIV3.SchemaObject): boolean {
185
+ if (schema.readOnly === true) return true;
186
+ if (name === "id") return true;
187
+ if (name === "expand" && isStringArray(schema)) return true;
188
+ return false;
189
+ }
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
+
198
+ /** When recursion hits the depth cap, return a type-appropriate placeholder
199
+ * rather than `{}` — `{}` for `array<string>` produces `[{}]` which 422s on
200
+ * every realistic API. */
201
+ function depthLimitDefault(schema: OpenAPIV3.SchemaObject, name?: string): unknown {
202
+ const t = Array.isArray(schema.type)
203
+ ? (schema.type as string[]).find(x => x !== "null")
204
+ : schema.type;
205
+ switch (t) {
206
+ case "string": return formatToPlaceholder(schema.format) ?? guessStringPlaceholder(schema, name);
207
+ case "integer": return 1;
208
+ case "number": return 1;
209
+ case "boolean": return true;
210
+ case "array": return [];
211
+ case "object":
212
+ default: return {};
213
+ }
214
+ }
215
+
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(
243
+ variants: OpenAPIV3.SchemaObject[],
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;
255
+ const en = (prop as { enum?: unknown[] }).enum;
256
+ const cn = (prop as { const?: unknown }).const;
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]!;
286
+ }
287
+
288
+ /** Stamp the discriminator key onto a generated object. Without this the
289
+ * variant choice is "anonymous" from the body's point of view — APIs that
290
+ * switch on `type` reject the request even when every other field is
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. */
298
+ function stampDiscriminator(
299
+ result: unknown,
300
+ variant: OpenAPIV3.SchemaObject,
301
+ discriminator: OpenAPIV3.DiscriminatorObject | undefined,
302
+ ): unknown {
303
+ const propertyName = discriminator?.propertyName;
304
+ if (!propertyName) return result;
305
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
306
+ let stamp: unknown;
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;
319
+ (result as Record<string, unknown>)[propertyName] = stamp;
320
+ return result;
321
+ }
322
+
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;
324
+
325
+ /** Names that strongly imply an email field. Kept in sync with the email
326
+ * branch of `guessStringPlaceholder`/`classifyFieldSource`. Used to gate the
327
+ * description-based domain heuristic so phrases like "verified sending
328
+ * domain" in the description of a `from`/`to` field don't override the
329
+ * email mapping. */
330
+ function isEmailContextName(name?: string): boolean {
331
+ if (!name) return false;
332
+ // Canonical snake_case so camelCase (`replyTo`, `userEmail`) matches the
333
+ // same rules as `reply_to` / `user_email`.
334
+ const lower = canonicalVarName(name);
335
+ return (
336
+ lower === "email" ||
337
+ lower === "from" ||
338
+ lower === "to" ||
339
+ lower === "cc" ||
340
+ lower === "bcc" ||
341
+ lower === "sender" ||
342
+ lower === "recipient" ||
343
+ lower === "reply_to" ||
344
+ lower.endsWith("_email") ||
345
+ lower.endsWith("_reply_to") ||
346
+ lower.endsWith("_from") ||
347
+ lower.endsWith("_to") ||
348
+ lower.endsWith("_cc") ||
349
+ lower.endsWith("_bcc")
350
+ );
351
+ }
352
+
353
+ /** A schema `pattern` that explicitly allows lowercase but not uppercase
354
+ * letters (typical slug regex like `^[a-z0-9_\-]+$`). Used to switch from
355
+ * mixed-case `{{$randomString}}` to a slug-shaped generator. */
356
+ function isLowercaseOnlyPattern(pattern: string | undefined): boolean {
357
+ if (!pattern) return false;
358
+ return pattern.includes("a-z") && !pattern.includes("A-Z");
359
+ }
360
+
361
+ /** A string example shaped like a UUID, on a field that looks like a foreign
362
+ * key (name ends with `_id` or schema declares `format: uuid`), is almost
363
+ * always a tenant-specific value the spec author left in `example:`. Sending
364
+ * it verbatim guarantees 422 on a fresh account and leaks foreign IDs. */
365
+ function isLikelyForeignFKExample(
366
+ schema: OpenAPIV3.SchemaObject,
367
+ name?: string,
368
+ value?: unknown,
369
+ ): boolean {
370
+ const ex = value !== undefined ? value : schema.example;
371
+ if (typeof ex !== "string") return false;
372
+ if (!UUID_RE.test(ex)) return false;
373
+ const fkByName = !!name && name.toLowerCase().endsWith("_id");
374
+ const fkByFormat = schema.format === "uuid";
375
+ return fkByName || fkByFormat;
376
+ }
377
+
378
+ /** Resolve the effective example value from a schema, supporting both
379
+ * OpenAPI 3.0 `example` (singular) and OpenAPI 3.1 / JSON Schema `examples`
380
+ * (plural array). `example` wins when both are set — it's the more
381
+ * intentional, single-source signal. `null` is treated as "no example"
382
+ * (see TASK-221). For `examples`, we pick the first non-null entry. */
383
+ function pickExampleValue(schema: OpenAPIV3.SchemaObject): unknown {
384
+ if (schema.example !== undefined && schema.example !== null) {
385
+ return schema.example;
386
+ }
387
+ const examples = (schema as { examples?: unknown }).examples;
388
+ if (Array.isArray(examples)) {
389
+ for (const ex of examples) {
390
+ if (ex !== null && ex !== undefined) return ex;
391
+ }
392
+ }
393
+ return undefined;
394
+ }
395
+
396
+ /**
397
+ * TASK-269 — per-field provenance for `zond generate --explain`.
398
+ *
399
+ * Returns a label describing *why* `generateFromSchema` would emit the
400
+ * value it does for a given (schema, propertyName). Mirrors the dispatch
401
+ * priority in `generateFromSchema` without producing the value, so
402
+ * `--explain` can show "name → {{$randomName}} [heuristic:name]" without
403
+ * re-executing generation.
404
+ *
405
+ * Kept as a parallel function instead of refactoring `generateFromSchema`
406
+ * to record sources — the recursion path-tracking complexity would
407
+ * outweigh the value for what is currently a debug-only surface. The
408
+ * heuristic order here MUST stay in lockstep with the function above; a
409
+ * unit test (data-factory.test.ts) pins the labels for each branch.
410
+ */
411
+ export type FieldSource =
412
+ | "example"
413
+ | "examples"
414
+ | "enum"
415
+ | "format"
416
+ | "pattern"
417
+ | "min"
418
+ | "max"
419
+ | "random"
420
+ | "default"
421
+ | `heuristic:${string}`;
422
+
423
+ export function classifyFieldSource(
424
+ schema: OpenAPIV3.SchemaObject,
425
+ propertyName?: string,
426
+ ): FieldSource {
427
+ // example > examples (3.1) — same FK-UUID guard as generateFromSchema.
428
+ if (schema.example !== undefined && schema.example !== null) {
429
+ if (!isLikelyForeignFKExample(schema, propertyName, schema.example)) {
430
+ return "example";
431
+ }
432
+ }
433
+ const examples = (schema as { examples?: unknown }).examples;
434
+ if (Array.isArray(examples)) {
435
+ for (const ex of examples) {
436
+ if (ex === null || ex === undefined) continue;
437
+ if (!isLikelyForeignFKExample(schema, propertyName, ex)) return "examples";
438
+ break;
439
+ }
440
+ }
441
+ if (schema.enum && schema.enum.length > 0) return "enum";
442
+ if (formatToPlaceholder(schema.format) !== undefined) return "format";
443
+
444
+ const t = Array.isArray(schema.type)
445
+ ? (schema.type as string[]).find(x => x !== "null")
446
+ : schema.type;
447
+
448
+ if (t === "string") {
449
+ // ARV-38: keep --explain in sync with guessStringPlaceholder — when a
450
+ // default is consumed, label the source as "default", not "random".
451
+ if (typeof schema.default === "string" && schema.default.length > 0) return "default";
452
+ if (isLowercaseOnlyPattern(schema.pattern)) return "pattern";
453
+ if (
454
+ schema.description &&
455
+ /\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
456
+ !isEmailContextName(propertyName)
457
+ ) {
458
+ return "heuristic:domain-from-description";
459
+ }
460
+ if (propertyName) {
461
+ const lower = canonicalVarName(propertyName);
462
+ if (lower === "slug" || lower.endsWith("_slug")) return "heuristic:slug";
463
+ if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) return "heuristic:domain";
464
+ if (lower === "platform") return "heuristic:platform";
465
+ if (lower === "language" || lower === "lang" || lower === "locale") return "heuristic:locale";
466
+ if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "heuristic:country";
467
+ if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "heuristic:timezone";
468
+ if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "heuristic:currency";
469
+ if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "heuristic:mcc";
470
+ if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "heuristic:color";
471
+ if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "heuristic:ip";
472
+ if (
473
+ lower === "email" || lower === "from" || lower === "to" || lower === "cc" ||
474
+ lower === "bcc" || lower === "sender" || lower === "recipient" ||
475
+ lower === "reply_to" ||
476
+ lower.endsWith("_email") ||
477
+ lower.endsWith("_reply_to") || lower.endsWith("_from") ||
478
+ lower.endsWith("_to") || lower.endsWith("_cc") || lower.endsWith("_bcc")
479
+ ) return "heuristic:email";
480
+ if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) return "heuristic:id";
481
+ if (lower === "name" || lower.endsWith("_name")) return "heuristic:name";
482
+ if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") return "heuristic:url";
483
+ if (lower === "password" || lower.endsWith("_password")) return "heuristic:password";
484
+ if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) return "heuristic:phone";
485
+ }
486
+ return "random";
487
+ }
488
+
489
+ if (t === "integer") {
490
+ if (schema.maximum !== undefined) return "max";
491
+ if (schema.minimum !== undefined && schema.minimum > 0) return "min";
492
+ return "random";
493
+ }
494
+
495
+ if (t === "number" || t === "boolean") return "default";
496
+ return "default";
497
+ }
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
+
108
576
  /**
109
577
  * Map an OpenAPI `format` value to a zond generator placeholder. Returns
110
578
  * undefined when the format is unknown or absent so callers can fall back
@@ -122,6 +590,20 @@ export function formatToPlaceholder(format: string | undefined): string | undefi
122
590
  case "ipv4": return "{{$randomIpv4}}";
123
591
  case "ipv6": return "::1";
124
592
  case "password": return "TestPass123!";
593
+ // ARV-165: format-aware helpers. None of these are standard OpenAPI 3.x
594
+ // formats, but Stripe/GitHub/Shopify/Twilio specs frequently carry them
595
+ // as ad-hoc `format:` tags. Falling through to {{$randomString}} guarantees
596
+ // 400 from format-validated APIs (R09 finding: 199 hit-but-fail Stripe steps).
597
+ case "iso-country-code":
598
+ case "country-code":
599
+ case "country": return "{{$randomCountryCode}}";
600
+ case "iso-currency-code":
601
+ case "currency-code":
602
+ case "currency": return "{{$randomCurrencyCode}}";
603
+ case "mcc": return "{{$randomMCC}}";
604
+ case "color":
605
+ case "hex-color":
606
+ case "rgb-hex": return "{{$randomColorHex}}";
125
607
  default: return undefined;
126
608
  }
127
609
  }
@@ -139,6 +621,7 @@ export function generateMultipartFromSchema(
139
621
 
140
622
  for (const [key, propSchema] of Object.entries(schema.properties)) {
141
623
  const s = propSchema as OpenAPIV3.SchemaObject;
624
+ if (shouldSkipForRequest(key, s)) continue;
142
625
  if (s.format === "binary" || s.format === "byte") {
143
626
  result[key] = { file: `./fixtures/${key}.bin`, content_type: "application/octet-stream" };
144
627
  } else {
@@ -154,16 +637,97 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
154
637
  // Format-based dispatch already happened earlier in generateFromSchema;
155
638
  // this branch only sees strings whose format is empty or unrecognised.
156
639
 
157
- // Name-based heuristics
640
+ // ARV-38: when the spec declares a JSON-Schema `default` for a string-typed
641
+ // field with no enum, prefer it over heuristics. PATCH endpoints in
642
+ // particular rely on this — e.g. a `PATCH /domains/{id}` with
643
+ // `tls: { type: string, default: "opportunistic" }` would otherwise get
644
+ // a random fallback and a guaranteed 422 every run.
645
+ if (typeof schema.default === "string" && schema.default.length > 0) {
646
+ return schema.default;
647
+ }
648
+
649
+ // Pattern-aware: many specs constrain slugs via regex like
650
+ // `^(?![0-9]+$)[a-z0-9_\-]+$` without setting `format`. Default
651
+ // `{{$randomString}}` mixes upper+lower → 400 from the validator.
652
+ // Heuristic: pattern allows `a-z` but forbids `A-Z` → emit a slug.
653
+ if (isLowercaseOnlyPattern(schema.pattern)) {
654
+ return "{{$randomSlug}}";
655
+ }
656
+
657
+ // Description-aware: when the schema describes a domain/hostname (e.g.
658
+ // a `POST /domains/`-style endpoint or DNS-zone create route) but the
659
+ // field is generically named `name`, the default `{{$randomName}}`
660
+ // returns "Bob Wilson" and the server rejects it. TASK-224.
661
+ // Skip when the field name is clearly in email vocabulary — email-API
662
+ // specs often describe `from`/`to`/etc. with phrases like "verified
663
+ // sending domain" or "Name <user@domain>", which trips the regex but
664
+ // the field is an email, not a domain. Email vocab > domain-from-description.
665
+ if (
666
+ schema.description &&
667
+ /\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
668
+ !isEmailContextName(name)
669
+ ) {
670
+ return "{{$randomDomain}}";
671
+ }
672
+
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.
158
677
  if (name) {
159
- const lower = name.toLowerCase();
160
- if (lower === "email" || lower.endsWith("_email") || lower.endsWith("Email")) {
678
+ const lower = canonicalVarName(name);
679
+ if (lower === "slug" || lower.endsWith("_slug")) {
680
+ return "{{$randomSlug}}";
681
+ }
682
+ if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) {
683
+ return "{{$randomDomain}}";
684
+ }
685
+ // Closed-vocabulary fields where servers validate against an internal
686
+ // dictionary even when the spec lacks `enum:`. Random strings → 400.
687
+ // Pick the most universally-accepted value per dictionary.
688
+ if (lower === "platform") return "python";
689
+ if (lower === "language" || lower === "lang" || lower === "locale") return "en";
690
+ // ARV-165: country/currency literals (US/USD) were universally accepted
691
+ // but offered zero variety — added endsWith() patterns so nested fields
692
+ // like `bank_account.country`, `payout.currency_code`, `from_country`
693
+ // also resolve. Still emit a literal — picking from the random helper
694
+ // would weaken the "always-valid" property for downstream assertions
695
+ // that pin on the first value.
696
+ if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "US";
697
+ if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "UTC";
698
+ if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "USD";
699
+ // ARV-165: MCC (merchant category code) — Stripe/Square/issuing APIs.
700
+ // Random {{$randomString}} → 400 because it's not a 4-digit code.
701
+ if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "{{$randomMCC}}";
702
+ // ARV-165: hex color — Stripe brand settings, Slack themes, GitHub labels.
703
+ if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "{{$randomColorHex}}";
704
+ // ARV-165: IP addresses — Stripe tos_acceptance.ip, audit logs, fraud APIs.
705
+ if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "{{$randomIpv4}}";
706
+ // Email-context fields. Email-API specs often
707
+ // omit `format: email` on `from`/`to`/`reply_to`/`cc`/`bcc` — the field
708
+ // name is the only clue, and `{{$randomString}}` guarantees a 422.
709
+ if (
710
+ lower === "email" ||
711
+ lower === "from" ||
712
+ lower === "to" ||
713
+ lower === "cc" ||
714
+ lower === "bcc" ||
715
+ lower === "sender" ||
716
+ lower === "recipient" ||
717
+ lower === "reply_to" ||
718
+ lower.endsWith("_email") ||
719
+ lower.endsWith("_reply_to") ||
720
+ lower.endsWith("_from") ||
721
+ lower.endsWith("_to") ||
722
+ lower.endsWith("_cc") ||
723
+ lower.endsWith("_bcc")
724
+ ) {
161
725
  return "{{$randomEmail}}";
162
726
  }
163
727
  if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
164
728
  return "{{$uuid}}";
165
729
  }
166
- if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
730
+ if (lower === "name" || lower.endsWith("_name")) {
167
731
  return "{{$randomName}}";
168
732
  }
169
733
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {