@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
@@ -29,30 +29,9 @@ import {
29
29
  } from "../../core/spec/layers.ts";
30
30
  import { liveAuthHeaders } from "../../core/probe/shared.ts";
31
31
  import { executeRequest } from "../../core/runner/http-client.ts";
32
-
33
- /**
34
- * Suffix-aware field extraction. For var `project_slug` we prefer the
35
- * response's `slug` field over `id`; for `team_uuid` we prefer `uuid`.
36
- * This matches the user's intent expressed in the env-var name and avoids
37
- * the surprise where every nested resource gets the same generic `id` even
38
- * when the path-param clearly wants a slug.
39
- */
40
- const VAR_SUFFIX_HINTS: Array<{ suffix: string; field: string }> = [
41
- { suffix: "_slug", field: "slug" },
42
- { suffix: "_uuid", field: "uuid" },
43
- { suffix: "_key", field: "key" },
44
- { suffix: "_version", field: "version" },
45
- { suffix: "_name", field: "name" },
46
- { suffix: "_code", field: "code" },
47
- { suffix: "_id", field: "id" },
48
- ];
49
-
50
- function preferredFieldFromVar(varName: string): string {
51
- for (const { suffix, field } of VAR_SUFFIX_HINTS) {
52
- if (varName.endsWith(suffix)) return field;
53
- }
54
- return "id";
55
- }
32
+ import { writeFixtureGaps, type FixtureGap } from "../../core/workspace/fixture-gaps.ts";
33
+ import { reportFixtureGaps, type FixtureGapReport } from "../../core/workspace/fixture-gap-report.ts";
34
+ import { parseSafe } from "../../core/parser/yaml-parser.ts";
56
35
 
