@kirrosh/zond 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +55 -6
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -21
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -3,13 +3,47 @@ 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
+ }
26
+
27
+ // Highest-priority signal: explicit example from spec.
28
+ // Beats enum, format, heuristics — the spec author told us what to send.
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
+ }
46
+ }
13
47
 
14
48
  // allOf: merge all schemas
15
49
  if (schema.allOf) {
@@ -20,26 +54,68 @@ export function generateFromSchema(
20
54
  merged.properties = { ...merged.properties, ...s.properties };
21
55
  }
22
56
  }
23
- return generateFromSchema(merged, propertyName, _depth + 1);
57
+ return recurse(merged, propertyName);
24
58
  }
25
59
 
26
- // 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>".
27
71
  if (schema.oneOf) {
28
- return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
72
+ const variants = schema.oneOf as OpenAPIV3.SchemaObject[];
73
+ const picked = pickDiscriminatorVariant(variants, schema.discriminator?.propertyName)
74
+ ?? pickPreferredVariant(variants);
75
+ const result = recurse(picked, propertyName);
76
+ return stampDiscriminator(result, picked, schema.discriminator?.propertyName);
29
77
  }
30
78
  if (schema.anyOf) {
31
- return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
79
+ const variants = schema.anyOf as OpenAPIV3.SchemaObject[];
80
+ const picked = pickDiscriminatorVariant(variants, schema.discriminator?.propertyName)
81
+ ?? pickPreferredVariant(variants);
82
+ const result = recurse(picked, propertyName);
83
+ return stampDiscriminator(result, picked, schema.discriminator?.propertyName);
32
84
  }
33
85
 
34
- // enum: first value
86
+ // enum: first value (always valid for the API contract)
35
87
  if (schema.enum && schema.enum.length > 0) {
36
88
  return schema.enum[0];
37
89
  }
38
90
 
39
- // uuid format overrides type (e.g. integer fields with format: uuid)
40
- if (schema.format === "uuid") return "{{$uuid}}";
91
+ // Format-based placeholders override type resolution. Schemas in the wild
92
+ // commonly carry `format` without an explicit `type` (loosely-defined specs)
93
+ // or with `type: ["string", "null"]` (OpenAPI 3.1 nullable). Falling through
94
+ // to the type switch in those cases dropped us into the default branch and
95
+ // produced `{{$randomString}}` for `format: email` — TASK-86 regression.
96
+ const formatPlaceholder = formatToPlaceholder(schema.format);
97
+ if (formatPlaceholder !== undefined) return formatPlaceholder;
41
98
 
