@kirrosh/zond 0.21.0 → 0.22.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 (52) hide show
  1. package/CHANGELOG.md +110 -3
  2. package/README.md +26 -15
  3. package/package.json +10 -6
  4. package/src/cli/commands/ci-init.ts +12 -6
  5. package/src/cli/commands/completions.ts +176 -0
  6. package/src/cli/commands/db.ts +2 -1
  7. package/src/cli/commands/generate.ts +0 -1
  8. package/src/cli/commands/init/agents-md.ts +61 -0
  9. package/src/cli/commands/init/bootstrap.ts +79 -0
  10. package/src/cli/commands/init/skills.ts +45 -0
  11. package/src/cli/commands/init/templates/agents.md +73 -0
  12. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  13. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  14. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  15. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  16. package/src/cli/commands/init.ts +124 -31
  17. package/src/cli/commands/probe-methods.ts +108 -0
  18. package/src/cli/commands/probe-validation.ts +124 -0
  19. package/src/cli/commands/run.ts +99 -10
  20. package/src/cli/commands/serve.ts +52 -19
  21. package/src/cli/commands/sync.ts +0 -1
  22. package/src/cli/commands/update.ts +1 -1
  23. package/src/cli/commands/use.ts +57 -0
  24. package/src/cli/index.ts +21 -609
  25. package/src/cli/program.ts +655 -0
  26. package/src/cli/version.ts +3 -0
  27. package/src/core/context/current.ts +35 -0
  28. package/src/core/diagnostics/db-analysis.ts +11 -2
  29. package/src/core/diagnostics/render-md.ts +112 -0
  30. package/src/core/generator/chunker.ts +14 -2
  31. package/src/core/generator/data-factory.ts +50 -19
  32. package/src/core/generator/guide-builder.ts +1 -1
  33. package/src/core/generator/openapi-reader.ts +18 -0
  34. package/src/core/generator/serializer.ts +11 -2
  35. package/src/core/generator/suite-generator.ts +106 -7
  36. package/src/core/meta/types.ts +0 -2
  37. package/src/core/parser/schema.ts +3 -1
  38. package/src/core/parser/types.ts +10 -1
  39. package/src/core/parser/variables.ts +90 -2
  40. package/src/core/parser/yaml-parser.ts +50 -1
  41. package/src/core/probe/method-probe.ts +197 -0
  42. package/src/core/probe/negative-probe.ts +657 -0
  43. package/src/core/reporter/console.ts +29 -3
  44. package/src/core/reporter/index.ts +2 -2
  45. package/src/core/reporter/json.ts +5 -2
  46. package/src/core/runner/assertions.ts +4 -1
  47. package/src/core/runner/executor.ts +132 -37
  48. package/src/core/runner/http-client.ts +40 -5
  49. package/src/core/runner/rate-limiter.ts +131 -0
  50. package/src/core/setup-api.ts +4 -1
  51. package/src/core/workspace/root.ts +94 -0
  52. package/src/db/schema.ts +4 -1
@@ -335,16 +335,25 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
335
335
  const pattern = group.response_status
336
336
  ? `${group.response_status} ${group.failure_type}`
337
337
  : group.failure_type;
338
+ // 5xx (api_error) are critical backend bugs — never collapse them.
339
+ // Diagnose must surface every 5xx, otherwise users miss real
340
+ // regressions hidden behind a single sample.
341
+ const isApiError = group.failure_type === "api_error";
342
+ const showAll = isApiError || maxExamples === 0;
338
343
  grouped_failures.push({
339
344
  pattern,
340
345
  count: group.items.length,
341
346
  failure_type: group.failure_type,
342
347
  recommended_action: group.items[0]!.recommended_action,
343
348
  hint: group.hint,
344
- examples: (maxExamples === 0 ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
349
+ examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
345
350
  response_status: group.response_status,
346
351
  });
347
- compactFailures.push(group.items[0]!);
352
+ if (isApiError) {
353
+ compactFailures.push(...group.items);
354
+ } else {
355
+ compactFailures.push(group.items[0]!);
356
+ }
348
357
  }
349
358
 
