@kirrosh/zond 0.22.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,898 @@
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
+ }