@workos/oagen 0.2.0 → 0.4.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.
@@ -3,13 +3,128 @@ import "dotenv/config";
3
3
  import { Command } from "commander";
4
4
  import * as fs from "node:fs/promises";
5
5
  import { readFile } from "node:fs/promises";
6
- import * as path from "node:path";
7
- import { relative, resolve } from "node:path";
6
+ import * as path$1 from "node:path";
7
+ import path, { relative, resolve } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { BaseResolver, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
9
+ import { BaseResolver, Oas3_1Types, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
10
10
  import Parser from "tree-sitter";
11
+ import { execFile, execFileSync } from "node:child_process";
12
+ import { promisify } from "node:util";
11
13
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
- import { execFileSync } from "node:child_process";
14
+ //#region src/ir/sdk-behavior.ts
15
+ /**
16
+ * Canonical SDK behavior defaults. These match the PHP emitter's current
17
+ * implementation (the most complete). Per-language overrides can be applied
18
+ * via `mergeSdkBehavior()` in each SDK's `oagen.config.ts`.
19
+ *
20
+ * - Retry: exponential backoff with jitter, retries on 429/5xx
21
+ * - Errors: standard HTTP status → exception kind mapping
22
+ * - Telemetry: enabled by default, tracks request ID + latency
23
+ * - Pagination: no inter-page delay (Node overrides to 350ms)
24
+ * - Idempotency: auto-generate UUID v4 for retryable POSTs
25
+ * - Logging: all lifecycle events enabled
26
+ * - User-Agent: SDK identifier + runtime version + app info + AI agent detection
27
+ * - Request guards: detect misplaced RequestOptions keys in params
28
+ * - Timeout: 60s default (Python overrides to 30s)
29
+ */
30
+ function defaultSdkBehavior() {
31
+ return {
32
+ retry: {
33
+ retryableStatusCodes: [
34
+ 429,
35
+ 500,
36
+ 502,
37
+ 503,
38
+ 504
39
+ ],
40
+ maxRetries: 3,
41
+ retryOnConnectionError: true,
42
+ retryOnTimeout: true,
43
+ backoff: {
44
+ initialDelay: 1,
45
+ multiplier: 2,
46
+ maxDelay: 30,
47
+ jitterFactor: .5
48
+ }
49
+ },
50
+ errors: {
51
+ statusCodeMap: {
52
+ 400: "BadRequest",
53
+ 401: "Authentication",
54
+ 403: "Authorization",
55
+ 404: "NotFound",
56
+ 409: "Conflict",
57
+ 422: "UnprocessableEntity",
58
+ 429: "RateLimitExceeded"
59
+ },
60
+ serverErrorKind: "Server",
61
+ clientErrorKind: "Api",
62
+ errorDocUrlTemplate: "https://workos.com/docs/errors/{code}"
63
+ },
64
+ telemetry: {
65
+ enabledByDefault: true,
66
+ headerName: "X-WorkOS-Client-Telemetry",
67
+ requestIdHeader: "X-Request-ID"
68
+ },
69
+ pagination: { autoPageDelayMs: 0 },
70
+ idempotency: {
71
+ headerName: "Idempotency-Key",
72
+ autoGenerateForPost: true
73
+ },
74
+ logging: {
75
+ enabled: true,
76
+ events: [
77
+ "request.start",
78
+ "request.success",
79
+ "request.retry",
80
+ "request.rate_limited",
81
+ "request.error",
82
+ "request.connection_error"
83
+ ]
84
+ },
85
+ userAgent: {
86
+ sdkIdentifierTemplate: "{name} {lang}/{version}",
87
+ includeRuntimeVersion: true,
88
+ allowAppInfo: true,
89
+ aiAgentEnvVars: [
90
+ {
91
+ envVar: "CLAUDE_CODE",
92
+ agentName: "ClaudeCode"
93
+ },
94
+ {
95
+ envVar: "CURSOR_AGENT",
96
+ agentName: "Cursor"
97
+ },
98
+ {
99
+ envVar: "CLINE_ACTIVE",
100
+ agentName: "Cline"
101
+ },
102
+ {
103
+ envVar: "WINDSURF_ACTIVE",
104
+ agentName: "Windsurf"
105
+ },
106
+ {
107
+ envVar: "COPILOT_AGENT",
108
+ agentName: "Copilot"
109
+ }
110
+ ]
111
+ },
112
+ requestGuard: { optionKeys: [
113
+ "api_key",
114
+ "apiKey",
115
+ "idempotency_key",
116
+ "idempotencyKey",
117
+ "extra_headers",
118
+ "extraHeaders",
119
+ "max_retries",
120
+ "maxRetries",
121
+ "base_url",
122
+ "baseUrl"
123
+ ] },
124
+ timeout: { defaultTimeoutSeconds: 60 }
125
+ };
126
+ }
127
+ //#endregion
13
128
  //#region src/errors.ts
14
129
  /**
15
130
  * Structured error hierarchy for oagen.
@@ -68,13 +183,11 @@ var RegistryError = class extends OagenError {
68
183
  //#region src/parser/refs.ts
69
184
  async function loadAndBundleSpec(specPath) {
70
185
  const absolutePath = resolve(specPath);
71
- const document = makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href);
72
- const config = await createConfig({});
73
- const resolver = new BaseResolver();
74
186
  const result = await bundleDocument({
75
- document,
76
- config: config.styleguide,
77
- externalRefResolver: resolver,
187
+ document: makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href),
188
+ config: await createConfig({}),
189
+ types: Oas3_1Types,
190
+ externalRefResolver: new BaseResolver(),
78
191
  dereference: false
79
192
  });
80
193
  if (result.problems.some((p) => p.severity === "error")) throw new SpecParseError(`Failed to parse spec: ${result.problems.filter((p) => p.severity === "error").map((p) => p.message).join("\n")}`, `Check the OpenAPI spec at "${absolutePath}" for syntax errors. Run a linter such as \`npx @redocly/cli lint ${specPath}\` to identify issues.`);
@@ -143,15 +256,49 @@ function walkTypeRef(ref, visitor) {
143
256
  //#endregion
144
257
  //#region src/utils/naming.ts
145
258
  /**
259
+ * Known compound tokens that the regex-based splitter over-splits.
260
+ * Each entry is [lowercase-word-sequence, canonical-form].
261
+ * Sorted longest-first so greedy matching works correctly.
262
+ */
263
+ const COMPOUND_WORDS = [[[
264
+ "m",
265
+ "2",
266
+ "m"
267
+ ], "M2M"], [["o", "auth"], "OAuth"]];
268
+ /**
269
+ * Recombine adjacent words that form a known compound token.
270
+ */
271
+ function recombineCompounds(words) {
272
+ const result = [];
273
+ let i = 0;
274
+ while (i < words.length) {
275
+ let matched = false;
276
+ for (const [pattern, canonical] of COMPOUND_WORDS) if (i + pattern.length <= words.length) {
277
+ if (pattern.every((p, j) => words[i + j].toLowerCase() === p)) {
278
+ result.push(canonical);
279
+ i += pattern.length;
280
+ matched = true;
281
+ break;
282
+ }
283
+ }
284
+ if (!matched) {
285
+ result.push(words[i]);
286
+ i++;
287
+ }
288
+ }
289
+ return result;
290
+ }
291
+ /**
146
292
  * Split a string into words, handling:
147
293
  * - camelCase / PascalCase boundaries
148
294
  * - snake_case / kebab-case separators
149
295
  * - Consecutive capitals (e.g., "HTTPClient" → ["HTTP", "Client"])
150
296
  * - Numbers as word boundaries (e.g., "OAuth2Token" → ["OAuth", "2", "Token"])
297
+ * - Known compounds are recombined (e.g., "M2M" stays as one word)
151
298
  */
152
299
  function splitWords(s) {
153
300
  if (!s) return [];
154
- return s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0);
301
+ return recombineCompounds(s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0));
155
302
  }
