@kirrosh/zond 0.23.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -2,7 +2,7 @@ import type { OpenAPIV3 } from "openapi-types";
2
2
  import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
3
3
  import type { RawSuite, RawStep } from "./serializer.ts";
4
4
  import type { SourceMetadata } from "../parser/types.ts";
5
- import { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
5
+ import { generateFromSchema, generateMultipartFromSchema, isFkFixtureField, canonicalVarName, effectiveObjectShape } from "./data-factory.ts";
6
6
  import { groupEndpointsByTag } from "./chunker.ts";
7
7
  import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
8
8
  import { flattenToFormFields } from "../runner/form-encode.ts";
@@ -48,9 +48,16 @@ export function resourceVar(resource: string, suffix: string): string {
48
48
  return `${singular.replace(/[^a-zA-Z0-9]+/g, "_")}_${suffix}`.toLowerCase();
49
49
  }
50
50
 
51
- /** Convert OpenAPI path params {param} to test interpolation {{param}} */
52
- function convertPath(path: string): string {
53
- return path.replace(/\{([^}]+)\}/g, "{{$1}}");
51
+ /** Convert OpenAPI path params {param} to test interpolation {{param}}.
52
+ * When `ambiguous` is given (ARV-369), a param name reused across
53
+ * distinct resources becomes a resource-scoped var (`{{templates_code}}`)
54
+ * instead of the raw name, so `.env.yaml` can hold distinct values per
55
+ * resource instead of one value silently applying everywhere. */
56
+ function convertPath(path: string, ambiguous?: Set<string>): string {
57
+ if (!ambiguous || ambiguous.size === 0) {
58
+ return path.replace(/\{([^}]+)\}/g, "{{$1}}");
59
+ }
60
+ return path.replace(/\{([^}]+)\}/g, (_, name) => `{{${fixtureVarNameForPathParam(path, name, ambiguous)}}}`);
54
61
  }
55
62
 
