@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.
- package/CHANGELOG.md +164 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +33 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- 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
|
|
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.
|
|
84
|
-
* • `page`
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
// prefix
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|