@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,898 +0,0 @@
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, singularizeResource, stripTrailingVersionSegments } 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-style: caller passes a cursor (e.g.
82
- * `starting_after=<id>`) derived from the last item of the
83
- * previous page.
84
- * • `page` — page-number style (GitHub, GitLab, Atlassian, Notion,
85
- * Linear): `?page=N&per_page=M`. ARV-220 enabled this in m-21.
86
- * • `offset` and `token` — declared for forward compatibility; the
87
- * check currently skips with a "pagination type not implemented"
88
- * reason so the yaml block stays a stable schema.
89
- *
90
- * Auto-detect fallback: if the list endpoint declares `starting_after`
91
- * / `cursor` / `page_token` query parameters in the spec, the check
92
- * uses sensible defaults (cursor_field=`id`, items_field=`data` →
93
- * `items` → `results`, has_more_field=`has_more`). Explicit yaml is
94
- * preferred — it documents intent and survives spec changes that
95
- * rename query params.
96
- */
97
- /**
98
- * ARV-172 (m-20 lifecycle-transitions): declared state machine for a
99
- * resource. Used by the `lifecycle_transitions` stateful check to
100
- * verify that documented actions (cancel / archive / publish / ...)
101
- * move a resource between declared states and that double-invoking an
102
- * action either 4xx's or stays idempotent (no state regression).
103
- *
104
- * The yaml block has three parts:
105
- * • `field` + `states` — name of the response field carrying the
106
- * state, plus the closed enum of legal values.
107
- * • `transitions` — a from→to graph; the check uses it to flag
108
- * forbidden transitions (cancelled → active) when an action lands
109
- * a resource somewhere the graph doesn't allow.
110
- * • `actions` — POST endpoints that should drive a transition.
111
- * `expected_state` is the state the resource must be in after a
112
- * successful action call.
113
- *
114
- * Manifest validation runs at load time and surfaces obvious
115
- * authoring bugs (unreachable states, missing terminal, action
116
- * referencing an undeclared state) before any HTTP call goes out.
117
- */
118
- export interface LifecycleAction {
119
- /** Endpoint label, e.g. "POST /v1/subscriptions/{id}/cancel". The
120
- * `{id}` placeholder is substituted with the created resource id. */
121
- endpoint: string;
122
- /** State the resource must be in after this action lands. */
123
- expectedState: string;
124
- /** Optional request body sent with the action POST. Most lifecycle
125
- * actions are body-less (cancel, archive, publish); leave empty
126
- * when not needed. Serialised as JSON or form depending on the
127
- * endpoint's declared content type. */
128
- body?: Record<string, unknown>;
129
- }
130
-
131
- export interface LifecycleConfig {
132
- /** Response field name carrying the state (e.g. `status`). */
133
- field: string;
134
- /** Closed enum of legal state values. Any state observed on the
135
- * wire that isn't in this list is a finding. */
136
- states: string[];
137
- /** Allowed from→to graph. States not listed as `from` are assumed
138
- * terminal (no outgoing transition). States not listed as `to` of
139
- * any transition are starting-only (unreachable post-create). */
140
- transitions: { from: string; to: string[] }[];
141
- /** Named actions keyed by action name (cancel / archive / publish).
142
- * The check runs through them in object-key order. */
143
- actions: Record<string, LifecycleAction>;
144
- }
145
-
146
- /**
147
- * Static validation of a lifecycle manifest. Returns the list of
148
- * authoring bugs without throwing — callers decide whether to fail
149
- * the run or just warn. Empty array = clean manifest.
150
- */
151
- export function validateLifecycleManifest(cfg: LifecycleConfig): string[] {
152
- const errors: string[] = [];
153
- if (!cfg.field || cfg.field.length === 0) errors.push("lifecycle.field is empty");
154
- if (!cfg.states || cfg.states.length === 0) errors.push("lifecycle.states is empty");
155
- const stateSet = new Set(cfg.states ?? []);
156
- for (const t of cfg.transitions ?? []) {
157
- if (!stateSet.has(t.from)) errors.push(`transitions: unknown "from" state "${t.from}"`);
158
- for (const to of t.to) {
159
- if (!stateSet.has(to)) errors.push(`transitions[${t.from}]: unknown "to" state "${to}"`);
160
- }
161
- }
162
- // At least one terminal — a state with no outgoing transition (or
163
- // an explicit `to: []`). A graph with every state having outgoing
164
- // edges is suspicious (no end-of-life, infinite churn).
165
- const hasOutgoing = new Set((cfg.transitions ?? []).filter((t) => t.to.length > 0).map((t) => t.from));
166
- const terminals = (cfg.states ?? []).filter((s) => !hasOutgoing.has(s));
167
- if (terminals.length === 0) errors.push("no terminal state — every declared state has outgoing transitions");
168
- // Actions must reference declared states.
169
- for (const [name, a] of Object.entries(cfg.actions ?? {})) {
170
- if (!stateSet.has(a.expectedState)) {
171
- errors.push(`actions.${name}.expected_state "${a.expectedState}" is not in states[]`);
172
- }
173
- if (!a.endpoint || a.endpoint.length === 0) {
174
- errors.push(`actions.${name}.endpoint is empty`);
175
- }
176
- }
177
- return errors;
178
- }
179
-
180
- export interface PaginationConfig {
181
- /** Pagination flavor. Default `cursor`. */
182
- type?: "cursor" | "page" | "offset" | "token";
183
- /** Query-param name that takes the cursor value. Default `starting_after`.
184
- * Only used when `type: cursor`. */
185
- cursorParam?: string;
186
- /** Response field on each item that becomes the next cursor (cursor-style)
187
- * and the dedupe key when comparing pages (both styles). Default `id`. */
188
- cursorField?: string;
189
- /** Response field that signals "more pages remain". Default `has_more`.
190
- * Only consulted for `type: cursor` — page-style APIs typically rely on
191
- * Link headers or `total_pages` instead, so the field is ignored there. */
192
- hasMoreField?: string;
193
- /** Query-param name for page size. Default `limit` for cursor-style,
194
- * `per_page` for page-style. */
195
- limitParam?: string;
196
- /** Probe page-size. Default 2 (small enough to land two replies fast). */
197
- defaultLimit?: number;
198
- /** Response field carrying the array of items. Default `data` (Stripe);
199
- * falls back to `items` / `results` when missing. */
200
- itemsField?: string;
201
- /** Page-number query-param name. Default `page`. Only used when `type: page`. */
202
- pageParam?: string;
203
- /** First page number (1-based on GitHub/GitLab, 0-based on some custom APIs).
204
- * Default 1. Only used when `type: page`. */
205
- startPage?: number;
206
- }
207
-
208
- /** ARV-187: LLM-authored example POST body. Stateful checks prefer this
209
- * over generateFromSchema(create) when present. */
210
- export interface SeedBodyConfig {
211
- /** Defaults to the create endpoint's requestBodyContentType. */
212
- contentType?: string;
213
- body: Record<string, unknown>;
214
- }
215
-
216
- export interface ApiResourceEntry {
217
- resource: string;
218
- basePath: string;
219
- itemPath: string;
220
- idParam: string;
221
- /** What field on the create response carries the new id (typically `id`). */
222
- captureField: string;
223
- /** True when the resource exposes List + Create + Read at minimum. */
224
- hasFullCrud: boolean;
225
- endpoints: {
226
- list?: string; // "GET /audiences"
227
- create?: string;
228
- read?: string;
229
- update?: string;
230
- delete?: string;
231
- };
232
- /** Update/Delete demand If-Match? (heuristic: 412 in spec or ETag in headers). */
233
- requiresEtag?: boolean;
234
- /** Heuristic: read-after-delete returns 200 instead of 404 (filled at runtime, default false). */
235
- softDelete?: boolean;
236
- /** Other resources whose ids this resource consumes (FK chain). */
237
- fkDependencies: ResourceFkRef[];
238
- /** ARV-169: optional cross-call-drift overrides. */
239
- readbackDiff?: ReadbackDiffConfig;
240
- /** ARV-170: opt-in idempotency-replay probe. */
241
- idempotency?: IdempotencyConfig;
242
- /** ARV-171: pagination-invariants probe. */
243
- pagination?: PaginationConfig;
244
- /** ARV-172: state-machine for the resource. */
245
- lifecycle?: LifecycleConfig;
246
- }
247
-
248
- export interface ApiResourceMap {
249
- generatedAt: string;
250
- specHash: string;
251
- resourceCount: number;
252
- resources: ApiResourceEntry[];
253
- /** Endpoints that didn't fit any CRUD group (action endpoints, RPC-style). */
254
- orphanEndpoints: string[];
255
- }
256
-
257
- function epLabel(ep: EndpointInfo): string {
258
- return `${ep.method.toUpperCase()} ${ep.path}`;
259
- }
260
-
261
- function pathStripSlash(p: string): string {
262
- return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
263
- }
264
-
265
- function isParamSeg(seg: string | undefined): boolean {
266
- return !!seg && /^\{[^}]+\}$/.test(seg);
267
- }
268
-
269
- function escapeRegex(s: string): string {
270
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
271
- }
272
-
273
- /**
274
- * ARV-134 (resource-builder fix #1): when two CRUD groups would collide on
275
- * the same `resource` name (e.g. `POST /segments` and `POST /contacts/
276
- * {contact_id}/segments` both produce `resource: "segments"`), keep the
277
- * canonical (shortest basePath) entry and rename the nested ones to
278
- * `<parent-noun>_<resource>` — `contact_segments` here. Without this,
279
- * `.api-resources.yaml` ends up with duplicate entries that break
280
- * map-by-name lookups (annotate overlay, refresh-api idempotence,
281
- * stateful-check resource configs).
282
- */
283
- function disambiguateResourceCollisions(groups: CrudGroup[]): CrudGroup[] {
284
- const byName = new Map<string, CrudGroup[]>();
285
- for (const g of groups) {
286
- const arr = byName.get(g.resource) ?? [];
287
- arr.push(g);
288
- byName.set(g.resource, arr);
289
- }
290
-
291
- const renames = new Map<CrudGroup, string>();
292
- const usedNames = new Set<string>(byName.keys());
293
-
294
- for (const [name, members] of byName) {
295
- if (members.length < 2) continue;
296
- // Canonical name goes to the entry with the *strictly* shortest
297
- // basePath (the top-level collection in the typical /segments vs
298
- // /contacts/{id}/segments case). If two members tie for shortest,
299
- // there is no obvious winner — rename all of them so the yaml never
300
- // silently picks one arbitrary entry as canonical.
301
- const sorted = [...members].sort((a, b) => a.basePath.length - b.basePath.length);
302
- const shortest = sorted[0]!.basePath.length;
303
- const tiedAtShortest = sorted.filter(g => g.basePath.length === shortest).length;
304
- const startFromIndex = tiedAtShortest === 1 ? 1 : 0;
305
-
306
- for (let i = startFromIndex; i < sorted.length; i++) {
307
- const g = sorted[i]!;
308
- const prefix = parentNounForBasePath(g.basePath);
309
- const singularPrefix = prefix ? singularizeResource(prefix) : null;
310
- let candidate = singularPrefix ? `${singularPrefix}_${name}` : `${name}_${i + 1}`;
311
- let n = 2;
312
- while (usedNames.has(candidate)) {
313
- candidate = singularPrefix ? `${singularPrefix}_${name}_${n++}` : `${name}_${n++}`;
314
- }
315
- usedNames.add(candidate);
316
- renames.set(g, candidate);
317
- }
318
- }
319
-
320
- if (renames.size === 0) return groups;
321
- return groups.map(g => (renames.has(g) ? { ...g, resource: renames.get(g)! } : g));
322
- }
323
-
324
- /**
325
- * ARV-134 follow-up: same rename strategy as `disambiguateResourceCollisions`,
326
- * but operating on the final `ApiResourceEntry[]` so CRUD-vs-implicit
327
- * name clashes also get resolved. Keeps the implementation parallel so
328
- * the behaviour stays consistent (strictly-shortest basePath keeps the
329
- * canonical name; ties get all-renamed; suffix-bumping on hash
330
- * collision).
331
- */
332
- function disambiguateEntryCollisions(entries: ApiResourceEntry[]): ApiResourceEntry[] {
333
- const byName = new Map<string, ApiResourceEntry[]>();
334
- for (const e of entries) {
335
- const arr = byName.get(e.resource) ?? [];
336
- arr.push(e);
337
- byName.set(e.resource, arr);
338
- }
339
-
340
- const renames = new Map<ApiResourceEntry, string>();
341
- const usedNames = new Set<string>(byName.keys());
342
-
343
- for (const [name, members] of byName) {
344
- if (members.length < 2) continue;
345
- const sorted = [...members].sort((a, b) => a.basePath.length - b.basePath.length);
346
- const shortest = sorted[0]!.basePath.length;
347
- const tiedAtShortest = sorted.filter(e => e.basePath.length === shortest).length;
348
- const startFromIndex = tiedAtShortest === 1 ? 1 : 0;
349
-
350
- for (let i = startFromIndex; i < sorted.length; i++) {
351
- const e = sorted[i]!;
352
- const prefix = parentNounForBasePath(e.basePath);
353
- const singularPrefix = prefix ? singularizeResource(prefix) : null;
354
- let candidate = singularPrefix ? `${singularPrefix}_${name}` : `${name}_${i + 1}`;
355
- let n = 2;
356
- while (usedNames.has(candidate)) {
357
- candidate = singularPrefix ? `${singularPrefix}_${name}_${n++}` : `${name}_${n++}`;
358
- }
359
- usedNames.add(candidate);
360
- renames.set(e, candidate);
361
- }
362
- }
363
-
364
- if (renames.size === 0) return entries;
365
- return entries.map(e => (renames.has(e) ? { ...e, resource: renames.get(e)! } : e));
366
- }
367
-
368
- function parentNounForBasePath(basePath: string): string | null {
369
- const segs = pathStripSlash(basePath).split("/").filter(Boolean);
370
- // Skip the last segment (the resource itself); walk back to the nearest
371
- // non-param noun. `/contacts/{contact_id}/segments` → "contacts".
372
- for (let i = segs.length - 2; i >= 0; i--) {
373
- if (!isParamSeg(segs[i])) return segs[i]!;
374
- }
375
- return null;
376
- }
377
-
378
- /**
379
- * ARV-134 (resource-builder fix #2): for an implicit list-only resource
380
- * (no CRUD group), look for a GET-by-id companion endpoint that gives us
381
- * a real idParam + itemPath. Resend's `/logs/{log_id}` and `/automations/
382
- * {automation_id}/runs/{run_id}` were getting `idParam: ""` because the
383
- * implicit-resource constructor never inspected the spec for their item
384
- * endpoints — `prepare-fixtures` then skipped them.
385
- */
386
- function findItemEndpointForListPath(
387
- listPath: string,
388
- endpoints: EndpointInfo[],
389
- ): EndpointInfo | null {
390
- const itemRe = new RegExp(`^${escapeRegex(listPath)}/\\{([^}]+)\\}/?$`);
391
- for (const ep of endpoints) {
392
- if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
393
- if (itemRe.test(pathStripSlash(ep.path))) return ep;
394
- }
395
- return null;
396
- }
397
-
398
- function getCaptureField(create: EndpointInfo): string {
399
- // Look at the create endpoint's success response schema for an `id`-ish
400
- // field. Falls back to "id" — the universal default.
401
- const success = create.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
402
- const schema = success?.schema as OpenAPIV3.SchemaObject | undefined;
403
- if (schema?.properties) {
404
- const props = schema.properties as Record<string, OpenAPIV3.SchemaObject>;
405
- for (const candidate of ["id", "uuid", "key", "code"]) {
406
- if (props[candidate]) return candidate;
407
- }
408
- }
409
- return "id";
410
- }
411
-
412
- /**
413
- * Structurally infer the list-endpoint that owns each path-parameter by
414
- * walking the actual URL graph in the spec. Beats name-stemming because
415
- *
416
- * • `_id_or_slug`, `_or_name`, non-English plurals, weird casing — all
417
- * transparent: we only look at segment positions, not at param names.
418
- * • Returns the *exact* GET path to call, not a guessed resource name we
419
- * later have to hope is wired up correctly.
420
- * • Two-strategy lookup so it survives both canonical nesting
421
- * (`/orgs/{org}/projects/{proj}/...` — prev seg `projects` is a list)
422
- * and common SaaS-style sibling-param chains
423
- * (`/projects/{org}/{proj}/...` — prev seg is itself a param;
424
- * we walk back to the nearest non-param segment and search for any
425
- * GET path ending with that hint).
426
- */
427
- // ARV-376: list-shaped verb suffixes. `/api/business-segment20/list` is the
428
- // collection listing for stem `/api/business-segment20`, even though the
429
- // stem itself is not a GET endpoint. Same for `/search` and `/find`.
430
- const LIST_VERB_SUFFIXES = new Set(["list", "search", "find"]);
431
-
432
- export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, string> {
433
- const getPathSet = new Set<string>();
434
- const getPathsByLastSeg = new Map<string, string[]>();
435
- // ARV-376: a `/list`-style endpoint indexed by its collection stem
436
- // (path minus the verb suffix) and by that stem's last noun segment, so a
437
- // read-by-id sibling (`/{code}`, `/byid/{id}`) can find its listing even
438
- // though the bare collection path has no GET.
439
- const listByStem = new Map<string, string>();
440
- const listByStemLastSeg = new Map<string, string[]>();
441
- const considerShortest = (map: Map<string, string>, key: string, path: string) => {
442
- const cur = map.get(key);
443
- if (!cur || path.length < cur.length) map.set(key, path);
444
- };
445
- for (const ep of endpoints) {
446
- if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
447
- const path = pathStripSlash(ep.path);
448
- getPathSet.add(path);
449
- const segs = path.split("/").filter(Boolean);
450
- const last = segs[segs.length - 1];
451
- if (last && !isParamSeg(last)) {
452
- const arr = getPathsByLastSeg.get(last) ?? [];
453
- arr.push(path);
454
- getPathsByLastSeg.set(last, arr);
455
- }
456
- // Index list-verb endpoints against their collection stem. The stem key
457
- // is the leading-slash path form the resolution loop below builds its
458
- // prefixes in (NOT the filtered form) so the two sides compare equal.
459
- if (last && LIST_VERB_SUFFIXES.has(last.toLowerCase()) && segs.length >= 2) {
460
- const stemLast = segs[segs.length - 2];
461
- if (stemLast && !isParamSeg(stemLast)) {
462
- const lastSlash = path.lastIndexOf("/");
463
- const stemPath = lastSlash > 0 ? path.slice(0, lastSlash) : "";
464
- considerShortest(listByStem, stemPath, path);
465
- const arr = listByStemLastSeg.get(stemLast) ?? [];
466
- arr.push(path);
467
- listByStemLastSeg.set(stemLast, arr);
468
- }
469
- }
470
- }
471
-
472
- const result = new Map<string, string>();
473
- const consider = (param: string, candidate: string) => {
474
- const existing = result.get(param);
475
- // Prefer shorter (more canonical/top-level) list path.
476
- if (!existing || candidate.length < existing.length) result.set(param, candidate);
477
- };
478
-
479
- for (const ep of endpoints) {
480
- if (ep.deprecated) continue;
481
- const segs = pathStripSlash(ep.path).split("/");
482
- for (let i = 0; i < segs.length; i++) {
483
- const seg = segs[i]!;
484
- const m = /^\{([^}]+)\}$/.exec(seg);
485
- if (!m) continue;
486
- const param = m[1]!;
487
-
488
- // Strategy 1 (canonical + ARV-376): walk back over the run of
489
- // consecutive non-param segments before `{param}`; for each candidate
490
- // prefix match either a collection GET or a `/list`-style endpoint.
491
- // Longest (most specific) prefix wins. Stops at the first param
492
- // segment so we never attribute a child id to a grandparent list. This
493
- // tolerates read-by-id marker segments (`/byid/{id}` → owner is the
494
- // `/business-segment20/list` two segments up).
495
- let matched = false;
496
- for (let k = i; k >= 1; k--) {
497
- if (isParamSeg(segs[k - 1]!)) break; // hit a param boundary — stop
498
- const prefix = segs.slice(0, k).join("/");
499
- if (getPathSet.has(prefix)) { consider(param, prefix); matched = true; break; }
500
- const listPath = listByStem.get(prefix);
501
- if (listPath) { consider(param, listPath); matched = true; break; }
502
- }
503
- if (matched) continue;
504
-
505
- // Strategy 2 (sibling-param chain): walk back to the nearest
506
- // non-param segment, then look for *any* GET path that terminates
507
- // with that segment (or a `/list`-style endpoint for that stem).
508
- let hint: string | undefined;
509
- for (let j = i - 1; j >= 0; j--) {
510
- const s = segs[j]!;
511
- if (!isParamSeg(s) && s !== "") {
512
- hint = s;
513
- break;
514
- }
515
- }
516
- if (!hint) continue;
517
- const candidates = getPathsByLastSeg.get(hint) ?? listByStemLastSeg.get(hint);
518
- if (!candidates || candidates.length === 0) continue;
519
- const shortest = candidates.reduce((a, b) => (a.length <= b.length ? a : b));
520
- consider(param, shortest);
521
- }
522
- }
523
-
524
- return result;
525
- }
526
-
527
- function listPathToResourceName(listPath: string): string {
528
- // ARV-372/ARV-369 follow-up: strip trailing version segments (`v30`)
529
- // before scanning backward, same as the CRUD-group resource-name
530
- // derivation in suite-generator.ts — otherwise list-only resources on a
531
- // spec shaped `/api/<resource>/v{N}` all collapse to "v30".
532
- const segs = stripTrailingVersionSegments(pathStripSlash(listPath).split("/").filter(Boolean));
533
- for (let i = segs.length - 1; i >= 0; i--) {
534
- if (!isParamSeg(segs[i])) return segs[i]!;
535
- }
536
- return "resource";
537
- }
538
-
539
- /**
540
- * Body-FK fallback. Used only when a body field's name doesn't appear
541
- * as a path-param anywhere (so the structural resolver has nothing to
542
- * say). Cheap heuristic — kept narrow on purpose.
543
- */
544
- function inferFkOwnerByName(paramName: string, allResources: string[]): string | null {
545
- const stem = paramName
546
- .replace(/_id_or_slug$|_id_or_name$|_or_slug$|_or_name$/, "")
547
- .replace(/_id$|Id$|_uuid$|_slug$/, "")
548
- .toLowerCase();
549
- if (!stem) return null;
550
- for (const res of allResources) {
551
- const r = res.toLowerCase();
552
- if (r === stem || r === `${stem}s` || `${r}s` === stem || r.replace(/s$/, "") === stem) {
553
- return res;
554
- }
555
- }
556
- return null;
557
- }
558
-
559
- function collectPathFkDeps(
560
- basePath: string,
561
- idParam: string,
562
- ownerListPaths: Map<string, string>,
563
- resourceByListPath: Map<string, string>,
564
- ): ResourceFkRef[] {
565
- const deps: ResourceFkRef[] = [];
566
- const seen = new Set<string>();
567
- const pathParamRe = /\{([^}]+)\}/g;
568
- let match: RegExpExecArray | null;
569
- while ((match = pathParamRe.exec(basePath)) !== null) {
570
- const param = match[1]!;
571
- if (param === idParam) continue;
572
- if (seen.has(param)) continue;
573
- seen.add(param);
574
- const listPath = ownerListPaths.get(param);
575
- const ownerResource = listPath ? (resourceByListPath.get(listPath) ?? null) : null;
576
- deps.push({ var: param, param, in: "path", ownerResource });
577
- }
578
- return deps;
579
- }
580
-
581
- function collectBodyFkDeps(
582
- group: CrudGroup,
583
- ownerListPaths: Map<string, string>,
584
- resourceByListPath: Map<string, string>,
585
- allResources: string[],
586
- ): ResourceFkRef[] {
587
- const deps: ResourceFkRef[] = [];
588
- if (!group.create?.requestBodySchema) return deps;
589
- const schema = group.create.requestBodySchema as OpenAPIV3.SchemaObject;
590
- const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
591
- const required = new Set(schema.required ?? []);
592
- for (const [name] of Object.entries(props)) {
593
- if (!/_id$|Id$|_uuid$/.test(name)) continue;
594
- if (!required.has(name)) continue;
595
- // Try structural resolution first (the body field name often matches a
596
- // path-param elsewhere — `audience_id` body field, `audience_id` path
597
- // param both point at /audiences/). Fall back to name-stemming.
598
- let ownerResource: string | null = null;
599
- const listPath = ownerListPaths.get(name);
600
- if (listPath) ownerResource = resourceByListPath.get(listPath) ?? null;
601
- if (!ownerResource) ownerResource = inferFkOwnerByName(name, allResources);
602
- deps.push({ var: name, param: name, in: "body", ownerResource });
603
- }
604
- return deps;
605
- }
606
-
607
- export interface BuildResourcesParams {
608
- endpoints: EndpointInfo[];
609
- specHash: string;
610
- }
611
-
612
- export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMap {
613
- const groups = disambiguateResourceCollisions(detectCrudGroups(params.endpoints));
614
- const ownerListPaths = resolveOwnerListPaths(params.endpoints);
615
-
616
- // ARV-134: reverse-index ownerListPaths so implicit resources can fall
617
- // back to the FK param name when no direct GET-by-id companion exists.
618
- const paramsByListPath = new Map<string, string[]>();
619
- for (const [param, listPath] of ownerListPaths) {
620
- const arr = paramsByListPath.get(listPath) ?? [];
621
- arr.push(param);
622
- paramsByListPath.set(listPath, arr);
623
- }
624
-
625
- // Index CRUD-group list paths by normalised path so the FK resolver can
626
- // hand back the resource name a structural lookup pointed at.
627
- const resourceByListPath = new Map<string, string>();
628
- for (const g of groups) {
629
- if (g.list) resourceByListPath.set(pathStripSlash(g.list.path), g.resource);
630
- }
631
-
632
- // Imp resources: any list path that path-FKs point at structurally but
633
- // no CRUD group claims (top-level GET-only collections like
634
- // `/api/0/organizations/`, nested list-only collections, etc.). Without
635
- // these, every FK that depends on a non-CRUD parent ends up with
636
- // `ownerResource: null` and `discover` skips them — the actual root
637
- // cause of the "discover --apply is a no-op" symptom.
638
- const implicitResources: ApiResourceEntry[] = [];
639
- const seenImplicit = new Set<string>();
640
- for (const [, listPath] of ownerListPaths) {
641
- if (resourceByListPath.has(listPath)) continue;
642
- if (seenImplicit.has(listPath)) continue;
643
- seenImplicit.add(listPath);
644
- const listEp = params.endpoints.find(
645
- e =>
646
- e.method.toUpperCase() === "GET" &&
647
- !e.deprecated &&
648
- pathStripSlash(e.path) === listPath,
649
- );
650
- if (!listEp) continue;
651
- const name = listPathToResourceName(listPath);
652
-
653
- // ARV-134: try to recover idParam + itemPath from a GET-by-id companion
654
- // (e.g. implicit `logs` at `/logs` + `GET /logs/{log_id}` → log_id).
655
- // Falls back to the reverse-indexed ownerListPaths param when no direct
656
- // item endpoint exists — preferring a name that matches the resource's
657
- // singular form so e.g. `attachment_id` wins over a sibling FK that
658
- // happens to point at the same list path.
659
- const itemEp = findItemEndpointForListPath(listPath, params.endpoints);
660
- let idParam = "";
661
- let itemPath = "";
662
- const endpoints: ApiResourceEntry["endpoints"] = { list: epLabel(listEp) };
663
- if (itemEp) {
664
- const m = pathStripSlash(itemEp.path).match(/\{([^}]+)\}\/?$/);
665
- if (m) {
666
- idParam = m[1]!;
667
- itemPath = itemEp.path;
668
- endpoints.read = epLabel(itemEp);
669
- }
670
- }
671
- if (!idParam) {
672
- const candidates = paramsByListPath.get(listPath) ?? [];
673
- if (candidates.length > 0) {
674
- const singular = singularizeResource(name).toLowerCase();
675
- const preferred = candidates.find(p => {
676
- const lower = p.toLowerCase();
677
- return lower === singular || lower.startsWith(`${singular}_`);
678
- });
679
- idParam = preferred ?? candidates[0]!;
680
- }
681
- }
682
-
683
- implicitResources.push({
684
- resource: name,
685
- basePath: listPath,
686
- itemPath,
687
- idParam,
688
- captureField: "id",
689
- hasFullCrud: false,
690
- endpoints,
691
- fkDependencies: [],
692
- });
693
- resourceByListPath.set(listPath, name);
694
- }
695
-
696
- const resourceNames = [
697
- ...groups.map(g => g.resource),
698
- ...implicitResources.map(r => r.resource),
699
- ];
700
-
701
- const crudResources: ApiResourceEntry[] = groups.map(g => {
702
- const captureField = g.create ? getCaptureField(g.create) : "id";
703
- const requiresEtag = !!(g.update?.requiresEtag || g.delete?.requiresEtag);
704
- return {
705
- resource: g.resource,
706
- basePath: g.basePath,
707
- itemPath: g.itemPath,
708
- idParam: g.idParam,
709
- captureField,
710
- hasFullCrud: !!(g.list && g.create && g.read),
711
- endpoints: {
712
- ...(g.list ? { list: epLabel(g.list) } : {}),
713
- ...(g.create ? { create: epLabel(g.create) } : {}),
714
- ...(g.read ? { read: epLabel(g.read) } : {}),
715
- ...(g.update ? { update: epLabel(g.update) } : {}),
716
- ...(g.delete ? { delete: epLabel(g.delete) } : {}),
717
- },
718
- ...(requiresEtag ? { requiresEtag: true } : {}),
719
- fkDependencies: [
720
- ...collectPathFkDeps(g.basePath, g.idParam, ownerListPaths, resourceByListPath),
721
- ...collectBodyFkDeps(g, ownerListPaths, resourceByListPath, resourceNames),
722
- ],
723
- };
724
- });
725
-
726
- // Implicit resources also chain — `/orgs/{org}/projects/` lists projects
727
- // but needs `organization_id_or_slug` set to call. Surface that so
728
- // `discover` knows to fetch the parent first.
729
- for (const r of implicitResources) {
730
- r.fkDependencies = collectPathFkDeps(r.basePath, "", ownerListPaths, resourceByListPath);
731
- }
732
-
733
-
734
- // ARV-134 (follow-up): the early disambiguation pass only operated on
735
- // CRUD groups, but collisions also fire CRUD-vs-implicit (`/repos/
736
- // {owner}/{repo}/check-runs` is CRUD, `/repos/{owner}/{repo}/commits/
737
- // {ref}/check-runs` is implicit-list-only — both end up named
738
- // `check-runs`). Run the same prefix-rename here on the combined list
739
- // so the final yaml never carries duplicate `resource:` lines.
740
- const resources = disambiguateEntryCollisions([...crudResources, ...implicitResources]);
741
-
742
- // ARV-376: surface a resource's OWN secondary lookup keys (e.g. the
743
- // `/{code}` read-by-code param, or a `/byid/{id}` param that isn't the
744
- // canonical idParam) as fillable targets. resolveOwnerListPaths already
745
- // links each such param to this resource's `/list` endpoint; expose it as a
746
- // self-referential fkDependency so prepare-fixtures reports a candidate
747
- // (GET the list, pick a value) instead of `miss-no-list`. Runs after
748
- // disambiguateEntryCollisions so it keys off the FINAL unique resource
749
- // names (pre-disambig implicit names collide on `list`).
750
- const byOwnListPath = new Map<string, ApiResourceEntry>();
751
- for (const r of resources) {
752
- const label = r.endpoints.list;
753
- if (!label) continue;
754
- byOwnListPath.set(pathStripSlash(label.slice(label.indexOf(" ") + 1)), r);
755
- }
756
- for (const [param, listPath] of ownerListPaths) {
757
- const r = byOwnListPath.get(pathStripSlash(listPath));
758
- if (!r || param === r.idParam) continue;
759
- if ((r.fkDependencies ?? []).some(d => d.var === param)) continue;
760
- r.fkDependencies.push({ var: param, param, in: "path", ownerResource: r.resource });
761
- }
762
-
763
- // Endpoints that aren't in any CRUD group — RPC-style actions, webhook
764
- // accept-only routes, etc. Implicit-list endpoints stay in orphans
765
- // because they're not full CRUD; they're surfaced through resources for
766
- // discovery purposes only.
767
- const claimedEps = new Set<string>();
768
- for (const g of groups) {
769
- if (g.list) claimedEps.add(epLabel(g.list));
770
- if (g.create) claimedEps.add(epLabel(g.create));
771
- if (g.read) claimedEps.add(epLabel(g.read));
772
- if (g.update) claimedEps.add(epLabel(g.update));
773
- if (g.delete) claimedEps.add(epLabel(g.delete));
774
- }
775
- const orphanEndpoints = params.endpoints
776
- .filter(ep => !claimedEps.has(epLabel(ep)))
777
- .map(epLabel);
778
-
779
- return {
780
- generatedAt: new Date().toISOString(),
781
- specHash: params.specHash,
782
- resourceCount: resources.length,
783
- resources,
784
- orphanEndpoints,
785
- };
786
- }
787
-
788
- // ── YAML serialization (minimal, no dep on yaml lib for the workspace) ──
789
-
790
- function escape(s: string): string {
791
- if (/[:#\[\]{}&*!|>'"@`,%]/.test(s) || s.includes("\n") || s === "") {
792
- return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
793
- }
794
- return s;
795
- }
796
-
797
- export function serializeApiResourceMap(m: ApiResourceMap): string {
798
- const lines: string[] = [];
799
- lines.push("# Auto-generated by zond. Do not edit by hand.");
800
- lines.push("# Regenerate via: zond refresh-api <name>");
801
- lines.push(`generatedAt: ${escape(m.generatedAt)}`);
802
- lines.push(`specHash: ${escape(m.specHash)}`);
803
- lines.push(`resourceCount: ${m.resourceCount}`);
804
- if (m.resources.length === 0) {
805
- lines.push("resources: []");
806
- } else {
807
- lines.push("resources:");
808
- }
809
- for (const r of m.resources) {
810
- lines.push(` - resource: ${escape(r.resource)}`);
811
- lines.push(` basePath: ${escape(r.basePath)}`);
812
- lines.push(` itemPath: ${escape(r.itemPath)}`);
813
- lines.push(` idParam: ${escape(r.idParam)}`);
814
- lines.push(` captureField: ${escape(r.captureField)}`);
815
- lines.push(` hasFullCrud: ${r.hasFullCrud}`);
816
- if (r.requiresEtag) lines.push(` requiresEtag: true`);
817
- lines.push(` endpoints:`);
818
- for (const [k, v] of Object.entries(r.endpoints)) {
819
- lines.push(` ${k}: ${escape(v as string)}`);
820
- }
821
- if (r.fkDependencies.length === 0) {
822
- lines.push(` fkDependencies: []`);
823
- } else {
824
- lines.push(` fkDependencies:`);
825
- for (const d of r.fkDependencies) {
826
- lines.push(` - var: ${escape(d.var)}`);
827
- lines.push(` param: ${escape(d.param)}`);
828
- lines.push(` in: ${d.in}`);
829
- lines.push(` ownerResource: ${d.ownerResource ? escape(d.ownerResource) : "null"}`);
830
- }
831
- }
832
- if (r.readbackDiff) {
833
- lines.push(` readback_diff:`);
834
- const ig = r.readbackDiff.ignoreFields ?? [];
835
- if (ig.length > 0) {
836
- lines.push(` ignore_fields:`);
837
- for (const f of ig) lines.push(` - ${escape(f)}`);
838
- }
839
- const map = r.readbackDiff.writeToReadMap ?? {};
840
- const mapKeys = Object.keys(map);
841
- if (mapKeys.length > 0) {
842
- lines.push(` write_to_read_map:`);
843
- for (const k of mapKeys) lines.push(` ${escape(k)}: ${escape(map[k]!)}`);
844
- }
845
- }
846
- if (r.idempotency) {
847
- lines.push(` idempotency:`);
848
- if (r.idempotency.header) lines.push(` header: ${escape(r.idempotency.header)}`);
849
- if (r.idempotency.scope) lines.push(` scope: ${r.idempotency.scope}`);
850
- const ig = r.idempotency.ignoreResponseFields ?? [];
851
- if (ig.length > 0) {
852
- lines.push(` ignore_response_fields:`);
853
- for (const f of ig) lines.push(` - ${escape(f)}`);
854
- }
855
- }
856
- if (r.pagination) {
857
- lines.push(` pagination:`);
858
- if (r.pagination.type) lines.push(` type: ${r.pagination.type}`);
859
- if (r.pagination.cursorParam) lines.push(` cursor_param: ${escape(r.pagination.cursorParam)}`);
860
- if (r.pagination.cursorField) lines.push(` cursor_field: ${escape(r.pagination.cursorField)}`);
861
- if (r.pagination.hasMoreField) lines.push(` has_more_field: ${escape(r.pagination.hasMoreField)}`);
862
- if (r.pagination.limitParam) lines.push(` limit_param: ${escape(r.pagination.limitParam)}`);
863
- if (r.pagination.defaultLimit != null) lines.push(` default_limit: ${r.pagination.defaultLimit}`);
864
- if (r.pagination.itemsField) lines.push(` items_field: ${escape(r.pagination.itemsField)}`);
865
- if (r.pagination.pageParam) lines.push(` page_param: ${escape(r.pagination.pageParam)}`);
866
- if (r.pagination.startPage != null) lines.push(` start_page: ${r.pagination.startPage}`);
867
- }
868
- if (r.lifecycle) {
869
- lines.push(` lifecycle:`);
870
- lines.push(` field: ${escape(r.lifecycle.field)}`);
871
- lines.push(` states:`);
872
- for (const s of r.lifecycle.states) lines.push(` - ${escape(s)}`);
873
- lines.push(` transitions:`);
874
- for (const t of r.lifecycle.transitions) {
875
- lines.push(` - from: ${escape(t.from)}`);
876
- if (t.to.length === 0) {
877
- lines.push(` to: []`);
878
- } else {
879
- lines.push(` to:`);
880
- for (const to of t.to) lines.push(` - ${escape(to)}`);
881
- }
882
- }
883
- lines.push(` actions:`);
884
- for (const [name, a] of Object.entries(r.lifecycle.actions)) {
885
- lines.push(` ${escape(name)}:`);
886
- lines.push(` endpoint: ${escape(a.endpoint)}`);
887
- lines.push(` expected_state: ${escape(a.expectedState)}`);
888
- }
889
- }
890
- }
891
- if (m.orphanEndpoints.length === 0) {
892
- lines.push("orphanEndpoints: []");
893
- } else {
894
- lines.push("orphanEndpoints:");
895
- for (const e of m.orphanEndpoints) lines.push(` - ${escape(e)}`);
896
- }
897
- return lines.join("\n") + "\n";
898
- }