57
36
  /** Strip a trailing FK-shape suffix (`_id`, `Id`, `_uuid`, `_slug`, `_name`,
58
37
  * `_code`) from a var name and return the stem. Used by ARV-69 to find an
@@ -92,37 +71,21 @@ export function inferOwnerFromVarName(
92
71
  return undefined;
93
72
  }
94
73
 
95
- function pickFieldFromObject(item: unknown, preferred: string): string | undefined {
96
- if (!item || typeof item !== "object") return undefined;
97
- const obj = item as Record<string, unknown>;
98
- const tryKey = (k: string): string | undefined => {
99
- if (k in obj) {
100
- const v = obj[k];
101
- if (typeof v === "string" || typeof v === "number") return String(v);
102
- }
103
- return undefined;
104
- };
105
- return (
106
- tryKey(preferred) ??
107
- tryKey("id") ??
108
- tryKey("slug") ??
109
- tryKey("uuid") ??
110
- tryKey("key") ??
111
- tryKey("name")
112
- );
113
- }
114
-
115
- /** Walk the response body for the first item matching common SaaS list shapes,
116
- * then pick a field hint-aware. */
117
- function extractFirstField(body: unknown, preferred: string): string | undefined {
118
- if (Array.isArray(body)) return pickFieldFromObject(body[0], preferred);
74
+ /** Return the first object in a list-response (bare array or a `data`/`items`/
75
+ * `results`/`records` envelope), or undefined. Distinguishes a non-empty
76
+ * recognized list ("resource exists, agent must pick a value") from an
77
+ * unrecognized shape. */
78
+ function firstListItem(body: unknown): Record<string, unknown> | undefined {
79
+ const pick = (arr: unknown): Record<string, unknown> | undefined =>
80
+ Array.isArray(arr) && arr[0] && typeof arr[0] === "object"
81
+ ? (arr[0] as Record<string, unknown>)
82
+ : undefined;
83
+ if (Array.isArray(body)) return pick(body);
119
84
  if (body && typeof body === "object") {
120
85
  const obj = body as Record<string, unknown>;
121
86
  for (const key of ["data", "items", "results", "records"]) {
122
- const arr = obj[key];
123
- if (Array.isArray(arr) && arr.length > 0) {
124
- return pickFieldFromObject(arr[0], preferred);
125
- }
87
+ const hit = pick(obj[key]);
88
+ if (hit) return hit;
126
89
  }
127
90
  }
128
91
  return undefined;
@@ -188,11 +151,12 @@ export interface DiscoveryItem {
188
151
  discovered?: string;
189
152
  /** What's currently in env (may be empty/placeholder). */
190
153
  current?: string;
191
- /** Action to take: write, skip-already-set, miss-no-list, miss-network, miss-status, miss-empty, miss-no-id. */
154
+ /** Gap/verify classification. ARV-362 (m-25): discover never harvests a
155
+ * value — the write path is gone. Non-empty lists surface as
156
+ * `miss-needs-value` (resource exists, agent picks); everything else is a
157
+ * verify state or a miss-* gap. */
192
158
  status:
193
- | "write"
194
159
  | "skip-already-set"
195
- | "skip-already-equal"
196
160
  | "skip-not-required"
197
161
  | "miss-no-list"
198
162
  | "miss-nested-list"
@@ -201,6 +165,9 @@ export interface DiscoveryItem {
201
165
  | "miss-status"
202
166
  | "miss-empty"
203
167
  | "miss-no-id"
168
+ // ARV-362: got a non-empty list, but which record/field fills the slot is
169
+ // the agent's call (ARV-334 lived here). discover reports the gap.
170
+ | "miss-needs-value"
204
171
  // TASK-281 verify-mode states
205
172
  | "verify-live"
206
173
  | "verify-stale"
@@ -220,15 +187,20 @@ export interface DiscoveryItem {
220
187
  /** ARV-46: source classification copied from `.api-fixtures.yaml`. */
221
188
  manifestSource?: FixtureManifestEntry["source"];
222
189
  reason?: string;
190
+ /** ARV-382: when zond can't CONFIDENTLY derive the owner list endpoint
191
+ * (miss-no-list), it surfaces the plausible GET/list endpoints — ranked by
192
+ * structural proximity to where the id is consumed — instead of dead-ending.
193
+ * zond does NOT pick a value; the agent reads these, picks one, and sets the
194
+ * fixture (or fires one `zond request` against the top candidate). Empty
195
+ * when nothing structurally plausible exists. */
196
+ candidates?: string[];
223
197
  /** TASK-294: agent-routable action for items the user must fix.
224
198
  * miss-* / verify-stale / verify-unknown → `fix_fixture`.
225
199
  * miss-network → `fix_network_config`.
226
- * write / skip-* / verify-live → undefined. */
200
+ * skip-* / verify-live → undefined. */
227
201
  recommended_action?: RecommendedAction;
228
- /** ARV-142: when --refresh re-resolves a verify-stale item, the original
229
- * verify-stale entry is replaced with the refresh outcome. This flag
230
- * preserves the "was stale before refresh" signal so summary can
231
- * report stale_fixed vs still_stale honestly. */
202
+ /** ARV-362: set when --refresh drops (unsets) a stale fixture. Marks which
203
+ * vars to unset on disk and feeds the summary `dropped` count. */
232
204
  wasStale?: boolean;
233
205
  }
234
206
 
@@ -245,19 +217,16 @@ export function discoveryAction(status: DiscoveryItem["status"]): RecommendedAct
245
217
  * prints this column when discover runs in manifest-driven mode and it's
246
218
  * exposed verbatim in the JSON envelope. */
247
219
  export type ManifestStatus =
248
- | "filled"
249
220
  | "failed:no-list-endpoint"
250
221
  | "failed:list-empty"
222
+ | "failed:needs-value"
251
223
  | "failed:miss-network"
252
224
  | "skipped:already-set"
253
225
  | "skipped:not-required";
254
226
 
255
227
  export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatus {
256
228
  switch (status) {
257
- case "write":
258
- return "filled";
259
229
  case "skip-already-set":
260
- case "skip-already-equal":
261
230
  return "skipped:already-set";
262
231
  case "skip-not-required":
263
232
  return "skipped:not-required";
@@ -265,6 +234,9 @@ export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatu
265
234
  return "failed:miss-network";
266
235
  case "miss-empty":
267
236
  return "failed:list-empty";
237
+ // ARV-362: list has records but discover won't pick — the agent fills it.
238
+ case "miss-needs-value":
239
+ return "failed:needs-value";
268
240
  // miss-no-list / miss-nested-list / miss-no-owner / miss-status / miss-no-id —
269
241
  // the underlying cause is "we have no usable list endpoint to read from", so
270
242
  // they collapse onto the same manifest-level bucket.
@@ -273,6 +245,34 @@ export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatu
273
245
  }
274
246
  }
275
247
 
248
+ /** ARV-324: project confirmed-empty/inaccessible/needs-value list-probes into
249
+ * the `.fixture-gaps.yaml` shape. `miss-status` (list endpoint rejected the
250
+ * request), `miss-empty` (200 with an empty collection) and `miss-needs-value`
251
+ * (ARV-362: 200 with records the agent must pick from) all carry an actual
252
+ * observed response worth cross-referencing against a later `checks run` —
253
+ * the other miss-* statuses mean "we never even sent a request" (no list
254
+ * endpoint / no owner resource). De-dupes by (method, path); a later pass's
255
+ * entry for the same var wins. */
256
+ export function gapsFromItems(items: DiscoveryItem[]): FixtureGap[] {
257
+ const byKey = new Map<string, FixtureGap>();
258
+ for (const item of items) {
259
+ if (
260
+ item.status !== "miss-status" &&
261
+ item.status !== "miss-empty" &&
262
+ item.status !== "miss-needs-value"
263
+ ) continue;
264
+ if (!item.listPath) continue;
265
+ byKey.set(`GET ${item.listPath}`, {
266
+ method: "GET",
267
+ path: item.listPath,
268
+ resource: item.resource,
269
+ var: item.varName,
270
+ reason: item.reason ?? item.status,
271
+ });
272
+ }
273
+ return [...byKey.values()];
274
+ }
275
+
276
276
  export function isPlaceholder(value: string | undefined): boolean {
277
277
  if (!value) return true;
278
278
  const trimmed = value.trim();
@@ -320,6 +320,8 @@ export interface ResourceYaml {
320
320
  limit_param?: string;
321
321
  default_limit?: number;
322
322
  items_field?: string;
323
+ page_param?: string;
324
+ start_page?: number;
323
325
  };
324
326
  /** ARV-172: per-resource state machine + action endpoints. */
325
327
  lifecycle?: {
@@ -512,6 +514,57 @@ export async function readFixtureManifest(apiDir: string): Promise<FixtureManife
512
514
  * via `inferOwnerFromVarName` (singular ↔ plural matching) so vars whose
513
515
  * name doesn't appear in the resource map's idParam table still get
514
516
  * attempted. */
517
+ /** ARV-382: for a fixture var that resolved to no confident owner list
518
+ * endpoint, surface the structurally-plausible GET/list endpoints instead of
519
+ * dead-ending. We walk back from each `{param}` segment in the endpoints the
520
+ * var affects and collect any GET whose path is that collection prefix (or
521
+ * prefix + a list verb), ranked by proximity (deeper prefix = closer). This
522
+ * is deterministic candidate EVIDENCE — zond does not pick a value or a
523
+ * winner; the agent judges. Reuses the existing list-verb set (no new
524
+ * markers). Returns [] when nothing plausible exists. */
525
+ export function findCandidateListEndpoints(
526
+ affectedLabels: string[],
527
+ endpoints: Array<{ method: string; path: string; deprecated?: boolean }>,
528
+ ): string[] {
529
+ const strip = (p: string) => (p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p);
530
+ const isParam = (s: string | undefined) => !!s && /^\{[^}]+\}$/.test(s);
531
+ // Prefer live endpoints; still surface a deprecated-only list (marked) so the
532
+ // agent — not zond — decides whether an EOL read is acceptable to seed an id.
533
+ const getByPath = new Map<string, { label: string; deprecated: boolean }>();
534
+ for (const e of endpoints) {
535
+ if (e.method.toUpperCase() !== "GET") continue;
536
+ const key = strip(e.path);
537
+ const entry = { label: `${e.method.toUpperCase()} ${e.path}`, deprecated: !!e.deprecated };
538
+ const prev = getByPath.get(key);
539
+ if (!prev || (prev.deprecated && !entry.deprecated)) getByPath.set(key, entry); // live wins
540
+ }
541
+ const VERBS = ["", "/list", "/search", "/find"];
542
+ const scored = new Map<string, number>();
543
+ const consider = (label: string, score: number) => {
544
+ if ((scored.get(label) ?? -1) < score) scored.set(label, score);
545
+ };
546
+ for (const label of affectedLabels) {
547
+ const path = label.slice(label.indexOf(" ") + 1);
548
+ const segs = strip(path).split("/");
549
+ for (let i = 0; i < segs.length; i++) {
550
+ if (!isParam(segs[i])) continue;
551
+ // Walk back over the run of non-param segments before this param.
552
+ for (let k = i; k >= 1; k--) {
553
+ if (isParam(segs[k - 1])) break;
554
+ const prefix = segs.slice(0, k).join("/");
555
+ if (prefix.includes("{")) continue; // an inner param — not a concrete GET path
556
+ for (const v of VERBS) {
557
+ const hit = getByPath.get(prefix + v);
558
+ if (!hit) continue;
559
+ // deeper prefix = closer; deprecated candidates rank below all live.
560
+ consider(hit.deprecated ? `${hit.label} (deprecated)` : hit.label, (hit.deprecated ? 0 : 1000) + k);
561
+ }
562
+ }
563
+ }
564
+ }
565
+ return [...scored.entries()].sort((a, b) => b[1] - a[1]).map((e) => e[0]).slice(0, 5);
566
+ }
567
+
515
568
  export function collectTargets(
516
569
  map: ApiResourceMapYaml,
517
570
  manifest?: FixtureManifestYaml,
@@ -648,34 +701,52 @@ export async function probeOne(
648
701
  }
649
702
  if (resp.status < 200 || resp.status >= 300) {
650
703
  item.status = "miss-status";
651
- item.reason = `${parsed.method} ${parsed.path}${resp.status}`;
704
+ // ARV-99: bare `METHOD path → status` leaves the agent guessing what
705
+ // to do. Append a status-specific next-step hint so 404 / 401 / 403 /
706
+ // 5xx each get a routed action. Spec drift (404) and token scope
707
+ // (401/403) are the two common root causes — call them out.
708
+ let hint = "";
709
+ if (resp.status === 404) {
710
+ hint =
711
+ ` — list endpoint 404'd. Spec may have a stale path. Try \`zond refresh-api <name>\` to re-sync; ` +
712
+ `if the path is correct, the API likely doesn't expose this resource for your token — add the var to ` +
713
+ `.api-resources.local.yaml as a custom create endpoint (extension overlay) or fill .env.yaml by hand`;
714
+ } else if (resp.status === 401 || resp.status === 403) {
715
+ hint =
716
+ ` — auth/scope rejection on the list endpoint. Check token scope; if the token genuinely cannot list ${target.ownerResource}, ` +
717
+ `fill .env.yaml by hand or rerun with \`--no-seed\` to skip futile attempts`;
718
+ } else if (resp.status >= 500) {
719
+ hint = ` — server-side error; retry later or check provider status before treating this as a fixture gap`;
720
+ }
721
+ item.reason = `${parsed.method} ${parsed.path} → ${resp.status}${hint}`;
652
722
  return item;
653
723
  }
724
+ // ARV-362 (m-25): discover no longer harvests a value from the list —
725
+ // which record/field fills the slot is the agent's call (ARV-334 lived in
726
+ // that guess). We only classify the gap so the agent knows the next step.
654
727
  const respBody = resp.body_parsed ?? resp.body;
655
- const id = extractFirstField(respBody, preferredFieldFromVar(target.varName));
656
- if (id === undefined) {
657
- // TASK-273: empty target-API is the most common cause of miss-no-id on
658
- // fresh workspaces. Distinguish "list is well-shaped but empty" from
659
- // "list shape unrecognized" so the user gets actionable guidance instead
660
- // of guessing for 30 minutes whether zond is broken.
661
- if (isEmptyListBody(respBody)) {
662
- item.status = "miss-empty";
663
- item.reason =
664
- `no ${target.ownerResource} in target API — re-run with \`zond prepare-fixtures --api <name> --seed --apply\` ` +
665
- `to POST-create one automatically, or create the resource yourself (in the product UI or via API) and re-run discover`;
666
- } else {
667
- item.status = "miss-no-id";
668
- item.reason = `response shape has no extractable first id (no array/data/items/results/records field)`;
669
- }
728
+ if (isEmptyListBody(respBody)) {
729
+ // TASK-273: empty target API — nothing to pick. Point the agent at
730
+ // creating the resource first.
731
+ item.status = "miss-empty";
732
+ item.reason =
733
+ `no ${target.ownerResource} in target API create the resource yourself ` +
734
+ `(in the product UI or via API), then set its id in .env.yaml (or run ` +
735
+ `\`zond fixtures add\`) and re-run \`zond prepare-fixtures --api <name>\``;
670
736
  return item;
671
737
  }
672
- if (current && current === id) {
673
- item.discovered = id;
674
- item.status = "skip-already-equal";
738
+ if (!firstListItem(respBody)) {
739
+ item.status = "miss-no-id";
740
+ item.reason = `response shape unrecognized (no array/data/items/results/records field)`;
675
741
  return item;
676
742
  }
677
- item.discovered = id;
678
- item.status = "write";
743
+ // Non-empty recognized list: the resource exists, but discover won't choose
744
+ // a record/field — the agent picks and fills .env.yaml by hand.
745
+ item.status = "miss-needs-value";
746
+ item.reason =
747
+ `${target.ownerResource} list has records but discover won't choose one — ` +
748
+ `pick a value and set {${target.varName}} in .env.yaml (or run \`zond fixtures add\`), ` +
749
+ `then re-run \`zond prepare-fixtures --api <name>\``;
679
750
  return item;
680
751
  }
681
752
 
@@ -785,6 +856,13 @@ export function upsertEnvLine(yamlText: string, key: string, value: string): str
785
856
  return lines.join("\n");
786
857
  }
787
858
 
859
+ /** First 8 `{{var}}` names + "… and N more" — keeps the gap warning readable
860
+ * when a barely-provisioned account leaves dozens of required fixtures empty. */
861
+ function capNames(names: string[], max = 8): string {
862
+ const head = names.slice(0, max).map(n => `{{${n}}}`).join(", ");
863
+ return names.length > max ? `${head}, … and ${names.length - max} more` : head;
864
+ }
865
+
788
866
  export async function discoverCommand(options: DiscoverOptions): Promise<number> {
789
867
  const commandName = options.commandName ?? "discover";
790
868
  try {
@@ -881,33 +959,15 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
881
959
  items.push(item);
882
960
  }
883
961
 
884
- // For each stale fixture, drop it from env so the upcoming probeOne call
885
- // treats it as a placeholder and re-resolves through the list endpoint.
886
- // Without --apply we stop hereverify is read-only by default.
962
+ // ARV-362 (m-25): --refresh drops the known-bad stale id (unsets it in
963
+ // .env.yaml, disk-write below) so the var resurfaces as a gap. discover
964
+ // no longer re-resolves a replacement value the agent picks the new
965
+ // one. `wasStale` marks which vars to unset on disk.
887
966
  if (options.apply) {
888
967
  for (const item of items) {
889
- if (item.status === "verify-stale") delete env[item.varName];
890
- }
891
- // Re-resolve only the previously-stale targets — leaves unverified live
892
- // ones in place (no point hitting the list endpoint for them).
893
- const staleTargets = targets.filter(t => items.some(i => i.varName === t.varName && i.status === "verify-stale"));
894
- for (const target of staleTargets) {
895
- const refreshed = await probeOne(
896
- target,
897
- env[target.varName],
898
- endpoints,
899
- securitySchemes,
900
- env,
901
- baseUrl,
902
- options.timeoutMs ?? 30000,
903
- );
904
- // Replace the verify-stale entry with the refresh outcome.
905
- // ARV-142: preserve the wasStale marker so summary can count
906
- // stale_fixed (refresh succeeded) vs still_stale (refresh failed).
907
- const idx = items.findIndex(i => i.varName === target.varName);
908
- if (idx >= 0) {
909
- refreshed.wasStale = true;
910
- items[idx] = refreshed;
968
+ if (item.status === "verify-stale") {
969
+ delete env[item.varName];
970
+ item.wasStale = true;
911
971
  }
912
972
  }
913
973
  }
@@ -1007,9 +1067,17 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1007
1067
  if (inferred) target = inferred;
1008
1068
  }
1009
1069
  if (!target) {
1070
+ // ARV-382: no confident owner — surface plausible list endpoints as
1071
+ // evidence instead of dead-ending. The agent picks (zond doesn't).
1072
+ const candidates = findCandidateListEndpoints(entry.affectedEndpoints ?? [], endpoints);
1010
1073
  placeholder.status = "miss-no-list";
1011
1074
  placeholder.manifestStatus = "failed:no-list-endpoint";
1012
- placeholder.reason = `${entry.source}-source var has no owner resource in .api-resources.yaml — cannot derive a list endpoint`;
1075
+ if (candidates.length > 0) {
1076
+ placeholder.candidates = candidates;
1077
+ placeholder.reason = `${entry.source}-source var has no confident owner in .api-resources.yaml — ${candidates.length} candidate list endpoint(s) surfaced; GET one, pick a record, set the value`;
1078
+ } else {
1079
+ placeholder.reason = `${entry.source}-source var has no owner resource in .api-resources.yaml — cannot derive a list endpoint`;
1080
+ }
1013
1081
  items.push(placeholder);
1014
1082
  continue;
1015
1083
  }
@@ -1051,10 +1119,15 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1051
1119
  if (action) it.recommended_action = action;
1052
1120
  }
1053
1121
 
1054
- const writes = items.filter(i => i.status === "write");
1122
+ // ARV-362 (m-25): discover never writes discovered values that's the
1123
+ // agent's call. The only disk mutation is --refresh unsetting stale ids so
1124
+ // the known-bad value is removed and the var resurfaces as a gap.
1125
+ const unsets = options.verify && options.apply
1126
+ ? items.filter(i => i.wasStale === true).map(i => i.varName)
1127
+ : [];
1055
1128
  let applied = false;
1056
1129
  let backupPath: string | null = null;
1057
- if (options.apply && writes.length > 0) {
1130
+ if (unsets.length > 0) {
1058
1131
  backupPath = `${envPath}.bak`;
1059
1132
  try {
1060
1133
  await copyFile(envPath, backupPath);
@@ -1064,8 +1137,8 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1064
1137
  }
1065
1138
  const file = Bun.file(envPath);
1066
1139
  let text = (await file.exists()) ? await file.text() : "";
1067
- for (const w of writes) {
1068
- text = upsertEnvLine(text, w.varName, w.discovered!);
1140
+ for (const v of unsets) {
1141
+ text = upsertEnvLine(text, v, "");
1069
1142
  }
1070
1143
  if (!text.endsWith("\n")) text += "\n";
1071
1144
  await Bun.write(envPath, text);
@@ -1075,24 +1148,62 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1075
1148
  // ARV-46: env keys without a manifest entry are noise — the user (or a
1076
1149
  // legacy hand-edit) put them there; the API doesn't actually need them.
1077
1150
  // Surface as warning so they can be removed; do not act on them.
1151
+ // ARV-260: exclude auto-managed auth keys (auth_token, api_key) — zond
1152
+ // itself writes them at `add api` time and uses them to inject the
1153
+ // Authorization/X-API-Key header even when the spec lacks
1154
+ // securitySchemes. They are not in the fixtures manifest by design.
1155
+ // Without this filter, prepare-fixtures tells users to "drop them from
1156
+ // .env.yaml or run refresh-api" — both wrong actions that would break
1157
+ // auth.
1158
+ const AUTO_MANAGED_KEYS = new Set(["auth_token", "api_key"]);
1078
1159
  let unknownEnvKeys: string[] = [];
1079
1160
  if (manifest) {
1080
1161
  const manifestNames = new Set(manifest.fixtures.map(f => f.name));
1081
- unknownEnvKeys = Object.keys(env).filter(k => !manifestNames.has(k));
1162
+ unknownEnvKeys = Object.keys(env).filter(
1163
+ k => !manifestNames.has(k) && !AUTO_MANAGED_KEYS.has(k),
1164
+ );
1082
1165
  }
1083
1166
 
1167
+ // ARV-324: persist confirmed empty/inaccessible operations so a later,
1168
+ // separate `checks run` invocation can tell "known fixture gap" apart
1169
+ // from "new backend bug" instead of mislabeling both report_backend_bug.
1170
+ // Rewritten wholesale every run so a since-fixed gap doesn't linger.
1171
+ await writeFixtureGaps(options.apiDir, gapsFromItems(items));
1172
+
1173
+ // ARV-349/350: report suite vars this single-pass run cannot resolve.
1174
+ // Load the generated suites (best-effort — absent tests/ dir is fine) and
1175
+ // scan them against the env we'd end up with (current + this run's writes).
1176
+ // Report only; never invent values / auto-seed.
1177
+ let gapReport: FixtureGapReport = { undefinedVars: [], unseededRoots: [] };
1178
+ try {
1179
+ const { suites } = await parseSafe(join(options.apiDir, "tests"));
1180
+ if (suites.length > 0) {
1181
+ // ARV-362: discover writes nothing, so the effective env is just what's
1182
+ // already on disk (minus any stale ids --refresh unset above).
1183
+ const effectiveEnv: Record<string, string> = { ...env };
1184
+ // Required manifest vars still empty after this pass = candidate
1185
+ // chain-roots. Source-agnostic: real specs model these as `path`
1186
+ // required:true with an empty default, not only `capture-chain`.
1187
+ const requiredEmptyVars = new Set(
1188
+ (manifest?.fixtures ?? [])
1189
+ .filter(f => f.required && !(effectiveEnv[f.name] && effectiveEnv[f.name]!.length > 0))
1190
+ .map(f => f.name),
1191
+ );
1192
+ gapReport = reportFixtureGaps(suites, effectiveEnv, requiredEmptyVars);
1193
+ }
1194
+ } catch { /* suite scan is best-effort — never fail prepare-fixtures on it */ }
1195
+
1084
1196
  const requiredManifestCount = manifest
1085
1197
  ? manifest.fixtures.filter(f => f.required).length
1086
1198
  : 0;
1087
- // ARV-143: in verify/refresh mode `manifestStatus` is not populated by
1088
- // the verify loop (it uses verify-* statuses instead). Count verify-live
1089
- // and verify-user-config as "filled" so the "Filled X/Y" line agrees with
1199
+ // ARV-362: discover never fills a value, so "filled" means "already set on
1200
+ // disk". Manifest-driven mode reports skip-already-set; verify mode reports
1201
+ // verify-live / verify-user-config. The "Filled X/Y" line agrees with
1090
1202
  // doctor and the user_config bucket isn't double-counted as UNSET.
1091
1203
  const filledCount = items.filter(i =>
1092
- i.manifestStatus === "filled" ||
1204
+ i.manifestStatus === "skipped:already-set" ||
1093
1205
  i.status === "verify-live" ||
1094
- i.status === "verify-user-config" ||
1095
- (i.wasStale === true && i.status === "write"),
1206
+ i.status === "verify-user-config",
1096
1207
  ).length;
1097
1208
 
1098
1209
  if (options.json) {
@@ -1108,9 +1219,13 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1108
1219
  items: safeItems,
1109
1220
  summary: {
1110
1221
  total: items.length,
1111
- writes: writes.length,
1222
+ // ARV-362: discover writes no values; --refresh may unset stale ids.
1223
+ unset: unsets.length,
1112
1224
  alreadySet: items.filter(i => i.status === "skip-already-set").length,
1113
1225
  misses: items.filter(i => i.status.startsWith("miss-")).length,
1226
+ // ARV-349/350: gaps prepare-fixtures cannot resolve deterministically.
1227
+ // Present so an agent/user can fill them; values are never invented.
1228
+ fixtureGaps: gapReport,
1114
1229
  ...(manifest ? {
1115
1230
  manifest: {
1116
1231
  required: requiredManifestCount,
@@ -1121,14 +1236,11 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1121
1236
  ...(options.verify ? {
1122
1237
  verify: {
1123
1238
  live: items.filter(i => i.status === "verify-live").length,
1124
- // ARV-142: items currently classified as stale (refresh didn't
1125
- // overwrite them either --apply was off, or refresh failed).
1239
+ // Items classified stale. With --refresh they are also unset on
1240
+ // disk (see `dropped`); without it they stay for the agent to fix.
1126
1241
  stale: items.filter(i => i.status === "verify-stale").length,
1127
- // ARV-142: stale items that --refresh successfully re-resolved.
1128
- stale_fixed: items.filter(i => i.wasStale === true && i.status === "write").length,
1129
- // ARV-142: stale items where --refresh ran but couldn't write a
1130
- // new value (e.g. list endpoint empty / unreachable).
1131
- still_stale: items.filter(i => i.wasStale === true && i.status !== "write").length,
1242
+ // ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
1243
+ dropped: items.filter(i => i.wasStale === true).length,
1132
1244
  unknown: items.filter(i => i.status === "verify-unknown").length,
1133
1245
  skipped: items.filter(i => i.status === "verify-skip-empty" || i.status === "verify-no-read").length,
1134
1246
  // ARV-143: filled vars with no verify path (user-config /
@@ -1149,25 +1261,21 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1149
1261
  i.resource || "—",
1150
1262
  i.listPath || "—",
1151
1263
  i.manifestStatus ?? i.status,
1152
- i.status === "write"
1153
- ? i.discovered!
1154
- : i.status === "skip-already-set"
1155
- ? `(kept: ${i.current})`
1156
- : i.status === "skip-already-equal"
1157
- ? `(unchanged: ${i.current})`
1158
- : i.status === "skip-not-required"
1159
- ? `(not owned by discover)`
1160
- : i.status === "verify-live"
1161
- ? `(live: ${i.current})`
1162
- : i.status === "verify-stale"
1163
- ? `(stale: ${i.current})${i.reason ? ` — ${i.reason}` : ""}`
1164
- : i.status === "verify-user-config"
1165
- // ARV-143 follow-up: never echo the raw value here —
1166
- // auth/header sources routinely carry tokens, and even
1167
- // server URLs can be sensitive. Mirror doctor's
1168
- // set/length-only contract from .secrets.yaml handling.
1169
- ? `(trusted, length=${(i.current ?? "").length})`
1170
- : (i.reason ?? ""),
1264
+ i.status === "skip-already-set"
1265
+ ? `(kept: ${i.current})`
1266
+ : i.status === "skip-not-required"
1267
+ ? `(not owned by discover)`
1268
+ : i.status === "verify-live"
1269
+ ? `(live: ${i.current})`
1270
+ : i.status === "verify-stale"
1271
+ ? `(stale: ${i.current})${i.reason ? ` — ${i.reason}` : ""}`
1272
+ : i.status === "verify-user-config"
1273
+ // ARV-143 follow-up: never echo the raw value here —
1274
+ // auth/header sources routinely carry tokens, and even
1275
+ // server URLs can be sensitive. Mirror doctor's
1276
+ // set/length-only contract from .secrets.yaml handling.
1277
+ ? `(trusted, length=${(i.current ?? "").length})`
1278
+ : (i.reason ?? ""),
1171
1279
  ]);
1172
1280
  // ARV-143 follow-up: redact every text cell through SecretRegistry so
1173
1281
  // an `auth_token` that happens to slip into a `(kept: ...)` /
@@ -1186,36 +1294,48 @@ export async function discoverCommand(options: DiscoverOptions): Promise<number>
1186
1294
  const live = items.filter(i => i.status === "verify-live").length;
1187
1295
  const stale = items.filter(i => i.status === "verify-stale").length;
1188
1296
  const unknown = items.filter(i => i.status === "verify-unknown").length;
1189
- // ARV-142: split stale-fixed vs still-stale so refresh telemetry no
1190
- // longer hides "0 stale" while quietly overwriting on disk.
1191
- const staleFixed = items.filter(i => i.wasStale === true && i.status === "write").length;
1192
- const stillStale = items.filter(i => i.wasStale === true && i.status !== "write").length;
1297
+ // ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
1298
+ const dropped = items.filter(i => i.wasStale === true).length;
1193
1299
  // ARV-143: filled vars verify can't reach — call them out as trusted.
1194
1300
  const userConfig = items.filter(i => i.status === "verify-user-config").length;
1195
1301
  const parts = [`${live} live`, `${stale} stale`];
1196
- if (staleFixed > 0) parts.push(`${staleFixed} stale-fixed`);
1197
- if (stillStale > 0) parts.push(`${stillStale} still-stale`);
1302
+ if (dropped > 0) parts.push(`${dropped} dropped`);
1198
1303
  parts.push(`${unknown} unknown`);
1199
1304
  if (userConfig > 0) parts.push(`${userConfig} trusted (no-verify-path)`);
1200
1305
  console.log(`Verify summary: ${parts.join(", ")}.`);
1201
1306
  if (stale > 0 && !options.apply) {
1202
- printWarning(`${stale} stale fixture(s) detected. Re-run with --refresh to drop and re-resolve them.`);
1307
+ printWarning(`${stale} stale fixture(s) detected. Re-run with --refresh to drop them (agent refills .env.yaml).`);
1203
1308
  }
1204
1309
  }
1205
1310
  if (manifest) {
1206
1311
  console.log(`Filled ${filledCount} / ${requiredManifestCount} manifest entries.`);
1207
1312
  }
1313
+ // ARV-350: chain-roots that gate whole CRUD suites — flag first, they're
1314
+ // the highest-leverage gap (one id un-skips a dependent chain). Cap the
1315
+ // inline list; the full set is in the JSON envelope for agent consumption.
1316
+ if (gapReport.unseededRoots.length > 0) {
1317
+ printWarning(
1318
+ `${gapReport.unseededRoots.length} unseeded chain-root(s): ${capNames(gapReport.unseededRoots.map(r => r.variable))}. ` +
1319
+ `Dependent CRUD suites skip until these are set — supply an id via \`fixtures add\` / .env.yaml ` +
1320
+ `(prepare-fixtures does not auto-seed; use --json for the full list).`,
1321
+ );
1322
+ }
1323
+ // ARV-349: suite vars nothing produces — no env value, capture, or param.
1324
+ if (gapReport.undefinedVars.length > 0) {
1325
+ printWarning(
1326
+ `${gapReport.undefinedVars.length} unresolved suite var(s): ${capNames(gapReport.undefinedVars.map(v => v.variable))}. ` +
1327
+ `Fill them via \`fixtures add\` / .env.yaml (prepare-fixtures does not invent values; use --json for the full list).`,
1328
+ );
1329
+ }
1208
1330
  if (unknownEnvKeys.length > 0) {
1209
1331
  printWarning(
1210
1332
  `${unknownEnvKeys.length} env key(s) not in manifest, ignored: ${unknownEnvKeys.join(", ")}. Drop them from .env.yaml or run \`zond refresh-api\` if the manifest is stale.`,
1211
1333
  );
1212
1334
  }
1213
1335
  if (applied) {
1214
- printSuccess(`Wrote ${writes.length} value(s) to ${envPath}` + (backupPath ? ` (backup: ${backupPath})` : ""));
1215
- } else if (writes.length === 0) {
1216
- if (!options.verify) console.log("Nothing to write (all targets already set or no discoveries succeeded).");
1217
- } else {
1218
- printWarning(`Dry-run: ${writes.length} value(s) ready. Re-run with --apply to write ${envPath}.`);
1336
+ printSuccess(`Unset ${unsets.length} stale fixture(s) in ${envPath}` + (backupPath ? ` (backup: ${backupPath})` : "") + ` — refill by hand / \`fixtures add\`.`);
1337
+ } else if (!options.verify) {
1338
+ console.log("discover reports gaps only — it never writes values (fill .env.yaml by hand or via `fixtures add`).");
1219
1339
  }
1220
1340
  }
1221
1341
  return 0;