@workos/oagen 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +72 -1
  2. package/dist/cli/index.mjs +955 -90
  3. package/dist/cli/index.mjs.map +1 -1
  4. package/dist/compat/index.d.mts +1 -1
  5. package/dist/compat/index.mjs +9 -4
  6. package/dist/compat/index.mjs.map +1 -1
  7. package/dist/{diff-Cn6S74vV.mjs → diff-CSy3g-Bq.mjs} +541 -43
  8. package/dist/diff-CSy3g-Bq.mjs.map +1 -0
  9. package/dist/{differ-B6472Yef.mjs → differ-CG5ZErKl.mjs} +2 -2
  10. package/dist/{differ-B6472Yef.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
  11. package/dist/index.d.mts +111 -3
  12. package/dist/index.d.mts.map +1 -1
  13. package/dist/index.mjs +340 -30
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/scripts/smoke/baseline.mjs +317 -33
  16. package/dist/scripts/smoke/baseline.mjs.map +1 -1
  17. package/dist/smoke.d.mts +140 -0
  18. package/dist/smoke.d.mts.map +1 -1
  19. package/dist/smoke.mjs +317 -33
  20. package/dist/smoke.mjs.map +1 -1
  21. package/dist/{tree-sitter-DFRXfWg9.mjs → tree-sitter-C53w_0WE.mjs} +5 -7
  22. package/dist/tree-sitter-C53w_0WE.mjs.map +1 -0
  23. package/dist/{types-O25qZxMl.d.mts → types-D84LjrVG.d.mts} +167 -2
  24. package/dist/types-D84LjrVG.d.mts.map +1 -0
  25. package/dist/verify/index.d.mts +1 -1
  26. package/dist/verify/index.mjs +4 -4
  27. package/dist/verify/index.mjs.map +1 -1
  28. package/docs/agents/architecture.md +1 -1
  29. package/docs/architecture/emitter-contract.md +1 -2
  30. package/docs/architecture/ir-types.md +73 -1
  31. package/docs/architecture/pipeline.md +0 -1
  32. package/docs/cli.md +29 -8
  33. package/docs/core/minimal-emitter.md +0 -4
  34. package/docs/core/quickstart.md +0 -1
  35. package/examples/reference-emitter/README.md +0 -1
  36. package/examples/reference-emitter/src/emitter.ts +0 -5
  37. package/package.json +4 -4
  38. package/skills/review-operations/SKILL.md +83 -0
  39. package/dist/diff-Cn6S74vV.mjs.map +0 -1
  40. package/dist/tree-sitter-DFRXfWg9.mjs.map +0 -1
  41. package/dist/types-O25qZxMl.d.mts.map +0 -1
  42. package/examples/reference-emitter/src/generators/config.ts +0 -19
@@ -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
  },
@@ -1403,7 +1662,9 @@ function extractParams(params, location) {
1403
1662
  description: p.description,
1404
1663
  deprecated: p.deprecated || p.schema?.deprecated || void 0,
1405
1664
  default: p.schema?.default,
1406
- example: p.example ?? p.schema?.example
1665
+ example: p.example ?? p.schema?.example,
1666
+ style: p.style,
1667
+ explode: p.explode
1407
1668
  }));
1408
1669
  }
1409
1670
  function extractRequestBody(body, op, inlineModels) {
@@ -1581,7 +1842,14 @@ function extractResponses(responses, op, path, method) {
1581
1842
  statusCode: code,
1582
1843
  type: extractedType
1583
1844
  });
