@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.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE.txt +21 -0
- package/README.md +197 -0
- package/dist/chunk-CIX5NOBF.js +263 -0
- package/dist/chunk-IJ5E5AE7.js +1040 -0
- package/dist/chunk-P5XUDUGY.js +1748 -0
- package/dist/cli/index.js +5603 -0
- package/dist/compat/index.d.ts +195 -0
- package/dist/compat/index.js +4453 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +1733 -0
- package/dist/scripts/smoke/baseline.js +1751 -0
- package/dist/scripts/smoke/sdk-test.js +93 -0
- package/dist/smoke.js +1963 -0
- package/dist/types-B5t9O9Wm.d.ts +367 -0
- package/dist/verify/index.d.ts +76 -0
- package/dist/verify/index.js +336 -0
- package/docs/advanced/index.md +11 -0
- package/docs/advanced/oagen-ignore.md +144 -0
- package/docs/agents/architecture.md +53 -0
- package/docs/agents/emitters.md +69 -0
- package/docs/agents/testing.md +60 -0
- package/docs/architecture/dependency-layers.md +75 -0
- package/docs/architecture/emitter-contract.md +160 -0
- package/docs/architecture/extractor-contract.md +227 -0
- package/docs/architecture/ir-types.md +275 -0
- package/docs/architecture/non-additive-changes.md +76 -0
- package/docs/architecture/pipeline.md +110 -0
- package/docs/architecture/workflows.md +215 -0
- package/docs/cli.md +156 -0
- package/docs/contributor/index.md +10 -0
- package/docs/contributor/public-api.md +59 -0
- package/docs/contributor/versioning.md +48 -0
- package/docs/core/index.md +12 -0
- package/docs/core/minimal-emitter.md +134 -0
- package/docs/core/quickstart.md +142 -0
- package/docs/diagrams/generate-sdk-workflow.md +50 -0
- package/docs/index.md +40 -0
- package/examples/reference-emitter/README.md +41 -0
- package/examples/reference-emitter/oagen.config.ts +7 -0
- package/examples/reference-emitter/spec/github-subset.yml +400 -0
- package/examples/reference-emitter/src/emitter.ts +52 -0
- package/examples/reference-emitter/src/generators/client.ts +37 -0
- package/examples/reference-emitter/src/generators/config.ts +19 -0
- package/examples/reference-emitter/src/generators/enums.ts +16 -0
- package/examples/reference-emitter/src/generators/errors.ts +39 -0
- package/examples/reference-emitter/src/generators/models.ts +29 -0
- package/examples/reference-emitter/src/generators/resources.ts +60 -0
- package/examples/reference-emitter/src/generators/tests.ts +7 -0
- package/examples/reference-emitter/src/index.ts +1 -0
- package/examples/reference-emitter/src/naming.ts +21 -0
- package/examples/reference-emitter/src/type-mapper.ts +39 -0
- package/examples/reference-emitter/tsconfig.json +19 -0
- package/package.json +102 -0
- package/skills/generate-emitter/SKILL.md +393 -0
- package/skills/generate-emitter/references/common-pitfalls.md +23 -0
- package/skills/generate-emitter/references/generator-guide.md +201 -0
- package/skills/generate-emitter/references/project-scaffold.md +138 -0
- package/skills/generate-extractor/SKILL.md +200 -0
- package/skills/generate-sdk/SKILL.md +155 -0
- package/skills/generate-smoke-test/SKILL.md +185 -0
- package/skills/integrate/SKILL.md +108 -0
- package/skills/verify-compat/SKILL.md +151 -0
- 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>`
|