350
359
  return { grouped_failures, compactFailures };
@@ -0,0 +1,112 @@
1
+ import type { DiagnoseResult } from "./db-analysis.ts";
2
+
3
+ /**
4
+ * Render a DiagnoseResult into a human-readable markdown digest.
5
+ * Output is the canonical body of the `zond://run/{id}/diagnosis` MCP resource.
6
+ */
7
+ export function renderDiagnosisMarkdown(result: DiagnoseResult): string {
8
+ const { run, summary, agent_directive, env_issue, auth_hint, cascade_skips, failures, grouped_failures } = result;
9
+ const out: string[] = [];
10
+
11
+ const passed = summary.passed;
12
+ const total = summary.total;
13
+ const env = run.environment ?? "no env";
14
+ const duration = run.duration_ms !== null ? `${run.duration_ms}ms` : "unknown duration";
15
+
16
+ out.push(`# Run ${run.id} — ${passed}/${total} passed`);
17
+ out.push(`${run.started_at} · ${env} · ${duration}`);
18
+ out.push("");
19
+ out.push("## Summary");
20
+ out.push(`- total: ${summary.total}`);
21
+ out.push(`- passed: ${summary.passed}`);
22
+ out.push(`- failed: ${summary.failed} (api_errors: ${summary.api_errors}, assertion_failures: ${summary.assertion_failures}, network_errors: ${summary.network_errors})`);
23
+
24
+ if (agent_directive) {
25
+ out.push("");
26
+ out.push("## Agent directive");
27
+ out.push(agent_directive);
28
+ }
29
+
30
+ if (env_issue) {
31
+ out.push("");
32
+ out.push("## Env issue");
33
+ out.push(env_issue);
34
+ }
35
+
36
+ if (auth_hint) {
37
+ out.push("");
38
+ out.push("## Auth hint");
39
+ out.push(auth_hint);
40
+ }
41
+
42
+ if (cascade_skips && cascade_skips.length > 0) {
43
+ out.push("");
44
+ out.push("## Cascade skips");
45
+ for (const group of cascade_skips) {
46
+ out.push(`- **${group.capture_var}** — ${group.count} test${group.count === 1 ? "" : "s"} skipped`);
47
+ for (const example of group.examples) {
48
+ out.push(` - ${example}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ if (grouped_failures && grouped_failures.length > 0) {
54
+ out.push("");
55
+ out.push("## Failure groups");
56
+ for (const group of grouped_failures) {
57
+ out.push("");
58
+ out.push(`### ${group.pattern} — ${group.count} occurrence${group.count === 1 ? "" : "s"}`);
59
+ out.push(`recommended_action: \`${group.recommended_action}\``);
60
+ if (group.hint) out.push(`hint: ${group.hint}`);
61
+ if (group.examples.length > 0) {
62
+ out.push("examples:");
63
+ for (const example of group.examples) {
64
+ out.push(`- ${example}`);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ if (failures.length > 0) {
71
+ out.push("");
72
+ out.push(grouped_failures && grouped_failures.length > 0 ? "## Representative failures" : "## Failures");
73
+ for (const f of failures) {
74
+ out.push("");
75
+ out.push(`### ${f.suite_name} > ${f.test_name}`);
76
+ const meta: string[] = [`failure_type: \`${f.failure_type}\``, `recommended_action: \`${f.recommended_action}\``];
77
+ if (f.suite_file) meta.push(`file: \`${f.suite_file}\``);
78
+ out.push(meta.join(" · "));
79
+ const requestLine = `${f.request_method ?? "?"} ${f.request_url ?? "?"}`;
80
+ const statusLine = f.response_status !== null ? ` → ${f.response_status}` : "";
81
+ out.push(`- request: ${requestLine}${statusLine}`);
82
+ if (f.error_message) out.push(`- error: ${f.error_message}`);
83
+ if (f.hint) out.push(`- hint: ${f.hint}`);
84
+ if (f.schema_hint) out.push(`- schema: ${f.schema_hint}`);
85
+ if (f.response_headers) {
86
+ const headerStr = Object.entries(f.response_headers).map(([k, v]) => `${k}: ${v}`).join(", ");
87
+ out.push(`- headers: ${headerStr}`);
88
+ }
89
+ if (f.response_body !== undefined) {
90
+ const bodyStr = typeof f.response_body === "string"
91
+ ? f.response_body
92
+ : JSON.stringify(f.response_body, null, 2);
93
+ const fenced = bodyStr.includes("\n") || bodyStr.length > 80;
94
+ if (fenced) {
95
+ out.push("- response_body:");
96
+ out.push(" ```");
97
+ for (const line of bodyStr.split("\n")) out.push(` ${line}`);
98
+ out.push(" ```");
99
+ } else {
100
+ out.push(`- response_body: \`${bodyStr}\``);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if (failures.length === 0 && (!grouped_failures || grouped_failures.length === 0)) {
107
+ out.push("");
108
+ out.push("_No failures._");
109
+ }
110
+
111
+ return out.join("\n");
112
+ }
@@ -39,9 +39,21 @@ export function planChunks(endpoints: EndpointInfo[]): ChunkPlan {
39
39
 
40
40
  /** Filter endpoints that have the given tag (case-insensitive) */
41
41
  export function filterByTag(endpoints: EndpointInfo[], tag: string): EndpointInfo[] {
42
- const lower = tag.toLowerCase();
42
+ const lower = tag.trim().toLowerCase();
43
43
  if (lower === "untagged") {
44
44
  return endpoints.filter(ep => ep.tags.length === 0);
45
45
  }
46
- return endpoints.filter(ep => ep.tags.some(t => t.toLowerCase() === lower));
46
+ return endpoints.filter(ep => ep.tags.some(t => t.trim().toLowerCase() === lower));
47
+ }
48
+
49
+ /** Collect the unique set of tags across all endpoints (sorted, original casing). */
50
+ export function collectTags(endpoints: EndpointInfo[]): string[] {
51
+ const seen = new Map<string, string>();
52
+ for (const ep of endpoints) {
53
+ for (const t of ep.tags) {
54
+ const key = t.trim().toLowerCase();
55
+ if (!seen.has(key)) seen.set(key, t.trim());
56
+ }
57
+ }
58
+ return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
47
59
  }
@@ -11,6 +11,12 @@ export function generateFromSchema(
11
11
  ): unknown {
12
12
  if (_depth > 5) return {};
13
13
 
14
+ // Highest-priority signal: explicit example from spec.
15
+ // Beats enum, format, heuristics — the spec author told us what to send.
16
+ if (schema.example !== undefined) {
17
+ return schema.example;
18
+ }
19
+
14
20
  // allOf: merge all schemas
15
21
  if (schema.allOf) {
16
22
  const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
@@ -31,15 +37,26 @@ export function generateFromSchema(
31
37
  return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
32
38
  }
33
39
 
34
- // enum: first value
40
+ // enum: first value (always valid for the API contract)
35
41
  if (schema.enum && schema.enum.length > 0) {
36
42
  return schema.enum[0];
37
43
  }
38
44
 
39
- // uuid format overrides type (e.g. integer fields with format: uuid)
40
- if (schema.format === "uuid") return "{{$uuid}}";
41
-
42
- switch (schema.type) {
45
+ // Format-based placeholders override type resolution. Schemas in the wild
46
+ // commonly carry `format` without an explicit `type` (loosely-defined specs)
47
+ // or with `type: ["string", "null"]` (OpenAPI 3.1 nullable). Falling through
48
+ // to the type switch in those cases dropped us into the default branch and
49
+ // produced `{{$randomString}}` for `format: email` — TASK-86 regression.
50
+ const formatPlaceholder = formatToPlaceholder(schema.format);
51
+ if (formatPlaceholder !== undefined) return formatPlaceholder;
52
+
53
+ // OpenAPI 3.1: type can be `["string", "null"]`. Collapse to the first
54
+ // non-null entry so the switch below routes correctly.
55
+ const effectiveType = Array.isArray(schema.type)
56
+ ? (schema.type as string[]).find(t => t !== "null") as OpenAPIV3.SchemaObject["type"] | undefined
57
+ : schema.type;
58
+
59
+ switch (effectiveType) {
43
60
  case "string":
44
61
  return guessStringPlaceholder(schema, propertyName);
45
62
 
@@ -53,8 +70,9 @@ export function generateFromSchema(
53
70
  return true;
54
71
 
55
72
  case "array": {
56
- if (schema.items) {
57
- const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
73
+ const arr = schema as OpenAPIV3.ArraySchemaObject;
74
+ if (arr.items) {
75
+ const item = generateFromSchema(arr.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
58
76
  return [item];
59
77
  }
60
78
  return [];
@@ -79,7 +97,7 @@ export function generateFromSchema(
79
97
  return { key1: "value1", key2: "value2" };
80
98
  }
81
99
  // Bare object with no properties
82
- if (schema.type === "object") {
100
+ if (effectiveType === "object") {
83
101
  return {};
84
102
  }
85
103
  return "{{$randomString}}";
@@ -87,6 +105,27 @@ export function generateFromSchema(
87
105
  }
88
106
  }
89
107
 
108
+ /**
109
+ * Map an OpenAPI `format` value to a zond generator placeholder. Returns
110
+ * undefined when the format is unknown or absent so callers can fall back
111
+ * to type / property-name heuristics. Exported for tests.
112
+ */
113
+ export function formatToPlaceholder(format: string | undefined): string | undefined {
114
+ switch (format) {
115
+ case "email": return "{{$randomEmail}}";
116
+ case "uuid": return "{{$uuid}}";
117
+ case "date-time": return "{{$randomIsoDate}}";
118
+ case "date": return "{{$randomDate}}";
119
+ case "uri":
120
+ case "url": return "{{$randomUrl}}";
121
+ case "hostname": return "{{$randomFqdn}}";
122
+ case "ipv4": return "{{$randomIpv4}}";
123
+ case "ipv6": return "::1";
124
+ case "password": return "TestPass123!";
125
+ default: return undefined;
126
+ }
127
+ }
128
+
90
129
  /**
91
130
  * Generate a multipart body object from an OpenAPI multipart/form-data schema.
92
131
  * Binary fields (format: binary/byte) become file upload objects; all others become strings.
@@ -112,16 +151,8 @@ export function generateMultipartFromSchema(
112
151
  }
113
152
 
114
153
  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!";
154
+ // Format-based dispatch already happened earlier in generateFromSchema;
155
+ // this branch only sees strings whose format is empty or unrecognised.
125
156
 
126
157
  // Name-based heuristics
127
158
  if (name) {
@@ -136,7 +167,7 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
136
167
  return "{{$randomName}}";
137
168
  }
138
169
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
139
- return "https://example.com/test";
170
+ return "{{$randomUrl}}";
140
171
  }
141
172
  if (lower === "password" || lower.endsWith("_password")) {
142
173
  return "TestPass123!";
@@ -120,7 +120,7 @@ Use \`json:\` for JSON request bodies. Do NOT use \`body:\` — it is not a vali
120
120
  For form-encoded: use \`form:\` instead of \`json:\`.
121
121
 
122
122
  ### Built-in generators
123
- \`{{$uuid}}\`, \`{{$randomInt}}\`, \`{{$timestamp}}\`, \`{{$isoTimestamp}}\`, \`{{$randomName}}\`, \`{{$randomEmail}}\`, \`{{$randomString}}\`
123
+ \`{{$uuid}}\`, \`{{$randomInt}}\`, \`{{$timestamp}}\`, \`{{$isoTimestamp}}\`, \`{{$randomName}}\`, \`{{$randomEmail}}\`, \`{{$randomString}}\`, \`{{$randomUrl}}\` (uri/url), \`{{$randomFqdn}}\` (hostname), \`{{$randomIpv4}}\` (ipv4), \`{{$randomDate}}\` (date), \`{{$randomIsoDate}}\` (date-time)
124
124
 
125
125
  ### Variable capture & interpolation
126
126
  \`\`\`yaml
@@ -93,6 +93,24 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
93
93
  const chosen = rb.content[requestBodyContentType!];
94
94
  if (chosen?.schema) {
95
95
  requestBodySchema = chosen.schema as OpenAPIV3.SchemaObject;
96
+ // OpenAPI allows examples at the media-type level (sibling to schema).
97
+ // Lift them onto the schema so the generator sees a single signal.
98
+ if (requestBodySchema.example === undefined) {
99
+ if ((chosen as OpenAPIV3.MediaTypeObject).example !== undefined) {
100
+ requestBodySchema = {
101
+ ...requestBodySchema,
102
+ example: (chosen as OpenAPIV3.MediaTypeObject).example,
103
+ };
104
+ } else if (chosen.examples) {
105
+ const firstNamed = Object.values(chosen.examples)[0];
106
+ if (firstNamed && typeof firstNamed === "object" && "value" in firstNamed) {
107
+ requestBodySchema = {
108
+ ...requestBodySchema,
109
+ example: (firstNamed as OpenAPIV3.ExampleObject).value,
110
+ };
111
+ }
112
+ }
113
+ }
96
114
  }
97
115
  }
98
116
  }
@@ -27,7 +27,7 @@ export interface RawStep {
27
27
  name: string;
28
28
  [methodKey: string]: unknown;
29
29
  expect: {
30
- status?: number;
30
+ status?: number | number[];
31
31
  body?: Record<string, Record<string, string>>;
32
32
  headers?: Record<string, unknown>;
33
33
  };
@@ -103,6 +103,11 @@ export function serializeSuite(suite: RawSuite): string {
103
103
  lines.push(` skip_if: ${yamlScalar(String(test.skip_if))}`);
104
104
  }
105
105
 
106
+ // always (cleanup steps that survive cascade-skip on tainted captures)
107
+ if (test.always === true) {
108
+ lines.push(" always: true");
109
+ }
110
+
106
111
  // retry_until
107
112
  if (test.retry_until && typeof test.retry_until === "object") {
108
113
  const rt = test.retry_until as Record<string, unknown>;
@@ -131,7 +136,11 @@ export function serializeSuite(suite: RawSuite): string {
131
136
  if (hasExpect) {
132
137
  lines.push(" expect:");
133
138
  if (test.expect.status !== undefined) {
134
- lines.push(` status: ${test.expect.status}`);
139
+ if (Array.isArray(test.expect.status)) {
140
+ lines.push(` status: [${test.expect.status.join(", ")}]`);
141
+ } else {
142
+ lines.push(` status: ${test.expect.status}`);
143
+ }
135
144
  }
136
145
  if (test.expect.body) {
137
146
  lines.push(" body:");
@@ -28,6 +28,30 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
28
28
  });
29
29
  }
30
30
 
31
+ /**
32
+ * For negative-smoke suites: replace path params with guaranteed-non-existent values.
33
+ * Picks a value that's syntactically valid for the param's type/format but very
34
+ * unlikely to match a real resource (zero-UUID, very large int, sentinel string).
35
+ */
36
+ function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
37
+ if (!schema) return "nonexistent_id_zzzzzz";
38
+ if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
39
+ if (schema.type === "integer" || schema.type === "number") return "999999999";
40
+ return "nonexistent_id_zzzzzz";
41
+ }
42
+
43
+ function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
44
+ return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
45
+ const param = ep.parameters.find(p => p.name === name && p.in === "path");
46
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
47
+ return getNonexistentSeed(schema);
48
+ });
49
+ }
50
+
51
+ function endpointHasPathParams(ep: EndpointInfo): boolean {
52
+ return ep.parameters.some(p => p.in === "path");
53
+ }
54
+
31
55
  function slugify(s: string): string {
32
56
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
33
57
  }
@@ -360,9 +384,11 @@ export function generateCrudSuite(
360
384
  });
361
385
  }
362
386
 
387
+ // T44: cleanup must run even if earlier assertions failed (tainted captures)
363
388
  const step: RawStep = {
364
389
  name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
365
390
  DELETE: itemPath,
391
+ always: true,
366
392
  expect: {
367
393
  status: getExpectedStatus(group.delete),
368
394
  },
@@ -372,11 +398,12 @@ export function generateCrudSuite(
372
398
  }
373
399
  tests.push(step);
374
400
 
375
- // 5. Verify deleted
401
+ // 5. Verify deleted — also always, so we confirm cleanup happened
376
402
  if (group.read) {
377
403
  tests.push({
378
404
  name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
379
405
  GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
406
+ always: true,
380
407
  expect: {
381
408
  status: 404,
382
409
  },
@@ -384,9 +411,13 @@ export function generateCrudSuite(
384
411
  }
385
412
  }
386
413
 
414
+ // T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
415
+ // in its starting state (ephemeral); without DELETE it leaves residual data.
416
+ const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
417
+
387
418
  const suite: RawSuite = {
388
419
  name: `${group.resource}-crud`,
389
- tags: ["crud"],
420
+ tags: ["crud", cleanupTag],
390
421
  fileStem: `crud-${slugify(group.resource)}`,
391
422
  base_url: "{{base_url}}",
392
423
  tests,
@@ -634,17 +665,20 @@ export function generateSuites(opts: {
634
665
  for (const [tag, tagEndpoints] of byTag) {
635
666
  const tagSlug = slugify(tag) || "api";
636
667
 
637
- // GET endpoints → smoke suite (use seed values for path params — no capture context)
668
+ // GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
638
669
  const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
639
- if (getEndpoints.length > 0) {
640
- const tests = getEndpoints.map(ep => {
670
+ const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
671
+ const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
672
+
673
+ // Regular smoke: paramless GETs (e.g. list endpoints, health checks)
674
+ if (paramlessGets.length > 0) {
675
+ const tests = paramlessGets.map(ep => {
641
676
  const step = generateStep(ep, securitySchemes);
642
- // Replace path param placeholders with seed values so the suite runs out of the box
643
677
  const seededPath = convertPathWithSeeds(ep.path, ep);
644
678
  (step as any)[ep.method.toUpperCase()] = seededPath;
645
679
  return step;
646
680
  });
647
- const headers = getSuiteHeaders(getEndpoints, securitySchemes);
681
+ const headers = getSuiteHeaders(paramlessGets, securitySchemes);
648
682
 
649
683
  const suite: RawSuite = {
650
684
  name: `${tagSlug}-smoke`,
@@ -666,6 +700,71 @@ export function generateSuites(opts: {
666
700
  suites.push(suite);
667
701
  }
668
702
 
703
+ // Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
704
+ if (pathParamGets.length > 0) {
705
+ const tests = pathParamGets.map(ep => {
706
+ const step = generateStep(ep, securitySchemes);
707
+ (step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
708
+ // Negative path: resource doesn't exist. Drop body assertions (response shape varies).
709
+ step.expect = { status: [400, 404, 422] };
710
+ return step;
711
+ });
712
+ const headers = getSuiteHeaders(pathParamGets, securitySchemes);
713
+
714
+ const suite: RawSuite = {
715
+ name: `${tagSlug}-smoke-negative`,
716
+ tags: ["smoke", "negative"],
717
+ fileStem: `smoke-${tagSlug}-negative`,
718
+ base_url: "{{base_url}}",
719
+ tests,
720
+ };
721
+
722
+ if (headers) {
723
+ suite.headers = headers;
724
+ for (const t of tests) {
725
+ if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
726
+ delete (t as any).headers;
727
+ }
728
+ }
729
+ }
730
+
731
+ suites.push(suite);
732
+ }
733
+
734
+ // Positive smoke: path-param GETs with {{var}} placeholders + skip_if for unset env
735
+ if (pathParamGets.length > 0) {
736
+ const tests = pathParamGets.map(ep => {
737
+ const step = generateStep(ep, securitySchemes);
738
+ // Path stays as {{param}} so user-provided env values flow in
739
+ // Pick the first path param for skip_if guard (the resource ID)
740
+ const firstPathParam = ep.parameters.find(p => p.in === "path");
741
+ if (firstPathParam) {
742
+ step.skip_if = `{{${firstPathParam.name}}} ==`;
743
+ }
744
+ return step;
745
+ });
746
+ const headers = getSuiteHeaders(pathParamGets, securitySchemes);
747
+
748
+ const suite: RawSuite = {
749
+ name: `${tagSlug}-smoke-positive`,
750
+ tags: ["smoke", "positive", "needs-id"],
751
+ fileStem: `smoke-${tagSlug}-positive`,
752
+ base_url: "{{base_url}}",
753
+ tests,
754
+ };
755
+
756
+ if (headers) {
757
+ suite.headers = headers;
758
+ for (const t of tests) {
759
+ if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
760
+ delete (t as any).headers;
761
+ }
762
+ }
763
+ }
764
+
765
+ suites.push(suite);
766
+ }
767
+
669
768
  // Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
670
769
  const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
671
770
  const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
@@ -12,8 +12,6 @@ export interface ZondMeta {
12
12
  zondVersion: string;
13
13
  /** ISO timestamp of last sync/generate */
14
14
  lastSyncedAt: string;
15
- /** Spec URL or file path used for last generation */
16
- specUrl: string;
17
15
  /** SHA-256 hex of spec content at time of last generation */
18
16
  specHash: string;
19
17
  /** Per-file metadata, keyed by filename (e.g. "smoke-users.yaml") */
@@ -92,7 +92,7 @@ const AssertionRuleSchemaInner: z.ZodType<AssertionRule> = z.preprocess(
92
92
  },
93
93
  z.object({
94
94
  capture: z.string().optional(),
95
- type: z.enum(["string", "integer", "number", "boolean", "array", "object"]).optional(),
95
+ type: z.enum(["string", "integer", "number", "boolean", "array", "object", "null"]).optional(),
96
96
  equals: z.unknown().optional(),
97
97
  not_equals: z.unknown().optional(),
98
98
  contains: z.string().optional(),
@@ -184,6 +184,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
184
184
  retry_until: RetryUntilSchema.optional(),
185
185
  for_each: ForEachSchema.optional(),
186
186
  set: z.record(z.string(), z.unknown()).optional(),
187
+ always: z.boolean().optional(),
187
188
  }),
188
189
  ) as z.ZodType<TestStep>;
189
190
 
@@ -220,6 +221,7 @@ const TestSuiteSchema = z.preprocess(
220
221
  tags: z.array(z.string()).optional(),
221
222
  base_url: z.string().optional(),
222
223
  headers: z.record(z.string(), z.string()).optional(),
224
+ parameterize: z.record(z.string(), z.array(z.unknown()).min(1)).optional(),
223
225
  config: SuiteConfigSchema,
224
226
  tests: z.array(TestStepSchema).min(1),
225
227
  }),
@@ -2,7 +2,7 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2
2
 
3
3
  export interface AssertionRule {
4
4
  capture?: string;
5
- type?: "string" | "integer" | "number" | "boolean" | "array" | "object";
5
+ type?: "string" | "integer" | "number" | "boolean" | "array" | "object" | "null";
6
6
  equals?: unknown;
7
7
  not_equals?: unknown;
8
8
  contains?: string;
@@ -63,6 +63,12 @@ export interface TestStep {
63
63
  retry_until?: RetryUntil;
64
64
  for_each?: ForEach;
65
65
  set?: Record<string, unknown>;
66
+ /**
67
+ * Run this step even when prior steps in the suite have failed assertions
68
+ * (so their captures are "tainted"). Designed for cleanup steps. Still
69
+ * skips if a referenced capture is genuinely missing from a response.
70
+ */
71
+ always?: boolean;
66
72
  }
67
73
 
68
74
  export interface SuiteConfig {
@@ -81,6 +87,9 @@ export interface TestSuite {
81
87
  tags?: string[];
82
88
  base_url?: string;
83
89
  headers?: Record<string, string>;
90
+ /** Cross-product parameterisation: each key contributes one variable
91
+ * binding per array entry. Suite body runs once per combination. */
92
+ parameterize?: Record<string, unknown[]>;
84
93
  config: SuiteConfig;
85
94
  tests: TestStep[];
86
95
  /** Absolute path to the source file, set by yaml-parser */