@workos/oagen 0.3.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.
@@ -6,11 +6,125 @@ import { readFile } from "node:fs/promises";
6
6
  import * as path$1 from "node:path";
7
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
11
  import { execFile, execFileSync } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
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
14
128
  //#region src/errors.ts
15
129
  /**
16
130
  * Structured error hierarchy for oagen.
@@ -69,13 +183,11 @@ var RegistryError = class extends OagenError {
69
183
  //#region src/parser/refs.ts
70
184
  async function loadAndBundleSpec(specPath) {
71
185
  const absolutePath = resolve(specPath);
72
- const document = makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href);
73
- const config = await createConfig({});
74
- const resolver = new BaseResolver();
75
186
  const result = await bundleDocument({
76
- document,
77
- config: config.styleguide,
78
- 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(),
79
191
  dereference: false
80
192
  });
81
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.`);
@@ -224,11 +336,7 @@ function toSnakeCase(s) {
224
336
  function toUpperSnakeCase(s) {
225
337
  return splitWords(s).map((w) => w.toUpperCase()).join("_");
226
338
  }
227
- const BACKEND_SUFFIXES = [
228
- "Dto",
229
- "DTO",
230
- "Controller"
231
- ];
339
+ const BACKEND_SUFFIXES = ["Controller"];
232
340
  function stripBackendSuffixes(name) {
233
341
  for (const suffix of BACKEND_SUFFIXES) if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length);
234
342
  return name;
@@ -255,7 +363,9 @@ const SINGULAR_SAFE_LIST = new Set([
255
363
  "Canvas",
256
364
  "Alias",
257
365
  "Basis",
258
- "Bus"
366
+ "Bus",
367
+ "Species",
368
+ "Series"
259
369
  ]);
260
370
  /**
261
371
  * Conservative singularize for the leading PascalCase word.
@@ -264,7 +374,7 @@ const SINGULAR_SAFE_LIST = new Set([
264
374
  * - trailing `s` for words >4 chars (e.g. Organizations → Organization)
265
375
  * Safe-listed words are never singularized.
266
376
  */
