@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,25 +13,15 @@
13
13
  */
14
14
 
15
15
  import type { EndpointInfo, SecuritySchemeInfo } from "./types.ts";
16
- import { schemeVarName, resourceVar } from "./suite-generator.ts";
16
+ import type { OpenAPIV3 } from "openapi-types";
17
+ import { schemeVarName, resourceVar, computeAmbiguousPathParams, fixtureVarNameForPathParam, owningCollectionForPathParam } from "./suite-generator.ts";
17
18
  import type { ApiResourceMap } from "./resources-builder.ts";
19
+ import { canonicalVarName, isFkFixtureField, effectiveObjectShape } from "./data-factory.ts";
18
20
 
19
- /**
20
- * ARV-138: canonicalise a body field name to a manifest var name.
21
- * Converts camelCase snake_case + lowercase so spec body fields
22
- * (`issueId`, `audienceId`, `accountID`) collapse onto the same manifest
23
- * entry as the spec's path-param spelling (`issue_id`, `audience_id`).
24
- *
25
- * Idempotent on already-snake_case input. The HTTP request still sends
26
- * the raw field name to the server — only the var-name namespace is
27
- * normalised (see `create-body.ts:substituteFkFields`).
28
- */
29
- export function canonicalVarName(name: string): string {
30
- return name
31
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
32
- .replace(/[^a-zA-Z0-9]+/g, "_")
33
- .toLowerCase();
34
- }
21
+ // canonicalVarName now lives in data-factory (leaf module, shared with the
22
+ // suite generator). Re-exported here for back-compat: create-body.ts and
23
+ // existing callers still import it from fixtures-builder.
24
+ export { canonicalVarName };
35
25
 
36
26
  export type FixtureSource = "auth" | "server" | "path" | "header" | "body-fk" | "capture-chain";
37
27
 
@@ -137,13 +127,22 @@ export function buildApiFixtureManifest(params: BuildFixturesParams): ApiFixture
137
127
  }
138
128
  }
139
129
 
