@kirrosh/zond 0.26.1 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +39 -22
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +36 -23
  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 -1162
  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,880 +0,0 @@
1
- /**
2
- * ARV-187: `zond api annotate` — agent-augmented overlay authoring.
3
- *
4
- * zond does NOT call an LLM and does NOT carry LLM prompt text.
5
- * It exposes two phases that bracket the agent's own inference:
6
- *
7
- * 1. `zond api annotate dump --<kind>` — emit raw, per-resource
8
- * spec slices + the expected response shape (zod-derived contract).
9
- * The agent reads them, decides how to ask its model, generates
10
- * one YAML response per resource.
11
- *
12
- * 2. `zond api annotate apply --<kind> --input <file|->` — read the
13
- * agent's YAML response, validate via zod, render a diff against
14
- * the existing `.api-resources.local.yaml`, and (with --yes) write.
15
- *
16
- * The agent owns prompt formulation, model choice, and inference. zond
17
- * owns spec-parsing, response validation, and overlay I/O. No network
18
- * calls from zond, no API keys, deterministic binary behaviour.
19
- */
20
-
21
- import { join } from "node:path";
22
- import { appendFile, mkdir, readFile } from "node:fs/promises";
23
- import { existsSync } from "node:fs";
24
- import type { Command } from "commander";
25
- import type { OpenAPIV3 } from "openapi-types";
26
- import { resolveApiCollection } from "../../../resolve.ts";
27
- import { readOpenApiSpec } from "../../../../core/generator/openapi-reader.ts";
28
- import { readResourceMap, type ResourceYaml } from "../../discover.ts";
29
- import { buildResourceSlices, type ResourceSlice, type EndpointDump } from "./prompts.ts";
30
- import { readLocalOverlay, writeLocalOverlay, mergePatches, renderChangesDiff, type ResourcePatch } from "./overlay.ts";
31
- import * as seedBodies from "./seed-bodies.ts";
32
- import * as lifecycle from "./lifecycle.ts";
33
- import * as idempotency from "./idempotency.ts";
34
- import * as pagination from "./pagination.ts";
35
- import * as readback from "./readback.ts";
36
- import * as resourcesModule from "./resources.ts";
37
- import { printError, printSuccess, printWarning } from "../../../output.ts";
38
- import { jsonOk, jsonError, printJson } from "../../../json-envelope.ts";
39
- import { globalJson } from "../../../resolve.ts";
40
-
41
- type SubcommandKind = "seed-bodies" | "lifecycle" | "idempotency" | "pagination" | "readback" | "resources";
42
-
43
- // ─── Dump phase ──────────────────────────────────────────────────────
44
-
45
- export interface DumpBundle {
46
- kind: SubcommandKind;
47
- /** "*orphans*" for kind=resources; resource name otherwise. */
48
- resource: string;
49
- /** Output of buildResourceSlices for a single resource — endpoints,
50
- * schemas, descriptions, x-codeSamples. The raw material. */
51
- data: unknown;
52
- /** zod-derived shape of the YAML response zond will accept in `apply`.
53
- * Not an LLM prompt — a typed contract the agent can reference. */
54
- expected_response_shape: unknown;
55
- }
56
-
57
- const EXPECTED_SHAPES: Record<SubcommandKind, unknown> = {
58
- "seed-bodies": seedBodies.EXPECTED_OUTPUT_SHAPE,
59
- "lifecycle": lifecycle.EXPECTED_OUTPUT_SHAPE,
60
- "idempotency": idempotency.EXPECTED_OUTPUT_SHAPE,
61
- "pagination": pagination.EXPECTED_OUTPUT_SHAPE,
62
- "readback": readback.EXPECTED_OUTPUT_SHAPE,
63
- "resources": resourcesModule.EXPECTED_OUTPUT_SHAPE,
64
- };
65
-
66
- export interface DumpOptions {
67
- api: string;
68
- kind: SubcommandKind;
69
- only?: string[];
70
- json?: boolean;
71
- dbPath?: string;
72
- /** ARV-277: enrich seed-bodies bundles with the most recent
73
- * fixture-kind POST attempt to the create endpoint, so the calling
74
- * agent sees exactly what zond tried last + how the server replied
75
- * (avoids the "agent re-discovers each 400 by hand" loop). */
76
- withLastAttempt?: boolean;
77
- /** ARV-278: when `withLastAttempt` is true, return the most recent N
78
- * attempts (most recent first) instead of just the last one. Surfaces
79
- * the *progression* of errors as the overlay was iterated — first 400
80
- * was "missing X", after fixing the body the next 400 is "stale FK"
81
- * pulls cascade-staleness (ARV-282) onto the agent's screen one
82
- * iteration earlier. `1` keeps the old single-snapshot behaviour. */
83
- historyLimit?: number;
84
- }
85
-
86
- export async function dumpCommand(opts: DumpOptions): Promise<number> {
87
- const col = resolveApiCollection(opts.api, opts.dbPath);
88
- if ("error" in col) { printError(col.error); return 2; }
89
- if (!col.baseDir || !col.spec) {
90
- printError(`API '${opts.api}' has no spec/base_dir registered.`);
91
- return 2;
92
- }
93
-
94
- const doc = await readOpenApiSpec(col.spec);
95
- const map = await readResourceMap(col.baseDir);
96
- if (!map) {
97
- printError(`API '${opts.api}' has no .api-resources.yaml. Run \`zond refresh-api ${opts.api}\` first.`);
98
- return 2;
99
- }
100
-
101
- let resources = map.resources;
102
- let unknownOnly: string[] = [];
103
- if (opts.only && opts.only.length > 0) {
104
- const wanted = new Set(opts.only);
105
- const knownNames = new Set(map.resources.map((r) => r.resource));
106
- unknownOnly = opts.only.filter((n) => !knownNames.has(n));
107
- resources = resources.filter((r) => wanted.has(r.resource));
108
- }
109
- const slices = buildResourceSlices(doc, resources);
110
- const bundles = buildDumpBundles(opts.kind, slices, doc, map.resources);
111
-
112
- // ARV-277: enrich seed-bodies bundles with the last fixture-kind POST
113
- // attempt (from `runs/results` DB). The agent reads `last_attempt`
114
- // alongside the spec slice instead of running `zond request POST …`
115
- // by hand to reproduce the failure mode.
116
- if (opts.withLastAttempt && opts.kind === "seed-bodies") {
117
- try {
118
- // ARV-330: use the wider all-run-kinds query (a create-path POST made
119
- // during a check/probe run counts too, not just fixture runs) and pass
120
- // the child-exclude pattern so probe sub-resource POSTs
121
- // (`/v1/accounts/{id}/reject`) don't monopolise the history window.
122
- const { getRecentCreatePosts } = await import("../../../../db/queries/results.ts");
123
- const limit = Math.max(1, Math.floor(opts.historyLimit ?? 1));
124
- for (const b of bundles) {
125
- const data = b.data as Record<string, unknown> | null;
126
- const endpoints = data?.endpoints as Record<string, EndpointDump | undefined> | undefined;
127
- const create = endpoints?.create;
128
- if (!create) continue;
129
- const pattern = createUrlLikePattern(create.path);
130
- const recent = getRecentCreatePosts(pattern, limit, childExcludePattern(create.path));
131
- if (recent.length === 0) continue;
132
- // ARV-278: keep `last_attempt` as the most-recent entry (back-compat
133
- // with single-snapshot consumers) and add `attempt_history` when
134
- // the caller asked for >1.
135
- (b.data as Record<string, unknown>).last_attempt = recent[0];
136
- if (limit > 1) (b.data as Record<string, unknown>).attempt_history = recent;
137
- }
138
- } catch (err) {
139
- // DB lookup is best-effort — degraded dump (no `last_attempt`) is
140
- // still useful, so we warn but don't fail the command.
141
- process.stderr.write(`zond: --with-last-attempt: DB lookup failed (${(err as Error).message}); dump emitted without last_attempt.\n`);
142
- }
143
- }
144
-
145
- // ARV-226: when `--only` filtered to resources that have no applicable
146
- // surface for this kind (no list endpoint for pagination/readback, no
147
- // write surface for idempotency/seed-bodies, etc.), the dump silently
148
- // returns []. Distinguish "no surface" from "unknown resource" so the
149
- // user can tell whether they mistyped or hit a category-not-applicable.
150
- if (!opts.json && opts.only && opts.only.length > 0 && opts.kind !== "resources") {
151
- const emitted = new Set(bundles.map((b) => b.resource));
152
- const filteredButNoSurface = opts.only.filter(
153
- (n) => !emitted.has(n) && !unknownOnly.includes(n),
154
- );
155
- for (const n of unknownOnly) {
156
- process.stderr.write(`zond: --only: resource '${n}' not in .api-resources.yaml (refresh-api or check spelling).\n`);
157
- }
158
- for (const n of filteredButNoSurface) {
159
- process.stderr.write(`zond: --only: resource '${n}' has no applicable surface for --${opts.kind} (skipped).\n`);
160
- }
161
- }
162
-
163
- if (opts.json) {
164
- printJson(jsonOk("api annotate dump", { kind: opts.kind, bundles }));
165
- } else {
166
- process.stdout.write(JSON.stringify(bundles, null, 2) + "\n");
167
- }
168
- return 0;
169
- }
170
-
171
- /**
172
- * ARV-277: convert a create-endpoint path (`/v1/customers/{customer}/sources`)
173
- * to a SQL LIKE pattern matched against full request_url
174
- * (`https://api.stripe.com/v1/customers/cus_…/sources?...`). Path-params
175
- * collapse to `%`; the prefix and suffix wildcards cover the scheme/host
176
- * and optional query string. Anchoring is intentionally loose because
177
- * recorders write absolute URLs and we want to find the seed POST
178
- * regardless of base_url shape.
179
- */
180
- export function createUrlLikePattern(createPath: string): string {
181
- const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
182
- // SQL LIKE `%` matches across slashes (no special escape needed for
183
- // the path segments since we don't expect %/_ in spec paths). Leading
184
- // wildcard absorbs `https://api.stripe.com`, trailing absorbs `?expand=…`.
185
- return `%${collapsed}%`;
186
- }
187
-
188
- /**
189
- * ARV-330: companion NOT-LIKE pattern that excludes child sub-resource
190
- * POSTs (`/v1/accounts/{id}/reject`, `.../persons`) from a create-path
191
- * match. Pairs with `createUrlLikePattern` in `getRecentCreatePosts`.
192
- */
193
- export function childExcludePattern(createPath: string): string {
194
- const collapsed = createPath.replace(/\{[^}]+\}/g, "%");
195
- return `%${collapsed}/%`;
196
- }
197
-
198
- function buildDumpBundles(
199
- kind: SubcommandKind,
200
- slices: ResourceSlice[],
201
- doc: OpenAPIV3.Document,
202
- allResources: ResourceYaml[],
203
- ): DumpBundle[] {
204
- if (kind === "resources") {
205
- const claimedPaths = new Set<string>();
206
- for (const r of allResources) {
207
- for (const role of ["list", "create", "read", "update", "delete"] as const) {
208
- const ep = r.endpoints[role];
209
- if (ep) claimedPaths.add(ep.split(/\s+/)[1] ?? "");
210
- }
211
- }
212
- const orphans: string[] = [];
213
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
214
- if (!item || typeof item !== "object") continue;
215
- for (const method of ["get", "post", "put", "patch", "delete"]) {
216
- if (!(item as Record<string, unknown>)[method]) continue;
217
- if (claimedPaths.has(path)) continue;
218
- orphans.push(`${method.toUpperCase()} ${path}`);
219
- }
220
- }
221
- if (orphans.length === 0) return [];
222
- return [{
223
- kind,
224
- resource: "*orphans*",
225
- data: {
226
- orphan_endpoints: orphans,
227
- existing_resources: allResources.map((r) => ({ resource: r.resource, basePath: r.basePath })),
228
- },
229
- expected_response_shape: EXPECTED_SHAPES[kind],
230
- }];
231
- }
232
-
233
- const out: DumpBundle[] = [];
234
- for (const slice of slices) {
235
- if (!isSliceApplicable(kind, slice)) continue;
236
- let data: unknown;
237
- switch (kind) {
238
- case "seed-bodies":
239
- case "idempotency":
240
- case "readback":
241
- data = sliceData(slice);
242
- break;
243
- case "pagination": {
244
- // ARV-235: when the list endpoint already declares standard
245
- // page-style params, agent doesn't need to re-read the full
246
- // params schema to produce the annotation — surface a precomputed
247
- // hint so the response can be `{ type: page, page_param, limit_param }`
248
- // without inspecting the spec slice further.
249
- const hint = detectPaginationHint(slice);
250
- const base = sliceData(slice) as Record<string, unknown>;
251
- data = hint ? { ...base, pagination_hint: hint } : base;
252
- break;
253
- }
254
- case "lifecycle":
255
- data = {
256
- ...(sliceData(slice) as Record<string, unknown>),
257
- action_endpoint_candidates: collectActionEndpoints(doc, slice),
258
- };
259
- break;
260
- default: throw new Error(`unhandled kind: ${kind}`);
261
- }
262
- out.push({
263
- kind,
264
- resource: slice.resource,
265
- data,
266
- expected_response_shape: EXPECTED_SHAPES[kind],
267
- });
268
- }
269
- return out;
270
- }
271
-
272
- function sliceData(slice: ResourceSlice): unknown {
273
- return {
274
- resource: slice.resource,
275
- basePath: slice.basePath,
276
- itemPath: slice.itemPath,
277
- endpoints: slice.endpoints,
278
- };
279
- }
280
-
281
- /**
282
- * ARV-235: detect well-known page-style pagination params on the list
283
- * endpoint so the dump's response shape becomes a one-liner for the
284
- * agent. Returns null if the params don't match a known shape — the
285
- * agent then walks the full slice as before.
286
- */
287
- function detectPaginationHint(slice: ResourceSlice): null | {
288
- detected_style: "page" | "offset";
289
- page_param?: string;
290
- limit_param?: string;
291
- offset_param?: string;
292
- note: string;
293
- } {
294
- const list = slice.endpoints.list;
295
- if (!list || !list.parameters) return null;
296
- const queryNames = new Set(
297
- list.parameters.filter((p) => p.in === "query").map((p) => p.name.toLowerCase()),
298
- );
299
- const pageParam = ["page"].find((n) => queryNames.has(n));
300
- const limitParam = ["per_page", "page_size", "pagesize", "limit"].find((n) => queryNames.has(n));
301
- const offsetParam = ["offset", "skip", "start"].find((n) => queryNames.has(n));
302
- if (pageParam) {
303
- return {
304
- detected_style: "page",
305
- page_param: pageParam,
306
- limit_param: limitParam,
307
- note: "list endpoint declares page-style params; respond with { type: 'page', page_param, limit_param } to skip rereading the full spec slice.",
308
- };
309
- }
310
- if (offsetParam) {
311
- return {
312
- detected_style: "offset",
313
- offset_param: offsetParam,
314
- limit_param: limitParam,
315
- note: "list endpoint declares offset-style params; pagination check short-circuits offset/token (per m-20). Skip annotation for this resource or return { pagination: null }.",
316
- };
317
- }
318
- return null;
319
- }
320
-
321
- function isSliceApplicable(kind: SubcommandKind, slice: ResourceSlice): boolean {
322
- switch (kind) {
323
- case "seed-bodies": return seedBodies.isApplicable(slice);
324
- case "lifecycle": return lifecycle.isApplicable(slice);
325
- case "idempotency": return idempotency.isApplicable(slice);
326
- case "pagination": return pagination.isApplicable(slice);
327
- case "readback": return readback.isApplicable(slice);
328
- case "resources": return true;
329
- }
330
- }
331
-
332
- function collectActionEndpoints(doc: OpenAPIV3.Document, slice: ResourceSlice): EndpointDump[] {
333
- const out: EndpointDump[] = [];
334
- const claimed = new Set<string>();
335
- for (const role of ["list", "create", "read", "update", "delete"] as const) {
336
- const ep = slice.endpoints[role];
337
- if (ep) claimed.add(`${ep.method} ${ep.path}`);
338
- }
339
- const baseLen = slice.basePath.length;
340
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
341
- if (!item || typeof item !== "object") continue;
342
- if (!path.startsWith(slice.basePath)) continue;
343
- const tail = path.slice(baseLen);
344
- if (!/\/\{[^}]+\}\/[a-zA-Z]/.test(tail)) continue;
345
- for (const method of ["get", "post", "put", "patch", "delete"]) {
346
- const op = (item as Record<string, unknown>)[method];
347
- if (!op) continue;
348
- const key = `${method.toUpperCase()} ${path}`;
349
- if (claimed.has(key)) continue;
350
- out.push({
351
- method: method.toUpperCase(),
352
- path,
353
- operationId: (op as { operationId?: string }).operationId,
354
- summary: (op as { summary?: string }).summary,
355
- description: ((op as { description?: string }).description ?? "").slice(0, 240),
356
- });
357
- }
358
- }
359
- return out;
360
- }
361
-
362
- // ─── Apply phase ─────────────────────────────────────────────────────
363
-
364
- export interface ApplyOptions {
365
- api: string;
366
- kind: SubcommandKind;
367
- input: string;
368
- yes?: boolean;
369
- force?: boolean;
370
- json?: boolean;
371
- dbPath?: string;
372
- /** ARV-281: agent-loop regression protection. When set, apply drops
373
- * any aspect-level field on a proposed patch when the existing
374
- * overlay already has that field set (regardless of value). The
375
- * agent's response can only ADD missing pieces — it can't overwrite
376
- * a previously curated `seed_body` even by accident. `--force`
377
- * ignores this flag (explicit override stays explicit). */
378
- gapFillOnly?: boolean;
379
- }
380
-
381
- export async function applyCommand(opts: ApplyOptions): Promise<number> {
382
- const col = resolveApiCollection(opts.api, opts.dbPath);
383
- if ("error" in col) { printError(col.error); return 2; }
384
- if (!col.baseDir || !col.spec) {
385
- printError(`API '${opts.api}' has no spec/base_dir registered.`);
386
- return 2;
387
- }
388
-
389
- const doc = await readOpenApiSpec(col.spec);
390
- const map = await readResourceMap(col.baseDir);
391
- if (!map) {
392
- printError(`API '${opts.api}' has no .api-resources.yaml.`);
393
- return 2;
394
- }
395
- const slicesByName = new Map<string, ResourceSlice>();
396
- for (const s of buildResourceSlices(doc, map.resources)) slicesByName.set(s.resource, s);
397
-
398
- const inputText = await readInput(opts.input);
399
- const documents = parseYamlDocuments(inputText);
400
- if (documents.length === 0) {
401
- printError(`No YAML documents found in input (${opts.input}).`);
402
- return 2;
403
- }
404
-
405
- if (opts.kind === "resources") {
406
- return applyResources(col.baseDir, documents, opts);
407
- }
408
-
409
- const drafts: Array<{ patch: ResourcePatch; audit: Record<string, unknown> }> = [];
410
- const errors: Array<{ resource: string; error: string }> = [];
411
-
412
- for (const document of documents) {
413
- const resourceName = (document as { resource?: string } | null)?.resource;
414
- if (typeof resourceName !== "string") {
415
- errors.push({ resource: "<unknown>", error: "document missing 'resource:' field" });
416
- continue;
417
- }
418
- const slice = slicesByName.get(resourceName);
419
- if (!slice) {
420
- errors.push({ resource: resourceName, error: "resource not present in .api-resources.yaml — refresh-api first?" });
421
- continue;
422
- }
423
- try {
424
- drafts.push(parseByKind(opts.kind, document, slice));
425
- } catch (err) {
426
- errors.push({ resource: resourceName, error: (err as Error).message });
427
- }
428
- }
429
-
430
- const nonEmpty = drafts.filter((d) => Object.keys(d.patch).filter((k) => k !== "resource").length > 0);
431
-
432
- const overlay = await readLocalOverlay(col.baseDir);
433
- const existing = (overlay.patches ?? []) as ResourcePatch[];
434
-
435
- // ARV-281: gap-fill-only — drop aspect-level fields where the existing
436
- // overlay already has a value set. Keeps the agent's response strictly
437
- // additive vs. previous (heuristic + curated) edits. `--force` opts out
438
- // of this guard, mirroring the existing conflict semantics.
439
- const filteredPatches = opts.gapFillOnly && opts.force !== true
440
- ? nonEmpty.map((d) => filterToGaps(d.patch, existing))
441
- : nonEmpty.map((d) => d.patch);
442
- const gapDroppedFields = opts.gapFillOnly && opts.force !== true
443
- ? countGapDroppedFields(nonEmpty.map((d) => d.patch), filteredPatches)
444
- : 0;
445
-
446
- const merge = mergePatches(existing, filteredPatches, { force: opts.force === true });
447
-
448
- const summary = {
449
- api: opts.api,
450
- kind: opts.kind,
451
- inputDocuments: documents.length,
452
- accepted: nonEmpty.length,
453
- dropped: drafts.length - nonEmpty.length,
454
- failures: errors,
455
- changes: merge.changes.length,
456
- conflicts: merge.conflicts.length,
457
- /** ARV-281: aspect-level fields the agent proposed that gap-fill-only
458
- * silently dropped because the existing overlay already had them
459
- * set. Distinct from `conflicts` which is mergePatches's concept of
460
- * field-level disagreement. */
461
- gap_fill_dropped: gapDroppedFields,
462
- };
463
-
464
- const diff = renderChangesDiff(merge);
465
- if (!opts.json) {
466
- if (errors.length > 0) {
467
- printWarning(`Failed to parse ${errors.length} document(s):`);
468
- for (const e of errors) process.stdout.write(` ✗ ${e.resource}: ${e.error}\n`);
469
- }
470
- if (gapDroppedFields > 0) {
471
- printWarning(`--gap-fill-only dropped ${gapDroppedFields} aspect-field(s) the agent proposed for resources that already had them set. Re-run with --force to overwrite anyway.`);
472
- }
473
- if (diff) {
474
- process.stdout.write("\nProposed changes (and conflicts):\n");
475
- process.stdout.write(diff + "\n\n");
476
- } else {
477
- process.stdout.write("No changes proposed.\n");
478
- }
479
- }
480
-
481
- if (!opts.yes) {
482
- if (!opts.json) process.stdout.write(`Dry-run. Re-run with --yes to write ${col.baseDir}/.api-resources.local.yaml.\n`);
483
- if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: false }));
484
- return 0;
485
- }
486
-
487
- if (merge.changes.length === 0 && merge.conflicts.length === 0) {
488
- if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: false }));
489
- return 0;
490
- }
491
-
492
- overlay.patches = merge.patches;
493
- await writeLocalOverlay(col.baseDir, overlay);
494
- await appendAuditLog(col.baseDir, {
495
- timestamp: new Date().toISOString(),
496
- kind: opts.kind,
497
- drafts: drafts.map((d) => d.audit),
498
- failures: errors,
499
- });
500
- if (!opts.json) printSuccess(`Wrote ${merge.changes.length} change(s) to ${col.baseDir}/.api-resources.local.yaml`);
501
- if (merge.conflicts.length > 0 && !opts.force && !opts.json) {
502
- printWarning(`${merge.conflicts.length} conflict(s) kept existing values. Re-run with --force to overwrite.`);
503
- }
504
- // ARV-217: after a successful apply, surface the basePaths the agent
505
- // should keep in `checks run --include` so a narrowed scope doesn't
506
- // silently skip the freshly annotated resources. Without this hint, the
507
- // next stateful run reports "no pagination config" for every endpoint
508
- // outside the include filter and the apply looks like a no-op.
509
- if (!opts.json && merge.changes.length > 0) {
510
- const annotatedBasePaths = new Set<string>();
511
- for (const patch of merge.patches) {
512
- const slice = slicesByName.get(patch.resource);
513
- if (slice?.basePath) annotatedBasePaths.add(slice.basePath);
514
- }
515
- if (annotatedBasePaths.size > 0) {
516
- const checkName = opts.kind === "pagination"
517
- ? "pagination_invariants"
518
- : opts.kind === "lifecycle"
519
- ? "lifecycle_transitions"
520
- : opts.kind === "idempotency"
521
- ? "idempotency_replay"
522
- : opts.kind === "readback"
523
- ? "cross_call_references"
524
- : opts.kind === "seed-bodies"
525
- ? "stateful"
526
- : "stateful";
527
- const includeAlt = [...annotatedBasePaths].map(escapeForPathRegex).join("|");
528
- process.stdout.write(
529
- `\nTip: to exercise these annotations, scope --include to cover the annotated basePath(s):\n` +
530
- ` zond checks run --api ${opts.api} --check ${checkName} --include 'path:^(${includeAlt})(/.*)?$'\n`,
531
- );
532
- }
533
- }
534
- if (opts.json) printJson(jsonOk("api annotate apply", { ...summary, written: true }));
535
- return 0;
536
- }
537
-
538
- function escapeForPathRegex(path: string): string {
539
- return path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
540
- }
541
-
542
- function parseByKind(kind: SubcommandKind, parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
543
- switch (kind) {
544
- case "seed-bodies": return seedBodies.parseSeedBodyResponse(parsed, slice);
545
- case "lifecycle": return lifecycle.parseLifecycleResponse(parsed, slice);
546
- case "idempotency": return idempotency.parseIdempotencyResponse(parsed, slice);
547
- case "pagination": return pagination.parsePaginationResponse(parsed, slice);
548
- case "readback": return readback.parseReadbackResponse(parsed, slice);
549
- case "resources": throw new Error("resources kind handled separately");
550
- }
551
- }
552
-
553
- async function applyResources(apiDir: string, documents: unknown[], opts: ApplyOptions): Promise<number> {
554
- if (documents.length !== 1) {
555
- printWarning(`Expected 1 YAML document with 'extensions:'; got ${documents.length}. Using the first.`);
556
- }
557
- let result;
558
- try { result = resourcesModule.parseResourcesResponse(documents[0]); }
559
- catch (err) { printError((err as Error).message); return 2; }
560
-
561
- if (!opts.json) {
562
- process.stdout.write(`Proposed: ${result.audit.proposed}; high-confidence accepted: ${result.extensions.length}; dropped: ${result.audit.droppedLowConfidence}\n`);
563
- }
564
- if (result.extensions.length === 0) {
565
- if (opts.json) printJson(jsonOk("api annotate apply", { written: false, accepted: 0 }));
566
- return 0;
567
- }
568
-
569
- const overlay = await readLocalOverlay(apiDir);
570
- const existing = (overlay.extensions ?? []);
571
- const existingNames = new Set(existing.map((e) => e.resource));
572
- const newOnes = result.extensions.filter((e) => !existingNames.has(e.resource));
573
-
574
- if (!opts.json) {
575
- process.stdout.write(`\nNew resource extension(s):\n`);
576
- for (const ext of newOnes) process.stdout.write(` + ${ext.resource} (${ext.basePath})\n`);
577
- }
578
-
579
- if (!opts.yes) {
580
- if (!opts.json) process.stdout.write(`\nDry-run. Re-run with --yes to write.\n`);
581
- if (opts.json) printJson(jsonOk("api annotate apply", { written: false, accepted: newOnes.length }));
582
- return 0;
583
- }
584
-
585
- overlay.extensions = [...existing, ...newOnes];
586
- await writeLocalOverlay(apiDir, overlay);
587
- await appendAuditLog(apiDir, {
588
- timestamp: new Date().toISOString(),
589
- kind: "resources",
590
- accepted: newOnes.length,
591
- dropped: result.audit.droppedLowConfidence,
592
- });
593
- if (!opts.json) printSuccess(`Wrote ${newOnes.length} extension(s) to ${apiDir}/.api-resources.local.yaml`);
594
- if (opts.json) printJson(jsonOk("api annotate apply", { written: true, accepted: newOnes.length }));
595
- return 0;
596
- }
597
-
598
- /**
599
- * ARV-280: error-message shapes Stripe/etc emit when an account-level
600
- * capability is missing rather than an overlay-level data issue.
601
- * Conservative — we only tag a resource as `account_capability_missing`
602
- * when the most recent attempts ALL match, because Stripe's
603
- * order-of-validation can surface a transient data error first and
604
- * then a capability error after the overlay is fixed.
605
- */
606
- const HARD_BLOCKED_PATTERNS = [
607
- /not\s+supported\s+for\s+country/i,
608
- /not\s+available\s+in\s+your\s+account/i,
609
- /capability/i,
610
- /not\s+enabled/i,
611
- /\bnot\s+onboarded\b/i,
612
- /raw[- ]card[- ]data\s+(api|access)/i,
613
- /you do not have access/i,
614
- /signed up for connect/i,
615
- ];
616
-
617
- /**
618
- * ARV-330: replies from security/auth probes that POST with deliberately
619
- * broken credentials. Their 401/403s say nothing about whether the
620
- * resource is capability-blocked, so the classifier drops them before
621
- * judging.
622
- */
623
- const AUTH_NOISE_PATTERNS = [
624
- /invalid api key/i,
625
- /did not provide an api key/i,
626
- /no api key provided/i,
627
- /provided key '/i,
628
- ];
629
-
630
- /**
631
- * ARV-330: verdict on a resource's create-path POST history. Pure so it
632
- * can be unit-tested without a DB. Returns `account_capability_missing`
633
- * when the account-level gate is the blocker, else null.
634
- *
635
- * Logic:
636
- * 1. Drop auth-probe noise (broken-cred 401/403s) — not about the resource.
637
- * 2. Any 2xx among valid attempts → the resource IS seedable, never blocked.
638
- * 3. A capability/onboarding gate is account-level and deterministic: one
639
- * hit on a valid-key attempt (with no success) is enough. We don't
640
- * require ALL attempts to match, because a generic-generator body can
641
- * trigger a data-shaped 400 on one attempt while a leaner body hits
642
- * the gate on another (the ARV-330 chicken-and-egg case).
643
- */
644
- /**
645
- * ARV-330: guard the loose `createUrlLikePattern` LIKE from counting
646
- * child sub-resource POSTs. `%/v1/accounts%` also matches
647
- * `/v1/accounts/{id}/reject` and `.../persons` — POSTs to *different*
648
- * endpoints (often auth-probe noise) that would drown out the real
649
- * create-path attempt. Match the recorded URL's path segment-for-segment
650
- * against the create-path (`{param}` matches any single segment).
651
- */
652
- export function urlMatchesCreatePath(requestUrl: string, createPath: string): boolean {
653
- let path: string;
654
- try { path = new URL(requestUrl).pathname; } catch { return false; }
655
- const want = createPath.split("/").filter(Boolean);
656
- const got = path.split("/").filter(Boolean);
657
- if (want.length !== got.length) return false;
658
- return want.every((seg, i) => seg.startsWith("{") || seg === got[i]);
659
- }
660
-
661
- export function classifyAttempts(
662
- attempts: Array<{ response_status: number | null; response_body: string | null }>,
663
- ): "account_capability_missing" | null {
664
- const valid = attempts.filter((a) => {
665
- if (a.response_status === 401 || a.response_status === 403) return false;
666
- const msg = extractMessage(a.response_body);
667
- return !(msg !== null && AUTH_NOISE_PATTERNS.some((re) => re.test(msg)));
668
- });
669
- if (valid.length === 0) return null;
670
- if (valid.some((a) => a.response_status !== null && a.response_status >= 200 && a.response_status < 300)) return null;
671
- const capabilityHit = valid.some((a) => {
672
- const msg = extractMessage(a.response_body);
673
- return msg !== null && HARD_BLOCKED_PATTERNS.some((re) => re.test(msg));
674
- });
675
- return capabilityHit ? "account_capability_missing" : null;
676
- }
677
-
678
- function extractMessage(body: string | null): string | null {
679
- if (!body) return null;
680
- try {
681
- const parsed = JSON.parse(body);
682
- const m = parsed?.error?.message;
683
- return typeof m === "string" ? m : null;
684
- } catch {
685
- return null;
686
- }
687
- }
688
-
689
- // ─── ARV-281: gap-fill filtering ─────────────────────────────────────
690
-
691
- /**
692
- * Drop aspect-level fields on a proposed patch when the existing
693
- * overlay (matched by resource name) already has that field set to a
694
- * non-empty value. Returns a new patch object — never mutates input.
695
- */
696
- export function filterToGaps(
697
- proposed: ResourcePatch,
698
- existing: ResourcePatch[],
699
- ): ResourcePatch {
700
- const prior = existing.find((p) => p.resource === proposed.resource);
701
- if (!prior) return proposed;
702
- const out: ResourcePatch = { resource: proposed.resource };
703
- for (const [key, value] of Object.entries(proposed) as Array<[string, unknown]>) {
704
- if (key === "resource") continue;
705
- const existingVal = (prior as unknown as Record<string, unknown>)[key];
706
- if (isPresent(existingVal)) continue; // already-set field → skip
707
- (out as Record<string, unknown>)[key] = value;
708
- }
709
- return out;
710
- }
711
-
712
- function isPresent(v: unknown): boolean {
713
- if (v === undefined || v === null) return false;
714
- if (typeof v === "string") return v.length > 0;
715
- if (Array.isArray(v)) return v.length > 0;
716
- if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length > 0;
717
- return true;
718
- }
719
-
720
- function countGapDroppedFields(
721
- before: ResourcePatch[],
722
- after: ResourcePatch[],
723
- ): number {
724
- let n = 0;
725
- for (let i = 0; i < before.length; i++) {
726
- const b = before[i]!;
727
- const a = after[i]!;
728
- for (const k of Object.keys(b)) {
729
- if (k === "resource") continue;
730
- if (!(k in a)) n++;
731
- }
732
- }
733
- return n;
734
- }
735
-
736
- // ─── I/O helpers ─────────────────────────────────────────────────────
737
-
738
- async function readInput(source: string): Promise<string> {
739
- if (source === "-") return await readStdin();
740
- return await readFile(source, "utf-8");
741
- }
742
-
743
- async function readStdin(): Promise<string> {
744
- const chunks: Buffer[] = [];
745
- for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
746
- return Buffer.concat(chunks).toString("utf-8");
747
- }
748
-
749
- function parseYamlDocuments(text: string): unknown[] {
750
- const trimmed = text.trim();
751
- if (trimmed.length === 0) return [];
752
- if (trimmed.startsWith("[") || trimmed.startsWith("- ")) {
753
- const parsed = Bun.YAML.parse(trimmed);
754
- if (Array.isArray(parsed)) return parsed;
755
- return [parsed];
756
- }
757
- const segments = trimmed.split(/^---\s*$/m).map((s) => s.trim()).filter(Boolean);
758
- return segments.map((s) => Bun.YAML.parse(s));
759
- }
760
-
761
- async function appendAuditLog(apiDir: string, record: Record<string, unknown>): Promise<void> {
762
- const path = join(apiDir, ".api-resources.annotate.log.ndjson");
763
- if (!existsSync(apiDir)) await mkdir(apiDir, { recursive: true });
764
- await appendFile(path, JSON.stringify(record) + "\n", "utf-8");
765
- }
766
-
767
- void jsonError;
768
-
769
- // ─── Commander registration ──────────────────────────────────────────
770
-
771
- export function registerApiAnnotate(program: Command): void {
772
- const api = program
773
- .command("api")
774
- .description("Per-API tooling — annotate the .api-resources.local.yaml overlay (ARV-187)");
775
-
776
- const annotate = api
777
- .command("annotate")
778
- .description("Overlay authoring for .api-resources.local.yaml. Two subcommands: `dump` (emit spec slices) + `apply` (validate & merge the agent's YAML) — agent-in-the-loop, ARV-187. zond carries no inference; the agent writes the annotations, zond validates and merges.");
779
-
780
- annotate
781
- .command("dump")
782
- .description("Emit per-resource spec slices + expected response shape on stdout (JSON). The agent decides how to prompt its LLM; zond carries no prompts.")
783
- .option("--api <name>", "Target API (else falls back to global --api)")
784
- .option("--seed-bodies", "Slices for seed_body{content_type, body}")
785
- .option("--lifecycle", "Slices + action-endpoint candidates for lifecycle")
786
- .option("--idempotency", "Slices for idempotency{header, scope, ...}")
787
- .option("--pagination", "Slices for pagination{type, cursor_param, ...}")
788
- .option("--readback", "Create+read pair for readback_diff")
789
- .option("--resources", "Orphan-endpoint list for resource-graph extensions")
790
- .option("--only <list>", "Comma-separated resource names — restrict scope", csv)
791
- .option("--with-last-attempt", "ARV-277: enrich seed-bodies bundles with the most recent fixture-kind POST attempt (`request_body`/`response_status`/`response_body`/`attempted_at`) so the agent sees what zond tried last without re-running prepare-fixtures.")
792
- .option("--history <N>", "ARV-278: with --with-last-attempt, return the most recent N attempts (newest first) under `attempt_history`. Surfaces error progression as the overlay was iterated.", (v) => Number.parseInt(v, 10))
793
- .option("--db <path>", "SQLite db path override")
794
- .action(async (rawOpts, cmd: Command) => {
795
- const kind = pickKind(rawOpts);
796
- if (!kind) {
797
- printError("Pick one of --seed-bodies | --lifecycle | --idempotency | --pagination | --readback | --resources");
798
- process.exitCode = 2;
799
- return;
800
- }
801
- if (rawOpts.withLastAttempt === true && kind !== "seed-bodies") {
802
- printWarning("--with-last-attempt is only meaningful with --seed-bodies; ignored.");
803
- }
804
- const apiName = resolveApiArg(rawOpts, cmd);
805
- if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
806
- process.exitCode = await dumpCommand({
807
- api: apiName,
808
- kind,
809
- only: rawOpts.only,
810
- dbPath: rawOpts.db,
811
- json: globalJson(cmd),
812
- withLastAttempt: rawOpts.withLastAttempt === true,
813
- historyLimit: Number.isFinite(rawOpts.history) ? rawOpts.history : undefined,
814
- });
815
- });
816
-
817
- annotate
818
- .command("apply")
819
- .description("Validate the agent's YAML responses, render a diff, and (with --yes) write into .api-resources.local.yaml.")
820
- .option("--api <name>", "Target API (else falls back to global --api)")
821
- .option("--seed-bodies", "Apply seed_body block")
822
- .option("--lifecycle", "Apply lifecycle block")
823
- .option("--idempotency", "Apply idempotency block")
824
- .option("--pagination", "Apply pagination block")
825
- .option("--readback", "Apply readback_diff block")
826
- .option("--resources", "Apply orphan-resource extension list")
827
- .option("--input <file>", "Path to the YAML responses file, or `-` for stdin", "-")
828
- .option("--yes", "Write the proposed patches to disk (default: dry-run + diff)")
829
- .option("--force", "Overwrite the existing value on field-level conflict")
830
- .option("--gap-fill-only", "ARV-281: drop aspect-level fields the agent proposed for resources that already have that field set in the overlay. Keeps the agent's response strictly additive — protects curated `seed_body` blocks from accidental regression. Bypassed by --force.")
831
- .option("--db <path>", "SQLite db path override")
832
- .action(async (rawOpts, cmd: Command) => {
833
- const kind = pickKind(rawOpts);
834
- if (!kind) {
835
- printError("Pick one of --seed-bodies | --lifecycle | --idempotency | --pagination | --readback | --resources");
836
- process.exitCode = 2;
837
- return;
838
- }
839
- const apiName = resolveApiArg(rawOpts, cmd);
840
- if (!apiName) { printError("No API selected."); process.exitCode = 2; return; }
841
- process.exitCode = await applyCommand({
842
- api: apiName,
843
- kind,
844
- input: rawOpts.input ?? "-",
845
- yes: rawOpts.yes === true,
846
- force: rawOpts.force === true,
847
- gapFillOnly: rawOpts.gapFillOnly === true,
848
- dbPath: rawOpts.db,
849
- json: globalJson(cmd),
850
- });
851
- });
852
- }
853
-
854
- function pickKind(opts: Record<string, unknown>): SubcommandKind | null {
855
- const flags: Array<[string, SubcommandKind]> = [
856
- ["seedBodies", "seed-bodies"],
857
- ["lifecycle", "lifecycle"],
858
- ["idempotency", "idempotency"],
859
- ["pagination", "pagination"],
860
- ["readback", "readback"],
861
- ["resources", "resources"],
862
- ];
863
- const set = flags.filter(([f]) => opts[f] === true);
864
- if (set.length !== 1) return null;
865
- return set[0]![1];
866
- }
867
-
868
- function csv(v: string): string[] {
869
- return v.split(",").map((s) => s.trim()).filter(Boolean);
870
- }
871
-
872
- function resolveApiArg(rawOpts: Record<string, unknown>, cmd: Command): string | null {
873
- const fromFlag = rawOpts.api;
874
- if (typeof fromFlag === "string" && fromFlag.length > 0) return fromFlag;
875
- const fromParent = cmd.parent?.parent?.parent?.opts().api;
876
- if (typeof fromParent === "string" && fromParent.length > 0) return fromParent;
877
- const fromEnv = process.env.ZOND_API_GLOBAL;
878
- if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
879
- return null;
880
- }