267
- function singularize(word) {
377
+ function singularize$1(word) {
268
378
  if (SINGULAR_SAFE_LIST.has(word)) return word;
269
379
  if (word.endsWith("ies") && word.length > 4) return word.slice(0, -3) + "y";
270
380
  if (word.endsWith("ses") && word.length > 4) return word.slice(0, -2);
@@ -290,7 +400,7 @@ function cleanSchemaName(name) {
290
400
  const match = result.match(/^([A-Z][a-z]*)/);
291
401
  if (match) {
292
402
  const leadWord = match[1];
293
- const singular = singularize(leadWord);
403
+ const singular = singularize$1(leadWord);
294
404
  if (singular !== leadWord) result = singular + result.slice(leadWord.length);
295
405
  }
296
406
  return result;
@@ -310,7 +420,7 @@ function qualifyInlineModelName(baseName, parentName) {
310
420
  const trailingMatch = baseName.match(/^(.*?)([A-Z][a-z]*)$/);
311
421
  if (trailingMatch) {
312
422
  const [, prefix, trailingWord] = trailingMatch;
313
- return `${cleanParent}${prefix}${singularize(trailingWord)}`;
423
+ return `${cleanParent}${prefix}${singularize$1(trailingWord)}`;
314
424
  }
315
425
  return `${cleanParent}${baseName}`;
316
426
  }
@@ -371,15 +481,39 @@ function buildInlineModel(name, schema) {
371
481
  }
372
482
  //#endregion
373
483
  //#region src/parser/schemas.ts
374
- 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) {
375
496
  const enums = [];
376
497
  if (!schemas) return {
377
498
  models: [],
378
499
  enums
379
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;
380
514
  const modelsByCleanName = /* @__PURE__ */ new Map();
381
515
  for (const [name, schema] of Object.entries(schemas)) {
382
- const pascalName = cleanSchemaName(toPascalCase(name));
516
+ const pascalName = resolveSchemaName(name);
383
517
  if (schema.enum) enums.push(extractEnum(pascalName, schema));
384
518
  else {
385
519
  const model = extractModel(pascalName, schema, schemas);
@@ -387,6 +521,12 @@ function extractSchemas(schemas) {
387
521
  if (existing) {
388
522
  if (model.fields.length > existing.fields.length) modelsByCleanName.set(pascalName, model);
389
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
+ }
390
530
  }
391
531
  }
392
532
  const models = [...modelsByCleanName.values()];
@@ -402,6 +542,10 @@ function extractSchemas(schemas) {
402
542
  enums
403
543
  };
404
544
  }
545
+ /** Clear the module-level schemaNameTransform. Called by parseSpec() after all extraction phases. */
546
+ function clearSchemaNameTransform() {
547
+ activeSchemaNameTransform = null;
548
+ }
405
549
  /**
406
550
  * Walk a single TypeRef and extract inline enum refs as top-level enum definitions.
407
551
  * Shared by both schemas.ts (field-level) and parse.ts (model-level) collection passes.
@@ -438,7 +582,7 @@ function collectInlineEnums(fields, enums) {
438
582
  function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, models, modelNames, queue) {
439
583
  walkTypeRef(ref, { model: (modelRef) => {
440
584
  if (modelNames.has(modelRef.name)) return;
441
- if (Object.keys(schemas).some((k) => cleanSchemaName(toPascalCase(k)) === modelRef.name)) return;
585
+ if (Object.keys(schemas).some((k) => resolveSchemaName(k) === modelRef.name)) return;
442
586
  const parentSchema = findSchemaByName(parentModelName, schemas);
443
587
  if (!parentSchema) return;
444
588
  const fieldSchema = findNestedFieldSchema(fieldName, parentSchema, schemas);
@@ -453,7 +597,7 @@ function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, mod
453
597
  }
454
598
  /** Find a component schema by PascalCase name. */
455
599
  function findSchemaByName(pascalName, schemas) {
456
- 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;
457
601
  return null;
458
602
  }
459
603
  /** Walk into a schema to find a field's sub-schema, resolving allOf. */
@@ -611,13 +755,88 @@ function extractAllOfModel(name, schema, schemas) {
611
755
  fields
612
756
  };
613
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
+ }
614
833
  function schemaToTypeRef(schema, contextName, parentModelName) {
615
834
  if (schema.$ref) {
616
835
  const segments = schema.$ref.split("/");
617
836
  const rawName = segments[segments.length - 1];
618
837
  return {
619
838
  kind: "model",
620
- name: cleanSchemaName(toPascalCase(rawName))
839
+ name: resolveSchemaName(rawName)
621
840
  };
622
841
  }
623
842
  if (Array.isArray(schema.type)) {
@@ -881,6 +1100,7 @@ const KNOWN_DATA_PATHS = new Set([
881
1100
  "edges"
882
1101
  ]);
883
1102
  const PAGINATION_METADATA_PATTERNS = [
1103
+ "meta",
884
1104
  "metadata",
885
1105
  "pagination",
886
1106
  "cursor",
@@ -912,11 +1132,12 @@ function detectListEnvelope(schema) {
912
1132
  }
913
1133
  if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
914
1134
  const arrayPropName = arrayProps[0];
915
- 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 {
916
1137
  isEnvelope: true,
917
1138
  dataPath: arrayPropName
918
1139
  };
919
- if (nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)))) return {
1140
+ if (hasPaginationCompanion) return {
920
1141
  isEnvelope: true,
921
1142
  dataPath: arrayPropName
922
1143
  };
@@ -1140,7 +1361,7 @@ function qualifyNestedName(parentName, fieldName) {
1140
1361
  const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
1141
1362
  const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
1142
1363
  if (match && match[2]) {
1143
- const trailing = singularize(match[2]);
1364
+ const trailing = singularize$1(match[2]);
1144
1365
  return match[1] + trailing;
1145
1366
  }
1146
1367
  return qualified;
@@ -1220,13 +1441,33 @@ function extractOperations(paths, operationIdTransform) {
1220
1441
  const op = pathItem[method];
1221
1442
  if (!op) continue;
1222
1443
  const serviceName = inferServiceName(path, op.tags?.[0]);
1223
- const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform);
1444
+ const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName);
1224
1445
  inlineModels.push(...opModels);
1225
1446
  const ops = serviceMap.get(serviceName) ?? [];
1226
1447
  ops.push(operation);
1227
1448
  serviceMap.set(serviceName, ops);
1228
1449
  }
1229
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
+ }
1230
1471
  for (const [, operations] of serviceMap) disambiguateOperationNames(operations);
1231
1472
  return {
1232
1473
  services: Array.from(serviceMap.entries()).map(([name, operations]) => ({
@@ -1342,13 +1583,14 @@ function inferDeeperContext(path) {
1342
1583
  if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
1343
1584
  return null;
1344
1585
  }
1345
- function buildOperation(method, path, op, pathLevelParams, operationIdTransform) {
1586
+ function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
1346
1587
  const allParams = [...pathLevelParams, ...op.parameters ?? []];
1347
1588
  const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
1348
- const pathParams = extractParams(allParams, "path");
1349
- const queryParams = extractParams(allParams, "query");
1350
- const headerParams = extractParams(allParams, "header").filter((p) => p.name.toLowerCase() !== "idempotency-key");
1351
- 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);
1352
1594
  const reqBodyModels = [];
1353
1595
  const { body: requestBody, encoding: requestBodyEncoding } = extractRequestBody(op.requestBody, op, reqBodyModels);
1354
1596
  const { response, successResponses, errors, inlineModels, isPaginated, dataPath: responseDataPath, itemType: responseItemType } = extractResponses(op.responses, op, path, method);
@@ -1365,6 +1607,7 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
1365
1607
  itemType
1366
1608
  };
1367
1609
  } else if (paginationFromParams) pagination = paginationFromParams;
1610
+ const security = extractOperationSecurity(op.security);
1368
1611
  return {
1369
1612
  operation: {
1370
1613
  name: inferOperationName(method, path, op.operationId, operationIdTransform),
@@ -1383,19 +1626,35 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
1383
1626
  pagination,
1384
1627
  injectIdempotencyKey: hasIdempotencyHeader,
1385
1628
  deprecated: op.deprecated || void 0,
1386
- async: op["x-oagen-async"]
1629
+ async: op["x-oagen-async"],
1630
+ security
1387
1631
  },
1388
1632
  inlineModels
1389
1633
  };
1390
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
+ }
1391
1650
  function buildDescription(summary, description) {
1392
1651
  if (summary && description && summary !== description) return `${summary}\n\n${description}`;
1393
1652
  return description ?? summary;
1394
1653
  }
1395
- function extractParams(params, location) {
1654
+ function extractParams(params, location, operationContext) {
1396
1655
  return params.filter((p) => p.in === location).map((p) => ({
1397
1656
  name: p.name,
1398
- type: p.schema ? schemaToTypeRef(p.schema, p.name) : {
1657
+ type: p.schema ? schemaToTypeRef(p.schema, p.name, operationContext ? toPascalCase(operationContext) : void 0) : {
1399
1658
  kind: "primitive",
1400
1659
  type: "string"
1401
1660
  },
@@ -1581,7 +1840,14 @@ function extractResponses(responses, op, path, method) {
1581
1840
  statusCode: code,
1582
1841
  type: extractedType
1583
1842
  });
1584
- } 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) {
1585
1851
  const jsonContent = resp.content?.["application/json"];
1586
1852
  const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
1587
1853
  errors.push({
@@ -1708,6 +1974,19 @@ function collectInlineEnumsFromModels(models, enums) {
1708
1974
  const enumNames = new Set(enums.map((e) => e.name));
1709
1975
  for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
1710
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
+ }
1711
1990
  //#endregion
1712
1991
  //#region src/parser/normalize-model-refs.ts
1713
1992
  function validateModelRefs(spec) {
@@ -1737,10 +2016,12 @@ async function parseSpec(specPath, options) {
1737
2016
  const spec = parsed;
1738
2017
  const version = spec.openapi ?? "";
1739
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}\`.`);
1740
- const { models, enums } = extractSchemas(spec.components?.schemas);
2019
+ const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
1741
2020
  const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
2021
+ clearSchemaNameTransform();
1742
2022
  const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
1743
2023
  collectInlineEnumsFromModels(finalModels, enums);
2024
+ collectInlineEnumsFromOperations(services, enums);
1744
2025
  const auth = extractAuthSchemes(spec.components?.securitySchemes);
1745
2026
  const serverEntries = (spec.servers ?? []).map((s) => ({
1746
2027
  url: s.url ?? "",
@@ -1755,7 +2036,8 @@ async function parseSpec(specPath, options) {
1755
2036
  services,
1756
2037
  models: finalModels,
1757
2038
  enums,
1758
- auth
2039
+ auth,
2040
+ sdk: defaultSdkBehavior()
1759
2041
  };
1760
2042
  validateModelRefs(result);
1761
2043
  return result;
@@ -2691,8 +2973,9 @@ const phpMergeAdapter = {
2691
2973
  testFilePatterns: [/Test\.php$/],
2692
2974
  urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
2693
2975
  resolveGrammar: (mod) => {
2694
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
2695
- return mod;
2976
+ const m = mod?.default ?? mod;
2977
+ if (typeof m === "object" && m !== null && "php" in m) return m.php;
2978
+ return m;
2696
2979
  },
2697
2980
  parseStatements(tree, source) {
2698
2981
  const imports = [];
@@ -3198,6 +3481,35 @@ async function extractStatements(source, language) {
3198
3481
  return adapter.parseStatements(tree, source);
3199
3482
  }
3200
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
+ /**
3201
3513
  * Merge generated content into an existing file.
3202
3514
  *
3203
3515
  * - Parses both files with tree-sitter
@@ -3546,15 +3858,18 @@ async function writeFiles(files, outputDir, options) {
3546
3858
  }
3547
3859
  const adapter = language ? getMergeAdapter(language) : void 0;
3548
3860
  if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
3549
- result.skipped.push(file.path);
3550
- continue;
3861
+ if (!(header && existingContent.startsWith(header))) {
3862
+ result.skipped.push(file.path);
3863
+ continue;
3864
+ }
3551
3865
  }
3552
3866
  if (existingContent === file.content) {
3553
3867
  result.identical.push(file.path);
3554
3868
  continue;
3555
3869
  }
3556
3870
  if (file.overwriteExisting) {
3557
- 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");
3558
3873
  result.written.push(file.path);
3559
3874
  continue;
3560
3875
  }
@@ -3598,6 +3913,311 @@ async function writeFiles(files, outputDir, options) {
3598
3913
  function isDefaultTestFile(filePath) {
3599
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);
3600
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
+ }
3601
4221
  //#endregion
3602
4222
  //#region src/engine/generate-files.ts
3603
4223
  function buildEmitterContext(spec, options) {
@@ -3607,7 +4227,8 @@ function buildEmitterContext(spec, options) {
3607
4227
  spec,
3608
4228
  outputDir: options.outputDir,
3609
4229
  apiSurface: options.apiSurface,
3610
- overlayLookup: options.overlayLookup
4230
+ overlayLookup: options.overlayLookup,
4231
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
3611
4232
  };
3612
4233
  }
3613
4234
  /**
@@ -3637,6 +4258,7 @@ function collectReferencedNames(services, models) {
3637
4258
  for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
3638
4259
  if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
3639
4260
  }
4261
+ for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
3640
4262
  const modelsByName = new Map(models.map((m) => [m.name, m]));
3641
4263
  const visited = /* @__PURE__ */ new Set();
3642
4264
  const queue = [...referencedModels];
@@ -3656,6 +4278,9 @@ function collectReferencedNames(services, models) {
3656
4278
  enums: referencedEnums
3657
4279
  };
3658
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
+ }
3659
4284
  /** Collect all generated files from an emitter (no headers, no path prefixes). */
3660
4285
  function generateAllFiles(spec, emitter, ctx) {
3661
4286
  const referenced = collectReferencedNames(spec.services, spec.models);
@@ -3756,11 +4381,33 @@ async function treeShakeFiles(files, language) {
3756
4381
  }
3757
4382
  deps.set(file.path, fileDeps);
3758
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
+ ]);
3759
4399
  const reachable = /* @__PURE__ */ new Set();
3760
4400
  const queue = [];
3761
- for (const file of files) if (isRootFile(file.path)) {
3762
- reachable.add(file.path);
3763
- queue.push(file.path);
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
+ }
3764
4411
  }
3765
4412
  while (queue.length > 0) {
3766
4413
  const current = queue.pop();
@@ -3784,11 +4431,61 @@ function mapFilesForTargetIntegration(files, language) {
3784
4431
  };
3785
4432
  });
3786
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
+ }
3787
4479
  async function integrateGeneratedFiles(opts) {
3788
- return writeFiles(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), 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, {
3789
4482
  language: opts.language,
3790
4483
  header: opts.header
3791
4484
  });
4485
+ for (const filePath of removals) try {
4486
+ await fs.unlink(filePath);
4487
+ } catch {}
4488
+ return result;
3792
4489
  }
3793
4490
  //#endregion
3794
4491
  //#region src/engine/formatter.ts
@@ -3849,7 +4546,11 @@ async function generate(spec, emitter, options) {
3849
4546
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
3850
4547
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
3851
4548
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
3852
- const allTargetFiles = [...targetResult.written, ...targetResult.merged];
4549
+ const allTargetFiles = [
4550
+ ...targetResult.written,
4551
+ ...targetResult.merged,
4552
+ ...targetResult.identical
4553
+ ];
3853
4554
  if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
3854
4555
  }
3855
4556
  return withHeaders;
@@ -4510,7 +5211,10 @@ function expandDocUrls(spec, docUrl) {
4510
5211
  //#endregion
4511
5212
  //#region src/cli/generate.ts
4512
5213
  async function generateCommand(opts) {
4513
- 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
+ });
4514
5218
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
4515
5219
  const emitter = getEmitter(opts.lang);
4516
5220
  const namespace = opts.namespace ?? ir.name;
@@ -4533,7 +5237,9 @@ async function generateCommand(opts) {
4533
5237
  outputDir: opts.output,
4534
5238
  target: opts.target,
4535
5239
  apiSurface,
4536
- overlayLookup
5240
+ overlayLookup,
5241
+ operationHints: opts.operationHints,
5242
+ mountRules: opts.mountRules
4537
5243
  });
4538
5244
  if (opts.dryRun) for (const f of files) console.log(f.path);
4539
5245
  else {
@@ -4890,7 +5596,10 @@ function summarize(changes) {
4890
5596
  //#endregion
4891
5597
  //#region src/cli/diff.ts
4892
5598
  async function diffCommand(opts) {
4893
- const parseOptions = { operationIdTransform: opts.operationIdTransform };
5599
+ const parseOptions = {
5600
+ operationIdTransform: opts.operationIdTransform,
5601
+ schemaNameTransform: opts.schemaNameTransform
5602
+ };
4894
5603
  let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
4895
5604
  if (opts.docUrl) {
4896
5605
  oldSpec = expandDocUrls(oldSpec, opts.docUrl);
@@ -5685,7 +6394,7 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
5685
6394
  const __filename = fileURLToPath(import.meta.url);
5686
6395
  const __dirname = path$1.dirname(__filename);
5687
6396
  function resolveScript(scriptRelPath) {
5688
- const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
6397
+ const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".mjs"));
5689
6398
  if (existsSync(distScript)) return {
5690
6399
  bin: "node",
5691
6400
  script: distScript
@@ -5804,7 +6513,7 @@ function printCompatResult(result, baseline) {
5804
6513
  console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
5805
6514
  }
5806
6515
  async function verifyCommand(opts) {
5807
- 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;
5808
6517
  const maxRetries = opts.maxRetries ?? 3;
5809
6518
  const diagData = {};
5810
6519
  let stepNum = 1;
@@ -5815,7 +6524,10 @@ async function verifyCommand(opts) {
5815
6524
  console.log(separator);
5816
6525
  const effectiveScope = scope ?? (spec ? "spec-only" : "full");
5817
6526
  let parsedSpec;
5818
- 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
+ });
5819
6531
  else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
5820
6532
  const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
5821
6533
  baseline,
@@ -5858,7 +6570,13 @@ async function verifyCommand(opts) {
5858
6570
  console.log(`\n${separator}`);
5859
6571
  console.log(`Step ${stepNum}: Staleness detection`);
5860
6572
  console.log(separator);
5861
- 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);
5862
6580
  if (stalenessResult.violations.length > 0) {
5863
6581
  console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
5864
6582
  for (const v of stalenessResult.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
@@ -6135,6 +6853,50 @@ async function initCommand(opts) {
6135
6853
  console.log(" - npm run sdk:verify -- --spec <path-to-spec>");
6136
6854
  }
6137
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
6138
6900
  //#region src/cli/config-loader.ts
6139
6901
  const CONFIG_NAMES = [
6140
6902
  "oagen.config.ts",
@@ -6170,14 +6932,20 @@ function handleError(err) {
6170
6932
  }
6171
6933
  let configSmokeRunners;
6172
6934
  let configOperationIdTransform;
6935
+ let configSchemaNameTransform;
6173
6936
  let configDocUrl;
6937
+ let configOperationHints;
6938
+ let configMountRules;
6174
6939
  try {
6175
6940
  const config = await loadConfig();
6176
6941
  if (config) {
6177
6942
  applyConfig(config);
6178
6943
  configSmokeRunners = config.smokeRunners;
6179
6944
  configOperationIdTransform = config.operationIdTransform;
6945
+ configSchemaNameTransform = config.schemaNameTransform;
6180
6946
  configDocUrl = config.docUrl;
6947
+ configOperationHints = config.operationHints;
6948
+ configMountRules = config.mountRules;
6181
6949
  }
6182
6950
  } catch (err) {
6183
6951
  handleError(err);
@@ -6200,13 +6968,17 @@ program.command("generate").description("Run a registered emitter against an Ope
6200
6968
  generateCommand({
6201
6969
  ...opts,
6202
6970
  operationIdTransform: configOperationIdTransform,
6203
- docUrl: configDocUrl
6971
+ schemaNameTransform: configSchemaNameTransform,
6972
+ docUrl: configDocUrl,
6973
+ operationHints: configOperationHints,
6974
+ mountRules: configMountRules
6204
6975
  }).catch(handleError);
6205
6976
  });
6206
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) => {
6207
6978
  diffCommand({
6208
6979
  ...opts,
6209
6980
  operationIdTransform: configOperationIdTransform,
6981
+ schemaNameTransform: configSchemaNameTransform,
6210
6982
  docUrl: configDocUrl
6211
6983
  }).catch(handleError);
6212
6984
  });
@@ -6220,7 +6992,23 @@ program.command("verify").description("Run smoke tests (and optional compat chec
6220
6992
  verifyCommand({
6221
6993
  ...opts,
6222
6994
  maxRetries: parseInt(opts.maxRetries, 10),
6223
- 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
6224
7012
  }).catch(handleError);
6225
7013
  });
6226
7014
  program.command("init").description("Scaffold a new emitter project").requiredOption("--lang <language>", "Target language").option("--project <dir>", "Project directory", ".").action((opts) => {