@kirrosh/zond 0.26.0 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1142
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,1356 +0,0 @@
1
- /**
2
- * `zond discover` — auto-fill `.env.yaml` from list-endpoints (TASK-136).
3
- *
4
- * Phase 2.5 of the audit flow used to be manual: `zond request GET /audiences`,
5
- * pluck the slug, paste into `.env.yaml`, repeat for every FK. ~15 min per
6
- * API. This command walks the resource map (`.api-resources.yaml`), hits
7
- * every owner-resource list-endpoint with the user's auth token, extracts
8
- * the first id, and proposes a diff. By default dry-run; `--apply` writes
9
- * with a `.env.yaml.bak` backup.
10
- *
11
- * Scope (v1):
12
- * - Only list-endpoints with no path-params (collection-level GETs).
13
- * - Only FK vars whose owner is identified in `.api-resources.yaml`.
14
- * - Skips vars already present in `.env.yaml` unless their value is empty
15
- * or a `# TODO` placeholder.
16
- */
17
- import { join } from "path";
18
- import { copyFile } from "fs/promises";
19
- import {
20
- readOpenApiSpec,
21
- extractEndpoints,
22
- extractSecuritySchemes,
23
- } from "../../core/generator/index.ts";
24
- import { loadEnvFile } from "../../core/parser/variables.ts";
25
- import {
26
- composeSpec,
27
- type ComposedSpec,
28
- type SpecLayer,
29
- } from "../../core/spec/layers.ts";
30
- import { liveAuthHeaders } from "../../core/probe/shared.ts";
31
- import { executeRequest } from "../../core/runner/http-client.ts";
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";
35
-
36
- /** Strip a trailing FK-shape suffix (`_id`, `Id`, `_uuid`, `_slug`, `_name`,
37
- * `_code`) from a var name and return the stem. Used by ARV-69 to find an
38
- * owner resource when the resource map doesn't link the var to a list
39
- * endpoint explicitly (common-style {id} placeholders).
40
- */
41
- function stemFromVarName(varName: string): string | null {
42
- const lower = varName.toLowerCase();
43
- for (const suffix of ["_id", "_uuid", "_slug", "_name", "_code"]) {
44
- if (lower.endsWith(suffix)) return lower.slice(0, -suffix.length);
45
- }
46
- // CamelCase: `domainId` → `domain`.
47
- const m = varName.match(/^(.+?)(Id|Uuid)$/);
48
- if (m) return m[1]!.toLowerCase();
49
- return null;
50
- }
51
-
52
- /** ARV-69 (feedback round-02 / F10): try to find a resource whose
53
- * list endpoint is a plausible source for `varName` based on the var's
54
- * name stem. Matches singular ↔ plural and is case-insensitive. Returns
55
- * the FkTarget on hit, undefined on miss.
56
- */
57
- export function inferOwnerFromVarName(
58
- varName: string,
59
- map: ApiResourceMapYaml,
60
- ): FkTarget | undefined {
61
- const stem = stemFromVarName(varName);
62
- if (!stem) return undefined;
63
- const candidates = new Set([stem, `${stem}s`, stem.endsWith("s") ? stem.slice(0, -1) : stem]);
64
- for (const r of map.resources) {
65
- if (!r.endpoints?.list) continue;
66
- const lower = r.resource.toLowerCase();
67
- if (candidates.has(lower)) {
68
- return { varName, ownerResource: r.resource, listLabel: r.endpoints.list };
69
- }
70
- }
71
- return undefined;
72
- }
73
-
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);
84
- if (body && typeof body === "object") {
85
- const obj = body as Record<string, unknown>;
86
- for (const key of ["data", "items", "results", "records"]) {
87
- const hit = pick(obj[key]);
88
- if (hit) return hit;
89
- }
90
- }
91
- return undefined;
92
- }
93
-
94
- /** True when the list-response is well-shaped but contains zero items.
95
- * Used to distinguish "no <entity> in target API yet — go create one"
96
- * from "response shape unrecognized" (TASK-273). */
97
- export function isEmptyListBody(body: unknown): boolean {
98
- if (Array.isArray(body)) return body.length === 0;
99
- if (body && typeof body === "object") {
100
- const obj = body as Record<string, unknown>;
101
- for (const key of ["data", "items", "results", "records"]) {
102
- if (key in obj) {
103
- const arr = obj[key];
104
- return Array.isArray(arr) && arr.length === 0;
105
- }
106
- }
107
- }
108
- return false;
109
- }
110
- import { printError, printSuccess, printWarning } from "../output.ts";
111
- import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
112
- import { getSecretRegistry } from "../../core/secrets/registry.ts";
113
- import type { EndpointInfo, SecuritySchemeInfo } from "../../core/generator/types.ts";
114
- import type { RecommendedAction } from "../../core/diagnostics/failure-hints.ts";
115
-
116
- export interface DiscoverOptions {
117
- specPath: string;
118
- /** Path to `apis/<name>/` — used to read .api-resources.yaml and write .env.yaml. */
119
- apiDir: string;
120
- /** Default `apis/<name>/.env.yaml`. */
121
- envPath?: string;
122
- apply?: boolean;
123
- /** Per-request timeout (ms). */
124
- timeoutMs?: number;
125
- json?: boolean;
126
- /** TASK-281: GET each fixture's read-by-id endpoint to classify live/stale/
127
- * unknown. Without `--apply` this is a read-only report; with `--apply` (or
128
- * the `--refresh` shortcut) stale fixtures are unset and re-resolved
129
- * through the normal discover flow. */
130
- verify?: boolean;
131
- /** ARV-205/F19 (R10/R13/R14): command name surfaced in the JSON envelope.
132
- * prepare-fixtures delegates here for the single-pass path; the envelope
133
- * should reflect the user-facing command, not the internal "discover". */
134
- commandName?: string;
135
- }
136
-
137
- export interface FkTarget {
138
- /** Env var name to fill (e.g. `audience_id`). */
139
- varName: string;
140
- /** Resource that owns the id (e.g. `audiences`). */
141
- ownerResource: string;
142
- /** List endpoint label, e.g. `GET /audiences`. */
143
- listLabel: string;
144
- }
145
-
146
- export interface DiscoveryItem {
147
- varName: string;
148
- resource: string;
149
- listPath: string;
150
- /** What was found, if anything. */
151
- discovered?: string;
152
- /** What's currently in env (may be empty/placeholder). */
153
- current?: string;
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. */
158
- status:
159
- | "skip-already-set"
160
- | "skip-not-required"
161
- | "miss-no-list"
162
- | "miss-nested-list"
163
- | "miss-no-owner"
164
- | "miss-network"
165
- | "miss-status"
166
- | "miss-empty"
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"
171
- // TASK-281 verify-mode states
172
- | "verify-live"
173
- | "verify-stale"
174
- | "verify-unknown"
175
- | "verify-no-read"
176
- | "verify-skip-empty"
177
- // ARV-143: filled var classified as trusted user input — manifest source
178
- // is user-config (auth/server/header) or there's no read-by-id endpoint
179
- // for its resource. Refresh has no verification path, so we mark it as
180
- // such instead of silently omitting (the doctor view that says set:true).
181
- | "verify-user-config";
182
- /** ARV-46: manifest-grade status enum projected onto agent-readable
183
- * envelope. Filled when discover ran in manifest-driven mode.
184
- * filled | failed:no-list-endpoint | failed:list-empty | failed:miss-network
185
- * | skipped:already-set | skipped:not-required */
186
- manifestStatus?: ManifestStatus;
187
- /** ARV-46: source classification copied from `.api-fixtures.yaml`. */
188
- manifestSource?: FixtureManifestEntry["source"];
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[];
197
- /** TASK-294: agent-routable action for items the user must fix.
198
- * miss-* / verify-stale / verify-unknown → `fix_fixture`.
199
- * miss-network → `fix_network_config`.
200
- * skip-* / verify-live → undefined. */
201
- recommended_action?: RecommendedAction;
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. */
204
- wasStale?: boolean;
205
- }
206
-
207
- /** TASK-294: derive recommended_action from a DiscoveryItem's status. */
208
- export function discoveryAction(status: DiscoveryItem["status"]): RecommendedAction | undefined {
209
- if (status === "miss-network") return "fix_network_config";
210
- if (status.startsWith("miss-") || status === "verify-stale" || status === "verify-unknown") {
211
- return "fix_fixture";
212
- }
213
- return undefined;
214
- }
215
-
216
- /** ARV-46: stable manifest-grade status enum for agent consumers. The CLI
217
- * prints this column when discover runs in manifest-driven mode and it's
218
- * exposed verbatim in the JSON envelope. */
219
- export type ManifestStatus =
220
- | "failed:no-list-endpoint"
221
- | "failed:list-empty"
222
- | "failed:needs-value"
223
- | "failed:miss-network"
224
- | "skipped:already-set"
225
- | "skipped:not-required";
226
-
227
- export function toManifestStatus(status: DiscoveryItem["status"]): ManifestStatus {
228
- switch (status) {
229
- case "skip-already-set":
230
- return "skipped:already-set";
231
- case "skip-not-required":
232
- return "skipped:not-required";
233
- case "miss-network":
234
- return "failed:miss-network";
235
- case "miss-empty":
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";
240
- // miss-no-list / miss-nested-list / miss-no-owner / miss-status / miss-no-id —
241
- // the underlying cause is "we have no usable list endpoint to read from", so
242
- // they collapse onto the same manifest-level bucket.
243
- default:
244
- return "failed:no-list-endpoint";
245
- }
246
- }
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
- export function isPlaceholder(value: string | undefined): boolean {
277
- if (!value) return true;
278
- const trimmed = value.trim();
279
- if (trimmed === "") return true;
280
- // `var: "" # TODO: fill in` lands as "" after YAML parse.
281
- if (/^TODO/i.test(trimmed)) return true;
282
- return false;
283
- }
284
-
285
- function parseEndpointLabel(label: string): { method: string; path: string } | null {
286
- const parts = label.trim().split(/\s+/);
287
- if (parts.length < 2) return null;
288
- return { method: parts[0]!.toUpperCase(), path: parts[1]! };
289
- }
290
-
291
- export interface ResourceYaml {
292
- resource: string;
293
- basePath: string;
294
- itemPath: string;
295
- idParam: string;
296
- captureField?: string;
297
- hasFullCrud?: boolean;
298
- endpoints: { list?: string; create?: string; read?: string; update?: string; delete?: string };
299
- fkDependencies: Array<{ var: string; param: string; in: "path" | "body"; ownerResource: string | null }>;
300
- /** ARV-169: optional POST→GET drift overrides. snake_case to match
301
- * yaml on disk; loaders preserve as-is so the check can read it. */
302
- readback_diff?: {
303
- ignore_fields?: string[];
304
- write_to_read_map?: Record<string, string>;
305
- };
306
- /** ARV-170: opt-in idempotency-replay probe for this resource's
307
- * create endpoint. */
308
- idempotency?: {
309
- header?: string;
310
- scope?: "endpoint" | "global";
311
- ignore_response_fields?: string[];
312
- };
313
- /** ARV-171: pagination-invariants probe for this resource's list
314
- * endpoint. */
315
- pagination?: {
316
- type?: "cursor" | "page" | "offset" | "token";
317
- cursor_param?: string;
318
- cursor_field?: string;
319
- has_more_field?: string;
320
- limit_param?: string;
321
- default_limit?: number;
322
- items_field?: string;
323
- page_param?: string;
324
- start_page?: number;
325
- };
326
- /** ARV-172: per-resource state machine + action endpoints. */
327
- lifecycle?: {
328
- field: string;
329
- states: string[];
330
- transitions: Array<{ from: string; to: string[] }>;
331
- actions: Record<string, {
332
- endpoint: string;
333
- expected_state: string;
334
- body?: Record<string, unknown>;
335
- }>;
336
- };
337
- /** ARV-187: LLM-authored example POST body for stateful checks that
338
- * need a valid create payload. When present, stateful CRUD checks
339
- * (cross_call_references, idempotency_replay, lifecycle_transitions,
340
- * ensure_resource_availability, use_after_free) prefer this over
341
- * `generateFromSchema(create.requestBodySchema)`. The fallback path
342
- * stays — yaml is purely additive. `content_type` defaults to the
343
- * create endpoint's `requestBodyContentType`. */
344
- seed_body?: {
345
- content_type?: string;
346
- body: Record<string, unknown>;
347
- };
348
- }
349
-
350
- export interface ApiResourceMapYaml {
351
- resources: ResourceYaml[];
352
- }
353
-
354
- /** ARV-122 layer ids — exported so downstream code (doctor, future
355
- * catalog --provenance) can compare against the provenance map
356
- * without re-typing the strings. */
357
- export const RESOURCE_LAYER_UPSTREAM = "upstream";
358
- export const RESOURCE_LAYER_EXTENSION = "extension";
359
-
360
- /** ARV-122: build the two-layer SpecLayer set for an API's resource
361
- * map. Kept here (and not in `core/spec/layers.ts`) so the YAML
362
- * loaders stay co-located with the schema types they parse. */
363
- function buildResourceLayers(apiDir: string): SpecLayer<ResourceYaml>[] {
364
- return [
365
- {
366
- id: RESOURCE_LAYER_UPSTREAM,
367
- path: join(apiDir, ".api-resources.yaml"),
368
- precedence: 10,
369
- scope: "resources",
370
- mergePolicy: "override",
371
- load: async () => {
372
- const file = Bun.file(join(apiDir, ".api-resources.yaml"));
373
- if (!(await file.exists())) return [];
374
- const parsed = Bun.YAML.parse(await file.text());
375
- if (!parsed || typeof parsed !== "object") return [];
376
- return (parsed as { resources?: ResourceYaml[] }).resources ?? [];
377
- },
378
- },
379
- {
380
- id: RESOURCE_LAYER_EXTENSION,
381
- path: join(apiDir, ".api-resources.local.yaml"),
382
- precedence: 20,
383
- scope: "resources",
384
- mergePolicy: "override",
385
- load: () => readResourceExtensions(apiDir),
386
- },
387
- ];
388
- }
389
-
390
- /** ARV-122: compose the resource map through the SpecLayer pipeline,
391
- * exposing the provenance map for callers that need to know which
392
- * layer contributed a given resource (doctor diagnostics, m-18 CLI
393
- * surface). `readResourceMap` keeps the legacy shape for callers
394
- * that don't care. */
395
- export async function composeResourceMap(
396
- apiDir: string,
397
- ): Promise<ComposedSpec<ResourceYaml>> {
398
- return composeSpec(buildResourceLayers(apiDir), (r) => r.resource);
399
- }
400
-
401
- export async function readResourceMap(apiDir: string): Promise<ApiResourceMapYaml | null> {
402
- // Old contract: return null when the upstream `.api-resources.yaml`
403
- // is missing (callers branch on this to surface a setup error). The
404
- // SpecLayer pipeline returns an empty list in that case, so check
405
- // existence explicitly to preserve behaviour.
406
- const upstream = Bun.file(join(apiDir, ".api-resources.yaml"));
407
- if (!(await upstream.exists())) return null;
408
-
409
- // ARV-122: route the merge through composeSpec. Behaviour is
410
- // identical to the previous ad-hoc Map merge — extension wins on
411
- // name collision (precedence 20 > 10, mergePolicy: "override") —
412
- // and the same path also feeds provenance into composeResourceMap.
413
- const composed = await composeResourceMap(apiDir);
414
- // ARV-169: field-level overlay for adding readback_diff / idempotency
415
- // / pagination / lifecycle without re-declaring the whole entry.
416
- const patches = await readResourcePatches(apiDir);
417
- return { resources: applyResourcePatches(composed.entries, patches) };
418
- }
419
-
420
- /** ARV-111: read `apis/<name>/.api-resources.local.yaml`. Same `resources:`
421
- * shape as the main file (top-level `extensions:` key is the only
422
- * difference, so the user can recognise it as a sibling). Returns [] when
423
- * missing or empty so the merge path stays simple. */
424
- export async function readResourceExtensions(apiDir: string): Promise<ResourceYaml[]> {
425
- const path = join(apiDir, ".api-resources.local.yaml");
426
- const file = Bun.file(path);
427
- if (!(await file.exists())) return [];
428
- const text = await file.text();
429
- const parsed = Bun.YAML.parse(text);
430
- if (!parsed || typeof parsed !== "object") return [];
431
- const obj = parsed as { extensions?: ResourceYaml[] };
432
- return obj.extensions ?? [];
433
- }
434
-
435
- /** ARV-169 (m-20): partial overlay for adding fields (readback_diff,
436
- * future idempotency / pagination / lifecycle) to an existing
437
- * resource entry without re-declaring its CRUD wiring. Lives in the
438
- * same `.api-resources.local.yaml` under top-level `patches:`. Each
439
- * entry MUST carry `resource:` (the merge key); any other declared
440
- * field overlays the upstream value, leaving omitted fields intact.
441
- *
442
- * Unlike `extensions:` (full replacement, ARV-111) this is field-
443
- * level merge. Both can coexist in the same file. Returns [] when
444
- * the file is missing or carries no `patches:` key. */
445
- export async function readResourcePatches(apiDir: string): Promise<Array<Partial<ResourceYaml> & { resource: string }>> {
446
- const path = join(apiDir, ".api-resources.local.yaml");
447
- const file = Bun.file(path);
448
- if (!(await file.exists())) return [];
449
- const text = await file.text();
450
- const parsed = Bun.YAML.parse(text);
451
- if (!parsed || typeof parsed !== "object") return [];
452
- const obj = parsed as { patches?: Array<Partial<ResourceYaml> & { resource?: string }> };
453
- const raw = obj.patches ?? [];
454
- return raw.filter((p): p is Partial<ResourceYaml> & { resource: string } =>
455
- typeof p?.resource === "string" && p.resource.length > 0,
456
- );
457
- }
458
-
459
- /** ARV-169: apply partial patches over a composed resource list.
460
- * Patch fields overwrite matching upstream fields; absent fields
461
- * are preserved. Patches whose `resource` doesn't match anything
462
- * upstream are dropped silently — callers wanting to ADD a whole
463
- * resource use `extensions:` instead. */
464
- function applyResourcePatches(
465
- resources: ResourceYaml[],
466
- patches: Array<Partial<ResourceYaml> & { resource: string }>,
467
- ): ResourceYaml[] {
468
- if (patches.length === 0) return resources;
469
- const byName = new Map(resources.map((r) => [r.resource, r] as const));
470
- for (const p of patches) {
471
- const upstream = byName.get(p.resource);
472
- if (!upstream) continue;
473
- byName.set(p.resource, { ...upstream, ...p });
474
- }
475
- return resources.map((r) => byName.get(r.resource) ?? r);
476
- }
477
-
478
- export interface FixtureManifestEntry {
479
- name: string;
480
- source: "auth" | "server" | "path" | "header" | "body-fk" | "capture-chain";
481
- required: boolean;
482
- description?: string;
483
- defaultValue?: string;
484
- affectedEndpoints?: string[];
485
- }
486
-
487
- export interface FixtureManifestYaml {
488
- fixtures: FixtureManifestEntry[];
489
- }
490
-
491
- /** Read `.api-fixtures.yaml`. Returns null when missing — caller falls back
492
- * to the legacy resource-map-driven path. */
493
- export async function readFixtureManifest(apiDir: string): Promise<FixtureManifestYaml | null> {
494
- const path = join(apiDir, ".api-fixtures.yaml");
495
- const file = Bun.file(path);
496
- if (!(await file.exists())) return null;
497
- const text = await file.text();
498
- const parsed = Bun.YAML.parse(text);
499
- if (!parsed || typeof parsed !== "object") return null;
500
- const obj = parsed as { fixtures?: FixtureManifestEntry[] };
501
- return { fixtures: obj.fixtures ?? [] };
502
- }
503
-
504
- /** Build the unique target list from FK deps. Each FK var = one discovery
505
- * attempt (we hit the owner's list endpoint once and reuse the result).
506
- *
507
- * ARV-133: also include each resource's own idParam (when it has a list
508
- * endpoint) — these are root-level required path-params with no fkDep edge
509
- * to another resource, but they're trivially harvestable from the resource's
510
- * own list endpoint. Without this, cascade silently skipped vars like
511
- * `domain_id`, `webhook_id`, `template_id` even though `/domains`,
512
- * `/webhooks`, `/templates` returned live data. Optional `manifest`
513
- * parameter wires manifest-required path/body-fk vars onto a list endpoint
514
- * via `inferOwnerFromVarName` (singular ↔ plural matching) so vars whose
515
- * name doesn't appear in the resource map's idParam table still get
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
-
568
- export function collectTargets(
569
- map: ApiResourceMapYaml,
570
- manifest?: FixtureManifestYaml,
571
- ): FkTarget[] {
572
- const seen = new Set<string>();
573
- const out: FkTarget[] = [];
574
- const push = (t: FkTarget): void => {
575
- if (seen.has(t.varName)) return;
576
- seen.add(t.varName);
577
- out.push(t);
578
- };
579
-
580
- // 1. fkDeps — parent-id edges declared by resource-builder.
581
- for (const r of map.resources) {
582
- for (const dep of r.fkDependencies ?? []) {
583
- if (dep.in !== "path") continue;
584
- if (!dep.ownerResource) continue;
585
- const owner = map.resources.find(x => x.resource === dep.ownerResource);
586
- const listLabel = owner?.endpoints.list ?? "";
587
- push({ varName: dep.var, ownerResource: dep.ownerResource, listLabel });
588
- }
589
- }
590
-
591
- // 2. Each resource's own idParam → its own list endpoint. resource-builder's
592
- // collectPathFkDeps skips this case (it emits only *parent* FKs), so
593
- // without an explicit pass `domain_id`/`webhook_id`/etc. drop out of
594
- // cascade entirely.
595
- for (const r of map.resources) {
596
- if (!r.idParam) continue;
597
- if (!r.endpoints?.list) continue;
598
- push({ varName: r.idParam, ownerResource: r.resource, listLabel: r.endpoints.list });
599
- }
600
-
601
- // 3. Manifest-required vars (path / body-fk) whose name doesn't match any
602
- // fkDep edge or resource idParam. Use singular↔plural stemming to find
603
- // an owner — same logic as the discover-via-manifest path uses (ARV-69).
604
- if (manifest) {
605
- for (const entry of manifest.fixtures) {
606
- if (!entry.required) continue;
607
- if (entry.source !== "path" && entry.source !== "body-fk") continue;
608
- if (seen.has(entry.name)) continue;
609
- const inferred = inferOwnerFromVarName(entry.name, map);
610
- if (inferred) push(inferred);
611
- }
612
- }
613
-
614
- return out;
615
- }
616
-
617
- export async function probeOne(
618
- target: FkTarget,
619
- current: string | undefined,
620
- endpoints: EndpointInfo[],
621
- schemes: SecuritySchemeInfo[],
622
- vars: Record<string, string>,
623
- baseUrl: string,
624
- timeoutMs: number,
625
- ): Promise<DiscoveryItem> {
626
- const item: DiscoveryItem = {
627
- varName: target.varName,
628
- resource: target.ownerResource,
629
- listPath: "",
630
- current,
631
- status: "miss-no-list",
632
- };
633
- if (!target.listLabel) {
634
- item.status = "miss-no-list";
635
- item.reason = `resource "${target.ownerResource}" has no list endpoint in .api-resources.yaml`;
636
- return item;
637
- }
638
- const parsed = parseEndpointLabel(target.listLabel);
639
- if (!parsed) {
640
- item.status = "miss-no-list";
641
- item.reason = `cannot parse endpoint label "${target.listLabel}"`;
642
- return item;
643
- }
644
- if (parsed.method !== "GET") {
645
- item.status = "miss-no-list";
646
- item.reason = `expected GET for list of ${target.ownerResource}, got ${parsed.method}`;
647
- return item;
648
- }
649
- // For nested list paths (e.g. /orgs/{org}/projects/), substitute any
650
- // parent path-params that are already known in vars. If all params resolve,
651
- // proceed as a normal list call. Only bail if a param is still missing.
652
- let effectivePath = parsed.path;
653
- if (parsed.path.includes("{")) {
654
- effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
655
- const val = vars[name];
656
- return typeof val === "string" && val ? val : `{${name}}`;
657
- });
658
- if (effectivePath.includes("{")) {
659
- item.status = "miss-nested-list";
660
- item.reason = `nested collection (${parsed.path}) — missing parent fixture(s) in .env.yaml`;
661
- return item;
662
- }
663
- }
664
- item.listPath = effectivePath;
665
-
666
- // Already filled and not a placeholder → skip the call (live API, save it).
667
- if (!isPlaceholder(current)) {
668
- item.status = "skip-already-set";
669
- return item;
670
- }
671
-
672
- const listEp = endpoints.find(
673
- e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
674
- );
675
- if (!listEp) {
676
- item.status = "miss-no-list";
677
- item.reason = `${parsed.path} not found in spec endpoints (resource map drift?)`;
678
- return item;
679
- }
680
-
681
- const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
682
- const headers: Record<string, string> = {
683
- accept: "application/json",
684
- ...liveAuthHeaders(listEp, schemes, vars),
685
- };
686
-
687
- let resp;
688
- try {
689
- // ARV-48: 1 network-class retry with exp+jitter backoff. Transient
690
- // DNS/connection-reset blips on shared CI runners must not cost the
691
- // user a whole prepare-fixtures rerun. Only network errors retry —
692
- // 4xx/5xx HTTP statuses keep their existing branches (miss-status).
693
- resp = await executeRequest(
694
- { method: "GET", url, headers },
695
- { timeout: timeoutMs, retries: 0, network_retries: 1 },
696
- );
697
- } catch (err) {
698
- item.status = "miss-network";
699
- item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
700
- return item;
701
- }
702
- if (resp.status < 200 || resp.status >= 300) {
703
- item.status = "miss-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}`;
722
- return item;
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.
727
- const respBody = resp.body_parsed ?? resp.body;
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>\``;
736
- return item;
737
- }
738
- if (!firstListItem(respBody)) {
739
- item.status = "miss-no-id";
740
- item.reason = `response shape unrecognized (no array/data/items/results/records field)`;
741
- return item;
742
- }
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>\``;
750
- return item;
751
- }
752
-
753
- /** TASK-281: GET <ownerResource>'s read-by-id endpoint with the current
754
- * fixture value and classify the result. 5xx is treated as `unknown` (don't
755
- * trash valid fixtures over a flaky API). */
756
- export async function verifyOne(
757
- target: FkTarget,
758
- current: string | undefined,
759
- ownerResource: ResourceYaml | undefined,
760
- endpoints: EndpointInfo[],
761
- schemes: SecuritySchemeInfo[],
762
- vars: Record<string, string>,
763
- baseUrl: string,
764
- timeoutMs: number,
765
- ): Promise<DiscoveryItem> {
766
- const item: DiscoveryItem = {
767
- varName: target.varName,
768
- resource: target.ownerResource,
769
- listPath: "",
770
- current,
771
- status: "verify-unknown",
772
- };
773
- if (isPlaceholder(current)) {
774
- item.status = "verify-skip-empty";
775
- item.reason = "fixture is empty/placeholder — nothing to verify";
776
- return item;
777
- }
778
- if (!ownerResource?.endpoints?.read) {
779
- item.status = "verify-no-read";
780
- item.reason = `resource "${target.ownerResource}" has no read-by-id endpoint in .api-resources.yaml`;
781
- return item;
782
- }
783
- const parsed = parseEndpointLabel(ownerResource.endpoints.read);
784
- if (!parsed) {
785
- item.status = "verify-no-read";
786
- item.reason = `cannot parse read endpoint label "${ownerResource.endpoints.read}"`;
787
- return item;
788
- }
789
- // Substitute parent path-params from env vars; the resource's own idParam is
790
- // taken from `current` (we are verifying that very value).
791
- const idParam = ownerResource.idParam;
792
- let effectivePath = parsed.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
793
- if (name === idParam) return current!;
794
- const val = vars[name];
795
- return typeof val === "string" && val ? val : `{${name}}`;
796
- });
797
- if (effectivePath.includes("{")) {
798
- item.status = "verify-unknown";
799
- item.reason = `cannot resolve parent path-params for ${parsed.path}`;
800
- return item;
801
- }
802
- item.listPath = effectivePath;
803
-
804
- const ep = endpoints.find(
805
- e => e.method.toUpperCase() === "GET" && e.path === parsed.path && !e.deprecated,
806
- );
807
- const url = `${baseUrl.replace(/\/+$/, "")}${effectivePath}`;
808
- const headers: Record<string, string> = {
809
- accept: "application/json",
810
- ...(ep ? liveAuthHeaders(ep, schemes, vars) : {}),
811
- };
812
- let resp;
813
- try {
814
- // ARV-48: same single network-class retry as the discover probe.
815
- resp = await executeRequest({ method: "GET", url, headers }, { timeout: timeoutMs, retries: 0, network_retries: 1 });
816
- } catch (err) {
817
- item.status = "verify-unknown";
818
- item.reason = `network error: ${err instanceof Error ? err.message : String(err)}`;
819
- return item;
820
- }
821
- if (resp.status >= 200 && resp.status < 300) {
822
- item.status = "verify-live";
823
- item.discovered = current;
824
- return item;
825
- }
826
- if (resp.status === 404 || resp.status === 410) {
827
- item.status = "verify-stale";
828
- item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
829
- return item;
830
- }
831
- // 401/403 — token/scope issue, not a stale id; 5xx — flake; treat both as
832
- // unknown so we never delete a fixture on shaky evidence.
833
- item.status = "verify-unknown";
834
- item.reason = `${parsed.method} ${effectivePath} → ${resp.status}`;
835
- return item;
836
- }
837
-
838
- /** Append-or-update a key in YAML text. Conservative: matches `<key>:` at
839
- * the start of a line and rewrites the value, preserving trailing comments
840
- * that documented original placeholders. */
841
- export function upsertEnvLine(yamlText: string, key: string, value: string): string {
842
- const lines = yamlText.split("\n");
843
- const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
844
- const idx = lines.findIndex(l => re.test(l));
845
- const newLine = `${key}: ${JSON.stringify(value)}`;
846
- if (idx === -1) {
847
- // Insert before final newline if file ends with one, otherwise append.
848
- if (lines[lines.length - 1] === "") {
849
- lines.splice(lines.length - 1, 0, newLine);
850
- } else {
851
- lines.push(newLine);
852
- }
853
- } else {
854
- lines[idx] = newLine;
855
- }
856
- return lines.join("\n");
857
- }
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
-
866
- export async function discoverCommand(options: DiscoverOptions): Promise<number> {
867
- const commandName = options.commandName ?? "discover";
868
- try {
869
- const doc = await readOpenApiSpec(options.specPath);
870
- const endpoints = extractEndpoints(doc);
871
- const securitySchemes = extractSecuritySchemes(doc);
872
-
873
- const resourceMap = await readResourceMap(options.apiDir);
874
- if (!resourceMap || resourceMap.resources.length === 0) {
875
- const msg = `No .api-resources.yaml in ${options.apiDir}. Run 'zond refresh-api <name>' to (re)build it.`;
876
- if (options.json) printJson(jsonError(commandName, [msg]));
877
- else printError(msg);
878
- return 2;
879
- }
880
-
881
- const envPath = options.envPath ?? join(options.apiDir, ".env.yaml");
882
- const env = (await loadEnvFile(envPath)) ?? {};
883
- // ARV-143 follow-up (security regression fix): register every loaded var
884
- // with the SecretRegistry so the user-config bucket (and any other path
885
- // that incidentally echoes a value) can't leak `.secrets.yaml`-resolved
886
- // tokens to stdout / scrollback / tee. base_url is filtered out because
887
- // we have to print it verbatim in the discovery header.
888
- {
889
- const reg = getSecretRegistry();
890
- for (const [k, v] of Object.entries(env)) {
891
- if (k === "base_url") continue;
892
- reg.register(k, v);
893
- }
894
- }
895
- const baseUrl = env["base_url"];
896
- if (!baseUrl) {
897
- const msg = `base_url is required in ${envPath} (live API calls need it).`;
898
- if (options.json) printJson(jsonError(commandName, [msg]));
899
- else printError(msg);
900
- return 2;
901
- }
902
-
903
- // ARV-46: manifest is the source-of-truth for the *list* of variables
904
- // this API needs (per decision-7). When `.api-fixtures.yaml` exists,
905
- // discover iterates it instead of `.env.yaml` keys / FK deps directly,
906
- // so vars present in tests but absent from FK deps still show up in the
907
- // status table — and env keys without a manifest entry surface as a
908
- // warning instead of being silently ignored.
909
- const manifest = await readFixtureManifest(options.apiDir);
910
-
911
- const targets = collectTargets(resourceMap);
912
- if (targets.length === 0 && !manifest) {
913
- if (options.json) {
914
- printJson(jsonOk(commandName, { items: [], message: "No path-FK dependencies with known owner resources." }));
915
- } else {
916
- console.log("No path-FK dependencies with known owner resources — nothing to discover.");
917
- }
918
- return 0;
919
- }
920
- // Index targets by var name so manifest entries can resolve their owner
921
- // resource via the FK chain (manifest knows *what*, resource map knows
922
- // *where to fetch*).
923
- const targetsByVar = new Map<string, FkTarget>();
924
- for (const t of targets) targetsByVar.set(t.varName, t);
925
- // Resource map's `collectPathFkDeps` skips the resource's own idParam —
926
- // it only emits *parent* FKs. The manifest legitimately wants discover
927
- // to fill `api_key_id` (idParam of /api-keys/{api_key_id}) from the list
928
- // endpoint /api-keys, so wire each resource's own idParam onto its list
929
- // endpoint here. This is what makes "discover walks the manifest" not
930
- // collapse 80% of entries into failed:no-list-endpoint.
931
- for (const r of resourceMap.resources) {
932
- if (!r.idParam || !r.endpoints?.list) continue;
933
- if (targetsByVar.has(r.idParam)) continue;
934
- targetsByVar.set(r.idParam, {
935
- varName: r.idParam,
936
- ownerResource: r.resource,
937
- listLabel: r.endpoints.list,
938
- });
939
- }
940
-
941
- // TASK-281: --verify mode — GET the read-by-id endpoint for every fixture
942
- // and classify (live / stale / unknown). Without --apply this is purely
943
- // diagnostic; with --apply we unset stale entries and re-resolve them via
944
- // the regular discover flow below.
945
- const items: DiscoveryItem[] = [];
946
- if (options.verify) {
947
- for (const target of targets) {
948
- const owner = resourceMap.resources.find(r => r.resource === target.ownerResource);
949
- const item = await verifyOne(
950
- target,
951
- env[target.varName],
952
- owner,
953
- endpoints,
954
- securitySchemes,
955
- env,
956
- baseUrl,
957
- options.timeoutMs ?? 30000,
958
- );
959
- items.push(item);
960
- }
961
-
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.
966
- if (options.apply) {
967
- for (const item of items) {
968
- if (item.status === "verify-stale") {
969
- delete env[item.varName];
970
- item.wasStale = true;
971
- }
972
- }
973
- }
974
-
975
- // ARV-143: surface filled vars that verify can't validate so the user
976
- // doesn't think they're missing. Two buckets:
977
- // 1. manifest user-config sources (auth / server / header) — never
978
- // had a read endpoint, refresh just trusts the value.
979
- // 2. targets whose verifyOne returned verify-no-read (resource exists
980
- // but `.api-resources.yaml` has no read endpoint) — same story.
981
- // Without this, refresh emitted "0 stale" + silence on these vars,
982
- // contradicting doctor's set:true reporting (feedback-02 F12).
983
- if (manifest) {
984
- const seen = new Set(items.map(i => i.varName));
985
- for (const entry of manifest.fixtures) {
986
- if (seen.has(entry.name)) continue;
987
- const current = env[entry.name];
988
- if (!current || isPlaceholder(current)) continue;
989
- const isUserConfig =
990
- entry.source === "auth" ||
991
- entry.source === "server" ||
992
- entry.source === "header";
993
- if (!isUserConfig) continue;
994
- items.push({
995
- varName: entry.name,
996
- resource: "",
997
- listPath: "",
998
- current,
999
- status: "verify-user-config",
1000
- manifestSource: entry.source,
1001
- reason: `${entry.source} var — no verification path, value trusted from .env.yaml`,
1002
- });
1003
- }
1004
- // Promote verify-no-read items with a filled value to the same bucket
1005
- // so they show up under "trusted user input" in the summary instead of
1006
- // being lumped with empty/skip items.
1007
- for (const item of items) {
1008
- if (item.status === "verify-no-read" && item.current && !isPlaceholder(item.current)) {
1009
- item.status = "verify-user-config";
1010
- item.reason = `no read-by-id endpoint in .api-resources.yaml — value trusted from .env.yaml`;
1011
- }
1012
- }
1013
- }
1014
- } else if (manifest) {
1015
- // ARV-46: drive the loop by manifest entries (one row per entry).
1016
- // Each entry's status maps onto the manifest-grade enum so agents
1017
- // get a stable contract independent of the underlying probe shape.
1018
- for (const entry of manifest.fixtures) {
1019
- const current = env[entry.name];
1020
- const placeholder: DiscoveryItem = {
1021
- varName: entry.name,
1022
- resource: "",
1023
- listPath: "",
1024
- current,
1025
- status: "skip-not-required",
1026
- manifestSource: entry.source,
1027
- };
1028
-
1029
- // Sources that discover does not own: the user fills these (auth/
1030
- // server/header) or the runtime captures them (capture-chain).
1031
- // required:false manifest entries (currently capture-chain) are also
1032
- // not the discover loop's responsibility.
1033
- const isOwnedByDiscover =
1034
- entry.required && (entry.source === "path" || entry.source === "body-fk");
1035
- if (!isOwnedByDiscover) {
1036
- placeholder.status = "skip-not-required";
1037
- placeholder.manifestStatus = "skipped:not-required";
1038
- items.push(placeholder);
1039
- continue;
1040
- }
1041
-
1042
- // Already filled (and not a TODO placeholder) — leave it alone.
1043
- if (!isPlaceholder(current)) {
1044
- placeholder.status = "skip-already-set";
1045
- placeholder.manifestStatus = "skipped:already-set";
1046
- items.push(placeholder);
1047
- continue;
1048
- }
1049
-
1050
- // Resolve owner resource via FK chain. body-fk vars often share the
1051
- // name with a path-param of another resource (audience_id ↔
1052
- // /audiences/{id}); resource map's collectBodyFkDeps already does
1053
- // name-stemming inference for us. A miss here means we have nothing
1054
- // to GET — the entry stays in the table as failed:no-list-endpoint.
1055
- let target = targetsByVar.get(entry.name);
1056
- if (!target) {
1057
- // ARV-69 (feedback round-02 / F10): the resource map only links a
1058
- // var to a list endpoint when the path explicitly carries it as a
1059
- // path-param (e.g. /audiences/{audience_id}). common-style APIs
1060
- // commonly use the generic {id} placeholder, so vars like
1061
- // `domain_id` / `segment_id` / `log_id` end up with no fkDep edge
1062
- // even though /domains, /segments, /logs are perfectly usable as
1063
- // list endpoints. Try a name-stemming fallback: strip the FK
1064
- // suffix and match a resource whose name is the singular or plural
1065
- // form.
1066
- const inferred = inferOwnerFromVarName(entry.name, resourceMap);
1067
- if (inferred) target = inferred;
1068
- }
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);
1073
- placeholder.status = "miss-no-list";
1074
- placeholder.manifestStatus = "failed:no-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
- }
1081
- items.push(placeholder);
1082
- continue;
1083
- }
1084
-
1085
- const item = await probeOne(
1086
- target,
1087
- current,
1088
- endpoints,
1089
- securitySchemes,
1090
- env,
1091
- baseUrl,
1092
- options.timeoutMs ?? 30000,
1093
- );
1094
- item.manifestSource = entry.source;
1095
- item.manifestStatus = toManifestStatus(item.status);
1096
- items.push(item);
1097
- }
1098
- } else {
1099
- // Legacy path: no manifest in the workspace — probe FK targets directly.
1100
- for (const target of targets) {
1101
- const current = env[target.varName];
1102
- const item = await probeOne(
1103
- target,
1104
- current,
1105
- endpoints,
1106
- securitySchemes,
1107
- env,
1108
- baseUrl,
1109
- options.timeoutMs ?? 30000,
1110
- );
1111
- items.push(item);
1112
- }
1113
- }
1114
-
1115
- // TASK-294: stamp every item with recommended_action before consumers
1116
- // (--json envelope, summary printer) read it.
1117
- for (const it of items) {
1118
- const action = discoveryAction(it.status);
1119
- if (action) it.recommended_action = action;
1120
- }
1121
-
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
- : [];
1128
- let applied = false;
1129
- let backupPath: string | null = null;
1130
- if (unsets.length > 0) {
1131
- backupPath = `${envPath}.bak`;
1132
- try {
1133
- await copyFile(envPath, backupPath);
1134
- } catch {
1135
- // missing source — write fresh; no backup needed.
1136
- backupPath = null;
1137
- }
1138
- const file = Bun.file(envPath);
1139
- let text = (await file.exists()) ? await file.text() : "";
1140
- for (const v of unsets) {
1141
- text = upsertEnvLine(text, v, "");
1142
- }
1143
- if (!text.endsWith("\n")) text += "\n";
1144
- await Bun.write(envPath, text);
1145
- applied = true;
1146
- }
1147
-
1148
- // ARV-46: env keys without a manifest entry are noise — the user (or a
1149
- // legacy hand-edit) put them there; the API doesn't actually need them.
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"]);
1159
- let unknownEnvKeys: string[] = [];
1160
- if (manifest) {
1161
- const manifestNames = new Set(manifest.fixtures.map(f => f.name));
1162
- unknownEnvKeys = Object.keys(env).filter(
1163
- k => !manifestNames.has(k) && !AUTO_MANAGED_KEYS.has(k),
1164
- );
1165
- }
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
-
1196
- const requiredManifestCount = manifest
1197
- ? manifest.fixtures.filter(f => f.required).length
1198
- : 0;
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
1202
- // doctor and the user_config bucket isn't double-counted as UNSET.
1203
- const filledCount = items.filter(i =>
1204
- i.manifestStatus === "skipped:already-set" ||
1205
- i.status === "verify-live" ||
1206
- i.status === "verify-user-config",
1207
- ).length;
1208
-
1209
- if (options.json) {
1210
- // ARV-143 follow-up: strip raw secret values from items[].current so the
1211
- // JSON envelope can't leak `.secrets.yaml`-resolved tokens. The
1212
- // SecretRegistry registered every non-base_url env var above, so
1213
- // redactObject swaps any registered value for `<redacted:<name>>`.
1214
- const safeItems = getSecretRegistry().redactObject(items);
1215
- printJson(jsonOk(commandName, {
1216
- envPath,
1217
- applied,
1218
- backup: backupPath,
1219
- items: safeItems,
1220
- summary: {
1221
- total: items.length,
1222
- // ARV-362: discover writes no values; --refresh may unset stale ids.
1223
- unset: unsets.length,
1224
- alreadySet: items.filter(i => i.status === "skip-already-set").length,
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,
1229
- ...(manifest ? {
1230
- manifest: {
1231
- required: requiredManifestCount,
1232
- filled: filledCount,
1233
- unknownEnvKeys,
1234
- },
1235
- } : {}),
1236
- ...(options.verify ? {
1237
- verify: {
1238
- live: items.filter(i => i.status === "verify-live").length,
1239
- // Items classified stale. With --refresh they are also unset on
1240
- // disk (see `dropped`); without it they stay for the agent to fix.
1241
- stale: items.filter(i => i.status === "verify-stale").length,
1242
- // ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
1243
- dropped: items.filter(i => i.wasStale === true).length,
1244
- unknown: items.filter(i => i.status === "verify-unknown").length,
1245
- skipped: items.filter(i => i.status === "verify-skip-empty" || i.status === "verify-no-read").length,
1246
- // ARV-143: filled vars with no verify path (user-config /
1247
- // resource-without-read). Doctor reports these as set:true;
1248
- // refresh now agrees by surfacing them in their own bucket.
1249
- user_config: items.filter(i => i.status === "verify-user-config").length,
1250
- },
1251
- } : {}),
1252
- },
1253
- }));
1254
- } else {
1255
- console.log(`Discovery against ${baseUrl} (${envPath}):`);
1256
- console.log("");
1257
- const cols = ["var", "source", "resource", "list", "status", "value/reason"];
1258
- const rows = items.map(i => [
1259
- i.varName,
1260
- i.manifestSource ?? "—",
1261
- i.resource || "—",
1262
- i.listPath || "—",
1263
- i.manifestStatus ?? i.status,
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 ?? ""),
1279
- ]);
1280
- // ARV-143 follow-up: redact every text cell through SecretRegistry so
1281
- // an `auth_token` that happens to slip into a `(kept: ...)` /
1282
- // `(live: ...)` cell can't reach stdout / scrollback / tee. The
1283
- // verify-user-config branch already substitutes length-only — this
1284
- // is defense in depth for the other status branches.
1285
- const reg = getSecretRegistry();
1286
- for (const r of rows) for (let i = 0; i < r.length; i++) r[i] = reg.redact(r[i]!);
1287
- const widths = cols.map((h, i) => Math.max(h.length, ...rows.map(r => r[i]!.length)));
1288
- const fmt = (cells: string[]) => cells.map((c, i) => c.padEnd(widths[i]!)).join(" ");
1289
- console.log(fmt(cols));
1290
- console.log(widths.map(w => "─".repeat(w)).join(" "));
1291
- for (const r of rows) console.log(fmt(r));
1292
- console.log("");
1293
- if (options.verify) {
1294
- const live = items.filter(i => i.status === "verify-live").length;
1295
- const stale = items.filter(i => i.status === "verify-stale").length;
1296
- const unknown = items.filter(i => i.status === "verify-unknown").length;
1297
- // ARV-362: stale ids --refresh removed from .env.yaml (now gaps).
1298
- const dropped = items.filter(i => i.wasStale === true).length;
1299
- // ARV-143: filled vars verify can't reach — call them out as trusted.
1300
- const userConfig = items.filter(i => i.status === "verify-user-config").length;
1301
- const parts = [`${live} live`, `${stale} stale`];
1302
- if (dropped > 0) parts.push(`${dropped} dropped`);
1303
- parts.push(`${unknown} unknown`);
1304
- if (userConfig > 0) parts.push(`${userConfig} trusted (no-verify-path)`);
1305
- console.log(`Verify summary: ${parts.join(", ")}.`);
1306
- if (stale > 0 && !options.apply) {
1307
- printWarning(`${stale} stale fixture(s) detected. Re-run with --refresh to drop them (agent refills .env.yaml).`);
1308
- }
1309
- }
1310
- if (manifest) {
1311
- console.log(`Filled ${filledCount} / ${requiredManifestCount} manifest entries.`);
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
- }
1330
- if (unknownEnvKeys.length > 0) {
1331
- printWarning(
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.`,
1333
- );
1334
- }
1335
- if (applied) {
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`).");
1339
- }
1340
- }
1341
- return 0;
1342
- } catch (err) {
1343
- const message = err instanceof Error ? err.message : String(err);
1344
- if (options.json) printJson(jsonError(commandName, [message]));
1345
- else printError(message);
1346
- return 2;
1347
- }
1348
- }
1349
-
1350
- // ARV-130 (m-19): file kept on purpose. CLI registration is owned by
1351
- // ./prepare-fixtures.ts (TASK-299, m-13 D); the `discoverCommand` core
1352
- // above is consumed both by that wrapper and by direct unit tests
1353
- // (`tests/cli/discover*.test.ts`). It is NOT a deprecated alias for a
1354
- // top-level `zond discover` command — that command does not exist and
1355
- // has never been registered in `src/cli/program.ts`. See the m-19
1356
- // audit note in backlog/tasks/arv-130.