@workos/oagen 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,13 +3,14 @@ import "dotenv/config";
3
3
  import { Command } from "commander";
4
4
  import * as fs from "node:fs/promises";
5
5
  import { readFile } from "node:fs/promises";
6
- import * as path from "node:path";
7
- import { relative, resolve } from "node:path";
6
+ import * as path$1 from "node:path";
7
+ import path, { relative, resolve } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { BaseResolver, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
10
10
  import Parser from "tree-sitter";
11
+ import { execFile, execFileSync } from "node:child_process";
12
+ import { promisify } from "node:util";
11
13
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
- import { execFileSync } from "node:child_process";
13
14
  //#region src/errors.ts
14
15
  /**
15
16
  * Structured error hierarchy for oagen.
@@ -143,15 +144,49 @@ function walkTypeRef(ref, visitor) {
143
144
  //#endregion
144
145
  //#region src/utils/naming.ts
145
146
  /**
147
+ * Known compound tokens that the regex-based splitter over-splits.
148
+ * Each entry is [lowercase-word-sequence, canonical-form].
149
+ * Sorted longest-first so greedy matching works correctly.
150
+ */
151
+ const COMPOUND_WORDS = [[[
152
+ "m",
153
+ "2",
154
+ "m"
155
+ ], "M2M"], [["o", "auth"], "OAuth"]];
156
+ /**
157
+ * Recombine adjacent words that form a known compound token.
158
+ */
159
+ function recombineCompounds(words) {
160
+ const result = [];
161
+ let i = 0;
162
+ while (i < words.length) {
163
+ let matched = false;
164
+ for (const [pattern, canonical] of COMPOUND_WORDS) if (i + pattern.length <= words.length) {
165
+ if (pattern.every((p, j) => words[i + j].toLowerCase() === p)) {
166
+ result.push(canonical);
167
+ i += pattern.length;
168
+ matched = true;
169
+ break;
170
+ }
171
+ }
172
+ if (!matched) {
173
+ result.push(words[i]);
174
+ i++;
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+ /**
146
180
  * Split a string into words, handling:
147
181
  * - camelCase / PascalCase boundaries
148
182
  * - snake_case / kebab-case separators
149
183
  * - Consecutive capitals (e.g., "HTTPClient" → ["HTTP", "Client"])
150
184
  * - Numbers as word boundaries (e.g., "OAuth2Token" → ["OAuth", "2", "Token"])
185
+ * - Known compounds are recombined (e.g., "M2M" stays as one word)
151
186
  */
152
187
  function splitWords(s) {
153
188
  if (!s) return [];
154
- return s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0);
189
+ return recombineCompounds(s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0));
155
190
  }
156
191
  const ACRONYM_SET = new Set([
157
192
  "SSO",
@@ -159,7 +194,8 @@ const ACRONYM_SET = new Set([
159
194
  "SAML",
160
195
  "SCIM",
161
196
  "JWT",
162
- "HMAC"
197
+ "HMAC",
198
+ "M2M"
163
199
  ]);
164
200
  function toPascalCase(s, acronyms) {
165
201
  const merged = acronyms ? new Set([...ACRONYM_SET, ...acronyms]) : ACRONYM_SET;
@@ -3310,11 +3346,27 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3310
3346
  const addedParts = [...toAppend];
3311
3347
  if (insertions) for (const ins of insertions) addedParts.push(ins.text);
3312
3348
  const addedCodeText = addedParts.join("\n");
3313
- const filteredImports = newImports.filter((imp) => {
3349
+ const filteredImports = [];
3350
+ for (const imp of newImports) {
3314
3351
  const braceMatch = imp.text.match(/\{([^}]+)\}/);
3315
- if (!braceMatch) return true;
3316
- return braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean).some((name) => addedCodeText.includes(name));
3317
- });
3352
+ if (!braceMatch) {
3353
+ filteredImports.push(imp);
3354
+ continue;
3355
+ }
3356
+ const names = braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean);
3357
+ const usedNames = names.filter((name) => addedCodeText.includes(name));
3358
+ if (usedNames.length === 0) continue;
3359
+ if (usedNames.length === names.length) filteredImports.push(imp);
3360
+ else {
3361
+ const prefix = imp.text.trimStart().startsWith("import type") ? "import type" : "import";
3362
+ const sourceMatch = imp.text.match(/from\s+(['"][^'"]+['"]);?/);
3363
+ if (sourceMatch) filteredImports.push({
3364
+ key: imp.key,
3365
+ text: `${prefix} { ${usedNames.join(", ")} } from ${sourceMatch[1]};`
3366
+ });
3367
+ else filteredImports.push(imp);
3368
+ }
3369
+ }
3318
3370
  if (filteredImports.length > 0) {
3319
3371
  const renderedImports = adapter.renderImports ? adapter.renderImports(filteredImports) : filteredImports.map((entry) => entry.text);
3320
3372
  const lines = result.split("\n");
@@ -3472,13 +3524,13 @@ async function writeFiles(files, outputDir, options) {
3472
3524
  const language = options?.language;
3473
3525
  const header = options?.header ?? "";
3474
3526
  for (const file of sorted) {
3475
- const fullPath = path.join(outputDir, file.path);
3527
+ const fullPath = path$1.join(outputDir, file.path);
3476
3528
  let existingContent = null;
3477
3529
  try {
3478
3530
  existingContent = await fs.readFile(fullPath, "utf-8");
3479
3531
  } catch {}
3480
3532
  if (existingContent === null) {
3481
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
3533
+ await fs.mkdir(path$1.dirname(fullPath), { recursive: true });
3482
3534
  await fs.writeFile(fullPath, file.content, "utf-8");
3483
3535
  result.written.push(file.path);
3484
3536
  continue;
@@ -3558,17 +3610,71 @@ function buildEmitterContext(spec, options) {
3558
3610
  overlayLookup: options.overlayLookup
3559
3611
  };
3560
3612
  }
3613
+ /**
3614
+ * Collect model and enum names transitively referenced by service operations.
3615
+ * Walks operation parameters, request bodies, responses, and pagination item
3616
+ * types, then chases model field references until the set stabilizes.
3617
+ */
3618
+ function collectReferencedNames(services, models) {
3619
+ const referencedModels = /* @__PURE__ */ new Set();
3620
+ const referencedEnums = /* @__PURE__ */ new Set();
3621
+ const collectFromTypeRef = (ref) => {
3622
+ walkTypeRef(ref, {
3623
+ model: (r) => referencedModels.add(r.name),
3624
+ enum: (r) => referencedEnums.add(r.name)
3625
+ });
3626
+ };
3627
+ for (const service of services) for (const op of service.operations) {
3628
+ for (const p of [
3629
+ ...op.pathParams,
3630
+ ...op.queryParams,
3631
+ ...op.headerParams,
3632
+ ...op.cookieParams ?? []
3633
+ ]) collectFromTypeRef(p.type);
3634
+ if (op.requestBody) collectFromTypeRef(op.requestBody);
3635
+ collectFromTypeRef(op.response);
3636
+ if (op.pagination) collectFromTypeRef(op.pagination.itemType);
3637
+ for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
3638
+ if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
3639
+ }
3640
+ const modelsByName = new Map(models.map((m) => [m.name, m]));
3641
+ const visited = /* @__PURE__ */ new Set();
3642
+ const queue = [...referencedModels];
3643
+ while (queue.length > 0) {
3644
+ const name = queue.pop();
3645
+ if (visited.has(name)) continue;
3646
+ visited.add(name);
3647
+ const model = modelsByName.get(name);
3648
+ if (!model) continue;
3649
+ for (const field of model.fields) {
3650
+ collectFromTypeRef(field.type);
3651
+ for (const m of referencedModels) if (!visited.has(m)) queue.push(m);
3652
+ }
3653
+ }
3654
+ return {
3655
+ models: referencedModels,
3656
+ enums: referencedEnums
3657
+ };
3658
+ }
3561
3659
  /** Collect all generated files from an emitter (no headers, no path prefixes). */
3562
3660
  function generateAllFiles(spec, emitter, ctx) {
3661
+ const referenced = collectReferencedNames(spec.services, spec.models);
3662
+ const reachableModels = spec.models.filter((m) => referenced.models.has(m.name));
3663
+ const reachableEnums = spec.enums.filter((e) => referenced.enums.has(e.name));
3664
+ const reachableSpec = {
3665
+ ...spec,
3666
+ models: reachableModels,
3667
+ enums: reachableEnums
3668
+ };
3563
3669
  return [
3564
- ...emitter.generateModels(spec.models, ctx),
3565
- ...emitter.generateEnums(spec.enums, ctx),
3670
+ ...emitter.generateModels(reachableModels, ctx),
3671
+ ...emitter.generateEnums(reachableEnums, ctx),
3566
3672
  ...emitter.generateResources(spec.services, ctx),
3567
3673
  ...emitter.generateClient(spec, ctx),
3568
3674
  ...emitter.generateErrors(ctx),
3569
3675
  ...emitter.generateConfig(ctx),
3570
- ...emitter.generateTypeSignatures?.(spec, ctx) ?? [],
3571
- ...emitter.generateTests(spec, ctx),
3676
+ ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
3677
+ ...emitter.generateTests(reachableSpec, ctx),
3572
3678
  ...emitter.generateManifest?.(spec, ctx) ?? []
3573
3679
  ];
3574
3680
  }
@@ -3592,6 +3698,81 @@ function generateFiles(spec, emitter, options) {
3592
3698
  }
3593
3699
  //#endregion
3594
3700
  //#region src/engine/integrate.ts
3701
+ /**
3702
+ * Resolve a relative import path against a source file path to get the
3703
+ * target file path. Handles implicit .ts extensions and /index.ts.
3704
+ */
3705
+ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
3706
+ const cleaned = importModulePath.replace(/^['"]|['"]$/g, "");
3707
+ if (!cleaned.startsWith(".")) return void 0;
3708
+ const dir = path.dirname(fromFilePath);
3709
+ const resolved = path.normalize(path.join(dir, cleaned));
3710
+ for (const candidate of [
3711
+ resolved,
3712
+ `${resolved}.ts`,
3713
+ `${resolved}/index.ts`
3714
+ ]) if (knownPaths.has(candidate)) return candidate;
3715
+ }
3716
+ /**
3717
+ * Determine whether a generated file is a "root" — a file that should always
3718
+ * be integrated into the target. Roots are entry points from which we trace
3719
+ * the import graph. Everything else must be reachable from a root.
3720
+ *
3721
+ * Roots: resource classes, client, config, errors, enums, barrel index files.
3722
+ * Non-roots: serializers, fixtures, standalone interfaces, test files.
3723
+ */
3724
+ function isRootFile(filePath) {
3725
+ if (filePath.includes("/fixtures/")) return false;
3726
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
3727
+ if (filePath.includes("/serializers/")) return false;
3728
+ if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
3729
+ return true;
3730
+ }
3731
+ /**
3732
+ * Build a file-level dependency graph from generated files and return only
3733
+ * files reachable from root entry points. Uses tree-sitter to parse imports.
3734
+ */
3735
+ async function treeShakeFiles(files, language) {
3736
+ if (!hasGrammar(language)) return files;
3737
+ const knownPaths = new Set(files.map((f) => f.path));
3738
+ const deps = /* @__PURE__ */ new Map();
3739
+ for (const file of files) {
3740
+ if (!file.path.endsWith(".ts") && !file.path.endsWith(".js")) {
3741
+ deps.set(file.path, /* @__PURE__ */ new Set());
3742
+ continue;
3743
+ }
3744
+ const parsed = await extractStatements(file.content, language);
3745
+ const fileDeps = /* @__PURE__ */ new Set();
3746
+ for (const imp of parsed.imports) {
3747
+ const resolved = resolveImportPath(file.path, imp.key, knownPaths);
3748
+ if (resolved) fileDeps.add(resolved);
3749
+ }
3750
+ for (const stmt of parsed.statements) if (stmt.kind === "reexport") {
3751
+ const match = stmt.text.match(/from\s+['"]([^'"]+)['"]/);
3752
+ if (match) {
3753
+ const resolved = resolveImportPath(file.path, match[1], knownPaths);
3754
+ if (resolved) fileDeps.add(resolved);
3755
+ }
3756
+ }
3757
+ deps.set(file.path, fileDeps);
3758
+ }
3759
+ const reachable = /* @__PURE__ */ new Set();
3760
+ const queue = [];
3761
+ for (const file of files) if (isRootFile(file.path)) {
3762
+ reachable.add(file.path);
3763
+ queue.push(file.path);
3764
+ }
3765
+ while (queue.length > 0) {
3766
+ const current = queue.pop();
3767
+ const fileDeps = deps.get(current);
3768
+ if (!fileDeps) continue;
3769
+ for (const dep of fileDeps) if (!reachable.has(dep)) {
3770
+ reachable.add(dep);
3771
+ queue.push(dep);
3772
+ }
3773
+ }
3774
+ return files.filter((f) => reachable.has(f.path));
3775
+ }
3595
3776
  function mapFilesForTargetIntegration(files, language) {
3596
3777
  const langPrefix = `${language}/`;
3597
3778
  return files.filter((f) => f.integrateTarget !== false).map((f) => {
@@ -3604,12 +3785,44 @@ function mapFilesForTargetIntegration(files, language) {
3604
3785
  });
3605
3786
  }
3606
3787
  async function integrateGeneratedFiles(opts) {
3607
- return writeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.targetDir, {
3788
+ return writeFiles(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir, {
3608
3789
  language: opts.language,
3609
3790
  header: opts.header
3610
3791
  });
3611
3792
  }
3612
3793
  //#endregion
3794
+ //#region src/engine/formatter.ts
3795
+ /**
3796
+ * Post-integration formatter.
3797
+ *
3798
+ * After generating and merging files into a target directory, runs the
3799
+ * emitter-provided format command on all written/merged files so that
3800
+ * generated code matches the target project's style conventions.
3801
+ */
3802
+ const execFileAsync = promisify(execFile);
3803
+ /**
3804
+ * Run the emitter's format command on the given target files.
3805
+ * Silently skips if the emitter has no formatCommand or returns null.
3806
+ */
3807
+ async function formatTargetFiles(emitter, targetDir, filePaths) {
3808
+ if (filePaths.length === 0) return;
3809
+ if (!emitter.formatCommand) return;
3810
+ const formatCmd = emitter.formatCommand(targetDir);
3811
+ if (!formatCmd) return;
3812
+ const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
3813
+ const batchSize = formatCmd.batchSize ?? 100;
3814
+ for (let i = 0; i < absolutePaths.length; i += batchSize) {
3815
+ const batch = absolutePaths.slice(i, i + batchSize);
3816
+ try {
3817
+ await execFileAsync(formatCmd.cmd, [...formatCmd.args, ...batch], {
3818
+ cwd: targetDir,
3819
+ maxBuffer: 10 * 1024 * 1024,
3820
+ timeout: 12e4
3821
+ });
3822
+ } catch {}
3823
+ }
3824
+ }
3825
+ //#endregion
3613
3826
  //#region src/engine/orchestrator.ts
3614
3827
  async function generate(spec, emitter, options) {
3615
3828
  const { files: withHeaders, header } = generateFiles(spec, emitter, options);
@@ -3636,6 +3849,8 @@ async function generate(spec, emitter, options) {
3636
3849
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
3637
3850
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
3638
3851
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
3852
+ const allTargetFiles = [...targetResult.written, ...targetResult.merged];
3853
+ if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
3639
3854
  }
3640
3855
  return withHeaders;
3641
3856
  }
@@ -4204,7 +4419,7 @@ function loadOverlayContext(opts) {
4204
4419
  throw new ConfigError(`Failed to parse API surface JSON: ${err instanceof Error ? err.message : String(err)}`, `Ensure "${opts.apiSurfacePath}" contains valid JSON. Re-run \`oagen extract\` to regenerate it.`);
4205
4420
  }
4206
4421
  let manifest;
4207
- const resolvedManifestPath = opts.manifestPath ?? path.join(opts.outputDir, "smoke-manifest.json");
4422
+ const resolvedManifestPath = opts.manifestPath ?? path$1.join(opts.outputDir, "smoke-manifest.json");
4208
4423
  if (opts.manifestPath || existsSync(resolvedManifestPath)) try {
4209
4424
  const parsed = JSON.parse(readFileSync(resolvedManifestPath, "utf-8"));
4210
4425
  if (Array.isArray(parsed)) manifest = parsed;
@@ -5277,7 +5492,7 @@ function cleanGeneratedFiles(dir) {
5277
5492
  return;
5278
5493
  }
5279
5494
  for (const entry of entries) {
5280
- const full = path.join(dir, entry);
5495
+ const full = path$1.join(dir, entry);
5281
5496
  let stat;
5282
5497
  try {
5283
5498
  stat = statSync(full);
@@ -5307,7 +5522,7 @@ function cleanGeneratedFiles(dir) {
5307
5522
  * it to ManifestEntry[] for the overlay builder.
5308
5523
  */
5309
5524
  function loadManifestFromDir(outputDir) {
5310
- const manifestPath = path.join(outputDir, "smoke-manifest.json");
5525
+ const manifestPath = path$1.join(outputDir, "smoke-manifest.json");
5311
5526
  if (!existsSync(manifestPath)) return void 0;
5312
5527
  try {
5313
5528
  const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
@@ -5468,16 +5683,16 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
5468
5683
  //#endregion
5469
5684
  //#region src/verify/run-smoke-check.ts
5470
5685
  const __filename = fileURLToPath(import.meta.url);
5471
- const __dirname = path.dirname(__filename);
5686
+ const __dirname = path$1.dirname(__filename);
5472
5687
  function resolveScript(scriptRelPath) {
5473
- const distScript = path.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
5688
+ const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
5474
5689
  if (existsSync(distScript)) return {
5475
5690
  bin: "node",
5476
5691
  script: distScript
5477
5692
  };
5478
5693
  return {
5479
5694
  bin: "npx",
5480
- script: path.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
5695
+ script: path$1.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
5481
5696
  };
5482
5697
  }
5483
5698
  function runPackagedScript(scriptRelPath, args) {
@@ -5928,7 +6143,7 @@ const CONFIG_NAMES = [
5928
6143
  ];
5929
6144
  async function loadConfig(cwd = process.cwd()) {
5930
6145
  for (const name of CONFIG_NAMES) {
5931
- const configPath = path.resolve(cwd, name);
6146
+ const configPath = path$1.resolve(cwd, name);
5932
6147
  if (!existsSync(configPath)) continue;
5933
6148
  try {
5934
6149
  const mod = await import(pathToFileURL(configPath).href);