140
- // 3. Required path params → one var per unique name
130
+ // 3. Required path params → one var per unique name, disambiguated by
131
+ // owning resource when the raw name is genuinely reused across
132
+ // distinct collections (ARV-369). Without this, a spec where
133
+ // `{code}` appears under both `/macros/v30/{code}` and
134
+ // `/templates/v30/{code}` collapses to ONE `code` fixture — one
135
+ // `.env.yaml` value then silently misapplies to whichever resources
136
+ // don't own it, producing false-negative 404s in generated suites.
137
+ // Single-owner param names (the common case) keep their raw name,
138
+ // unchanged from pre-ARV-369 behavior.
139
+ const ambiguousPathParams = computeAmbiguousPathParams(params.endpoints);
141
140
  for (const ep of params.endpoints) {
142
141
  for (const p of ep.parameters) {
143
142
  if (p.in !== "path") continue;
144
143
  if (p.required === false) continue;
145
- const name = p.name;
146
- let req = fixtures.get(name);
144
+ const varName = fixtureVarNameForPathParam(ep.path, p.name, ambiguousPathParams);
145
+ let req = fixtures.get(varName);
147
146
  if (!req) {
148
147
  const schema = p.schema as { type?: string; format?: string; example?: unknown } | undefined;
149
148
  let defaultValue = "";
@@ -151,15 +150,18 @@ export function buildApiFixtureManifest(params: BuildFixturesParams): ApiFixture
151
150
  else if (schema?.format === "uuid") defaultValue = "";
152
151
  else if (schema?.type === "integer" || schema?.type === "number") defaultValue = "";
153
152
 
153
+ const scopeNote = ambiguousPathParams.has(p.name)
154
+ ? ` Scoped to "${owningCollectionForPathParam(ep.path, p.name) ?? "?"}" — the raw name "${p.name}" is also used by other resources with unrelated ids.`
155
+ : "";
154
156
  req = {
155
- name,
157
+ name: varName,
156
158
  source: "path",
157
- description: `Path parameter ${name}${schema?.format ? ` (${schema.format})` : schema?.type ? ` (${schema.type})` : ""}. Set to a real id from your account, or leave blank to skip dependent tests.`,
159
+ description: `Path parameter ${p.name}${schema?.format ? ` (${schema.format})` : schema?.type ? ` (${schema.type})` : ""}.${scopeNote} Set to a real id from your account, or leave blank to skip dependent tests.`,
158
160
  affectedEndpoints: [],
159
161
  required: true,
160
162
  defaultValue,
161
163
  };
162
- fixtures.set(name, req);
164
+ fixtures.set(varName, req);
163
165
  }
164
166
  pushAffected(req, ep);
165
167
  }
@@ -200,14 +202,19 @@ export function buildApiFixtureManifest(params: BuildFixturesParams): ApiFixture
200
202
  for (const ep of params.endpoints) {
201
203
  const method = ep.method.toUpperCase();
202
204
  if (method !== "POST" && method !== "PUT" && method !== "PATCH") continue;
203
- const schema = ep.requestBodySchema as
204
- | { properties?: Record<string, unknown>; required?: string[] }
205
- | undefined;
206
- if (!schema?.properties) continue;
207
- const required = new Set(schema.required ?? []);
208
- for (const fieldName of Object.keys(schema.properties)) {
209
- if (!/_id$|Id$|_uuid$/.test(fieldName)) continue;
205
+ const schema = ep.requestBodySchema as OpenAPIV3.SchemaObject | undefined;
206
+ if (!schema) continue;
207
+ // effectiveObjectShape merges allOf — .NET/Swagger bodies wrap models in
208
+ // allOf, so a direct schema.properties read misses every FK field.
209
+ const { properties, required } = effectiveObjectShape(schema);
210
+ if (Object.keys(properties).length === 0) continue;
211
+ for (const [fieldName, propSchema] of Object.entries(properties)) {
210
212
  if (!required.has(fieldName)) continue;
213
+ // ARV-45: catch FK ids AND closed-vocab reference codes
214
+ // (`sequenceTypeCode`) the generator can't synthesise — kept in
215
+ // lockstep with `wireBodyFkRefs` in suite-generator so every var the
216
+ // tests reference appears here (decision-7 manifest contract).
217
+ if (!isFkFixtureField(fieldName, propSchema as OpenAPIV3.SchemaObject)) continue;
211
218
  // ARV-138: canonicalise camelCase to snake_case so `issueId` shares
212
219
  // a manifest slot with path-param `issue_id`. The raw `fieldName`
213
220
  // still goes to the server unchanged via `create-body.ts` —
@@ -223,7 +230,7 @@ export function buildApiFixtureManifest(params: BuildFixturesParams): ApiFixture
223
230
  fixtures.set(varName, {
224
231
  name: varName,
225
232
  source: "body-fk",
226
- description: `Foreign-key id consumed by ${epLabel(ep)} request body. Set to a real id from your account, or leave blank to skip dependent tests.`,
233
+ description: `Foreign-key / reference field "${fieldName}" consumed by ${epLabel(ep)} request body. Set to a real value from your account, or leave blank to skip dependent tests.`,
227
234
  affectedEndpoints: [epLabel(ep)],
228
235
  required: true,
229
236
  defaultValue: "",
@@ -12,5 +12,3 @@ export { buildApiResourceMap, serializeApiResourceMap } from "./resources-builde
12
12
  export type { ApiResourceMap, ApiResourceEntry, ResourceFkRef } from "./resources-builder.ts";
13
13
  export { buildApiFixtureManifest, serializeApiFixtureManifest } from "./fixtures-builder.ts";
14
14
  export type { ApiFixtureManifest, FixtureRequirement, FixtureSource } from "./fixtures-builder.ts";
15
- export { buildCreateRequestBody } from "./create-body.ts";
16
- export type { BuildCreateRequestBodyOptions } from "./create-body.ts";
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { rootCertificates } from "node:tls";
1
3
  import { dereference } from "@readme/openapi-parser";
2
4
  import type { OpenAPIV3 } from "openapi-types";
3
5
  import type { EndpointInfo, ResponseInfo, SecuritySchemeInfo } from "./types.ts";
@@ -5,12 +7,48 @@ import { disambiguateGenericPathParams } from "./path-param-disambig.ts";
5
7
 
6
8
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
7
9
 
8
- export async function readOpenApiSpec(specPath: string, options?: { insecure?: boolean }): Promise<OpenAPIV3.Document> {
10
+ export interface SpecFetchTlsOptions {
11
+ /** Disable TLS verification entirely (bun `--insecure`). Last resort. */
12
+ insecure?: boolean;
13
+ /** Path to a PEM CA bundle to trust in addition to the public roots.
14
+ * Falls back to the `NODE_EXTRA_CA_CERTS` env var. */
15
+ caPath?: string;
16
+ }
17
+
18
+ /** MF1 (ARV-367): resolve the bun `fetch` `tls` option for a spec fetch so a
19
+ * self-signed / internal corporate CA validates *without* disabling TLS.
20
+ *
21
+ * Precedence: `insecure` (verification off) > explicit `caPath` /
22
+ * `NODE_EXTRA_CA_CERTS` (extra CA APPENDED to the public roots — never
23
+ * replacing them, so public specs keep validating) > default (undefined).
24
+ * Returns undefined when nothing special is needed. Throws if a CA path is
25
+ * set but unreadable — a misconfigured CA should surface, not fall through
26
+ * to a confusing "self signed certificate" error. */
27
+ export function resolveSpecFetchTls(
28
+ options?: SpecFetchTlsOptions,
29
+ ): { rejectUnauthorized: false } | { ca: string[] } | undefined {
30
+ if (options?.insecure) return { rejectUnauthorized: false };
31
+ const caPath = options?.caPath ?? process.env.NODE_EXTRA_CA_CERTS;
32
+ if (caPath) {
33
+ let extra: string;
34
+ try {
35
+ extra = readFileSync(caPath, "utf8");
36
+ } catch (e) {
37
+ throw new Error(
38
+ `CA bundle not readable: ${caPath} (${(e as Error).message}). ` +
39
+ `Set --ca / NODE_EXTRA_CA_CERTS to a valid PEM file, or use --insecure.`,
40
+ );
41
+ }
42
+ if (extra.trim()) return { ca: [extra, ...rootCertificates] };
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ export async function readOpenApiSpec(specPath: string, options?: SpecFetchTlsOptions): Promise<OpenAPIV3.Document> {
9
48
  // For HTTP URLs, fetch the spec first then dereference the parsed object
10
49
  if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
11
- const resp = await fetch(specPath, {
12
- ...(options?.insecure ? { tls: { rejectUnauthorized: false } } : {}),
13
- });
50
+ const tls = resolveSpecFetchTls(options);
51
+ const resp = await fetch(specPath, { ...(tls ? { tls } : {}) });
14
52
  if (!resp.ok) throw new Error(`Failed to fetch spec: ${resp.status} ${resp.statusText}`);
15
53
  const spec = await resp.json();
16
54
  const api = await dereference(spec as string);
@@ -20,6 +58,29 @@ export async function readOpenApiSpec(specPath: string, options?: { insecure?: b
20
58
  return api as OpenAPIV3.Document;
21
59
  }
22
60
 
61
+ /** ARV-376: align declared path-param names with the path template when they
62
+ * diverge (spec quirk: path `/byid/{id}` but param declared `byid_id`). The
63
+ * template is authoritative for substitution, so unmatched params are renamed
64
+ * to the unmatched template segment names, in positional order. No-op when
65
+ * names already agree (the common, well-formed case). Mutates in place. */
66
+ export function reconcilePathParamNames(
67
+ path: string,
68
+ parameters: OpenAPIV3.ParameterObject[],
69
+ ): void {
70
+ const tplNames = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!);
71
+ if (tplNames.length === 0) return;
72
+ const pathParams = parameters.filter((p) => p.in === "path");
73
+ const declared = new Set(pathParams.map((p) => p.name));
74
+ const unmatchedTpl = tplNames.filter((n) => !declared.has(n));
75
+ const unmatchedParams = pathParams.filter((p) => !tplNames.includes(p.name));
76
+ // Only reconcile a clean 1:1 correspondence — if the counts differ we can't
77
+ // safely guess the mapping, so leave the (already odd) spec untouched.
78
+ if (unmatchedTpl.length === 0 || unmatchedTpl.length !== unmatchedParams.length) return;
79
+ unmatchedParams.forEach((p, i) => {
80
+ (p as { name: string }).name = unmatchedTpl[i]!;
81
+ });
82
+ }
83
+
23
84
  export function extractSecuritySchemes(doc: OpenAPIV3.Document): SecuritySchemeInfo[] {
24
85
  const schemes: SecuritySchemeInfo[] = [];
25
86
  const securitySchemes = doc.components?.securitySchemes;
@@ -88,6 +149,15 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
88
149
  }
89
150
  }
90
151
 
152
+ // ARV-376: reconcile a malformed spec where a path *template* segment
153
+ // (`/byid/{id}`) disagrees with the declared path *parameter* name
154
+ // (`byid_id`) — a docgen-style quirk. The path template is what the
155
+ // runner substitutes, so it wins: rename the diverging param(s) to the
156
+ // template segment name(s), positionally. Without this the fixture var
157
+ // (from the param) never matches the `{...}` in the path, and every
158
+ // downstream layer (disambig, manifest, resource-graph) diverges.
159
+ reconcilePathParamNames(path, parameters);
160
+
91
161
  // Request body schema + content type
92
162
  let requestBodySchema: OpenAPIV3.SchemaObject | undefined;
93
163
  let requestBodyContentType: string | undefined;
@@ -159,6 +229,12 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
159
229
  responses.some(r => r.statusCode === 412) ||
160
230
  parameters.some(p => p.name.toLowerCase() === "if-match" && p.in === "header");
161
231
 
232
+ // ARV-189: harvest `x-*` vendor extensions. Operation-level wins on
233
+ // key collision over path-level so spec authors can default at the
234
+ // path and override per-operation. Empty result = undefined (avoids
235
+ // a churn-y `extensions: {}` field in serialised endpoint snapshots).
236
+ const extensions = collectExtensions(pathItem, operation);
237
+
162
238
  endpoints.push({
163
239
  path,
164
240
  method: method.toUpperCase(),
@@ -173,6 +249,7 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
173
249
  security,
174
250
  deprecated: (operation.deprecated ?? false) || isMarkedDeprecatedInText(operation.summary, operation.description, operation.operationId),
175
251
  requiresEtag,
252
+ ...(extensions ? { extensions } : {}),
176
253
  });
177
254
  }
178
255
  }
@@ -184,6 +261,23 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
184
261
  return disambiguateGenericPathParams(endpoints);
185
262
  }
186
263
 
264
+ /** Collect `x-*` vendor extensions from a path item and operation,
265
+ * with operation values winning on key collision. Returns undefined
266
+ * when neither has any so callers can omit the field cleanly. */
267
+ function collectExtensions(
268
+ pathItem: OpenAPIV3.PathItemObject,
269
+ operation: OpenAPIV3.OperationObject,
270
+ ): Record<string, unknown> | undefined {
271
+ const out: Record<string, unknown> = {};
272
+ for (const [k, v] of Object.entries(pathItem)) {
273
+ if (k.startsWith("x-")) out[k] = v;
274
+ }
275
+ for (const [k, v] of Object.entries(operation)) {
276
+ if (k.startsWith("x-")) out[k] = v;
277
+ }
278
+ return Object.keys(out).length > 0 ? out : undefined;
279
+ }
280
+
187
281
  /** Spec authors often mark endpoints as deprecated in the summary or
188
282
  * description text instead of (or in addition to) the `deprecated: true`
189
283
  * flag — common across many SaaS and legacy specs. Without this
@@ -24,12 +24,35 @@
24
24
 
25
25
  import type { EndpointInfo } from "./types.ts";
26
26
 
27
- const GENERIC_PARAM_NAMES = new Set(["id", "slug", "uuid", "key", "name", "identifier"]);
27
+ const GENERIC_PARAM_NAMES = new Set(["id", "slug", "uuid", "key", "name", "identifier", "code"]);
28
28
 
29
- function isParamSeg(seg: string | undefined): seg is string {
29
+ // ARV-376: read-by-id accessor markers. `/business-segment20/byid/{id}` names
30
+ // the resource `business-segment20`, not `byid` — the `byid` (or `by-id`)
31
+ // segment is a "get by id" verb, not the owning collection. Without skipping
32
+ // it, every `/*/byid/{id}` across the spec collapses to one global `byid_id`
33
+ // var with no owner resource, so prepare-fixtures reports `miss-no-list`.
34
+ const ACCESSOR_MARKER_SEGS = new Set(["byid", "by-id", "by_id"]);
35
+
36
+ // ARV-381: version segments (`v30`, `v2`) sit between the collection and its
37
+ // id/code param (`/api/macros/v30/{code}`). owningCollectionForPathParam +
38
+ // CRUD resource-name derivation ALREADY strip these via
39
+ // stripTrailingVersionSegments; the disambig parent-walk must skip them too,
40
+ // or `macros_v30_code` (scoped past the version) has no matching resource in
41
+ // the graph → miss-no-list. Same regex as stripTrailingVersionSegments.
42
+ function isVersionSeg(seg: string): boolean {
43
+ return /^v(ersion)?\d+$/i.test(seg);
44
+ }
45
+
46
+ function isParamSeg(seg: string | undefined): boolean {
30
47
  return !!seg && /^\{[^}]+\}$/.test(seg);
31
48
  }
32
49
 
50
+ /** Segments the parent-walk steps over to reach the owning collection:
51
+ * read-by-id accessor markers and version segments. */
52
+ function isSkippableSeg(seg: string): boolean {
53
+ return ACCESSOR_MARKER_SEGS.has(seg.toLowerCase()) || isVersionSeg(seg);
54
+ }
55
+
33
56
  /** English singularization sufficient for resource-collection nouns. */
34
57
  function singularize(word: string): string {
35
58
  if (word.length > 3 && /ies$/i.test(word)) return word.slice(0, -3) + "y";
@@ -61,11 +84,14 @@ export function disambiguateGenericPathParams(endpoints: EndpointInfo[]): Endpoi
61
84
  if (!m) continue;
62
85
  const paramName = m[1]!;
63
86
  if (!GENERIC_PARAM_NAMES.has(paramName.toLowerCase())) continue;
64
- // Walk back to nearest non-param non-empty segment as "parent".
87
+ // Walk back to nearest non-param non-empty segment as "parent",
88
+ // skipping read-by-id accessor markers (`byid`, ARV-376) and version
89
+ // segments (`v30`, ARV-381) so the owning collection — not an accessor
90
+ // verb or version marker — names the param.
65
91
  let parent: string | undefined;
66
92
  for (let j = i - 1; j >= 0; j--) {
67
93
  const s = segs[j]!;
68
- if (s && !isParamSeg(s)) {
94
+ if (s && !isParamSeg(s) && !isSkippableSeg(s)) {
69
95
  parent = s;
70
96
  break;
71
97
  }