@kirrosh/zond 0.23.0 → 0.26.1

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 +176 -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 +53 -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
@@ -69,6 +69,18 @@ export interface DumpOptions {
69
69
  only?: string[];
70
70
  json?: boolean;
71
71
  dbPath?: string;
72
+ /** ARV-277: enrich seed-bodies bundles with the most recent
73
+ * fixture-kind POST attempt to the create endpoint, so the calling
74
+ * agent sees exactly what zond tried last + how the server replied
75
+ * (avoids the "agent re-discovers each 400 by hand" loop). */
76
+ withLastAttempt?: boolean;
77
+ /** ARV-278: when `withLastAttempt` is true, return the most recent N
78
+ * attempts (most recent first) instead of just the last one. Surfaces
79
+ * the *progression* of errors as the overlay was iterated — first 400
80
+ * was "missing X", after fixing the body the next 400 is "stale FK"
81
+ * pulls cascade-staleness (ARV-282) onto the agent's screen one
82
+ * iteration earlier. `1` keeps the old single-snapshot behaviour. */
83
+ historyLimit?: number;
72
84
  }
73
85
 
74
86
  export async function dumpCommand(opts: DumpOptions): Promise<number> {
@@ -87,13 +99,67 @@ export async function dumpCommand(opts: DumpOptions): Promise<number> {
87
99
  }
88
100
 
89
101
  let resources = map.resources;
102
+ let unknownOnly: string[] = [];
90
103
  if (opts.only && opts.only.length > 0) {
91
104
  const wanted = new Set(opts.only);
105
+ const knownNames = new Set(map.resources.map((r) => r.resource));
106
+ unknownOnly = opts.only.filter((n) => !knownNames.has(n));
92
107
  resources = resources.filter((r) => wanted.has(r.resource));
93
108
  }
94
109
  const slices = buildResourceSlices(doc, resources);
95
110
  const bundles = buildDumpBundles(opts.kind, slices, doc, map.resources);
96
111
 
112
+ // ARV-277: enrich seed-bodies bundles with the last fixture-kind POST
113
+ // attempt (from `runs/results` DB). The agent reads `last_attempt`
114
+ // alongside the spec slice instead of running `zond request POST …`
115
+ // by hand to reproduce the failure mode.
116
+ if (opts.withLastAttempt && opts.kind === "seed-bodies") {
117
+ try {
118
+ // ARV-330: use the wider all-run-kinds query (a create-path POST made
119
+ // during a check/probe run counts too, not just fixture runs) and pass
120
+ // the child-exclude pattern so probe sub-resource POSTs
121
+ // (`/v1/accounts/{id}/reject`) don't monopolise the history window.
122
+ const { getRecentCreatePosts } = await import("../../../../db/queries/results.ts");
123
+ const limit = Math.max(1, Math.floor(opts.historyLimit ?? 1));
124
+ for (const b of bundles) {
125
+ const data = b.data as Record<string, unknown> | null;
126
+ const endpoints = data?.endpoints as Record<string, EndpointDump | undefined> | undefined;
127
+ const create = endpoints?.create;
128
+ if (!create) continue;
129
+ const pattern = createUrlLikePattern(create.path);
130
+ const recent = getRecentCreatePosts(pattern, limit, childExcludePattern(create.path));
131
+ if (recent.length === 0) continue;
132
+ // ARV-278: keep `last_attempt` as the most-recent entry (back-compat
133
+ // with single-snapshot consumers) and add `attempt_history` when
134
+ // the caller asked for >1.
135
+ (b.data as Record<string, unknown>).last_attempt = recent[0];
136
+ if (limit > 1) (b.data as Record<string, unknown>).attempt_history = recent;
137
+ }
138
+ } catch (err) {
139
+ // DB lookup is best-effort — degraded dump (no `last_attempt`) is
140
+ // still useful, so we warn but don't fail the command.
141
+ process.stderr.write(`zond: --with-last-attempt: DB lookup failed (${(err as Error).message}); dump emitted without last_attempt.\n`);
142
+ }
143
+ }
144
+
145
+ // ARV-226: when `--only` filtered to resources that have no applicable
146
+ // surface for this kind (no list endpoint for pagination/readback, no
147
+ // write surface for idempotency/seed-bodies, etc.), the dump silently
148
+ // returns []. Distinguish "no surface" from "unknown resource" so the
149
+ // user can tell whether they mistyped or hit a category-not-applicable.
150
+ if (!opts.json && opts.only && opts.only.length > 0 && opts.kind !== "resources") {
151
+ const emitted = new Set(bundles.map((b) => b.resource));
152
+ const filteredButNoSurface = opts.only.filter(
153
+ (n) => !emitted.has(n) && !unknownOnly.includes(n),
154
+ );
155
+ for (const n of unknownOnly) {
156
+ process.stderr.write(`zond: --only: resource '${n}' not in .api-resources.yaml (refresh-api or check spelling).\n`);
157
+ }
158
+ for (const n of filteredButNoSurface) {
159
+ process.stderr.write(`zond: --only: resource '${n}' has no applicable surface for --${opts.kind} (skipped).\n`);
160
+ }
161
+ }
162
+
97
163
  if (opts.json) {
98
164
  printJson(jsonOk("api annotate dump", { kind: opts.kind, bundles }));
99
165
  } else {
@@ -102,6 +168,33 @@ export async function dumpCommand(opts: DumpOptions): Promise<number> {
102
168
  return 0;
103
169
  }
104
170
 
171
+ /**
172
+ * ARV-277: convert a create-endpoint path (`/v1/customers/{customer}/sources`)
173
+ * to a SQL LIKE pattern matched against full request_url
174
+ * (`https://api.stripe.com/v1/customers/cus_…/sources?...`). Path-params
175
+ * collapse to `%`; the prefix and suffix wildcards cover the scheme/host
176
+ * and optional query string. Anchoring is intentionally loose because
177
+ * recorders write absolute URLs and we want to find the seed POST
178
+ * regardless of base_url shape.
179
+ */
180
+ export function createUrlLikePattern(createPath: string): string {
181
+ const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
182
+ // SQL LIKE `%` matches across slashes (no special escape needed for
183
+ // the path segments since we don't expect %/_ in spec paths). Leading
184
+ // wildcard absorbs `https://api.stripe.com`, trailing absorbs `?expand=…`.
185
+ return `%${collapsed}%`;
186
+ }
187
+
188
+ /**
189
+ * ARV-330: companion NOT-LIKE pattern that excludes child sub-resource
190
+ * POSTs (`/v1/accounts/{id}/reject`, `.../persons`) from a create-path
191
+ * match. Pairs with `createUrlLikePattern` in `getRecentCreatePosts`.
192
+ */
193
+ export function childExcludePattern(createPath: string): string {
194
+ const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
195
+ return `%${collapsed}/%`;
196
+ }
197
+
105
198
  function buildDumpBundles(
106
199
  kind: SubcommandKind,
107
200
  slices: ResourceSlice[],
@@ -144,10 +237,20 @@ function buildDumpBundles(
144
237
  switch (kind) {
145
238
  case "seed-bodies":
146
239
  case "idempotency":
147
- case "pagination":
148
240
  case "readback":
149
241
  data = sliceData(slice);
150
242
  break;
243
+ case "pagination": {
244
+ // ARV-235: when the list endpoint already declares standard
245
+ // page-style params, agent doesn't need to re-read the full
246
+ // params schema to produce the annotation — surface a precomputed
247
+ // hint so the response can be `{ type: page, page_param, limit_param }`
248
+ // without inspecting the spec slice further.
249
+ const hint = detectPaginationHint(slice);
250
+ const base = sliceData(slice) as Record<string, unknown>;
251
+ data = hint ? { ...base, pagination_hint: hint } : base;
252
+ break;
253
+ }
151
254
  case "lifecycle":
152
255
  data = {
153
256
  ...(sliceData(slice) as Record<string, unknown>),
@@ -175,6 +278,46 @@ function sliceData(slice: ResourceSlice): unknown {
175
278
  };
176
279
  }
177
280
 
281
+ /**
282
+ * ARV-235: detect well-known page-style pagination params on the list
283
+ * endpoint so the dump's response shape becomes a one-liner for the
284
+ * agent. Returns null if the params don't match a known shape — the
285
+ * agent then walks the full slice as before.
286
+ */
287
+ function detectPaginationHint(slice: ResourceSlice): null | {
288
+ detected_style: "page" | "offset";
289
+ page_param?: string;
290
+ limit_param?: string;
291
+ offset_param?: string;
292
+ note: string;
293
+ } {
294
+ const list = slice.endpoints.list;
295
+ if (!list || !list.parameters) return null;
296
+ const queryNames = new Set(
297
+ list.parameters.filter((p) => p.in === "query").map((p) => p.name.toLowerCase()),
298
+ );
299
+ const pageParam = ["page"].find((n) => queryNames.has(n));
300
+ const limitParam = ["per_page", "page_size", "pagesize", "limit"].find((n) => queryNames.has(n));
301
+ const offsetParam = ["offset", "skip", "start"].find((n) => queryNames.has(n));
302
+ if (pageParam) {
303
+ return {
304
+ detected_style: "page",
305
+ page_param: pageParam,
306
+ limit_param: limitParam,
307
+ note: "list endpoint declares page-style params; respond with { type: 'page', page_param, limit_param } to skip rereading the full spec slice.",
308
+ };
309
+ }
310
+ if (offsetParam) {
311
+ return {
312
+ detected_style: "offset",
313
+ offset_param: offsetParam,
314
+ limit_param: limitParam,
315
+ note: "list endpoint declares offset-style params; pagination check short-circuits offset/token (per m-20). Skip annotation for this resource or return { pagination: null }.",
316
+ };
317
+ }
318
+ return null;
319
+ }
320
+
178
321
  function isSliceApplicable(kind: SubcommandKind, slice: ResourceSlice): boolean {
179
322
  switch (kind) {
180
323
  case "seed-bodies": return seedBodies.isApplicable(slice);
@@ -226,6 +369,13 @@ export interface ApplyOptions {
226
369
  force?: boolean;
227
370
  json?: boolean;
228
371
  dbPath?: string;
372
+ /** ARV-281: agent-loop regression protection. When set, apply drops
373
+ * any aspect-level field on a proposed patch when the existing
374
+ * overlay already has that field set (regardless of value). The
375
+ * agent's response can only ADD missing pieces — it can't overwrite
376
+ * a previously curated `seed_body` even by accident. `--force`
377
+ * ignores this flag (explicit override stays explicit). */
378
+ gapFillOnly?: boolean;
229
379
  }
230
380
 
231
381
  export async function applyCommand(opts: ApplyOptions): Promise<number> {
@@ -281,7 +431,19 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
281
431
 
282
432
  const overlay = await readLocalOverlay(col.baseDir);
283
433
  const existing = (overlay.patches ?? []) as ResourcePatch[];
284
- const merge = mergePatches(existing, nonEmpty.map((d) => d.patch), { force: opts.force === true });
434
+
435
+ // ARV-281: gap-fill-only — drop aspect-level fields where the existing
436
+ // overlay already has a value set. Keeps the agent's response strictly
437
+ // additive vs. previous (heuristic + curated) edits. `--force` opts out
438
+ // of this guard, mirroring the existing conflict semantics.
439
+ const filteredPatches = opts.gapFillOnly && opts.force !== true
440
+ ? nonEmpty.map((d) => filterToGaps(d.patch, existing))
441
+ : nonEmpty.map((d) => d.patch);
442
+ const gapDroppedFields = opts.gapFillOnly && opts.force !== true
443
+ ? countGapDroppedFields(nonEmpty.map((d) => d.patch), filteredPatches)
444
+ : 0;
445
+
446
+ const merge = mergePatches(existing, filteredPatches, { force: opts.force === true });
285
447
 
286
448
  const summary = {
287
449
  api: opts.api,
@@ -292,6 +454,11 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
292
454
  failures: errors,
293
455
  changes: merge.changes.length,
294
456
  conflicts: merge.conflicts.length,
457
+ /** ARV-281: aspect-level fields the agent proposed that gap-fill-only
458
+ * silently dropped because the existing overlay already had them
459
+ * set. Distinct from `conflicts` which is mergePatches's concept of
460
+ * field-level disagreement. */
461
+ gap_fill_dropped: gapDroppedFields,
295
462
  };
296
463
 
297
464
  const diff = renderChangesDiff(merge);
@@ -300,6 +467,9 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
300
467
  printWarning(`Failed to parse ${errors.length} document(s):`);
301
468
  for (const e of errors) process.stdout.write(` ✗ ${e.resource}: ${e.error}\n`);
302
469
  }
470
+ if (gapDroppedFields > 0) {
471
+ printWarning(`--gap-fill-only dropped ${gapDroppedFields} aspect-field(s) the agent proposed for resources that already had them set. Re-run with --force to overwrite anyway.`);
472
+ }
303
473
  if (diff) {
304
474
  process.stdout.write("\nProposed changes (and conflicts):\n");
305
475
  process.stdout.write(diff + "\n\n");
@@ -331,10 +501,44 @@ export async function applyCommand(opts: ApplyOptions): Promise<number> {
331
501
  if (merge.conflicts.length > 0 && !opts.force && !opts.json) {
332
502
  printWarning(`${merge.conflicts.length} conflict(s) kept existing values. Re-run with --force to overwrite.`);
333
503
  }
504
+ // ARV-217: after a successful apply, surface the basePaths the agent
505
+ // should keep in `checks run --include` so a narrowed scope doesn't
506
+ // silently skip the freshly annotated resources. Without this hint, the
507
+ // next stateful run reports "no pagination config" for every endpoint
508
+ // outside the include filter and the apply looks like a no-op.
509
+ if (!opts.json && merge.changes.length > 0) {
510
+ const annotatedBasePaths = new Set<string>();
511
+ for (const patch of merge.patches) {
512
+ const slice = slicesByName.get(patch.resource);
513
+ if (slice?.basePath) annotatedBasePaths.add(slice.basePath);
514
+ }
515
+ if (annotatedBasePaths.size > 0) {
516
+ const checkName = opts.kind === "pagination"
517
+ ? "pagination_invariants"
518
+ : opts.kind === "lifecycle"
519
+ ? "lifecycle_transitions"
520
+ : opts.kind === "idempotency"
521
+ ? "idempotency_replay"
522
+ : opts.kind === "readback"
523
+ ? "cross_call_references"
524
+ : opts.kind === "seed-bodies"
525
+ ? "stateful"
526
+ : "stateful";
527
+ const includeAlt = [...annotatedBasePaths].map(escapeForPathRegex).join("|");
528
+ process.stdout.write(
529
+ `\nTip: to exercise these annotations, scope --include to cover the annotated basePath(s):\n` +
530
+ ` zond checks run --api ${opts.api} --check ${checkName} --include 'path:^(${includeAlt})(/.*)?$'\n`,
531
+ );
532
+ }
533
+ }
334
534
  if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: true }));
335
535
  return 0;
336
536
  }
337
537
 
538
+ function escapeForPathRegex(path: string): string {
539
+ return path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
540
+ }
541
+
338
542
  function parseByKind(kind: SubcommandKind, parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
339
543
  switch (kind) {
340
544
  case "seed-bodies": return seedBodies.parseSeedBodyResponse(parsed, slice);
@@ -391,6 +595,144 @@ async function applyResources(apiDir: string, documents: unknown[], opts: ApplyO
391
595
  return 0;
392
596
  }
393
597
 
598
+ /**
599
+ * ARV-280: error-message shapes Stripe/etc emit when an account-level
600
+ * capability is missing rather than an overlay-level data issue.
601
+ * Conservative — we only tag a resource as `account_capability_missing`
602
+ * when the most recent attempts ALL match, because Stripe's
603
+ * order-of-validation can surface a transient data error first and
604
+ * then a capability error after the overlay is fixed.
605
+ */
606
+ const HARD_BLOCKED_PATTERNS = [
607
+ /not\s+supported\s+for\s+country/i,
608
+ /not\s+available\s+in\s+your\s+account/i,
609
+ /capability/i,
610
+ /not\s+enabled/i,
611
+ /\bnot\s+onboarded\b/i,
612
+ /raw[- ]card[- ]data\s+(api|access)/i,
613
+ /you do not have access/i,
614
+ /signed up for connect/i,
615
+ ];
616
+
617
+ /**
618
+ * ARV-330: replies from security/auth probes that POST with deliberately
619
+ * broken credentials. Their 401/403s say nothing about whether the
620
+ * resource is capability-blocked, so the classifier drops them before
621
+ * judging.
622
+ */
623
+ const AUTH_NOISE_PATTERNS = [
624
+ /invalid api key/i,
625
+ /did not provide an api key/i,
626
+ /no api key provided/i,
627
+ /provided key '/i,
628
+ ];
629
+
630
+ /**
631
+ * ARV-330: verdict on a resource's create-path POST history. Pure so it
632
+ * can be unit-tested without a DB. Returns `account_capability_missing`
633
+ * when the account-level gate is the blocker, else null.
634
+ *
635
+ * Logic:
636
+ * 1. Drop auth-probe noise (broken-cred 401/403s) — not about the resource.
637
+ * 2. Any 2xx among valid attempts → the resource IS seedable, never blocked.
638
+ * 3. A capability/onboarding gate is account-level and deterministic: one
639
+ * hit on a valid-key attempt (with no success) is enough. We don't
640
+ * require ALL attempts to match, because a generic-generator body can
641
+ * trigger a data-shaped 400 on one attempt while a leaner body hits
642
+ * the gate on another (the ARV-330 chicken-and-egg case).
643
+ */
644
+ /**
645
+ * ARV-330: guard the loose `createUrlLikePattern` LIKE from counting
646
+ * child sub-resource POSTs. `%/v1/accounts%` also matches
647
+ * `/v1/accounts/{id}/reject` and `.../persons` — POSTs to *different*
648
+ * endpoints (often auth-probe noise) that would drown out the real
649
+ * create-path attempt. Match the recorded URL's path segment-for-segment
650
+ * against the create-path (`{param}` matches any single segment).
651
+ */
652
+ export function urlMatchesCreatePath(requestUrl: string, createPath: string): boolean {
653
+ let path: string;
654
+ try { path = new URL(requestUrl).pathname; } catch { return false; }
655
+ const want = createPath.split("/").filter(Boolean);
656
+ const got = path.split("/").filter(Boolean);
657
+ if (want.length !== got.length) return false;
658
+ return want.every((seg, i) => seg.startsWith("{") || seg === got[i]);
659
+ }
660
+
661
+ export function classifyAttempts(
662
+ attempts: Array<{ response_status: number | null; response_body: string | null }>,
663
+ ): "account_capability_missing" | null {
664
+ const valid = attempts.filter((a) => {
665
+ if (a.response_status === 401 || a.response_status === 403) return false;
666
+ const msg = extractMessage(a.response_body);
667
+ return !(msg !== null && AUTH_NOISE_PATTERNS.some((re) => re.test(msg)));
668
+ });
669
+ if (valid.length === 0) return null;
670
+ if (valid.some((a) => a.response_status !== null && a.response_status >= 200 && a.response_status < 300)) return null;
671
+ const capabilityHit = valid.some((a) => {
672
+ const msg = extractMessage(a.response_body);
673
+ return msg !== null && HARD_BLOCKED_PATTERNS.some((re) => re.test(msg));
674
+ });
675
+ return capabilityHit ? "account_capability_missing" : null;
676
+ }
677
+
678
+ function extractMessage(body: string | null): string | null {
679
+ if (!body) return null;
680
+ try {
681
+ const parsed = JSON.parse(body);
682
+ const m = parsed?.error?.message;
683
+ return typeof m === "string" ? m : null;
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+
689
+ // ─── ARV-281: gap-fill filtering ─────────────────────────────────────
690
+
691
+ /**
692
+ * Drop aspect-level fields on a proposed patch when the existing
693
+ * overlay (matched by resource name) already has that field set to a
694
+ * non-empty value. Returns a new patch object — never mutates input.
695
+ */
696
+ export function filterToGaps(
697
+ proposed: ResourcePatch,
698
+ existing: ResourcePatch[],
699
+ ): ResourcePatch {
700
+ const prior = existing.find((p) => p.resource === proposed.resource);
701
+ if (!prior) return proposed;
702
+ const out: ResourcePatch = { resource: proposed.resource };
703
+ for (const [key, value] of Object.entries(proposed) as Array<[string, unknown]>) {
704
+ if (key === "resource") continue;
705
+ const existingVal = (prior as unknown as Record<string, unknown>)[key];
706
+ if (isPresent(existingVal)) continue; // already-set field → skip
707
+ (out as Record<string, unknown>)[key] = value;
708
+ }
709
+ return out;
710
+ }
711
+
712
+ function isPresent(v: unknown): boolean {
713
+ if (v === undefined || v === null) return false;
714
+ if (typeof v === "string") return v.length > 0;
715
+ if (Array.isArray(v)) return v.length > 0;
716
+ if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length > 0;
717
+ return true;
718
+ }
719
+
720
+ function countGapDroppedFields(
721
+ before: ResourcePatch[],
722
+ after: ResourcePatch[],
723
+ ): number {
724
+ let n = 0;
725
+ for (let i = 0; i < before.length; i++) {
726
+ const b = before[i]!;
727
+ const a = after[i]!;
728
+ for (const k of Object.keys(b)) {
729
+ if (k === "resource") continue;
730
+ if (!(k in a)) n++;
731
+ }
732
+ }
733
+ return n;
734
+ }
735
+
394
736
  // ─── I/O helpers ─────────────────────────────────────────────────────
395
737
 
396
738
  async function readInput(source: string): Promise<string> {
@@ -433,7 +775,7 @@ export function registerApiAnnotate(program: Command): void {
433
775
 
434
776
  const annotate = api
435
777
  .command("annotate")
436
- .description("Agent-augmented overlay authoring. zond emits raw spec slices agent generates YAML zond validates+applies. Two subcommands: `dump` and `apply`.");
778
+ .description("Overlay authoring for .api-resources.local.yaml. Two subcommands: `dump` (emit spec slices) + `apply` (validate & merge the agent's YAML) agent-in-the-loop, ARV-187. zond carries no inference; the agent writes the annotations, zond validates and merges.");
437
779
 
438
780
  annotate
439
781
  .command("dump")
@@ -446,6 +788,8 @@ export function registerApiAnnotate(program: Command): void {
446
788
  .option("--readback", "Create+read pair for readback_diff")
447
789
  .option("--resources", "Orphan-endpoint list for resource-graph extensions")
448
790
  .option("--only <list>", "Comma-separated resource names — restrict scope", csv)
791
+ .option("--with-last-attempt", "ARV-277: enrich seed-bodies bundles with the most recent fixture-kind POST attempt (`request_body`/`response_status`/`response_body`/`attempted_at`) so the agent sees what zond tried last without re-running prepare-fixtures.")
792
+ .option("--history <N>", "ARV-278: with --with-last-attempt, return the most recent N attempts (newest first) under `attempt_history`. Surfaces error progression as the overlay was iterated.", (v) => Number.parseInt(v, 10))
449
793
  .option("--db <path>", "SQLite db path override")
450
794
  .action(async (rawOpts, cmd: Command) => {
451
795
  const kind = pickKind(rawOpts);
@@ -454,10 +798,19 @@ export function registerApiAnnotate(program: Command): void {
454
798
  process.exitCode = 2;
455
799
  return;
456
800
  }
801
+ if (rawOpts.withLastAttempt === true && kind !== "seed-bodies") {
802
+ printWarning("--with-last-attempt is only meaningful with --seed-bodies; ignored.");
803
+ }
457
804
  const apiName = resolveApiArg(rawOpts, cmd);
458
805
  if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
459
806
  process.exitCode = await dumpCommand({
460
- api: apiName, kind, only: rawOpts.only, dbPath: rawOpts.db, json: globalJson(cmd),
807
+ api: apiName,
808
+ kind,
809
+ only: rawOpts.only,
810
+ dbPath: rawOpts.db,
811
+ json: globalJson(cmd),
812
+ withLastAttempt: rawOpts.withLastAttempt === true,
813
+ historyLimit: Number.isFinite(rawOpts.history) ? rawOpts.history : undefined,
461
814
  });
462
815
  });
463
816
 
@@ -474,6 +827,7 @@ export function registerApiAnnotate(program: Command): void {
474
827
  .option("--input <file>", "Path to the YAML responses file, or `-` for stdin", "-")
475
828
  .option("--yes", "Write the proposed patches to disk (default: dry-run + diff)")
476
829
  .option("--force", "Overwrite the existing value on field-level conflict")
830
+ .option("--gap-fill-only", "ARV-281: drop aspect-level fields the agent proposed for resources that already have that field set in the overlay. Keeps the agent's response strictly additive — protects curated `seed_body` blocks from accidental regression. Bypassed by --force.")
477
831
  .option("--db <path>", "SQLite db path override")
478
832
  .action(async (rawOpts, cmd: Command) => {
479
833
  const kind = pickKind(rawOpts);
@@ -490,6 +844,7 @@ export function registerApiAnnotate(program: Command): void {
490
844
  input: rawOpts.input ?? "-",
491
845
  yes: rawOpts.yes === true,
492
846
  force: rawOpts.force === true,
847
+ gapFillOnly: rawOpts.gapFillOnly === true,
493
848
  dbPath: rawOpts.db,
494
849
  json: globalJson(cmd),
495
850
  });
@@ -32,7 +32,7 @@ export const EXPECTED_OUTPUT_SHAPE = {
32
32
  field: "string (response field holding state, e.g. 'status')",
33
33
  states: "string[] (≥2 enum values)",
34
34
  transitions: "[{from: state, to: state[]}]",
35
- actions: "{ <verb>: { endpoint: 'METHOD /path', expected_state: state, body?: object } }",
35
+ actions: "{ <verb>: { endpoint: 'METHOD /path', expected_state: state, body?: object } } — return {} for read-only state machines (observation mode walks the list endpoint and asserts observed ⊆ states; cannot verify transitions in this mode)",
36
36
  },
37
37
  rationale: "string (optional)",
38
38
  confidence: "low | medium | high",
@@ -133,7 +133,7 @@ function stableJson(v: unknown): string {
133
133
  }
134
134
 
135
135
  function deepClone<T>(v: T): T {
136
- return JSON.parse(JSON.stringify(v));
136
+ return structuredClone(v);
137
137
  }
138
138
 
139
139
  /**
@@ -14,6 +14,8 @@ const PaginationSchema = z.object({
14
14
  limit_param: z.string().optional(),
15
15
  default_limit: z.number().int().positive().optional(),
16
16
  items_field: z.string().optional(),
17
+ page_param: z.string().optional(),
18
+ start_page: z.number().int().nonnegative().optional(),
17
19
  });
18
20
 
19
21
  const ResponseSchema = z.object({
@@ -26,13 +28,15 @@ const ResponseSchema = z.object({
26
28
  export const EXPECTED_OUTPUT_SHAPE = {
27
29
  resource: "string (echo input)",
28
30
  pagination: {
29
- type: "cursor | page | offset | token",
30
- cursor_param: "string (optional — query param carrying cursor value)",
31
- cursor_field: "string (optional — response field that becomes next cursor)",
32
- has_more_field: "string (optional — boolean response field signalling more)",
33
- limit_param: "string (optional)",
34
- default_limit: "integer (optional)",
31
+ type: "cursor | page | offset | token (cursor and page are implemented; offset/token short-circuit)",
32
+ cursor_param: "string (optional — only for type=cursor; query param carrying cursor value)",
33
+ cursor_field: "string (optional — for cursor: next-cursor field; for page: dedupe key across pages; default `id`)",
34
+ has_more_field: "string (optional — only for type=cursor; boolean response field signalling more)",
35
+ limit_param: "string (optional — page size param; default `limit` for cursor, `per_page` for page)",
36
+ default_limit: "integer (optional — probe page size; default 2)",
35
37
  items_field: "string (optional — response field carrying array)",
38
+ page_param: "string (optional — only for type=page; default `page`)",
39
+ start_page: "integer (optional — only for type=page; first page number, default 1; some APIs use 0)",
36
40
  },
37
41
  rationale: "string (optional)",
38
42
  confidence: "low | medium | high",
@@ -95,8 +95,9 @@ function dumpEndpoint(doc: OpenAPIV3.Document, method: string, path: string): En
95
95
  if (!op) return null;
96
96
 
97
97
  const parameters: EndpointDump["parameters"] = [];
98
- for (const p of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])]) {
99
- if ("$ref" in p) continue;
98
+ for (const raw of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])]) {
99
+ const p = resolveParameter(doc, raw);
100
+ if (!p) continue;
100
101
  parameters.push({
101
102
  name: p.name,
102
103
  in: p.in,
@@ -181,3 +182,39 @@ function truncate(s: string | undefined, n: number): string | undefined {
181
182
  return s.length > n ? s.slice(0, n) + "…" : s;
182
183
  }
183
184
 
185
+ /**
186
+ * Resolve a parameter that may be inline or a $ref. Handles
187
+ * `#/components/parameters/X` (canonical) and, leniently,
188
+ * `#/components/headers/X` — some specs (Stripe) keep their
189
+ * `Idempotency-Key` declaration under `components.headers` and reuse
190
+ * it as a parameter $ref. Returns null on unresolvable refs.
191
+ */
192
+ function resolveParameter(
193
+ doc: OpenAPIV3.Document,
194
+ raw: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject,
195
+ ): OpenAPIV3.ParameterObject | null {
196
+ if (!("$ref" in raw)) return raw;
197
+ const ref = raw.$ref;
198
+ const paramMatch = ref.match(/^#\/components\/parameters\/(.+)$/);
199
+ if (paramMatch) {
200
+ const target = doc.components?.parameters?.[paramMatch[1]!];
201
+ if (target && !("$ref" in target)) return target as OpenAPIV3.ParameterObject;
202
+ }
203
+ const headerMatch = ref.match(/^#\/components\/headers\/(.+)$/);
204
+ if (headerMatch) {
205
+ const name = headerMatch[1]!;
206
+ const target = doc.components?.headers?.[name];
207
+ if (target && !("$ref" in target)) {
208
+ const h = target as OpenAPIV3.HeaderObject;
209
+ return {
210
+ name,
211
+ in: "header",
212
+ required: h.required,
213
+ description: h.description,
214
+ schema: h.schema,
215
+ } as OpenAPIV3.ParameterObject;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+