1584
- } else if (code >= 400) {
1845
+ } else if (code >= 300 && code < 400) allSuccessResponses.push({
1846
+ statusCode: code,
1847
+ type: {
1848
+ kind: "primitive",
1849
+ type: "unknown"
1850
+ }
1851
+ });
1852
+ else if (code >= 400) {
1585
1853
  const jsonContent = resp.content?.["application/json"];
1586
1854
  const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
1587
1855
  errors.push({
@@ -1708,6 +1976,19 @@ function collectInlineEnumsFromModels(models, enums) {
1708
1976
  const enumNames = new Set(enums.map((e) => e.name));
1709
1977
  for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
1710
1978
  }
1979
+ /**
1980
+ * Collect inline enum definitions from operation parameters (query, path, header)
1981
+ * and promote them to top-level enums so emitters can generate typed enum files.
1982
+ */
1983
+ function collectInlineEnumsFromOperations(services, enums) {
1984
+ const enumNames = new Set(enums.map((e) => e.name));
1985
+ for (const service of services) for (const op of service.operations) for (const param of [
1986
+ ...op.pathParams,
1987
+ ...op.queryParams,
1988
+ ...op.headerParams,
1989
+ ...op.cookieParams ?? []
1990
+ ]) collectInlineEnumFromRef(param.type, enums, enumNames);
1991
+ }
1711
1992
  //#endregion
1712
1993
  //#region src/parser/normalize-model-refs.ts
1713
1994
  function validateModelRefs(spec) {
@@ -1737,10 +2018,12 @@ async function parseSpec(specPath, options) {
1737
2018
  const spec = parsed;
1738
2019
  const version = spec.openapi ?? "";
1739
2020
  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);
2021
+ const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
1741
2022
  const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
2023
+ clearSchemaNameTransform();
1742
2024
  const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
1743
2025
  collectInlineEnumsFromModels(finalModels, enums);
2026
+ collectInlineEnumsFromOperations(services, enums);
1744
2027
  const auth = extractAuthSchemes(spec.components?.securitySchemes);
1745
2028
  const serverEntries = (spec.servers ?? []).map((s) => ({
1746
2029
  url: s.url ?? "",
@@ -1755,7 +2038,8 @@ async function parseSpec(specPath, options) {
1755
2038
  services,
1756
2039
  models: finalModels,
1757
2040
  enums,
1758
- auth
2041
+ auth,
2042
+ sdk: defaultSdkBehavior()
1759
2043
  };
1760
2044
  validateModelRefs(result);
1761
2045
  return result;
@@ -2691,8 +2975,9 @@ const phpMergeAdapter = {
2691
2975
  testFilePatterns: [/Test\.php$/],
2692
2976
  urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
2693
2977
  resolveGrammar: (mod) => {
2694
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
2695
- return mod;
2978
+ const m = mod?.default ?? mod;
2979
+ if (typeof m === "object" && m !== null && "php" in m) return m.php;
2980
+ return m;
2696
2981
  },
2697
2982
  parseStatements(tree, source) {
2698
2983
  const imports = [];
@@ -3143,6 +3428,30 @@ function safeParse(parser, source) {
3143
3428
  * Supports any language with a tree-sitter grammar package.
3144
3429
  */
3145
3430
  const parserCache = /* @__PURE__ */ new Map();
3431
+ /**
3432
+ * When replacing an existing docstring with a generated one, carry over any
3433
+ * `@deprecated` lines from the existing doc that aren't present in the
3434
+ * generated doc. This prevents hand-written deprecation notices from being
3435
+ * silently dropped during docstring refresh.
3436
+ */
3437
+ function preserveDeprecatedTags(existingDoc, generatedDoc) {
3438
+ if (!existingDoc.includes("@deprecated")) return generatedDoc;
3439
+ if (generatedDoc.includes("@deprecated")) return generatedDoc;
3440
+ const deprecatedParts = [];
3441
+ for (const line of existingDoc.split("\n")) if (/@deprecated\b/.test(line)) {
3442
+ const match = line.match(/@deprecated\b.*/);
3443
+ if (match) {
3444
+ const cleaned = match[0].replace(/\s*\*\/\s*$/, "");
3445
+ deprecatedParts.push(` * ${cleaned}`);
3446
+ }
3447
+ }
3448
+ if (deprecatedParts.length === 0) return generatedDoc;
3449
+ const closingIdx = generatedDoc.lastIndexOf("*/");
3450
+ if (closingIdx === -1) return generatedDoc;
3451
+ const before = generatedDoc.slice(0, closingIdx);
3452
+ const after = generatedDoc.slice(closingIdx);
3453
+ return before + deprecatedParts.join("\n") + "\n " + after;
3454
+ }
3146
3455
  function findIgnoredRegions(source) {
3147
3456
  const regions = [];
3148
3457
  const startTag = "@oagen-ignore-start";
@@ -3198,6 +3507,35 @@ async function extractStatements(source, language) {
3198
3507
  return adapter.parseStatements(tree, source);
3199
3508
  }
3200
3509
  /**
3510
+ * Return a map of top-level class name → { bodyEndLine, bodyEndCol } using
3511
+ * tree-sitter. `bodyEndLine` is 0-based. For brace-delimited classes the
3512
+ * end points at the closing brace line; for indentation-delimited classes
3513
+ * it points one past the last line of the class body.
3514
+ */
3515
+ async function extractClassEndLines(source, language) {
3516
+ const tree = safeParse(await getParser(language), source);
3517
+ const map = /* @__PURE__ */ new Map();
3518
+ function processClassNode(node) {
3519
+ const nameNode = node.childForFieldName("name");
3520
+ if (!nameNode) return;
3521
+ const name = nameNode.text;
3522
+ const body = node.childForFieldName("body");
3523
+ const endRow = body ? body.endPosition.row : node.endPosition.row;
3524
+ if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
3525
+ else map.set(name, { bodyEndLine: endRow + 1 });
3526
+ }
3527
+ for (const child of tree.rootNode.children) {
3528
+ if (child.type === "class_definition" || child.type === "class_declaration") processClassNode(child);
3529
+ if (child.type === "decorated_definition") {
3530
+ for (const inner of child.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
3531
+ }
3532
+ if (child.type === "export_statement") {
3533
+ for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
3534
+ }
3535
+ }
3536
+ return map;
3537
+ }
3538
+ /**
3201
3539
  * Merge generated content into an existing file.
3202
3540
  *
3203
3541
  * - Parses both files with tree-sitter
@@ -3227,10 +3565,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3227
3565
  }
3228
3566
  for (const anchor of existingStatements.importAnchors) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(anchor)).split("\n").length - 1 + anchor.split("\n").length - 1;
3229
3567
  if (lastImportEndIndex === -1 && existingStatements.importInsertionAnchor) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(existingStatements.importInsertionAnchor)).split("\n").length - 1 + existingStatements.importInsertionAnchor.split("\n").length - 1;
3568
+ const existingExportedNames = /* @__PURE__ */ new Set();
3230
3569
  for (const stmt of existingStatements.statements) {
3231
- if (stmt.kind === "reexport") existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
3232
- if (stmt.key) existingKeys.add(stmt.key);
3233
- else existingUnnamedTexts.add(stmt.text.trim());
3570
+ if (stmt.kind === "reexport") {
3571
+ existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
3572
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
3573
+ if (braceMatch) for (const part of braceMatch[1].split(",")) {
3574
+ const segments = part.trim().split(/\s+as\s+/);
3575
+ const exportedName = (segments[1] ?? segments[0]).trim();
3576
+ if (exportedName) existingExportedNames.add(exportedName);
3577
+ }
3578
+ }
3579
+ if (stmt.key) {
3580
+ existingKeys.add(stmt.key);
3581
+ existingExportedNames.add(stmt.key);
3582
+ } else existingUnnamedTexts.add(stmt.text.trim());
3234
3583
  }
3235
3584
  const headerLine = header.trim();
3236
3585
  const newImports = [];
@@ -3290,6 +3639,17 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3290
3639
  preserved++;
3291
3640
  continue;
3292
3641
  }
3642
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
3643
+ if (braceMatch) {
3644
+ const exportedNames = braceMatch[1].split(",").map((n) => {
3645
+ const parts = n.trim().split(/\s+as\s+/);
3646
+ return (parts[1] ?? parts[0]).trim();
3647
+ }).filter(Boolean);
3648
+ if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) {
3649
+ preserved++;
3650
+ continue;
3651
+ }
3652
+ }
3293
3653
  }
3294
3654
  if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) {
3295
3655
  preserved++;
@@ -3391,10 +3751,11 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3391
3751
  const genDoc = genInfo.docstring && genInfo.docstring.text.trim() !== headerLine ? genInfo.docstring : null;
3392
3752
  if (genDoc) if (existInfo.docstring) {
3393
3753
  if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
3754
+ const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
3394
3755
  edits.push({
3395
3756
  start: existInfo.docstring.startIndex,
3396
3757
  end: existInfo.docstring.endIndex,
3397
- newText: genDoc.text
3758
+ newText
3398
3759
  });
3399
3760
  docstringUpdates++;
3400
3761
  }
@@ -3411,14 +3772,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3411
3772
  const matchedExistMembers = /* @__PURE__ */ new Set();
3412
3773
  for (const [memberName, genMember] of genInfo.members) {
3413
3774
  const existMember = existInfo.members.get(memberName);
3414
- if (!existMember || !genMember.docstring) continue;
3775
+ if (!existMember) continue;
3415
3776
  matchedExistMembers.add(memberName);
3777
+ if (!genMember.docstring) continue;
3416
3778
  if (existMember.docstring) {
3417
3779
  if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
3780
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
3418
3781
  edits.push({
3419
3782
  start: existMember.docstring.startIndex,
3420
3783
  end: existMember.docstring.endIndex,
3421
- newText: genMember.docstring.text
3784
+ newText
3422
3785
  });
3423
3786
  docstringUpdates++;
3424
3787
  }
@@ -3433,34 +3796,59 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3433
3796
  docstringUpdates++;
3434
3797
  }
3435
3798
  }
3436
- for (const [_genName, genMember] of genInfo.members) {
3799
+ const unmatchedGenByFp = /* @__PURE__ */ new Map();
3800
+ for (const [genName, genMember] of genInfo.members) {
3801
+ if (matchedExistMembers.has(genName)) continue;
3437
3802
  if (!genMember.docstring || !genMember.urlFingerprint) continue;
3438
- for (const [existName, existMember] of existInfo.members) {
3439
- if (matchedExistMembers.has(existName)) continue;
3440
- if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue;
3441
- if (existMember.docstring) {
3442
- if (existMember.docstring.text.includes("@oagen-ignore")) continue;
3443
- if (existMember.docstring.text !== genMember.docstring.text) {
3444
- edits.push({
3445
- start: existMember.docstring.startIndex,
3446
- end: existMember.docstring.endIndex,
3447
- newText: genMember.docstring.text
3448
- });
3449
- docstringUpdates++;
3450
- }
3451
- } else {
3452
- const lineStart = existMember.declStartIndex - existMember.declColumn;
3453
- const indent = " ".repeat(existMember.declColumn);
3803
+ if (existInfo.members.has(genName)) continue;
3804
+ const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? [];
3805
+ bucket.push({
3806
+ name: genName,
3807
+ member: genMember
3808
+ });
3809
+ unmatchedGenByFp.set(genMember.urlFingerprint, bucket);
3810
+ }
3811
+ const unmatchedExistByFp = /* @__PURE__ */ new Map();
3812
+ for (const [existName, existMember] of existInfo.members) {
3813
+ if (matchedExistMembers.has(existName)) continue;
3814
+ if (!existMember.urlFingerprint) continue;
3815
+ const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? [];
3816
+ bucket.push({
3817
+ name: existName,
3818
+ member: existMember
3819
+ });
3820
+ unmatchedExistByFp.set(existMember.urlFingerprint, bucket);
3821
+ }
3822
+ for (const [fp, genBucket] of unmatchedGenByFp) {
3823
+ if (genBucket.length !== 1) continue;
3824
+ const existBucket = unmatchedExistByFp.get(fp);
3825
+ if (!existBucket || existBucket.length !== 1) continue;
3826
+ const genMember = genBucket[0].member;
3827
+ const existEntry = existBucket[0];
3828
+ const existMember = existEntry.member;
3829
+ if (!genMember.docstring) continue;
3830
+ if (existMember.docstring) {
3831
+ if (existMember.docstring.text.includes("@oagen-ignore")) continue;
3832
+ if (existMember.docstring.text !== genMember.docstring.text) {
3833
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
3454
3834
  edits.push({
3455
- start: lineStart,
3456
- end: lineStart,
3457
- newText: indent + genMember.docstring.text + "\n"
3835
+ start: existMember.docstring.startIndex,
3836
+ end: existMember.docstring.endIndex,
3837
+ newText
3458
3838
  });
3459
3839
  docstringUpdates++;
3460
3840
  }
3461
- matchedExistMembers.add(existName);
3462
- break;
3841
+ } else {
3842
+ const lineStart = existMember.declStartIndex - existMember.declColumn;
3843
+ const indent = " ".repeat(existMember.declColumn);
3844
+ edits.push({
3845
+ start: lineStart,
3846
+ end: lineStart,
3847
+ newText: indent + genMember.docstring.text + "\n"
3848
+ });
3849
+ docstringUpdates++;
3463
3850
  }
3851
+ matchedExistMembers.add(existEntry.name);
3464
3852
  }
3465
3853
  }
3466
3854
  if (edits.length > 0) {
@@ -3546,15 +3934,18 @@ async function writeFiles(files, outputDir, options) {
3546
3934
  }
3547
3935
  const adapter = language ? getMergeAdapter(language) : void 0;
3548
3936
  if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
3549
- result.skipped.push(file.path);
3550
- continue;
3937
+ if (!(header && existingContent.startsWith(header))) {
3938
+ result.skipped.push(file.path);
3939
+ continue;
3940
+ }
3551
3941
  }
3552
3942
  if (existingContent === file.content) {
3553
3943
  result.identical.push(file.path);
3554
3944
  continue;
3555
3945
  }
3556
3946
  if (file.overwriteExisting) {
3557
- await fs.writeFile(fullPath, file.content, "utf-8");
3947
+ const content = existingContent.includes("@oagen-ignore-start") && language && hasGrammar(language) ? await overwriteWithPreservedRegions(existingContent, file.content, language) : file.content;
3948
+ await fs.writeFile(fullPath, content, "utf-8");
3558
3949
  result.written.push(file.path);
3559
3950
  continue;
3560
3951
  }
@@ -3598,6 +3989,317 @@ async function writeFiles(files, outputDir, options) {
3598
3989
  function isDefaultTestFile(filePath) {
3599
3990
  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
3991
  }
3992
+ /**
3993
+ * Overwrite a file with generated content while preserving
3994
+ * `@oagen-ignore-start` / `@oagen-ignore-end` regions (and their
3995
+ * required imports) from the existing file.
3996
+ *
3997
+ * Uses tree-sitter via `extractClassEndLines` to find accurate class
3998
+ * boundaries so ignore blocks are inserted at the correct position even
3999
+ * when docstrings contain unindented text.
4000
+ */
4001
+ async function overwriteWithPreservedRegions(existingContent, generatedContent, language) {
4002
+ const existingLines = existingContent.split("\n");
4003
+ const generatedLines = generatedContent.split("\n");
4004
+ const blocks = [];
4005
+ for (let i = 0; i < existingLines.length; i++) {
4006
+ if (!existingLines[i].includes("@oagen-ignore-start")) continue;
4007
+ const startIdx = i;
4008
+ while (i < existingLines.length && !existingLines[i].includes("@oagen-ignore-end")) i++;
4009
+ blocks.push({
4010
+ containingClass: findContainingClass(existingLines, startIdx),
4011
+ lines: existingLines.slice(startIdx, i + 1)
4012
+ });
4013
+ }
4014
+ if (blocks.length === 0) return generatedContent;
4015
+ const result = [...generatedLines];
4016
+ spliceExtraImports(existingLines, result);
4017
+ const classEndLines = await extractClassEndLines(result.join("\n"), language);
4018
+ const blocksByClass = /* @__PURE__ */ new Map();
4019
+ for (const b of blocks) {
4020
+ if (!blocksByClass.has(b.containingClass)) blocksByClass.set(b.containingClass, []);
4021
+ blocksByClass.get(b.containingClass).push(b.lines);
4022
+ }
4023
+ const replacedClasses = /* @__PURE__ */ new Set();
4024
+ const handledBlockKeys = /* @__PURE__ */ new Set();
4025
+ const replacements = [];
4026
+ for (const [className, classBlocks] of blocksByClass) {
4027
+ let targetClass = className;
4028
+ if (!targetClass) for (const blockLines of classBlocks) {
4029
+ for (const l of blockLines) {
4030
+ const m = l.match(/^(?:export\s+)?class\s+(\w+)/);
4031
+ if (m && classEndLines.has(m[1])) {
4032
+ targetClass = m[1];
4033
+ break;
4034
+ }
4035
+ }
4036
+ if (targetClass) break;
4037
+ }
4038
+ if (!targetClass) continue;
4039
+ if (!classBlocks.some((blockLines) => blockLines.some((l) => {
4040
+ return l.match(/^(?:export\s+)?class\s+(\w+)/)?.[1] === targetClass;
4041
+ }))) continue;
4042
+ const info = classEndLines.get(targetClass);
4043
+ if (!info) continue;
4044
+ const startLine = findClassDeclarationStart(result, targetClass, info.bodyEndLine);
4045
+ if (startLine < 0) continue;
4046
+ const content = [];
4047
+ for (const block of classBlocks) {
4048
+ content.push("");
4049
+ content.push(...block);
4050
+ }
4051
+ replacements.push({
4052
+ startLine,
4053
+ endLine: info.bodyEndLine,
4054
+ content
4055
+ });
4056
+ replacedClasses.add(targetClass);
4057
+ handledBlockKeys.add(className);
4058
+ }
4059
+ replacements.sort((a, b) => b.startLine - a.startLine);
4060
+ for (const rep of replacements) result.splice(rep.startLine, rep.endLine - rep.startLine, ...rep.content);
4061
+ let effectiveClassEndLines = classEndLines;
4062
+ if (replacedClasses.size > 0) effectiveClassEndLines = await extractClassEndLines(result.join("\n"), language);
4063
+ const insertions = [];
4064
+ for (const [className, classBlocks] of blocksByClass) {
4065
+ if (handledBlockKeys.has(className) || replacedClasses.has(className)) continue;
4066
+ const info = className ? effectiveClassEndLines.get(className) : void 0;
4067
+ const insertLine = info ? info.bodyEndLine : result.length;
4068
+ const content = [];
4069
+ for (const block of classBlocks) {
4070
+ content.push("");
4071
+ content.push(...block);
4072
+ }
4073
+ insertions.push({
4074
+ line: insertLine,
4075
+ content
4076
+ });
4077
+ }
4078
+ insertions.sort((a, b) => b.line - a.line);
4079
+ for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
4080
+ return result.join("\n");
4081
+ }
4082
+ /** Scan backwards from `lineIdx` to find the nearest class declaration name. */
4083
+ function findContainingClass(lines, lineIdx) {
4084
+ for (let j = lineIdx - 1; j >= 0; j--) {
4085
+ const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
4086
+ if (m) return m[1];
4087
+ }
4088
+ return "";
4089
+ }
4090
+ /**
4091
+ * Find the start line of a class declaration (including decorators above it)
4092
+ * by scanning backwards from `beforeLine`.
4093
+ */
4094
+ function findClassDeclarationStart(lines, className, beforeLine) {
4095
+ const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
4096
+ for (let i = beforeLine - 1; i >= 0; i--) {
4097
+ if (!classPattern.test(lines[i])) continue;
4098
+ let start = i;
4099
+ while (start > 0 && /^\s*@\w+/.test(lines[start - 1])) start--;
4100
+ return start;
4101
+ }
4102
+ return -1;
4103
+ }
4104
+ /**
4105
+ * Collect `from X import Y, Z` identifiers grouped by module, handling
4106
+ * both single-line and multiline (parenthesized) forms.
4107
+ */
4108
+ function collectFromImports(lines) {
4109
+ const byModule = /* @__PURE__ */ new Map();
4110
+ for (let i = 0; i < lines.length; i++) {
4111
+ if (!/^from\s+/.test(lines[i])) continue;
4112
+ let fullText = lines[i];
4113
+ if (fullText.includes("(") && !fullText.includes(")")) while (i + 1 < lines.length) {
4114
+ i++;
4115
+ fullText += " " + lines[i].trim();
4116
+ if (lines[i].includes(")")) break;
4117
+ }
4118
+ const match = fullText.replace(/[()]/g, "").replace(/\s+/g, " ").trim().match(/^from\s+(\S+)\s+import\s+(.+)$/);
4119
+ if (!match) continue;
4120
+ const mod = match[1];
4121
+ if (!byModule.has(mod)) byModule.set(mod, /* @__PURE__ */ new Set());
4122
+ for (const id of match[2].split(",").map((s) => s.trim()).filter(Boolean)) byModule.get(mod).add(id);
4123
+ }
4124
+ return byModule;
4125
+ }
4126
+ /**
4127
+ * Add imports from the existing file that are absent from the generated
4128
+ * result. Handles both `import X` and `from X import Y` forms.
4129
+ * Mutates `resultLines` in place.
4130
+ */
4131
+ function spliceExtraImports(existingLines, resultLines) {
4132
+ const isSimpleImport = (l) => /^import\s+\w/.test(l);
4133
+ const existingSimple = new Set(existingLines.filter(isSimpleImport).map((l) => l.trim()));
4134
+ const generatedSimple = new Set(resultLines.filter(isSimpleImport).map((l) => l.trim()));
4135
+ const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
4136
+ const existingFrom = collectFromImports(existingLines);
4137
+ const generatedFrom = collectFromImports(resultLines);
4138
+ const allGeneratedIds = /* @__PURE__ */ new Set();
4139
+ for (const ids of generatedFrom.values()) for (const id of ids) allGeneratedIds.add(id);
4140
+ const extraFromLines = [];
4141
+ for (const [mod, existIds] of existingFrom) {
4142
+ const genIds = generatedFrom.get(mod);
4143
+ if (!genIds) {
4144
+ const extra = [...existIds].filter((id) => !allGeneratedIds.has(id));
4145
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
4146
+ } else {
4147
+ const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id));
4148
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
4149
+ }
4150
+ }
4151
+ const allExtra = [...extraSimple, ...extraFromLines];
4152
+ if (allExtra.length === 0) return;
4153
+ let lastImportIdx = -1;
4154
+ for (let i = 0; i < resultLines.length; i++) if (isSimpleImport(resultLines[i]) || /^from\s+/.test(resultLines[i])) {
4155
+ lastImportIdx = i;
4156
+ if (resultLines[i].includes("(") && !resultLines[i].includes(")")) while (i + 1 < resultLines.length) {
4157
+ i++;
4158
+ lastImportIdx = i;
4159
+ if (resultLines[i].includes(")")) break;
4160
+ }
4161
+ }
4162
+ if (lastImportIdx >= 0) resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
4163
+ }
4164
+ //#endregion
4165
+ //#region src/ir/operation-hints.ts
4166
+ /** Minimal snake_case converter for path segments (avoids cross-layer import). */
4167
+ function toSnake(s) {
4168
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s.]+/g, "_").toLowerCase();
4169
+ }
4170
+ /**
4171
+ * Action verbs recognised at the terminal path segment.
4172
+ * When a path ends with one of these, we use it as the verb instead of
4173
+ * deriving a CRUD verb from the HTTP method.
4174
+ */
4175
+ const ACTION_VERBS = new Set([
4176
+ "accept",
4177
+ "activate",
4178
+ "authorize",
4179
+ "challenge",
4180
+ "check",
4181
+ "complete",
4182
+ "confirm",
4183
+ "deactivate",
4184
+ "disable",
4185
+ "enable",
4186
+ "enroll",
4187
+ "generate",
4188
+ "reactivate",
4189
+ "resend",
4190
+ "revoke",
4191
+ "send",
4192
+ "verify"
4193
+ ]);
4194
+ /** Map HTTP method → default CRUD verb. */
4195
+ const CRUD_VERB = {
4196
+ get: "list",
4197
+ post: "create",
4198
+ put: "update",
4199
+ patch: "update",
4200
+ delete: "delete",
4201
+ head: "get",
4202
+ options: "list",
4203
+ trace: "list"
4204
+ };
4205
+ /**
4206
+ * Naive singularization: strips trailing "s" with common-sense exceptions.
4207
+ * Good enough for API resource nouns; not a general-purpose English stemmer.
4208
+ */
4209
+ function singularize(word) {
4210
+ if (word.length <= 2) return word;
4211
+ if (word.endsWith("ies")) return word.slice(0, -3) + "y";
4212
+ if (word.endsWith("ses") || word.endsWith("zes") || word.endsWith("xes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
4213
+ if (word.endsWith("ss") || word.endsWith("us") || word.endsWith("is")) return word;
4214
+ if (word.endsWith("s")) return word.slice(0, -1);
4215
+ return word;
4216
+ }
4217
+ /**
4218
+ * Derive a snake_case method name from an operation's HTTP method and path.
4219
+ *
4220
+ * Rules:
4221
+ * 1. Strip path-param segments (`{id}`, `{slug}`, etc.)
4222
+ * 2. If the terminal segment is an action verb, use it as the verb
4223
+ * 3. Otherwise, use the CRUD verb for the HTTP method
4224
+ * 4. For GET on a collection (no trailing {id}), use "list"; with {id}, use "get"
4225
+ * 5. For POST on a resource path, if only one path param, use "create" singular
4226
+ * 6. Singularize the resource noun for single-resource operations
4227
+ */
4228
+ function deriveMethodName(op, _service) {
4229
+ const segments = op.path.split("/").filter(Boolean);
4230
+ const staticSegments = [];
4231
+ let trailingParam = false;
4232
+ for (const seg of segments) if (seg.startsWith("{") && seg.endsWith("}")) trailingParam = true;
4233
+ else {
4234
+ staticSegments.push(seg);
4235
+ trailingParam = false;
4236
+ }
4237
+ if (staticSegments.length === 0) return `${CRUD_VERB[op.httpMethod] ?? "get"}_root`;
4238
+ const terminal = toSnake(staticSegments[staticSegments.length - 1]);
4239
+ if (ACTION_VERBS.has(terminal)) {
4240
+ const context = staticSegments.slice(0, -1).filter((s) => !s.startsWith("{"));
4241
+ const resource = context.length > 0 ? toSnake(singularize(context[context.length - 1])) : "";
4242
+ if (resource) return `${terminal}_${resource}`;
4243
+ return terminal;
4244
+ }
4245
+ let verb = CRUD_VERB[op.httpMethod] ?? "get";
4246
+ if (op.httpMethod === "get" && trailingParam) verb = "get";
4247
+ let resource = toSnake(terminal);
4248
+ if (staticSegments.length >= 2) {
4249
+ const penultimate = staticSegments[staticSegments.length - 2];
4250
+ const penIdx = segments.lastIndexOf(penultimate);
4251
+ const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
4252
+ if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
4253
+ }
4254
+ const noun = trailingParam ? singularize(resource) : resource;
4255
+ return `${verb}_${verb === "list" ? resource : noun}`;
4256
+ }
4257
+ /**
4258
+ * Resolve all operations in the spec, applying hints and mount rules.
4259
+ *
4260
+ * @param spec - The parsed API spec
4261
+ * @param hints - Per-operation overrides keyed by "METHOD /path"
4262
+ * @param mountRules - Service name → target service mappings
4263
+ * @returns Resolved operations ready for emitter consumption
4264
+ */
4265
+ function resolveOperations(spec, hints, mountRules) {
4266
+ const resolved = [];
4267
+ const hintMap = hints ?? {};
4268
+ const mounts = mountRules ?? {};
4269
+ for (const service of spec.services) for (const op of service.operations) {
4270
+ const hint = hintMap[`${op.httpMethod.toUpperCase()} ${op.path}`];
4271
+ const methodName = hint?.name ?? deriveMethodName(op, service);
4272
+ const mountOn = hint?.mountOn ?? mounts[service.name] ?? service.name;
4273
+ let wrappers;
4274
+ if (hint?.split) wrappers = hint.split.map((sh) => ({
4275
+ name: sh.name,
4276
+ targetVariant: sh.targetVariant,
4277
+ defaults: sh.defaults ?? {},
4278
+ inferFromClient: sh.inferFromClient ?? [],
4279
+ exposedParams: sh.exposedParams ?? [],
4280
+ optionalParams: [],
4281
+ responseModelName: resolveResponseModelName(op)
4282
+ }));
4283
+ resolved.push({
4284
+ operation: op,
4285
+ service,
4286
+ methodName,
4287
+ mountOn,
4288
+ wrappers,
4289
+ defaults: hint?.defaults ?? {},
4290
+ inferFromClient: hint?.inferFromClient ?? []
4291
+ });
4292
+ }
4293
+ return resolved;
4294
+ }
4295
+ /** Extract the response model name from an operation (for wrapper metadata). */
4296
+ function resolveResponseModelName(op) {
4297
+ if (op.httpMethod === "delete") return null;
4298
+ const ref = op.response;
4299
+ if (ref.kind === "model") return ref.name;
4300
+ if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
4301
+ return null;
4302
+ }
3601
4303
  //#endregion
3602
4304
  //#region src/engine/generate-files.ts
3603
4305
  function buildEmitterContext(spec, options) {
@@ -3607,7 +4309,8 @@ function buildEmitterContext(spec, options) {
3607
4309
  spec,
3608
4310
  outputDir: options.outputDir,
3609
4311
  apiSurface: options.apiSurface,
3610
- overlayLookup: options.overlayLookup
4312
+ overlayLookup: options.overlayLookup,
4313
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
3611
4314
  };
3612
4315
  }
3613
4316
  /**
@@ -3637,6 +4340,7 @@ function collectReferencedNames(services, models) {
3637
4340
  for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
3638
4341
  if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
3639
4342
  }
4343
+ for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
3640
4344
  const modelsByName = new Map(models.map((m) => [m.name, m]));
3641
4345
  const visited = /* @__PURE__ */ new Set();
3642
4346
  const queue = [...referencedModels];
@@ -3656,6 +4360,9 @@ function collectReferencedNames(services, models) {
3656
4360
  enums: referencedEnums
3657
4361
  };
3658
4362
  }
4363
+ function isDiscriminatedModel(model) {
4364
+ return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
4365
+ }
3659
4366
  /** Collect all generated files from an emitter (no headers, no path prefixes). */
3660
4367
  function generateAllFiles(spec, emitter, ctx) {
3661
4368
  const referenced = collectReferencedNames(spec.services, spec.models);
@@ -3672,7 +4379,6 @@ function generateAllFiles(spec, emitter, ctx) {
3672
4379
  ...emitter.generateResources(spec.services, ctx),
3673
4380
  ...emitter.generateClient(spec, ctx),
3674
4381
  ...emitter.generateErrors(ctx),
3675
- ...emitter.generateConfig(ctx),
3676
4382
  ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
3677
4383
  ...emitter.generateTests(reachableSpec, ctx),
3678
4384
  ...emitter.generateManifest?.(spec, ctx) ?? []
@@ -3756,11 +4462,33 @@ async function treeShakeFiles(files, language) {
3756
4462
  }
3757
4463
  deps.set(file.path, fileDeps);
3758
4464
  }
4465
+ const codeExtensions = new Set([
4466
+ ".ts",
4467
+ ".js",
4468
+ ".tsx",
4469
+ ".jsx",
4470
+ ".py",
4471
+ ".rb",
4472
+ ".go",
4473
+ ".rs",
4474
+ ".kt",
4475
+ ".cs",
4476
+ ".ex",
4477
+ ".exs",
4478
+ ".php"
4479
+ ]);
3759
4480
  const reachable = /* @__PURE__ */ new Set();
3760
4481
  const queue = [];
3761
- for (const file of files) if (isRootFile(file.path)) {
3762
- reachable.add(file.path);
3763
- queue.push(file.path);
4482
+ for (const file of files) {
4483
+ const ext = file.path.slice(file.path.lastIndexOf("."));
4484
+ if (!codeExtensions.has(ext)) {
4485
+ reachable.add(file.path);
4486
+ continue;
4487
+ }
4488
+ if (isRootFile(file.path)) {
4489
+ reachable.add(file.path);
4490
+ queue.push(file.path);
4491
+ }
3764
4492
  }
3765
4493
  while (queue.length > 0) {
3766
4494
  const current = queue.pop();
@@ -3784,11 +4512,61 @@ function mapFilesForTargetIntegration(files, language) {
3784
4512
  };
3785
4513
  });
3786
4514
  }
4515
+ /**
4516
+ * Resolve file-to-directory conflicts in the target.
4517
+ *
4518
+ * When a generated file creates a package directory (e.g., `api_keys/__init__.py`)
4519
+ * that would shadow an existing module file (e.g., `api_keys.py`), move the
4520
+ * module file's content into `_compat.py` inside the package and add a
4521
+ * re-export from `__init__.py`. This preserves backward-compatible imports
4522
+ * (e.g., `from workos.api_keys import ApiKeysModule`) while the package directory
4523
+ * provides the generated models and resource classes.
4524
+ */
4525
+ async function resolveFileToDirectoryConflicts(files, targetDir) {
4526
+ const result = [...files];
4527
+ const removals = [];
4528
+ for (let i = 0; i < result.length; i++) {
4529
+ const file = result[i];
4530
+ const match = file.path.match(/^(.+)\/__init__\.py$/);
4531
+ if (!match) continue;
4532
+ const dirPath = match[1];
4533
+ const moduleFilePath = `${dirPath}.py`;
4534
+ const fullModulePath = path.join(targetDir, moduleFilePath);
4535
+ let moduleContent;
4536
+ try {
4537
+ moduleContent = await fs.readFile(fullModulePath, "utf-8");
4538
+ } catch {
4539
+ continue;
4540
+ }
4541
+ const compatPath = `${dirPath}/_compat.py`;
4542
+ result.push({
4543
+ path: compatPath,
4544
+ content: moduleContent,
4545
+ overwriteExisting: true
4546
+ });
4547
+ const compatImport = "from ._compat import * # noqa: F401,F403 # pyright: ignore[reportUnusedImport]";
4548
+ const existingContent = file.content.endsWith("\n") ? file.content : file.content + "\n";
4549
+ result[i] = {
4550
+ ...file,
4551
+ content: existingContent + compatImport + "\n"
4552
+ };
4553
+ removals.push(fullModulePath);
4554
+ }
4555
+ return {
4556
+ files: result,
4557
+ removals
4558
+ };
4559
+ }
3787
4560
  async function integrateGeneratedFiles(opts) {
3788
- return writeFiles(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir, {
4561
+ const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
4562
+ const result = await writeFiles(resolved, opts.targetDir, {
3789
4563
  language: opts.language,
3790
4564
  header: opts.header
3791
4565
  });
4566
+ for (const filePath of removals) try {
4567
+ await fs.unlink(filePath);
4568
+ } catch {}
4569
+ return result;
3792
4570
  }
3793
4571
  //#endregion
3794
4572
  //#region src/engine/formatter.ts
@@ -3849,7 +4627,11 @@ async function generate(spec, emitter, options) {
3849
4627
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
3850
4628
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
3851
4629
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
3852
- const allTargetFiles = [...targetResult.written, ...targetResult.merged];
4630
+ const allTargetFiles = [
4631
+ ...targetResult.written,
4632
+ ...targetResult.merged,
4633
+ ...targetResult.identical
4634
+ ];
3853
4635
  if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
3854
4636
  }
3855
4637
  return withHeaders;
@@ -4510,7 +5292,10 @@ function expandDocUrls(spec, docUrl) {
4510
5292
  //#endregion
4511
5293
  //#region src/cli/generate.ts
4512
5294
  async function generateCommand(opts) {
4513
- let ir = await parseSpec(opts.spec, { operationIdTransform: opts.operationIdTransform });
5295
+ let ir = await parseSpec(opts.spec, {
5296
+ operationIdTransform: opts.operationIdTransform,
5297
+ schemaNameTransform: opts.schemaNameTransform
5298
+ });
4514
5299
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
4515
5300
  const emitter = getEmitter(opts.lang);
4516
5301
  const namespace = opts.namespace ?? ir.name;
@@ -4533,7 +5318,9 @@ async function generateCommand(opts) {
4533
5318
  outputDir: opts.output,
4534
5319
  target: opts.target,
4535
5320
  apiSurface,
4536
- overlayLookup
5321
+ overlayLookup,
5322
+ operationHints: opts.operationHints,
5323
+ mountRules: opts.mountRules
4537
5324
  });
4538
5325
  if (opts.dryRun) for (const f of files) console.log(f.path);
4539
5326
  else {
@@ -4890,7 +5677,10 @@ function summarize(changes) {
4890
5677
  //#endregion
4891
5678
  //#region src/cli/diff.ts
4892
5679
  async function diffCommand(opts) {
4893
- const parseOptions = { operationIdTransform: opts.operationIdTransform };
5680
+ const parseOptions = {
5681
+ operationIdTransform: opts.operationIdTransform,
5682
+ schemaNameTransform: opts.schemaNameTransform
5683
+ };
4894
5684
  let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
4895
5685
  if (opts.docUrl) {
4896
5686
  oldSpec = expandDocUrls(oldSpec, opts.docUrl);
@@ -5685,7 +6475,7 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
5685
6475
  const __filename = fileURLToPath(import.meta.url);
5686
6476
  const __dirname = path$1.dirname(__filename);
5687
6477
  function resolveScript(scriptRelPath) {
5688
- const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
6478
+ const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".mjs"));
5689
6479
  if (existsSync(distScript)) return {
5690
6480
  bin: "node",
5691
6481
  script: distScript
@@ -5804,7 +6594,7 @@ function printCompatResult(result, baseline) {
5804
6594
  console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
5805
6595
  }
5806
6596
  async function verifyCommand(opts) {
5807
- const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform } = opts;
6597
+ const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
5808
6598
  const maxRetries = opts.maxRetries ?? 3;
5809
6599
  const diagData = {};
5810
6600
  let stepNum = 1;
@@ -5815,7 +6605,10 @@ async function verifyCommand(opts) {
5815
6605
  console.log(separator);
5816
6606
  const effectiveScope = scope ?? (spec ? "spec-only" : "full");
5817
6607
  let parsedSpec;
5818
- if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, { operationIdTransform });
6608
+ if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
6609
+ operationIdTransform,
6610
+ schemaNameTransform
6611
+ });
5819
6612
  else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
5820
6613
  const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
5821
6614
  baseline,
@@ -5858,7 +6651,13 @@ async function verifyCommand(opts) {
5858
6651
  console.log(`\n${separator}`);
5859
6652
  console.log(`Step ${stepNum}: Staleness detection`);
5860
6653
  console.log(separator);
5861
- const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, { operationIdTransform }), await parseSpec(spec, { operationIdTransform }), lang);
6654
+ const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, {
6655
+ operationIdTransform,
6656
+ schemaNameTransform
6657
+ }), await parseSpec(spec, {
6658
+ operationIdTransform,
6659
+ schemaNameTransform
6660
+ }), lang);
5862
6661
  if (stalenessResult.violations.length > 0) {
5863
6662
  console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
5864
6663
  for (const v of stalenessResult.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
@@ -6037,10 +6836,6 @@ export const ${`${toCamelCase(lang)}Emitter`}: Emitter = {
6037
6836
  return [];
6038
6837
  },
6039
6838
 
6040
- generateConfig(_ctx: EmitterContext): GeneratedFile[] {
6041
- return [];
6042
- },
6043
-
6044
6839
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
6045
6840
  return [];
6046
6841
  },
@@ -6135,6 +6930,50 @@ async function initCommand(opts) {
6135
6930
  console.log(" - npm run sdk:verify -- --spec <path-to-spec>");
6136
6931
  }
6137
6932
  //#endregion
6933
+ //#region src/cli/resolve.ts
6934
+ async function resolveCommand(opts) {
6935
+ let ir = await parseSpec(opts.spec, {
6936
+ operationIdTransform: opts.operationIdTransform,
6937
+ schemaNameTransform: opts.schemaNameTransform
6938
+ });
6939
+ if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
6940
+ const resolved = resolveOperations(ir, opts.operationHints, opts.mountRules);
6941
+ if ((opts.format ?? "table") === "json") {
6942
+ const output = resolved.map(toJsonRow);
6943
+ console.log(JSON.stringify(output, null, 2));
6944
+ return;
6945
+ }
6946
+ printTable(resolved, opts.operationHints);
6947
+ }
6948
+ function toJsonRow(r) {
6949
+ return {
6950
+ service: r.service.name,
6951
+ method: r.operation.httpMethod.toUpperCase(),
6952
+ path: r.operation.path,
6953
+ derivedName: r.methodName,
6954
+ hintApplied: r.methodName !== r.operation.name,
6955
+ mountOn: r.mountOn,
6956
+ wrappers: r.wrappers?.map((w) => w.name)
6957
+ };
6958
+ }
6959
+ function printTable(resolved, hints) {
6960
+ const hintMap = hints ?? {};
6961
+ console.log("| Service | Method | Path | Resolved Name | Hint | Mount On |");
6962
+ console.log("|---|---|---|---|---|---|");
6963
+ const unhinted = [];
6964
+ for (const r of resolved) {
6965
+ const hasHint = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}` in hintMap;
6966
+ const hintMarker = hasHint ? "yes" : "";
6967
+ const mountChanged = r.mountOn !== r.service.name;
6968
+ const mountCol = mountChanged ? r.mountOn : "";
6969
+ console.log(`| ${r.service.name} | ${r.operation.httpMethod.toUpperCase()} | ${r.operation.path} | \`${r.methodName}\` | ${hintMarker} | ${mountCol} |`);
6970
+ if (r.wrappers) for (const w of r.wrappers) console.log(`| | | | \`${w.name}\` (wrapper) | yes | ${mountCol} |`);
6971
+ if (!hasHint && !mountChanged) unhinted.push(r);
6972
+ }
6973
+ console.log("");
6974
+ console.log(`Total: ${resolved.length} operations, ${resolved.length - unhinted.length} with hints/mounts, ${unhinted.length} algorithm-only`);
6975
+ }
6976
+ //#endregion
6138
6977
  //#region src/cli/config-loader.ts
6139
6978
  const CONFIG_NAMES = [
6140
6979
  "oagen.config.ts",
@@ -6170,14 +7009,20 @@ function handleError(err) {
6170
7009
  }
6171
7010
  let configSmokeRunners;
6172
7011
  let configOperationIdTransform;
7012
+ let configSchemaNameTransform;
6173
7013
  let configDocUrl;
7014
+ let configOperationHints;
7015
+ let configMountRules;
6174
7016
  try {
6175
7017
  const config = await loadConfig();
6176
7018
  if (config) {
6177
7019
  applyConfig(config);
6178
7020
  configSmokeRunners = config.smokeRunners;
6179
7021
  configOperationIdTransform = config.operationIdTransform;
7022
+ configSchemaNameTransform = config.schemaNameTransform;
6180
7023
  configDocUrl = config.docUrl;
7024
+ configOperationHints = config.operationHints;
7025
+ configMountRules = config.mountRules;
6181
7026
  }
6182
7027
  } catch (err) {
6183
7028
  handleError(err);
@@ -6200,13 +7045,17 @@ program.command("generate").description("Run a registered emitter against an Ope
6200
7045
  generateCommand({
6201
7046
  ...opts,
6202
7047
  operationIdTransform: configOperationIdTransform,
6203
- docUrl: configDocUrl
7048
+ schemaNameTransform: configSchemaNameTransform,
7049
+ docUrl: configDocUrl,
7050
+ operationHints: configOperationHints,
7051
+ mountRules: configMountRules
6204
7052
  }).catch(handleError);
6205
7053
  });
6206
7054
  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
7055
  diffCommand({
6208
7056
  ...opts,
6209
7057
  operationIdTransform: configOperationIdTransform,
7058
+ schemaNameTransform: configSchemaNameTransform,
6210
7059
  docUrl: configDocUrl
6211
7060
  }).catch(handleError);
6212
7061
  });
@@ -6220,7 +7069,23 @@ program.command("verify").description("Run smoke tests (and optional compat chec
6220
7069
  verifyCommand({
6221
7070
  ...opts,
6222
7071
  maxRetries: parseInt(opts.maxRetries, 10),
6223
- operationIdTransform: configOperationIdTransform
7072
+ operationIdTransform: configOperationIdTransform,
7073
+ schemaNameTransform: configSchemaNameTransform
7074
+ }).catch(handleError);
7075
+ });
7076
+ 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) => {
7077
+ opts.spec ??= process.env.OPENAPI_SPEC_PATH;
7078
+ if (!opts.spec) {
7079
+ console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
7080
+ process.exit(1);
7081
+ }
7082
+ resolveCommand({
7083
+ ...opts,
7084
+ operationIdTransform: configOperationIdTransform,
7085
+ schemaNameTransform: configSchemaNameTransform,
7086
+ docUrl: configDocUrl,
7087
+ operationHints: configOperationHints,
7088
+ mountRules: configMountRules
6224
7089
  }).catch(handleError);
6225
7090
  });
6226
7091
  program.command("init").description("Scaffold a new emitter project").requiredOption("--lang <language>", "Target language").option("--project <dir>", "Project directory", ".").action((opts) => {