@kirrosh/zond 0.23.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 (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { EndpointInfo, CrudGroup } from "./types.ts";
15
15
  import type { OpenAPIV3 } from "openapi-types";
16
- import { detectCrudGroups } from "./suite-generator.ts";
16
+ import { detectCrudGroups, singularizeResource, stripTrailingVersionSegments } from "./suite-generator.ts";
17
17
 
18
18
  export interface ResourceFkRef {
19
19
  /** Variable name expected in `.env.yaml` to satisfy the FK (e.g. `audience_id`). */
@@ -78,10 +78,12 @@ export interface IdempotencyConfig {
78
78
  * disjointness + has_more consistency.
79
79
  *
80
80
  * Supported types in this milestone:
81
- * • `cursor` — Stripe/GitHub style: caller passes a cursor (e.g.
81
+ * • `cursor` — Stripe-style: caller passes a cursor (e.g.
82
82
  * `starting_after=<id>`) derived from the last item of the
83
- * previous page. The check is built around this pattern.
84
- * • `page` and `offset` declared for forward compatibility; the
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
85
87
  * check currently skips with a "pagination type not implemented"
86
88
  * reason so the yaml block stays a stable schema.
87
89
  *
@@ -178,19 +180,29 @@ export function validateLifecycleManifest(cfg: LifecycleConfig): string[] {
178
180
  export interface PaginationConfig {
179
181
  /** Pagination flavor. Default `cursor`. */
180
182
  type?: "cursor" | "page" | "offset" | "token";
181
- /** Query-param name that takes the cursor value. Default `starting_after`. */
183
+ /** Query-param name that takes the cursor value. Default `starting_after`.
184
+ * Only used when `type: cursor`. */
182
185
  cursorParam?: string;
183
- /** Response field on each item that becomes the next cursor. Default `id`. */
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`. */
184
188
  cursorField?: string;
185
- /** Response field that signals "more pages remain". Default `has_more`. */
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. */
186
192
  hasMoreField?: string;
187
- /** Query-param name for page size. Default `limit`. */
193
+ /** Query-param name for page size. Default `limit` for cursor-style,
194
+ * `per_page` for page-style. */
188
195
  limitParam?: string;
189
196
  /** Probe page-size. Default 2 (small enough to land two replies fast). */
190
197
  defaultLimit?: number;
191
198
  /** Response field carrying the array of items. Default `data` (Stripe);
192
199
  * falls back to `items` / `results` when missing. */
193
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;
194
206
  }
195
207
 
196
208
  /** ARV-187: LLM-authored example POST body. Stateful checks prefer this
@@ -254,6 +266,135 @@ function isParamSeg(seg: string | undefined): boolean {
254
266
  return !!seg && /^\{[^}]+\}$/.test(seg);
255
267
  }
256
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
+
257
398
  function getCaptureField(create: EndpointInfo): string {
258
399
  // Look at the create endpoint's success response schema for an `id`-ish
259
400
  // field. Falls back to "id" — the universal default.
@@ -283,9 +424,24 @@ function getCaptureField(create: EndpointInfo): string {
283
424
  * we walk back to the nearest non-param segment and search for any
284
425
  * GET path ending with that hint).
285
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
+
286
432
  export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, string> {
287
433
  const getPathSet = new Set<string>();
288
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
+ };
289
445
  for (const ep of endpoints) {
290
446
  if (ep.method.toUpperCase() !== "GET" || ep.deprecated) continue;
291
447
  const path = pathStripSlash(ep.path);
@@ -297,6 +453,20 @@ export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, st
297
453
  arr.push(path);
298
454
  getPathsByLastSeg.set(last, arr);
299
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
+ }
300
470
  }
301
471
 
302
472
  const result = new Map<string, string>();
@@ -314,21 +484,27 @@ export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, st
314
484
  const m = /^\{([^}]+)\}$/.exec(seg);
315
485
  if (!m) continue;
316
486
  const param = m[1]!;
317
- const prevSeg = segs[i - 1];
318
-
319
- // Strategy 1 (canonical): prev seg is a non-param noun and the
320
- // prefix up to (but not including) `{param}` is a GET endpoint.
321
- if (prevSeg && !isParamSeg(prevSeg)) {
322
- const prefix = segs.slice(0, i).join("/");
323
- if (getPathSet.has(prefix)) {
324
- consider(param, prefix);
325
- continue;
326
- }
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; }
327
502
  }
503
+ if (matched) continue;
328
504
 
329
505
  // Strategy 2 (sibling-param chain): walk back to the nearest
330
506
  // non-param segment, then look for *any* GET path that terminates
331
- // with that segment. Pick the shortest match.
507
+ // with that segment (or a `/list`-style endpoint for that stem).
332
508
  let hint: string | undefined;
333
509
  for (let j = i - 1; j >= 0; j--) {
334
510
  const s = segs[j]!;
@@ -338,7 +514,7 @@ export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, st
338
514
  }
339
515
  }
340
516
  if (!hint) continue;
341
- const candidates = getPathsByLastSeg.get(hint);
517
+ const candidates = getPathsByLastSeg.get(hint) ?? listByStemLastSeg.get(hint);
342
518
  if (!candidates || candidates.length === 0) continue;
343
519
  const shortest = candidates.reduce((a, b) => (a.length <= b.length ? a : b));
344
520
  consider(param, shortest);
@@ -349,7 +525,11 @@ export function resolveOwnerListPaths(endpoints: EndpointInfo[]): Map<string, st
349
525
  }
350
526
 
351
527
  function listPathToResourceName(listPath: string): string {
352
- const segs = pathStripSlash(listPath).split("/").filter(Boolean);
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));
353
533
  for (let i = segs.length - 1; i >= 0; i--) {
354
534
  if (!isParamSeg(segs[i])) return segs[i]!;
355
535
  }
@@ -430,9 +610,18 @@ export interface BuildResourcesParams {
430
610
  }
431
611
 
432
612
  export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMap {
433
- const groups = detectCrudGroups(params.endpoints);
613
+ const groups = disambiguateResourceCollisions(detectCrudGroups(params.endpoints));
434
614
  const ownerListPaths = resolveOwnerListPaths(params.endpoints);
435
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
+
436
625
  // Index CRUD-group list paths by normalised path so the FK resolver can
437
626
  // hand back the resource name a structural lookup pointed at.
438
627
  const resourceByListPath = new Map<string, string>();
@@ -460,14 +649,45 @@ export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMa
460
649
  );
461
650
  if (!listEp) continue;
462
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
+
463
683
  implicitResources.push({
464
684
  resource: name,
465
685
  basePath: listPath,
466
- itemPath: "",
467
- idParam: "",
686
+ itemPath,
687
+ idParam,
468
688
  captureField: "id",
469
689
  hasFullCrud: false,
470
- endpoints: { list: epLabel(listEp) },
690
+ endpoints,
471
691
  fkDependencies: [],
472
692
  });
473
693
  resourceByListPath.set(listPath, name);
@@ -510,7 +730,35 @@ export function buildApiResourceMap(params: BuildResourcesParams): ApiResourceMa
510
730
  r.fkDependencies = collectPathFkDeps(r.basePath, "", ownerListPaths, resourceByListPath);
511
731
  }
512
732
 
513
- const resources = [...crudResources, ...implicitResources];
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
+ }
514
762
 
515
763
  // Endpoints that aren't in any CRUD group — RPC-style actions, webhook
516
764
  // accept-only routes, etc. Implicit-list endpoints stay in orphans
@@ -614,6 +862,8 @@ export function serializeApiResourceMap(m: ApiResourceMap): string {
614
862
  if (r.pagination.limitParam) lines.push(` limit_param: ${escape(r.pagination.limitParam)}`);
615
863
  if (r.pagination.defaultLimit != null) lines.push(` default_limit: ${r.pagination.defaultLimit}`);
616
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}`);
617
867
  }
618
868
  if (r.lifecycle) {
619
869
  lines.push(` lifecycle:`);
@@ -4,6 +4,16 @@ import type { OpenAPIV3 } from "openapi-types";
4
4
  * Deep-clone an object, replacing circular references with the vendor-extension
5
5
  * sentinel `{ "x-circular": true }`. Uses WeakSet to track visited objects.
6
6
  *
7
+ * Trade-off (intentional): we mark the SECOND visit of any shared object
8
+ * (post-`dereference()` $ref-target reused under multiple parents) as
9
+ * circular, not just true cycles. The alternative — a DFS path stack —
10
+ * preserves every duplicate fresh, but blows the GitHub spec from 14 MB
11
+ * to 106 MB on disk because shared `per_page`/`page`/`owner` params and
12
+ * response schemas are re-cloned for every endpoint that references them.
13
+ * Downstream tools that need param/schema visibility on every endpoint
14
+ * (e.g. `zond api annotate auto`, ARV-262) handle this by re-reading the
15
+ * source spec themselves rather than relying on `apis/<name>/spec.json`.
16
+ *
7
17
  * Why a vendor extension and not `$ref`: the decycled doc is now written to
8
18
  * disk (apis/<name>/spec.json) and re-read by `@readme/openapi-parser` in
9
19
  * downstream commands (check spec, describe, generate). If the sentinel
@@ -24,6 +34,18 @@ export function decycleSchema(obj: unknown): unknown {
24
34
 
25
35
  const obj = value as Record<string, unknown>;
26
36
  if (seen.has(obj)) {
37
+ // ARV-262: parameter-shaped objects (have both `name` and `in`)
38
+ // appear post-`dereference()` as shared identities across every
39
+ // endpoint that $ref'd them — `per_page`, `page`, `owner`, etc.
40
+ // Past the first visit, the full `{"x-circular": true}` stub
41
+ // erases name+in and downstream tools (annotate auto, generator)
42
+ // can no longer tell what query/header that slot represented.
43
+ // Preserve name+in on revisit; schema/required/description still
44
+ // collapse so the bulk-size trade-off is unchanged (revisits
45
+ // grow by ~30 B each, vs. ~7×-blowup if we cloned everything).
46
+ if (typeof obj.name === "string" && typeof obj.in === "string") {
47
+ return { name: obj.name, in: obj.in };
48
+ }
27
49
  return { "x-circular": true };
28
50
  }
29
51
  seen.add(obj);