56
63
  /**
@@ -96,6 +103,75 @@ function slugify(s: string): string {
96
103
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
97
104
  }
98
105
 
106
+ /** Strip trailing API-version path segments (`v1`, `v30`, `version2`) so
107
+ * resource-name derivation doesn't mistake a version marker for the
108
+ * resource itself on APIs shaped `/api/<resource>/v{N}` (ARV-372). */
109
+ export function stripTrailingVersionSegments(segments: string[]): string[] {
110
+ const out = [...segments];
111
+ while (out.length > 1 && /^v(ersion)?\d+$/i.test(out[out.length - 1]!)) {
112
+ out.pop();
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Owning collection for a path-param: the last non-version path segment
118
+ * before its `{name}` placeholder. Used to disambiguate a path-param name
119
+ * (`code`, `id`, `key`) that's reused across genuinely distinct resources
120
+ * (ARV-369) — e.g. `/api/templates/v30/{code}` → "templates" vs
121
+ * `/api/macros/v30/{code}` → "macros". */
122
+ // ARV-376: read-by-id accessor markers — kept in sync with the set in
123
+ // path-param-disambig.ts. `/business-segment20/byid/{id}` is owned by
124
+ // `business-segment20`, not the `byid` accessor verb.
125
+ const ACCESSOR_MARKER_SEGS = new Set(["byid", "by-id", "by_id"]);
126
+
127
+ export function owningCollectionForPathParam(path: string, paramName: string): string | null {
128
+ const marker = `{${paramName}}`;
129
+ const idx = path.indexOf(marker);
130
+ if (idx === -1) return null;
131
+ const segments = stripTrailingVersionSegments(path.slice(0, idx).split("/").filter(Boolean));
132
+ // Skip trailing accessor markers so the owner is the collection, not the
133
+ // `byid`/`by-code` verb — otherwise a param behind `/byid/{id}` and its
134
+ // sibling `DELETE /{id}` disagree on owner and get double-scoped.
135
+ let end = segments.length - 1;
136
+ while (end >= 0 && ACCESSOR_MARKER_SEGS.has(segments[end]!.toLowerCase())) end--;
137
+ return end >= 0 ? segments[end]! : null;
138
+ }
139
+
140
+ /** Path-param names reused by more than one distinct owning collection
141
+ * across the whole spec — these need a resource-scoped fixture var name
142
+ * (`templates_code`) instead of the raw name (`code`), otherwise one
143
+ * `.env.yaml` value silently applies to unrelated resources (ARV-369). */
144
+ export function computeAmbiguousPathParams(endpoints: EndpointInfo[]): Set<string> {
145
+ const owners = new Map<string, Set<string>>();
146
+ for (const ep of endpoints) {
147
+ for (const p of ep.parameters) {
148
+ if (p.in !== "path" || p.required === false) continue;
149
+ const owner = owningCollectionForPathParam(ep.path, p.name) ?? "";
150
+ if (!owners.has(p.name)) owners.set(p.name, new Set());
151
+ owners.get(p.name)!.add(owner);
152
+ }
153
+ }
154
+ const ambiguous = new Set<string>();
155
+ for (const [name, ownerSet] of owners) {
156
+ if (ownerSet.size > 1) ambiguous.add(name);
157
+ }
158
+ return ambiguous;
159
+ }
160
+
161
+ /** Resource-scoped fixture var name for a path-param, when ambiguous;
162
+ * otherwise the raw param name (single-owner APIs — the common case —
163
+ * keep producing the same var names as before ARV-369). */
164
+ export function fixtureVarNameForPathParam(
165
+ path: string,
166
+ paramName: string,
167
+ ambiguous: Set<string>,
168
+ ): string {
169
+ if (!ambiguous.has(paramName)) return paramName;
170
+ const owner = owningCollectionForPathParam(path, paramName);
171
+ if (!owner) return paramName;
172
+ return `${owner.replace(/[^a-zA-Z0-9]+/g, "_")}_${paramName}`;
173
+ }
174
+
99
175
  function escapeRegex(s: string): string {
100
176
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
101
177
  }
@@ -242,6 +318,13 @@ function getSuiteHeaders(
242
318
  // stays stateless from the caller's perspective.
243
319
  let _suiteDefaultAuthVar: string | null = null;
244
320
 
321
+ /** Same module-scoping rationale as `_suiteDefaultAuthVar` above: computed
322
+ * once from the full endpoint list at the top of generateSuites (ARV-369
323
+ * needs the whole spec to detect a param-name collision), consulted by
324
+ * `generateStep`'s path conversion. Reset to empty at the end of
325
+ * generateSuites. */
326
+ let _ambiguousPathParams: Set<string> = new Set();
327
+
245
328
  /** Common id-like field names looked up after `id` itself.
246
329
  * TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
247
330
  * or `name` instead of an `id` field on create responses. Without these,
@@ -348,6 +431,42 @@ export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | und
348
431
  // Public API
349
432
  // ──────────────────────────────────────────────
350
433
 
434
+ /**
435
+ * ARV-45: swap FK-shaped required body fields for `{{fixture}}` references so
436
+ * their values come from `.env.yaml` instead of `{{$randomString}}`/`{{$uuid}}`
437
+ * junk that 400s and kills the CRUD chain at step 1. Field names that motivated
438
+ * this — `sequenceTypeCode`, `templateGroupCode` — are closed-vocab codes the
439
+ * generator can't guess. The var name is canonicalised (`sequence_type_code`)
440
+ * to match the manifest entry (fixtures-builder step 5); the HTTP body key
441
+ * keeps the raw spec spelling. Recurses into nested objects for nested FKs.
442
+ *
443
+ * Keep the detection predicate (`isFkFixtureField`) in lockstep with the
444
+ * manifest builder — every var the tests reference must appear in the manifest
445
+ * (decision-7).
446
+ */
447
+ export function wireBodyFkRefs(schema: OpenAPIV3.SchemaObject, body: unknown): unknown {
448
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
449
+ // `effectiveObjectShape` merges allOf so allOf-wrapped bodies (the .NET/
450
+ // Swagger norm) resolve their properties — a direct schema.properties read
451
+ // misses every FK on those specs.
452
+ const { properties, required } = effectiveObjectShape(schema);
453
+ const out = body as Record<string, unknown>;
454
+ for (const [name, propSchema] of Object.entries(properties)) {
455
+ if (!(name in out)) continue;
456
+ if (required.has(name) && isFkFixtureField(name, propSchema)) {
457
+ out[name] = `{{${canonicalVarName(name)}}}`;
458
+ } else if (out[name] && typeof out[name] === "object" && !Array.isArray(out[name])) {
459
+ out[name] = wireBodyFkRefs(propSchema, out[name]);
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ /** Generate a request body from a schema with FK fields wired to fixtures. */
466
+ function generateBody(schema: OpenAPIV3.SchemaObject): unknown {
467
+ return wireBodyFkRefs(schema, generateFromSchema(schema));
468
+ }
469
+
351
470
  /** Generate a single test step from an EndpointInfo */
352
471
  export function generateStep(
353
472
  ep: EndpointInfo,
@@ -355,7 +474,7 @@ export function generateStep(
355
474
  ): RawStep {
356
475
  const method = ep.method.toUpperCase();
357
476
  const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
358
- const path = convertPath(ep.path);
477
+ const path = convertPath(ep.path, _ambiguousPathParams);
359
478
 
360
479
  const step: RawStep = {
361
480
  name,
@@ -379,9 +498,9 @@ export function generateStep(
379
498
  // the runner posts URL-encoded bodies with bracket notation. Without
380
499
  // this, generate baked `json:` blocks and every POST 400'd with
381
500
  // "wrong content type".
382
- step.form = flattenToFormFields(generateFromSchema(ep.requestBodySchema));
501
+ step.form = flattenToFormFields(generateBody(ep.requestBodySchema));
383
502
  } else {
384
- step.json = generateFromSchema(ep.requestBodySchema);
503
+ step.json = generateBody(ep.requestBodySchema);
385
504
  }
386
505
  }
387
506
 
@@ -450,7 +569,13 @@ export function detectCrudGroupsWithDiagnostics(
450
569
 
451
570
  for (const createEp of postEndpoints) {
452
571
  const basePath = stripTrailingSlash(createEp.path);
453
- const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
572
+ // ARV-372: skip trailing version segments (`v1`, `v30`) before taking the
573
+ // last segment as the resource name — otherwise every collection on a
574
+ // spec shaped `/api/<resource>/v{N}` (a common convention) resolves to
575
+ // the SAME resource name ("v30"), and multiple CRUD suites collide on
576
+ // the same `crud-<resource>.yaml` output filename, silently overwriting
577
+ // each other.
578
+ const resource = stripTrailingVersionSegments(basePath.split("/").filter(Boolean)).pop() ?? "resource";
454
579
 
455
580
  // Match `<basePath>/{param}` with optional trailing slash. Tolerates
456
581
  // both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
@@ -500,11 +625,15 @@ export function detectCrudGroupsWithDiagnostics(
500
625
  ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
501
626
  );
502
627
  const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
503
- // List endpoint matches with the same trailing-slash tolerance.
628
+ // List endpoint matches with the same trailing-slash tolerance. ARV-376:
629
+ // also accept a `/list`, `/search`, `/find` verb suffix on the base path
630
+ // (`GET /api/deal-kind20/list`) — common in RPC-flavoured REST specs
631
+ // where the bare collection path has no GET.
632
+ const listStems = [basePath, `${basePath}/list`, `${basePath}/search`, `${basePath}/find`];
504
633
  const list = endpoints.find(
505
634
  ep =>
506
635
  ep.method.toUpperCase() === "GET" &&
507
- stripTrailingSlash(ep.path) === basePath &&
636
+ listStems.includes(stripTrailingSlash(ep.path)) &&
508
637
  !ep.deprecated,
509
638
  );
510
639
 
@@ -573,11 +702,31 @@ export function detectCrudGroupsWithDiagnostics(
573
702
  }
574
703
 
575
704
  /** Generate a CRUD chain suite from a CrudGroup */
705
+ /** ARV-368: does the create's success response actually carry the capture
706
+ * field? If not — a 204 no-body create, or a response schema without the id —
707
+ * the runtime capture is empty and `{{captureVar}}` falls back to the
708
+ * read-fixture of the SAME name (ARV-137 deliberately shares it). A PUT/DELETE
709
+ * then targets a *pre-existing* resource whose id the user harvested for read
710
+ * coverage — silent data-loss. Gate mutating chain steps on this so the suite
711
+ * can only ever update/delete what it actually captured, never live data. */
712
+ function createCapturesId(
713
+ create: EndpointInfo | undefined,
714
+ captureField: string,
715
+ ): boolean {
716
+ if (!create) return false;
717
+ const props = getSuccessSchema(create)?.properties;
718
+ return !!props && captureField in props;
719
+ }
720
+
576
721
  export function generateCrudSuite(
577
722
  group: CrudGroup,
578
723
  securitySchemes: SecuritySchemeInfo[],
579
724
  ): RawSuite {
580
725
  const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
726
+ // ARV-368: only chain PUT/DELETE when the create response yields the id we'd
727
+ // capture. Otherwise the capture is empty at runtime and the mutating step
728
+ // falls back to the shared read-fixture → deletes/overwrites pre-existing data.
729
+ const canChainMutations = createCapturesId(group.create, captureField);
581
730
  // ARV-137: use the spec's path-param name as the capture var. Previously
582
731
  // we synthesised `<resource>_id` via `resourceVar(...)`, which produced
583
732
  // phantom manifest dupes whenever the spec named the path-param anything
@@ -622,8 +771,9 @@ export function generateCrudSuite(
622
771
  tests.push(step);
623
772
  }
624
773
 
625
- // 3. Update
626
- if (group.update) {
774
+ // 3. Update (ARV-368: only if the chain self-captures its id — else the
775
+ // fixture-fallback would overwrite a pre-existing resource)
776
+ if (group.update && canChainMutations) {
627
777
  const method = group.update.method.toUpperCase();
628
778
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
629
779
  const etagVar = resourceVar(group.resource, "etag");
@@ -653,13 +803,14 @@ export function generateCrudSuite(
653
803
  step.headers = { "If-Match": `"{{${etagVar}}}"` };
654
804
  }
655
805
  if (group.update.requestBodySchema) {
656
- step.json = generateFromSchema(group.update.requestBodySchema);
806
+ step.json = generateBody(group.update.requestBodySchema);
657
807
  }
658
808
  tests.push(step);
659
809
  }
660
810
 
661
- // 4. Delete
662
- if (group.delete) {
811
+ // 4. Delete (ARV-368: only if the chain self-captures its id — else the
812
+ // fixture-fallback would DELETE a pre-existing resource → data-loss)
813
+ if (group.delete && canChainMutations) {
663
814
  const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
664
815
  const etagVar = resourceVar(group.resource, "etag");
665
816
 
@@ -938,6 +1089,7 @@ export function generateSuites(opts: {
938
1089
  }): RawSuite[] {
939
1090
  const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
940
1091
  _suiteDefaultAuthVar = defaultAuthVar ?? null;
1092
+ _ambiguousPathParams = computeAmbiguousPathParams(endpoints);
941
1093
 
942
1094
  // Filter deprecated unless caller opted in. The list of skipped paths is
943
1095
  // exposed separately via `getSkippedDeprecated` for stdout reporting.
@@ -1144,5 +1296,6 @@ export function generateSuites(opts: {
1144
1296
  }
1145
1297
 
1146
1298
  _suiteDefaultAuthVar = null; // ARV-212
1299
+ _ambiguousPathParams = new Set(); // ARV-369
1147
1300
  return allSuites;
1148
1301
  }
@@ -28,6 +28,12 @@ export interface EndpointInfo {
28
28
  security: string[];
29
29
  deprecated?: boolean;
30
30
  requiresEtag?: boolean;
31
+ /** ARV-189 (m-21): vendor extensions starting with `x-` from the
32
+ * operation (and merged from the path item — operation wins on key
33
+ * collision). Used by the `x-zond-*` opt-in/skip rules so callers can
34
+ * declare check-level policy directly in the spec without an overlay
35
+ * yaml file. Empty/undefined when the spec carries no extensions. */
36
+ extensions?: Record<string, unknown>;
31
37
  }
32
38
 
33
39
  export interface SecuritySchemeInfo {
Binary file
@@ -1,19 +1,15 @@
1
1
  # core/output — typed `--report` / `--output` / `--json` policy
2
2
 
3
3
  `OutputSpec<Payload>` is the single source-of-truth for how a command
4
- produces output. Per-command parsers (`checks run`, `probe security`,
5
- `run`, …) are migrated in ARV-117/118/119 — this directory only ships
6
- the infrastructure.
7
-
8
- Closes the seven divergent-output bugs collected in
4
+ produces output. Closes the seven divergent-output bugs collected in
9
5
  `strategy/lessons.md` §E (ARV-50, ARV-82, ARV-97, …) by replacing N
10
6
  ad-hoc parsers with one resolver.
11
7
 
12
8
  ## Policy matrix
13
9
 
14
- The runner (`runCommandWithOutput`) reads `--report`, `--output`, and
15
- `--json` from the CLI layer and resolves them to a `ResolvedOutput`
16
- decision according to this matrix:
10
+ `resolveOutput(spec, opts)` reads `--report`, `--output`, and `--json`
11
+ from the CLI layer and resolves them to a `ResolvedOutput` decision
12
+ according to this matrix:
17
13
 
18
14
  | Input | Format | Channel | Notes |
19
15
  | ------------------------------------ | -------------- | ------------------- | ----- |
@@ -50,30 +46,16 @@ export const CHECKS_RUN_OUTPUT: OutputSpec<ChecksPayload> = {
50
46
  // `--report ndjson` is a friendly alias retained from skill prompts.
51
47
  ndjson: "ndjson",
52
48
  },
53
- render: (format, payload) => {
54
- if (format === "sarif") return generateSarifReport(payload);
55
- if (format === "ndjson") return payload.findings.map(f => JSON.stringify(f)).join("\n");
56
- return JSON.stringify(payload, null, 2);
57
- },
58
49
  };
59
50
  ```
60
51
 
61
- The CLI handler then does:
62
-
63
- ```ts
64
- const { resolved, exitCode } = await runCommandWithOutput(
65
- CHECKS_RUN_OUTPUT,
66
- cmd.opts<OutputOptions>(),
67
- async () => runChecks({ /* ... */ }),
68
- );
69
- process.exit(exitCode);
70
- ```
71
-
72
- `resolveOutput()` can be called standalone (without rendering) when a
73
- command wants to plug the resolution into its own streaming pipeline —
74
- e.g. `checks run` opens an fd ahead of time and feeds events into it
75
- incrementally; in that case the command consumes `resolved.path` and
76
- `resolved.channel` and handles I/O itself.
52
+ The CLI handler calls `resolveOutput()` and renders/writes the payload
53
+ itself — each command's shape (streaming vs single-shot, envelope vs
54
+ raw) differs enough that a shared render/write runner added indirection
55
+ without removing per-command logic. `checks run` and `probe *` open an
56
+ fd from `resolved.path`/`resolved.channel` and feed output into it
57
+ incrementally; `run` renders a single payload and writes it once. See
58
+ any of `src/cli/commands/{run,checks,probe}.ts` for the pattern.
77
59
 
78
60
  ## Why the format set is open
79
61
 
@@ -10,4 +10,4 @@ export type {
10
10
  ResolvedOutput,
11
11
  } from "./types.ts";
12
12
  export { OutputSpecError } from "./types.ts";
13
- export { resolveOutput, runCommandWithOutput, type RunOutputResult } from "./run.ts";
13
+ export { resolveOutput } from "./run.ts";
@@ -89,38 +89,3 @@ function pickEnvelopeFormat<P>(spec: OutputSpec<P>): string | undefined {
89
89
  return undefined;
90
90
  }
91
91
 
92
- export interface RunOutputResult<P> {
93
- resolved: ResolvedOutput;
94
- payload: P;
95
- exitCode: number;
96
- }
97
-
98
- /** Render and write the payload according to the resolved decision.
99
- * Returns the resolved decision plus the exit code from the spec's
100
- * `exitCodePolicy` (0 by default). The CLI handler typically passes
101
- * the exit code straight to `process.exit`. */
102
- export async function runCommandWithOutput<P>(
103
- spec: OutputSpec<P>,
104
- opts: OutputOptions,
105
- produce: () => Promise<P>,
106
- ): Promise<RunOutputResult<P>> {
107
- const resolved = resolveOutput(spec, opts);
108
- const payload = await produce();
109
-
110
- if (!spec.render) {
111
- throw new OutputSpecError(
112
- `OutputSpec for "${spec.command}" has no render() hook — cannot serialise payload`,
113
- );
114
- }
115
- const body = spec.render(resolved.format, payload);
116
-
117
- if (resolved.channel === "file") {
118
- await Bun.write(resolved.path!, body);
119
- } else {
120
- process.stdout.write(body);
121
- if (!body.endsWith("\n")) process.stdout.write("\n");
122
- }
123
-
124
- const exitCode = spec.exitCodePolicy ? spec.exitCodePolicy(payload) : 0;
125
- return { resolved, payload, exitCode };
126
- }
@@ -83,13 +83,6 @@ export interface OutputSpec<Payload = unknown> {
83
83
  * skill prompts ship it). Keys are flag values seen on the CLI;
84
84
  * values are the resolved format name. */
85
85
  aliases?: Record<string, OutputFormat>;
86
- /** Optional pre-validated render hook. Called by the runner once
87
- * the format is resolved. Receives the payload plus the resolved
88
- * format and returns the serialized output. */
89
- render?: (format: OutputFormat, payload: Payload) => string;
90
- /** Optional exit-code policy. Receives the payload after a
91
- * successful run; returns the process exit code (0 by default). */
92
- exitCodePolicy?: (payload: Payload) => number;
93
86
  }
94
87
 
95
88
  /** Decision the runner makes after applying the spec to CLI flags. */
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `#(funcName)` / `#(funcName(args))` dynamic-value substitution for
3
+ * loaded YAML values (ARV-190, m-21).
4
+ *
5
+ * Lets a workspace avoid stale hardcoded data — UUIDs that get reused
6
+ * by the API and pin existing rows, dates that expire days after
7
+ * checkout, idempotency keys that race against past runs. Functions
8
+ * are evaluated at LOAD time (per-run, not per-request), so a single
9
+ * `#(uuid)` reference resolves to the same value across every step
10
+ * inside one run — the idempotency-replay scenarios only work if the
11
+ * key stays stable for the run's lifetime.
12
+ *
13
+ * Supported functions:
14
+ * #(uuid) — fresh UUID v4 (stable within one run via cache)
15
+ * #(uuidStable(seed)) — deterministic UUID derived from seed (sha-256 → v4 shape)
16
+ * #(today) — YYYY-MM-DD now (UTC)
17
+ * #(todayPlus(N)) — today + N days (N may be negative)
18
+ * #(now) — ISO 8601 timestamp
19
+ * #(unix) — seconds since epoch
20
+ * #(alphanumeric(N)) — N random a-z0-9 chars
21
+ * #(env:VAR) — process.env.VAR (alias for ${VAR})
22
+ *
23
+ * Resolution order: env-interpolation (${VAR}) runs first because its
24
+ * default-value syntax can produce strings the dynamic resolver should
25
+ * see literally. Dynamic values run BEFORE @secret/@identity reference
26
+ * resolution so the function tokens never leak into a secret-typed
27
+ * value (and so secret-stored values that happen to look like `#(...)`
28
+ * stay opaque). Strings that have no `#(` substring short-circuit.
29
+ *
30
+ * Nested forms: a single value may carry multiple references mixed
31
+ * with literal text — `"req-#(uuid)-#(today)"` yields `req-<uuid>-2026-05-16`.
32
+ */
33
+
34
+ import { createHash } from "node:crypto";
35
+
36
+ export interface DynamicValueContext {
37
+ /** Per-run cache keyed by raw expression (`#(uuid)`, `#(today)`). */
38
+ cache: Map<string, string>;
39
+ /** Source of env vars; defaults to process.env. Override in tests. */
40
+ env?: Record<string, string | undefined>;
41
+ /** File path the value came from — surfaced in error messages. */
42
+ filePath?: string;
43
+ }
44
+
45
+ /** Build a fresh cache for one resolution pass. Exported so the caller
46
+ * can share a cache across multiple `resolveDynamicValuesDeep` calls
47
+ * that belong to the same run (e.g. workspace + per-API env files). */
48
+ export function newDynamicCache(): Map<string, string> {
49
+ return new Map();
50
+ }
51
+
52
+ // Captures `#(funcName)` or `#(funcName(args))`. `args` may contain any
53
+ // chars except an unescaped closing paren — keep it simple, the funcs we
54
+ // support take string or integer args without nested parens.
55
+ const FUNC_RE = /#\(([A-Za-z_][A-Za-z0-9_]*)(?::([^)]*))?(?:\(([^)]*)\))?\)/g;
56
+
57
+ export function resolveDynamicValues(
58
+ text: string,
59
+ ctx: DynamicValueContext,
60
+ ): string {
61
+ if (typeof text !== "string" || text.indexOf("#(") === -1) return text;
62
+ return text.replace(FUNC_RE, (full, name: string, colonArg: string | undefined, parenArg: string | undefined) => {
63
+ const cached = ctx.cache.get(full);
64
+ if (cached !== undefined) return cached;
65
+ const value = evaluate(name, colonArg ?? parenArg, full, ctx);
66
+ ctx.cache.set(full, value);
67
+ return value;
68
+ });
69
+ }
70
+
71
+ export function resolveDynamicValuesDeep(
72
+ obj: Record<string, unknown>,
73
+ ctx: DynamicValueContext,
74
+ ): Record<string, string> {
75
+ const out: Record<string, string> = {};
76
+ for (const [k, v] of Object.entries(obj)) {
77
+ if (typeof v === "string") {
78
+ out[k] = resolveDynamicValues(v, ctx);
79
+ } else {
80
+ out[k] = String(v);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function evaluate(
87
+ name: string,
88
+ arg: string | undefined,
89
+ fullExpr: string,
90
+ ctx: DynamicValueContext,
91
+ ): string {
92
+ switch (name) {
93
+ case "uuid":
94
+ return crypto.randomUUID();
95
+ case "uuidStable": {
96
+ if (!arg) throw makeError(`#(uuidStable(seed)) requires a seed argument`, fullExpr, ctx);
97
+ return seedToUuid(arg);
98
+ }
99
+ case "today":
100
+ return new Date().toISOString().slice(0, 10);
101
+ case "todayPlus": {
102
+ if (!arg) throw makeError(`#(todayPlus(N)) requires an integer N`, fullExpr, ctx);
103
+ const n = Number.parseInt(arg, 10);
104
+ if (!Number.isFinite(n)) throw makeError(`#(todayPlus(${arg})) — N must be an integer`, fullExpr, ctx);
105
+ const d = new Date();
106
+ d.setUTCDate(d.getUTCDate() + n);
107
+ return d.toISOString().slice(0, 10);
108
+ }
109
+ case "now":
110
+ return new Date().toISOString();
111
+ case "unix":
112
+ return String(Math.floor(Date.now() / 1000));
113
+ case "alphanumeric": {
114
+ const n = arg ? Number.parseInt(arg, 10) : 8;
115
+ if (!Number.isFinite(n) || n <= 0 || n > 1024) {
116
+ throw makeError(`#(alphanumeric(${arg ?? ""})) — length must be 1..1024`, fullExpr, ctx);
117
+ }
118
+ return randomAlphanumeric(n);
119
+ }
120
+ case "env": {
121
+ if (!arg) throw makeError(`#(env:VAR) requires a variable name`, fullExpr, ctx);
122
+ const env = ctx.env ?? (process.env as Record<string, string | undefined>);
123
+ const v = env[arg];
124
+ if (v === undefined || v === "") {
125
+ throw makeError(`#(env:${arg}) is not set — define it in your shell or CI secret`, fullExpr, ctx);
126
+ }
127
+ return v;
128
+ }
129
+ default:
130
+ throw makeError(`unknown dynamic function "${name}" in ${fullExpr} — supported: uuid, uuidStable, today, todayPlus, now, unix, alphanumeric, env`, fullExpr, ctx);
131
+ }
132
+ }
133
+
134
+ function seedToUuid(seed: string): string {
135
+ // SHA-256 the seed, take first 16 bytes, format as UUID v4 shape so
136
+ // any consumer-side `format: uuid` validator accepts it. Variant +
137
+ // version bits are forced to match v4 — the value is still
138
+ // deterministic because the source bytes are stable per seed.
139
+ const hash = createHash("sha256").update(seed).digest();
140
+ const bytes = Buffer.from(hash.subarray(0, 16));
141
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4
142
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80; // RFC 4122 variant
143
+ const hex = bytes.toString("hex");
144
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
145
+ }
146
+
147
+ const ALPHANUM = "abcdefghijklmnopqrstuvwxyz0123456789";
148
+
149
+ function randomAlphanumeric(n: number): string {
150
+ let out = "";
151
+ for (let i = 0; i < n; i++) {
152
+ out += ALPHANUM[Math.floor(Math.random() * ALPHANUM.length)];
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function makeError(msg: string, expr: string, ctx: DynamicValueContext): Error {
158
+ const where = ctx.filePath ? ` (referenced from ${ctx.filePath})` : "";
159
+ return new Error(`Dynamic value ${expr}: ${msg}${where}`);
160
+ }
Binary file
@@ -53,5 +53,9 @@ export function formatDryRunDigest(plans: EndpointPlan[]): string {
53
53
  const summary = summarizeDryRun(plans).summary;
54
54
  lines.push("");
55
55
  lines.push(`Plan: ${summary.planned} planned · ${summary.skipped} skipped · ${summary.totalEndpoints} total`);
56
+ // ARV-309: the plan above lists what *would* be attacked — no traffic was
57
+ // sent. Without this line a reader can't tell "ran, found nothing" from
58
+ // "never fired" (the plan reads like a findings list). State it explicitly.
59
+ lines.push("Dry-run: mutation probes NOT executed — no requests sent. Re-run without --dry-run to attack live.");
56
60
  return lines.join("\n");
57
61
  }