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