@kirrosh/zond 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -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 +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -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 +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Build `.api-resources.yaml` — the CRUD-chain map of an API.
3
+ *
4
+ * Purpose: skill code (scenario authoring, audit setup) reads this instead
5
+ * of grep'ing the OpenAPI spec to answer "what resources can I CRUD, what
6
+ * field captures the id, are there ETag / soft-delete pitfalls". The
7
+ * extended form also lists FK dependencies so a scenario can plan a
8
+ * setup chain (audience → contact requires audience_id, etc.).
9
+ *
10
+ * The file is git-trackable evidence of the API's surface; regenerated
11
+ * by `zond add api` and (later) `zond refresh-api`.
12
+ */
13
+
14
+ import type { EndpointInfo, CrudGroup } from "./types.ts";
15
+ import type { OpenAPIV3 } from "openapi-types";
16
+ import { detectCrudGroups } from "./suite-generator.ts";
17
+
18
+ export interface ResourceFkRef {
19
+ /** Variable name expected in `.env.yaml` to satisfy the FK (e.g. `audience_id`). */
20
+ var: string;
21
+ /** Path-parameter or body-field name that consumes the FK in the API. */
22
+ param: string;
23
+ /** Where the value gets injected: path | body. */
24
+ in: "path" | "body";
25
+ /** Resource name we believe owns this id (best-effort, may be null). */
26
+ ownerResource: string | null;
27
+ }
28
+
29
+ /**
30
+ * ARV-169 (m-20 cross-call drift): per-resource overrides for the
31
+ * POST→GET shape-diff probe. All fields optional — when absent the
32
+ * check falls back to `DEFAULT_READBACK_IGNORE` (timestamp / etag /
33
+ * envelope quirks) so a probe works on a stock spec without yaml work.
34
+ * Authored by `zond api annotate --readback` (ARV-187) or by hand.
35
+ */
36
+ export interface ReadbackDiffConfig {
37
+ /** Field names dropped before diff. Suppresses known API-quirks
38
+ * (Stripe `metadata` stripping, livemode, object discriminators)
39
+ * so they don't drown out real drift. */
40
+ ignoreFields?: string[];
41
+ /** Write-shape → read-shape rename. Stripe takes `tax_id_data` on
42
+ * create but exposes it as `tax_ids` on read; without this the
43
+ * field looks like state-not-persisted on every probe. */
44
+ writeToReadMap?: Record<string, string>;
45
+ }
46
+
47
+ /**
48
+ * ARV-170 (m-20 idempotency-replay): per-resource declaration that the
49
+ * create endpoint honors an Idempotency-Key header. When present, the
50
+ * `idempotency_replay` stateful check sends POST twice with the same
51
+ * key and asserts (a) no duplicate resource is created and (b) the two
52
+ * responses are bit-identical modulo `ignoreResponseFields`.
53
+ *
54
+ * Auto-detect fallback: if `idempotency:` is absent from yaml but the
55
+ * create endpoint declares an `Idempotency-Key` header parameter in
56
+ * the spec, the check still runs with `header="Idempotency-Key"` and
57
+ * the default ignore list. Explicit yaml is preferred — it documents
58
+ * intent and lets the user customise the ignore list per API quirks
59
+ * (Stripe `request_id`, Resend `retry_after`).
60
+ */
61
+ export interface IdempotencyConfig {
62
+ /** Header that carries the key. Default `Idempotency-Key`. */
63
+ header?: string;
64
+ /** Informational. `endpoint` = key scoped per-endpoint (Stripe).
65
+ * `global` = same key replays across endpoints. Today the check
66
+ * uses the same flow either way; field is read for diagnostics. */
67
+ scope?: "endpoint" | "global";
68
+ /** Response-body field names stripped before the R1==R2 compare.
69
+ * Defaults to a baseline list shared with readback-diff
70
+ * (timestamps, request_id, etag) when omitted. */
71
+ ignoreResponseFields?: string[];
72
+ }
73
+
74
+ /**
75
+ * ARV-171 (m-20 pagination-invariants): per-list-endpoint declaration
76
+ * of the pagination strategy. The `pagination_invariants` stateful
77
+ * check uses this to ask for two consecutive pages and assert
78
+ * disjointness + has_more consistency.
79
+ *
80
+ * Supported types in this milestone:
81
+ * • `cursor` — Stripe/GitHub style: caller passes a cursor (e.g.
82
+ * `starting_after=<id>`) derived from the last item of the
83
+ * previous page. The check is built around this pattern.
84
+ * • `page` and `offset` — declared for forward compatibility; the
85
+ * check currently skips with a "pagination type not implemented"
86
+ * reason so the yaml block stays a stable schema.
87
+ *
88
+ * Auto-detect fallback: if the list endpoint declares `starting_after`
89
+ * / `cursor` / `page_token` query parameters in the spec, the check
90
+ * uses sensible defaults (cursor_field=`id`, items_field=`data` →
91
+ * `items` → `results`, has_more_field=`has_more`). Explicit yaml is
92
+ * preferred — it documents intent and survives spec changes that
93
+ * rename query params.
94
+ */
95
+ /**
96
+ * ARV-172 (m-20 lifecycle-transitions): declared state machine for a
97
+ * resource. Used by the `lifecycle_transitions` stateful check to
98
+ * verify that documented actions (cancel / archive / publish / ...)
99
+ * move a resource between declared states and that double-invoking an
100
+ * action either 4xx's or stays idempotent (no state regression).
101
+ *
102
+ * The yaml block has three parts:
103
+ * • `field` + `states` — name of the response field carrying the
104
+ * state, plus the closed enum of legal values.
105
+ * • `transitions` — a from→to graph; the check uses it to flag
106
+ * forbidden transitions (cancelled → active) when an action lands
107
+ * a resource somewhere the graph doesn't allow.
108
+ * • `actions` — POST endpoints that should drive a transition.
109
+ * `expected_state` is the state the resource must be in after a
110
+ * successful action call.
111
+ *
112
+ * Manifest validation runs at load time and surfaces obvious
113
+ * authoring bugs (unreachable states, missing terminal, action
114
+ * referencing an undeclared state) before any HTTP call goes out.
115
+ */
116
+ export interface LifecycleAction {
117
+ /** Endpoint label, e.g. "POST /v1/subscriptions/{id}/cancel". The
118
+ * `{id}` placeholder is substituted with the created resource id. */
119
+ endpoint: string;
120
+ /** State the resource must be in after this action lands. */
121
+ expectedState: string;
122
+ /** Optional request body sent with the action POST. Most lifecycle
123
+ * actions are body-less (cancel, archive, publish); leave empty
124
+ * when not needed. Serialised as JSON or form depending on the
125
+ * endpoint's declared content type. */
126
+ body?: Record<string, unknown>;
127
+ }
128
+
129
+ export interface LifecycleConfig {
130
+ /** Response field name carrying the state (e.g. `status`). */
131
+ field: string;
132
+ /** Closed enum of legal state values. Any state observed on the
133
+ * wire that isn't in this list is a finding. */
134
+ states: string[];
135
+ /** Allowed from→to graph. States not listed as `from` are assumed
136
+ * terminal (no outgoing transition). States not listed as `to` of
137
+ * any transition are starting-only (unreachable post-create). */
138
+ transitions: { from: string; to: string[] }[];
139
+ /** Named actions keyed by action name (cancel / archive / publish).
140
+ * The check runs through them in object-key order. */
141
+ actions: Record<string, LifecycleAction>;
142
+ }
143
+
144
+ /**
145
+ * Static validation of a lifecycle manifest. Returns the list of
146
+ * authoring bugs without throwing — callers decide whether to fail
147
+ * the run or just warn. Empty array = clean manifest.
148
+ */
149
+ export function validateLifecycleManifest(cfg: LifecycleConfig): string[] {
150
+ const errors: string[] = [];
151
+ if (!cfg.field || cfg.field.length === 0) errors.push("lifecycle.field is empty");
152
+ if (!cfg.states || cfg.states.length === 0) errors.push("lifecycle.states is empty");
153
+ const stateSet = new Set(cfg.states ?? []);
154
+ for (const t of cfg.transitions ?? []) {
155
+ if (!stateSet.has(t.from)) errors.push(`transitions: unknown "from" state "${t.from}"`);
156
+ for (const to of t.to) {
157
+ if (!stateSet.has(to)) errors.push(`transitions[${t.from}]: unknown "to" state "${to}"`);
158
+ }
159
+ }
160
+ // At least one terminal — a state with no outgoing transition (or
161
+ // an explicit `to: []`). A graph with every state having outgoing
162
+ // edges is suspicious (no end-of-life, infinite churn).
163
+ const hasOutgoing = new Set((cfg.transitions ?? []).filter((t) => t.to.length > 0).map((t) => t.from));
164
+ const terminals = (cfg.states ?? []).filter((s) => !hasOutgoing.has(s));
165
+ if (terminals.length === 0) errors.push("no terminal state — every declared state has outgoing transitions");
166
+ // Actions must reference declared states.
167
+ for (const [name, a] of Object.entries(cfg.actions ?? {})) {
168
+ if (!stateSet.has(a.expectedState)) {
169
+ errors.push(`actions.${name}.expected_state "${a.expectedState}" is not in states[]`);
170
+ }
171
+ if (!a.endpoint || a.endpoint.length === 0) {
172
+ errors.push(`actions.${name}.endpoint is empty`);
173
+ }
174
+ }
175
+ return errors;
176
+ }
177
+
178
+ export interface PaginationConfig {
179
+ /** Pagination flavor. Default `cursor`. */
180
+ type?: "cursor" | "page" | "offset" | "token";
181
+ /** Query-param name that takes the cursor value. Default `starting_after`. */
182
+ cursorParam?: string;
183
+ /** Response field on each item that becomes the next cursor. Default `id`. */
184
+ cursorField?: string;
185
+ /** Response field that signals "more pages remain". Default `has_more`. */
186
+ hasMoreField?: string;
187
+ /** Query-param name for page size. Default `limit`. */
188
+ limitParam?: string;
189
+ /** Probe page-size. Default 2 (small enough to land two replies fast). */
190
+ defaultLimit?: number;
191
+ /** Response field carrying the array of items. Default `data` (Stripe);
192
+ * falls back to `items` / `results` when missing. */
193
+ itemsField?: string;
194
+ }
195
+
196
+ /** ARV-187: LLM-authored example POST body. Stateful checks prefer this
197
+ * over generateFromSchema(create) when present. */
198
+ export interface SeedBodyConfig {
199
+ /** Defaults to the create endpoint's requestBodyContentType. */
200
+ contentType?: string;
201
+ body: Record<string, unknown>;
202
+ }
203
+
204
+ export interface ApiResourceEntry {
205
+ resource: string;
206
+ basePath: string;
207
+ itemPath: string;
208
+ idParam: string;
209
+ /** What field on the create response carries the new id (typically `id`). */
210
+ captureField: string;
211
+ /** True when the resource exposes List + Create + Read at minimum. */
212
+ hasFullCrud: boolean;
213
+ endpoints: {
214
+ list?: string; // "GET /audiences"
215
+ create?: string;
216
+ read?: string;
217
+ update?: string;
218
+ delete?: string;
219
+ };
220
+ /** Update/Delete demand If-Match? (heuristic: 412 in spec or ETag in headers). */
221
+ requiresEtag?: boolean;
222
+ /** Heuristic: read-after-delete returns 200 instead of 404 (filled at runtime, default false). */
223
+ softDelete?: boolean;
224
+ /** Other resources whose ids this resource consumes (FK chain). */
225
+ fkDependencies: ResourceFkRef[];
226
+ /** ARV-169: optional cross-call-drift overrides. */
227
+ readbackDiff?: ReadbackDiffConfig;
228
+ /** ARV-170: opt-in idempotency-replay probe. */
229
+ idempotency?: IdempotencyConfig;
230
+ /** ARV-171: pagination-invariants probe. */
231
+ pagination?: PaginationConfig;
232
+ /** ARV-172: state-machine for the resource. */
233
+ lifecycle?: LifecycleConfig;
234
+ }
235
+
236
+ export interface ApiResourceMap {
237
+ generatedAt: string;
238
+ specHash: string;
239
+ resourceCount: number;
240
+ resources: ApiResourceEntry[];
241
+ /** Endpoints that didn't fit any CRUD group (action endpoints, RPC-style). */
242
+ orphanEndpoints: string[];
243
+ }
244
+
245
+ function epLabel(ep: EndpointInfo): string {
246
+ return `${ep.method.toUpperCase()} ${ep.path}`;
247
+ }
248
+
249
+ function pathStripSlash(p: string): string {
250
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
251
+ }
252
+
253
+ function isParamSeg(seg: string | undefined): boolean {
254
+ return !!seg && /^\{[^}]+\}$/.test(seg);
255
+ }
256
+
257
+ function getCaptureField(create: EndpointInfo): string {
258
+ // Look at the create endpoint's success response schema for an `id`-ish
259
+ // field. Falls back to "id" — the universal default.
260
+ const success = create.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
261
+ const schema = success?.schema as OpenAPIV3.SchemaObject | undefined;
262
+ if (schema?.properties) {
263
+ const props = schema.properties as Record<string, OpenAPIV3.SchemaObject>;
264
+ for (const candidate of ["id", "uuid", "key", "code"]) {
265
+ if (props[candidate]) return candidate;
266
+ }
267
+ }
268
+ return "id";
269
+ }
270
+
271
+ /**
272
+ * Structurally infer the list-endpoint that owns each path-parameter by
273
+ * walking the actual URL graph in the spec. Beats name-stemming because
274
+ *
275
+ * • `_id_or_slug`, `_or_name`, non-English plurals, weird casing — all
276
+ * transparent: we only look at segment positions, not at param names.
277
+ * • Returns the *exact* GET path to call, not a guessed resource name we
278
+ * later have to hope is wired up correctly.
279
+ * • Two-strategy lookup so it survives both canonical nesting
280
+ * (`/orgs/{org}/projects/{proj}/...` — prev seg `projects` is a list)
281
+ * and common SaaS-style sibling-param chains
282
+ * (`/projects/{org}/{proj}/...` — prev seg is itself a param;
283
+ * we walk back to the nearest non-param segment and search for any
284
+ * GET path ending with that hint).
285
+ */
286
+ export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, string> {
287
+ const getPathSet = new Set<string>();
288
+ const getPathsByLastSeg = new Map<string, string[]>();
289
+ for (const ep of endpoints) {
290
+ if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
291
+ const path = pathStripSlash(ep.path);
292
+ getPathSet.add(path);
293
+ const segs = path.split("/").filter(Boolean);
294
+ const last = segs[segs.length - 1];
295
+ if (last && !isParamSeg(last)) {
296
+ const arr = getPathsByLastSeg.get(last) ?? [];
297
+ arr.push(path);
298
+ getPathsByLastSeg.set(last, arr);
299
+ }
300
+ }
301
+
302
+ const result = new Map<string, string>();
303
+ const consider = (param: string, candidate: string) => {
304
+ const existing = result.get(param);
305
+ // Prefer shorter (more canonical/top-level) list path.
306
+ if (!existing || candidate.length < existing.length) result.set(param, candidate);
307
+ };
308
+
309
+ for (const ep of endpoints) {
310
+ if (ep.deprecated) continue;
311
+ const segs = pathStripSlash(ep.path).split("/");
312
+ for (let i = 0; i < segs.length; i++) {
313
+ const seg = segs[i]!;
314
+ const m = /^\{([^}]+)\}$/.exec(seg);
315
+ if (!m) continue;
316
+ const param = m[1]!;
317
+ const prevSeg = segs[i - 1];
318
+
319
+ // Strategy 1 (canonical): prev seg is a non-param noun and the
320
+ // prefix up to (but not including) `{param}` is a GET endpoint.
321
+ if (prevSeg && !isParamSeg(prevSeg)) {
322
+ const prefix = segs.slice(0, i).join("/");
323
+ if (getPathSet.has(prefix)) {
324
+ consider(param, prefix);
325
+ continue;
326
+ }
327
+ }
328
+
329
+ // Strategy 2 (sibling-param chain): walk back to the nearest
330
+ // non-param segment, then look for *any* GET path that terminates
331
+ // with that segment. Pick the shortest match.
332
+ let hint: string | undefined;
333
+ for (let j = i - 1; j >= 0; j--) {
334
+ const s = segs[j]!;
335
+ if (!isParamSeg(s) && s !== "") {
336
+ hint = s;
337
+ break;
338
+ }
339
+ }
340
+ if (!hint) continue;
341
+ const candidates = getPathsByLastSeg.get(hint);
342
+ if (!candidates || candidates.length === 0) continue;
343
+ const shortest = candidates.reduce((a, b) => (a.length <= b.length ? a : b));
344
+ consider(param, shortest);
345
+ }
346
+ }
347
+
348
+ return result;
349
+ }
350
+
351
+ function listPathToResourceName(listPath: string): string {
352
+ const segs = pathStripSlash(listPath).split("/").filter(Boolean);
353
+ for (let i = segs.length - 1; i >= 0; i--) {
354
+ if (!isParamSeg(segs[i])) return segs[i]!;
355
+ }
356
+ return "resource";
357
+ }
358
+
359
+ /**
360
+ * Body-FK fallback. Used only when a body field's name doesn't appear
361
+ * as a path-param anywhere (so the structural resolver has nothing to
362
+ * say). Cheap heuristic — kept narrow on purpose.
363
+ */
364
+ function inferFkOwnerByName(paramName: string, allResources: string[]): string | null {
365
+ const stem = paramName
366
+ .replace(/_id_or_slug$|_id_or_name$|_or_slug$|_or_name$/, "")
367
+ .replace(/_id$|Id$|_uuid$|_slug$/, "")
368
+ .toLowerCase();
369
+ if (!stem) return null;
370
+ for (const res of allResources) {
371
+ const r = res.toLowerCase();
372
+ if (r === stem || r === `${stem}s` || `${r}s` === stem || r.replace(/s$/, "") === stem) {
373
+ return res;
374
+ }
375
+ }
376
+ return null;
377
+ }
378
+
379
+ function collectPathFkDeps(
380
+ basePath: string,
381
+ idParam: string,
382
+ ownerListPaths: Map<string, string>,
383
+ resourceByListPath: Map<string, string>,
384
+ ): ResourceFkRef[] {
385
+ const deps: ResourceFkRef[] = [];
386
+ const seen = new Set<string>();
387
+ const pathParamRe = /\{([^}]+)\}/g;
388
+ let match: RegExpExecArray | null;
389
+ while ((match = pathParamRe.exec(basePath)) !== null) {
390
+ const param = match[1]!;
391
+ if (param === idParam) continue;
392
+ if (seen.has(param)) continue;
393
+ seen.add(param);
394
+ const listPath = ownerListPaths.get(param);
395
+ const ownerResource = listPath ? (resourceByListPath.get(listPath) ?? null) : null;
396
+ deps.push({ var: param, param, in: "path", ownerResource });
397
+ }
398
+ return deps;
399
+ }
400
+
401
+ function collectBodyFkDeps(
402
+ group: CrudGroup,
403
+ ownerListPaths: Map<string, string>,
404
+ resourceByListPath: Map<string, string>,
405
+ allResources: string[],
406
+ ): ResourceFkRef[] {
407
+ const deps: ResourceFkRef[] = [];
408
+ if (!group.create?.requestBodySchema) return deps;
409
+ const schema = group.create.requestBodySchema as OpenAPIV3.SchemaObject;
410
+ const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
411
+ const required = new Set(schema.required ?? []);
412
+ for (const [name] of Object.entries(props)) {
413
+ if (!/_id$|Id$|_uuid$/.test(name)) continue;
414
+ if (!required.has(name)) continue;
415
+ // Try structural resolution first (the body field name often matches a
416
+ // path-param elsewhere — `audience_id` body field, `audience_id` path
417
+ // param both point at /audiences/). Fall back to name-stemming.
418
+ let ownerResource: string | null = null;
419
+ const listPath = ownerListPaths.get(name);
420
+ if (listPath) ownerResource = resourceByListPath.get(listPath) ?? null;
421
+ if (!ownerResource) ownerResource = inferFkOwnerByName(name, allResources);
422
+ deps.push({ var: name, param: name, in: "body", ownerResource });
423
+ }
424
+ return deps;
425
+ }
426
+
427
+ export interface BuildResourcesParams {
428
+ endpoints: EndpointInfo[];
429
+ specHash: string;
430
+ }
431
+
432
+ export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMap {
433
+ const groups = detectCrudGroups(params.endpoints);
434
+ const ownerListPaths = resolveOwnerListPaths(params.endpoints);
435
+
436
+ // Index CRUD-group list paths by normalised path so the FK resolver can
437
+ // hand back the resource name a structural lookup pointed at.
438
+ const resourceByListPath = new Map<string, string>();
439
+ for (const g of groups) {
440
+ if (g.list) resourceByListPath.set(pathStripSlash(g.list.path), g.resource);
441
+ }
442
+
443
+ // Imp resources: any list path that path-FKs point at structurally but
444
+ // no CRUD group claims (top-level GET-only collections like
445
+ // `/api/0/organizations/`, nested list-only collections, etc.). Without
446
+ // these, every FK that depends on a non-CRUD parent ends up with
447
+ // `ownerResource: null` and `discover` skips them — the actual root
448
+ // cause of the "discover --apply is a no-op" symptom.
449
+ const implicitResources: ApiResourceEntry[] = [];
450
+ const seenImplicit = new Set<string>();
451
+ for (const [, listPath] of ownerListPaths) {
452
+ if (resourceByListPath.has(listPath)) continue;
453
+ if (seenImplicit.has(listPath)) continue;
454
+ seenImplicit.add(listPath);
455
+ const listEp = params.endpoints.find(
456
+ e =>
457
+ e.method.toUpperCase() === "GET" &&
458
+ !e.deprecated &&
459
+ pathStripSlash(e.path) === listPath,
460
+ );
461
+ if (!listEp) continue;
462
+ const name = listPathToResourceName(listPath);
463
+ implicitResources.push({
464
+ resource: name,
465
+ basePath: listPath,
466
+ itemPath: "",
467
+ idParam: "",
468
+ captureField: "id",
469
+ hasFullCrud: false,
470
+ endpoints: { list: epLabel(listEp) },
471
+ fkDependencies: [],
472
+ });
473
+ resourceByListPath.set(listPath, name);
474
+ }
475
+
476
+ const resourceNames = [
477
+ ...groups.map(g => g.resource),
478
+ ...implicitResources.map(r => r.resource),
479
+ ];
480
+
481
+ const crudResources: ApiResourceEntry[] = groups.map(g => {
482
+ const captureField = g.create ? getCaptureField(g.create) : "id";
483
+ const requiresEtag = !!(g.update?.requiresEtag || g.delete?.requiresEtag);
484
+ return {
485
+ resource: g.resource,
486
+ basePath: g.basePath,
487
+ itemPath: g.itemPath,
488
+ idParam: g.idParam,
489
+ captureField,
490
+ hasFullCrud: !!(g.list && g.create && g.read),
491
+ endpoints: {
492
+ ...(g.list ? { list: epLabel(g.list) } : {}),
493
+ ...(g.create ? { create: epLabel(g.create) } : {}),
494
+ ...(g.read ? { read: epLabel(g.read) } : {}),
495
+ ...(g.update ? { update: epLabel(g.update) } : {}),
496
+ ...(g.delete ? { delete: epLabel(g.delete) } : {}),
497
+ },
498
+ ...(requiresEtag ? { requiresEtag: true } : {}),
499
+ fkDependencies: [
500
+ ...collectPathFkDeps(g.basePath, g.idParam, ownerListPaths, resourceByListPath),
501
+ ...collectBodyFkDeps(g, ownerListPaths, resourceByListPath, resourceNames),
502
+ ],
503
+ };
504
+ });
505
+
506
+ // Implicit resources also chain — `/orgs/{org}/projects/` lists projects
507
+ // but needs `organization_id_or_slug` set to call. Surface that so
508
+ // `discover` knows to fetch the parent first.
509
+ for (const r of implicitResources) {
510
+ r.fkDependencies = collectPathFkDeps(r.basePath, "", ownerListPaths, resourceByListPath);
511
+ }
512
+
513
+ const resources = [...crudResources, ...implicitResources];
514
+
515
+ // Endpoints that aren't in any CRUD group — RPC-style actions, webhook
516
+ // accept-only routes, etc. Implicit-list endpoints stay in orphans
517
+ // because they're not full CRUD; they're surfaced through resources for
518
+ // discovery purposes only.
519
+ const claimedEps = new Set<string>();
520
+ for (const g of groups) {
521
+ if (g.list) claimedEps.add(epLabel(g.list));
522
+ if (g.create) claimedEps.add(epLabel(g.create));
523
+ if (g.read) claimedEps.add(epLabel(g.read));
524
+ if (g.update) claimedEps.add(epLabel(g.update));
525
+ if (g.delete) claimedEps.add(epLabel(g.delete));
526
+ }
527
+ const orphanEndpoints = params.endpoints
528
+ .filter(ep => !claimedEps.has(epLabel(ep)))
529
+ .map(epLabel);
530
+
531
+ return {
532
+ generatedAt: new Date().toISOString(),
533
+ specHash: params.specHash,
534
+ resourceCount: resources.length,
535
+ resources,
536
+ orphanEndpoints,
537
+ };
538
+ }
539
+
540
+ // ── YAML serialization (minimal, no dep on yaml lib for the workspace) ──
541
+
542
+ function escape(s: string): string {
543
+ if (/[:#\[\]{}&*!|>'"@`,%]/.test(s) || s.includes("\n") || s === "") {
544
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
545
+ }
546
+ return s;
547
+ }
548
+
549
+ export function serializeApiResourceMap(m: ApiResourceMap): string {
550
+ const lines: string[] = [];
551
+ lines.push("# Auto-generated by zond. Do not edit by hand.");
552
+ lines.push("# Regenerate via: zond refresh-api <name>");
553
+ lines.push(`generatedAt: ${escape(m.generatedAt)}`);
554
+ lines.push(`specHash: ${escape(m.specHash)}`);
555
+ lines.push(`resourceCount: ${m.resourceCount}`);
556
+ if (m.resources.length === 0) {
557
+ lines.push("resources: []");
558
+ } else {
559
+ lines.push("resources:");
560
+ }
561
+ for (const r of m.resources) {
562
+ lines.push(` - resource: ${escape(r.resource)}`);
563
+ lines.push(` basePath: ${escape(r.basePath)}`);
564
+ lines.push(` itemPath: ${escape(r.itemPath)}`);
565
+ lines.push(` idParam: ${escape(r.idParam)}`);
566
+ lines.push(` captureField: ${escape(r.captureField)}`);
567
+ lines.push(` hasFullCrud: ${r.hasFullCrud}`);
568
+ if (r.requiresEtag) lines.push(` requiresEtag: true`);
569
+ lines.push(` endpoints:`);
570
+ for (const [k, v] of Object.entries(r.endpoints)) {
571
+ lines.push(` ${k}: ${escape(v as string)}`);
572
+ }
573
+ if (r.fkDependencies.length === 0) {
574
+ lines.push(` fkDependencies: []`);
575
+ } else {
576
+ lines.push(` fkDependencies:`);
577
+ for (const d of r.fkDependencies) {
578
+ lines.push(` - var: ${escape(d.var)}`);
579
+ lines.push(` param: ${escape(d.param)}`);
580
+ lines.push(` in: ${d.in}`);
581
+ lines.push(` ownerResource: ${d.ownerResource ? escape(d.ownerResource) : "null"}`);
582
+ }
583
+ }
584
+ if (r.readbackDiff) {
585
+ lines.push(` readback_diff:`);
586
+ const ig = r.readbackDiff.ignoreFields ?? [];
587
+ if (ig.length > 0) {
588
+ lines.push(` ignore_fields:`);
589
+ for (const f of ig) lines.push(` - ${escape(f)}`);
590
+ }
591
+ const map = r.readbackDiff.writeToReadMap ?? {};
592
+ const mapKeys = Object.keys(map);
593
+ if (mapKeys.length > 0) {
594
+ lines.push(` write_to_read_map:`);
595
+ for (const k of mapKeys) lines.push(` ${escape(k)}: ${escape(map[k]!)}`);
596
+ }
597
+ }
598
+ if (r.idempotency) {
599
+ lines.push(` idempotency:`);
600
+ if (r.idempotency.header) lines.push(` header: ${escape(r.idempotency.header)}`);
601
+ if (r.idempotency.scope) lines.push(` scope: ${r.idempotency.scope}`);
602
+ const ig = r.idempotency.ignoreResponseFields ?? [];
603
+ if (ig.length > 0) {
604
+ lines.push(` ignore_response_fields:`);
605
+ for (const f of ig) lines.push(` - ${escape(f)}`);
606
+ }
607
+ }
608
+ if (r.pagination) {
609
+ lines.push(` pagination:`);
610
+ if (r.pagination.type) lines.push(` type: ${r.pagination.type}`);
611
+ if (r.pagination.cursorParam) lines.push(` cursor_param: ${escape(r.pagination.cursorParam)}`);
612
+ if (r.pagination.cursorField) lines.push(` cursor_field: ${escape(r.pagination.cursorField)}`);
613
+ if (r.pagination.hasMoreField) lines.push(` has_more_field: ${escape(r.pagination.hasMoreField)}`);
614
+ if (r.pagination.limitParam) lines.push(` limit_param: ${escape(r.pagination.limitParam)}`);
615
+ if (r.pagination.defaultLimit != null) lines.push(` default_limit: ${r.pagination.defaultLimit}`);
616
+ if (r.pagination.itemsField) lines.push(` items_field: ${escape(r.pagination.itemsField)}`);
617
+ }
618
+ if (r.lifecycle) {
619
+ lines.push(` lifecycle:`);
620
+ lines.push(` field: ${escape(r.lifecycle.field)}`);
621
+ lines.push(` states:`);
622
+ for (const s of r.lifecycle.states) lines.push(` - ${escape(s)}`);
623
+ lines.push(` transitions:`);
624
+ for (const t of r.lifecycle.transitions) {
625
+ lines.push(` - from: ${escape(t.from)}`);
626
+ if (t.to.length === 0) {
627
+ lines.push(` to: []`);
628
+ } else {
629
+ lines.push(` to:`);
630
+ for (const to of t.to) lines.push(` - ${escape(to)}`);
631
+ }
632
+ }
633
+ lines.push(` actions:`);
634
+ for (const [name, a] of Object.entries(r.lifecycle.actions)) {
635
+ lines.push(` ${escape(name)}:`);
636
+ lines.push(` endpoint: ${escape(a.endpoint)}`);
637
+ lines.push(` expected_state: ${escape(a.expectedState)}`);
638
+ }
639
+ }
640
+ }
641
+ if (m.orphanEndpoints.length === 0) {
642
+ lines.push("orphanEndpoints: []");
643
+ } else {
644
+ lines.push("orphanEndpoints:");
645
+ for (const e of m.orphanEndpoints) lines.push(` - ${escape(e)}`);
646
+ }
647
+ return lines.join("\n") + "\n";
648
+ }
@@ -1,8 +1,16 @@
1
1
  import type { OpenAPIV3 } from "openapi-types";
2
2
 
3
3
  /**
4
- * Deep-clone an object, replacing circular references with `{ "$ref": "[Circular]" }`.
5
- * Uses WeakSet to track visited objects.
4
+ * Deep-clone an object, replacing circular references with the vendor-extension
5
+ * sentinel `{ "x-circular": true }`. Uses WeakSet to track visited objects.
6
+ *
7
+ * Why a vendor extension and not `$ref`: the decycled doc is now written to
8
+ * disk (apis/<name>/spec.json) and re-read by `@readme/openapi-parser` in
9
+ * downstream commands (check spec, describe, generate). If the sentinel
10
+ * carried a `$ref` field, the parser would try to resolve its value as a
11
+ * JSON pointer / file path — e.g. `apis/stripe/[Circular]` — and fail
12
+ * (ARV-146). `x-*` keys are explicitly reserved for vendor extensions in
13
+ * OpenAPI 3.x and pass through every parser untouched.
6
14
  */
7
15
  export function decycleSchema(obj: unknown): unknown {
8
16
  const seen = new WeakSet<object>();
@@ -16,7 +24,7 @@ export function decycleSchema(obj: unknown): unknown {
16
24
 
17
25
  const obj = value as Record<string, unknown>;
18
26
  if (seen.has(obj)) {
19
- return { $ref: "[Circular]" };
27
+ return { "x-circular": true };
20
28
  }
21
29
  seen.add(obj);
22
30