@kirrosh/zond 0.20.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 (59) 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/catalog.ts +62 -0
  5. package/src/cli/commands/ci-init.ts +12 -6
  6. package/src/cli/commands/completions.ts +176 -0
  7. package/src/cli/commands/db.ts +2 -1
  8. package/src/cli/commands/generate.ts +18 -2
  9. package/src/cli/commands/init/agents-md.ts +61 -0
  10. package/src/cli/commands/init/bootstrap.ts +79 -0
  11. package/src/cli/commands/init/skills.ts +45 -0
  12. package/src/cli/commands/init/templates/agents.md +73 -0
  13. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  14. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  15. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  16. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  17. package/src/cli/commands/init.ts +124 -31
  18. package/src/cli/commands/probe-methods.ts +108 -0
  19. package/src/cli/commands/probe-validation.ts +124 -0
  20. package/src/cli/commands/run.ts +99 -10
  21. package/src/cli/commands/serve.ts +52 -19
  22. package/src/cli/commands/sync.ts +28 -1
  23. package/src/cli/commands/update.ts +1 -1
  24. package/src/cli/commands/use.ts +57 -0
  25. package/src/cli/index.ts +21 -591
  26. package/src/cli/program.ts +655 -0
  27. package/src/cli/version.ts +3 -0
  28. package/src/core/context/current.ts +35 -0
  29. package/src/core/diagnostics/db-analysis.ts +11 -2
  30. package/src/core/diagnostics/render-md.ts +112 -0
  31. package/src/core/generator/catalog-builder.ts +179 -0
  32. package/src/core/generator/chunker.ts +14 -2
  33. package/src/core/generator/data-factory.ts +50 -19
  34. package/src/core/generator/guide-builder.ts +1 -1
  35. package/src/core/generator/index.ts +2 -0
  36. package/src/core/generator/openapi-reader.ts +18 -0
  37. package/src/core/generator/serializer.ts +11 -2
  38. package/src/core/generator/suite-generator.ts +106 -7
  39. package/src/core/meta/types.ts +0 -2
  40. package/src/core/parser/schema.ts +3 -1
  41. package/src/core/parser/types.ts +10 -1
  42. package/src/core/parser/variables.ts +90 -2
  43. package/src/core/parser/yaml-parser.ts +50 -1
  44. package/src/core/probe/method-probe.ts +197 -0
  45. package/src/core/probe/negative-probe.ts +657 -0
  46. package/src/core/reporter/console.ts +29 -3
  47. package/src/core/reporter/index.ts +2 -2
  48. package/src/core/reporter/json.ts +5 -2
  49. package/src/core/runner/assertions.ts +4 -1
  50. package/src/core/runner/executor.ts +132 -37
  51. package/src/core/runner/http-client.ts +40 -5
  52. package/src/core/runner/rate-limiter.ts +131 -0
  53. package/src/core/setup-api.ts +4 -1
  54. package/src/core/workspace/root.ts +94 -0
  55. package/src/db/schema.ts +4 -1
  56. package/src/web/routes/api.ts +80 -0
  57. package/src/web/routes/dashboard.ts +15 -0
  58. package/src/web/static/style.css +290 -0
  59. package/src/web/views/explorer-tab.ts +402 -0
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Negative-input probe generator (T49).
3
+ *
4
+ * Goal: catch the class of bugs where an API returns 5xx (unhandled exception)
5
+ * instead of 4xx (validation error) when given malformed input. The contract
6
+ * is simple: any client-supplied invalid input MUST produce a 4xx, never a 5xx.
7
+ *
8
+ * For each endpoint we generate a suite of probe steps. Each step expects a
9
+ * "no 5xx" response (status in [400, 401, 403, 404, 405, 409, 415, 422]).
10
+ * If the API returns 500/502/503 — the test fails and the runner logs it as
11
+ * a bug candidate via the regular reporter / `zond db diagnose` flow.
12
+ *
13
+ * The probes are deterministic — same spec → same suites — so the generated
14
+ * YAML can be committed as a regression test.
15
+ */
16
+ import type { OpenAPIV3 } from "openapi-types";
17
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
18
+ import type { RawSuite, RawStep } from "../generator/serializer.ts";
19
+ import { generateFromSchema } from "../generator/data-factory.ts";
20
+
21
+ // ──────────────────────────────────────────────
22
+ // Constants
23
+ // ──────────────────────────────────────────────
24
+
25
+ /** Statuses we consider an *acceptable* response to invalid input. Anything
26
+ * outside this set (notably 5xx, but also 200/201 which would mean the API
27
+ * silently accepted the bad input) is a probe failure. */
28
+ const ACCEPTABLE_4XX = [400, 401, 403, 404, 405, 409, 415, 422];
29
+
30
+ /** Long string for boundary probes — 10_000 chars. */
31
+ const LONG_STRING = "a".repeat(10_000);
32
+
33
+ /** Mixed unicode + emoji + RTL for charset probes. */
34
+ const UNICODE_MIX = "Mix🌐مرحبا\u200B";
35
+
36
+ /** Sentinel non-UUID inputs for path/UUID probes. */
37
+ const INVALID_UUID_VALUES = [
38
+ "not-a-uuid",
39
+ "12345",
40
+ "00000000",
41
+ "../../etc/passwd",
42
+ ];
43
+
44
+ /** Sentinel invalid emails. */
45
+ const INVALID_EMAIL_VALUES = [
46
+ "not-an-email",
47
+ "@no-local.example.com",
48
+ "spaces in@email.com",
49
+ ];
50
+
51
+ /** Sentinel invalid URIs. */
52
+ const INVALID_URI_VALUES = [
53
+ "not a url",
54
+ "javascript:alert(1)",
55
+ "ftp:/missing-slash",
56
+ ];
57
+
58
+ /** Sentinel invalid date-time strings. */
59
+ const INVALID_DATETIME_VALUES = [
60
+ "yesterday",
61
+ "2023-13-45T99:99:99Z",
62
+ "2023-10-06:23:47:56.678Z", // colon-instead-of-T (real bug we caught)
63
+ ];
64
+
65
+ // ──────────────────────────────────────────────
66
+ // Types
67
+ // ──────────────────────────────────────────────
68
+
69
+ export interface ProbeOptions {
70
+ endpoints: EndpointInfo[];
71
+ securitySchemes: SecuritySchemeInfo[];
72
+ /** Cap probes per endpoint (default 50). Hard cutoff for huge schemas. */
73
+ maxProbesPerEndpoint?: number;
74
+ /**
75
+ * Skip emission of follow-up DELETE cleanup steps for mutating probes
76
+ * (POST/PUT/PATCH). Useful for namespace-isolated test environments
77
+ * (staging dump-and-reset) where cleanup is handled out of band.
78
+ */
79
+ noCleanup?: boolean;
80
+ }
81
+
82
+ export interface ProbeResult {
83
+ suites: RawSuite[];
84
+ /** Number of endpoints that received probes. */
85
+ probedEndpoints: number;
86
+ /** Endpoints we skipped (no body & no UUID path params). */
87
+ skippedEndpoints: number;
88
+ /** Total generated probe steps. */
89
+ totalProbes: number;
90
+ /**
91
+ * Generation-time warnings — typically about mutating endpoints whose
92
+ * probes might leak resources because the spec defines no DELETE
93
+ * counterpart. CLI surfaces these to the user.
94
+ */
95
+ warnings: string[];
96
+ }
97
+
98
+ // ──────────────────────────────────────────────
99
+ // Helpers
100
+ // ──────────────────────────────────────────────
101
+
102
+ function convertPath(path: string): string {
103
+ return path.replace(/\{([^}]+)\}/g, "{{$1}}");
104
+ }
105
+
106
+ function slugify(s: string): string {
107
+ return s
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9]+/g, "-")
110
+ .replace(/^-|-$/g, "");
111
+ }
112
+
113
+ function endpointStem(ep: EndpointInfo): string {
114
+ const path = ep.path
115
+ .replace(/\{[^}]+\}/g, "by-id")
116
+ .replace(/^\//, "")
117
+ .replace(/\//g, "-");
118
+ return slugify(`${ep.method.toLowerCase()}-${path}`);
119
+ }
120
+
121
+ function getAuthHeaders(
122
+ ep: EndpointInfo,
123
+ schemes: SecuritySchemeInfo[],
124
+ ): Record<string, string> | undefined {
125
+ if (ep.security.length === 0) return undefined;
126
+ for (const secName of ep.security) {
127
+ const scheme = schemes.find((s) => s.name === secName);
128
+ if (!scheme) continue;
129
+ if (scheme.type === "http") {
130
+ if (scheme.scheme === "bearer" || !scheme.scheme) {
131
+ return { Authorization: "Bearer {{auth_token}}" };
132
+ }
133
+ if (scheme.scheme === "basic") {
134
+ return { Authorization: "Basic {{auth_token}}" };
135
+ }
136
+ }
137
+ if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
138
+ if (scheme.apiKeyName === "Authorization") {
139
+ return { Authorization: "Bearer {{auth_token}}" };
140
+ }
141
+ return { [scheme.apiKeyName]: "{{api_key}}" };
142
+ }
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /** Path with placeholders replaced by valid-but-nonexistent IDs (for body probes
148
+ * on PUT/PATCH/DELETE — we don't want path validation to mask body errors). */
149
+ function pathWithPlaceholders(ep: EndpointInfo, badId: string): string {
150
+ return ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
151
+ const param = ep.parameters.find((p) => p.name === name && p.in === "path");
152
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
153
+ if (badId === "valid-shape") {
154
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
155
+ if (schema?.type === "integer" || schema?.type === "number") return "999999999";
156
+ return "nonexistent-zzzzz";
157
+ }
158
+ return badId;
159
+ });
160
+ }
161
+
162
+ function findUuidPathParams(ep: EndpointInfo): OpenAPIV3.ParameterObject[] {
163
+ return ep.parameters.filter((p) => {
164
+ if (p.in !== "path") return false;
165
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
166
+ if (!schema) return false;
167
+ if (schema.format === "uuid") return true;
168
+ // also probe path params named like *_id / *_uuid
169
+ const lower = p.name.toLowerCase();
170
+ return lower === "id" || lower.endsWith("_id") || lower === "uuid";
171
+ });
172
+ }
173
+
174
+ /** Walk schema and collect required-field paths up to depth 1 with their schema. */
175
+ function collectRequiredFields(
176
+ schema: OpenAPIV3.SchemaObject | undefined,
177
+ ): Array<{ name: string; schema: OpenAPIV3.SchemaObject }> {
178
+ if (!schema || !schema.properties) return [];
179
+ const required = new Set(schema.required ?? []);
180
+ const out: Array<{ name: string; schema: OpenAPIV3.SchemaObject }> = [];
181
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
182
+ if (required.has(name)) {
183
+ out.push({ name, schema: propSchema as OpenAPIV3.SchemaObject });
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** Walk schema (depth 1) and collect all properties with their schema. */
190
+ function collectAllProps(
191
+ schema: OpenAPIV3.SchemaObject | undefined,
192
+ ): Array<{ name: string; schema: OpenAPIV3.SchemaObject; required: boolean }> {
193
+ if (!schema || !schema.properties) return [];
194
+ const required = new Set(schema.required ?? []);
195
+ return Object.entries(schema.properties).map(([name, s]) => ({
196
+ name,
197
+ schema: s as OpenAPIV3.SchemaObject,
198
+ required: required.has(name),
199
+ }));
200
+ }
201
+
202
+ // ──────────────────────────────────────────────
203
+ // Probe generators
204
+ // ──────────────────────────────────────────────
205
+
206
+ /**
207
+ * Build a step that targets `endpoint`, but with an arbitrary body override.
208
+ * Authentication and required path params are populated with valid placeholders
209
+ * so the request reaches the body-validation layer.
210
+ */
211
+ function buildStep(
212
+ ep: EndpointInfo,
213
+ schemes: SecuritySchemeInfo[],
214
+ opts: {
215
+ name: string;
216
+ json?: unknown;
217
+ pathOverride?: string;
218
+ expectStatusOk?: number[];
219
+ },
220
+ ): RawStep {
221
+ const method = ep.method.toUpperCase();
222
+ const path = opts.pathOverride ?? pathWithPlaceholders(ep, "valid-shape");
223
+ const headers = getAuthHeaders(ep, schemes);
224
+
225
+ const step: RawStep = {
226
+ name: opts.name,
227
+ [method]: convertPath(path),
228
+ expect: {
229
+ status: opts.expectStatusOk ?? ACCEPTABLE_4XX,
230
+ },
231
+ };
232
+ if (headers) step.headers = headers;
233
+ if (opts.json !== undefined) (step as any).json = opts.json;
234
+ return step;
235
+ }
236
+
237
+ function probeEmptyBody(ep: EndpointInfo, schemes: SecuritySchemeInfo[]): RawStep | null {
238
+ if (!hasJsonBody(ep)) return null;
239
+ const required = collectRequiredFields(ep.requestBodySchema);
240
+ // Only meaningful when there *is* required data — otherwise {} is valid.
241
+ if (required.length === 0) return null;
242
+ return buildStep(ep, schemes, {
243
+ name: "empty body — must reject (no 5xx)",
244
+ json: {},
245
+ });
246
+ }
247
+
248
+ function probeMissingRequired(
249
+ ep: EndpointInfo,
250
+ schemes: SecuritySchemeInfo[],
251
+ budget: number,
252
+ ): RawStep[] {
253
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
254
+ const required = collectRequiredFields(ep.requestBodySchema);
255
+ if (required.length === 0) return [];
256
+
257
+ // Build a baseline valid object, then drop one required field at a time.
258
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
259
+ if (typeof baseline !== "object" || baseline === null) return [];
260
+
261
+ const out: RawStep[] = [];
262
+ for (const field of required) {
263
+ if (out.length >= budget) break;
264
+ const variant = { ...baseline };
265
+ delete variant[field.name];
266
+ out.push(
267
+ buildStep(ep, schemes, {
268
+ name: `missing required field "${field.name}" — must reject (no 5xx)`,
269
+ json: variant,
270
+ }),
271
+ );
272
+ }
273
+ return out;
274
+ }
275
+
276
+ function probeBoundaryString(
277
+ ep: EndpointInfo,
278
+ schemes: SecuritySchemeInfo[],
279
+ budget: number,
280
+ ): RawStep[] {
281
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
282
+ const props = collectAllProps(ep.requestBodySchema).filter(
283
+ (p) => p.schema.type === "string",
284
+ );
285
+ if (props.length === 0) return [];
286
+
287
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
288
+ if (typeof baseline !== "object" || baseline === null) return [];
289
+
290
+ const out: RawStep[] = [];
291
+ // Only probe the first N string fields to stay within budget
292
+ for (const field of props.slice(0, Math.max(1, Math.floor(budget / 3)))) {
293
+ if (out.length + 3 > budget) break;
294
+ out.push(
295
+ buildStep(ep, schemes, {
296
+ name: `${field.name}: empty string — must reject (no 5xx)`,
297
+ json: { ...baseline, [field.name]: "" },
298
+ }),
299
+ buildStep(ep, schemes, {
300
+ name: `${field.name}: 10000-char string — must reject or accept (no 5xx)`,
301
+ json: { ...baseline, [field.name]: LONG_STRING },
302
+ }),
303
+ buildStep(ep, schemes, {
304
+ name: `${field.name}: unicode/emoji/RTL — must not 5xx`,
305
+ json: { ...baseline, [field.name]: UNICODE_MIX },
306
+ }),
307
+ );
308
+ }
309
+ return out;
310
+ }
311
+
312
+ function probeTypeConfusion(
313
+ ep: EndpointInfo,
314
+ schemes: SecuritySchemeInfo[],
315
+ budget: number,
316
+ ): RawStep[] {
317
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
318
+ const props = collectAllProps(ep.requestBodySchema);
319
+ if (props.length === 0) return [];
320
+
321
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
322
+ if (typeof baseline !== "object" || baseline === null) return [];
323
+
324
+ const out: RawStep[] = [];
325
+ for (const field of props) {
326
+ if (out.length >= budget) break;
327
+ const wrongValue = pickWrongType(field.schema);
328
+ if (wrongValue === undefined) continue;
329
+ out.push(
330
+ buildStep(ep, schemes, {
331
+ name: `${field.name}: wrong type (${describeType(field.schema)} → ${typeof wrongValue}) — must reject (no 5xx)`,
332
+ json: { ...baseline, [field.name]: wrongValue },
333
+ }),
334
+ );
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function probeInvalidFormat(
340
+ ep: EndpointInfo,
341
+ schemes: SecuritySchemeInfo[],
342
+ budget: number,
343
+ ): RawStep[] {
344
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
345
+ const props = collectAllProps(ep.requestBodySchema);
346
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
347
+ if (typeof baseline !== "object" || baseline === null) return [];
348
+
349
+ const out: RawStep[] = [];
350
+ for (const field of props) {
351
+ if (out.length >= budget) break;
352
+ const fmt = field.schema.format;
353
+ let badValue: string | undefined;
354
+ if (fmt === "email") badValue = INVALID_EMAIL_VALUES[0];
355
+ else if (fmt === "uri" || fmt === "url") badValue = INVALID_URI_VALUES[0];
356
+ else if (fmt === "date-time") badValue = INVALID_DATETIME_VALUES[0];
357
+ else if (fmt === "uuid") badValue = INVALID_UUID_VALUES[0];
358
+ if (badValue === undefined) continue;
359
+ out.push(
360
+ buildStep(ep, schemes, {
361
+ name: `${field.name}: invalid ${fmt} (${JSON.stringify(badValue)}) — must reject (no 5xx)`,
362
+ json: { ...baseline, [field.name]: badValue },
363
+ }),
364
+ );
365
+ }
366
+ return out;
367
+ }
368
+
369
+ function probeInvalidEnum(
370
+ ep: EndpointInfo,
371
+ schemes: SecuritySchemeInfo[],
372
+ budget: number,
373
+ ): RawStep[] {
374
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
375
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
376
+ if (typeof baseline !== "object" || baseline === null) return [];
377
+
378
+ const out: RawStep[] = [];
379
+ // Walk depth 1 for plain enum strings
380
+ for (const field of collectAllProps(ep.requestBodySchema)) {
381
+ if (out.length >= budget) break;
382
+ if (Array.isArray(field.schema.enum) && field.schema.enum.length > 0) {
383
+ out.push(
384
+ buildStep(ep, schemes, {
385
+ name: `${field.name}: unknown enum value "zond_invalid_value" — must reject (no 5xx)`,
386
+ json: { ...baseline, [field.name]: "zond_invalid_value" },
387
+ }),
388
+ );
389
+ }
390
+ // enum-of-strings inside an array (e.g. webhooks.events: [enum])
391
+ if (field.schema.type === "array" && field.schema.items) {
392
+ const items = field.schema.items as OpenAPIV3.SchemaObject;
393
+ const enumLike = Array.isArray(items.enum) && items.enum.length > 0;
394
+ const isStringArray = items.type === "string";
395
+ if (enumLike || isStringArray) {
396
+ // even when no enum is declared, names like "events"/"types"/"channels"
397
+ // strongly imply a backing whitelist — bug #05B
398
+ out.push(
399
+ buildStep(ep, schemes, {
400
+ name: `${field.name}: array with unknown value ["zond.nonexistent.event"] — must reject (no 5xx)`,
401
+ json: { ...baseline, [field.name]: ["zond.nonexistent.event"] },
402
+ }),
403
+ );
404
+ }
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+
410
+ function probeInvalidPathId(
411
+ ep: EndpointInfo,
412
+ schemes: SecuritySchemeInfo[],
413
+ budget: number,
414
+ ): RawStep[] {
415
+ const params = findUuidPathParams(ep);
416
+ if (params.length === 0) return [];
417
+ // Skip POST /resource (no path id) — covered by body probes
418
+ const out: RawStep[] = [];
419
+ for (const param of params) {
420
+ for (const bad of INVALID_UUID_VALUES) {
421
+ if (out.length >= budget) break;
422
+ const path = ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
423
+ if (name === param.name) return bad;
424
+ const other = ep.parameters.find((p) => p.name === name && p.in === "path");
425
+ const schema = other?.schema as OpenAPIV3.SchemaObject | undefined;
426
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
427
+ if (schema?.type === "integer" || schema?.type === "number") return "999999999";
428
+ return "nonexistent-zzzzz";
429
+ });
430
+ out.push(
431
+ buildStep(ep, schemes, {
432
+ name: `path param ${param.name}=${JSON.stringify(bad)} — must reject (no 5xx)`,
433
+ pathOverride: path,
434
+ }),
435
+ );
436
+ }
437
+ }
438
+ return out;
439
+ }
440
+
441
+ // ──────────────────────────────────────────────
442
+ // Type-confusion helpers
443
+ // ──────────────────────────────────────────────
444
+
445
+ function pickWrongType(schema: OpenAPIV3.SchemaObject): unknown | undefined {
446
+ switch (schema.type) {
447
+ case "string":
448
+ return 12345; // number where string expected
449
+ case "integer":
450
+ case "number":
451
+ return "five"; // string where number expected
452
+ case "boolean":
453
+ return "true"; // string where boolean expected
454
+ case "array":
455
+ return { not: "an-array" }; // object where array expected
456
+ case "object":
457
+ return ["not", "an", "object"]; // array where object expected
458
+ default:
459
+ return undefined;
460
+ }
461
+ }
462
+
463
+ function describeType(schema: OpenAPIV3.SchemaObject): string {
464
+ if (schema.format) return `${schema.type ?? "any"}/${schema.format}`;
465
+ return schema.type ?? "any";
466
+ }
467
+
468
+ function hasJsonBody(ep: EndpointInfo): boolean {
469
+ return (
470
+ ep.method !== "GET" &&
471
+ ep.method !== "DELETE" &&
472
+ ep.requestBodyContentType === "application/json" &&
473
+ ep.requestBodySchema !== undefined
474
+ );
475
+ }
476
+
477
+ function escapeRegex(s: string): string {
478
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
+ }
480
+
481
+ function isMutating(method: string): boolean {
482
+ const m = method.toUpperCase();
483
+ return m === "POST" || m === "PUT" || m === "PATCH";
484
+ }
485
+
486
+ /**
487
+ * Find the DELETE endpoint that owns resources created by `ep`:
488
+ * - POST /collection → DELETE /collection/{id}
489
+ * - PUT /collection/{id} → DELETE /collection/{id} (same path)
490
+ * - PATCH /collection/{id} → DELETE /collection/{id} (same path)
491
+ */
492
+ function findDeleteCounterpart(
493
+ ep: EndpointInfo,
494
+ all: EndpointInfo[],
495
+ ): EndpointInfo | undefined {
496
+ const m = ep.method.toUpperCase();
497
+ if (m === "POST") {
498
+ const re = new RegExp(`^${escapeRegex(ep.path)}/\\{[^}]+\\}$`);
499
+ return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && re.test(e.path));
500
+ }
501
+ if (m === "PUT" || m === "PATCH") {
502
+ return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && e.path === ep.path);
503
+ }
504
+ return undefined;
505
+ }
506
+
507
+ /**
508
+ * Pick the response field that holds the new resource's id. Defaults to "id";
509
+ * falls back to the first integer / uuid property when no `id` field exists.
510
+ */
511
+ function captureFieldFor(ep: EndpointInfo): string {
512
+ const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300 && r.schema);
513
+ const schema = success?.schema;
514
+ if (schema?.properties) {
515
+ if ("id" in schema.properties) return "id";
516
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
517
+ const s = propSchema as OpenAPIV3.SchemaObject;
518
+ if (s.type === "integer" || s.format === "uuid") return name;
519
+ }
520
+ }
521
+ return "id";
522
+ }
523
+
524
+ /** Build a cleanup-DELETE step for a single mutating probe. The capture var
525
+ * must come from the paired probe step. If the probe didn't capture (e.g.
526
+ * the API correctly returned 4xx and no resource was created), the runner
527
+ * skips this step via the standard "missing capture" path — exactly the
528
+ * semantics we want. */
529
+ function buildCleanupStep(
530
+ deleteEp: EndpointInfo,
531
+ schemes: SecuritySchemeInfo[],
532
+ captureVar: string,
533
+ probeStepName: string,
534
+ ): RawStep {
535
+ // Replace the DELETE's path-id placeholder with our captured var.
536
+ const path = convertPath(deleteEp.path).replace(/\{\{[^}]+\}\}/, `{{${captureVar}}}`);
537
+ const headers = getAuthHeaders(deleteEp, schemes);
538
+ const step: RawStep = {
539
+ name: `cleanup leaked resource from "${probeStepName}"`,
540
+ always: true,
541
+ DELETE: path,
542
+ expect: {
543
+ status: [200, 202, 204, 404],
544
+ },
545
+ };
546
+ if (headers) step.headers = headers;
547
+ return step;
548
+ }
549
+
550
+ function headersEqual(a: Record<string, string>, b: Record<string, string>): boolean {
551
+ const ka = Object.keys(a);
552
+ const kb = Object.keys(b);
553
+ if (ka.length !== kb.length) return false;
554
+ for (const k of ka) if (a[k] !== b[k]) return false;
555
+ return true;
556
+ }
557
+
558
+ // ──────────────────────────────────────────────
559
+ // Public API
560
+ // ──────────────────────────────────────────────
561
+
562
+ export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
563
+ const { endpoints, securitySchemes } = opts;
564
+ const cap = opts.maxProbesPerEndpoint ?? 50;
565
+ const noCleanup = opts.noCleanup === true;
566
+
567
+ const suites: RawSuite[] = [];
568
+ const warnings: string[] = [];
569
+ let probedEndpoints = 0;
570
+ let skippedEndpoints = 0;
571
+ let totalProbes = 0;
572
+
573
+ for (const ep of endpoints) {
574
+ if (ep.deprecated) continue;
575
+
576
+ const steps: RawStep[] = [];
577
+ const remaining = () => Math.max(0, cap - steps.length);
578
+
579
+ // 1. Path-id probes (cheap, deterministic)
580
+ steps.push(...probeInvalidPathId(ep, securitySchemes, remaining()));
581
+
582
+ // 2. Body probes (only for body-bearing methods)
583
+ const empty = probeEmptyBody(ep, securitySchemes);
584
+ if (empty && steps.length < cap) steps.push(empty);
585
+
586
+ steps.push(...probeMissingRequired(ep, securitySchemes, remaining()));
587
+ steps.push(...probeTypeConfusion(ep, securitySchemes, remaining()));
588
+ steps.push(...probeInvalidFormat(ep, securitySchemes, remaining()));
589
+ steps.push(...probeBoundaryString(ep, securitySchemes, remaining()));
590
+ steps.push(...probeInvalidEnum(ep, securitySchemes, remaining()));
591
+
592
+ if (steps.length === 0) {
593
+ skippedEndpoints++;
594
+ continue;
595
+ }
596
+
597
+ // T79: Cleanup for mutating probes. If a probe accidentally returns 2xx
598
+ // (the bug class probe-validation hunts for), the resource sticks around
599
+ // unless we follow up with a DELETE. We pair each probe step with a
600
+ // cleanup-DELETE marked `always: true`; the runner skips the DELETE
601
+ // automatically when no id was captured (i.e. the probe correctly got 4xx).
602
+ const cleanupSteps: RawStep[] = [];
603
+ if (isMutating(ep.method) && !noCleanup) {
604
+ const deleteEp = findDeleteCounterpart(ep, endpoints);
605
+ if (deleteEp) {
606
+ const idField = captureFieldFor(ep);
607
+ for (let i = 0; i < steps.length; i++) {
608
+ const probeStep = steps[i]!;
609
+ const captureVar = `leaked_id_${i}`;
610
+ const probeExpect = probeStep.expect as { body?: Record<string, unknown> };
611
+ if (!probeExpect.body) probeExpect.body = {};
612
+ // capture-only rule: doesn't add an assertion, just extracts the id
613
+ // when the response body has one. extractCaptures is a no-op when
614
+ // the field is absent (the typical 4xx case).
615
+ probeExpect.body[idField] = { capture: captureVar };
616
+ cleanupSteps.push(buildCleanupStep(deleteEp, securitySchemes, captureVar, probeStep.name));
617
+ }
618
+ } else {
619
+ warnings.push(
620
+ `${ep.method.toUpperCase()} ${ep.path}: probe-validation may create resources but spec defines no DELETE counterpart — manual cleanup required if any probe unexpectedly returns 2xx`,
621
+ );
622
+ }
623
+ }
624
+ if (cleanupSteps.length > 0) {
625
+ steps.push(...cleanupSteps);
626
+ }
627
+
628
+ probedEndpoints++;
629
+ totalProbes += steps.length;
630
+
631
+ // Hoist auth headers to suite level — every probe in this suite hits the
632
+ // same endpoint, so per-step headers are pure duplication. Dropping them
633
+ // here keeps generated YAML small and makes suite-level overrides
634
+ // (e.g. switching auth tokens) work as expected.
635
+ const suiteHeaders = getAuthHeaders(ep, securitySchemes);
636
+ if (suiteHeaders) {
637
+ for (const step of steps) {
638
+ if (step.headers && headersEqual(step.headers as Record<string, string>, suiteHeaders)) {
639
+ delete (step as { headers?: unknown }).headers;
640
+ }
641
+ }
642
+ }
643
+
644
+ const stem = endpointStem(ep);
645
+ const suite: RawSuite = {
646
+ name: `probe ${ep.method} ${ep.path}`,
647
+ tags: ["probe-validation", "negative-input", "no-5xx"],
648
+ fileStem: `probe-${stem}`,
649
+ base_url: "{{base_url}}",
650
+ ...(suiteHeaders ? { headers: suiteHeaders } : {}),
651
+ tests: steps,
652
+ };
653
+ suites.push(suite);
654
+ }
655
+
656
+ return { suites, probedEndpoints, skippedEndpoints, totalProbes, warnings };
657
+ }