156
303
  const ACRONYM_SET = new Set([
157
304
  "SSO",
@@ -159,7 +306,8 @@ const ACRONYM_SET = new Set([
159
306
  "SAML",
160
307
  "SCIM",
161
308
  "JWT",
162
- "HMAC"
309
+ "HMAC",
310
+ "M2M"
163
311
  ]);
164
312
  function toPascalCase(s, acronyms) {
165
313
  const merged = acronyms ? new Set([...ACRONYM_SET, ...acronyms]) : ACRONYM_SET;
@@ -188,11 +336,7 @@ function toSnakeCase(s) {
188
336
  function toUpperSnakeCase(s) {
189
337
  return splitWords(s).map((w) => w.toUpperCase()).join("_");
190
338
  }
191
- const BACKEND_SUFFIXES = [
192
- "Dto",
193
- "DTO",
194
- "Controller"
195
- ];
339
+ const BACKEND_SUFFIXES = ["Controller"];
196
340
  function stripBackendSuffixes(name) {
197
341
  for (const suffix of BACKEND_SUFFIXES) if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length);
198
342
  return name;
@@ -219,7 +363,9 @@ const SINGULAR_SAFE_LIST = new Set([
219
363
  "Canvas",
220
364
  "Alias",
221
365
  "Basis",
222
- "Bus"
366
+ "Bus",
367
+ "Species",
368
+ "Series"
223
369
  ]);
224
370
  /**
225
371
  * Conservative singularize for the leading PascalCase word.
@@ -228,7 +374,7 @@ const SINGULAR_SAFE_LIST = new Set([
228
374
  * - trailing `s` for words >4 chars (e.g. Organizations → Organization)
229
375
  * Safe-listed words are never singularized.
230
376
  */
231
- function singularize(word) {
377
+ function singularize$1(word) {
232
378
  if (SINGULAR_SAFE_LIST.has(word)) return word;
233
379
  if (word.endsWith("ies") && word.length > 4) return word.slice(0, -3) + "y";
234
380
  if (word.endsWith("ses") && word.length > 4) return word.slice(0, -2);
@@ -254,7 +400,7 @@ function cleanSchemaName(name) {
254
400
  const match = result.match(/^([A-Z][a-z]*)/);
255
401
  if (match) {
256
402
  const leadWord = match[1];
257
- const singular = singularize(leadWord);
403
+ const singular = singularize$1(leadWord);
258
404
  if (singular !== leadWord) result = singular + result.slice(leadWord.length);
259
405
  }
260
406
  return result;
@@ -274,7 +420,7 @@ function qualifyInlineModelName(baseName, parentName) {
274
420
  const trailingMatch = baseName.match(/^(.*?)([A-Z][a-z]*)$/);
275
421
  if (trailingMatch) {
276
422
  const [, prefix, trailingWord] = trailingMatch;
277
- return `${cleanParent}${prefix}${singularize(trailingWord)}`;
423
+ return `${cleanParent}${prefix}${singularize$1(trailingWord)}`;
278
424
  }
279
425
  return `${cleanParent}${baseName}`;
280
426
  }
@@ -335,15 +481,39 @@ function buildInlineModel(name, schema) {
335
481
  }
336
482
  //#endregion
337
483
  //#region src/parser/schemas.ts
338
- function extractSchemas(schemas) {
484
+ /**
485
+ * Module-level transform set during extractSchemas(). Used by schemaToTypeRef()
486
+ * to apply the same name transform to $ref model/enum references.
487
+ */
488
+ let activeSchemaNameTransform = null;
489
+ /** Apply cleanSchemaName + the active transform (if any) to a raw schema name. */
490
+ function resolveSchemaName(rawName) {
491
+ let name = cleanSchemaName(toPascalCase(rawName));
492
+ if (activeSchemaNameTransform) name = activeSchemaNameTransform(name);
493
+ return name;
494
+ }
495
+ function extractSchemas(schemas, options) {
339
496
  const enums = [];
340
497
  if (!schemas) return {
341
498
  models: [],
342
499
  enums
343
500
  };
501
+ if (options?.schemaNameTransform) {
502
+ const transform = options.schemaNameTransform;
503
+ const cleanedNames = Object.keys(schemas).map((n) => cleanSchemaName(toPascalCase(n)));
504
+ const transformedToOriginals = /* @__PURE__ */ new Map();
505
+ for (const cleaned of cleanedNames) {
506
+ const transformed = transform(cleaned);
507
+ if (!transformedToOriginals.has(transformed)) transformedToOriginals.set(transformed, []);
508
+ transformedToOriginals.get(transformed).push(cleaned);
509
+ }
510
+ const unsafeToTransform = /* @__PURE__ */ new Set();
511
+ for (const [, originals] of transformedToOriginals) if (originals.length > 1) for (const n of originals) unsafeToTransform.add(n);
512
+ activeSchemaNameTransform = (name) => unsafeToTransform.has(name) ? name : transform(name);
513
+ } else activeSchemaNameTransform = null;
344
514
  const modelsByCleanName = /* @__PURE__ */ new Map();
345
515
  for (const [name, schema] of Object.entries(schemas)) {
346
- const pascalName = cleanSchemaName(toPascalCase(name));
516
+ const pascalName = resolveSchemaName(name);
347
517
  if (schema.enum) enums.push(extractEnum(pascalName, schema));
348
518
  else {
349
519
  const model = extractModel(pascalName, schema, schemas);
@@ -351,6 +521,12 @@ function extractSchemas(schemas) {
351
521
  if (existing) {
352
522
  if (model.fields.length > existing.fields.length) modelsByCleanName.set(pascalName, model);
353
523
  } else modelsByCleanName.set(pascalName, model);
524
+ for (const inlineModel of extractDiscriminatedAllOfVariantModels(schema, pascalName)) {
525
+ const existingInline = modelsByCleanName.get(inlineModel.name);
526
+ if (existingInline) {
527
+ if (inlineModel.fields.length > existingInline.fields.length) modelsByCleanName.set(inlineModel.name, inlineModel);
528
+ } else modelsByCleanName.set(inlineModel.name, inlineModel);
529
+ }
354
530
  }
355
531
  }
356
532
  const models = [...modelsByCleanName.values()];
@@ -366,6 +542,10 @@ function extractSchemas(schemas) {
366
542
  enums
367
543
  };
368
544
  }
545
+ /** Clear the module-level schemaNameTransform. Called by parseSpec() after all extraction phases. */
546
+ function clearSchemaNameTransform() {
547
+ activeSchemaNameTransform = null;
548
+ }
369
549
  /**
370
550
  * Walk a single TypeRef and extract inline enum refs as top-level enum definitions.
371
551
  * Shared by both schemas.ts (field-level) and parse.ts (model-level) collection passes.
@@ -402,7 +582,7 @@ function collectInlineEnums(fields, enums) {
402
582
  function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, models, modelNames, queue) {
403
583
  walkTypeRef(ref, { model: (modelRef) => {
404
584
  if (modelNames.has(modelRef.name)) return;
405
- if (Object.keys(schemas).some((k) => cleanSchemaName(toPascalCase(k)) === modelRef.name)) return;
585
+ if (Object.keys(schemas).some((k) => resolveSchemaName(k) === modelRef.name)) return;
406
586
  const parentSchema = findSchemaByName(parentModelName, schemas);
407
587
  if (!parentSchema) return;
408
588
  const fieldSchema = findNestedFieldSchema(fieldName, parentSchema, schemas);
@@ -417,7 +597,7 @@ function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, mod
417
597
  }
418
598
  /** Find a component schema by PascalCase name. */
419
599
  function findSchemaByName(pascalName, schemas) {
420
- for (const [k, v] of Object.entries(schemas)) if (cleanSchemaName(toPascalCase(k)) === pascalName) return v;
600
+ for (const [k, v] of Object.entries(schemas)) if (resolveSchemaName(k) === pascalName) return v;
421
601
  return null;
422
602
  }
423
603
  /** Walk into a schema to find a field's sub-schema, resolving allOf. */
@@ -575,13 +755,88 @@ function extractAllOfModel(name, schema, schemas) {
575
755
  fields
576
756
  };
577
757
  }
758
+ function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
759
+ const models = [];
760
+ const seenNames = /* @__PURE__ */ new Set();
761
+ for (const subSchema of schema.allOf ?? []) for (const variant of subSchema.oneOf ?? []) {
762
+ if (!variant.properties || variant.type !== void 0 && variant.type !== "object") continue;
763
+ const variantName = deriveDiscriminatedVariantName(variant, fallbackName);
764
+ for (const model of extractInlineModelDeep(variantName, variant)) {
765
+ if (seenNames.has(model.name)) continue;
766
+ seenNames.add(model.name);
767
+ models.push(model);
768
+ }
769
+ }
770
+ return models;
771
+ }
772
+ function deriveDiscriminatedVariantName(schema, fallbackName) {
773
+ if (!schema.properties) return fallbackName;
774
+ for (const name of [
775
+ "event",
776
+ "type",
777
+ "object"
778
+ ]) {
779
+ const field = schema.properties[name];
780
+ if (!field) continue;
781
+ if (typeof field.const === "string") return toPascalCase(field.const);
782
+ if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
783
+ }
784
+ for (const field of Object.values(schema.properties)) {
785
+ if (!field) continue;
786
+ if (typeof field.const === "string") return toPascalCase(field.const);
787
+ if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
788
+ }
789
+ return fallbackName;
790
+ }
791
+ function extractInlineModelDeep(name, schema) {
792
+ const requiredSet = new Set(schema.required ?? []);
793
+ const fields = [];
794
+ const properties = schema.properties ?? {};
795
+ const nestedModels = [];
796
+ for (const [fieldName, fieldSchema] of Object.entries(properties)) {
797
+ if (!fieldSchema) continue;
798
+ fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, requiredSet));
799
+ if (fieldSchema.type === "object" && fieldSchema.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema));
800
+ if (fieldSchema.type === "array" && fieldSchema.items?.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema.items));
801
+ if (fieldSchema.allOf) {
802
+ const mergedProperties = {};
803
+ const mergedRequired = [];
804
+ for (const sub of fieldSchema.allOf) {
805
+ if (sub.properties) Object.assign(mergedProperties, sub.properties);
806
+ if (sub.required) mergedRequired.push(...sub.required);
807
+ }
808
+ if (Object.keys(mergedProperties).length > 0) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), {
809
+ type: "object",
810
+ properties: mergedProperties,
811
+ required: mergedRequired
812
+ }));
813
+ }
814
+ if (fieldSchema.oneOf) {
815
+ const objectVariant = fieldSchema.oneOf.find((v) => v.properties && (v.type === "object" || !v.type));
816
+ if (objectVariant) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), objectVariant));
817
+ }
818
+ }
819
+ return [{
820
+ name,
821
+ description: schema.description,
822
+ fields
823
+ }, ...nestedModels];
824
+ }
825
+ function qualifyNestedInlineName(parentName, fieldName) {
826
+ const pascalField = toPascalCase(fieldName);
827
+ if (pascalField.startsWith(parentName)) return pascalField;
828
+ const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
829
+ const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
830
+ if (match && match[2]) return match[1] + singularize$1(match[2]);
831
+ return qualified;
832
+ }
578
833
  function schemaToTypeRef(schema, contextName, parentModelName) {
579
834
  if (schema.$ref) {
580
835
  const segments = schema.$ref.split("/");
581
836
  const rawName = segments[segments.length - 1];
582
837
  return {
583
838
  kind: "model",
584
- name: cleanSchemaName(toPascalCase(rawName))
839
+ name: resolveSchemaName(rawName)
585
840
  };
586
841
  }
587
842
  if (Array.isArray(schema.type)) {
@@ -845,6 +1100,7 @@ const KNOWN_DATA_PATHS = new Set([
845
1100
  "edges"
846
1101
  ]);
847
1102
  const PAGINATION_METADATA_PATTERNS = [
1103
+ "meta",
848
1104
  "metadata",
849
1105
  "pagination",
850
1106
  "cursor",
@@ -876,11 +1132,12 @@ function detectListEnvelope(schema) {
876
1132
  }
877
1133
  if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
878
1134
  const arrayPropName = arrayProps[0];
879
- if (KNOWN_DATA_PATHS.has(arrayPropName)) return {
1135
+ const hasPaginationCompanion = nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)));
1136
+ if (KNOWN_DATA_PATHS.has(arrayPropName) && hasPaginationCompanion) return {
880
1137
  isEnvelope: true,
881
1138
  dataPath: arrayPropName
882
1139
  };
883
- if (nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)))) return {
1140
+ if (hasPaginationCompanion) return {
884
1141
  isEnvelope: true,
885
1142
  dataPath: arrayPropName
886
1143
  };
@@ -1104,7 +1361,7 @@ function qualifyNestedName(parentName, fieldName) {
1104
1361
  const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
1105
1362
  const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
1106
1363
  if (match && match[2]) {
1107
- const trailing = singularize(match[2]);
1364
+ const trailing = singularize$1(match[2]);
1108
1365
  return match[1] + trailing;
1109
1366
  }
1110
1367
  return qualified;
@@ -1184,13 +1441,33 @@ function extractOperations(paths, operationIdTransform) {
1184
1441
  const op = pathItem[method];
1185
1442
  if (!op) continue;
1186
1443
  const serviceName = inferServiceName(path, op.tags?.[0]);
1187
- const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform);
1444
+ const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName);
1188
1445
  inlineModels.push(...opModels);
1189
1446
  const ops = serviceMap.get(serviceName) ?? [];
1190
1447
  ops.push(operation);
1191
1448
  serviceMap.set(serviceName, ops);
1192
1449
  }
