@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
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Build `.api-fixtures.yaml` — manifest of variables this API needs from
3
+ * the user's `.env.yaml`.
4
+ *
5
+ * Purpose: when the skill (or `zond doctor`, future) needs to tell the
6
+ * user what to fill in before scenarios will run, it reads this manifest
7
+ * instead of inferring fixtures from generated tests. The manifest is
8
+ * derived purely from the OpenAPI spec — auth schemes, required path
9
+ * params, server URL — so it's stable across re-runs of `generate`.
10
+ *
11
+ * Manifest is read-only (regenerate via `zond refresh-api`); user edits
12
+ * land in `.env.yaml`, not here.
13
+ */
14
+
15
+ import type { EndpointInfo, SecuritySchemeInfo } from "./types.ts";
16
+ import { schemeVarName, resourceVar } from "./suite-generator.ts";
17
+ import type { ApiResourceMap } from "./resources-builder.ts";
18
+
19
+ /**
20
+ * ARV-138: canonicalise a body field name to a manifest var name.
21
+ * Converts camelCase → snake_case + lowercase so spec body fields
22
+ * (`issueId`, `audienceId`, `accountID`) collapse onto the same manifest
23
+ * entry as the spec's path-param spelling (`issue_id`, `audience_id`).
24
+ *
25
+ * Idempotent on already-snake_case input. The HTTP request still sends
26
+ * the raw field name to the server — only the var-name namespace is
27
+ * normalised (see `create-body.ts:substituteFkFields`).
28
+ */
29
+ export function canonicalVarName(name: string): string {
30
+ return name
31
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
32
+ .replace(/[^a-zA-Z0-9]+/g, "_")
33
+ .toLowerCase();
34
+ }
35
+
36
+ export type FixtureSource = "auth" | "server" | "path" | "header" | "body-fk" | "capture-chain";
37
+
38
+ export interface FixtureRequirement {
39
+ /** Variable name as referenced via {{var}} in tests. */
40
+ name: string;
41
+ /** Where this fixture comes from in the spec. */
42
+ source: FixtureSource;
43
+ /** Free-text description for the user (one line). */
44
+ description: string;
45
+ /** Endpoints affected if this fixture is missing (sample, ≤10). */
46
+ affectedEndpoints: string[];
47
+ /** True when at least one consumer marks this required. */
48
+ required: boolean;
49
+ /** Suggested placeholder value, used to seed `.env.yaml`. */
50
+ defaultValue?: string;
51
+ }
52
+
53
+ export interface ApiFixtureManifest {
54
+ generatedAt: string;
55
+ specHash: string;
56
+ fixtureCount: number;
57
+ fixtures: FixtureRequirement[];
58
+ }
59
+
60
+ function epLabel(ep: EndpointInfo): string {
61
+ return `${ep.method.toUpperCase()} ${ep.path}`;
62
+ }
63
+
64
+ function pushAffected(req: FixtureRequirement, ep: EndpointInfo): void {
65
+ if (req.affectedEndpoints.length >= 10) return;
66
+ const label = epLabel(ep);
67
+ if (!req.affectedEndpoints.includes(label)) req.affectedEndpoints.push(label);
68
+ }
69
+
70
+ export interface BuildFixturesParams {
71
+ endpoints: EndpointInfo[];
72
+ securitySchemes: SecuritySchemeInfo[];
73
+ baseUrl?: string;
74
+ specHash: string;
75
+ /**
76
+ * Resource map (CRUD groups + body-FK refs) — when provided, the manifest
77
+ * also lists body-FK and capture-chain variables that the test generator
78
+ * will reference. Keeps `.api-fixtures.yaml` in sync with what generated
79
+ * tests actually consume (per decision-7: manifest = source of truth for
80
+ * the *list* of variables).
81
+ */
82
+ resourceMap?: ApiResourceMap;
83
+ }
84
+
85
+ export function buildApiFixtureManifest(params: BuildFixturesParams): ApiFixtureManifest {
86
+ const fixtures = new Map<string, FixtureRequirement>();
87
+
88
+ // 1. Server URL → base_url
89
+ fixtures.set("base_url", {
90
+ name: "base_url",
91
+ source: "server",
92
+ description: params.baseUrl
93
+ ? `Base URL of the API (from spec: ${params.baseUrl}).`
94
+ : `Base URL of the API. Spec did not declare a server — fill in manually.`,
95
+ affectedEndpoints: ["*"],
96
+ required: true,
97
+ defaultValue: params.baseUrl ?? "",
98
+ });
99
+
100
+ // 2. Auth schemes → auth tokens
101
+ // We map each scheme that endpoints actually reference into an env var.
102
+ const usedSchemeNames = new Set<string>();
103
+ for (const ep of params.endpoints) {
104
+ for (const s of ep.security) usedSchemeNames.add(s);
105
+ }
106
+ for (const scheme of params.securitySchemes) {
107
+ if (!usedSchemeNames.has(scheme.name)) continue;
108
+ const varName = schemeVarName(scheme, params.securitySchemes);
109
+ let description: string;
110
+ if (scheme.type === "http" && scheme.scheme === "bearer") {
111
+ description = `Bearer token for security scheme "${scheme.name}".`;
112
+ } else if (scheme.type === "apiKey") {
113
+ description = `API key for "${scheme.name}" (sent as ${scheme.in === "header" ? `header ${scheme.apiKeyName}` : `${scheme.in} param ${scheme.apiKeyName}`}).`;
114
+ } else if (scheme.type === "oauth2") {
115
+ description = `OAuth2 access token for scheme "${scheme.name}".`;
116
+ } else {
117
+ description = `Token for security scheme "${scheme.name}" (${scheme.type}).`;
118
+ }
119
+ const existing = fixtures.get(varName);
120
+ if (existing) {
121
+ // Multiple schemes might collapse to one var (single-bearer case).
122
+ // Keep the most informative description.
123
+ if (description.length > existing.description.length) existing.description = description;
124
+ } else {
125
+ fixtures.set(varName, {
126
+ name: varName,
127
+ source: "auth",
128
+ description,
129
+ affectedEndpoints: [],
130
+ required: true,
131
+ defaultValue: "",
132
+ });
133
+ }
134
+ const req = fixtures.get(varName)!;
135
+ for (const ep of params.endpoints) {
136
+ if (ep.security.includes(scheme.name)) pushAffected(req, ep);
137
+ }
138
+ }
139
+
140
+ // 3. Required path params → one var per unique name
141
+ for (const ep of params.endpoints) {
142
+ for (const p of ep.parameters) {
143
+ if (p.in !== "path") continue;
144
+ if (p.required === false) continue;
145
+ const name = p.name;
146
+ let req = fixtures.get(name);
147
+ if (!req) {
148
+ const schema = p.schema as { type?: string; format?: string; example?: unknown } | undefined;
149
+ let defaultValue = "";
150
+ if (schema?.example !== undefined) defaultValue = String(schema.example);
151
+ else if (schema?.format === "uuid") defaultValue = "";
152
+ else if (schema?.type === "integer" || schema?.type === "number") defaultValue = "";
153
+
154
+ req = {
155
+ name,
156
+ source: "path",
157
+ description: `Path parameter ${name}${schema?.format ? ` (${schema.format})` : schema?.type ? ` (${schema.type})` : ""}. Set to a real id from your account, or leave blank to skip dependent tests.`,
158
+ affectedEndpoints: [],
159
+ required: true,
160
+ defaultValue,
161
+ };
162
+ fixtures.set(name, req);
163
+ }
164
+ pushAffected(req, ep);
165
+ }
166
+ }
167
+
168
+ // 4. Required header params → one var per unique name (skip Authorization
169
+ // & Content-Type — those are handled by auth + suite headers).
170
+ for (const ep of params.endpoints) {
171
+ for (const p of ep.parameters) {
172
+ if (p.in !== "header") continue;
173
+ if (p.required === false) continue;
174
+ const lname = p.name.toLowerCase();
175
+ if (lname === "authorization" || lname === "content-type" || lname === "accept") continue;
176
+ const varName = lname.replace(/-/g, "_");
177
+ let req = fixtures.get(varName);
178
+ if (!req) {
179
+ req = {
180
+ name: varName,
181
+ source: "header",
182
+ description: `Required header ${p.name}.`,
183
+ affectedEndpoints: [],
184
+ required: true,
185
+ defaultValue: "",
186
+ };
187
+ fixtures.set(varName, req);
188
+ }
189
+ pushAffected(req, ep);
190
+ }
191
+ }
192
+
193
+ // 5. Body-FK fields — required parent-id fields in request bodies that
194
+ // the generator copies from `.env.yaml` (e.g. `audience_id` in
195
+ // POST /contacts). Without these in the manifest, prepare-fixtures
196
+ // discovers/seeds nothing for them and `zond audit` 422s on first
197
+ // nested resource. Walks ALL mutating endpoints (not only full
198
+ // CRUD groups) so POST-only resources still surface their FK deps.
199
+ // Source precedence: path > body-fk (path-params more constraining).
200
+ for (const ep of params.endpoints) {
201
+ const method = ep.method.toUpperCase();
202
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH") continue;
203
+ const schema = ep.requestBodySchema as
204
+ | { properties?: Record<string, unknown>; required?: string[] }
205
+ | undefined;
206
+ if (!schema?.properties) continue;
207
+ const required = new Set(schema.required ?? []);
208
+ for (const fieldName of Object.keys(schema.properties)) {
209
+ if (!/_id$|Id$|_uuid$/.test(fieldName)) continue;
210
+ if (!required.has(fieldName)) continue;
211
+ // ARV-138: canonicalise camelCase to snake_case so `issueId` shares
212
+ // a manifest slot with path-param `issue_id`. The raw `fieldName`
213
+ // still goes to the server unchanged via `create-body.ts` —
214
+ // canonicalisation only affects the manifest var-name namespace.
215
+ const varName = canonicalVarName(fieldName);
216
+ const existing = fixtures.get(varName);
217
+ if (existing) {
218
+ // Already covered (likely as path-param). Keep the existing entry
219
+ // and just surface the additional affected endpoint.
220
+ pushAffected(existing, ep);
221
+ continue;
222
+ }
223
+ fixtures.set(varName, {
224
+ name: varName,
225
+ source: "body-fk",
226
+ description: `Foreign-key id consumed by ${epLabel(ep)} request body. Set to a real id from your account, or leave blank to skip dependent tests.`,
227
+ affectedEndpoints: [epLabel(ep)],
228
+ required: true,
229
+ defaultValue: "",
230
+ });
231
+ }
232
+ }
233
+
234
+ // 6. CRUD-chain capture vars — the generator emits `capture: <idParam>`
235
+ // in POST steps and references {{<idParam>}} downstream. These are
236
+ // auto-captured at runtime; surfacing them in the manifest keeps the
237
+ // "var in tests but not in manifest" contract intact (per decision-7)
238
+ // and lets prepare-fixtures distinguish "captured automatically" from
239
+ // "user must fill". required: false — env override is advanced-only.
240
+ //
241
+ // ARV-137: capture name = `r.idParam` (the spec's path-param name), not
242
+ // `resourceVar(r.resource, "id")`. The synthesised form produced phantom
243
+ // manifest dupes whenever the spec's path-param didn't equal
244
+ // `<resource>_id` (e.g. monitors/monitor_id_or_slug, saved/query_id,
245
+ // releases/version). Now the manifest carries one var per resource id
246
+ // that matches both the path-source entry and the generated test refs.
247
+ if (params.resourceMap) {
248
+ for (const r of params.resourceMap.resources) {
249
+ if (!r.endpoints.create) continue;
250
+ const captureName = r.idParam || resourceVar(r.resource, "id");
251
+ if (fixtures.has(captureName)) continue;
252
+ const description = `Captured automatically from ${r.endpoints.create} response (field "${r.captureField}") and used in downstream CRUD steps. Set in .env.yaml only to override the captured value.`;
253
+ const affectedFromGroup = Object.entries(r.endpoints)
254
+ .filter(([k]) => k !== "list" && k !== "create")
255
+ .map(([, v]) => v as string);
256
+ const req: FixtureRequirement = {
257
+ name: captureName,
258
+ source: "capture-chain",
259
+ description,
260
+ affectedEndpoints: affectedFromGroup.slice(0, 10),
261
+ required: false,
262
+ defaultValue: "",
263
+ };
264
+ fixtures.set(captureName, req);
265
+ }
266
+ }
267
+
268
+ const ordered = Array.from(fixtures.values()).sort((a, b) => {
269
+ const sourceOrder: Record<FixtureSource, number> = {
270
+ server: 0,
271
+ auth: 1,
272
+ header: 2,
273
+ path: 3,
274
+ "body-fk": 4,
275
+ "capture-chain": 5,
276
+ };
277
+ if (sourceOrder[a.source] !== sourceOrder[b.source]) {
278
+ return sourceOrder[a.source] - sourceOrder[b.source];
279
+ }
280
+ return a.name.localeCompare(b.name);
281
+ });
282
+
283
+ return {
284
+ generatedAt: new Date().toISOString(),
285
+ specHash: params.specHash,
286
+ fixtureCount: ordered.length,
287
+ fixtures: ordered,
288
+ };
289
+ }
290
+
291
+ // ── YAML serialization ──
292
+
293
+ function escape(s: string): string {
294
+ if (/[:#\[\]{}&*!|>'"@`,%]/.test(s) || s.includes("\n") || s === "") {
295
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
296
+ }
297
+ return s;
298
+ }
299
+
300
+ export function serializeApiFixtureManifest(m: ApiFixtureManifest): string {
301
+ const lines: string[] = [];
302
+ lines.push("# Auto-generated by zond. Do not edit by hand.");
303
+ lines.push("# Read-only manifest of variables this API needs in .env.yaml.");
304
+ lines.push("# Regenerate via: zond refresh-api <name>");
305
+ lines.push(`generatedAt: ${escape(m.generatedAt)}`);
306
+ lines.push(`specHash: ${escape(m.specHash)}`);
307
+ lines.push(`fixtureCount: ${m.fixtureCount}`);
308
+ lines.push("fixtures:");
309
+ for (const f of m.fixtures) {
310
+ lines.push(` - name: ${escape(f.name)}`);
311
+ lines.push(` source: ${f.source}`);
312
+ lines.push(` required: ${f.required}`);
313
+ lines.push(` description: ${escape(f.description)}`);
314
+ if (f.defaultValue !== undefined) {
315
+ lines.push(` defaultValue: ${escape(f.defaultValue)}`);
316
+ }
317
+ if (f.affectedEndpoints.length === 0) {
318
+ lines.push(` affectedEndpoints: []`);
319
+ } else {
320
+ lines.push(` affectedEndpoints:`);
321
+ for (const e of f.affectedEndpoints) lines.push(` - ${escape(e)}`);
322
+ }
323
+ }
324
+ return lines.join("\n") + "\n";
325
+ }
@@ -1,14 +1,16 @@
1
1
  export { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "./openapi-reader.ts";
2
- export { serializeSuite, isRelativeUrl, sanitizeEnvName, resolveSpecPath } from "./serializer.ts";
2
+ export { serializeSuite } from "./serializer.ts";
3
3
  export type { RawSuite, RawStep } from "./serializer.ts";
4
- export { generateFromSchema } from "./data-factory.ts";
5
4
  export { scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex } from "./coverage-scanner.ts";
6
5
  export type { CoveredEndpoint } from "./coverage-scanner.ts";
7
6
  export { analyzeEndpoints } from "./endpoint-warnings.ts";
8
- export { compressEndpointsWithSchemas, buildGenerationGuide } from "./guide-builder.ts";
9
- export type { GuideOptions } from "./guide-builder.ts";
10
7
  export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
11
8
  export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
12
- export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
13
9
  export { buildCatalog, serializeCatalog } from "./catalog-builder.ts";
14
10
  export type { ApiCatalog, CatalogEndpoint } from "./catalog-builder.ts";
11
+ export { buildApiResourceMap, serializeApiResourceMap } from "./resources-builder.ts";
12
+ export type { ApiResourceMap, ApiResourceEntry, ResourceFkRef } from "./resources-builder.ts";
13
+ export { buildApiFixtureManifest, serializeApiFixtureManifest } from "./fixtures-builder.ts";
14
+ export type { ApiFixtureManifest, FixtureRequirement, FixtureSource } from "./fixtures-builder.ts";
15
+ export { buildCreateRequestBody } from "./create-body.ts";
16
+ export type { BuildCreateRequestBodyOptions } from "./create-body.ts";
@@ -1,6 +1,7 @@
1
1
  import { dereference } from "@readme/openapi-parser";
2
2
  import type { OpenAPIV3 } from "openapi-types";
3
3
  import type { EndpointInfo, ResponseInfo, SecuritySchemeInfo } from "./types.ts";
4
+ import { disambiguateGenericPathParams } from "./path-param-disambig.ts";
4
5
 
5
6
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
6
7
 
@@ -57,9 +58,16 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
57
58
 
58
59
  const parameters: OpenAPIV3.ParameterObject[] = [];
59
60
 
61
+ // Skip circular-ref sentinel stubs emitted by decycleSchema —
62
+ // they look like `{ "x-circular": true }` (no .name, no .in) and
63
+ // crash downstream code that expects p.name/p.in. ARV-200 (R10/F1).
64
+ const isUsableParam = (p: any): p is OpenAPIV3.ParameterObject =>
65
+ p != null && typeof p === "object" && typeof p.name === "string" && typeof p.in === "string";
66
+
60
67
  // Path-level parameters
61
68
  if (pathItem.parameters) {
62
69
  for (const p of pathItem.parameters) {
70
+ if (!isUsableParam(p)) continue;
63
71
  parameters.push(p as OpenAPIV3.ParameterObject);
64
72
  }
65
73
  }
@@ -67,6 +75,7 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
67
75
  // Operation-level parameters (override path-level)
68
76
  if (operation.parameters) {
69
77
  for (const p of operation.parameters) {
78
+ if (!isUsableParam(p)) continue;
70
79
  const param = p as OpenAPIV3.ParameterObject;
71
80
  const existingIdx = parameters.findIndex(
72
81
  (existing) => existing.name === param.name && existing.in === param.in,
@@ -93,6 +102,24 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
93
102
  const chosen = rb.content[requestBodyContentType!];
94
103
  if (chosen?.schema) {
95
104
  requestBodySchema = chosen.schema as OpenAPIV3.SchemaObject;
105
+ // OpenAPI allows examples at the media-type level (sibling to schema).
106
+ // Lift them onto the schema so the generator sees a single signal.
107
+ if (requestBodySchema.example === undefined) {
108
+ if ((chosen as OpenAPIV3.MediaTypeObject).example !== undefined) {
109
+ requestBodySchema = {
110
+ ...requestBodySchema,
111
+ example: (chosen as OpenAPIV3.MediaTypeObject).example,
112
+ };
113
+ } else if (chosen.examples) {
114
+ const firstNamed = Object.values(chosen.examples)[0];
115
+ if (firstNamed && typeof firstNamed === "object" && "value" in firstNamed) {
116
+ requestBodySchema = {
117
+ ...requestBodySchema,
118
+ example: (firstNamed as OpenAPIV3.ExampleObject).value,
119
+ };
120
+ }
121
+ }
122
+ }
96
123
  }
97
124
  }
98
125
  }
@@ -102,9 +129,12 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
102
129
  const responseContentTypesSet = new Set<string>();
103
130
  if (operation.responses) {
104
131
  for (const [statusCode, responseObj] of Object.entries(operation.responses)) {
132
+ const parsedStatus = parseInt(statusCode, 10);
133
+ // Skip non-numeric keys like "default" — they have no asserting status code.
134
+ if (!Number.isFinite(parsedStatus)) continue;
105
135
  const resp = responseObj as OpenAPIV3.ResponseObject;
106
136
  const info: ResponseInfo = {
107
- statusCode: parseInt(statusCode, 10),
137
+ statusCode: parsedStatus,
108
138
  description: resp.description || "",
109
139
  };
110
140
  if (resp.content) {
@@ -141,11 +171,33 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
141
171
  responseContentTypes: [...responseContentTypesSet],
142
172
  responses,
143
173
  security,
144
- deprecated: operation.deprecated ?? false,
174
+ deprecated: (operation.deprecated ?? false) || isMarkedDeprecatedInText(operation.summary, operation.description, operation.operationId),
145
175
  requiresEtag,
146
176
  });
147
177
  }
148
178
  }
149
179
 
150
- return endpoints;
180
+ // ARV-40: when generic path-param names (`{id}`, `{slug}`, ...) collide
181
+ // across multiple resources, rewrite each to `<parent_singular>_<param>`
182
+ // so the manifest derives per-resource vars and tests stop sharing one
183
+ // global `id`. In-memory only; on-disk spec stays untouched.
184
+ return disambiguateGenericPathParams(endpoints);
185
+ }
186
+
187
+ /** Spec authors often mark endpoints as deprecated in the summary or
188
+ * description text instead of (or in addition to) the `deprecated: true`
189
+ * flag — common across many SaaS and legacy specs. Without this
190
+ * fallback, generator emits CRUD suites whose POST returns 404 from a dead
191
+ * endpoint. (TASK-245) */
192
+ /** Matches `(DEPRECATED) ...`, `[DEPRECATED] ...`, `DEPRECATED: ...` at the
193
+ * start of a string. Also matches markdown `## Deprecated` headings, which
194
+ * some spec authors use in operation `description` to flag end-of-life
195
+ * endpoints. */
196
+ const DEPRECATED_PREFIX_RE = /^\s*[\(\[]?\s*DEPRECATED\s*[\)\]:\-—\s]/i;
197
+ const DEPRECATED_HEADING_RE = /^\s*#+\s*Deprecated\b/im;
198
+ function isMarkedDeprecatedInText(summary?: string, description?: string, operationId?: string): boolean {
199
+ if (summary && DEPRECATED_PREFIX_RE.test(summary)) return true;
200
+ if (operationId && DEPRECATED_PREFIX_RE.test(operationId)) return true;
201
+ if (description && (DEPRECATED_PREFIX_RE.test(description) || DEPRECATED_HEADING_RE.test(description))) return true;
202
+ return false;
151
203
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Disambiguate generic path-parameter names that collide across resources.
3
+ *
4
+ * Some OpenAPI specs declare item paths as `/<resource>/{id}` instead of
5
+ * `/<resource>/{<resource>_id}` — fine
6
+ * within one resource, catastrophic across many: the manifest derives one
7
+ * global `id` var, `.env.yaml` stores one value, and N>1 CRUD suites end up
8
+ * pointing at the same uuid (false 404 at best; false 200 against a stranger's
9
+ * object at worst — the GET returns a real object so the test "passes" while
10
+ * having tested nothing of the target resource).
11
+ *
12
+ * ARV-40 fix: when a generic param name (`id`, `slug`, `uuid`, `key`,
13
+ * `identifier`) appears under more than one distinct parent collection in the
14
+ * spec, rewrite each occurrence to `<parent_singular>_<param>` in
15
+ * EndpointInfo.path AND the matching parameter entry. All downstream code
16
+ * (resource map, fixture manifest, suite generator, mass-assignment probe)
17
+ * then sees per-resource var names without any extra plumbing.
18
+ *
19
+ * The on-disk OpenAPI spec.json is untouched — this is an in-memory
20
+ * normalisation. Coverage matches by structural shape (`{x}` is `{x}`), and
21
+ * the runner substitutes `{{var}}` after path templating, so renaming
22
+ * `{id}` → `{template_id}` only affects how zond *names* the variable.
23
+ */
24
+
25
+ import type { EndpointInfo } from "./types.ts";
26
+
27
+ const GENERIC_PARAM_NAMES = new Set(["id", "slug", "uuid", "key", "name", "identifier"]);
28
+
29
+ function isParamSeg(seg: string | undefined): seg is string {
30
+ return !!seg && /^\{[^}]+\}$/.test(seg);
31
+ }
32
+
33
+ /** English singularization sufficient for resource-collection nouns. */
34
+ function singularize(word: string): string {
35
+ if (word.length > 3 && /ies$/i.test(word)) return word.slice(0, -3) + "y";
36
+ if (word.length > 3 && /(ch|sh|x|ss|z)es$/i.test(word)) return word.slice(0, -2);
37
+ if (word.length > 1 && /[^s]s$/i.test(word)) return word.slice(0, -1);
38
+ return word;
39
+ }
40
+
41
+ /** Turn a path segment (`contact-properties`) into an identifier stem (`contact_property`). */
42
+ function segToStem(seg: string): string {
43
+ return singularize(seg).replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase();
44
+ }
45
+
46
+ interface Occurrence {
47
+ ep: EndpointInfo;
48
+ /** Index of the {param} segment in the path. */
49
+ segIdx: number;
50
+ }
51
+
52
+ /** Mutates endpoints in place; returns the same array for chaining. */
53
+ export function disambiguateGenericPathParams(endpoints: EndpointInfo[]): EndpointInfo[] {
54
+ // param-name → parent-seg → occurrences
55
+ const byParamParent = new Map<string, Map<string, Occurrence[]>>();
56
+
57
+ for (const ep of endpoints) {
58
+ const segs = ep.path.split("/");
59
+ for (let i = 0; i < segs.length; i++) {
60
+ const m = /^\{([^}]+)\}$/.exec(segs[i]!);
61
+ if (!m) continue;
62
+ const paramName = m[1]!;
63
+ if (!GENERIC_PARAM_NAMES.has(paramName.toLowerCase())) continue;
64
+ // Walk back to nearest non-param non-empty segment as "parent".
65
+ let parent: string | undefined;
66
+ for (let j = i - 1; j >= 0; j--) {
67
+ const s = segs[j]!;
68
+ if (s && !isParamSeg(s)) {
69
+ parent = s;
70
+ break;
71
+ }
72
+ }
73
+ if (!parent) continue;
74
+ let perParent = byParamParent.get(paramName);
75
+ if (!perParent) {
76
+ perParent = new Map();
77
+ byParamParent.set(paramName, perParent);
78
+ }
79
+ const arr = perParent.get(parent) ?? [];
80
+ arr.push({ ep, segIdx: i });
81
+ perParent.set(parent, arr);
82
+ }
83
+ }
84
+
85
+ for (const [paramName, perParent] of byParamParent) {
86
+ // Only rename when the param collides across ≥2 parents — single-resource
87
+ // use of `{id}` stays as-is to avoid churning user .env.yaml entries on
88
+ // APIs where the convention isn't a problem.
89
+ if (perParent.size < 2) continue;
90
+ const lowerParam = paramName.toLowerCase();
91
+ for (const [parent, occs] of perParent) {
92
+ const stem = segToStem(parent);
93
+ if (!stem) continue;
94
+ const newName = lowerParam === "id" ? `${stem}_id` : `${stem}_${lowerParam}`;
95
+ // Skip rename if newName collides with an unrelated existing name
96
+ // for the same endpoint (would corrupt parameters[]). Cheap guard.
97
+ for (const occ of occs) {
98
+ // ARV-183: preserve the original spec path before mutating, so
99
+ // downstream checks (status_code_conformance, response_headers_conformance)
100
+ // can still look up `doc.paths[...]` by string equality. Set only
101
+ // on first rename — subsequent renames of the same endpoint keep
102
+ // the truly original path.
103
+ if (occ.ep.originalPath === undefined) occ.ep.originalPath = occ.ep.path;
104
+ const segs = occ.ep.path.split("/");
105
+ segs[occ.segIdx] = `{${newName}}`;
106
+ occ.ep.path = segs.join("/");
107
+ const param = occ.ep.parameters.find(p => p.name === paramName && p.in === "path");
108
+ if (param) (param as { name: string }).name = newName;
109
+ }
110
+ }
111
+ }
112
+
113
+ return endpoints;
114
+ }