@workos/oagen 0.22.6 → 0.23.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.
- package/README.md +1 -1
- package/dist/cli/index.mjs +271 -42
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +2 -2
- package/dist/compat/index.mjs +9 -8
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-CQzcGuFJ.mjs → diff-B7dMUCoJ.mjs} +235 -27
- package/dist/diff-B7dMUCoJ.mjs.map +1 -0
- package/dist/{differ-CC65Efzt.d.mts → differ-DMmw0tbA.d.mts} +5 -5
- package/dist/{differ-CC65Efzt.d.mts.map → differ-DMmw0tbA.d.mts.map} +1 -1
- package/dist/index.d.mts +64 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +26 -11
- package/dist/index.mjs.map +1 -1
- package/dist/{overlay-cb32jDBS.mjs → overlay-sQHQoN0I.mjs} +4 -4
- package/dist/overlay-sQHQoN0I.mjs.map +1 -0
- package/dist/scripts/smoke/baseline.mjs +25 -10
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/scripts/smoke/sdk-test.mjs +3 -3
- package/dist/scripts/smoke/sdk-test.mjs.map +1 -1
- package/dist/smoke.d.mts +19 -4
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +25 -10
- package/dist/smoke.mjs.map +1 -1
- package/dist/tree-sitter-DcUvm0KA.mjs.map +1 -1
- package/dist/{types-Cjh39AGQ.d.mts → types-nM5Kp7UP.d.mts} +16 -9
- package/dist/types-nM5Kp7UP.d.mts.map +1 -0
- package/dist/verify/index.d.mts +2 -2
- package/dist/verify/index.mjs +2 -2
- package/docs/architecture/extractor-contract.md +2 -2
- package/docs/architecture/pipeline.md +2 -2
- package/docs/cli.md +19 -19
- package/docs/core/compatibility-policy.md +1 -1
- package/docs/core/manifest-schema.md +2 -2
- package/package.json +1 -1
- package/skills/generate-emitter/SKILL.md +2 -2
- package/skills/generate-emitter/references/common-pitfalls.md +1 -1
- package/skills/generate-emitter/references/project-scaffold.md +3 -3
- package/skills/generate-extractor/SKILL.md +2 -2
- package/skills/generate-smoke-test/SKILL.md +1 -1
- package/skills/verify-smoke-test/SKILL.md +1 -1
- package/dist/diff-CQzcGuFJ.mjs.map +0 -1
- package/dist/overlay-cb32jDBS.mjs.map +0 -1
- package/dist/types-Cjh39AGQ.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -165,7 +165,7 @@ export default {
|
|
|
165
165
|
retry: { backoff: { initialDelay: 0.5, maxDelay: 8.0 } },
|
|
166
166
|
timeout: {
|
|
167
167
|
defaultTimeoutSeconds: 30,
|
|
168
|
-
timeoutEnvVar: "
|
|
168
|
+
timeoutEnvVar: "SDK_REQUEST_TIMEOUT",
|
|
169
169
|
},
|
|
170
170
|
pagination: { autoPageDelayMs: 0 },
|
|
171
171
|
},
|
package/dist/cli/index.mjs
CHANGED
|
@@ -14,9 +14,8 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rea
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
//#region src/ir/sdk-behavior.ts
|
|
16
16
|
/**
|
|
17
|
-
* Canonical SDK behavior defaults.
|
|
18
|
-
*
|
|
19
|
-
* via `mergeSdkBehavior()` in each SDK's `oagen.config.ts`.
|
|
17
|
+
* Canonical SDK behavior defaults. Per-language or per-project overrides can
|
|
18
|
+
* be applied via `mergeSdkBehavior()` in each SDK's `oagen.config.ts`.
|
|
20
19
|
*
|
|
21
20
|
* - Retry: exponential backoff with jitter, retries on 429/5xx
|
|
22
21
|
* - Errors: standard HTTP status → exception kind mapping
|
|
@@ -59,12 +58,11 @@ function defaultSdkBehavior() {
|
|
|
59
58
|
429: "RateLimitExceeded"
|
|
60
59
|
},
|
|
61
60
|
serverErrorKind: "Server",
|
|
62
|
-
clientErrorKind: "Api"
|
|
63
|
-
errorDocUrlTemplate: "https://workos.com/docs/errors/{code}"
|
|
61
|
+
clientErrorKind: "Api"
|
|
64
62
|
},
|
|
65
63
|
telemetry: {
|
|
66
64
|
enabledByDefault: true,
|
|
67
|
-
headerName: "X-
|
|
65
|
+
headerName: "X-Client-Telemetry",
|
|
68
66
|
requestIdHeader: "X-Request-ID"
|
|
69
67
|
},
|
|
70
68
|
pagination: { autoPageDelayMs: 0 },
|
|
@@ -2044,11 +2042,11 @@ function inferVerb(method, path) {
|
|
|
2044
2042
|
* Uses the last non-param segment (the resource being operated on).
|
|
2045
2043
|
*
|
|
2046
2044
|
* Examples:
|
|
2047
|
-
* /
|
|
2048
|
-
* /
|
|
2049
|
-
* /
|
|
2045
|
+
* /accounts/users → "Users"
|
|
2046
|
+
* /accounts/users/{id} → "User" (singular for item operations)
|
|
2047
|
+
* /accounts/users/{id}/auth_factors → "AuthFactors"
|
|
2050
2048
|
* /organizations/{id}/api_keys → "ApiKeys"
|
|
2051
|
-
* /
|
|
2049
|
+
* /auth/authorize → "Authorize" (action endpoint)
|
|
2052
2050
|
*/
|
|
2053
2051
|
function inferResourceFromPath(path) {
|
|
2054
2052
|
const segments = path.split("/").filter(Boolean);
|
|
@@ -2694,6 +2692,7 @@ async function parseSpec(specPath, options) {
|
|
|
2694
2692
|
const fieldInlineModels = extractInlineModelsFromSchemas(spec.components?.schemas);
|
|
2695
2693
|
clearSchemaNameTransform();
|
|
2696
2694
|
const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(responseNormalizedModels, fieldInlineModels), services);
|
|
2695
|
+
applyFieldHints(finalModels, options?.fieldHints);
|
|
2697
2696
|
collectInlineEnumsFromModels(finalModels, enums);
|
|
2698
2697
|
collectInlineEnumsFromOperations(services, enums);
|
|
2699
2698
|
const auth = extractAuthSchemes(spec.components?.securitySchemes);
|
|
@@ -2716,6 +2715,22 @@ async function parseSpec(specPath, options) {
|
|
|
2716
2715
|
validateModelRefs(result);
|
|
2717
2716
|
return result;
|
|
2718
2717
|
}
|
|
2718
|
+
/**
|
|
2719
|
+
* Apply `fieldHints` onto the built models, setting `Field.domainName` for each
|
|
2720
|
+
* matching (model name, wire field name) pair. Serialization still uses
|
|
2721
|
+
* `Field.name`; only the domain-facing identifier changes.
|
|
2722
|
+
*/
|
|
2723
|
+
function applyFieldHints(models, fieldHints) {
|
|
2724
|
+
if (!fieldHints) return;
|
|
2725
|
+
for (const model of models) {
|
|
2726
|
+
const hints = fieldHints[model.name];
|
|
2727
|
+
if (!hints) continue;
|
|
2728
|
+
for (const field of model.fields) {
|
|
2729
|
+
const domainName = hints[field.name];
|
|
2730
|
+
if (domainName) field.domainName = domainName;
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2719
2734
|
/** Extract authentication schemes from OpenAPI securitySchemes. */
|
|
2720
2735
|
function extractAuthSchemes(securitySchemes) {
|
|
2721
2736
|
if (!securitySchemes) return void 0;
|
|
@@ -3313,12 +3328,12 @@ function extractKotlinClassMembers(classBody, source) {
|
|
|
3313
3328
|
}
|
|
3314
3329
|
/**
|
|
3315
3330
|
* KDoc signature that identifies a generator-emitted service accessor inside
|
|
3316
|
-
*
|
|
3331
|
+
* a Kotlin client class. The Kotlin client emitter writes every accessor with this
|
|
3317
3332
|
* exact doc preamble — when the existing file carries a member with this doc
|
|
3318
3333
|
* but the regenerated content omits it, the member is stale (its mount target
|
|
3319
3334
|
* was remapped or removed) and gets pruned.
|
|
3320
3335
|
*/
|
|
3321
|
-
const KOTLIN_MANAGED_ACCESSOR_DOC = /\/\*\*[\s\S]*?Lazily-constructed \[[^\]]+\] accessor for this \[
|
|
3336
|
+
const KOTLIN_MANAGED_ACCESSOR_DOC = /\/\*\*[\s\S]*?Lazily-constructed \[[^\]]+\] accessor for this \[[^\]]+\] client\.[\s\S]*?\*\//;
|
|
3322
3337
|
const kotlinMergeAdapter = {
|
|
3323
3338
|
language: "kotlin",
|
|
3324
3339
|
grammarModule: "tree-sitter-kotlin",
|
|
@@ -3571,7 +3586,6 @@ const nodeMergeAdapter = {
|
|
|
3571
3586
|
const newText = newMembers.map((m) => m.text).join("\n");
|
|
3572
3587
|
for (const match of newText.matchAll(/this\.(\w+)/g)) {
|
|
3573
3588
|
const propName = match[1];
|
|
3574
|
-
if (propName === "workos") continue;
|
|
3575
3589
|
if (propName && !existingMemberKeys.has(propName)) return true;
|
|
3576
3590
|
}
|
|
3577
3591
|
return false;
|
|
@@ -5272,6 +5286,60 @@ function collectTsImports(lines) {
|
|
|
5272
5286
|
return out;
|
|
5273
5287
|
}
|
|
5274
5288
|
/**
|
|
5289
|
+
* Remove the given symbols from generated `import type` bindings, in place.
|
|
5290
|
+
*
|
|
5291
|
+
* Called when an existing *value* import for the same symbol is being spliced
|
|
5292
|
+
* back into the file. A value import satisfies both runtime and type uses, so
|
|
5293
|
+
* leaving the generated `import type { X }` would re-declare `X` (TS2300
|
|
5294
|
+
* duplicate identifier). Callers guarantee every symbol here is absent from
|
|
5295
|
+
* generated *value* imports, so each occurrence is type-only and safe to drop.
|
|
5296
|
+
*
|
|
5297
|
+
* Handles whole-line `import type { … }` and inline `import { …, type X }`,
|
|
5298
|
+
* single- or multi-line. A statement whose bindings all get stripped is
|
|
5299
|
+
* removed entirely.
|
|
5300
|
+
*/
|
|
5301
|
+
function dropGeneratedTypeOnlyBindings(resultLines, symbols) {
|
|
5302
|
+
if (symbols.size === 0) return;
|
|
5303
|
+
const out = [];
|
|
5304
|
+
for (let i = 0; i < resultLines.length; i++) {
|
|
5305
|
+
const line = resultLines[i];
|
|
5306
|
+
const trimmed = line.trim();
|
|
5307
|
+
if (!/^import\b/.test(trimmed) || !trimmed.includes("{")) {
|
|
5308
|
+
out.push(line);
|
|
5309
|
+
continue;
|
|
5310
|
+
}
|
|
5311
|
+
const span = [line];
|
|
5312
|
+
while (!span.join(" ").includes("}") && i + 1 < resultLines.length) {
|
|
5313
|
+
i++;
|
|
5314
|
+
span.push(resultLines[i]);
|
|
5315
|
+
}
|
|
5316
|
+
const m = span.join(" ").replace(/\s+/g, " ").trim().replace(/;\s*$/, "").match(/^import\s+(type\s+)?(?:(\w+)\s*,\s*)?\{\s*([^}]*)\s*\}\s+from\s+(['"][^'"]+['"])$/);
|
|
5317
|
+
if (!m) {
|
|
5318
|
+
for (const s of span) out.push(s);
|
|
5319
|
+
continue;
|
|
5320
|
+
}
|
|
5321
|
+
const isTypeOnly = !!m[1];
|
|
5322
|
+
const defaultName = m[2];
|
|
5323
|
+
const items = m[3].split(",").map((s) => s.trim()).filter(Boolean);
|
|
5324
|
+
const modQuoted = m[4];
|
|
5325
|
+
const kept = items.filter((raw) => {
|
|
5326
|
+
const isTypeBinding = isTypeOnly || raw.startsWith("type ");
|
|
5327
|
+
const bare = raw.replace(/^type\s+/, "");
|
|
5328
|
+
return !(isTypeBinding && symbols.has(bare));
|
|
5329
|
+
});
|
|
5330
|
+
if (kept.length === items.length) {
|
|
5331
|
+
for (const s of span) out.push(s);
|
|
5332
|
+
continue;
|
|
5333
|
+
}
|
|
5334
|
+
if (kept.length === 0 && !defaultName) continue;
|
|
5335
|
+
const lead = line.match(/^(\s*)/)?.[1] ?? "";
|
|
5336
|
+
const prefix = isTypeOnly ? "import type " : "import ";
|
|
5337
|
+
const defaultPart = defaultName ? `${defaultName}, ` : "";
|
|
5338
|
+
out.push(`${lead}${prefix}${defaultPart}{ ${kept.join(", ")} } from ${modQuoted};`);
|
|
5339
|
+
}
|
|
5340
|
+
resultLines.splice(0, resultLines.length, ...out);
|
|
5341
|
+
}
|
|
5342
|
+
/**
|
|
5275
5343
|
* Add imports from the existing file that are absent from the generated
|
|
5276
5344
|
* result. Handles:
|
|
5277
5345
|
*
|
|
@@ -5393,28 +5461,49 @@ function spliceExtraImports(existingLines, resultLines) {
|
|
|
5393
5461
|
const existingTs = collectTsImports(existingOutsideIgnore);
|
|
5394
5462
|
const generatedTs = collectTsImports(resultLines);
|
|
5395
5463
|
const extraTsLines = [];
|
|
5464
|
+
const allGeneratedTsIds = /* @__PURE__ */ new Set();
|
|
5465
|
+
const generatedValueIds = /* @__PURE__ */ new Set();
|
|
5466
|
+
for (const ids of generatedTs.named.values()) for (const id of ids) {
|
|
5467
|
+
allGeneratedTsIds.add(id);
|
|
5468
|
+
generatedValueIds.add(id);
|
|
5469
|
+
}
|
|
5470
|
+
for (const ids of generatedTs.typeOnly.values()) for (const id of ids) allGeneratedTsIds.add(id);
|
|
5471
|
+
for (const name of generatedTs.defaults.values()) {
|
|
5472
|
+
allGeneratedTsIds.add(name);
|
|
5473
|
+
generatedValueIds.add(name);
|
|
5474
|
+
}
|
|
5475
|
+
for (const name of generatedTs.namespaces.values()) {
|
|
5476
|
+
allGeneratedTsIds.add(name);
|
|
5477
|
+
generatedValueIds.add(name);
|
|
5478
|
+
}
|
|
5479
|
+
const reclaimedValueIds = /* @__PURE__ */ new Set();
|
|
5480
|
+
const generatedHasValue = (id) => generatedValueIds.has(id);
|
|
5481
|
+
const generatedHasTypeOnly = (id) => allGeneratedTsIds.has(id) && !generatedValueIds.has(id);
|
|
5396
5482
|
for (const mod of existingTs.sideEffects) if (!generatedTs.sideEffects.has(mod)) extraTsLines.push(`import '${mod}';`);
|
|
5397
5483
|
for (const [mod, name] of existingTs.defaults) {
|
|
5398
5484
|
if (generatedTs.defaults.has(mod)) continue;
|
|
5399
5485
|
if (!identifierUsed(ignoreRegionText, name)) continue;
|
|
5486
|
+
if (generatedHasValue(name)) continue;
|
|
5487
|
+
if (generatedHasTypeOnly(name)) reclaimedValueIds.add(name);
|
|
5400
5488
|
extraTsLines.push(`import ${name} from '${mod}';`);
|
|
5401
5489
|
}
|
|
5402
5490
|
for (const [mod, name] of existingTs.namespaces) {
|
|
5403
5491
|
if (!identifierUsed(ignoreRegionText, name)) continue;
|
|
5404
|
-
if (
|
|
5492
|
+
if (generatedHasValue(name)) continue;
|
|
5493
|
+
if (generatedTs.namespaces.has(mod)) continue;
|
|
5494
|
+
if (generatedHasTypeOnly(name)) reclaimedValueIds.add(name);
|
|
5495
|
+
extraTsLines.push(`import * as ${name} from '${mod}';`);
|
|
5405
5496
|
}
|
|
5406
5497
|
for (const [mod, existIds] of existingTs.named) {
|
|
5407
|
-
const
|
|
5408
|
-
const
|
|
5409
|
-
const extra = [...existIds].filter((id) => !genIds.has(id) && !genTypeIds.has(id) && identifierUsed(ignoreRegionText, id));
|
|
5498
|
+
const extra = [...existIds].filter((id) => !generatedHasValue(id) && identifierUsed(ignoreRegionText, id));
|
|
5499
|
+
for (const id of extra) if (generatedHasTypeOnly(id)) reclaimedValueIds.add(id);
|
|
5410
5500
|
if (extra.length > 0) extraTsLines.push(`import { ${extra.join(", ")} } from '${mod}';`);
|
|
5411
5501
|
}
|
|
5412
5502
|
for (const [mod, existIds] of existingTs.typeOnly) {
|
|
5413
|
-
const
|
|
5414
|
-
const genNamedIds = generatedTs.named.get(mod) ?? /* @__PURE__ */ new Set();
|
|
5415
|
-
const extra = [...existIds].filter((id) => !genIds.has(id) && !genNamedIds.has(id) && identifierUsed(ignoreRegionText, id));
|
|
5503
|
+
const extra = [...existIds].filter((id) => !allGeneratedTsIds.has(id) && identifierUsed(ignoreRegionText, id));
|
|
5416
5504
|
if (extra.length > 0) extraTsLines.push(`import type { ${extra.join(", ")} } from '${mod}';`);
|
|
5417
5505
|
}
|
|
5506
|
+
dropGeneratedTypeOnlyBindings(resultLines, reclaimedValueIds);
|
|
5418
5507
|
const isPhpUseImport = (l) => /^use\s+[\w\\]+\\[\w\\]*(\s+as\s+\w+)?\s*;?$/.test(l);
|
|
5419
5508
|
const existingPhpUse = new Set(existingOutsideIgnore.filter(isPhpUseImport).map((l) => l.trim()));
|
|
5420
5509
|
const generatedPhpUse = new Set(resultLines.filter(isPhpUseImport).map((l) => l.trim()));
|
|
@@ -5458,7 +5547,31 @@ function spliceExtraImports(existingLines, resultLines) {
|
|
|
5458
5547
|
if ((resultLines[i].match(/[)}]/g) ?? []).length > 0) break;
|
|
5459
5548
|
}
|
|
5460
5549
|
}
|
|
5461
|
-
if (lastImportIdx >= 0)
|
|
5550
|
+
if (lastImportIdx >= 0) {
|
|
5551
|
+
resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
|
|
5552
|
+
return;
|
|
5553
|
+
}
|
|
5554
|
+
let insertAt = 0;
|
|
5555
|
+
let inBlockComment = false;
|
|
5556
|
+
for (let i = 0; i < resultLines.length; i++) {
|
|
5557
|
+
const t = resultLines[i].trim();
|
|
5558
|
+
if (inBlockComment) {
|
|
5559
|
+
insertAt = i + 1;
|
|
5560
|
+
if (t.includes("*/")) inBlockComment = false;
|
|
5561
|
+
continue;
|
|
5562
|
+
}
|
|
5563
|
+
if (t.startsWith("//")) {
|
|
5564
|
+
insertAt = i + 1;
|
|
5565
|
+
continue;
|
|
5566
|
+
}
|
|
5567
|
+
if (t.startsWith("/*")) {
|
|
5568
|
+
insertAt = i + 1;
|
|
5569
|
+
inBlockComment = !t.includes("*/");
|
|
5570
|
+
continue;
|
|
5571
|
+
}
|
|
5572
|
+
break;
|
|
5573
|
+
}
|
|
5574
|
+
resultLines.splice(insertAt, 0, ...allExtra);
|
|
5462
5575
|
}
|
|
5463
5576
|
//#endregion
|
|
5464
5577
|
//#region src/ir/operation-hints.ts
|
|
@@ -5603,6 +5716,15 @@ function resolveResponseModelName(op) {
|
|
|
5603
5716
|
//#endregion
|
|
5604
5717
|
//#region src/engine/generate-files.ts
|
|
5605
5718
|
function buildEmitterContext(spec, options) {
|
|
5719
|
+
const resolvedOperations = resolveOperations(spec, options.operationHints, options.mountRules);
|
|
5720
|
+
let scopedModelNames;
|
|
5721
|
+
let scopedEnumNames;
|
|
5722
|
+
if (options.scopedServices && options.scopedServices.size > 0) {
|
|
5723
|
+
const selected = options.scopedServices;
|
|
5724
|
+
const reachable = collectReferencedNames(spec.services.filter((s) => resolvedOperations.some((r) => r.service.name === s.name && selected.has(r.mountOn))), spec.models, { preserveAllDiscriminated: false });
|
|
5725
|
+
scopedModelNames = reachable.models;
|
|
5726
|
+
scopedEnumNames = reachable.enums;
|
|
5727
|
+
}
|
|
5606
5728
|
return {
|
|
5607
5729
|
namespace: toSnakeCase(options.namespace),
|
|
5608
5730
|
namespacePascal: options.namespace,
|
|
@@ -5610,11 +5732,14 @@ function buildEmitterContext(spec, options) {
|
|
|
5610
5732
|
outputDir: options.outputDir,
|
|
5611
5733
|
apiSurface: options.apiSurface,
|
|
5612
5734
|
overlayLookup: options.overlayLookup,
|
|
5613
|
-
resolvedOperations
|
|
5735
|
+
resolvedOperations,
|
|
5614
5736
|
modelHints: options.modelHints,
|
|
5615
5737
|
emitterOptions: options.emitterOptions,
|
|
5616
5738
|
targetDir: options.target,
|
|
5617
|
-
priorTargetManifestPaths: options.priorTargetManifestPaths
|
|
5739
|
+
priorTargetManifestPaths: options.priorTargetManifestPaths,
|
|
5740
|
+
scopedServices: options.scopedServices,
|
|
5741
|
+
scopedModelNames,
|
|
5742
|
+
scopedEnumNames
|
|
5618
5743
|
};
|
|
5619
5744
|
}
|
|
5620
5745
|
/**
|
|
@@ -5622,7 +5747,8 @@ function buildEmitterContext(spec, options) {
|
|
|
5622
5747
|
* Walks operation parameters, request bodies, responses, and pagination item
|
|
5623
5748
|
* types, then chases model field references until the set stabilizes.
|
|
5624
5749
|
*/
|
|
5625
|
-
function collectReferencedNames(services, models) {
|
|
5750
|
+
function collectReferencedNames(services, models, opts = {}) {
|
|
5751
|
+
const preserveAllDiscriminated = opts.preserveAllDiscriminated !== false;
|
|
5626
5752
|
const referencedModels = /* @__PURE__ */ new Set();
|
|
5627
5753
|
const referencedEnums = /* @__PURE__ */ new Set();
|
|
5628
5754
|
const collectFromTypeRef = (ref) => {
|
|
@@ -5644,7 +5770,7 @@ function collectReferencedNames(services, models) {
|
|
|
5644
5770
|
for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
|
|
5645
5771
|
if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
|
|
5646
5772
|
}
|
|
5647
|
-
for (const model of models) {
|
|
5773
|
+
if (preserveAllDiscriminated) for (const model of models) {
|
|
5648
5774
|
if (isDiscriminatedModel(model)) referencedModels.add(model.name);
|
|
5649
5775
|
const disc = model.discriminator;
|
|
5650
5776
|
if (disc?.mapping) for (const variantName of Object.values(disc.mapping)) referencedModels.add(variantName);
|
|
@@ -5658,6 +5784,13 @@ function collectReferencedNames(services, models) {
|
|
|
5658
5784
|
visited.add(name);
|
|
5659
5785
|
const model = modelsByName.get(name);
|
|
5660
5786
|
if (!model) continue;
|
|
5787
|
+
const disc = model.discriminator;
|
|
5788
|
+
if (disc?.mapping) {
|
|
5789
|
+
for (const variantName of Object.values(disc.mapping)) if (!referencedModels.has(variantName)) {
|
|
5790
|
+
referencedModels.add(variantName);
|
|
5791
|
+
queue.push(variantName);
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5661
5794
|
for (const field of model.fields) {
|
|
5662
5795
|
collectFromTypeRef(field.type);
|
|
5663
5796
|
for (const m of referencedModels) if (!visited.has(m)) queue.push(m);
|
|
@@ -5671,7 +5804,16 @@ function collectReferencedNames(services, models) {
|
|
|
5671
5804
|
function isDiscriminatedModel(model) {
|
|
5672
5805
|
return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
|
|
5673
5806
|
}
|
|
5674
|
-
/**
|
|
5807
|
+
/**
|
|
5808
|
+
* Collect all generated files from an emitter (no headers, no path prefixes).
|
|
5809
|
+
*
|
|
5810
|
+
* In a scoped (`--services`) run the spec is NOT filtered: every generator runs
|
|
5811
|
+
* over the full spec so model placement, dedup, the root client, and all
|
|
5812
|
+
* aggregate/barrel files are byte-identical to a full run (and a brand-new
|
|
5813
|
+
* selected service is wired into the client automatically). The emitters
|
|
5814
|
+
* themselves consult `ctx.scopedServices` to emit only the selected services'
|
|
5815
|
+
* per-service resource/test files.
|
|
5816
|
+
*/
|
|
5675
5817
|
function generateAllFiles(spec, emitter, ctx) {
|
|
5676
5818
|
const referenced = collectReferencedNames(spec.services, spec.models);
|
|
5677
5819
|
const reachableModels = spec.models.filter((m) => referenced.models.has(m.name));
|
|
@@ -5712,6 +5854,47 @@ function generateFiles(spec, emitter, options) {
|
|
|
5712
5854
|
};
|
|
5713
5855
|
}
|
|
5714
5856
|
//#endregion
|
|
5857
|
+
//#region src/engine/scoped-services.ts
|
|
5858
|
+
/**
|
|
5859
|
+
* Validate a `--services` selection and return it as a set of POST-MOUNT service
|
|
5860
|
+
* names for emitters to scope on.
|
|
5861
|
+
*
|
|
5862
|
+
* Scoped generation does NOT filter the IR: model placement, dedup/aliasing, and
|
|
5863
|
+
* shared-schema decisions are global computations over the full service/model set
|
|
5864
|
+
* (filtering them churns shared `common/` files). Instead the full spec flows to
|
|
5865
|
+
* the emitters unchanged and this set is passed as `ctx.scopedServices`; each
|
|
5866
|
+
* emitter gates only its per-service resource/test emission on it.
|
|
5867
|
+
*
|
|
5868
|
+
* Matching is on the POST-MOUNT name. Mount resolution never mutates
|
|
5869
|
+
* `spec.services` — the post-mount target lives on `ResolvedOperation.mountOn`
|
|
5870
|
+
* (`mountOn = hint?.mountOn ?? mountRules[service.name] ?? service.name`) — so the
|
|
5871
|
+
* post-mount name of a source service is derived here as
|
|
5872
|
+
* `mountRules[service.name] ?? service.name`. Selecting a target (e.g.
|
|
5873
|
+
* `DirectorySync`) is valid because every source tag that mounts into it
|
|
5874
|
+
* (`DirectoryUsers`, `DirectoryGroups`, …) shares that post-mount name, and the
|
|
5875
|
+
* emitters group resources by mount, so all siblings emit together.
|
|
5876
|
+
*
|
|
5877
|
+
* @param spec - The parsed API spec (never mutated; never filtered).
|
|
5878
|
+
* @param selected - Post-mount service names from `--services` (normalized/trimmed).
|
|
5879
|
+
* @param mountRules - Source-service-name → post-mount-target mappings.
|
|
5880
|
+
* @returns The validated selection as a `Set` of post-mount names.
|
|
5881
|
+
* @throws A `ConfigError` listing valid post-mount names when a selection is
|
|
5882
|
+
* unknown — scoped generation never silently emits the wrong set.
|
|
5883
|
+
*/
|
|
5884
|
+
function resolveScopedServices(spec, selected, mountRules) {
|
|
5885
|
+
const mounts = mountRules ?? {};
|
|
5886
|
+
const postMountName = (name) => mounts[name] ?? name;
|
|
5887
|
+
const validPostMount = new Set(spec.services.map((s) => postMountName(s.name)));
|
|
5888
|
+
const selectedSet = new Set(selected);
|
|
5889
|
+
const unknown = [...selectedSet].filter((s) => !validPostMount.has(s));
|
|
5890
|
+
if (unknown.length > 0) {
|
|
5891
|
+
const err = /* @__PURE__ */ new Error(`Unknown --services: ${unknown.sort().join(", ")}. Valid post-mount services: ${[...validPostMount].sort().join(", ")}`);
|
|
5892
|
+
err.name = "ConfigError";
|
|
5893
|
+
throw err;
|
|
5894
|
+
}
|
|
5895
|
+
return selectedSet;
|
|
5896
|
+
}
|
|
5897
|
+
//#endregion
|
|
5715
5898
|
//#region src/engine/integrate.ts
|
|
5716
5899
|
/**
|
|
5717
5900
|
* Resolve a relative import path against a source file path to get the
|
|
@@ -5828,7 +6011,7 @@ function mapFilesForTargetIntegration(files, language) {
|
|
|
5828
6011
|
* that would shadow an existing module file (e.g., `api_keys.py`), move the
|
|
5829
6012
|
* module file's content into `_compat.py` inside the package and add a
|
|
5830
6013
|
* re-export from `__init__.py`. This preserves backward-compatible imports
|
|
5831
|
-
* (e.g., `from
|
|
6014
|
+
* (e.g., `from acme.api_keys import ApiKeysModule`) while the package directory
|
|
5832
6015
|
* provides the generated models and resource classes.
|
|
5833
6016
|
*/
|
|
5834
6017
|
async function resolveFileToDirectoryConflicts(files, targetDir) {
|
|
@@ -5994,6 +6177,35 @@ function computeStalePaths(prev, currentPaths) {
|
|
|
5994
6177
|
return prev.files.filter((p) => !current.has(p)).sort();
|
|
5995
6178
|
}
|
|
5996
6179
|
/**
|
|
6180
|
+
* Merge a scoped run's emitted records into the prior manifest's records.
|
|
6181
|
+
*
|
|
6182
|
+
* A scoped (`--services`) run only emits files for the selected services, but it
|
|
6183
|
+
* must NOT discard the prior manifest's record of unselected services' files
|
|
6184
|
+
* records. The result unions prior + scoped file paths and shallow-merges the
|
|
6185
|
+
* operations maps, with scoped operations overriding prior entries for the same
|
|
6186
|
+
* "METHOD /path" key (a re-emission updates that operation's record).
|
|
6187
|
+
*
|
|
6188
|
+
* When `prev` is null (the first-ever run is scoped) there is nothing to
|
|
6189
|
+
* preserve, so the scoped subset is returned unchanged — matching the
|
|
6190
|
+
* first-adoption behavior of a full run.
|
|
6191
|
+
*/
|
|
6192
|
+
function mergeScopedManifestRecords(prev, scopedFiles, scopedOperations) {
|
|
6193
|
+
const scopedFileList = [...scopedFiles];
|
|
6194
|
+
if (!prev) return {
|
|
6195
|
+
files: scopedFileList,
|
|
6196
|
+
operations: scopedOperations
|
|
6197
|
+
};
|
|
6198
|
+
const files = [...new Set([...prev.files, ...scopedFileList])].sort();
|
|
6199
|
+
const priorOps = prev.operations;
|
|
6200
|
+
return {
|
|
6201
|
+
files,
|
|
6202
|
+
operations: priorOps || scopedOperations ? {
|
|
6203
|
+
...priorOps,
|
|
6204
|
+
...scopedOperations
|
|
6205
|
+
} : void 0
|
|
6206
|
+
};
|
|
6207
|
+
}
|
|
6208
|
+
/**
|
|
5997
6209
|
* Delete stale files from `dir`.
|
|
5998
6210
|
*
|
|
5999
6211
|
* When `header` is provided, a file is only deleted if its contents start with
|
|
@@ -6062,12 +6274,17 @@ async function removeEmptyParents(startDir, stopDir) {
|
|
|
6062
6274
|
//#endregion
|
|
6063
6275
|
//#region src/engine/orchestrator.ts
|
|
6064
6276
|
async function generate(spec, emitter, options) {
|
|
6065
|
-
const
|
|
6066
|
-
const
|
|
6277
|
+
const scopedServices = options.services && options.services.length > 0 ? resolveScopedServices(spec, options.services, options.mountRules) : void 0;
|
|
6278
|
+
const scoped = scopedServices !== void 0;
|
|
6279
|
+
const noPrune = options.noPrune === true || scoped;
|
|
6280
|
+
const skipManifestRead = options.noPrune === true && !scoped;
|
|
6281
|
+
const outputPrevManifestForCtx = skipManifestRead ? null : await readManifest(options.outputDir);
|
|
6282
|
+
const targetManifestForCtx = options.target && !skipManifestRead ? await readManifest(options.target) : null;
|
|
6067
6283
|
const priorTargetManifestPaths = targetManifestForCtx ? new Set(targetManifestForCtx.files) : outputPrevManifestForCtx ? new Set(outputPrevManifestForCtx.files) : void 0;
|
|
6068
6284
|
const { files: withHeaders, header, operations } = generateFiles(spec, emitter, {
|
|
6069
6285
|
...options,
|
|
6070
|
-
priorTargetManifestPaths
|
|
6286
|
+
priorTargetManifestPaths,
|
|
6287
|
+
scopedServices
|
|
6071
6288
|
});
|
|
6072
6289
|
if (options.dryRun) {
|
|
6073
6290
|
if (options.target) {
|
|
@@ -6091,8 +6308,9 @@ async function generate(spec, emitter, options) {
|
|
|
6091
6308
|
currentPaths: outputEmittedPaths,
|
|
6092
6309
|
language: emitter.language,
|
|
6093
6310
|
header,
|
|
6094
|
-
noPrune
|
|
6095
|
-
operations
|
|
6311
|
+
noPrune,
|
|
6312
|
+
operations,
|
|
6313
|
+
scoped
|
|
6096
6314
|
});
|
|
6097
6315
|
const allOutputFiles = [...writeResult.written, ...writeResult.merged];
|
|
6098
6316
|
if (allOutputFiles.length > 0) await formatTargetFiles(emitter, options.outputDir, allOutputFiles);
|
|
@@ -6114,8 +6332,9 @@ async function generate(spec, emitter, options) {
|
|
|
6114
6332
|
currentPaths: targetResult.emittedPaths,
|
|
6115
6333
|
language: emitter.language,
|
|
6116
6334
|
header,
|
|
6117
|
-
noPrune
|
|
6118
|
-
operations
|
|
6335
|
+
noPrune,
|
|
6336
|
+
operations,
|
|
6337
|
+
scoped
|
|
6119
6338
|
});
|
|
6120
6339
|
const allTargetFiles = [
|
|
6121
6340
|
...targetResult.written,
|
|
@@ -6131,11 +6350,15 @@ async function generate(spec, emitter, options) {
|
|
|
6131
6350
|
* then write the fresh manifest. Safe on first adoption (no previous manifest).
|
|
6132
6351
|
*/
|
|
6133
6352
|
async function applyManifestPrune(opts) {
|
|
6134
|
-
const
|
|
6135
|
-
language: opts.language,
|
|
6353
|
+
const records = opts.scoped ? mergeScopedManifestRecords(opts.prevManifest, opts.currentPaths, opts.operations) : {
|
|
6136
6354
|
files: opts.currentPaths,
|
|
6137
6355
|
operations: opts.operations
|
|
6138
6356
|
};
|
|
6357
|
+
const manifestOpts = {
|
|
6358
|
+
language: opts.language,
|
|
6359
|
+
files: records.files,
|
|
6360
|
+
operations: records.operations
|
|
6361
|
+
};
|
|
6139
6362
|
if (opts.noPrune) {
|
|
6140
6363
|
await writeManifest(opts.dir, manifestOpts);
|
|
6141
6364
|
return;
|
|
@@ -6775,7 +6998,7 @@ function loadOverlayContext(opts) {
|
|
|
6775
6998
|
//#region src/utils/expand-doc-urls.ts
|
|
6776
6999
|
/**
|
|
6777
7000
|
* Walk all description fields in the IR and expand relative URL paths
|
|
6778
|
-
* (e.g. `/reference/
|
|
7001
|
+
* (e.g. `/reference/users/user`) into full URLs using the given base.
|
|
6779
7002
|
*
|
|
6780
7003
|
* Matches markdown-style links: `[text](/path)` → `[text](https://example.com/path)`
|
|
6781
7004
|
* and bare parenthetical refs: `(/path)` → `(https://example.com/path)`
|
|
@@ -6832,11 +7055,13 @@ async function generateCommand(opts) {
|
|
|
6832
7055
|
let ir = await parseSpec(opts.spec, {
|
|
6833
7056
|
operationIdTransform: opts.operationIdTransform,
|
|
6834
7057
|
schemaNameTransform: opts.schemaNameTransform,
|
|
6835
|
-
transformSpec: opts.transformSpec
|
|
7058
|
+
transformSpec: opts.transformSpec,
|
|
7059
|
+
fieldHints: opts.fieldHints
|
|
6836
7060
|
});
|
|
6837
7061
|
if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
|
|
6838
7062
|
const emitter = getEmitter(opts.lang);
|
|
6839
7063
|
const namespace = opts.namespace ?? ir.name;
|
|
7064
|
+
const services = (Array.isArray(opts.services) ? opts.services : opts.services?.split(","))?.map((s) => s.trim()).filter(Boolean);
|
|
6840
7065
|
let apiSurface;
|
|
6841
7066
|
let overlayLookup;
|
|
6842
7067
|
if (opts.apiSurface && opts.compatCheck !== false) {
|
|
@@ -6860,7 +7085,8 @@ async function generateCommand(opts) {
|
|
|
6860
7085
|
mountRules: opts.mountRules,
|
|
6861
7086
|
modelHints: opts.modelHints,
|
|
6862
7087
|
emitterOptions: opts.emitterOptions,
|
|
6863
|
-
noPrune: opts.prune === false
|
|
7088
|
+
noPrune: opts.prune === false,
|
|
7089
|
+
services: services && services.length > 0 ? services : void 0
|
|
6864
7090
|
});
|
|
6865
7091
|
if (opts.dryRun) for (const f of files) console.log(f.path);
|
|
6866
7092
|
else {
|
|
@@ -8123,7 +8349,7 @@ function specDerivedFieldPaths(spec, hints) {
|
|
|
8123
8349
|
return paths;
|
|
8124
8350
|
}
|
|
8125
8351
|
/**
|
|
8126
|
-
* Compute the set of method paths (e.g., "
|
|
8352
|
+
* Compute the set of method paths (e.g., "Auth.authorize") that are defined
|
|
8127
8353
|
* as operations in the OpenAPI spec's services. Used by filterSurface to
|
|
8128
8354
|
* exclude hand-written SDK methods that don't correspond to spec operations.
|
|
8129
8355
|
*/
|
|
@@ -8159,7 +8385,7 @@ function filterRecord(record, allowed) {
|
|
|
8159
8385
|
*
|
|
8160
8386
|
* For classes: when methodPaths is provided, only keeps methods whose names
|
|
8161
8387
|
* appear in the spec-derived method paths. This prevents false positives from
|
|
8162
|
-
* hand-written SDK methods (e.g.,
|
|
8388
|
+
* hand-written SDK methods (e.g., Auth.authorization_url, Webhooks.construct_event)
|
|
8163
8389
|
* that don't correspond to spec operations. Properties on service classes are
|
|
8164
8390
|
* also filtered: only UPPER_CASE constants that are spec-derivable are kept.
|
|
8165
8391
|
*/
|
|
@@ -9802,6 +10028,7 @@ let configDocUrl;
|
|
|
9802
10028
|
let configOperationHints;
|
|
9803
10029
|
let configMountRules;
|
|
9804
10030
|
let configModelHints;
|
|
10031
|
+
let configFieldHints;
|
|
9805
10032
|
let configEmitterOptions;
|
|
9806
10033
|
let configCompat;
|
|
9807
10034
|
try {
|
|
@@ -9816,6 +10043,7 @@ try {
|
|
|
9816
10043
|
configOperationHints = config.operationHints;
|
|
9817
10044
|
configMountRules = config.mountRules;
|
|
9818
10045
|
configModelHints = config.modelHints;
|
|
10046
|
+
configFieldHints = config.fieldHints;
|
|
9819
10047
|
configEmitterOptions = config.emitterOptions;
|
|
9820
10048
|
configCompat = config.compat;
|
|
9821
10049
|
}
|
|
@@ -9836,7 +10064,7 @@ program.command("parse").description("Parse an OpenAPI spec and output IR as JSO
|
|
|
9836
10064
|
transformSpec: configTransformSpec
|
|
9837
10065
|
});
|
|
9838
10066
|
});
|
|
9839
|
-
program.command("generate").description("Run a registered emitter against an OpenAPI spec").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Output directory").option("--target <dir>", "Target directory for live SDK integration (merged output)").option("--namespace <name>", "SDK namespace/package name").option("--dry-run", "Preview files without writing").option("--api-surface <path>", "Path to baseline API surface JSON for compat overlay").option("--no-compat-check", "Skip compat overlay even if --api-surface is provided").option("--no-prune", "Skip deletion of stale files recorded in the previous .oagen-manifest.json").action((opts) => {
|
|
10067
|
+
program.command("generate").description("Run a registered emitter against an OpenAPI spec").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Output directory").option("--target <dir>", "Target directory for live SDK integration (merged output)").option("--namespace <name>", "SDK namespace/package name").option("--dry-run", "Preview files without writing").option("--api-surface <path>", "Path to baseline API surface JSON for compat overlay").option("--no-compat-check", "Skip compat overlay even if --api-surface is provided").option("--no-prune", "Skip deletion of stale files recorded in the previous .oagen-manifest.json").option("--services <list>", "Comma-separated post-mount service names to generate (default: all)").action((opts) => {
|
|
9840
10068
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
9841
10069
|
if (!opts.spec) {
|
|
9842
10070
|
console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
|
|
@@ -9851,6 +10079,7 @@ program.command("generate").description("Run a registered emitter against an Ope
|
|
|
9851
10079
|
operationHints: configOperationHints,
|
|
9852
10080
|
mountRules: configMountRules,
|
|
9853
10081
|
modelHints: configModelHints,
|
|
10082
|
+
fieldHints: configFieldHints,
|
|
9854
10083
|
emitterOptions: configEmitterOptions?.[opts.lang]
|
|
9855
10084
|
}).catch(handleError);
|
|
9856
10085
|
});
|