@workos/oagen 0.0.1

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 (64) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE.txt +21 -0
  3. package/README.md +197 -0
  4. package/dist/chunk-CIX5NOBF.js +263 -0
  5. package/dist/chunk-IJ5E5AE7.js +1040 -0
  6. package/dist/chunk-P5XUDUGY.js +1748 -0
  7. package/dist/cli/index.js +5603 -0
  8. package/dist/compat/index.d.ts +195 -0
  9. package/dist/compat/index.js +4453 -0
  10. package/dist/index.d.ts +300 -0
  11. package/dist/index.js +1733 -0
  12. package/dist/scripts/smoke/baseline.js +1751 -0
  13. package/dist/scripts/smoke/sdk-test.js +93 -0
  14. package/dist/smoke.js +1963 -0
  15. package/dist/types-B5t9O9Wm.d.ts +367 -0
  16. package/dist/verify/index.d.ts +76 -0
  17. package/dist/verify/index.js +336 -0
  18. package/docs/advanced/index.md +11 -0
  19. package/docs/advanced/oagen-ignore.md +144 -0
  20. package/docs/agents/architecture.md +53 -0
  21. package/docs/agents/emitters.md +69 -0
  22. package/docs/agents/testing.md +60 -0
  23. package/docs/architecture/dependency-layers.md +75 -0
  24. package/docs/architecture/emitter-contract.md +160 -0
  25. package/docs/architecture/extractor-contract.md +227 -0
  26. package/docs/architecture/ir-types.md +275 -0
  27. package/docs/architecture/non-additive-changes.md +76 -0
  28. package/docs/architecture/pipeline.md +110 -0
  29. package/docs/architecture/workflows.md +215 -0
  30. package/docs/cli.md +156 -0
  31. package/docs/contributor/index.md +10 -0
  32. package/docs/contributor/public-api.md +59 -0
  33. package/docs/contributor/versioning.md +48 -0
  34. package/docs/core/index.md +12 -0
  35. package/docs/core/minimal-emitter.md +134 -0
  36. package/docs/core/quickstart.md +142 -0
  37. package/docs/diagrams/generate-sdk-workflow.md +50 -0
  38. package/docs/index.md +40 -0
  39. package/examples/reference-emitter/README.md +41 -0
  40. package/examples/reference-emitter/oagen.config.ts +7 -0
  41. package/examples/reference-emitter/spec/github-subset.yml +400 -0
  42. package/examples/reference-emitter/src/emitter.ts +52 -0
  43. package/examples/reference-emitter/src/generators/client.ts +37 -0
  44. package/examples/reference-emitter/src/generators/config.ts +19 -0
  45. package/examples/reference-emitter/src/generators/enums.ts +16 -0
  46. package/examples/reference-emitter/src/generators/errors.ts +39 -0
  47. package/examples/reference-emitter/src/generators/models.ts +29 -0
  48. package/examples/reference-emitter/src/generators/resources.ts +60 -0
  49. package/examples/reference-emitter/src/generators/tests.ts +7 -0
  50. package/examples/reference-emitter/src/index.ts +1 -0
  51. package/examples/reference-emitter/src/naming.ts +21 -0
  52. package/examples/reference-emitter/src/type-mapper.ts +39 -0
  53. package/examples/reference-emitter/tsconfig.json +19 -0
  54. package/package.json +102 -0
  55. package/skills/generate-emitter/SKILL.md +393 -0
  56. package/skills/generate-emitter/references/common-pitfalls.md +23 -0
  57. package/skills/generate-emitter/references/generator-guide.md +201 -0
  58. package/skills/generate-emitter/references/project-scaffold.md +138 -0
  59. package/skills/generate-extractor/SKILL.md +200 -0
  60. package/skills/generate-sdk/SKILL.md +155 -0
  61. package/skills/generate-smoke-test/SKILL.md +185 -0
  62. package/skills/integrate/SKILL.md +108 -0
  63. package/skills/verify-compat/SKILL.md +151 -0
  64. package/skills/verify-smoke-test/SKILL.md +176 -0
