@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ARV-187 / lifecycle: parser + expected shape. No prompts inside zond.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ResourcePatch } from "./overlay.ts";
7
+ import type { ResourceSlice } from "./prompts.ts";
8
+
9
+ const ActionSchema = z.object({
10
+ endpoint: z.string(),
11
+ expected_state: z.string(),
12
+ body: z.record(z.string(), z.unknown()).optional(),
13
+ });
14
+
15
+ const LifecycleSchema = z.object({
16
+ field: z.string(),
17
+ states: z.array(z.string()).min(2),
18
+ transitions: z.array(z.object({ from: z.string(), to: z.array(z.string()) })),
19
+ actions: z.record(z.string(), ActionSchema).default({}),
20
+ });
21
+
22
+ const ResponseSchema = z.object({
23
+ resource: z.string(),
24
+ lifecycle: LifecycleSchema.nullable(),
25
+ rationale: z.string().optional(),
26
+ confidence: z.enum(["low", "medium", "high"]).optional(),
27
+ });
28
+
29
+ export const EXPECTED_OUTPUT_SHAPE = {
30
+ resource: "string (echo input)",
31
+ lifecycle: {
32
+ field: "string (response field holding state, e.g. 'status')",
33
+ states: "string[] (≥2 enum values)",
34
+ transitions: "[{from: state, to: state[]}]",
35
+ actions: "{ <verb>: { endpoint: 'METHOD /path', expected_state: state, body?: object } } — return {} for read-only state machines (observation mode walks the list endpoint and asserts observed ⊆ states; cannot verify transitions in this mode)",
36
+ },
37
+ rationale: "string (optional)",
38
+ confidence: "low | medium | high",
39
+ null_form: "if no observable state machine, return { resource, lifecycle: null }",
40
+ };
41
+
42
+ export function parseLifecycleResponse(parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
43
+ const validated = ResponseSchema.safeParse(parsed);
44
+ if (!validated.success) {
45
+ throw new Error(`lifecycle response failed schema for ${slice.resource}: ${validated.error.message}`);
46
+ }
47
+ const v = validated.data;
48
+ if (v.lifecycle == null) {
49
+ return {
50
+ patch: { resource: slice.resource },
51
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence, dropped: "no state machine" },
52
+ };
53
+ }
54
+ return {
55
+ patch: {
56
+ resource: slice.resource,
57
+ lifecycle: {
58
+ field: v.lifecycle.field,
59
+ states: v.lifecycle.states,
60
+ transitions: v.lifecycle.transitions,
61
+ actions: Object.fromEntries(
62
+ Object.entries(v.lifecycle.actions).map(([name, a]) => [name, {
63
+ endpoint: a.endpoint,
64
+ expected_state: a.expected_state,
65
+ body: a.body,
66
+ }]),
67
+ ),
68
+ },
69
+ },
70
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence },
71
+ };
72
+ }
73
+
74
+ export function isApplicable(_slice: ResourceSlice): boolean { return true; }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * ARV-187: read / merge / write `.api-resources.local.yaml`.
3
+ *
4
+ * The annotate command writes _only_ into the `patches:` block. That's
5
+ * the ARV-169 field-level overlay (see discover.ts:readResourcePatches)
6
+ * — strictly additive, doesn't touch the upstream `.api-resources.yaml`.
7
+ * The `extensions:` block (ARV-111, full-resource replacement) is left
8
+ * intact when we re-write the file, so users can mix annotate-generated
9
+ * patches with hand-written extensions.
10
+ *
11
+ * Idempotent re-annotation: when a patch field already exists in the
12
+ * overlay, we compare values. Equal → no-op (keep existing). Different
13
+ * → conflict (surface in diff, requires --yes to overwrite, or --keep
14
+ * to skip).
15
+ */
16
+
17
+ import { join } from "node:path";
18
+ import { writeFile } from "node:fs/promises";
19
+ import type { ResourceYaml } from "../../discover.ts";
20
+
21
+ export type ResourcePatch = Partial<ResourceYaml> & { resource: string };
22
+
23
+ export interface LocalOverlayFile {
24
+ extensions?: ResourceYaml[];
25
+ patches?: ResourcePatch[];
26
+ /** Any other top-level keys the user added (preserved on rewrite). */
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ const FILENAME = ".api-resources.local.yaml";
31
+
32
+ export async function readLocalOverlay(apiDir: string): Promise<LocalOverlayFile> {
33
+ const file = Bun.file(join(apiDir, FILENAME));
34
+ if (!(await file.exists())) return {};
35
+ const parsed = Bun.YAML.parse(await file.text());
36
+ if (!parsed || typeof parsed !== "object") return {};
37
+ return parsed as LocalOverlayFile;
38
+ }
39
+
40
+ export async function writeLocalOverlay(apiDir: string, overlay: LocalOverlayFile): Promise<void> {
41
+ const path = join(apiDir, FILENAME);
42
+ const header = `# .api-resources.local.yaml — local overlay (ARV-111 / ARV-169 / ARV-187)
43
+ #
44
+ # extensions: full ResourceYaml entries that REPLACE the upstream entry
45
+ # (by resource name) or ADD a new one.
46
+ # patches: partial overlay — fields here are merged onto the matching
47
+ # upstream entry (idempotency / pagination / lifecycle /
48
+ # readback_diff / seed_body).
49
+ #
50
+ # This file is checked into git. Edit by hand or via \`zond api annotate\`.
51
+ `;
52
+ // Bun.YAML doesn't have a stringify; use the yaml package.
53
+ const { stringify } = await import("yaml");
54
+ const body = stringify(overlay, { lineWidth: 0, defaultStringType: "PLAIN" });
55
+ await writeFile(path, header + "\n" + body, "utf-8");
56
+ }
57
+
58
+ export interface MergeConflict {
59
+ resource: string;
60
+ field: string;
61
+ existing: unknown;
62
+ proposed: unknown;
63
+ }
64
+
65
+ export interface MergeResult {
66
+ /** New patches array, with proposed merged over existing. */
67
+ patches: ResourcePatch[];
68
+ /** Per-resource per-field conflicts (existing value differs from proposed). */
69
+ conflicts: MergeConflict[];
70
+ /** Per-resource per-field accepted changes (new field, or overwrite when force=true). */
71
+ changes: MergeConflict[];
72
+ }
73
+
74
+ /**
75
+ * Merge proposed patches into existing patches.
76
+ *
77
+ * Conflict policy:
78
+ * - Field absent in existing → added (counts as a `change`).
79
+ * - Field present and structurally equal → kept (no-op).
80
+ * - Field present and different → if force=true, overwritten and
81
+ * counted as both `change` and `conflict`; else kept and counted
82
+ * as `conflict` only.
83
+ *
84
+ * "Structural equality" uses JSON stringification with stable key order;
85
+ * good enough for yaml-roundtrip data which is plain JSON-shaped.
86
+ */
87
+ export function mergePatches(
88
+ existing: ResourcePatch[],
89
+ proposed: ResourcePatch[],
90
+ opts: { force?: boolean } = {},
91
+ ): MergeResult {
92
+ const force = opts.force === true;
93
+ const byName = new Map<string, ResourcePatch>();
94
+ for (const p of existing) byName.set(p.resource, deepClone(p));
95
+ const conflicts: MergeConflict[] = [];
96
+ const changes: MergeConflict[] = [];
97
+
98
+ for (const proposedPatch of proposed) {
99
+ const name = proposedPatch.resource;
100
+ const current = byName.get(name) ?? { resource: name };
101
+ for (const [field, proposedVal] of Object.entries(proposedPatch)) {
102
+ if (field === "resource") continue;
103
+ const existingVal = (current as Record<string, unknown>)[field];
104
+ if (existingVal === undefined) {
105
+ (current as Record<string, unknown>)[field] = proposedVal;
106
+ changes.push({ resource: name, field, existing: undefined, proposed: proposedVal });
107
+ continue;
108
+ }
109
+ if (structurallyEqual(existingVal, proposedVal)) continue;
110
+ // Conflict.
111
+ conflicts.push({ resource: name, field, existing: existingVal, proposed: proposedVal });
112
+ if (force) {
113
+ (current as Record<string, unknown>)[field] = proposedVal;
114
+ changes.push({ resource: name, field, existing: existingVal, proposed: proposedVal });
115
+ }
116
+ }
117
+ byName.set(name, current);
118
+ }
119
+
120
+ return { patches: [...byName.values()], conflicts, changes };
121
+ }
122
+
123
+ function structurallyEqual(a: unknown, b: unknown): boolean {
124
+ return stableJson(a) === stableJson(b);
125
+ }
126
+
127
+ function stableJson(v: unknown): string {
128
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
129
+ if (Array.isArray(v)) return "[" + v.map(stableJson).join(",") + "]";
130
+ const obj = v as Record<string, unknown>;
131
+ const keys = Object.keys(obj).sort();
132
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableJson(obj[k])).join(",") + "}";
133
+ }
134
+
135
+ function deepClone<T>(v: T): T {
136
+ return structuredClone(v);
137
+ }
138
+
139
+ /**
140
+ * Render a unified, human-readable diff of the changes a merge produced.
141
+ * Returns "" when there are no changes.
142
+ *
143
+ * Format (kept simple so it works in plain stdout, no chalk dep):
144
+ *
145
+ * resource: customers
146
+ * + seed_body:
147
+ * content_type: application/x-www-form-urlencoded
148
+ * body:
149
+ * description: 'zond probe customer'
150
+ * email: 'probe@example.com'
151
+ * ~ idempotency: (conflict — kept existing)
152
+ * existing: { header: Idempotency-Key }
153
+ * proposed: { header: X-Idempotency }
154
+ */
155
+ export function renderChangesDiff(result: MergeResult): string {
156
+ const lines: string[] = [];
157
+ const byResource = new Map<string, { changes: MergeConflict[]; conflicts: MergeConflict[] }>();
158
+ for (const c of result.changes) {
159
+ if (!byResource.has(c.resource)) byResource.set(c.resource, { changes: [], conflicts: [] });
160
+ byResource.get(c.resource)!.changes.push(c);
161
+ }
162
+ for (const c of result.conflicts) {
163
+ if (!byResource.has(c.resource)) byResource.set(c.resource, { changes: [], conflicts: [] });
164
+ byResource.get(c.resource)!.conflicts.push(c);
165
+ }
166
+ for (const [resource, group] of byResource) {
167
+ lines.push(`resource: ${resource}`);
168
+ for (const ch of group.changes) {
169
+ const op = ch.existing === undefined ? "+" : "~";
170
+ lines.push(` ${op} ${ch.field}:`);
171
+ lines.push(indent(yamlSnippet(ch.proposed), 6));
172
+ }
173
+ for (const cf of group.conflicts) {
174
+ // Skip if already rendered as a change (force=true case).
175
+ if (group.changes.some((c) => c.field === cf.field && c.existing !== undefined)) continue;
176
+ lines.push(` ! ${cf.field}: (conflict — kept existing; pass --yes to overwrite)`);
177
+ lines.push(` existing: ${oneLineYaml(cf.existing)}`);
178
+ lines.push(` proposed: ${oneLineYaml(cf.proposed)}`);
179
+ }
180
+ }
181
+ return lines.join("\n");
182
+ }
183
+
184
+ function yamlSnippet(v: unknown): string {
185
+ // Reuse yaml package for nested rendering.
186
+ try {
187
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
188
+ const { stringify } = require("yaml") as { stringify: (v: unknown, opts?: unknown) => string };
189
+ return stringify(v, { lineWidth: 0 }).trimEnd();
190
+ } catch {
191
+ return JSON.stringify(v, null, 2);
192
+ }
193
+ }
194
+
195
+ function oneLineYaml(v: unknown): string {
196
+ try {
197
+ return JSON.stringify(v);
198
+ } catch {
199
+ return String(v);
200
+ }
201
+ }
202
+
203
+ function indent(text: string, n: number): string {
204
+ const pad = " ".repeat(n);
205
+ return text.split("\n").map((l) => pad + l).join("\n");
206
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ARV-187 / pagination: parser + expected shape.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ResourcePatch } from "./overlay.ts";
7
+ import type { ResourceSlice } from "./prompts.ts";
8
+
9
+ const PaginationSchema = z.object({
10
+ type: z.enum(["cursor", "page", "offset", "token"]),
11
+ cursor_param: z.string().optional(),
12
+ cursor_field: z.string().optional(),
13
+ has_more_field: z.string().optional(),
14
+ limit_param: z.string().optional(),
15
+ default_limit: z.number().int().positive().optional(),
16
+ items_field: z.string().optional(),
17
+ page_param: z.string().optional(),
18
+ start_page: z.number().int().nonnegative().optional(),
19
+ });
20
+
21
+ const ResponseSchema = z.object({
22
+ resource: z.string(),
23
+ pagination: PaginationSchema.nullable(),
24
+ rationale: z.string().optional(),
25
+ confidence: z.enum(["low", "medium", "high"]).optional(),
26
+ });
27
+
28
+ export const EXPECTED_OUTPUT_SHAPE = {
29
+ resource: "string (echo input)",
30
+ pagination: {
31
+ type: "cursor | page | offset | token (cursor and page are implemented; offset/token short-circuit)",
32
+ cursor_param: "string (optional — only for type=cursor; query param carrying cursor value)",
33
+ cursor_field: "string (optional — for cursor: next-cursor field; for page: dedupe key across pages; default `id`)",
34
+ has_more_field: "string (optional — only for type=cursor; boolean response field signalling more)",
35
+ limit_param: "string (optional — page size param; default `limit` for cursor, `per_page` for page)",
36
+ default_limit: "integer (optional — probe page size; default 2)",
37
+ items_field: "string (optional — response field carrying array)",
38
+ page_param: "string (optional — only for type=page; default `page`)",
39
+ start_page: "integer (optional — only for type=page; first page number, default 1; some APIs use 0)",
40
+ },
41
+ rationale: "string (optional)",
42
+ confidence: "low | medium | high",
43
+ null_form: "if list endpoint doesn't paginate, return { resource, pagination: null }",
44
+ };
45
+
46
+ export function parsePaginationResponse(parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
47
+ const validated = ResponseSchema.safeParse(parsed);
48
+ if (!validated.success) {
49
+ throw new Error(`pagination response failed schema for ${slice.resource}: ${validated.error.message}`);
50
+ }
51
+ const v = validated.data;
52
+ if (v.pagination == null) {
53
+ return {
54
+ patch: { resource: slice.resource },
55
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence, dropped: "endpoint does not paginate" },
56
+ };
57
+ }
58
+ return {
59
+ patch: { resource: slice.resource, pagination: v.pagination },
60
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence },
61
+ };
62
+ }
63
+
64
+ export function isApplicable(slice: ResourceSlice): boolean { return Boolean(slice.endpoints.list); }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * ARV-187: shared prompt utilities — extract per-resource CRUD slices
3
+ * from a parsed OpenAPI document so each subcommand can build a tight,
4
+ * cheap prompt for the LLM.
5
+ *
6
+ * Per-resource slicing (vs. send-the-whole-spec) is the cost-control
7
+ * pattern AutoRestTest / KAT both use: keeps each call <4k tokens and
8
+ * lets us cache by `sha256(slice + prompt_version + model)`.
9
+ */
10
+
11
+ import type { OpenAPIV3 } from "openapi-types";
12
+ import type { ResourceYaml } from "../../discover.ts";
13
+
14
+ export interface ResourceSlice {
15
+ resource: string;
16
+ basePath: string;
17
+ itemPath: string;
18
+ /** Concrete EndpointInfo-like dump per role with the *relevant* spec
19
+ * bits inlined (summary, description, parameters, request schema,
20
+ * example bodies, x-codeSamples). Designed to be JSON.stringify'd
21
+ * straight into the prompt. */
22
+ endpoints: {
23
+ list?: EndpointDump;
24
+ create?: EndpointDump;
25
+ read?: EndpointDump;
26
+ update?: EndpointDump;
27
+ delete?: EndpointDump;
28
+ };
29
+ }
30
+
31
+ export interface EndpointDump {
32
+ method: string;
33
+ path: string;
34
+ operationId?: string;
35
+ summary?: string;
36
+ description?: string;
37
+ parameters?: Array<{
38
+ name: string;
39
+ in: string;
40
+ required?: boolean;
41
+ description?: string;
42
+ schema?: unknown;
43
+ }>;
44
+ requestBody?: {
45
+ contentType?: string;
46
+ description?: string;
47
+ schema?: unknown;
48
+ example?: unknown;
49
+ };
50
+ responses?: Record<string, { description?: string; schema?: unknown }>;
51
+ /** Stripe/Redocly convention — prose curl examples live here. */
52
+ xCodeSamples?: unknown;
53
+ }
54
+
55
+ /**
56
+ * Build per-resource CRUD slices from spec + the upstream resource map.
57
+ * We trust the resource map's endpoint pointers (already computed by
58
+ * the catalog builder) rather than re-doing path-grouping. Each slice
59
+ * carries the spec-fragments the LLM needs to reason about that
60
+ * resource — no more, no less.
61
+ */
62
+ export function buildResourceSlices(
63
+ doc: OpenAPIV3.Document,
64
+ resources: ResourceYaml[],
65
+ ): ResourceSlice[] {
66
+ return resources.map((r) => {
67
+ const out: ResourceSlice = {
68
+ resource: r.resource,
69
+ basePath: r.basePath,
70
+ itemPath: r.itemPath,
71
+ endpoints: {},
72
+ };
73
+ for (const role of ["list", "create", "read", "update", "delete"] as const) {
74
+ const label = r.endpoints[role];
75
+ if (!label) continue;
76
+ const parsed = parseLabel(label);
77
+ if (!parsed) continue;
78
+ const dump = dumpEndpoint(doc, parsed.method, parsed.path);
79
+ if (dump) out.endpoints[role] = dump;
80
+ }
81
+ return out;
82
+ });
83
+ }
84
+
85
+ function parseLabel(label: string): { method: string; path: string } | null {
86
+ const parts = label.trim().split(/\s+/);
87
+ if (parts.length < 2) return null;
88
+ return { method: parts[0]!.toLowerCase(), path: parts[1]! };
89
+ }
90
+
91
+ function dumpEndpoint(doc: OpenAPIV3.Document, method: string, path: string): EndpointDump | null {
92
+ const pathItem = doc.paths?.[path] as OpenAPIV3.PathItemObject | undefined;
93
+ if (!pathItem) return null;
94
+ const op = (pathItem as Record<string, unknown>)[method] as OpenAPIV3.OperationObject | undefined;
95
+ if (!op) return null;
96
+
97
+ const parameters: EndpointDump["parameters"] = [];
98
+ for (const raw of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])]) {
99
+ const p = resolveParameter(doc, raw);
100
+ if (!p) continue;
101
+ parameters.push({
102
+ name: p.name,
103
+ in: p.in,
104
+ required: p.required,
105
+ description: truncate(p.description, 240),
106
+ schema: simplifySchema(p.schema as OpenAPIV3.SchemaObject | undefined),
107
+ });
108
+ }
109
+
110
+ let requestBody: EndpointDump["requestBody"];
111
+ if (op.requestBody && !("$ref" in op.requestBody)) {
112
+ const rb = op.requestBody as OpenAPIV3.RequestBodyObject;
113
+ const contentEntries = Object.entries(rb.content ?? {});
114
+ const [ct, media] = contentEntries[0] ?? [];
115
+ if (media) {
116
+ requestBody = {
117
+ contentType: ct,
118
+ description: truncate(rb.description, 240),
119
+ schema: simplifySchema(media.schema as OpenAPIV3.SchemaObject | undefined),
120
+ example: media.example,
121
+ };
122
+ }
123
+ }
124
+
125
+ const responses: EndpointDump["responses"] = {};
126
+ for (const [code, resp] of Object.entries(op.responses ?? {})) {
127
+ if (resp && !("$ref" in resp)) {
128
+ const r = resp as OpenAPIV3.ResponseObject;
129
+ const content = Object.values(r.content ?? {})[0];
130
+ responses[code] = {
131
+ description: truncate(r.description, 240),
132
+ schema: simplifySchema(content?.schema as OpenAPIV3.SchemaObject | undefined),
133
+ };
134
+ }
135
+ }
136
+
137
+ return {
138
+ method: method.toUpperCase(),
139
+ path,
140
+ operationId: op.operationId,
141
+ summary: truncate(op.summary, 240),
142
+ description: truncate(op.description, 600),
143
+ parameters: parameters.length > 0 ? parameters : undefined,
144
+ requestBody,
145
+ responses: Object.keys(responses).length > 0 ? responses : undefined,
146
+ xCodeSamples: (op as Record<string, unknown>)["x-codeSamples"],
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Strip refs and nested noise from a schema dump. We don't need full
152
+ * fidelity — the LLM just needs to know the field names + types + any
153
+ * descriptions that hint at example values. Drops `additionalProperties`,
154
+ * collapses `oneOf/anyOf` to the first variant, truncates descriptions.
155
+ */
156
+ function simplifySchema(s: OpenAPIV3.SchemaObject | undefined): unknown {
157
+ if (!s) return undefined;
158
+ if ((s as OpenAPIV3.ReferenceObject).$ref) return { $ref: (s as OpenAPIV3.ReferenceObject).$ref };
159
+ const out: Record<string, unknown> = {};
160
+ if (s.type) out.type = s.type;
161
+ if (s.format) out.format = s.format;
162
+ if (s.enum) out.enum = s.enum;
163
+ if (s.example !== undefined) out.example = s.example;
164
+ if (s.default !== undefined) out.default = s.default;
165
+ if (s.description) out.description = truncate(s.description, 240);
166
+ if (s.required) out.required = s.required;
167
+ if (s.properties) {
168
+ out.properties = Object.fromEntries(
169
+ Object.entries(s.properties).map(([k, v]) => [k, simplifySchema(v as OpenAPIV3.SchemaObject)]),
170
+ );
171
+ }
172
+ const asArr = s as OpenAPIV3.ArraySchemaObject;
173
+ if (asArr.items) out.items = simplifySchema(asArr.items as OpenAPIV3.SchemaObject);
174
+ const o = s as Record<string, unknown>;
175
+ if (Array.isArray(o.oneOf) && o.oneOf.length > 0) out.oneOf_first = simplifySchema(o.oneOf[0] as OpenAPIV3.SchemaObject);
176
+ if (Array.isArray(o.anyOf) && o.anyOf.length > 0) out.anyOf_first = simplifySchema(o.anyOf[0] as OpenAPIV3.SchemaObject);
177
+ return out;
178
+ }
179
+
180
+ function truncate(s: string | undefined, n: number): string | undefined {
181
+ if (!s) return undefined;
182
+ return s.length > n ? s.slice(0, n) + "…" : s;
183
+ }
184
+
185
+ /**
186
+ * Resolve a parameter that may be inline or a $ref. Handles
187
+ * `#/components/parameters/X` (canonical) and, leniently,
188
+ * `#/components/headers/X` — some specs (Stripe) keep their
189
+ * `Idempotency-Key` declaration under `components.headers` and reuse
190
+ * it as a parameter $ref. Returns null on unresolvable refs.
191
+ */
192
+ function resolveParameter(
193
+ doc: OpenAPIV3.Document,
194
+ raw: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject,
195
+ ): OpenAPIV3.ParameterObject | null {
196
+ if (!("$ref" in raw)) return raw;
197
+ const ref = raw.$ref;
198
+ const paramMatch = ref.match(/^#\/components\/parameters\/(.+)$/);
199
+ if (paramMatch) {
200
+ const target = doc.components?.parameters?.[paramMatch[1]!];
201
+ if (target && !("$ref" in target)) return target as OpenAPIV3.ParameterObject;
202
+ }
203
+ const headerMatch = ref.match(/^#\/components\/headers\/(.+)$/);
204
+ if (headerMatch) {
205
+ const name = headerMatch[1]!;
206
+ const target = doc.components?.headers?.[name];
207
+ if (target && !("$ref" in target)) {
208
+ const h = target as OpenAPIV3.HeaderObject;
209
+ return {
210
+ name,
211
+ in: "header",
212
+ required: h.required,
213
+ description: h.description,
214
+ schema: h.schema,
215
+ } as OpenAPIV3.ParameterObject;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ARV-187 / readback: parser + expected shape.
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type { ResourcePatch } from "./overlay.ts";
7
+ import type { ResourceSlice } from "./prompts.ts";
8
+
9
+ const ReadbackSchema = z.object({
10
+ ignore_fields: z.array(z.string()).default([]),
11
+ write_to_read_map: z.record(z.string(), z.string()).default({}),
12
+ });
13
+
14
+ const ResponseSchema = z.object({
15
+ resource: z.string(),
16
+ readback_diff: ReadbackSchema.nullable(),
17
+ rationale: z.string().optional(),
18
+ confidence: z.enum(["low", "medium", "high"]).optional(),
19
+ });
20
+
21
+ export const EXPECTED_OUTPUT_SHAPE = {
22
+ resource: "string (echo input)",
23
+ readback_diff: {
24
+ ignore_fields: "string[] (write-only fields cross_call_references should ignore)",
25
+ write_to_read_map: "{ <write_field>: <read_field> } (renames on the read shape)",
26
+ },
27
+ rationale: "string (optional)",
28
+ confidence: "low | medium | high",
29
+ null_form: "if read shape echoes write shape, return { resource, readback_diff: null }",
30
+ };
31
+
32
+ export function parseReadbackResponse(parsed: unknown, slice: ResourceSlice): { patch: ResourcePatch; audit: Record<string, unknown> } {
33
+ const validated = ResponseSchema.safeParse(parsed);
34
+ if (!validated.success) {
35
+ throw new Error(`readback response failed schema for ${slice.resource}: ${validated.error.message}`);
36
+ }
37
+ const v = validated.data;
38
+ if (v.readback_diff == null) {
39
+ return {
40
+ patch: { resource: slice.resource },
41
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence, dropped: "no drift expected" },
42
+ };
43
+ }
44
+ if ((v.readback_diff.ignore_fields?.length ?? 0) === 0 && Object.keys(v.readback_diff.write_to_read_map ?? {}).length === 0) {
45
+ return {
46
+ patch: { resource: slice.resource },
47
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence, dropped: "empty readback hints" },
48
+ };
49
+ }
50
+ return {
51
+ patch: { resource: slice.resource, readback_diff: v.readback_diff },
52
+ audit: { resource: slice.resource, rationale: v.rationale, confidence: v.confidence },
53
+ };
54
+ }
55
+
56
+ export function isApplicable(slice: ResourceSlice): boolean {
57
+ return Boolean(slice.endpoints.create && slice.endpoints.read);
58
+ }