1193
1450
  }
1451
+ for (const [serviceName, operations] of serviceMap) {
1452
+ const pathGroups = /* @__PURE__ */ new Map();
1453
+ for (const op of operations) {
1454
+ const firstSeg = op.path.split("/").filter(Boolean)[0] ?? "";
1455
+ const groupKey = firstSeg.startsWith("{") ? serviceName : toPascalCase(firstSeg);
1456
+ const group = pathGroups.get(groupKey) ?? [];
1457
+ group.push(op);
1458
+ pathGroups.set(groupKey, group);
1459
+ }
1460
+ if (pathGroups.size > 1) {
1461
+ if ([...pathGroups.keys()].some((k) => k !== serviceName)) {
1462
+ serviceMap.delete(serviceName);
1463
+ for (const [groupName, groupOps] of pathGroups) {
1464
+ const existing = serviceMap.get(groupName) ?? [];
1465
+ existing.push(...groupOps);
1466
+ serviceMap.set(groupName, existing);
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1194
1471
  for (const [, operations] of serviceMap) disambiguateOperationNames(operations);
1195
1472
  return {
1196
1473
  services: Array.from(serviceMap.entries()).map(([name, operations]) => ({
@@ -1306,13 +1583,14 @@ function inferDeeperContext(path) {
1306
1583
  if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
1307
1584
  return null;
1308
1585
  }
1309
- function buildOperation(method, path, op, pathLevelParams, operationIdTransform) {
1586
+ function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
1310
1587
  const allParams = [...pathLevelParams, ...op.parameters ?? []];
1311
1588
  const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
1312
- const pathParams = extractParams(allParams, "path");
1313
- const queryParams = extractParams(allParams, "query");
1314
- const headerParams = extractParams(allParams, "header").filter((p) => p.name.toLowerCase() !== "idempotency-key");
1315
- const cookieParams = extractParams(allParams, "cookie");
1589
+ const opContext = serviceName;
1590
+ const pathParams = extractParams(allParams, "path", opContext);
1591
+ const queryParams = extractParams(allParams, "query", opContext);
1592
+ const headerParams = extractParams(allParams, "header", opContext).filter((p) => p.name.toLowerCase() !== "idempotency-key");
1593
+ const cookieParams = extractParams(allParams, "cookie", opContext);
1316
1594
  const reqBodyModels = [];
1317
1595
  const { body: requestBody, encoding: requestBodyEncoding } = extractRequestBody(op.requestBody, op, reqBodyModels);
1318
1596
  const { response, successResponses, errors, inlineModels, isPaginated, dataPath: responseDataPath, itemType: responseItemType } = extractResponses(op.responses, op, path, method);
@@ -1329,6 +1607,7 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
1329
1607
  itemType
1330
1608
  };
1331
1609
  } else if (paginationFromParams) pagination = paginationFromParams;
1610
+ const security = extractOperationSecurity(op.security);
1332
1611
  return {
1333
1612
  operation: {
1334
1613
  name: inferOperationName(method, path, op.operationId, operationIdTransform),
@@ -1347,19 +1626,35 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
1347
1626
  pagination,
1348
1627
  injectIdempotencyKey: hasIdempotencyHeader,
1349
1628
  deprecated: op.deprecated || void 0,
1350
- async: op["x-oagen-async"]
1629
+ async: op["x-oagen-async"],
1630
+ security
1351
1631
  },
1352
1632
  inlineModels
1353
1633
  };
1354
1634
  }
1635
+ /**
1636
+ * Extract per-operation security requirements from an OpenAPI security directive.
1637
+ * Returns undefined when the operation uses the spec-level default security.
1638
+ *
1639
+ * OpenAPI security format: `security: [{ schemeName: [scope1, scope2] }]`
1640
+ */
1641
+ function extractOperationSecurity(security) {
1642
+ if (!security || security.length === 0) return void 0;
1643
+ const requirements = [];
1644
+ for (const entry of security) for (const [schemeName, scopes] of Object.entries(entry)) requirements.push({
1645
+ schemeName,
1646
+ scopes: scopes ?? []
1647
+ });
1648
+ return requirements.length > 0 ? requirements : void 0;
1649
+ }
1355
1650
  function buildDescription(summary, description) {
1356
1651
  if (summary && description && summary !== description) return `${summary}\n\n${description}`;
1357
1652
  return description ?? summary;
1358
1653
  }
1359
- function extractParams(params, location) {
1654
+ function extractParams(params, location, operationContext) {
1360
1655
  return params.filter((p) => p.in === location).map((p) => ({
1361
1656
  name: p.name,
1362
- type: p.schema ? schemaToTypeRef(p.schema, p.name) : {
1657
+ type: p.schema ? schemaToTypeRef(p.schema, p.name, operationContext ? toPascalCase(operationContext) : void 0) : {
1363
1658
  kind: "primitive",
1364
1659
  type: "string"
1365
1660
  },
@@ -1545,7 +1840,14 @@ function extractResponses(responses, op, path, method) {
1545
1840
  statusCode: code,
1546
1841
  type: extractedType
1547
1842
  });
1548
- } else if (code >= 400) {
1843
+ } else if (code >= 300 && code < 400) allSuccessResponses.push({
1844
+ statusCode: code,
1845
+ type: {
1846
+ kind: "primitive",
1847
+ type: "unknown"
1848
+ }
1849
+ });
1850
+ else if (code >= 400) {
1549
1851
  const jsonContent = resp.content?.["application/json"];
1550
1852
  const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
1551
1853
  errors.push({
@@ -1672,6 +1974,19 @@ function collectInlineEnumsFromModels(models, enums) {
1672
1974
  const enumNames = new Set(enums.map((e) => e.name));
1673
1975
  for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
1674
1976
  }
1977
+ /**
1978
+ * Collect inline enum definitions from operation parameters (query, path, header)
1979
+ * and promote them to top-level enums so emitters can generate typed enum files.
1980
+ */
1981
+ function collectInlineEnumsFromOperations(services, enums) {
1982
+ const enumNames = new Set(enums.map((e) => e.name));
1983
+ for (const service of services) for (const op of service.operations) for (const param of [
1984
+ ...op.pathParams,
1985
+ ...op.queryParams,
1986
+ ...op.headerParams,
1987
+ ...op.cookieParams ?? []
1988
+ ]) collectInlineEnumFromRef(param.type, enums, enumNames);
1989
+ }
1675
1990
  //#endregion
1676
1991
  //#region src/parser/normalize-model-refs.ts
1677
1992
  function validateModelRefs(spec) {
@@ -1701,10 +2016,12 @@ async function parseSpec(specPath, options) {
1701
2016
  const spec = parsed;
1702
2017
  const version = spec.openapi ?? "";
1703
2018
  if (!version.startsWith("3.")) throw new SpecParseError(`Unsupported OpenAPI version: ${version}. oagen requires OpenAPI 3.x`, `Update the spec to OpenAPI 3.0 or 3.1. If you are using Swagger 2.x, convert it first with \`npx swagger2openapi ${specPath}\`.`);
1704
- const { models, enums } = extractSchemas(spec.components?.schemas);
2019
+ const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
1705
2020
  const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
2021
+ clearSchemaNameTransform();
1706
2022
  const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
1707
2023
  collectInlineEnumsFromModels(finalModels, enums);
2024
+ collectInlineEnumsFromOperations(services, enums);
1708
2025
  const auth = extractAuthSchemes(spec.components?.securitySchemes);
1709
2026
  const serverEntries = (spec.servers ?? []).map((s) => ({
1710
2027
  url: s.url ?? "",
@@ -1719,7 +2036,8 @@ async function parseSpec(specPath, options) {
1719
2036
  services,
1720
2037
  models: finalModels,
1721
2038
  enums,
1722
- auth
2039
+ auth,
2040
+ sdk: defaultSdkBehavior()
1723
2041
  };
1724
2042
  validateModelRefs(result);
1725
2043
  return result;
@@ -2655,8 +2973,9 @@ const phpMergeAdapter = {
2655
2973
  testFilePatterns: [/Test\.php$/],
2656
2974
  urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
2657
2975
  resolveGrammar: (mod) => {
2658
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
2659
- return mod;
2976
+ const m = mod?.default ?? mod;
2977
+ if (typeof m === "object" && m !== null && "php" in m) return m.php;
2978
+ return m;
2660
2979
  },
2661
2980
  parseStatements(tree, source) {
2662
2981
  const imports = [];
@@ -3162,6 +3481,35 @@ async function extractStatements(source, language) {
3162
3481
  return adapter.parseStatements(tree, source);
3163
3482
  }
3164
3483
  /**
3484
+ * Return a map of top-level class name → { bodyEndLine, bodyEndCol } using
3485
+ * tree-sitter. `bodyEndLine` is 0-based. For brace-delimited classes the
3486
+ * end points at the closing brace line; for indentation-delimited classes
3487
+ * it points one past the last line of the class body.
3488
+ */
3489
+ async function extractClassEndLines(source, language) {
3490
+ const tree = safeParse(await getParser(language), source);
3491
+ const map = /* @__PURE__ */ new Map();
3492
+ function processClassNode(node) {
3493
+ const nameNode = node.childForFieldName("name");
3494
+ if (!nameNode) return;
3495
+ const name = nameNode.text;
3496
+ const body = node.childForFieldName("body");
3497
+ const endRow = body ? body.endPosition.row : node.endPosition.row;
3498
+ if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
3499
+ else map.set(name, { bodyEndLine: endRow + 1 });
3500
+ }
3501
+ for (const child of tree.rootNode.children) {
3502
+ if (child.type === "class_definition" || child.type === "class_declaration") processClassNode(child);
3503
+ if (child.type === "decorated_definition") {
3504
+ for (const inner of child.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
3505
+ }
3506
+ if (child.type === "export_statement") {
3507
+ for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
3508
+ }
3509
+ }
3510
+ return map;
3511
+ }
3512
+ /**
3165
3513
  * Merge generated content into an existing file.
3166
3514
  *
3167
3515
  * - Parses both files with tree-sitter
@@ -3310,11 +3658,27 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3310
3658
  const addedParts = [...toAppend];
3311
3659
  if (insertions) for (const ins of insertions) addedParts.push(ins.text);
3312
3660
  const addedCodeText = addedParts.join("\n");
3313
- const filteredImports = newImports.filter((imp) => {
3661
+ const filteredImports = [];
3662
+ for (const imp of newImports) {
3314
3663
  const braceMatch = imp.text.match(/\{([^}]+)\}/);
3315
- if (!braceMatch) return true;
3316
- return braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean).some((name) => addedCodeText.includes(name));
3317
- });
3664
+ if (!braceMatch) {
3665
+ filteredImports.push(imp);
3666
+ continue;
3667
+ }
3668
+ const names = braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean);
3669
+ const usedNames = names.filter((name) => addedCodeText.includes(name));
3670
+ if (usedNames.length === 0) continue;
3671
+ if (usedNames.length === names.length) filteredImports.push(imp);
3672
+ else {
3673
+ const prefix = imp.text.trimStart().startsWith("import type") ? "import type" : "import";
3674
+ const sourceMatch = imp.text.match(/from\s+(['"][^'"]+['"]);?/);
3675
+ if (sourceMatch) filteredImports.push({
3676
+ key: imp.key,
3677
+ text: `${prefix} { ${usedNames.join(", ")} } from ${sourceMatch[1]};`
3678
+ });
3679
+ else filteredImports.push(imp);
3680
+ }
3681
+ }
3318
3682
  if (filteredImports.length > 0) {
3319
3683
  const renderedImports = adapter.renderImports ? adapter.renderImports(filteredImports) : filteredImports.map((entry) => entry.text);
3320
3684
  const lines = result.split("\n");
@@ -3472,13 +3836,13 @@ async function writeFiles(files, outputDir, options) {
3472
3836
  const language = options?.language;
3473
3837
  const header = options?.header ?? "";
3474
3838
  for (const file of sorted) {
3475
- const fullPath = path.join(outputDir, file.path);
3839
+ const fullPath = path$1.join(outputDir, file.path);
3476
3840
  let existingContent = null;
3477
3841
  try {
3478
3842
  existingContent = await fs.readFile(fullPath, "utf-8");
3479
3843
  } catch {}
3480
3844
  if (existingContent === null) {
3481
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
3845
+ await fs.mkdir(path$1.dirname(fullPath), { recursive: true });
3482
3846
  await fs.writeFile(fullPath, file.content, "utf-8");
3483
3847
  result.written.push(file.path);
3484
3848
  continue;
@@ -3494,15 +3858,18 @@ async function writeFiles(files, outputDir, options) {
3494
3858
  }
3495
3859
  const adapter = language ? getMergeAdapter(language) : void 0;
3496
3860
  if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
3497
- result.skipped.push(file.path);
3498
- continue;
3861
+ if (!(header && existingContent.startsWith(header))) {
3862
+ result.skipped.push(file.path);
3863
+ continue;
3864
+ }
3499
3865
  }
3500
3866
  if (existingContent === file.content) {
3501
3867
  result.identical.push(file.path);
3502
3868
  continue;
3503
3869
  }
3504
3870
  if (file.overwriteExisting) {
3505
- await fs.writeFile(fullPath, file.content, "utf-8");
3871
+ const content = existingContent.includes("@oagen-ignore-start") && language && hasGrammar(language) ? await overwriteWithPreservedRegions(existingContent, file.content, language) : file.content;
3872
+ await fs.writeFile(fullPath, content, "utf-8");
3506
3873
  result.written.push(file.path);
3507
3874
  continue;
3508
3875
  }
@@ -3546,6 +3913,311 @@ async function writeFiles(files, outputDir, options) {
3546
3913
  function isDefaultTestFile(filePath) {
3547
3914
  return /\.(spec|test)\.[jt]sx?$/.test(filePath) || filePath.endsWith("_test.go") || filePath.endsWith("_test.rb") || filePath.endsWith("_spec.rb") || /(?:^|\/)test_.*\.py$/.test(filePath) || filePath.endsWith("_test.py") || filePath.endsWith("Test.php") || filePath.endsWith("_test.rs") || /(?:^|\/)tests\/.*\.rs$/.test(filePath);
3548
3915
  }
3916
+ /**
3917
+ * Overwrite a file with generated content while preserving
3918
+ * `@oagen-ignore-start` / `@oagen-ignore-end` regions (and their
3919
+ * required imports) from the existing file.
3920
+ *
3921
+ * Uses tree-sitter via `extractClassEndLines` to find accurate class
3922
+ * boundaries so ignore blocks are inserted at the correct position even
3923
+ * when docstrings contain unindented text.
3924
+ */
3925
+ async function overwriteWithPreservedRegions(existingContent, generatedContent, language) {
3926
+ const existingLines = existingContent.split("\n");
3927
+ const generatedLines = generatedContent.split("\n");
3928
+ const blocks = [];
3929
+ for (let i = 0; i < existingLines.length; i++) {
3930
+ if (!existingLines[i].includes("@oagen-ignore-start")) continue;
3931
+ const startIdx = i;
3932
+ while (i < existingLines.length && !existingLines[i].includes("@oagen-ignore-end")) i++;
3933
+ blocks.push({
3934
+ containingClass: findContainingClass(existingLines, startIdx),
3935
+ lines: existingLines.slice(startIdx, i + 1)
3936
+ });
3937
+ }
3938
+ if (blocks.length === 0) return generatedContent;
3939
+ const result = [...generatedLines];
3940
+ spliceExtraImports(existingLines, result);
3941
+ const classEndLines = await extractClassEndLines(result.join("\n"), language);
3942
+ const blocksByClass = /* @__PURE__ */ new Map();
3943
+ for (const b of blocks) {
3944
+ if (!blocksByClass.has(b.containingClass)) blocksByClass.set(b.containingClass, []);
3945
+ blocksByClass.get(b.containingClass).push(b.lines);
3946
+ }
3947
+ const replacedClasses = /* @__PURE__ */ new Set();
3948
+ const handledBlockKeys = /* @__PURE__ */ new Set();
3949
+ const replacements = [];
3950
+ for (const [className, classBlocks] of blocksByClass) {
3951
+ let targetClass = className;
3952
+ if (!targetClass) for (const blockLines of classBlocks) {
3953
+ for (const l of blockLines) {
3954
+ const m = l.match(/^(?:export\s+)?class\s+(\w+)/);
3955
+ if (m && classEndLines.has(m[1])) {
3956
+ targetClass = m[1];
3957
+ break;
3958
+ }
3959
+ }
3960
+ if (targetClass) break;
3961
+ }
3962
+ if (!targetClass) continue;
3963
+ if (!classBlocks.some((blockLines) => blockLines.some((l) => {
3964
+ return l.match(/^(?:export\s+)?class\s+(\w+)/)?.[1] === targetClass;
3965
+ }))) continue;
3966
+ const info = classEndLines.get(targetClass);
3967
+ if (!info) continue;
3968
+ const startLine = findClassDeclarationStart(result, targetClass, info.bodyEndLine);
3969
+ if (startLine < 0) continue;
3970
+ const content = [];
3971
+ for (const block of classBlocks) {
3972
+ content.push("");
3973
+ content.push(...block);
3974
+ }
3975
+ replacements.push({
3976
+ startLine,
3977
+ endLine: info.bodyEndLine,
3978
+ content
3979
+ });
3980
+ replacedClasses.add(targetClass);
3981
+ handledBlockKeys.add(className);
3982
+ }
3983
+ replacements.sort((a, b) => b.startLine - a.startLine);
3984
+ for (const rep of replacements) result.splice(rep.startLine, rep.endLine - rep.startLine, ...rep.content);
3985
+ let effectiveClassEndLines = classEndLines;
3986
+ if (replacedClasses.size > 0) effectiveClassEndLines = await extractClassEndLines(result.join("\n"), language);
3987
+ const insertions = [];
3988
+ for (const [className, classBlocks] of blocksByClass) {
3989
+ if (handledBlockKeys.has(className) || replacedClasses.has(className)) continue;
3990
+ const info = className ? effectiveClassEndLines.get(className) : void 0;
3991
+ const insertLine = info ? info.bodyEndLine : result.length;
3992
+ const content = [];
3993
+ for (const block of classBlocks) {
3994
+ content.push("");
3995
+ content.push(...block);
3996
+ }
3997
+ insertions.push({
3998
+ line: insertLine,
3999
+ content
4000
+ });
4001
+ }
4002
+ insertions.sort((a, b) => b.line - a.line);
4003
+ for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
4004
+ return result.join("\n");
4005
+ }
4006
+ /** Scan backwards from `lineIdx` to find the nearest class declaration name. */
4007
+ function findContainingClass(lines, lineIdx) {
4008
+ for (let j = lineIdx - 1; j >= 0; j--) {
4009
+ const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
4010
+ if (m) return m[1];
4011
+ }
4012
+ return "";
4013
+ }
4014
+ /**
4015
+ * Find the start line of a class declaration (including decorators above it)
4016
+ * by scanning backwards from `beforeLine`.
4017
+ */
4018
+ function findClassDeclarationStart(lines, className, beforeLine) {
4019
+ const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
4020
+ for (let i = beforeLine - 1; i >= 0; i--) {
4021
+ if (!classPattern.test(lines[i])) continue;
4022
+ let start = i;
4023
+ while (start > 0 && /^\s*@\w+/.test(lines[start - 1])) start--;
4024
+ return start;
4025
+ }
4026
+ return -1;
4027
+ }
4028
+ /**
4029
+ * Collect `from X import Y, Z` identifiers grouped by module, handling
4030
+ * both single-line and multiline (parenthesized) forms.
4031
+ */
4032
+ function collectFromImports(lines) {
4033
+ const byModule = /* @__PURE__ */ new Map();
4034
+ for (let i = 0; i < lines.length; i++) {
4035
+ if (!/^from\s+/.test(lines[i])) continue;
4036
+ let fullText = lines[i];
4037
+ if (fullText.includes("(") && !fullText.includes(")")) while (i + 1 < lines.length) {
4038
+ i++;
4039
+ fullText += " " + lines[i].trim();
4040
+ if (lines[i].includes(")")) break;
4041
+ }
4042
+ const match = fullText.replace(/[()]/g, "").replace(/\s+/g, " ").trim().match(/^from\s+(\S+)\s+import\s+(.+)$/);
4043
+ if (!match) continue;
4044
+ const mod = match[1];
4045
+ if (!byModule.has(mod)) byModule.set(mod, /* @__PURE__ */ new Set());
4046
+ for (const id of match[2].split(",").map((s) => s.trim()).filter(Boolean)) byModule.get(mod).add(id);
4047
+ }
4048
+ return byModule;
4049
+ }
4050
+ /**
4051
+ * Add imports from the existing file that are absent from the generated
4052
+ * result. Handles both `import X` and `from X import Y` forms.
4053
+ * Mutates `resultLines` in place.
4054
+ */
4055
+ function spliceExtraImports(existingLines, resultLines) {
4056
+ const isSimpleImport = (l) => /^import\s+\w/.test(l);
4057
+ const existingSimple = new Set(existingLines.filter(isSimpleImport).map((l) => l.trim()));
4058
+ const generatedSimple = new Set(resultLines.filter(isSimpleImport).map((l) => l.trim()));
4059
+ const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
4060
+ const existingFrom = collectFromImports(existingLines);
4061
+ const generatedFrom = collectFromImports(resultLines);
4062
+ const extraFromLines = [];
4063
+ for (const [mod, existIds] of existingFrom) {
4064
+ const genIds = generatedFrom.get(mod);
4065
+ if (!genIds) extraFromLines.push(`from ${mod} import ${[...existIds].join(", ")}`);
4066
+ else {
4067
+ const extra = [...existIds].filter((id) => !genIds.has(id));
4068
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
4069
+ }
4070
+ }
4071
+ const allExtra = [...extraSimple, ...extraFromLines];
4072
+ if (allExtra.length === 0) return;
4073
+ let lastImportIdx = -1;
4074
+ for (let i = 0; i < resultLines.length; i++) if (isSimpleImport(resultLines[i]) || /^from\s+/.test(resultLines[i])) {
4075
+ lastImportIdx = i;
4076
+ if (resultLines[i].includes("(") && !resultLines[i].includes(")")) while (i + 1 < resultLines.length) {
4077
+ i++;
4078
+ lastImportIdx = i;
4079
+ if (resultLines[i].includes(")")) break;
4080
+ }
4081
+ }
4082
+ if (lastImportIdx >= 0) resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
4083
+ }
4084
+ //#endregion
4085
+ //#region src/ir/operation-hints.ts
4086
+ /** Minimal snake_case converter for path segments (avoids cross-layer import). */
4087
+ function toSnake(s) {
4088
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s.]+/g, "_").toLowerCase();
4089
+ }
4090
+ /**
4091
+ * Action verbs recognised at the terminal path segment.
4092
+ * When a path ends with one of these, we use it as the verb instead of
4093
+ * deriving a CRUD verb from the HTTP method.
4094
+ */
4095
+ const ACTION_VERBS = new Set([
4096
+ "accept",
4097
+ "activate",
4098
+ "authorize",
4099
+ "challenge",
4100
+ "check",
4101
+ "complete",
4102
+ "confirm",
4103
+ "deactivate",
4104
+ "disable",
4105
+ "enable",
4106
+ "enroll",
4107
+ "generate",
4108
+ "reactivate",
4109
+ "resend",
4110
+ "revoke",
4111
+ "send",
4112
+ "verify"
4113
+ ]);
4114
+ /** Map HTTP method → default CRUD verb. */
4115
+ const CRUD_VERB = {
4116
+ get: "list",
4117
+ post: "create",
4118
+ put: "update",
4119
+ patch: "update",
4120
+ delete: "delete",
4121
+ head: "get",
4122
+ options: "list",
4123
+ trace: "list"
4124
+ };
4125
+ /**
4126
+ * Naive singularization: strips trailing "s" with common-sense exceptions.
4127
+ * Good enough for API resource nouns; not a general-purpose English stemmer.
4128
+ */
4129
+ function singularize(word) {
4130
+ if (word.length <= 2) return word;
4131
+ if (word.endsWith("ies")) return word.slice(0, -3) + "y";
4132
+ if (word.endsWith("ses") || word.endsWith("zes") || word.endsWith("xes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
4133
+ if (word.endsWith("ss") || word.endsWith("us") || word.endsWith("is")) return word;
4134
+ if (word.endsWith("s")) return word.slice(0, -1);
4135
+ return word;
4136
+ }
4137
+ /**
4138
+ * Derive a snake_case method name from an operation's HTTP method and path.
4139
+ *
4140
+ * Rules:
4141
+ * 1. Strip path-param segments (`{id}`, `{slug}`, etc.)
4142
+ * 2. If the terminal segment is an action verb, use it as the verb
4143
+ * 3. Otherwise, use the CRUD verb for the HTTP method
4144
+ * 4. For GET on a collection (no trailing {id}), use "list"; with {id}, use "get"
4145
+ * 5. For POST on a resource path, if only one path param, use "create" singular
4146
+ * 6. Singularize the resource noun for single-resource operations
4147
+ */
4148
+ function deriveMethodName(op, _service) {
4149
+ const segments = op.path.split("/").filter(Boolean);
4150
+ const staticSegments = [];
4151
+ let trailingParam = false;
4152
+ for (const seg of segments) if (seg.startsWith("{") && seg.endsWith("}")) trailingParam = true;
4153
+ else {
4154
+ staticSegments.push(seg);
4155
+ trailingParam = false;
4156
+ }
4157
+ if (staticSegments.length === 0) return `${CRUD_VERB[op.httpMethod] ?? "get"}_root`;
4158
+ const terminal = toSnake(staticSegments[staticSegments.length - 1]);
4159
+ if (ACTION_VERBS.has(terminal)) {
4160
+ const context = staticSegments.slice(0, -1).filter((s) => !s.startsWith("{"));
4161
+ const resource = context.length > 0 ? toSnake(singularize(context[context.length - 1])) : "";
4162
+ if (resource) return `${terminal}_${resource}`;
4163
+ return terminal;
4164
+ }
4165
+ let verb = CRUD_VERB[op.httpMethod] ?? "get";
4166
+ if (op.httpMethod === "get" && trailingParam) verb = "get";
4167
+ let resource = toSnake(terminal);
4168
+ if (staticSegments.length >= 2) {
4169
+ const penultimate = staticSegments[staticSegments.length - 2];
4170
+ const penIdx = segments.lastIndexOf(penultimate);
4171
+ const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
4172
+ if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
4173
+ }
4174
+ const noun = trailingParam ? singularize(resource) : resource;
4175
+ return `${verb}_${verb === "list" ? resource : noun}`;
4176
+ }
4177
+ /**
4178
+ * Resolve all operations in the spec, applying hints and mount rules.
4179
+ *
4180
+ * @param spec - The parsed API spec
4181
+ * @param hints - Per-operation overrides keyed by "METHOD /path"
4182
+ * @param mountRules - Service name → target service mappings
4183
+ * @returns Resolved operations ready for emitter consumption
4184
+ */
4185
+ function resolveOperations(spec, hints, mountRules) {
4186
+ const resolved = [];
4187
+ const hintMap = hints ?? {};
4188
+ const mounts = mountRules ?? {};
4189
+ for (const service of spec.services) for (const op of service.operations) {
4190
+ const hint = hintMap[`${op.httpMethod.toUpperCase()} ${op.path}`];
4191
+ const methodName = hint?.name ?? deriveMethodName(op, service);
4192
+ const mountOn = hint?.mountOn ?? mounts[service.name] ?? service.name;
4193
+ let wrappers;
4194
+ if (hint?.split) wrappers = hint.split.map((sh) => ({
4195
+ name: sh.name,
4196
+ targetVariant: sh.targetVariant,
4197
+ defaults: sh.defaults ?? {},
4198
+ inferFromClient: sh.inferFromClient ?? [],
4199
+ exposedParams: sh.exposedParams ?? [],
4200
+ optionalParams: [],
4201
+ responseModelName: resolveResponseModelName(op)
4202
+ }));
4203
+ resolved.push({
4204
+ operation: op,
4205
+ service,
4206
+ methodName,
4207
+ mountOn,
4208
+ wrappers
4209
+ });
4210
+ }
4211
+ return resolved;
4212
+ }
4213
+ /** Extract the response model name from an operation (for wrapper metadata). */
4214
+ function resolveResponseModelName(op) {
4215
+ if (op.httpMethod === "delete") return null;
4216
+ const ref = op.response;
4217
+ if (ref.kind === "model") return ref.name;
4218
+ if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
4219
+ return null;
4220
+ }
3549
4221
  //#endregion
3550
4222
  //#region src/engine/generate-files.ts
3551
4223
  function buildEmitterContext(spec, options) {
@@ -3555,20 +4227,79 @@ function buildEmitterContext(spec, options) {
3555
4227
  spec,
3556
4228
  outputDir: options.outputDir,
3557
4229
  apiSurface: options.apiSurface,
3558
- overlayLookup: options.overlayLookup
4230
+ overlayLookup: options.overlayLookup,
4231
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
3559
4232
  };
3560
4233
  }
4234
+ /**
4235
+ * Collect model and enum names transitively referenced by service operations.
4236
+ * Walks operation parameters, request bodies, responses, and pagination item
4237
+ * types, then chases model field references until the set stabilizes.
4238
+ */
4239
+ function collectReferencedNames(services, models) {
4240
+ const referencedModels = /* @__PURE__ */ new Set();
4241
+ const referencedEnums = /* @__PURE__ */ new Set();
4242
+ const collectFromTypeRef = (ref) => {
4243
+ walkTypeRef(ref, {
4244
+ model: (r) => referencedModels.add(r.name),
4245
+ enum: (r) => referencedEnums.add(r.name)
4246
+ });
4247
+ };
4248
+ for (const service of services) for (const op of service.operations) {
4249
+ for (const p of [
4250
+ ...op.pathParams,
4251
+ ...op.queryParams,
4252
+ ...op.headerParams,
4253
+ ...op.cookieParams ?? []
4254
+ ]) collectFromTypeRef(p.type);
4255
+ if (op.requestBody) collectFromTypeRef(op.requestBody);
4256
+ collectFromTypeRef(op.response);
4257
+ if (op.pagination) collectFromTypeRef(op.pagination.itemType);
4258
+ for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
4259
+ if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
4260
+ }
4261
+ for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
4262
+ const modelsByName = new Map(models.map((m) => [m.name, m]));
4263
+ const visited = /* @__PURE__ */ new Set();
4264
+ const queue = [...referencedModels];
4265
+ while (queue.length > 0) {
4266
+ const name = queue.pop();
4267
+ if (visited.has(name)) continue;
4268
+ visited.add(name);
4269
+ const model = modelsByName.get(name);
4270
+ if (!model) continue;
4271
+ for (const field of model.fields) {
4272
+ collectFromTypeRef(field.type);
4273
+ for (const m of referencedModels) if (!visited.has(m)) queue.push(m);
4274
+ }
4275
+ }
4276
+ return {
4277
+ models: referencedModels,
4278
+ enums: referencedEnums
4279
+ };
4280
+ }
4281
+ function isDiscriminatedModel(model) {
4282
+ return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
4283
+ }
3561
4284
  /** Collect all generated files from an emitter (no headers, no path prefixes). */
3562
4285
  function generateAllFiles(spec, emitter, ctx) {
4286
+ const referenced = collectReferencedNames(spec.services, spec.models);
4287
+ const reachableModels = spec.models.filter((m) => referenced.models.has(m.name));
4288
+ const reachableEnums = spec.enums.filter((e) => referenced.enums.has(e.name));
4289
+ const reachableSpec = {
4290
+ ...spec,
4291
+ models: reachableModels,
4292
+ enums: reachableEnums
4293
+ };
3563
4294
  return [
3564
- ...emitter.generateModels(spec.models, ctx),
3565
- ...emitter.generateEnums(spec.enums, ctx),
4295
+ ...emitter.generateModels(reachableModels, ctx),
4296
+ ...emitter.generateEnums(reachableEnums, ctx),
3566
4297
  ...emitter.generateResources(spec.services, ctx),
3567
4298
  ...emitter.generateClient(spec, ctx),
3568
4299
  ...emitter.generateErrors(ctx),
3569
4300
  ...emitter.generateConfig(ctx),
3570
- ...emitter.generateTypeSignatures?.(spec, ctx) ?? [],
3571
- ...emitter.generateTests(spec, ctx),
4301
+ ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
4302
+ ...emitter.generateTests(reachableSpec, ctx),
3572
4303
  ...emitter.generateManifest?.(spec, ctx) ?? []
3573
4304
  ];
3574
4305
  }
@@ -3592,6 +4323,103 @@ function generateFiles(spec, emitter, options) {
3592
4323
  }
3593
4324
  //#endregion
3594
4325
  //#region src/engine/integrate.ts
4326
+ /**
4327
+ * Resolve a relative import path against a source file path to get the
4328
+ * target file path. Handles implicit .ts extensions and /index.ts.
4329
+ */
4330
+ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
4331
+ const cleaned = importModulePath.replace(/^['"]|['"]$/g, "");
4332
+ if (!cleaned.startsWith(".")) return void 0;
4333
+ const dir = path.dirname(fromFilePath);
4334
+ const resolved = path.normalize(path.join(dir, cleaned));
4335
+ for (const candidate of [
4336
+ resolved,
4337
+ `${resolved}.ts`,
4338
+ `${resolved}/index.ts`
4339
+ ]) if (knownPaths.has(candidate)) return candidate;
4340
+ }
4341
+ /**
4342
+ * Determine whether a generated file is a "root" — a file that should always
4343
+ * be integrated into the target. Roots are entry points from which we trace
4344
+ * the import graph. Everything else must be reachable from a root.
4345
+ *
4346
+ * Roots: resource classes, client, config, errors, enums, barrel index files.
4347
+ * Non-roots: serializers, fixtures, standalone interfaces, test files.
4348
+ */
4349
+ function isRootFile(filePath) {
4350
+ if (filePath.includes("/fixtures/")) return false;
4351
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
4352
+ if (filePath.includes("/serializers/")) return false;
4353
+ if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
4354
+ return true;
4355
+ }
4356
+ /**
4357
+ * Build a file-level dependency graph from generated files and return only
4358
+ * files reachable from root entry points. Uses tree-sitter to parse imports.
4359
+ */
4360
+ async function treeShakeFiles(files, language) {
4361
+ if (!hasGrammar(language)) return files;
4362
+ const knownPaths = new Set(files.map((f) => f.path));
4363
+ const deps = /* @__PURE__ */ new Map();
4364
+ for (const file of files) {
4365
+ if (!file.path.endsWith(".ts") && !file.path.endsWith(".js")) {
4366
+ deps.set(file.path, /* @__PURE__ */ new Set());
4367
+ continue;
4368
+ }
4369
+ const parsed = await extractStatements(file.content, language);
4370
+ const fileDeps = /* @__PURE__ */ new Set();
4371
+ for (const imp of parsed.imports) {
4372
+ const resolved = resolveImportPath(file.path, imp.key, knownPaths);
4373
+ if (resolved) fileDeps.add(resolved);
4374
+ }
4375
+ for (const stmt of parsed.statements) if (stmt.kind === "reexport") {
4376
+ const match = stmt.text.match(/from\s+['"]([^'"]+)['"]/);
4377
+ if (match) {
4378
+ const resolved = resolveImportPath(file.path, match[1], knownPaths);
4379
+ if (resolved) fileDeps.add(resolved);
4380
+ }
4381
+ }
4382
+ deps.set(file.path, fileDeps);
4383
+ }
4384
+ const codeExtensions = new Set([
4385
+ ".ts",
4386
+ ".js",
4387
+ ".tsx",
4388
+ ".jsx",
4389
+ ".py",
4390
+ ".rb",
4391
+ ".go",
4392
+ ".rs",
4393
+ ".kt",
4394
+ ".cs",
4395
+ ".ex",
4396
+ ".exs",
4397
+ ".php"
4398
+ ]);
4399
+ const reachable = /* @__PURE__ */ new Set();
4400
+ const queue = [];
4401
+ for (const file of files) {
4402
+ const ext = file.path.slice(file.path.lastIndexOf("."));
4403
+ if (!codeExtensions.has(ext)) {
4404
+ reachable.add(file.path);
4405
+ continue;
4406
+ }
4407
+ if (isRootFile(file.path)) {
4408
+ reachable.add(file.path);
4409
+ queue.push(file.path);
4410
+ }
4411
+ }
4412
+ while (queue.length > 0) {
4413
+ const current = queue.pop();
4414
+ const fileDeps = deps.get(current);
4415
+ if (!fileDeps) continue;
4416
+ for (const dep of fileDeps) if (!reachable.has(dep)) {
4417
+ reachable.add(dep);
4418
+ queue.push(dep);
4419
+ }
4420
+ }
4421
+ return files.filter((f) => reachable.has(f.path));
4422
+ }
3595
4423
  function mapFilesForTargetIntegration(files, language) {
3596
4424
  const langPrefix = `${language}/`;
3597
4425
  return files.filter((f) => f.integrateTarget !== false).map((f) => {
@@ -3603,11 +4431,93 @@ function mapFilesForTargetIntegration(files, language) {
3603
4431
  };
3604
4432
  });
3605
4433
  }
4434
+ /**
4435
+ * Resolve file-to-directory conflicts in the target.
4436
+ *
4437
+ * When a generated file creates a package directory (e.g., `api_keys/__init__.py`)
4438
+ * that would shadow an existing module file (e.g., `api_keys.py`), move the
4439
+ * module file's content into `_compat.py` inside the package and add a
4440
+ * re-export from `__init__.py`. This preserves backward-compatible imports
4441
+ * (e.g., `from workos.api_keys import ApiKeysModule`) while the package directory
4442
+ * provides the generated models and resource classes.
4443
+ */
4444
+ async function resolveFileToDirectoryConflicts(files, targetDir) {
4445
+ const result = [...files];
4446
+ const removals = [];
4447
+ for (let i = 0; i < result.length; i++) {
4448
+ const file = result[i];
4449
+ const match = file.path.match(/^(.+)\/__init__\.py$/);
4450
+ if (!match) continue;
4451
+ const dirPath = match[1];
4452
+ const moduleFilePath = `${dirPath}.py`;
4453
+ const fullModulePath = path.join(targetDir, moduleFilePath);
4454
+ let moduleContent;
4455
+ try {
4456
+ moduleContent = await fs.readFile(fullModulePath, "utf-8");
4457
+ } catch {
4458
+ continue;
4459
+ }
4460
+ const compatPath = `${dirPath}/_compat.py`;
4461
+ result.push({
4462
+ path: compatPath,
4463
+ content: moduleContent,
4464
+ overwriteExisting: true
4465
+ });
4466
+ const compatImport = "from ._compat import * # noqa: F401,F403 # pyright: ignore[reportUnusedImport]";
4467
+ const existingContent = file.content.endsWith("\n") ? file.content : file.content + "\n";
4468
+ result[i] = {
4469
+ ...file,
4470
+ content: existingContent + compatImport + "\n"
4471
+ };
4472
+ removals.push(fullModulePath);
4473
+ }
4474
+ return {
4475
+ files: result,
4476
+ removals
4477
+ };
4478
+ }
3606
4479
  async function integrateGeneratedFiles(opts) {
3607
- return writeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.targetDir, {
4480
+ const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
4481
+ const result = await writeFiles(resolved, opts.targetDir, {
3608
4482
  language: opts.language,
3609
4483
  header: opts.header
3610
4484
  });
4485
+ for (const filePath of removals) try {
4486
+ await fs.unlink(filePath);
4487
+ } catch {}
4488
+ return result;
4489
+ }
4490
+ //#endregion
4491
+ //#region src/engine/formatter.ts
4492
+ /**
4493
+ * Post-integration formatter.
4494
+ *
4495
+ * After generating and merging files into a target directory, runs the
4496
+ * emitter-provided format command on all written/merged files so that
4497
+ * generated code matches the target project's style conventions.
4498
+ */
4499
+ const execFileAsync = promisify(execFile);
4500
+ /**
4501
+ * Run the emitter's format command on the given target files.
4502
+ * Silently skips if the emitter has no formatCommand or returns null.
4503
+ */
4504
+ async function formatTargetFiles(emitter, targetDir, filePaths) {
4505
+ if (filePaths.length === 0) return;
4506
+ if (!emitter.formatCommand) return;
4507
+ const formatCmd = emitter.formatCommand(targetDir);
4508
+ if (!formatCmd) return;
4509
+ const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
4510
+ const batchSize = formatCmd.batchSize ?? 100;
4511
+ for (let i = 0; i < absolutePaths.length; i += batchSize) {
4512
+ const batch = absolutePaths.slice(i, i + batchSize);
4513
+ try {
4514
+ await execFileAsync(formatCmd.cmd, [...formatCmd.args, ...batch], {
4515
+ cwd: targetDir,
4516
+ maxBuffer: 10 * 1024 * 1024,
4517
+ timeout: 12e4
4518
+ });
4519
+ } catch {}
4520
+ }
3611
4521
  }
3612
4522
  //#endregion
3613
4523
  //#region src/engine/orchestrator.ts
@@ -3636,6 +4546,12 @@ async function generate(spec, emitter, options) {
3636
4546
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
3637
4547
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
3638
4548
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
4549
+ const allTargetFiles = [
4550
+ ...targetResult.written,
4551
+ ...targetResult.merged,
4552
+ ...targetResult.identical
4553
+ ];
4554
+ if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
3639
4555
  }
3640
4556
  return withHeaders;
3641
4557
  }
@@ -4204,7 +5120,7 @@ function loadOverlayContext(opts) {
4204
5120
  throw new ConfigError(`Failed to parse API surface JSON: ${err instanceof Error ? err.message : String(err)}`, `Ensure "${opts.apiSurfacePath}" contains valid JSON. Re-run \`oagen extract\` to regenerate it.`);
4205
5121
  }
4206
5122
  let manifest;
4207
- const resolvedManifestPath = opts.manifestPath ?? path.join(opts.outputDir, "smoke-manifest.json");
5123
+ const resolvedManifestPath = opts.manifestPath ?? path$1.join(opts.outputDir, "smoke-manifest.json");
4208
5124
  if (opts.manifestPath || existsSync(resolvedManifestPath)) try {
4209
5125
  const parsed = JSON.parse(readFileSync(resolvedManifestPath, "utf-8"));
4210
5126
  if (Array.isArray(parsed)) manifest = parsed;
@@ -4295,7 +5211,10 @@ function expandDocUrls(spec, docUrl) {
4295
5211
  //#endregion
4296
5212
  //#region src/cli/generate.ts
4297
5213
  async function generateCommand(opts) {
4298
- let ir = await parseSpec(opts.spec, { operationIdTransform: opts.operationIdTransform });
5214
+ let ir = await parseSpec(opts.spec, {
5215
+ operationIdTransform: opts.operationIdTransform,
5216
+ schemaNameTransform: opts.schemaNameTransform
5217
+ });
4299
5218
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
4300
5219
  const emitter = getEmitter(opts.lang);
4301
5220
  const namespace = opts.namespace ?? ir.name;
@@ -4318,7 +5237,9 @@ async function generateCommand(opts) {
4318
5237
  outputDir: opts.output,
4319
5238
  target: opts.target,
4320
5239
  apiSurface,
4321
- overlayLookup
5240
+ overlayLookup,
5241
+ operationHints: opts.operationHints,
5242
+ mountRules: opts.mountRules
4322
5243
  });
4323
5244
  if (opts.dryRun) for (const f of files) console.log(f.path);
4324
5245
  else {
@@ -4675,7 +5596,10 @@ function summarize(changes) {
4675
5596
  //#endregion
4676
5597
  //#region src/cli/diff.ts
4677
5598
  async function diffCommand(opts) {
4678
- const parseOptions = { operationIdTransform: opts.operationIdTransform };
5599
+ const parseOptions = {
5600
+ operationIdTransform: opts.operationIdTransform,
5601
+ schemaNameTransform: opts.schemaNameTransform
5602
+ };
4679
5603
  let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
4680
5604
  if (opts.docUrl) {
4681
5605
  oldSpec = expandDocUrls(oldSpec, opts.docUrl);
@@ -5277,7 +6201,7 @@ function cleanGeneratedFiles(dir) {
5277
6201
  return;
5278
6202
  }
5279
6203
  for (const entry of entries) {
5280
- const full = path.join(dir, entry);
6204
+ const full = path$1.join(dir, entry);
5281
6205
  let stat;
5282
6206
  try {
5283
6207
  stat = statSync(full);
@@ -5307,7 +6231,7 @@ function cleanGeneratedFiles(dir) {
5307
6231
  * it to ManifestEntry[] for the overlay builder.
5308
6232
  */
5309
6233
  function loadManifestFromDir(outputDir) {
5310
- const manifestPath = path.join(outputDir, "smoke-manifest.json");
6234
+ const manifestPath = path$1.join(outputDir, "smoke-manifest.json");
5311
6235
  if (!existsSync(manifestPath)) return void 0;
5312
6236
  try {
5313
6237
  const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
@@ -5468,16 +6392,16 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
5468
6392
  //#endregion
5469
6393
  //#region src/verify/run-smoke-check.ts
5470
6394
  const __filename = fileURLToPath(import.meta.url);
5471
- const __dirname = path.dirname(__filename);
6395
+ const __dirname = path$1.dirname(__filename);
5472
6396
  function resolveScript(scriptRelPath) {
5473
- const distScript = path.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
6397
+ const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".mjs"));
5474
6398
  if (existsSync(distScript)) return {
5475
6399
  bin: "node",
5476
6400
  script: distScript
5477
6401
  };
5478
6402
  return {
5479
6403
  bin: "npx",
5480
- script: path.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
6404
+ script: path$1.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
5481
6405
  };
5482
6406
  }
5483
6407
  function runPackagedScript(scriptRelPath, args) {
@@ -5589,7 +6513,7 @@ function printCompatResult(result, baseline) {
5589
6513
  console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
5590
6514
  }
5591
6515
  async function verifyCommand(opts) {
5592
- const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform } = opts;
6516
+ const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
5593
6517
  const maxRetries = opts.maxRetries ?? 3;
5594
6518
  const diagData = {};
5595
6519
  let stepNum = 1;
@@ -5600,7 +6524,10 @@ async function verifyCommand(opts) {
5600
6524
  console.log(separator);
5601
6525
  const effectiveScope = scope ?? (spec ? "spec-only" : "full");
5602
6526
  let parsedSpec;
5603
- if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, { operationIdTransform });
6527
+ if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
6528
+ operationIdTransform,
6529
+ schemaNameTransform
6530
+ });
5604
6531
  else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
5605
6532
  const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
5606
6533
  baseline,
@@ -5643,7 +6570,13 @@ async function verifyCommand(opts) {
5643
6570
  console.log(`\n${separator}`);
5644
6571
  console.log(`Step ${stepNum}: Staleness detection`);
5645
6572
  console.log(separator);
5646
- const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, { operationIdTransform }), await parseSpec(spec, { operationIdTransform }), lang);
6573
+ const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, {
6574
+ operationIdTransform,
6575
+ schemaNameTransform
6576
+ }), await parseSpec(spec, {
6577
+ operationIdTransform,
6578
+ schemaNameTransform
6579
+ }), lang);
5647
6580
  if (stalenessResult.violations.length > 0) {
5648
6581
  console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
5649
6582
  for (const v of stalenessResult.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
@@ -5920,6 +6853,50 @@ async function initCommand(opts) {
5920
6853
  console.log(" - npm run sdk:verify -- --spec <path-to-spec>");
5921
6854
  }
5922
6855
  //#endregion
6856
+ //#region src/cli/resolve.ts
6857
+ async function resolveCommand(opts) {
6858
+ let ir = await parseSpec(opts.spec, {
6859
+ operationIdTransform: opts.operationIdTransform,
6860
+ schemaNameTransform: opts.schemaNameTransform
6861
+ });
6862
+ if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
6863
+ const resolved = resolveOperations(ir, opts.operationHints, opts.mountRules);
6864
+ if ((opts.format ?? "table") === "json") {
6865
+ const output = resolved.map(toJsonRow);
6866
+ console.log(JSON.stringify(output, null, 2));
6867
+ return;
6868
+ }
6869
+ printTable(resolved, opts.operationHints);
6870
+ }
6871
+ function toJsonRow(r) {
6872
+ return {
6873
+ service: r.service.name,
6874
+ method: r.operation.httpMethod.toUpperCase(),
6875
+ path: r.operation.path,
6876
+ derivedName: r.methodName,
6877
+ hintApplied: r.methodName !== r.operation.name,
6878
+ mountOn: r.mountOn,
6879
+ wrappers: r.wrappers?.map((w) => w.name)
6880
+ };
6881
+ }
6882
+ function printTable(resolved, hints) {
6883
+ const hintMap = hints ?? {};
6884
+ console.log("| Service | Method | Path | Resolved Name | Hint | Mount On |");
6885
+ console.log("|---|---|---|---|---|---|");
6886
+ const unhinted = [];
6887
+ for (const r of resolved) {
6888
+ const hasHint = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}` in hintMap;
6889
+ const hintMarker = hasHint ? "yes" : "";
6890
+ const mountChanged = r.mountOn !== r.service.name;
6891
+ const mountCol = mountChanged ? r.mountOn : "";
6892
+ console.log(`| ${r.service.name} | ${r.operation.httpMethod.toUpperCase()} | ${r.operation.path} | \`${r.methodName}\` | ${hintMarker} | ${mountCol} |`);
6893
+ if (r.wrappers) for (const w of r.wrappers) console.log(`| | | | \`${w.name}\` (wrapper) | yes | ${mountCol} |`);
6894
+ if (!hasHint && !mountChanged) unhinted.push(r);
6895
+ }
6896
+ console.log("");
6897
+ console.log(`Total: ${resolved.length} operations, ${resolved.length - unhinted.length} with hints/mounts, ${unhinted.length} algorithm-only`);
6898
+ }
6899
+ //#endregion
5923
6900
  //#region src/cli/config-loader.ts
5924
6901
  const CONFIG_NAMES = [
5925
6902
  "oagen.config.ts",
@@ -5928,7 +6905,7 @@ const CONFIG_NAMES = [
5928
6905
  ];
5929
6906
  async function loadConfig(cwd = process.cwd()) {
5930
6907
  for (const name of CONFIG_NAMES) {
5931
- const configPath = path.resolve(cwd, name);
6908
+ const configPath = path$1.resolve(cwd, name);
5932
6909
  if (!existsSync(configPath)) continue;
5933
6910
  try {
5934
6911
  const mod = await import(pathToFileURL(configPath).href);
@@ -5955,14 +6932,20 @@ function handleError(err) {
5955
6932
  }
5956
6933
  let configSmokeRunners;
5957
6934
  let configOperationIdTransform;
6935
+ let configSchemaNameTransform;
5958
6936
  let configDocUrl;
6937
+ let configOperationHints;
6938
+ let configMountRules;
5959
6939
  try {
5960
6940
  const config = await loadConfig();
5961
6941
  if (config) {
5962
6942
  applyConfig(config);
5963
6943
  configSmokeRunners = config.smokeRunners;
5964
6944
  configOperationIdTransform = config.operationIdTransform;
6945
+ configSchemaNameTransform = config.schemaNameTransform;
5965
6946
  configDocUrl = config.docUrl;
6947
+ configOperationHints = config.operationHints;
6948
+ configMountRules = config.mountRules;
5966
6949
  }
5967
6950
  } catch (err) {
5968
6951
  handleError(err);
@@ -5985,13 +6968,17 @@ program.command("generate").description("Run a registered emitter against an Ope
5985
6968
  generateCommand({
5986
6969
  ...opts,
5987
6970
  operationIdTransform: configOperationIdTransform,
5988
- docUrl: configDocUrl
6971
+ schemaNameTransform: configSchemaNameTransform,
6972
+ docUrl: configDocUrl,
6973
+ operationHints: configOperationHints,
6974
+ mountRules: configMountRules
5989
6975
  }).catch(handleError);
5990
6976
  });
5991
6977
  program.command("diff").description("Compare two OpenAPI specs and output a diff report").requiredOption("--old <path>", "Path to old spec").requiredOption("--new <path>", "Path to new spec").action((opts) => {
5992
6978
  diffCommand({
5993
6979
  ...opts,
5994
6980
  operationIdTransform: configOperationIdTransform,
6981
+ schemaNameTransform: configSchemaNameTransform,
5995
6982
  docUrl: configDocUrl
5996
6983
  }).catch(handleError);
5997
6984
  });
@@ -6005,7 +6992,23 @@ program.command("verify").description("Run smoke tests (and optional compat chec
6005
6992
  verifyCommand({
6006
6993
  ...opts,
6007
6994
  maxRetries: parseInt(opts.maxRetries, 10),
6008
- operationIdTransform: configOperationIdTransform
6995
+ operationIdTransform: configOperationIdTransform,
6996
+ schemaNameTransform: configSchemaNameTransform
6997
+ }).catch(handleError);
6998
+ });
6999
+ program.command("resolve").description("Resolve operation names from spec and output a review table").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").option("--format <format>", "Output format: table or json", "table").action((opts) => {
7000
+ opts.spec ??= process.env.OPENAPI_SPEC_PATH;
7001
+ if (!opts.spec) {
7002
+ console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
7003
+ process.exit(1);
7004
+ }
7005
+ resolveCommand({
7006
+ ...opts,
7007
+ operationIdTransform: configOperationIdTransform,
7008
+ schemaNameTransform: configSchemaNameTransform,
7009
+ docUrl: configDocUrl,
7010
+ operationHints: configOperationHints,
7011
+ mountRules: configMountRules
6009
7012
  }).catch(handleError);
6010
7013
  });
6011
7014
  program.command("init").description("Scaffold a new emitter project").requiredOption("--lang <language>", "Target language").option("--project <dir>", "Project directory", ".").action((opts) => {