@@ -0,0 +1,336 @@
1
+ import {
2
+ diffSpecs,
3
+ generate,
4
+ getEmitter
5
+ } from "../chunk-P5XUDUGY.js";
6
+ import {
7
+ buildOverlayLookup,
8
+ diffSurfaces,
9
+ filterSurface,
10
+ getExtractor,
11
+ patchOverlay,
12
+ specDerivedEnumValues,
13
+ specDerivedFieldPaths,
14
+ specDerivedMethodPaths,
15
+ specDerivedNames
16
+ } from "../chunk-IJ5E5AE7.js";
17
+ import {
18
+ CommandError
19
+ } from "../chunk-CIX5NOBF.js";
20
+
21
+ // src/verify/run-compat-check.ts
22
+ async function runCompatCheck(baseline, outputDir, lang, spec) {
23
+ const extractor = getExtractor(lang);
24
+ const candidate = await extractor.extract(outputDir);
25
+ let scopedBaseline = baseline;
26
+ let scopedToSpec = false;
27
+ let scopedSymbolCount;
28
+ if (spec) {
29
+ const allowed = specDerivedNames(spec, extractor.hints);
30
+ const fieldPaths = specDerivedFieldPaths(spec, extractor.hints);
31
+ const methodPaths = specDerivedMethodPaths(spec);
32
+ const enumVals = specDerivedEnumValues(spec);
33
+ scopedBaseline = filterSurface(baseline, allowed, { fieldPaths, methodPaths, enumValues: enumVals });
34
+ scopedToSpec = true;
35
+ scopedSymbolCount = Object.keys(scopedBaseline.interfaces).length + Object.keys(scopedBaseline.classes).length + Object.keys(scopedBaseline.typeAliases).length + Object.keys(scopedBaseline.enums).length;
36
+ }
37
+ const diff = diffSurfaces(scopedBaseline, candidate, extractor.hints);
38
+ const passed = diff.violations.every((v) => v.severity !== "breaking");
39
+ return { passed, diff, scopedToSpec, scopedSymbolCount };
40
+ }
41
+
42
+ // src/verify/run-overlay-retry-loop.ts
43
+ import { existsSync, readFileSync, readdirSync, unlinkSync, statSync, openSync, readSync, closeSync } from "fs";
44
+ import * as path from "path";
45
+ var PATCHABLE_CATEGORIES = /* @__PURE__ */ new Set(["public-api", "export-structure"]);
46
+ var GENERATED_MARKERS = ["Code generated by oagen", "auto-generated by oagen", "This file is auto-generated"];
47
+ function cleanGeneratedFiles(dir) {
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir);
51
+ } catch {
52
+ return;
53
+ }
54
+ for (const entry of entries) {
55
+ const full = path.join(dir, entry);
56
+ let stat;
57
+ try {
58
+ stat = statSync(full);
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (stat.isDirectory()) {
63
+ cleanGeneratedFiles(full);
64
+ continue;
65
+ }
66
+ if (entry.endsWith(".json") || entry.endsWith(".lock")) continue;
67
+ try {
68
+ const buf = Buffer.alloc(200);
69
+ const fd = openSync(full, "r");
70
+ try {
71
+ readSync(fd, buf, 0, 200, 0);
72
+ } finally {
73
+ closeSync(fd);
74
+ }
75
+ const header = buf.toString("utf-8");
76
+ if (GENERATED_MARKERS.some((m) => header.includes(m))) {
77
+ unlinkSync(full);
78
+ }
79
+ } catch {
80
+ }
81
+ }
82
+ }
83
+ function loadManifestFromDir(outputDir) {
84
+ const manifestPath = path.join(outputDir, "smoke-manifest.json");
85
+ if (!existsSync(manifestPath)) return void 0;
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
88
+ if (Array.isArray(parsed)) return parsed;
89
+ if (typeof parsed === "object" && parsed !== null) {
90
+ return Object.entries(parsed).map(([httpKey, value]) => {
91
+ const spaceIdx = httpKey.indexOf(" ");
92
+ const httpMethod = httpKey.slice(0, spaceIdx);
93
+ const httpPath = httpKey.slice(spaceIdx + 1);
94
+ const v = value;
95
+ return {
96
+ operationId: "",
97
+ sdkResourceProperty: v.service ?? "",
98
+ sdkMethodName: v.sdkMethod ?? "",
99
+ httpMethod,
100
+ path: httpPath,
101
+ pathParams: [],
102
+ bodyFields: [],
103
+ queryFields: []
104
+ };
105
+ });
106
+ }
107
+ } catch {
108
+ }
109
+ return void 0;
110
+ }
111
+ async function runOverlayRetryLoop(opts) {
112
+ const { baseline, parsedSpec, outputDir, lang, maxRetries, namespace, onRetry } = opts;
113
+ const manifest = loadManifestFromDir(outputDir);
114
+ const extractor = getExtractor(lang);
115
+ let overlay = buildOverlayLookup(baseline, manifest, parsedSpec, extractor.hints);
116
+ let prevPreserved = -1;
117
+ const patchedPerIteration = [];
118
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
119
+ const compatResult2 = await runCompatCheck(baseline, outputDir, lang, parsedSpec);
120
+ if (compatResult2.passed) {
121
+ return {
122
+ status: "passed",
123
+ attempts: attempt,
124
+ patchedPerIteration,
125
+ compatResult: compatResult2
126
+ };
127
+ }
128
+ if (attempt === maxRetries) {
129
+ return {
130
+ status: "max-retries",
131
+ attempts: attempt,
132
+ patchedPerIteration,
133
+ compatResult: compatResult2
134
+ };
135
+ }
136
+ const patchable = compatResult2.diff.violations.filter((v) => PATCHABLE_CATEGORIES.has(v.category));
137
+ if (patchable.length === 0) {
138
+ return {
139
+ status: "no-patchable",
140
+ attempts: attempt,
141
+ patchedPerIteration,
142
+ compatResult: compatResult2
143
+ };
144
+ }
145
+ const currentPreserved = compatResult2.diff.preservedSymbols;
146
+ if (attempt > 0 && currentPreserved <= prevPreserved) {
147
+ return {
148
+ status: "stalled",
149
+ attempts: attempt,
150
+ patchedPerIteration,
151
+ compatResult: compatResult2
152
+ };
153
+ }
154
+ prevPreserved = currentPreserved;
155
+ patchedPerIteration.push(patchable.length);
156
+ onRetry?.(attempt + 1, maxRetries, patchable.length);
157
+ overlay = patchOverlay(overlay, patchable, baseline);
158
+ cleanGeneratedFiles(outputDir);
159
+ const emitter = getEmitter(lang);
160
+ await generate(parsedSpec, emitter, {
161
+ namespace: namespace ?? parsedSpec.name,
162
+ outputDir,
163
+ overlayLookup: overlay,
164
+ apiSurface: baseline
165
+ });
166
+ }
167
+ const compatResult = await runCompatCheck(baseline, outputDir, lang, parsedSpec);
168
+ return {
169
+ status: "max-retries",
170
+ attempts: maxRetries,
171
+ patchedPerIteration,
172
+ compatResult
173
+ };
174
+ }
175
+
176
+ // src/compat/staleness.ts
177
+ function detectStaleSymbols(liveSurface, oldSpec, newSpec, hints) {
178
+ const violations = [];
179
+ const oldNames = specDerivedNames(oldSpec, hints);
180
+ const newNames = specDerivedNames(newSpec, hints);
181
+ const removedNames = /* @__PURE__ */ new Set();
182
+ for (const name of oldNames) {
183
+ if (!newNames.has(name)) removedNames.add(name);
184
+ }
185
+ for (const name of removedNames) {
186
+ if (liveSurface.classes[name]) {
187
+ violations.push({
188
+ category: "staleness",
189
+ severity: "warning",
190
+ symbolPath: name,
191
+ baseline: name,
192
+ candidate: "(removed from spec)",
193
+ message: `Class "${name}" is no longer defined in the OpenAPI spec`
194
+ });
195
+ }
196
+ if (liveSurface.interfaces[name]) {
197
+ violations.push({
198
+ category: "staleness",
199
+ severity: "warning",
200
+ symbolPath: name,
201
+ baseline: name,
202
+ candidate: "(removed from spec)",
203
+ message: `Interface "${name}" is no longer defined in the OpenAPI spec`
204
+ });
205
+ }
206
+ if (liveSurface.typeAliases[name]) {
207
+ violations.push({
208
+ category: "staleness",
209
+ severity: "warning",
210
+ symbolPath: name,
211
+ baseline: name,
212
+ candidate: "(removed from spec)",
213
+ message: `Type alias "${name}" is no longer defined in the OpenAPI spec`
214
+ });
215
+ }
216
+ if (liveSurface.enums[name]) {
217
+ violations.push({
218
+ category: "staleness",
219
+ severity: "warning",
220
+ symbolPath: name,
221
+ baseline: name,
222
+ candidate: "(removed from spec)",
223
+ message: `Enum "${name}" is no longer defined in the OpenAPI spec`
224
+ });
225
+ }
226
+ }
227
+ const oldFieldPaths = specDerivedFieldPaths(oldSpec, hints);
228
+ const newFieldPaths = specDerivedFieldPaths(newSpec, hints);
229
+ for (const fieldPath of oldFieldPaths) {
230
+ if (newFieldPaths.has(fieldPath)) continue;
231
+ const [modelName, fieldName] = fieldPath.split(".");
232
+ if (removedNames.has(modelName)) continue;
233
+ const iface = liveSurface.interfaces[modelName];
234
+ if (iface?.fields[fieldName]) {
235
+ violations.push({
236
+ category: "staleness",
237
+ severity: "warning",
238
+ symbolPath: fieldPath,
239
+ baseline: fieldName,
240
+ candidate: "(removed from spec)",
241
+ message: `Field "${fieldPath}" is no longer defined in the OpenAPI spec`
242
+ });
243
+ }
244
+ }
245
+ const diff = diffSpecs(oldSpec, newSpec);
246
+ for (const change of diff.changes) {
247
+ if (change.kind === "operation-removed") {
248
+ const cls = liveSurface.classes[change.serviceName];
249
+ if (cls?.methods[change.operationName]) {
250
+ violations.push({
251
+ category: "staleness",
252
+ severity: "warning",
253
+ symbolPath: `${change.serviceName}.${change.operationName}`,
254
+ baseline: change.operationName,
255
+ candidate: "(removed from spec)",
256
+ message: `Method "${change.serviceName}.${change.operationName}" is no longer defined in the OpenAPI spec`
257
+ });
258
+ }
259
+ }
260
+ }
261
+ return violations;
262
+ }
263
+
264
+ // src/verify/run-staleness-check.ts
265
+ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
266
+ const extractor = getExtractor(lang);
267
+ const violations = detectStaleSymbols(baseline, oldSpec, newSpec, extractor.hints);
268
+ return { violations };
269
+ }
270
+
271
+ // src/verify/run-smoke-check.ts
272
+ import { execFileSync } from "child_process";
273
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
274
+ import { fileURLToPath } from "url";
275
+ import * as path2 from "path";
276
+ var __filename = fileURLToPath(import.meta.url);
277
+ var __dirname = path2.dirname(__filename);
278
+ function resolveScript(scriptRelPath) {
279
+ const distScript = path2.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".js"));
280
+ if (existsSync2(distScript)) {
281
+ return { bin: "node", script: distScript };
282
+ }
283
+ const srcScript = path2.resolve(__dirname, "..", "..", "scripts", scriptRelPath);
284
+ return { bin: "npx", script: srcScript };
285
+ }
286
+ function runPackagedScript(scriptRelPath, args) {
287
+ const { bin, script } = resolveScript(scriptRelPath);
288
+ const fullArgs = bin === "npx" ? ["tsx", script, ...args] : [script, ...args];
289
+ execFileSync(bin, fullArgs, { stdio: "inherit", env: process.env });
290
+ }
291
+ function runSmokeCheck(opts) {
292
+ const { spec, lang, output, rawResults, smokeConfig, smokeRunner } = opts;
293
+ let baselinePath = rawResults ?? "smoke-results-raw.json";
294
+ let generatedBaseline = false;
295
+ if (!rawResults && !existsSync2("smoke-results-raw.json")) {
296
+ if (!spec) {
297
+ throw new CommandError(
298
+ "error: --spec <path> or OPENAPI_SPEC_PATH env var is required when no raw baseline exists",
299
+ "",
300
+ 1
301
+ );
302
+ }
303
+ try {
304
+ runPackagedScript("smoke/baseline.ts", ["--spec", spec]);
305
+ } catch {
306
+ throw new CommandError("Baseline generation failed", "", 1);
307
+ }
308
+ baselinePath = "smoke-results-spec-baseline.json";
309
+ generatedBaseline = true;
310
+ }
311
+ const smokeArgs = ["--lang", lang, "--sdk-path", output, "--raw-results", baselinePath];
312
+ if (spec) smokeArgs.push("--spec", spec);
313
+ if (smokeConfig) smokeArgs.push("--smoke-config", smokeConfig);
314
+ try {
315
+ if (smokeRunner) {
316
+ const bin = smokeRunner.endsWith(".ts") ? "npx" : "node";
317
+ const fullArgs = bin === "npx" ? ["tsx", smokeRunner, ...smokeArgs] : [smokeRunner, ...smokeArgs];
318
+ execFileSync(bin, fullArgs, { stdio: "inherit", env: process.env });
319
+ } else {
320
+ runPackagedScript("smoke/sdk-test.ts", smokeArgs);
321
+ }
322
+ return { passed: true, baselinePath, generatedBaseline };
323
+ } catch {
324
+ if (existsSync2("smoke-compile-errors.json")) {
325
+ return { passed: false, compileErrors: true, baselinePath, generatedBaseline };
326
+ }
327
+ const findingsCount = existsSync2("smoke-diff-findings.json") ? JSON.parse(readFileSync2("smoke-diff-findings.json", "utf-8")).length : void 0;
328
+ return { passed: false, findingsCount, baselinePath, generatedBaseline };
329
+ }
330
+ }
331
+ export {
332
+ runCompatCheck,
333
+ runOverlayRetryLoop,
334
+ runSmokeCheck,
335
+ runStalenessCheck
336
+ };
@@ -0,0 +1,11 @@
1
+ # Advanced Docs
2
+
3
+ These features are useful, but they are not required to adopt oagen as a framework.
4
+
5
+ Use this section when you need to preserve an existing SDK surface, verify behavior with smoke tests, or integrate generated files into a live SDK tree.
6
+
7
+ - [Workflows](../architecture/workflows.md)
8
+ - [Extractor Contract](../architecture/extractor-contract.md)
9
+ - [Non-Additive Changes](../architecture/non-additive-changes.md)
10
+ - [`@oagen-ignore` — Preserving Hand-Written Code](./oagen-ignore.md)
11
+ - [CLI Reference](../cli.md)
@@ -0,0 +1,144 @@
1
+ # `@oagen-ignore` — Preserving Hand-Written Code
2
+
3
+ The `@oagen-ignore` system lets you protect hand-written code from being overwritten during regeneration. It operates at three scopes: docstring, region, and file.
4
+
5
+ ## Docstring-Level: `@oagen-ignore`
6
+
7
+ Add `@oagen-ignore` anywhere inside a docstring to prevent the merger from overwriting it. The tag is a simple substring match — no regex, no parsing.
8
+
9
+ ### TypeScript
10
+
11
+ ```typescript
12
+ /**
13
+ * Exchange an authorization code for a profile and access token.
14
+ *
15
+ * Auto-detects public vs confidential client mode:
16
+ * - If codeVerifier is provided: Uses PKCE flow (public client)
17
+ * - If no codeVerifier: Uses client_secret from API key (confidential client)
18
+ *
19
+ * @oagen-ignore
20
+ * @throws Error if neither codeVerifier nor API key is available
21
+ */
22
+ async getProfileAndToken(...) { ... }
23
+ ```
24
+
25
+ ### Python
26
+
27
+ ```python
28
+ def get_profile_and_token(self, ...):
29
+ """Exchange an authorization code for a profile and access token.
30
+
31
+ Auto-detects public vs confidential client mode.
32
+
33
+ @oagen-ignore
34
+ """
35
+ ```
36
+
37
+ ### Scope
38
+
39
+ Protects:
40
+
41
+ - **Top-level symbol docstrings** — classes, interfaces, type aliases, enums
42
+ - **Member-level docstrings** — methods, fields, properties
43
+
44
+ Applies in both merge modes (full merge and docstring-only).
45
+
46
+ ### Behavior
47
+
48
+ - Matched via `text.includes('@oagen-ignore')`.
49
+ - If the existing docstring contains `@oagen-ignore`, it is kept as-is regardless of what the generated output says.
50
+ - If the existing symbol has no docstring at all, a generated docstring is still inserted (there's nothing to preserve).
51
+ - Language-agnostic: works for any language whose merge adapter implements `extractDocstrings`.
52
+
53
+ ## Region-Level: `@oagen-ignore-start` / `@oagen-ignore-end`
54
+
55
+ Wrap one or more symbols in a region to protect them from **both** deep merge (new members) and docstring refresh.
56
+
57
+ ### TypeScript
58
+
59
+ ```typescript
60
+ // @oagen-ignore-start
61
+ /**
62
+ * Hand-written SSO class with custom PKCE flow logic.
63
+ * The merger will not add new methods or update this docstring.
64
+ */
65
+ export class SSO {
66
+ getProfileAndToken() {
67
+ /* custom implementation */
68
+ }
69
+ }
70
+ // @oagen-ignore-end
71
+
72
+ // This class is NOT protected — normal merge rules apply
73
+ export class Users {
74
+ list() {}
75
+ }
76
+ ```
77
+
78
+ ### Python
79
+
80
+ ```python
81
+ # @oagen-ignore-start
82
+ class SSO:
83
+ """Hand-written SSO class."""
84
+
85
+ def get_profile_and_token(self):
86
+ """Custom PKCE flow."""
87
+ pass
88
+ # @oagen-ignore-end
89
+ ```
90
+
91
+ ### Scope
92
+
93
+ For each symbol whose declaration start falls within a region:
94
+
95
+ - **Deep merge** is skipped — no new members are added
96
+ - **Docstring refresh** is skipped — both top-level and member docstrings are preserved
97
+ - **Top-level append** still works — new symbols outside the region are appended normally
98
+
99
+ ### Behavior
100
+
101
+ - Markers are matched as literal substrings in comments. The comment style doesn't matter (`//`, `#`, `/* */`).
102
+ - Regions are paired: each `@oagen-ignore-start` must have a matching `@oagen-ignore-end`. An unclosed start does **not** protect anything.
103
+ - Multiple regions per file are supported.
104
+ - New symbols (not present in the existing file) are still appended even if existing symbols are in ignored regions.
105
+
106
+ ## File-Level: `@oagen-ignore-file`
107
+
108
+ Add `@oagen-ignore-file` anywhere in an existing file to prevent the writer from touching it at all. The file is completely skipped — no merge, no docstring refresh, no overwrite.
109
+
110
+ ### TypeScript
111
+
112
+ ```typescript
113
+ // @oagen-ignore-file
114
+ // This entire file is hand-maintained and should never be regenerated.
115
+
116
+ export class CustomClient {
117
+ // ...
118
+ }
119
+ ```
120
+
121
+ ### Python
122
+
123
+ ```python
124
+ # @oagen-ignore-file
125
+
126
+ class CustomClient:
127
+ """Fully hand-written client."""
128
+ pass
129
+ ```
130
+
131
+ ### Behavior
132
+
133
+ - Only checked on **existing** files. A brand-new file is always written, even if the generated content contains `@oagen-ignore-file`.
134
+ - Matched via `existingContent.includes('@oagen-ignore-file')`.
135
+ - The file appears in `WriteResult.ignored` and is logged as `Ignored N files (@oagen-ignore-file)`.
136
+ - Does not affect other files in the same batch.
137
+
138
+ ## Summary
139
+
140
+ | Marker | Scope | Protects |
141
+ | ------------------------------------------- | --------- | ---------------------------------------------------- |
142
+ | `@oagen-ignore` | Docstring | That specific docstring from overwrite |
143
+ | `@oagen-ignore-start` / `@oagen-ignore-end` | Region | Enclosed symbols from deep merge + docstring refresh |
144
+ | `@oagen-ignore-file` | File | Entire file from any writer action |
@@ -0,0 +1,53 @@
1
+ # Architecture And Core Contracts
2
+
3
+ Use this guide when changing parsing, IR types, orchestration, file writing, or shared framework architecture.
4
+
5
+ ## Pipeline
6
+
7
+ `OpenAPI spec -> Parser -> IR -> Emitter -> GeneratedFile[] -> Writer -> disk`
8
+
9
+ Three stages transform an OpenAPI spec into SDK files:
10
+
11
+ 1. **Parse** (`src/parser/parse.ts`) — Load and bundle the spec via `@redocly/openapi-core`, extract `Model[]`, `Enum[]`, and `Service[]` into the IR (`ApiSpec`). Sub-modules handle ref resolution (`refs.ts`), schema walking (`schemas.ts`), operation grouping (`operations.ts`), pagination detection (`pagination.ts`), and inline model extraction (`inline-models.ts`).
12
+
13
+ 2. **Emit** (`src/engine/orchestrator.ts`) — Call each emitter method in order (`generateModels` → `generateEnums` → `generateResources` → `generateClient` → `generateErrors` → `generateConfig` → `generateTypeSignatures` → `generateTests` → `generateManifest`), collect `GeneratedFile[]`, prepend file headers.
14
+
15
+ 3. **Write** (`src/engine/writer.ts`) — Write files to disk. New files are created in full. Existing files are merged at the AST level via `merger.ts` (additive-only — new symbols appended, existing symbols untouched). Files marked `skipIfExists` are never overwritten.
16
+
17
+ ## Modification Patterns
18
+
19
+ When changing pipeline behavior:
20
+
21
+ - **Adding a new IR node** — Update `src/ir/types.ts`, add `assertNever` branch in emitters. See [IR Types](../architecture/ir-types.md).
22
+ - **Adding a parser feature** — Add or modify sub-modules in `src/parser/`, wire into `parseSpec()`. Parser tests live in `test/parser/`.
23
+ - **Changing orchestration** — Modify `src/engine/orchestrator.ts`. The `generate()` function builds `EmitterContext`, calls `generateAllFiles()`, then `writeFiles()`.
24
+ - **Changing write behavior** — Modify `src/engine/writer.ts` or `src/engine/merger.ts`. The writer never deletes files; see [Non-Additive Changes](../architecture/non-additive-changes.md) for staleness detection.
25
+ - **Plugin/config changes** — Modify `src/cli/config-loader.ts`. The `OagenConfig` interface defines all user-facing config fields. See [CLI Reference](../cli.md) for command details.
26
+
27
+ ## Core Contracts
28
+
29
+ - IR types live in `src/ir/types.ts`
30
+ - The emitter interface lives in `src/engine/types.ts`
31
+ - Operation planning lives in `src/engine/operation-plan.ts`
32
+ - Emitter registration lives in `src/engine/registry.ts`
33
+ - Config loading lives in `src/cli/config-loader.ts`
34
+
35
+ ## Source Of Truth
36
+
37
+ - Pipeline details: [docs/architecture/pipeline.md](../architecture/pipeline.md)
38
+ - Dependency rules: [docs/architecture/dependency-layers.md](../architecture/dependency-layers.md)
39
+ - IR contract details: [docs/architecture/ir-types.md](../architecture/ir-types.md)
40
+ - Emitter contract details: [docs/architecture/emitter-contract.md](../architecture/emitter-contract.md)
41
+ - Non-additive changes: [docs/architecture/non-additive-changes.md](../architecture/non-additive-changes.md)
42
+ - CLI reference: [docs/cli.md](../cli.md)
43
+ - Workflow diagrams: [docs/diagrams/generate-sdk-workflow.md](../diagrams/generate-sdk-workflow.md)
44
+
45
+ ## Directory Landmarks
46
+
47
+ - `src/parser/`: OpenAPI to IR (refs, schemas, operations, pagination, inline models)
48
+ - `src/engine/`: orchestration, writer, merger, registry, shared engine types
49
+ - Language-specific emitters live in the separate `oagen-emitters` project (import from `@workos/oagen`)
50
+ - `src/differ/`: incremental diff engine
51
+ - `src/compat/`: compat verification, overlay, staleness detection, extractors
52
+ - `src/cli/`: CLI entry points and config loader
53
+ - `src/utils/`: naming utilities (snake_case, camelCase, PascalCase conversions)
@@ -0,0 +1,69 @@
1
+ # Emitter Implementation
2
+
3
+ Use this guide when adding or changing a language emitter.
4
+
5
+ ## Working Model
6
+
7
+ Emitters transform IR into `GeneratedFile[]`. Keep generator methods pure: no I/O and no side effects.
8
+
9
+ ## Common Emitter Layout
10
+
11
+ Emitters live in the separate `oagen-emitters` project and import types from `@workos/oagen`. Most emitters are organized around:
12
+
13
+ - `index.ts`: emitter entry point
14
+ - `type-map.ts`: `TypeRef` to target-language type mapping
15
+ - `naming.ts`: target-language naming rules
16
+ - `models.ts`: model generation
17
+ - `enums.ts`: enum generation
18
+ - `resources.ts`: service and resource generation
19
+ - `client.ts`: HTTP client generation
20
+ - `errors.ts`: error types
21
+ - `config.ts`: configuration output
22
+ - `tests.ts`: generated test files
23
+ - `fixtures.ts`: generated fixtures
24
+ - `manifest.ts`: smoke manifest output when needed
25
+
26
+ Language-specific extras already exist in some emitters:
27
+
28
+ - Ruby: `yard.ts`, `types-rbs.ts`, `types-rbi.ts`
29
+ - Node: `options.ts`, `common.ts`, `manifest.ts`
30
+
31
+ - `src/engine/operation-plan.ts` provides shared operation semantics (`OperationPlan`). Emitters should call `planOperation(op)` rather than duplicating decision logic for `isDelete`, `hasBody`, `isPaginated`, `responseModelName`, etc.
32
+
33
+ ## Naming
34
+
35
+ IR names use PascalCase. Each emitter is responsible for converting names through its local `naming.ts`.
36
+
37
+ ## Verifying Backwards Compatibility
38
+
39
+ When regenerating an SDK for a language that already has a published SDK, verify that the generated output preserves the existing public API surface.
40
+
41
+ **When to use:**
42
+
43
+ - The target language has an existing SDK with consumers who depend on its public API
44
+ - You're modifying an emitter and need to ensure no regressions in method names, signatures, or exports
45
+
46
+ **When to skip:**
47
+
48
+ - The target language is brand new (no existing SDK) — pass `--no-compat-check` to the generate command
49
+ - You're doing a full rewrite where breaking changes are intentional and documented
50
+
51
+ **How the self-correcting loop works:**
52
+
53
+ 1. Extract the live SDK's API surface (`oagen extract --sdk-path <path> --lang <language> --output <output>/sdk-{language}-surface.json`)
54
+ 2. Generate with the overlay (`--api-surface <output>/sdk-{language}-surface.json`) so the emitter preserves existing names
55
+ 3. Verify the generated output against the baseline (`oagen verify --api-surface <output>/sdk-{language}-surface.json --lang <language> --output <output>`)
56
+ 4. If violations exist, fix the emitter and regenerate — loop mode (`--loop`) automates this cycle
57
+
58
+ The differ and overlay delegate all language-specific logic to the extractor's `hints: LanguageHints` object. Every extractor must provide hints — use `resolveHints({...})` to override only what differs from Node defaults. See `docs/architecture/extractor-contract.md` for the full hints reference.
59
+
60
+ The `--api-surface` flag is supported by both `oagen generate` and `oagen diff` (for incremental generation with compat overlay).
61
+
62
+ Run `/generate-extractor <language>` to scaffold the extractor, then `/verify-compat <language>` for the full guided workflow.
63
+
64
+ ## Source Of Truth
65
+
66
+ - Per-language design docs: `docs/sdk-architecture/{language}.md` in the emitter project
67
+ - Extractor contract: `docs/architecture/extractor-contract.md`
68
+ - Create a new emitter: `/generate-emitter <language>`
69
+ - Create a new extractor: `/generate-extractor <language>`
@@ -0,0 +1,60 @@
1
+ # Testing And Smoke Validation
2
+
3
+ Use this guide when changing framework behavior, emitter-generated output, or compat verification coverage.
4
+
5
+ ## Quick Start: Spec Update Pipeline
6
+
7
+ When a new spec version arrives:
8
+
9
+ ```bash
10
+ oagen diff --old v1.yml --new v2.yml --report # review what changed
11
+ oagen generate --spec v2.yml --lang node --output ./sdk
12
+ oagen verify --spec v2.yml --lang node --output ./sdk
13
+
14
+ # With custom smoke runner
15
+ oagen verify --spec v2.yml --lang node --output ./sdk --smoke-runner ./my-runner.ts
16
+ ```
17
+
18
+ Exit 0 = all passed. Exit 1 = findings (`smoke-diff-findings.json`). Exit 2 = compile error (`smoke-compile-errors.json`).
19
+
20
+ See [Workflows](../architecture/workflows.md) for the full workflow diagram (setup vs. ongoing).
21
+
22
+ ## Unit And Integration Tests
23
+
24
+ - `test/` mirrors `src/`
25
+ - Prefer `toMatchInlineSnapshot()` for representative output assertions
26
+
27
+ ## Compat Verification
28
+
29
+ Compat verification checks that generated SDK code preserves the public API surface of an existing live SDK. It sits between unit tests and smoke tests in the testing pyramid:
30
+
31
+ - **Unit tests** — verify emitter logic in isolation (fast, no external dependencies)
32
+ - **Compat verification** — verify the API surface (names, signatures, exports) matches the live SDK
33
+ - **Smoke tests** — verify wire-level HTTP behavior against the real API
34
+
35
+ Compat verification catches regressions like renamed methods, changed parameter signatures, missing exports, and reorganized barrel files — issues that unit tests don't cover because they don't compare against the live SDK, and that smoke tests don't cover because they focus on HTTP behavior rather than API shape.
36
+
37
+ - Compat extraction: `oagen extract --sdk-path <path> --lang <language> --output <output>/sdk-{language}-surface.json`
38
+ - Compat verification: `oagen verify --api-surface <output>/sdk-{language}-surface.json --lang <language> --output <output>`
39
+ - Unified verify (includes compat): `oagen verify --api-surface <output>/sdk-{language}-surface.json --lang <language> --output <output>`
40
+ - Run `/verify-compat <language>` for the full guided workflow
41
+ - See `docs/architecture/extractor-contract.md` for building new language extractors
42
+ - The differ and overlay use `LanguageHints` from the extractor to handle language-specific type comparisons (nullability, unions, extraction artifacts, derived model names). See the "Language Hints" section in the extractor contract doc.
43
+
44
+ ## Smoke Tests
45
+
46
+ Smoke scripts live under `scripts/smoke/`.
47
+
48
+ - **Verify (recommended):** `oagen verify --lang <language> --output <path>` (after `oagen generate`)
49
+ - **Custom smoke runner:** `oagen verify --lang <language> --output <path> --smoke-runner ./my-runner.ts` or set `smokeRunners` (per-language map) in `oagen.config.ts`
50
+ - General smoke runner: `npm run smoke`
51
+ - Raw baseline generation: `npm run smoke:raw`
52
+ - Spec-only baseline: `npm run smoke:baseline`
53
+ - Per-language SDK smoke tests: registered via `smokeRunners` in `oagen.config.ts`
54
+ - Diffing: `npm run smoke:diff`
55
+ - Validation: `npm run smoke:validate`
56
+
57
+ ## Source Of Truth
58
+
59
+ - Smoke testing guide: `scripts/smoke/README.md`
60
+ - Create a smoke test with the `.claude` skill: `/generate-smoke-test <language>`