42
- switch (schema.type) {
99
+ // OpenAPI 3.1: type can be `["string", "null"]`. Collapse to the first
100
+ // non-null entry so the switch below routes correctly.
101
+ let effectiveType = Array.isArray(schema.type)
102
+ ? (schema.type as string[]).find(t => t !== "null") as OpenAPIV3.SchemaObject["type"] | undefined
103
+ : schema.type;
104
+
105
+ // ARV-67 (feedback round-01 / F7): schemas in the wild routinely omit
106
+ // `type` on nested-object fields and rely on `properties` / `required`
107
+ // / `items` to convey shape. Without the salvage below, the default
108
+ // branch returns "{{$randomString}}" for a missing-type field — which
109
+ // is what made `prepare-fixtures --seed` send a string for nested
110
+ // objects like `automations.config` / `automations.steps` and earn
111
+ // "Expected object, received string" 422s. Infer the type
112
+ // from structural hints when nothing else gives one.
113
+ if (effectiveType === undefined) {
114
+ if ((schema as { items?: unknown }).items !== undefined) effectiveType = "array";
115
+ else if (schema.properties || Array.isArray(schema.required)) effectiveType = "object";
116
+ }
117
+
118
+ switch (effectiveType) {
43
119
  case "string":
44
120
  return guessStringPlaceholder(schema, propertyName);
45
121
 
@@ -53,8 +129,9 @@ export function generateFromSchema(
53
129
  return true;
54
130
 
55
131
  case "array": {
56
- if (schema.items) {
57
- const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
132
+ const arr = schema as OpenAPIV3.ArraySchemaObject;
133
+ if (arr.items) {
134
+ const item = recurse(arr.items as OpenAPIV3.SchemaObject, undefined);
58
135
  return [item];
59
136
  }
60
137
  return [];
@@ -66,20 +143,27 @@ export function generateFromSchema(
66
143
  if (schema.properties) {
67
144
  const obj: Record<string, unknown> = {};
68
145
  for (const [key, propSchema] of Object.entries(schema.properties)) {
69
- obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key, _depth + 1);
146
+ const ps = propSchema as OpenAPIV3.SchemaObject;
147
+ if (forRequest && shouldSkipForRequest(key, ps)) continue;
148
+ obj[key] = recurse(ps, key);
70
149
  }
71
150
  return obj;
72
151
  }
73
- // Record type: additionalProperties defines value schema
74
- if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
75
- const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
76
- return { key1: generateFromSchema(valSchema, "key1", _depth + 1), key2: generateFromSchema(valSchema, "key2", _depth + 1) };
77
- }
78
- if (schema.additionalProperties === true) {
79
- return { key1: "value1", key2: "value2" };
152
+ // Record type (additionalProperties only). The historical behavior was
153
+ // to materialize fake `key1`/`key2` entries to make the shape visible.
154
+ // Real APIs reject those — the keys are always domain-specific
155
+ // (filter names, label keys). Emit an empty `{}` instead: it preserves
156
+ // the object type (so type-validators pass) without injecting payloads
157
+ // the server didn't ask for. Callers who need realistic record content
158
+ // should override via fixture-pack/.env.yaml.
159
+ if (
160
+ (schema.additionalProperties && typeof schema.additionalProperties === "object") ||
161
+ schema.additionalProperties === true
162
+ ) {
163
+ return {};
80
164
  }
81
165
  // Bare object with no properties
82
- if (schema.type === "object") {
166
+ if (effectiveType === "object") {
83
167
  return {};
84
168
  }
85
169
  return "{{$randomString}}";
@@ -87,6 +171,306 @@ export function generateFromSchema(
87
171
  }
88
172
  }
89
173
 
174
+ /** Fields the client must not send in a request body: explicit `readOnly: true`,
175
+ * or the literal name `id`. The latter is a heuristic for under-specified specs
176
+ * (common in in-house APIs) that don't mark the server-assigned id readOnly
177
+ * but still 4xx on it being present. */
178
+ function shouldSkipForRequest(name: string, schema: OpenAPIV3.SchemaObject): boolean {
179
+ if (schema.readOnly === true) return true;
180
+ if (name === "id") return true;
181
+ return false;
182
+ }
183
+
184
+ /** When recursion hits the depth cap, return a type-appropriate placeholder
185
+ * rather than `{}` — `{}` for `array<string>` produces `[{}]` which 422s on
186
+ * every realistic API. */
187
+ function depthLimitDefault(schema: OpenAPIV3.SchemaObject, name?: string): unknown {
188
+ const t = Array.isArray(schema.type)
189
+ ? (schema.type as string[]).find(x => x !== "null")
190
+ : schema.type;
191
+ switch (t) {
192
+ case "string": return formatToPlaceholder(schema.format) ?? guessStringPlaceholder(schema, name);
193
+ case "integer": return 1;
194
+ case "number": return 1;
195
+ case "boolean": return true;
196
+ case "array": return [];
197
+ case "object":
198
+ default: return {};
199
+ }
200
+ }
201
+
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(
207
+ 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;
214
+ const en = (prop as { enum?: unknown[] }).enum;
215
+ 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;
220
+ }
221
+
222
+ /** Stamp the discriminator key onto a generated object. Without this the
223
+ * variant choice is "anonymous" from the body's point of view — APIs that
224
+ * 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. */
227
+ function stampDiscriminator(
228
+ result: unknown,
229
+ variant: OpenAPIV3.SchemaObject,
230
+ propertyName: string | undefined,
231
+ ): unknown {
232
+ if (!propertyName) return result;
233
+ 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
+ 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;
242
+ (result as Record<string, unknown>)[propertyName] = stamp;
243
+ return result;
244
+ }
245
+
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
+ 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
+
265
+ /** Names that strongly imply an email field. Kept in sync with the email
266
+ * branch of `guessStringPlaceholder`/`classifyFieldSource`. Used to gate the
267
+ * description-based domain heuristic so phrases like "verified sending
268
+ * domain" in the description of a `from`/`to` field don't override the
269
+ * email mapping. */
270
+ function isEmailContextName(name?: string): boolean {
271
+ if (!name) return false;
272
+ const lower = name.toLowerCase();
273
+ return (
274
+ lower === "email" ||
275
+ lower === "from" ||
276
+ lower === "to" ||
277
+ lower === "cc" ||
278
+ lower === "bcc" ||
279
+ lower === "sender" ||
280
+ lower === "recipient" ||
281
+ lower === "reply_to" ||
282
+ lower === "replyto" ||
283
+ lower.endsWith("_email") ||
284
+ lower.endsWith("Email") ||
285
+ lower.endsWith("_reply_to") ||
286
+ lower.endsWith("_from") ||
287
+ lower.endsWith("_to") ||
288
+ lower.endsWith("_cc") ||
289
+ lower.endsWith("_bcc")
290
+ );
291
+ }
292
+
293
+ /** A schema `pattern` that explicitly allows lowercase but not uppercase
294
+ * letters (typical slug regex like `^[a-z0-9_\-]+$`). Used to switch from
295
+ * mixed-case `{{$randomString}}` to a slug-shaped generator. */
296
+ function isLowercaseOnlyPattern(pattern: string | undefined): boolean {
297
+ if (!pattern) return false;
298
+ return pattern.includes("a-z") && !pattern.includes("A-Z");
299
+ }
300
+
301
+ /** A string example shaped like a UUID, on a field that looks like a foreign
302
+ * key (name ends with `_id` or schema declares `format: uuid`), is almost
303
+ * always a tenant-specific value the spec author left in `example:`. Sending
304
+ * it verbatim guarantees 422 on a fresh account and leaks foreign IDs. */
305
+ function isLikelyForeignFKExample(
306
+ schema: OpenAPIV3.SchemaObject,
307
+ name?: string,
308
+ value?: unknown,
309
+ ): boolean {
310
+ const ex = value !== undefined ? value : schema.example;
311
+ if (typeof ex !== "string") return false;
312
+ if (!UUID_RE.test(ex)) return false;
313
+ const fkByName = !!name && name.toLowerCase().endsWith("_id");
314
+ const fkByFormat = schema.format === "uuid";
315
+ return fkByName || fkByFormat;
316
+ }
317
+
318
+ /** Resolve the effective example value from a schema, supporting both
319
+ * OpenAPI 3.0 `example` (singular) and OpenAPI 3.1 / JSON Schema `examples`
320
+ * (plural array). `example` wins when both are set — it's the more
321
+ * intentional, single-source signal. `null` is treated as "no example"
322
+ * (see TASK-221). For `examples`, we pick the first non-null entry. */
323
+ function pickExampleValue(schema: OpenAPIV3.SchemaObject): unknown {
324
+ if (schema.example !== undefined && schema.example !== null) {
325
+ return schema.example;
326
+ }
327
+ const examples = (schema as { examples?: unknown }).examples;
328
+ if (Array.isArray(examples)) {
329
+ for (const ex of examples) {
330
+ if (ex !== null && ex !== undefined) return ex;
331
+ }
332
+ }
333
+ return undefined;
334
+ }
335
+
336
+ /**
337
+ * TASK-269 — per-field provenance for `zond generate --explain`.
338
+ *
339
+ * Returns a label describing *why* `generateFromSchema` would emit the
340
+ * value it does for a given (schema, propertyName). Mirrors the dispatch
341
+ * priority in `generateFromSchema` without producing the value, so
342
+ * `--explain` can show "name → {{$randomName}} [heuristic:name]" without
343
+ * re-executing generation.
344
+ *
345
+ * Kept as a parallel function instead of refactoring `generateFromSchema`
346
+ * to record sources — the recursion path-tracking complexity would
347
+ * outweigh the value for what is currently a debug-only surface. The
348
+ * heuristic order here MUST stay in lockstep with the function above; a
349
+ * unit test (data-factory.test.ts) pins the labels for each branch.
350
+ */
351
+ export type FieldSource =
352
+ | "example"
353
+ | "examples"
354
+ | "enum"
355
+ | "format"
356
+ | "pattern"
357
+ | "min"
358
+ | "max"
359
+ | "random"
360
+ | "default"
361
+ | `heuristic:${string}`;
362
+
363
+ export function classifyFieldSource(
364
+ schema: OpenAPIV3.SchemaObject,
365
+ propertyName?: string,
366
+ ): FieldSource {
367
+ // example > examples (3.1) — same FK-UUID guard as generateFromSchema.
368
+ if (schema.example !== undefined && schema.example !== null) {
369
+ if (!isLikelyForeignFKExample(schema, propertyName, schema.example)) {
370
+ return "example";
371
+ }
372
+ }
373
+ const examples = (schema as { examples?: unknown }).examples;
374
+ if (Array.isArray(examples)) {
375
+ for (const ex of examples) {
376
+ if (ex === null || ex === undefined) continue;
377
+ if (!isLikelyForeignFKExample(schema, propertyName, ex)) return "examples";
378
+ break;
379
+ }
380
+ }
381
+ if (schema.enum && schema.enum.length > 0) return "enum";
382
+ if (formatToPlaceholder(schema.format) !== undefined) return "format";
383
+
384
+ const t = Array.isArray(schema.type)
385
+ ? (schema.type as string[]).find(x => x !== "null")
386
+ : schema.type;
387
+
388
+ if (t === "string") {
389
+ // ARV-38: keep --explain in sync with guessStringPlaceholder — when a
390
+ // default is consumed, label the source as "default", not "random".
391
+ if (typeof schema.default === "string" && schema.default.length > 0) return "default";
392
+ if (isLowercaseOnlyPattern(schema.pattern)) return "pattern";
393
+ if (
394
+ schema.description &&
395
+ /\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
396
+ !isEmailContextName(propertyName)
397
+ ) {
398
+ return "heuristic:domain-from-description";
399
+ }
400
+ if (propertyName) {
401
+ const lower = propertyName.toLowerCase();
402
+ if (lower === "slug" || lower.endsWith("_slug")) return "heuristic:slug";
403
+ if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) return "heuristic:domain";
404
+ if (lower === "platform") return "heuristic:platform";
405
+ if (lower === "language" || lower === "lang" || lower === "locale") return "heuristic:locale";
406
+ if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "heuristic:country";
407
+ if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "heuristic:timezone";
408
+ if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "heuristic:currency";
409
+ if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "heuristic:mcc";
410
+ if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "heuristic:color";
411
+ if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "heuristic:ip";
412
+ if (
413
+ lower === "email" || lower === "from" || lower === "to" || lower === "cc" ||
414
+ lower === "bcc" || lower === "sender" || lower === "recipient" ||
415
+ lower === "reply_to" || lower === "replyto" ||
416
+ lower.endsWith("_email") || lower.endsWith("Email") ||
417
+ lower.endsWith("_reply_to") || lower.endsWith("_from") ||
418
+ lower.endsWith("_to") || lower.endsWith("_cc") || lower.endsWith("_bcc")
419
+ ) return "heuristic:email";
420
+ 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";
422
+ if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") return "heuristic:url";
423
+ if (lower === "password" || lower.endsWith("_password")) return "heuristic:password";
424
+ if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) return "heuristic:phone";
425
+ }
426
+ return "random";
427
+ }
428
+
429
+ if (t === "integer") {
430
+ if (schema.maximum !== undefined) return "max";
431
+ if (schema.minimum !== undefined && schema.minimum > 0) return "min";
432
+ return "random";
433
+ }
434
+
435
+ if (t === "number" || t === "boolean") return "default";
436
+ return "default";
437
+ }
438
+
439
+ /**
440
+ * Map an OpenAPI `format` value to a zond generator placeholder. Returns
441
+ * undefined when the format is unknown or absent so callers can fall back
442
+ * to type / property-name heuristics. Exported for tests.
443
+ */
444
+ export function formatToPlaceholder(format: string | undefined): string | undefined {
445
+ switch (format) {
446
+ case "email": return "{{$randomEmail}}";
447
+ case "uuid": return "{{$uuid}}";
448
+ case "date-time": return "{{$randomIsoDate}}";
449
+ case "date": return "{{$randomDate}}";
450
+ case "uri":
451
+ case "url": return "{{$randomUrl}}";
452
+ case "hostname": return "{{$randomFqdn}}";
453
+ case "ipv4": return "{{$randomIpv4}}";
454
+ case "ipv6": return "::1";
455
+ case "password": return "TestPass123!";
456
+ // ARV-165: format-aware helpers. None of these are standard OpenAPI 3.x
457
+ // formats, but Stripe/GitHub/Shopify/Twilio specs frequently carry them
458
+ // as ad-hoc `format:` tags. Falling through to {{$randomString}} guarantees
459
+ // 400 from format-validated APIs (R09 finding: 199 hit-but-fail Stripe steps).
460
+ case "iso-country-code":
461
+ case "country-code":
462
+ case "country": return "{{$randomCountryCode}}";
463
+ case "iso-currency-code":
464
+ case "currency-code":
465
+ case "currency": return "{{$randomCurrencyCode}}";
466
+ case "mcc": return "{{$randomMCC}}";
467
+ case "color":
468
+ case "hex-color":
469
+ case "rgb-hex": return "{{$randomColorHex}}";
470
+ default: return undefined;
471
+ }
472
+ }
473
+
90
474
  /**
91
475
  * Generate a multipart body object from an OpenAPI multipart/form-data schema.
92
476
  * Binary fields (format: binary/byte) become file upload objects; all others become strings.
@@ -100,6 +484,7 @@ export function generateMultipartFromSchema(
100
484
 
101
485
  for (const [key, propSchema] of Object.entries(schema.properties)) {
102
486
  const s = propSchema as OpenAPIV3.SchemaObject;
487
+ if (shouldSkipForRequest(key, s)) continue;
103
488
  if (s.format === "binary" || s.format === "byte") {
104
489
  result[key] = { file: `./fixtures/${key}.bin`, content_type: "application/octet-stream" };
105
490
  } else {
@@ -112,21 +497,93 @@ export function generateMultipartFromSchema(
112
497
  }
113
498
 
114
499
  function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
115
- // Format-based
116
- if (schema.format === "email") return "{{$randomEmail}}";
117
- if (schema.format === "uuid") return "{{$uuid}}";
118
- if (schema.format === "date-time") return "2025-01-01T00:00:00Z";
119
- if (schema.format === "date") return "2025-01-01";
120
- if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
121
- if (schema.format === "hostname") return "example.com";
122
- if (schema.format === "ipv4") return "192.168.1.1";
123
- if (schema.format === "ipv6") return "::1";
124
- if (schema.format === "password") return "TestPass123!";
500
+ // Format-based dispatch already happened earlier in generateFromSchema;
501
+ // this branch only sees strings whose format is empty or unrecognised.
502
+
503
+ // ARV-38: when the spec declares a JSON-Schema `default` for a string-typed
504
+ // field with no enum, prefer it over heuristics. PATCH endpoints in
505
+ // particular rely on this — e.g. a `PATCH /domains/{id}` with
506
+ // `tls: { type: string, default: "opportunistic" }` would otherwise get
507
+ // a random fallback and a guaranteed 422 every run.
508
+ if (typeof schema.default === "string" && schema.default.length > 0) {
509
+ return schema.default;
510
+ }
511
+
512
+ // Pattern-aware: many specs constrain slugs via regex like
513
+ // `^(?![0-9]+$)[a-z0-9_\-]+$` without setting `format`. Default
514
+ // `{{$randomString}}` mixes upper+lower → 400 from the validator.
515
+ // Heuristic: pattern allows `a-z` but forbids `A-Z` → emit a slug.
516
+ if (isLowercaseOnlyPattern(schema.pattern)) {
517
+ return "{{$randomSlug}}";
518
+ }
519
+
520
+ // Description-aware: when the schema describes a domain/hostname (e.g.
521
+ // a `POST /domains/`-style endpoint or DNS-zone create route) but the
522
+ // field is generically named `name`, the default `{{$randomName}}`
523
+ // returns "Bob Wilson" and the server rejects it. TASK-224.
524
+ // Skip when the field name is clearly in email vocabulary — email-API
525
+ // specs often describe `from`/`to`/etc. with phrases like "verified
526
+ // sending domain" or "Name <user@domain>", which trips the regex but
527
+ // the field is an email, not a domain. Email vocab > domain-from-description.
528
+ if (
529
+ schema.description &&
530
+ /\b(domain|hostname|fqdn)\b/i.test(schema.description) &&
531
+ !isEmailContextName(name)
532
+ ) {
533
+ return "{{$randomDomain}}";
534
+ }
125
535
 
126
536
  // Name-based heuristics
127
537
  if (name) {
128
538
  const lower = name.toLowerCase();
129
- if (lower === "email" || lower.endsWith("_email") || lower.endsWith("Email")) {
539
+ if (lower === "slug" || lower.endsWith("_slug")) {
540
+ return "{{$randomSlug}}";
541
+ }
542
+ if (lower === "domain" || lower === "hostname" || lower === "fqdn" || lower.endsWith("_domain")) {
543
+ return "{{$randomDomain}}";
544
+ }
545
+ // Closed-vocabulary fields where servers validate against an internal
546
+ // dictionary even when the spec lacks `enum:`. Random strings → 400.
547
+ // Pick the most universally-accepted value per dictionary.
548
+ if (lower === "platform") return "python";
549
+ if (lower === "language" || lower === "lang" || lower === "locale") return "en";
550
+ // ARV-165: country/currency literals (US/USD) were universally accepted
551
+ // but offered zero variety — added endsWith() patterns so nested fields
552
+ // like `bank_account.country`, `payout.currency_code`, `from_country`
553
+ // also resolve. Still emit a literal — picking from the random helper
554
+ // would weaken the "always-valid" property for downstream assertions
555
+ // that pin on the first value.
556
+ if (lower === "country" || lower === "country_code" || lower.endsWith("_country") || lower.endsWith("_country_code")) return "US";
557
+ if (lower === "timezone" || lower === "time_zone" || lower === "tz") return "UTC";
558
+ if (lower === "currency" || lower === "currency_code" || lower.endsWith("_currency") || lower.endsWith("_currency_code")) return "USD";
559
+ // ARV-165: MCC (merchant category code) — Stripe/Square/issuing APIs.
560
+ // Random {{$randomString}} → 400 because it's not a 4-digit code.
561
+ if (lower === "mcc" || lower.endsWith("_mcc") || lower === "merchant_category_code") return "{{$randomMCC}}";
562
+ // ARV-165: hex color — Stripe brand settings, Slack themes, GitHub labels.
563
+ if (lower === "color" || lower.endsWith("_color") || lower === "background_color" || lower === "hex" || lower.endsWith("_hex_color")) return "{{$randomColorHex}}";
564
+ // ARV-165: IP addresses — Stripe tos_acceptance.ip, audit logs, fraud APIs.
565
+ if (lower === "ip" || lower === "ip_address" || lower.endsWith("_ip") || lower.endsWith("_ip_address")) return "{{$randomIpv4}}";
566
+ // Email-context fields. Email-API specs often
567
+ // omit `format: email` on `from`/`to`/`reply_to`/`cc`/`bcc` — the field
568
+ // name is the only clue, and `{{$randomString}}` guarantees a 422.
569
+ if (
570
+ lower === "email" ||
571
+ lower === "from" ||
572
+ lower === "to" ||
573
+ lower === "cc" ||
574
+ lower === "bcc" ||
575
+ lower === "sender" ||
576
+ lower === "recipient" ||
577
+ lower === "reply_to" ||
578
+ lower === "replyto" ||
579
+ lower.endsWith("_email") ||
580
+ lower.endsWith("Email") ||
581
+ lower.endsWith("_reply_to") ||
582
+ lower.endsWith("_from") ||
583
+ lower.endsWith("_to") ||
584
+ lower.endsWith("_cc") ||
585
+ lower.endsWith("_bcc")
586
+ ) {
130
587
  return "{{$randomEmail}}";
131
588
  }
132
589
  if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
@@ -136,7 +593,7 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
136
593
  return "{{$randomName}}";
137
594
  }
138
595
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
139
- return "https://example.com/test";
596
+ return "{{$randomUrl}}";
140
597
  }
141
598
  if (lower === "password" || lower.endsWith("_password")) {
142
599
  return "TestPass123!";
@@ -25,7 +25,7 @@ export interface CompactEndpoint {
25
25
  deprecated: boolean;
26
26
  }
27
27
 
28
- export function generateTestSnippet(params: {
28
+ function generateTestSnippet(params: {
29
29
  method: string;
30
30
  path: string;
31
31
  operationId?: string;