@workos/oagen 0.7.0 → 0.9.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/dist/cli/index.mjs +1190 -512
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +144 -57
- package/dist/compat/index.d.mts.map +1 -1
- package/dist/compat/index.mjs +362 -41
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-BYadQQV7.mjs → diff-DZvzV9p3.mjs} +31 -22
- package/dist/{diff-BYadQQV7.mjs.map → diff-DZvzV9p3.mjs.map} +1 -1
- package/dist/differ-CTl98yoS.d.mts +116 -0
- package/dist/differ-CTl98yoS.d.mts.map +1 -0
- package/dist/index.d.mts +13 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +50 -10
- package/dist/index.mjs.map +1 -1
- package/dist/{differ-CG5ZErKl.mjs → overlay-Bt6oz0dg.mjs} +1112 -553
- package/dist/overlay-Bt6oz0dg.mjs.map +1 -0
- package/dist/scripts/smoke/baseline.mjs +49 -9
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +10 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +49 -9
- package/dist/smoke.mjs.map +1 -1
- package/dist/tree-sitter-C53w_0WE.mjs.map +1 -1
- package/dist/{types-DJMbCtIO.d.mts → types-mTtkgy_h.d.mts} +180 -2
- package/dist/types-mTtkgy_h.d.mts.map +1 -0
- package/dist/verify/index.d.mts +4 -3
- package/dist/verify/index.d.mts.map +1 -1
- package/dist/verify/index.mjs +56 -39
- package/dist/verify/index.mjs.map +1 -1
- package/docs/architecture/emitter-contract.md +2 -2
- package/docs/architecture/extractor-contract.md +6 -5
- package/docs/architecture/pipeline.md +4 -3
- package/docs/architecture/workflows.md +5 -3
- package/docs/cli.md +148 -6
- package/docs/contributor/public-api.md +1 -1
- package/docs/core/compatibility-ir.md +200 -0
- package/docs/core/compatibility-policy.md +170 -0
- package/docs/core/compatibility.md +83 -0
- package/docs/core/index.md +5 -0
- package/docs/core/manifest-schema.md +76 -0
- package/docs/core/quickstart.md +9 -2
- package/docs/core/verify-compat.md +235 -0
- package/package.json +1 -1
- package/skills/generate-emitter/SKILL.md +12 -7
- package/skills/generate-emitter/references/project-scaffold.md +27 -6
- package/skills/generate-extractor/SKILL.md +14 -8
- package/skills/generate-sdk/SKILL.md +2 -2
- package/skills/generate-smoke-test/SKILL.md +9 -5
- package/skills/review-operations/SKILL.md +2 -2
- package/skills/verify-compat/SKILL.md +1 -1
- package/dist/differ-CG5ZErKl.mjs.map +0 -1
- package/dist/types-DJMbCtIO.d.mts.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import Parser from "tree-sitter";
|
|
|
11
11
|
import { execFile, execFileSync } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
14
15
|
//#region src/ir/sdk-behavior.ts
|
|
15
16
|
/**
|
|
16
17
|
* Canonical SDK behavior defaults. These match the PHP emitter's current
|
|
@@ -822,6 +823,7 @@ function extractModel(name, schema, schemas) {
|
|
|
822
823
|
function extractAllOfModel(name, schema, schemas) {
|
|
823
824
|
const fields = [];
|
|
824
825
|
const requiredSet = /* @__PURE__ */ new Set();
|
|
826
|
+
let resultDiscriminator;
|
|
825
827
|
for (const subSchema of schema.allOf ?? []) {
|
|
826
828
|
let resolved = subSchema;
|
|
827
829
|
if (subSchema.$ref && schemas) {
|
|
@@ -840,14 +842,18 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
840
842
|
}
|
|
841
843
|
} else if (resolved.oneOf || resolved.anyOf) {
|
|
842
844
|
const variants = resolved.oneOf ?? resolved.anyOf ?? [];
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
845
|
+
const discriminatorInfo = detectAllOfVariantDiscriminator(variants, name);
|
|
846
|
+
if (discriminatorInfo) resultDiscriminator = discriminatorInfo;
|
|
847
|
+
else {
|
|
848
|
+
const emptyRequired = /* @__PURE__ */ new Set();
|
|
849
|
+
const seenFieldNames = new Set(fields.map((f) => f.name));
|
|
850
|
+
for (const variant of variants) {
|
|
851
|
+
if (!variant.properties) continue;
|
|
852
|
+
for (const [fieldName, fieldSchema] of Object.entries(variant.properties)) {
|
|
853
|
+
if (!fieldSchema || seenFieldNames.has(fieldName)) continue;
|
|
854
|
+
seenFieldNames.add(fieldName);
|
|
855
|
+
fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, emptyRequired));
|
|
856
|
+
}
|
|
851
857
|
}
|
|
852
858
|
}
|
|
853
859
|
} else {
|
|
@@ -866,7 +872,42 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
866
872
|
return {
|
|
867
873
|
name,
|
|
868
874
|
description: schema.description,
|
|
869
|
-
fields
|
|
875
|
+
fields,
|
|
876
|
+
...resultDiscriminator ? { discriminator: resultDiscriminator } : {}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Detect an implicit discriminator on an allOf+oneOf where every variant is an
|
|
881
|
+
* object that pins the same property to a distinct const value. Unlike
|
|
882
|
+
* detectConstPropertyDiscriminator (which requires $ref or title for variant
|
|
883
|
+
* model names), this version derives model names from the const value itself
|
|
884
|
+
* using deriveDiscriminatedVariantName — matching how extractDiscriminatedAllOfVariantModels
|
|
885
|
+
* names those models.
|
|
886
|
+
*
|
|
887
|
+
* Returns null when no discriminator can be reliably detected.
|
|
888
|
+
*/
|
|
889
|
+
function detectAllOfVariantDiscriminator(variants, fallbackName) {
|
|
890
|
+
if (variants.length < 2) return null;
|
|
891
|
+
const candidates = Object.keys(variants[0]?.properties ?? {});
|
|
892
|
+
let candidateProperty = null;
|
|
893
|
+
for (const propName of candidates) if (variants.every((v) => getConstPropertyValue(v, propName) !== null)) {
|
|
894
|
+
candidateProperty = propName;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
if (!candidateProperty) return null;
|
|
898
|
+
const mapping = {};
|
|
899
|
+
const seenModelNames = /* @__PURE__ */ new Set();
|
|
900
|
+
for (const v of variants) {
|
|
901
|
+
const value = getConstPropertyValue(v, candidateProperty);
|
|
902
|
+
if (value === null) return null;
|
|
903
|
+
const modelName = deriveDiscriminatedVariantName(v, fallbackName);
|
|
904
|
+
if (seenModelNames.has(modelName)) return null;
|
|
905
|
+
seenModelNames.add(modelName);
|
|
906
|
+
mapping[value] = modelName;
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
property: candidateProperty,
|
|
910
|
+
mapping
|
|
870
911
|
};
|
|
871
912
|
}
|
|
872
913
|
function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
|
|
@@ -4730,8 +4771,7 @@ function generateAllFiles(spec, emitter, ctx) {
|
|
|
4730
4771
|
...emitter.generateClient(spec, ctx),
|
|
4731
4772
|
...emitter.generateErrors(ctx),
|
|
4732
4773
|
...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
|
|
4733
|
-
...emitter.generateTests(reachableSpec, ctx)
|
|
4734
|
-
...emitter.generateManifest?.(spec, ctx) ?? []
|
|
4774
|
+
...emitter.generateTests(reachableSpec, ctx)
|
|
4735
4775
|
];
|
|
4736
4776
|
}
|
|
4737
4777
|
/** Apply file header to generated files, respecting headerPlacement and JSON files. */
|
|
@@ -4745,11 +4785,13 @@ function applyFileHeaders(files, header) {
|
|
|
4745
4785
|
function generateFiles(spec, emitter, options) {
|
|
4746
4786
|
const ctx = buildEmitterContext(spec, options);
|
|
4747
4787
|
const files = generateAllFiles(spec, emitter, ctx);
|
|
4788
|
+
const operations = emitter.buildOperationsMap?.(spec, ctx);
|
|
4748
4789
|
const header = emitter.fileHeader();
|
|
4749
4790
|
return {
|
|
4750
4791
|
files: applyFileHeaders(files, header),
|
|
4751
4792
|
ctx,
|
|
4752
|
-
header
|
|
4793
|
+
header,
|
|
4794
|
+
operations
|
|
4753
4795
|
};
|
|
4754
4796
|
}
|
|
4755
4797
|
//#endregion
|
|
@@ -4980,7 +5022,7 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
|
4980
5022
|
* one-line notice is printed — no surprise deletions.
|
|
4981
5023
|
*/
|
|
4982
5024
|
const MANIFEST_FILENAME = ".oagen-manifest.json";
|
|
4983
|
-
const MANIFEST_VERSION =
|
|
5025
|
+
const MANIFEST_VERSION = 2;
|
|
4984
5026
|
/** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
|
|
4985
5027
|
async function readManifest(dir) {
|
|
4986
5028
|
const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
|
|
@@ -5007,8 +5049,16 @@ async function writeManifest(dir, opts) {
|
|
|
5007
5049
|
const manifest = {
|
|
5008
5050
|
version: MANIFEST_VERSION,
|
|
5009
5051
|
language: opts.language,
|
|
5052
|
+
...opts.sdkName !== void 0 ? { sdkName: opts.sdkName } : {},
|
|
5010
5053
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5011
|
-
|
|
5054
|
+
...opts.specSha !== void 0 ? { specSha: opts.specSha } : {},
|
|
5055
|
+
...opts.specPath !== void 0 ? { specPath: opts.specPath } : {},
|
|
5056
|
+
...opts.emitterSha !== void 0 ? { emitterSha: opts.emitterSha } : {},
|
|
5057
|
+
...opts.emitterVersion !== void 0 ? { emitterVersion: opts.emitterVersion } : {},
|
|
5058
|
+
...opts.configSha !== void 0 ? { configSha: opts.configSha } : {},
|
|
5059
|
+
...opts.compatSchemaVersion !== void 0 ? { compatSchemaVersion: opts.compatSchemaVersion } : {},
|
|
5060
|
+
files: [...new Set(opts.files)].sort(),
|
|
5061
|
+
...opts.operations !== void 0 ? { operations: opts.operations } : {}
|
|
5012
5062
|
};
|
|
5013
5063
|
const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
|
|
5014
5064
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -5090,7 +5140,7 @@ async function removeEmptyParents(startDir, stopDir) {
|
|
|
5090
5140
|
async function generate(spec, emitter, options) {
|
|
5091
5141
|
const targetManifestForCtx = options.target && !options.noPrune ? await readManifest(options.target) : null;
|
|
5092
5142
|
const priorTargetManifestPaths = targetManifestForCtx ? new Set(targetManifestForCtx.files) : void 0;
|
|
5093
|
-
const { files: withHeaders, header } = generateFiles(spec, emitter, {
|
|
5143
|
+
const { files: withHeaders, header, operations } = generateFiles(spec, emitter, {
|
|
5094
5144
|
...options,
|
|
5095
5145
|
priorTargetManifestPaths
|
|
5096
5146
|
});
|
|
@@ -5116,8 +5166,11 @@ async function generate(spec, emitter, options) {
|
|
|
5116
5166
|
currentPaths: outputEmittedPaths,
|
|
5117
5167
|
language: emitter.language,
|
|
5118
5168
|
header,
|
|
5119
|
-
noPrune: options.noPrune
|
|
5169
|
+
noPrune: options.noPrune,
|
|
5170
|
+
operations
|
|
5120
5171
|
});
|
|
5172
|
+
const allOutputFiles = [...writeResult.written, ...writeResult.merged];
|
|
5173
|
+
if (allOutputFiles.length > 0) await formatTargetFiles(emitter, options.outputDir, allOutputFiles);
|
|
5121
5174
|
if (options.target) {
|
|
5122
5175
|
const targetPrevManifest = targetManifestForCtx;
|
|
5123
5176
|
const targetResult = await integrateGeneratedFiles({
|
|
@@ -5136,7 +5189,8 @@ async function generate(spec, emitter, options) {
|
|
|
5136
5189
|
currentPaths: targetResult.emittedPaths,
|
|
5137
5190
|
language: emitter.language,
|
|
5138
5191
|
header,
|
|
5139
|
-
noPrune: options.noPrune
|
|
5192
|
+
noPrune: options.noPrune,
|
|
5193
|
+
operations
|
|
5140
5194
|
});
|
|
5141
5195
|
const allTargetFiles = [
|
|
5142
5196
|
...targetResult.written,
|
|
@@ -5152,19 +5206,18 @@ async function generate(spec, emitter, options) {
|
|
|
5152
5206
|
* then write the fresh manifest. Safe on first adoption (no previous manifest).
|
|
5153
5207
|
*/
|
|
5154
5208
|
async function applyManifestPrune(opts) {
|
|
5209
|
+
const manifestOpts = {
|
|
5210
|
+
language: opts.language,
|
|
5211
|
+
files: opts.currentPaths,
|
|
5212
|
+
operations: opts.operations
|
|
5213
|
+
};
|
|
5155
5214
|
if (opts.noPrune) {
|
|
5156
|
-
await writeManifest(opts.dir,
|
|
5157
|
-
language: opts.language,
|
|
5158
|
-
files: opts.currentPaths
|
|
5159
|
-
});
|
|
5215
|
+
await writeManifest(opts.dir, manifestOpts);
|
|
5160
5216
|
return;
|
|
5161
5217
|
}
|
|
5162
5218
|
if (!opts.prevManifest) {
|
|
5163
5219
|
console.log(`${opts.label}: no ${MANIFEST_FILENAME} found — skipping prune (this baseline manifest will be written now).`);
|
|
5164
|
-
await writeManifest(opts.dir,
|
|
5165
|
-
language: opts.language,
|
|
5166
|
-
files: opts.currentPaths
|
|
5167
|
-
});
|
|
5220
|
+
await writeManifest(opts.dir, manifestOpts);
|
|
5168
5221
|
return;
|
|
5169
5222
|
}
|
|
5170
5223
|
const stale = computeStalePaths(opts.prevManifest, opts.currentPaths);
|
|
@@ -5176,10 +5229,7 @@ async function applyManifestPrune(opts) {
|
|
|
5176
5229
|
for (const p of preserved) console.log(` ${p}`);
|
|
5177
5230
|
}
|
|
5178
5231
|
}
|
|
5179
|
-
await writeManifest(opts.dir,
|
|
5180
|
-
language: opts.language,
|
|
5181
|
-
files: opts.currentPaths
|
|
5182
|
-
});
|
|
5232
|
+
await writeManifest(opts.dir, manifestOpts);
|
|
5183
5233
|
}
|
|
5184
5234
|
//#endregion
|
|
5185
5235
|
//#region src/engine/registry.ts
|
|
@@ -5659,12 +5709,18 @@ function buildModelNameMap(surface, spec, lookup, hints, options) {
|
|
|
5659
5709
|
}
|
|
5660
5710
|
}
|
|
5661
5711
|
}
|
|
5712
|
+
/** Categories that can be patched by the overlay retry loop. */
|
|
5713
|
+
const PATCHABLE_CATEGORIES = new Set(["symbol_removed", "symbol_renamed"]);
|
|
5714
|
+
/** Check if a classified change is patchable by the overlay loop. */
|
|
5715
|
+
function isPatchableChange(change) {
|
|
5716
|
+
return PATCHABLE_CATEGORIES.has(change.category);
|
|
5717
|
+
}
|
|
5662
5718
|
/**
|
|
5663
|
-
* Patch overlay with
|
|
5719
|
+
* Patch overlay with classified changes from a failed verification.
|
|
5664
5720
|
* Adds explicit name mappings for symbols that were generated with wrong names.
|
|
5665
5721
|
* Returns a new OverlayLookup (immutable).
|
|
5666
5722
|
*/
|
|
5667
|
-
function patchOverlay(overlay,
|
|
5723
|
+
function patchOverlay(overlay, changes, baseline) {
|
|
5668
5724
|
const patched = {
|
|
5669
5725
|
methodByOperation: new Map(overlay.methodByOperation),
|
|
5670
5726
|
httpKeyByMethod: new Map(overlay.httpKeyByMethod),
|
|
@@ -5675,42 +5731,33 @@ function patchOverlay(overlay, violations, baseline) {
|
|
|
5675
5731
|
fileBySymbol: new Map(overlay.fileBySymbol)
|
|
5676
5732
|
};
|
|
5677
5733
|
let warnedManifest = false;
|
|
5678
|
-
for (const
|
|
5679
|
-
if (
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
}
|
|
5734
|
+
for (const c of changes) {
|
|
5735
|
+
if (!isPatchableChange(c)) continue;
|
|
5736
|
+
const parts = c.symbol.split(".");
|
|
5737
|
+
if (parts.length === 2) {
|
|
5738
|
+
const [className, methodName] = parts;
|
|
5739
|
+
const baseClass = baseline.classes[className];
|
|
5740
|
+
if (baseClass && baseClass.methods[methodName]?.length > 0) {
|
|
5741
|
+
const httpKey = overlay.httpKeyByMethod.get(`${className}.${methodName}`);
|
|
5742
|
+
if (!httpKey && overlay.httpKeyByMethod.size === 0 && !warnedManifest) {
|
|
5743
|
+
console.warn("Warning: No operations map in .oagen-manifest.json. Method-level violations cannot be auto-patched. Implement buildOperationsMap in your emitter for the self-correcting loop to resolve these.");
|
|
5744
|
+
warnedManifest = true;
|
|
5745
|
+
}
|
|
5746
|
+
if (httpKey) {
|
|
5747
|
+
const method = baseClass.methods[methodName][0];
|
|
5748
|
+
patched.methodByOperation.set(httpKey, {
|
|
5749
|
+
className,
|
|
5750
|
+
methodName,
|
|
5751
|
+
params: method.params,
|
|
5752
|
+
returnType: method.returnType
|
|
5753
|
+
});
|
|
5699
5754
|
}
|
|
5700
|
-
if (baseline.interfaces[className]) patched.interfaceByName.set(className, className);
|
|
5701
|
-
} else if (parts.length === 1) {
|
|
5702
|
-
const name = parts[0];
|
|
5703
|
-
if (baseline.interfaces[name]) patched.interfaceByName.set(name, name);
|
|
5704
|
-
if (baseline.typeAliases[name]) patched.typeAliasByName.set(name, name);
|
|
5705
|
-
}
|
|
5706
|
-
}
|
|
5707
|
-
if (v.category === "export-structure") {
|
|
5708
|
-
const match = v.symbolPath.match(/^exports\[(.+?)\]\.(.+)$/);
|
|
5709
|
-
if (match) {
|
|
5710
|
-
const [, path, symbol] = match;
|
|
5711
|
-
if (!patched.requiredExports.has(path)) patched.requiredExports.set(path, /* @__PURE__ */ new Set());
|
|
5712
|
-
patched.requiredExports.get(path).add(symbol);
|
|
5713
5755
|
}
|
|
5756
|
+
if (baseline.interfaces[className]) patched.interfaceByName.set(className, className);
|
|
5757
|
+
} else if (parts.length === 1) {
|
|
5758
|
+
const name = parts[0];
|
|
5759
|
+
if (baseline.interfaces[name]) patched.interfaceByName.set(name, name);
|
|
5760
|
+
if (baseline.typeAliases[name]) patched.typeAliasByName.set(name, name);
|
|
5714
5761
|
}
|
|
5715
5762
|
}
|
|
5716
5763
|
return patched;
|
|
@@ -5727,6 +5774,49 @@ function getExtractor(language) {
|
|
|
5727
5774
|
return extractor;
|
|
5728
5775
|
}
|
|
5729
5776
|
//#endregion
|
|
5777
|
+
//#region src/engine/manifest-reader.ts
|
|
5778
|
+
/**
|
|
5779
|
+
* Read the operations map from `.oagen-manifest.json` in the given directory
|
|
5780
|
+
* and convert it to `ManifestEntry[]` for the overlay builder.
|
|
5781
|
+
*
|
|
5782
|
+
* Returns undefined if the manifest is absent or has no operations field.
|
|
5783
|
+
*/
|
|
5784
|
+
function readManifestSync(dir) {
|
|
5785
|
+
const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
|
|
5786
|
+
if (!existsSync(manifestPath)) return void 0;
|
|
5787
|
+
try {
|
|
5788
|
+
const operations = JSON.parse(readFileSync(manifestPath, "utf-8"))?.operations;
|
|
5789
|
+
if (!operations || typeof operations !== "object") return void 0;
|
|
5790
|
+
return convertOperationsToManifestEntries(operations);
|
|
5791
|
+
} catch {
|
|
5792
|
+
return;
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
/** Convert the operations map from .oagen-manifest.json into ManifestEntry[]. */
|
|
5796
|
+
function convertOperationsToManifestEntries(operations) {
|
|
5797
|
+
const entries = [];
|
|
5798
|
+
for (const [httpKey, value] of Object.entries(operations)) {
|
|
5799
|
+
const spaceIdx = httpKey.indexOf(" ");
|
|
5800
|
+
const httpMethod = httpKey.slice(0, spaceIdx);
|
|
5801
|
+
const httpPath = httpKey.slice(spaceIdx + 1);
|
|
5802
|
+
const values = Array.isArray(value) ? value : [value];
|
|
5803
|
+
for (const v of values) {
|
|
5804
|
+
const entry = v;
|
|
5805
|
+
entries.push({
|
|
5806
|
+
operationId: "",
|
|
5807
|
+
sdkResourceProperty: entry.service ?? "",
|
|
5808
|
+
sdkMethodName: entry.sdkMethod ?? "",
|
|
5809
|
+
httpMethod,
|
|
5810
|
+
path: httpPath,
|
|
5811
|
+
pathParams: [],
|
|
5812
|
+
bodyFields: [],
|
|
5813
|
+
queryFields: []
|
|
5814
|
+
});
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
return entries;
|
|
5818
|
+
}
|
|
5819
|
+
//#endregion
|
|
5730
5820
|
//#region src/cli/overlay-loader.ts
|
|
5731
5821
|
/**
|
|
5732
5822
|
* Load an API surface from disk, discover/load the manifest, resolve
|
|
@@ -5745,30 +5835,7 @@ function loadOverlayContext(opts) {
|
|
|
5745
5835
|
} catch (err) {
|
|
5746
5836
|
throw new ConfigError(`Failed to parse API surface JSON: ${err instanceof Error ? err.message : String(err)}`, `Ensure "${opts.apiSurfacePath}" contains valid JSON. Re-run \`oagen extract\` to regenerate it.`);
|
|
5747
5837
|
}
|
|
5748
|
-
|
|
5749
|
-
const resolvedManifestPath = opts.manifestPath ?? path$1.join(opts.outputDir, "smoke-manifest.json");
|
|
5750
|
-
if (opts.manifestPath || existsSync(resolvedManifestPath)) try {
|
|
5751
|
-
const parsed = JSON.parse(readFileSync(resolvedManifestPath, "utf-8"));
|
|
5752
|
-
if (Array.isArray(parsed)) manifest = parsed;
|
|
5753
|
-
else if (typeof parsed === "object" && parsed !== null) manifest = Object.entries(parsed).map(([httpKey, value]) => {
|
|
5754
|
-
const spaceIdx = httpKey.indexOf(" ");
|
|
5755
|
-
const httpMethod = httpKey.slice(0, spaceIdx);
|
|
5756
|
-
const httpPath = httpKey.slice(spaceIdx + 1);
|
|
5757
|
-
const v = value;
|
|
5758
|
-
return {
|
|
5759
|
-
operationId: "",
|
|
5760
|
-
sdkResourceProperty: v.service ?? "",
|
|
5761
|
-
sdkMethodName: v.sdkMethod ?? "",
|
|
5762
|
-
httpMethod,
|
|
5763
|
-
path: httpPath,
|
|
5764
|
-
pathParams: [],
|
|
5765
|
-
bodyFields: [],
|
|
5766
|
-
queryFields: []
|
|
5767
|
-
};
|
|
5768
|
-
});
|
|
5769
|
-
} catch {
|
|
5770
|
-
if (opts.manifestPath) throw new ConfigError(`Failed to read manifest: ${resolvedManifestPath}`, `Verify the manifest file exists and contains valid JSON. If using --manifest, check the path is correct.`);
|
|
5771
|
-
}
|
|
5838
|
+
const manifest = readManifestSync(opts.outputDir);
|
|
5772
5839
|
let hints = nodeHints;
|
|
5773
5840
|
try {
|
|
5774
5841
|
hints = getExtractor(opts.lang).hints;
|
|
@@ -5849,7 +5916,6 @@ async function generateCommand(opts) {
|
|
|
5849
5916
|
if (opts.apiSurface && opts.compatCheck !== false) {
|
|
5850
5917
|
const ctx = loadOverlayContext({
|
|
5851
5918
|
apiSurfacePath: opts.apiSurface,
|
|
5852
|
-
manifestPath: opts.manifest,
|
|
5853
5919
|
outputDir: opts.output,
|
|
5854
5920
|
lang: opts.lang,
|
|
5855
5921
|
spec: ir
|
|
@@ -6247,6 +6313,327 @@ async function extractCommand(opts) {
|
|
|
6247
6313
|
console.log(`Extracted ${symbolCount} symbols → ${opts.output}`);
|
|
6248
6314
|
}
|
|
6249
6315
|
//#endregion
|
|
6316
|
+
//#region src/cli/compat-extract.ts
|
|
6317
|
+
const SNAPSHOT_FILENAME = ".oagen-compat-snapshot.json";
|
|
6318
|
+
async function compatExtractCommand(opts) {
|
|
6319
|
+
const extractor = getExtractor(opts.lang);
|
|
6320
|
+
console.log(`Extracting ${opts.lang} compat snapshot from ${opts.sdkPath}...`);
|
|
6321
|
+
const snapshot = await extractor.extractSnapshot(opts.sdkPath);
|
|
6322
|
+
if (opts.spec) {
|
|
6323
|
+
const specContent = readFileSync(opts.spec, "utf-8");
|
|
6324
|
+
snapshot.source.specSha = createHash("sha256").update(specContent).digest("hex");
|
|
6325
|
+
enrichWithSpecContext(snapshot, await parseSpec(opts.spec));
|
|
6326
|
+
}
|
|
6327
|
+
const outputPath = resolve(opts.output, SNAPSHOT_FILENAME);
|
|
6328
|
+
writeFileSync(outputPath, JSON.stringify(snapshot, null, 2));
|
|
6329
|
+
console.log(`Extracted ${snapshot.symbols.length} symbols → ${outputPath}`);
|
|
6330
|
+
}
|
|
6331
|
+
/**
|
|
6332
|
+
* Enrich snapshot symbols with operationId and route from the parsed spec.
|
|
6333
|
+
*
|
|
6334
|
+
* Matches callable symbols to spec operations by comparing method names
|
|
6335
|
+
* derived from the spec against the symbol's fqName.
|
|
6336
|
+
*/
|
|
6337
|
+
function enrichWithSpecContext(snapshot, spec) {
|
|
6338
|
+
const opLookup = /* @__PURE__ */ new Map();
|
|
6339
|
+
for (const service of spec.services) for (const op of service.operations) {
|
|
6340
|
+
const key = `${service.name}.${op.name}`;
|
|
6341
|
+
opLookup.set(key, {
|
|
6342
|
+
operationId: op.name,
|
|
6343
|
+
method: op.httpMethod,
|
|
6344
|
+
path: op.path
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
for (const sym of snapshot.symbols) {
|
|
6348
|
+
if (sym.kind !== "callable") continue;
|
|
6349
|
+
const match = opLookup.get(sym.fqName);
|
|
6350
|
+
if (match) {
|
|
6351
|
+
sym.operationId = match.operationId;
|
|
6352
|
+
sym.route = {
|
|
6353
|
+
method: match.method,
|
|
6354
|
+
path: match.path
|
|
6355
|
+
};
|
|
6356
|
+
}
|
|
6357
|
+
}
|
|
6358
|
+
}
|
|
6359
|
+
//#endregion
|
|
6360
|
+
//#region src/compat/schema.ts
|
|
6361
|
+
/** Validate the basic structure of a parsed compat snapshot. */
|
|
6362
|
+
function validateSnapshot(data) {
|
|
6363
|
+
if (typeof data !== "object" || data === null) return false;
|
|
6364
|
+
const obj = data;
|
|
6365
|
+
return typeof obj.schemaVersion === "string" && typeof obj.source === "object" && obj.source !== null && typeof obj.policies === "object" && obj.policies !== null && Array.isArray(obj.symbols);
|
|
6366
|
+
}
|
|
6367
|
+
//#endregion
|
|
6368
|
+
//#region src/compat/config.ts
|
|
6369
|
+
const BREAKING_CATEGORIES = new Set([
|
|
6370
|
+
"symbol_removed",
|
|
6371
|
+
"symbol_renamed",
|
|
6372
|
+
"parameter_removed",
|
|
6373
|
+
"parameter_renamed",
|
|
6374
|
+
"parameter_requiredness_increased",
|
|
6375
|
+
"parameter_type_narrowed",
|
|
6376
|
+
"parameter_position_changed_order_sensitive",
|
|
6377
|
+
"constructor_position_changed_order_sensitive",
|
|
6378
|
+
"named_arg_name_removed",
|
|
6379
|
+
"keyword_name_removed",
|
|
6380
|
+
"overload_removed",
|
|
6381
|
+
"union_wrapper_migration_without_compat_alias",
|
|
6382
|
+
"field_type_changed",
|
|
6383
|
+
"return_type_changed",
|
|
6384
|
+
"enum_member_value_changed"
|
|
6385
|
+
]);
|
|
6386
|
+
const SOFT_RISK_CATEGORIES = new Set([
|
|
6387
|
+
"parameter_added_non_terminal_optional",
|
|
6388
|
+
"constructor_reordered_named_friendly",
|
|
6389
|
+
"default_value_changed",
|
|
6390
|
+
"wrapper_stricter_than_previous_sdk_but_matches_spec",
|
|
6391
|
+
"doc_surface_drift"
|
|
6392
|
+
]);
|
|
6393
|
+
/** Get the default severity for a change category. */
|
|
6394
|
+
function defaultSeverityForCategory(category) {
|
|
6395
|
+
if (BREAKING_CATEGORIES.has(category)) return "breaking";
|
|
6396
|
+
if (SOFT_RISK_CATEGORIES.has(category)) return "soft-risk";
|
|
6397
|
+
return "additive";
|
|
6398
|
+
}
|
|
6399
|
+
/** Check whether a severity meets or exceeds a fail threshold. */
|
|
6400
|
+
function severityMeetsThreshold(severity, threshold) {
|
|
6401
|
+
if (threshold === "none") return false;
|
|
6402
|
+
if (threshold === "soft-risk") return severity === "breaking" || severity === "soft-risk";
|
|
6403
|
+
return severity === "breaking";
|
|
6404
|
+
}
|
|
6405
|
+
//#endregion
|
|
6406
|
+
//#region src/compat/classify.ts
|
|
6407
|
+
/**
|
|
6408
|
+
* Classify changes between a baseline and candidate symbol.
|
|
6409
|
+
* Returns one or more classified changes for the diff.
|
|
6410
|
+
*/
|
|
6411
|
+
function classifySymbolChanges(baseline, candidate, policy) {
|
|
6412
|
+
const changes = [];
|
|
6413
|
+
if (!candidate) {
|
|
6414
|
+
changes.push(makeChange({
|
|
6415
|
+
category: "symbol_removed",
|
|
6416
|
+
symbol: baseline.fqName,
|
|
6417
|
+
old: { symbol: baseline.fqName },
|
|
6418
|
+
new: { symbol: "(removed)" },
|
|
6419
|
+
message: `Symbol "${baseline.displayName}" was removed`,
|
|
6420
|
+
policy
|
|
6421
|
+
}));
|
|
6422
|
+
return changes;
|
|
6423
|
+
}
|
|
6424
|
+
if (baseline.fqName !== candidate.fqName && baseline.id === candidate.id) changes.push(makeChange({
|
|
6425
|
+
category: "symbol_renamed",
|
|
6426
|
+
symbol: baseline.fqName,
|
|
6427
|
+
old: { name: baseline.fqName },
|
|
6428
|
+
new: { name: candidate.fqName },
|
|
6429
|
+
message: `Symbol renamed from "${baseline.displayName}" to "${candidate.displayName}"`,
|
|
6430
|
+
policy
|
|
6431
|
+
}));
|
|
6432
|
+
if (baseline.parameters && candidate.parameters) changes.push(...classifyParameterChanges(baseline, candidate, policy));
|
|
6433
|
+
if (baseline.returns && candidate.returns && baseline.returns.name !== candidate.returns.name) changes.push(makeChange({
|
|
6434
|
+
category: "return_type_changed",
|
|
6435
|
+
symbol: baseline.fqName,
|
|
6436
|
+
old: { returnType: baseline.returns.name },
|
|
6437
|
+
new: { returnType: candidate.returns.name },
|
|
6438
|
+
message: `Return type changed for "${baseline.displayName}" from "${baseline.returns.name}" to "${candidate.returns.name}"`,
|
|
6439
|
+
policy
|
|
6440
|
+
}));
|
|
6441
|
+
if (baseline.typeRef && candidate.typeRef && baseline.typeRef.name !== candidate.typeRef.name) changes.push(makeChange({
|
|
6442
|
+
category: "field_type_changed",
|
|
6443
|
+
symbol: baseline.fqName,
|
|
6444
|
+
old: { type: baseline.typeRef.name },
|
|
6445
|
+
new: { type: candidate.typeRef.name },
|
|
6446
|
+
message: `Type changed for "${baseline.displayName}" from "${baseline.typeRef.name}" to "${candidate.typeRef.name}"`,
|
|
6447
|
+
policy
|
|
6448
|
+
}));
|
|
6449
|
+
if (baseline.kind === "enum_member" && candidate.kind === "enum_member" && baseline.value !== void 0 && candidate.value !== void 0 && baseline.value !== candidate.value) changes.push(makeChange({
|
|
6450
|
+
category: "enum_member_value_changed",
|
|
6451
|
+
symbol: baseline.fqName,
|
|
6452
|
+
old: { value: String(baseline.value) },
|
|
6453
|
+
new: { value: String(candidate.value) },
|
|
6454
|
+
message: `Enum value changed for "${baseline.displayName}" from "${baseline.value}" to "${candidate.value}"`,
|
|
6455
|
+
policy
|
|
6456
|
+
}));
|
|
6457
|
+
return changes;
|
|
6458
|
+
}
|
|
6459
|
+
/**
|
|
6460
|
+
* Classify parameter-level changes between two symbol versions.
|
|
6461
|
+
*/
|
|
6462
|
+
function classifyParameterChanges(baseline, candidate, policy) {
|
|
6463
|
+
const changes = [];
|
|
6464
|
+
const baseParams = baseline.parameters ?? [];
|
|
6465
|
+
const candParams = candidate.parameters ?? [];
|
|
6466
|
+
const baseByName = new Map(baseParams.map((p) => [p.publicName, p]));
|
|
6467
|
+
const candByName = new Map(candParams.map((p) => [p.publicName, p]));
|
|
6468
|
+
const isConstructor = baseline.kind === "constructor";
|
|
6469
|
+
for (const baseParam of baseParams) {
|
|
6470
|
+
const candParam = candByName.get(baseParam.publicName);
|
|
6471
|
+
if (!candParam) {
|
|
6472
|
+
const positionalMatch = candParams[baseParam.position];
|
|
6473
|
+
if (positionalMatch && !baseByName.has(positionalMatch.publicName)) {
|
|
6474
|
+
const isBreakingRename = parameterNameIsPublicApi(baseParam, policy, isConstructor);
|
|
6475
|
+
changes.push(makeChange({
|
|
6476
|
+
category: "parameter_renamed",
|
|
6477
|
+
symbol: baseline.fqName,
|
|
6478
|
+
old: { parameter: baseParam.publicName },
|
|
6479
|
+
new: { parameter: positionalMatch.publicName },
|
|
6480
|
+
message: `Parameter "${baseParam.publicName}" renamed to "${positionalMatch.publicName}" on "${baseline.displayName}"`,
|
|
6481
|
+
policy,
|
|
6482
|
+
severityOverride: isBreakingRename ? void 0 : "soft-risk"
|
|
6483
|
+
}));
|
|
6484
|
+
} else changes.push(makeChange({
|
|
6485
|
+
category: "parameter_removed",
|
|
6486
|
+
symbol: baseline.fqName,
|
|
6487
|
+
old: { parameter: baseParam.publicName },
|
|
6488
|
+
new: { parameter: "(removed)" },
|
|
6489
|
+
message: `Parameter "${baseParam.publicName}" removed from "${baseline.displayName}"`,
|
|
6490
|
+
policy
|
|
6491
|
+
}));
|
|
6492
|
+
continue;
|
|
6493
|
+
}
|
|
6494
|
+
if (!baseParam.required && candParam.required) changes.push(makeChange({
|
|
6495
|
+
category: "parameter_requiredness_increased",
|
|
6496
|
+
symbol: baseline.fqName,
|
|
6497
|
+
old: {
|
|
6498
|
+
parameter: baseParam.publicName,
|
|
6499
|
+
required: "false"
|
|
6500
|
+
},
|
|
6501
|
+
new: {
|
|
6502
|
+
parameter: candParam.publicName,
|
|
6503
|
+
required: "true"
|
|
6504
|
+
},
|
|
6505
|
+
message: `Parameter "${baseParam.publicName}" became required on "${baseline.displayName}"`,
|
|
6506
|
+
policy
|
|
6507
|
+
}));
|
|
6508
|
+
if (baseParam.type.name !== candParam.type.name) changes.push(makeChange({
|
|
6509
|
+
category: "parameter_type_narrowed",
|
|
6510
|
+
symbol: baseline.fqName,
|
|
6511
|
+
old: {
|
|
6512
|
+
parameter: baseParam.publicName,
|
|
6513
|
+
type: baseParam.type.name
|
|
6514
|
+
},
|
|
6515
|
+
new: {
|
|
6516
|
+
parameter: candParam.publicName,
|
|
6517
|
+
type: candParam.type.name
|
|
6518
|
+
},
|
|
6519
|
+
message: `Parameter type changed for "${baseParam.publicName}" on "${baseline.displayName}"`,
|
|
6520
|
+
policy
|
|
6521
|
+
}));
|
|
6522
|
+
if (baseParam.position !== candParam.position) if (isConstructor ? policy.constructorOrderMatters : baseParam.sensitivity.order) {
|
|
6523
|
+
const category = isConstructor ? "constructor_position_changed_order_sensitive" : "parameter_position_changed_order_sensitive";
|
|
6524
|
+
changes.push(makeChange({
|
|
6525
|
+
category,
|
|
6526
|
+
symbol: baseline.fqName,
|
|
6527
|
+
old: {
|
|
6528
|
+
parameter: baseParam.publicName,
|
|
6529
|
+
position: String(baseParam.position)
|
|
6530
|
+
},
|
|
6531
|
+
new: {
|
|
6532
|
+
parameter: candParam.publicName,
|
|
6533
|
+
position: String(candParam.position)
|
|
6534
|
+
},
|
|
6535
|
+
message: `Parameter "${baseParam.publicName}" moved from position ${baseParam.position} to ${candParam.position} on "${baseline.displayName}"`,
|
|
6536
|
+
policy
|
|
6537
|
+
}));
|
|
6538
|
+
} else changes.push(makeChange({
|
|
6539
|
+
category: "constructor_reordered_named_friendly",
|
|
6540
|
+
symbol: baseline.fqName,
|
|
6541
|
+
old: {
|
|
6542
|
+
parameter: baseParam.publicName,
|
|
6543
|
+
position: String(baseParam.position)
|
|
6544
|
+
},
|
|
6545
|
+
new: {
|
|
6546
|
+
parameter: candParam.publicName,
|
|
6547
|
+
position: String(candParam.position)
|
|
6548
|
+
},
|
|
6549
|
+
message: `Parameter "${baseParam.publicName}" reordered on "${baseline.displayName}" (named-friendly language)`,
|
|
6550
|
+
policy
|
|
6551
|
+
}));
|
|
6552
|
+
}
|
|
6553
|
+
for (const candParam of candParams) if (!baseByName.has(candParam.publicName)) {
|
|
6554
|
+
if (changes.some((c) => c.category === "parameter_renamed" && c.new.parameter === candParam.publicName)) continue;
|
|
6555
|
+
const isTerminal = candParam.position === candParams.length - 1;
|
|
6556
|
+
const category = candParam.required ? "parameter_requiredness_increased" : isTerminal ? "parameter_added_optional_terminal" : "parameter_added_non_terminal_optional";
|
|
6557
|
+
if (candParam.required) changes.push(makeChange({
|
|
6558
|
+
category,
|
|
6559
|
+
symbol: baseline.fqName,
|
|
6560
|
+
old: { parameter: "(absent)" },
|
|
6561
|
+
new: {
|
|
6562
|
+
parameter: candParam.publicName,
|
|
6563
|
+
required: "true"
|
|
6564
|
+
},
|
|
6565
|
+
message: `Required parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
|
|
6566
|
+
policy
|
|
6567
|
+
}));
|
|
6568
|
+
else changes.push(makeChange({
|
|
6569
|
+
category,
|
|
6570
|
+
symbol: baseline.fqName,
|
|
6571
|
+
old: { parameter: "(absent)" },
|
|
6572
|
+
new: { parameter: candParam.publicName },
|
|
6573
|
+
message: `Optional parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
|
|
6574
|
+
policy
|
|
6575
|
+
}));
|
|
6576
|
+
}
|
|
6577
|
+
return changes;
|
|
6578
|
+
}
|
|
6579
|
+
function parameterNameIsPublicApi(param, policy, isConstructor) {
|
|
6580
|
+
if (param.sensitivity.publicName) return true;
|
|
6581
|
+
if (isConstructor) return policy.constructorParameterNamesArePublicApi;
|
|
6582
|
+
return policy.methodParameterNamesArePublicApi;
|
|
6583
|
+
}
|
|
6584
|
+
/** Build a deterministic conceptual change ID. */
|
|
6585
|
+
function buildConceptualChangeId(category, symbol, match) {
|
|
6586
|
+
const parts = [
|
|
6587
|
+
"chg",
|
|
6588
|
+
category,
|
|
6589
|
+
symbol.replace(/[^a-zA-Z0-9_.]/g, "_")
|
|
6590
|
+
];
|
|
6591
|
+
if (match.parameter) parts.push(match.parameter);
|
|
6592
|
+
if (match.member) parts.push(match.member);
|
|
6593
|
+
return parts.join("_").toLowerCase();
|
|
6594
|
+
}
|
|
6595
|
+
function makeChange(opts) {
|
|
6596
|
+
return {
|
|
6597
|
+
category: opts.category,
|
|
6598
|
+
severity: opts.severityOverride ?? defaultSeverityForCategory(opts.category),
|
|
6599
|
+
symbol: opts.symbol,
|
|
6600
|
+
conceptualChangeId: buildConceptualChangeId(opts.category, opts.symbol, opts.old),
|
|
6601
|
+
provenance: opts.provenance ?? "unknown",
|
|
6602
|
+
old: opts.old,
|
|
6603
|
+
new: opts.new,
|
|
6604
|
+
message: opts.message
|
|
6605
|
+
};
|
|
6606
|
+
}
|
|
6607
|
+
/**
|
|
6608
|
+
* Classify a new symbol as additive.
|
|
6609
|
+
*/
|
|
6610
|
+
function classifyAddedSymbol(symbol) {
|
|
6611
|
+
return {
|
|
6612
|
+
category: "symbol_added",
|
|
6613
|
+
severity: "additive",
|
|
6614
|
+
symbol: symbol.fqName,
|
|
6615
|
+
conceptualChangeId: buildConceptualChangeId("symbol_added", symbol.fqName, {}),
|
|
6616
|
+
provenance: "unknown",
|
|
6617
|
+
old: { symbol: "(absent)" },
|
|
6618
|
+
new: { symbol: symbol.fqName },
|
|
6619
|
+
message: `Symbol "${symbol.displayName}" was added`
|
|
6620
|
+
};
|
|
6621
|
+
}
|
|
6622
|
+
/** Summarize a list of classified changes by severity. */
|
|
6623
|
+
function summarizeChanges(changes) {
|
|
6624
|
+
let breaking = 0;
|
|
6625
|
+
let softRisk = 0;
|
|
6626
|
+
let additive = 0;
|
|
6627
|
+
for (const c of changes) if (c.severity === "breaking") breaking++;
|
|
6628
|
+
else if (c.severity === "soft-risk") softRisk++;
|
|
6629
|
+
else additive++;
|
|
6630
|
+
return {
|
|
6631
|
+
breaking,
|
|
6632
|
+
softRisk,
|
|
6633
|
+
additive
|
|
6634
|
+
};
|
|
6635
|
+
}
|
|
6636
|
+
//#endregion
|
|
6250
6637
|
//#region src/compat/spec-filter.ts
|
|
6251
6638
|
/**
|
|
6252
6639
|
* Compute the set of symbol names that are derivable from the OpenAPI spec.
|
|
@@ -6412,378 +6799,609 @@ function filterSurface(surface, allowedNames, opts) {
|
|
|
6412
6799
|
}
|
|
6413
6800
|
//#endregion
|
|
6414
6801
|
//#region src/compat/differ.ts
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
candidate: formatSignature(candOverloads[0]),
|
|
6463
|
-
message: `Signature mismatch for "${name}.${methodName}"`
|
|
6464
|
-
});
|
|
6465
|
-
continue;
|
|
6466
|
-
}
|
|
6467
|
-
preserved++;
|
|
6468
|
-
}
|
|
6469
|
-
}
|
|
6470
|
-
for (const methodName of Object.keys(candClass.methods)) if (!baseClass.methods[methodName]) additions.push({
|
|
6471
|
-
symbolPath: `${name}.${methodName}`,
|
|
6472
|
-
symbolType: "method"
|
|
6473
|
-
});
|
|
6474
|
-
for (const [propName, baseProp] of Object.entries(baseClass.properties)) {
|
|
6475
|
-
totalBaseline++;
|
|
6476
|
-
const candProp = candClass.properties[propName];
|
|
6477
|
-
if (!candProp) {
|
|
6478
|
-
violations.push({
|
|
6479
|
-
category: "public-api",
|
|
6480
|
-
severity: "breaking",
|
|
6481
|
-
symbolPath: `${name}.${propName}`,
|
|
6482
|
-
baseline: baseProp.type,
|
|
6483
|
-
candidate: "(missing)",
|
|
6484
|
-
message: `Property "${name}.${propName}" exists in baseline but not in generated output`
|
|
6485
|
-
});
|
|
6486
|
-
continue;
|
|
6487
|
-
}
|
|
6488
|
-
if (baseProp.type !== candProp.type) {
|
|
6489
|
-
const nullableOnly = hints.isNullableOnlyDifference(baseProp.type, candProp.type);
|
|
6490
|
-
violations.push({
|
|
6491
|
-
category: "signature",
|
|
6492
|
-
severity: nullableOnly ? "warning" : "breaking",
|
|
6493
|
-
symbolPath: `${name}.${propName}`,
|
|
6494
|
-
baseline: baseProp.type,
|
|
6495
|
-
candidate: candProp.type,
|
|
6496
|
-
message: `Property type mismatch for "${name}.${propName}"`
|
|
6497
|
-
});
|
|
6498
|
-
if (nullableOnly) preserved++;
|
|
6499
|
-
continue;
|
|
6500
|
-
}
|
|
6501
|
-
preserved++;
|
|
6502
|
-
}
|
|
6503
|
-
for (const propName of Object.keys(candClass.properties)) if (!baseClass.properties[propName]) additions.push({
|
|
6504
|
-
symbolPath: `${name}.${propName}`,
|
|
6505
|
-
symbolType: "property"
|
|
6802
|
+
/**
|
|
6803
|
+
* Diff two compat snapshots, producing classified changes with
|
|
6804
|
+
* policy-aware severity.
|
|
6805
|
+
*/
|
|
6806
|
+
function diffSnapshots(baseline, candidate, policy) {
|
|
6807
|
+
const effectivePolicy = policy ?? baseline.policies;
|
|
6808
|
+
const changes = [];
|
|
6809
|
+
const candById = /* @__PURE__ */ new Map();
|
|
6810
|
+
const candByFqName = /* @__PURE__ */ new Map();
|
|
6811
|
+
for (const sym of candidate.symbols) {
|
|
6812
|
+
candById.set(sym.id, sym);
|
|
6813
|
+
candByFqName.set(sym.fqName, sym);
|
|
6814
|
+
}
|
|
6815
|
+
const baseByFqName = /* @__PURE__ */ new Set();
|
|
6816
|
+
for (const sym of baseline.symbols) baseByFqName.add(sym.fqName);
|
|
6817
|
+
for (const baseSym of baseline.symbols) {
|
|
6818
|
+
const candSym = candById.get(baseSym.id) ?? candByFqName.get(baseSym.fqName);
|
|
6819
|
+
changes.push(...classifySymbolChanges(baseSym, candSym, effectivePolicy));
|
|
6820
|
+
}
|
|
6821
|
+
for (const candSym of candidate.symbols) if (!baseByFqName.has(candSym.fqName)) changes.push(classifyAddedSymbol(candSym));
|
|
6822
|
+
return {
|
|
6823
|
+
changes,
|
|
6824
|
+
summary: summarizeChanges(changes)
|
|
6825
|
+
};
|
|
6826
|
+
}
|
|
6827
|
+
//#endregion
|
|
6828
|
+
//#region src/compat/concepts.ts
|
|
6829
|
+
/**
|
|
6830
|
+
* Group classified changes from multiple languages into conceptual changes.
|
|
6831
|
+
*
|
|
6832
|
+
* Changes are grouped by their `conceptualChangeId` — a deterministic ID
|
|
6833
|
+
* derived from the symbol, category, and match details. Changes with the
|
|
6834
|
+
* same ID across different languages represent the same conceptual change.
|
|
6835
|
+
*/
|
|
6836
|
+
function buildConceptualRollup(perLanguageResults) {
|
|
6837
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
6838
|
+
for (const { diff: result, language } of perLanguageResults) for (const change of result.changes) {
|
|
6839
|
+
const existing = grouped.get(change.conceptualChangeId);
|
|
6840
|
+
if (existing) existing.impact[language] = change.severity;
|
|
6841
|
+
else grouped.set(change.conceptualChangeId, {
|
|
6842
|
+
id: change.conceptualChangeId,
|
|
6843
|
+
symbol: change.symbol,
|
|
6844
|
+
category: change.category,
|
|
6845
|
+
impact: { [language]: change.severity },
|
|
6846
|
+
message: change.message,
|
|
6847
|
+
old: change.old,
|
|
6848
|
+
new: change.new
|
|
6506
6849
|
});
|
|
6507
6850
|
}
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
const
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6528
|
-
preserved += Object.keys(baseIface.fields).length;
|
|
6529
|
-
continue;
|
|
6530
|
-
}
|
|
6531
|
-
if (!tolerated) {
|
|
6532
|
-
const baseFieldNamesLower = new Set(Object.keys(baseIface.fields).map((f) => f.toLowerCase()));
|
|
6533
|
-
if (baseFieldNamesLower.size > 0) {
|
|
6534
|
-
for (const [, candFieldNamesLower] of candIfaceFieldSets) if (candFieldNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candFieldNamesLower.has(f))) {
|
|
6535
|
-
tolerated = true;
|
|
6536
|
-
break;
|
|
6537
|
-
}
|
|
6538
|
-
if (!tolerated) {
|
|
6539
|
-
for (const [, candPropNamesLower] of candClassPropSets) if (candPropNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candPropNamesLower.has(f))) {
|
|
6540
|
-
tolerated = true;
|
|
6541
|
-
break;
|
|
6542
|
-
}
|
|
6543
|
-
}
|
|
6544
|
-
} else tolerated = true;
|
|
6545
|
-
}
|
|
6546
|
-
if (tolerated) {
|
|
6547
|
-
preserved++;
|
|
6548
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6549
|
-
preserved += Object.keys(baseIface.fields).length;
|
|
6550
|
-
continue;
|
|
6551
|
-
}
|
|
6552
|
-
violations.push({
|
|
6553
|
-
category: "public-api",
|
|
6554
|
-
severity: "breaking",
|
|
6555
|
-
symbolPath: name,
|
|
6556
|
-
baseline: name,
|
|
6557
|
-
candidate: "(missing)",
|
|
6558
|
-
message: `Interface "${name}" exists in baseline but not in generated output`
|
|
6559
|
-
});
|
|
6560
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6561
|
-
continue;
|
|
6562
|
-
}
|
|
6563
|
-
preserved++;
|
|
6564
|
-
for (const [fieldName, baseField] of Object.entries(baseIface.fields)) {
|
|
6565
|
-
totalBaseline++;
|
|
6566
|
-
const candField = candIface.fields[fieldName];
|
|
6567
|
-
if (!candField) {
|
|
6568
|
-
const baseTypeClean = baseField.type.replace(/\[\]$/, "").replace(/ \| null$/, "");
|
|
6569
|
-
const typeIsUnresolvable = NAMED_TYPE_RE.test(baseTypeClean) && !typeExistsInSurface(baseTypeClean, candidate);
|
|
6570
|
-
violations.push({
|
|
6571
|
-
category: "public-api",
|
|
6572
|
-
severity: typeIsUnresolvable ? "warning" : "breaking",
|
|
6573
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6574
|
-
baseline: baseField.type,
|
|
6575
|
-
candidate: "(missing)",
|
|
6576
|
-
message: `Field "${name}.${fieldName}" exists in baseline but not in generated output`
|
|
6577
|
-
});
|
|
6578
|
-
if (typeIsUnresolvable) preserved++;
|
|
6579
|
-
continue;
|
|
6580
|
-
}
|
|
6581
|
-
if (baseField.type !== candField.type) {
|
|
6582
|
-
if (hints.isUnionReorder(baseField.type, candField.type)) {
|
|
6583
|
-
preserved++;
|
|
6584
|
-
continue;
|
|
6585
|
-
}
|
|
6586
|
-
if (hints.isTypeEquivalent?.(baseField.type, candField.type, candidate)) {
|
|
6587
|
-
preserved++;
|
|
6588
|
-
continue;
|
|
6589
|
-
}
|
|
6590
|
-
const nullableOnly = hints.isNullableOnlyDifference(baseField.type, candField.type);
|
|
6591
|
-
const genericParam = hints.isGenericTypeParam(baseField.type);
|
|
6592
|
-
const extractionArtifact = hints.isExtractionArtifact(candField.type);
|
|
6593
|
-
const isWarning = nullableOnly || genericParam || extractionArtifact;
|
|
6594
|
-
violations.push({
|
|
6595
|
-
category: "signature",
|
|
6596
|
-
severity: isWarning ? "warning" : "breaking",
|
|
6597
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6598
|
-
baseline: baseField.type,
|
|
6599
|
-
candidate: candField.type,
|
|
6600
|
-
message: `Field type mismatch for "${name}.${fieldName}"`
|
|
6601
|
-
});
|
|
6602
|
-
if (isWarning) preserved++;
|
|
6603
|
-
continue;
|
|
6604
|
-
}
|
|
6605
|
-
preserved++;
|
|
6606
|
-
}
|
|
6607
|
-
for (const fieldName of Object.keys(candIface.fields)) if (!baseIface.fields[fieldName]) additions.push({
|
|
6608
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6609
|
-
symbolType: "property"
|
|
6610
|
-
});
|
|
6851
|
+
return { conceptualChanges: [...grouped.values()] };
|
|
6852
|
+
}
|
|
6853
|
+
/** Get the highest severity across all languages for a conceptual change. */
|
|
6854
|
+
function highestSeverity(change) {
|
|
6855
|
+
const severities = Object.values(change.impact);
|
|
6856
|
+
if (severities.includes("breaking")) return "breaking";
|
|
6857
|
+
if (severities.includes("soft-risk")) return "soft-risk";
|
|
6858
|
+
return "additive";
|
|
6859
|
+
}
|
|
6860
|
+
/** Count conceptual changes by highest severity. */
|
|
6861
|
+
function summarizeConceptualChanges(rollup) {
|
|
6862
|
+
let breaking = 0;
|
|
6863
|
+
let softRisk = 0;
|
|
6864
|
+
let additive = 0;
|
|
6865
|
+
for (const change of rollup.conceptualChanges) {
|
|
6866
|
+
const sev = highestSeverity(change);
|
|
6867
|
+
if (sev === "breaking") breaking++;
|
|
6868
|
+
else if (sev === "soft-risk") softRisk++;
|
|
6869
|
+
else additive++;
|
|
6611
6870
|
}
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6871
|
+
return {
|
|
6872
|
+
breaking,
|
|
6873
|
+
softRisk,
|
|
6874
|
+
additive
|
|
6875
|
+
};
|
|
6876
|
+
}
|
|
6877
|
+
//#endregion
|
|
6878
|
+
//#region src/compat/report.ts
|
|
6879
|
+
/** Generate a machine-readable compat report from a diff result. */
|
|
6880
|
+
function generateReport(diff, language) {
|
|
6881
|
+
return {
|
|
6882
|
+
schemaVersion: "1",
|
|
6883
|
+
language: language ?? "unknown",
|
|
6884
|
+
summary: diff.summary,
|
|
6885
|
+
changes: diff.changes.map((c) => ({
|
|
6886
|
+
severity: c.severity,
|
|
6887
|
+
category: c.category,
|
|
6888
|
+
symbol: c.symbol,
|
|
6889
|
+
conceptualChangeId: c.conceptualChangeId,
|
|
6890
|
+
provenance: c.provenance,
|
|
6891
|
+
old: c.old,
|
|
6892
|
+
new: c.new,
|
|
6893
|
+
...c.message ? { message: c.message } : {}
|
|
6894
|
+
}))
|
|
6895
|
+
};
|
|
6896
|
+
}
|
|
6897
|
+
/** Generate a human-readable summary string for terminal output. */
|
|
6898
|
+
function formatHumanSummary(diff, opts) {
|
|
6899
|
+
const lines = [];
|
|
6900
|
+
lines.push(`Compat report${opts?.language ? ` for ${opts.language}` : ""}:`);
|
|
6901
|
+
lines.push(` ${diff.summary.breaking} breaking, ${diff.summary.softRisk} soft-risk, ${diff.summary.additive} additive`);
|
|
6902
|
+
if (diff.changes.length === 0) {
|
|
6903
|
+
lines.push(" No compatibility changes detected.");
|
|
6904
|
+
return lines.join("\n");
|
|
6905
|
+
}
|
|
6906
|
+
const breaking = diff.changes.filter((c) => c.severity === "breaking");
|
|
6907
|
+
const softRisk = diff.changes.filter((c) => c.severity === "soft-risk");
|
|
6908
|
+
const additive = diff.changes.filter((c) => c.severity === "additive");
|
|
6909
|
+
if (breaking.length > 0) {
|
|
6910
|
+
lines.push("");
|
|
6911
|
+
lines.push(" Breaking:");
|
|
6912
|
+
for (const c of breaking) {
|
|
6913
|
+
lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
|
|
6914
|
+
if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
|
|
6650
6915
|
}
|
|
6651
|
-
preserved++;
|
|
6652
6916
|
}
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
const candEnum = candidate.enums[name];
|
|
6660
|
-
if (!candEnum) {
|
|
6661
|
-
violations.push({
|
|
6662
|
-
category: "public-api",
|
|
6663
|
-
severity: "breaking",
|
|
6664
|
-
symbolPath: name,
|
|
6665
|
-
baseline: name,
|
|
6666
|
-
candidate: "(missing)",
|
|
6667
|
-
message: `Enum "${name}" exists in baseline but not in generated output`
|
|
6668
|
-
});
|
|
6669
|
-
continue;
|
|
6917
|
+
if (softRisk.length > 0) {
|
|
6918
|
+
lines.push("");
|
|
6919
|
+
lines.push(" Soft-risk:");
|
|
6920
|
+
for (const c of softRisk) {
|
|
6921
|
+
lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
|
|
6922
|
+
if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
|
|
6670
6923
|
}
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
|
|
6924
|
+
}
|
|
6925
|
+
if (additive.length > 0) {
|
|
6926
|
+
lines.push("");
|
|
6927
|
+
lines.push(` Additive: ${additive.length} new symbol(s)`);
|
|
6928
|
+
}
|
|
6929
|
+
return lines.join("\n");
|
|
6930
|
+
}
|
|
6931
|
+
//#endregion
|
|
6932
|
+
//#region src/cli/compat-diff.ts
|
|
6933
|
+
async function compatDiffCommand(opts) {
|
|
6934
|
+
const baselineData = JSON.parse(readFileSync(opts.baseline, "utf-8"));
|
|
6935
|
+
const candidateData = JSON.parse(readFileSync(opts.candidate, "utf-8"));
|
|
6936
|
+
if (!validateSnapshot(baselineData)) throw new Error(`Invalid baseline snapshot: ${opts.baseline}`);
|
|
6937
|
+
if (!validateSnapshot(candidateData)) throw new Error(`Invalid candidate snapshot: ${opts.candidate}`);
|
|
6938
|
+
const diff = diffSnapshots(baselineData, candidateData);
|
|
6939
|
+
console.log(formatHumanSummary(diff, { explain: opts.explain }));
|
|
6940
|
+
if (opts.output) {
|
|
6941
|
+
const report = generateReport(diff);
|
|
6942
|
+
writeFileSync(opts.output, JSON.stringify(report, null, 2));
|
|
6943
|
+
console.log(`Report written to ${opts.output}`);
|
|
6944
|
+
}
|
|
6945
|
+
const failOn = opts.failOn ?? "breaking";
|
|
6946
|
+
if (diff.changes.some((c) => severityMeetsThreshold(c.severity, failOn))) process.exitCode = 1;
|
|
6947
|
+
}
|
|
6948
|
+
//#endregion
|
|
6949
|
+
//#region src/cli/compat-summary.ts
|
|
6950
|
+
/**
|
|
6951
|
+
* Format compat report(s) as a markdown PR comment.
|
|
6952
|
+
*
|
|
6953
|
+
* Single report:
|
|
6954
|
+
* oagen compat-summary --report node-report.json | gh pr comment --body-file -
|
|
6955
|
+
*
|
|
6956
|
+
* Multiple reports (cross-language rollup):
|
|
6957
|
+
* oagen compat-summary --report php.json --report python.json --report go.json
|
|
6958
|
+
*/
|
|
6959
|
+
async function compatSummaryCommand(opts) {
|
|
6960
|
+
const reports = (Array.isArray(opts.report) ? opts.report : [opts.report]).map((p) => JSON.parse(readFileSync(p, "utf-8")));
|
|
6961
|
+
const md = reports.length === 1 ? formatSingleReport(reports[0]) : formatCrossLanguageRollup(reports);
|
|
6962
|
+
if (opts.output) {
|
|
6963
|
+
writeFileSync(opts.output, md);
|
|
6964
|
+
console.log(`Summary written to ${opts.output}`);
|
|
6965
|
+
} else process.stdout.write(md);
|
|
6966
|
+
}
|
|
6967
|
+
function formatSingleReport(report) {
|
|
6968
|
+
const lines = [];
|
|
6969
|
+
const { breaking, softRisk, additive } = report.summary;
|
|
6970
|
+
const total = breaking + softRisk + additive;
|
|
6971
|
+
if (breaking > 0) lines.push(`## :x: Compat check failed — ${report.language}`);
|
|
6972
|
+
else if (softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${report.language}`);
|
|
6973
|
+
else lines.push(`## :white_check_mark: Compat check passed — ${report.language}`);
|
|
6974
|
+
lines.push("");
|
|
6975
|
+
lines.push("| Severity | Count |");
|
|
6976
|
+
lines.push("| --- | --- |");
|
|
6977
|
+
lines.push(`| Breaking | ${breaking} |`);
|
|
6978
|
+
lines.push(`| Soft-risk | ${softRisk} |`);
|
|
6979
|
+
lines.push(`| Additive | ${additive} |`);
|
|
6980
|
+
lines.push(`| **Total** | **${total}** |`);
|
|
6981
|
+
if (total === 0) {
|
|
6982
|
+
lines.push("");
|
|
6983
|
+
lines.push("No compatibility changes detected.");
|
|
6984
|
+
return lines.join("\n") + "\n";
|
|
6985
|
+
}
|
|
6986
|
+
const breakingChanges = report.changes.filter((c) => c.severity === "breaking");
|
|
6987
|
+
if (breakingChanges.length > 0) {
|
|
6988
|
+
lines.push("");
|
|
6989
|
+
lines.push("### Breaking changes");
|
|
6990
|
+
lines.push("");
|
|
6991
|
+
lines.push(formatChangesTable(breakingChanges));
|
|
6992
|
+
}
|
|
6993
|
+
const softRiskChanges = report.changes.filter((c) => c.severity === "soft-risk");
|
|
6994
|
+
if (softRiskChanges.length > 0) {
|
|
6995
|
+
lines.push("");
|
|
6996
|
+
lines.push("<details>");
|
|
6997
|
+
lines.push(`<summary>Soft-risk changes (${softRiskChanges.length})</summary>`);
|
|
6998
|
+
lines.push("");
|
|
6999
|
+
lines.push(formatChangesTable(softRiskChanges));
|
|
7000
|
+
lines.push("</details>");
|
|
7001
|
+
}
|
|
7002
|
+
const additiveChanges = report.changes.filter((c) => c.severity === "additive");
|
|
7003
|
+
if (additiveChanges.length > 0) {
|
|
7004
|
+
lines.push("");
|
|
7005
|
+
lines.push("<details>");
|
|
7006
|
+
lines.push(`<summary>Additive changes (${additiveChanges.length})</summary>`);
|
|
7007
|
+
lines.push("");
|
|
7008
|
+
lines.push(formatChangesTable(additiveChanges));
|
|
7009
|
+
lines.push("</details>");
|
|
7010
|
+
}
|
|
7011
|
+
return lines.join("\n") + "\n";
|
|
7012
|
+
}
|
|
7013
|
+
function formatCrossLanguageRollup(reports) {
|
|
7014
|
+
const languages = reports.map((r) => r.language);
|
|
7015
|
+
const rollup = buildConceptualRollup(reports.map((r) => ({
|
|
7016
|
+
diff: {
|
|
7017
|
+
changes: r.changes,
|
|
7018
|
+
summary: r.summary
|
|
7019
|
+
},
|
|
7020
|
+
language: r.language
|
|
7021
|
+
})));
|
|
7022
|
+
const summary = summarizeConceptualChanges(rollup);
|
|
7023
|
+
const lines = [];
|
|
7024
|
+
if (summary.breaking > 0) lines.push(`## :x: Compat check failed — ${summary.breaking} breaking across ${languages.length} languages`);
|
|
7025
|
+
else if (summary.softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${summary.softRisk} soft-risk across ${languages.length} languages`);
|
|
7026
|
+
else lines.push(`## :white_check_mark: Compat check passed — ${languages.length} languages`);
|
|
7027
|
+
lines.push("");
|
|
7028
|
+
lines.push("| Language | Breaking | Soft-risk | Additive |");
|
|
7029
|
+
lines.push("| --- | --- | --- | --- |");
|
|
7030
|
+
for (const r of reports) lines.push(`| ${r.language} | ${r.summary.breaking} | ${r.summary.softRisk} | ${r.summary.additive} |`);
|
|
7031
|
+
if (rollup.conceptualChanges.length === 0) {
|
|
7032
|
+
lines.push("");
|
|
7033
|
+
lines.push("No compatibility changes detected.");
|
|
7034
|
+
return lines.join("\n") + "\n";
|
|
7035
|
+
}
|
|
7036
|
+
const breakingConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "breaking");
|
|
7037
|
+
if (breakingConcepts.length > 0) {
|
|
7038
|
+
lines.push("");
|
|
7039
|
+
lines.push("### Breaking changes");
|
|
7040
|
+
lines.push("");
|
|
7041
|
+
lines.push(formatConceptualTable(breakingConcepts, languages));
|
|
7042
|
+
}
|
|
7043
|
+
const softRiskConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "soft-risk");
|
|
7044
|
+
if (softRiskConcepts.length > 0) {
|
|
7045
|
+
lines.push("");
|
|
7046
|
+
lines.push("<details>");
|
|
7047
|
+
lines.push(`<summary>Soft-risk changes (${softRiskConcepts.length})</summary>`);
|
|
7048
|
+
lines.push("");
|
|
7049
|
+
lines.push(formatConceptualTable(softRiskConcepts, languages));
|
|
7050
|
+
lines.push("</details>");
|
|
7051
|
+
}
|
|
7052
|
+
const additiveConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "additive");
|
|
7053
|
+
if (additiveConcepts.length > 0) {
|
|
7054
|
+
lines.push("");
|
|
7055
|
+
lines.push("<details>");
|
|
7056
|
+
lines.push(`<summary>Additive changes (${additiveConcepts.length})</summary>`);
|
|
7057
|
+
lines.push("");
|
|
7058
|
+
lines.push(formatConceptualTable(additiveConcepts, languages));
|
|
7059
|
+
lines.push("</details>");
|
|
7060
|
+
}
|
|
7061
|
+
return lines.join("\n") + "\n";
|
|
7062
|
+
}
|
|
7063
|
+
function formatConceptualTable(concepts, languages) {
|
|
7064
|
+
const lines = [];
|
|
7065
|
+
const langHeaders = languages.map((l) => l).join(" | ");
|
|
7066
|
+
lines.push(`| Symbol | Category | ${langHeaders} |`);
|
|
7067
|
+
lines.push(`| --- | --- | ${languages.map(() => "---").join(" | ")} |`);
|
|
7068
|
+
for (const c of concepts) {
|
|
7069
|
+
const severities = languages.map((l) => severityIcon(c.impact[l])).join(" | ");
|
|
7070
|
+
lines.push(`| \`${c.symbol}\` | \`${c.category}\` | ${severities} |`);
|
|
7071
|
+
}
|
|
7072
|
+
return lines.join("\n");
|
|
7073
|
+
}
|
|
7074
|
+
function severityIcon(severity) {
|
|
7075
|
+
if (!severity) return "—";
|
|
7076
|
+
switch (severity) {
|
|
7077
|
+
case "breaking": return ":x: breaking";
|
|
7078
|
+
case "soft-risk": return ":warning: soft-risk";
|
|
7079
|
+
case "additive": return ":white_check_mark: additive";
|
|
7080
|
+
}
|
|
7081
|
+
}
|
|
7082
|
+
function formatChangesTable(changes) {
|
|
7083
|
+
const lines = [];
|
|
7084
|
+
lines.push("| Category | Symbol | Detail |");
|
|
7085
|
+
lines.push("| --- | --- | --- |");
|
|
7086
|
+
for (const c of changes) {
|
|
7087
|
+
const detail = c.message ?? formatOldNew(c.old, c.new);
|
|
7088
|
+
lines.push(`| \`${c.category}\` | \`${c.symbol}\` | ${escapeMarkdown(detail)} |`);
|
|
7089
|
+
}
|
|
7090
|
+
return lines.join("\n");
|
|
7091
|
+
}
|
|
7092
|
+
function formatOldNew(old, updated) {
|
|
7093
|
+
const parts = [];
|
|
7094
|
+
for (const key of Object.keys(old)) if (updated[key] && old[key] !== updated[key]) parts.push(`${key}: \`${old[key]}\` → \`${updated[key]}\``);
|
|
7095
|
+
return parts.length > 0 ? parts.join(", ") : "";
|
|
7096
|
+
}
|
|
7097
|
+
function escapeMarkdown(text) {
|
|
7098
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
7099
|
+
}
|
|
7100
|
+
//#endregion
|
|
7101
|
+
//#region src/compat/approvals.ts
|
|
7102
|
+
/**
|
|
7103
|
+
* Match a classified change against a list of approvals.
|
|
7104
|
+
* Returns the first matching approval, or null if none match.
|
|
7105
|
+
*/
|
|
7106
|
+
function matchApproval(change, approvals, language) {
|
|
7107
|
+
for (const approval of approvals) if (approvalMatchesChange(approval, change, language)) return approval;
|
|
7108
|
+
return null;
|
|
7109
|
+
}
|
|
7110
|
+
/**
|
|
7111
|
+
* Filter changes to only those NOT covered by approvals.
|
|
7112
|
+
* These are the remaining unapproved changes that may cause failure.
|
|
7113
|
+
*/
|
|
7114
|
+
function unapprovedChanges(changes, approvals, language) {
|
|
7115
|
+
return changes.filter((change) => !matchApproval(change, approvals, language));
|
|
7116
|
+
}
|
|
7117
|
+
function approvalMatchesChange(approval, change, language) {
|
|
7118
|
+
if (approval.approved === false) return false;
|
|
7119
|
+
if (approval.category !== change.category) return false;
|
|
7120
|
+
if (!symbolMatches(approval.symbol, change.symbol)) return false;
|
|
7121
|
+
if (!languageMatches(approval.appliesTo, language)) return false;
|
|
7122
|
+
if (approval.match && !narrowingMatches(approval.match, change)) return false;
|
|
7123
|
+
return true;
|
|
7124
|
+
}
|
|
7125
|
+
function symbolMatches(approvalSymbol, changeSymbol) {
|
|
7126
|
+
if (approvalSymbol === changeSymbol) return true;
|
|
7127
|
+
return approvalSymbol.replace(/::/g, ".").replace(/\\/g, ".") === changeSymbol.replace(/::/g, ".").replace(/\\/g, ".");
|
|
7128
|
+
}
|
|
7129
|
+
function languageMatches(appliesTo, language) {
|
|
7130
|
+
if (!appliesTo || appliesTo === "all-impacted-languages") return true;
|
|
7131
|
+
return appliesTo.includes(language);
|
|
7132
|
+
}
|
|
7133
|
+
function narrowingMatches(match, change) {
|
|
7134
|
+
if (match.parameter) {
|
|
7135
|
+
const paramInOld = change.old.parameter === match.parameter;
|
|
7136
|
+
const paramInNew = change.new.parameter === match.parameter;
|
|
7137
|
+
if (!paramInOld && !paramInNew) return false;
|
|
7138
|
+
}
|
|
7139
|
+
if (match.member) {
|
|
7140
|
+
const memberInOld = change.old.member === match.member;
|
|
7141
|
+
const memberInNew = change.new.member === match.member;
|
|
7142
|
+
if (!memberInOld && !memberInNew) return false;
|
|
7143
|
+
}
|
|
7144
|
+
if (match.oldName) {
|
|
7145
|
+
if (!Object.values(change.old).some((v) => v === match.oldName)) return false;
|
|
7146
|
+
}
|
|
7147
|
+
if (match.newName) {
|
|
7148
|
+
if (!Object.values(change.new).some((v) => v === match.newName)) return false;
|
|
7149
|
+
}
|
|
7150
|
+
return true;
|
|
7151
|
+
}
|
|
7152
|
+
//#endregion
|
|
7153
|
+
//#region src/compat/policy.ts
|
|
7154
|
+
/** Built-in language defaults. */
|
|
7155
|
+
const LANGUAGE_DEFAULTS = {
|
|
7156
|
+
php: {
|
|
7157
|
+
callerUsesParamNames: true,
|
|
7158
|
+
constructorOrderMatters: true,
|
|
7159
|
+
methodParameterNamesArePublicApi: true,
|
|
7160
|
+
constructorParameterNamesArePublicApi: true,
|
|
7161
|
+
overloadsArePublicApi: false,
|
|
7162
|
+
arityIsPublicApi: false
|
|
7163
|
+
},
|
|
7164
|
+
python: {
|
|
7165
|
+
callerUsesParamNames: true,
|
|
7166
|
+
constructorOrderMatters: true,
|
|
7167
|
+
methodParameterNamesArePublicApi: true,
|
|
7168
|
+
constructorParameterNamesArePublicApi: true,
|
|
7169
|
+
overloadsArePublicApi: false,
|
|
7170
|
+
arityIsPublicApi: false
|
|
7171
|
+
},
|
|
7172
|
+
ruby: {
|
|
7173
|
+
callerUsesParamNames: true,
|
|
7174
|
+
constructorOrderMatters: true,
|
|
7175
|
+
methodParameterNamesArePublicApi: true,
|
|
7176
|
+
constructorParameterNamesArePublicApi: true,
|
|
7177
|
+
overloadsArePublicApi: false,
|
|
7178
|
+
arityIsPublicApi: false
|
|
7179
|
+
},
|
|
7180
|
+
go: {
|
|
7181
|
+
callerUsesParamNames: false,
|
|
7182
|
+
constructorOrderMatters: true,
|
|
7183
|
+
methodParameterNamesArePublicApi: false,
|
|
7184
|
+
constructorParameterNamesArePublicApi: false,
|
|
7185
|
+
overloadsArePublicApi: false,
|
|
7186
|
+
arityIsPublicApi: true
|
|
7187
|
+
},
|
|
7188
|
+
kotlin: {
|
|
7189
|
+
callerUsesParamNames: true,
|
|
7190
|
+
constructorOrderMatters: false,
|
|
7191
|
+
methodParameterNamesArePublicApi: true,
|
|
7192
|
+
constructorParameterNamesArePublicApi: true,
|
|
7193
|
+
overloadsArePublicApi: true,
|
|
7194
|
+
arityIsPublicApi: false
|
|
7195
|
+
},
|
|
7196
|
+
dotnet: {
|
|
7197
|
+
callerUsesParamNames: true,
|
|
7198
|
+
constructorOrderMatters: false,
|
|
7199
|
+
methodParameterNamesArePublicApi: true,
|
|
7200
|
+
constructorParameterNamesArePublicApi: true,
|
|
7201
|
+
overloadsArePublicApi: true,
|
|
7202
|
+
arityIsPublicApi: false
|
|
7203
|
+
},
|
|
7204
|
+
elixir: {
|
|
7205
|
+
callerUsesParamNames: true,
|
|
7206
|
+
constructorOrderMatters: false,
|
|
7207
|
+
methodParameterNamesArePublicApi: true,
|
|
7208
|
+
constructorParameterNamesArePublicApi: false,
|
|
7209
|
+
overloadsArePublicApi: false,
|
|
7210
|
+
arityIsPublicApi: true
|
|
7211
|
+
},
|
|
7212
|
+
rust: {
|
|
7213
|
+
callerUsesParamNames: false,
|
|
7214
|
+
constructorOrderMatters: true,
|
|
7215
|
+
methodParameterNamesArePublicApi: false,
|
|
7216
|
+
constructorParameterNamesArePublicApi: false,
|
|
7217
|
+
overloadsArePublicApi: false,
|
|
7218
|
+
arityIsPublicApi: true
|
|
7219
|
+
},
|
|
7220
|
+
node: {
|
|
7221
|
+
callerUsesParamNames: false,
|
|
7222
|
+
constructorOrderMatters: false,
|
|
7223
|
+
methodParameterNamesArePublicApi: false,
|
|
7224
|
+
constructorParameterNamesArePublicApi: false,
|
|
7225
|
+
overloadsArePublicApi: false,
|
|
7226
|
+
arityIsPublicApi: false
|
|
7227
|
+
}
|
|
7228
|
+
};
|
|
7229
|
+
/** Get the built-in default policy for a language. */
|
|
7230
|
+
function getDefaultPolicy(language) {
|
|
7231
|
+
return { ...LANGUAGE_DEFAULTS[language] };
|
|
7232
|
+
}
|
|
7233
|
+
/** Merge user overrides onto language defaults. Only provided keys are overridden. */
|
|
7234
|
+
function mergePolicy(defaults, overrides) {
|
|
7235
|
+
return {
|
|
7236
|
+
...defaults,
|
|
7237
|
+
...overrides
|
|
7238
|
+
};
|
|
7239
|
+
}
|
|
7240
|
+
//#endregion
|
|
7241
|
+
//#region src/compat/ir.ts
|
|
7242
|
+
/**
|
|
7243
|
+
* Convert a legacy ApiSurface to a CompatSnapshot.
|
|
7244
|
+
*
|
|
7245
|
+
* This bridge allows existing extractors (which produce ApiSurface) to feed
|
|
7246
|
+
* into the new classified diff engine without requiring immediate migration.
|
|
7247
|
+
*/
|
|
7248
|
+
function apiSurfaceToSnapshot(surface) {
|
|
7249
|
+
const language = surface.language || "node";
|
|
7250
|
+
const symbols = [];
|
|
7251
|
+
for (const [className, cls] of Object.entries(surface.classes)) {
|
|
7252
|
+
symbols.push({
|
|
7253
|
+
id: `class:${className}`,
|
|
7254
|
+
kind: "service_accessor",
|
|
7255
|
+
fqName: className,
|
|
7256
|
+
displayName: className,
|
|
7257
|
+
visibility: "public",
|
|
7258
|
+
stability: cls.deprecationMessage ? "deprecated" : "stable",
|
|
7259
|
+
sourceKind: "generated_service_wrapper"
|
|
7260
|
+
});
|
|
7261
|
+
if (cls.constructorParams.length > 0) symbols.push({
|
|
7262
|
+
id: `ctor:${className}`,
|
|
7263
|
+
kind: "constructor",
|
|
7264
|
+
fqName: `${className}.constructor`,
|
|
7265
|
+
ownerFqName: className,
|
|
7266
|
+
displayName: `new ${className}`,
|
|
7267
|
+
visibility: "public",
|
|
7268
|
+
stability: "stable",
|
|
7269
|
+
sourceKind: "generated_resource_constructor",
|
|
7270
|
+
parameters: cls.constructorParams.map((p, i) => apiParamToCompatParam(p, i, language))
|
|
7271
|
+
});
|
|
7272
|
+
for (const [methodName, overloads] of Object.entries(cls.methods)) for (let oi = 0; oi < overloads.length; oi++) {
|
|
7273
|
+
const method = overloads[oi];
|
|
7274
|
+
const suffix = overloads.length > 1 ? `#${oi}` : "";
|
|
7275
|
+
symbols.push({
|
|
7276
|
+
id: `method:${className}.${methodName}${suffix}`,
|
|
7277
|
+
kind: "callable",
|
|
7278
|
+
fqName: `${className}.${methodName}`,
|
|
7279
|
+
ownerFqName: className,
|
|
7280
|
+
displayName: `${className}.${methodName}`,
|
|
7281
|
+
visibility: "public",
|
|
7282
|
+
stability: "stable",
|
|
7283
|
+
sourceKind: "generated_service_wrapper",
|
|
7284
|
+
parameters: method.params.map((p, i) => apiParamToCompatParam(p, i, language)),
|
|
7285
|
+
returns: { name: method.returnType }
|
|
6723
7286
|
});
|
|
6724
|
-
enumMatch = false;
|
|
6725
7287
|
}
|
|
6726
|
-
|
|
7288
|
+
for (const [propName, prop] of Object.entries(cls.properties)) symbols.push({
|
|
7289
|
+
id: `prop:${className}.${propName}`,
|
|
7290
|
+
kind: "property",
|
|
7291
|
+
fqName: `${className}.${propName}`,
|
|
7292
|
+
ownerFqName: className,
|
|
7293
|
+
displayName: `${className}.${propName}`,
|
|
7294
|
+
visibility: "public",
|
|
7295
|
+
stability: "stable",
|
|
7296
|
+
sourceKind: "generated_service_wrapper",
|
|
7297
|
+
typeRef: { name: prop.type }
|
|
7298
|
+
});
|
|
7299
|
+
}
|
|
7300
|
+
for (const [ifaceName, iface] of Object.entries(surface.interfaces)) {
|
|
7301
|
+
symbols.push({
|
|
7302
|
+
id: `iface:${ifaceName}`,
|
|
7303
|
+
kind: "alias",
|
|
7304
|
+
fqName: ifaceName,
|
|
7305
|
+
displayName: ifaceName,
|
|
7306
|
+
visibility: "public",
|
|
7307
|
+
stability: "stable",
|
|
7308
|
+
sourceKind: "generated_resource_constructor"
|
|
7309
|
+
});
|
|
7310
|
+
for (const [fieldName, field] of Object.entries(iface.fields)) symbols.push({
|
|
7311
|
+
id: `field:${ifaceName}.${fieldName}`,
|
|
7312
|
+
kind: "field",
|
|
7313
|
+
fqName: `${ifaceName}.${fieldName}`,
|
|
7314
|
+
ownerFqName: ifaceName,
|
|
7315
|
+
displayName: `${ifaceName}.${fieldName}`,
|
|
7316
|
+
visibility: "public",
|
|
7317
|
+
stability: "stable",
|
|
7318
|
+
sourceKind: "generated_resource_constructor",
|
|
7319
|
+
typeRef: { name: field.type }
|
|
7320
|
+
});
|
|
7321
|
+
if (iface.hasCustomConstructor) symbols.push({
|
|
7322
|
+
id: `ctor:${ifaceName}`,
|
|
7323
|
+
kind: "constructor",
|
|
7324
|
+
fqName: `${ifaceName}.constructor`,
|
|
7325
|
+
ownerFqName: ifaceName,
|
|
7326
|
+
displayName: `new ${ifaceName}`,
|
|
7327
|
+
visibility: "public",
|
|
7328
|
+
stability: "stable",
|
|
7329
|
+
sourceKind: "generated_resource_constructor"
|
|
7330
|
+
});
|
|
6727
7331
|
}
|
|
6728
|
-
for (const
|
|
6729
|
-
|
|
6730
|
-
|
|
7332
|
+
for (const [aliasName] of Object.entries(surface.typeAliases)) symbols.push({
|
|
7333
|
+
id: `alias:${aliasName}`,
|
|
7334
|
+
kind: "alias",
|
|
7335
|
+
fqName: aliasName,
|
|
7336
|
+
displayName: aliasName,
|
|
7337
|
+
visibility: "public",
|
|
7338
|
+
stability: "stable",
|
|
7339
|
+
sourceKind: "generated_resource_constructor"
|
|
6731
7340
|
});
|
|
6732
|
-
for (const [
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
message: `Export "${exp}" from "${path}" not found in generated output`
|
|
7341
|
+
for (const [enumName, enumDef] of Object.entries(surface.enums)) {
|
|
7342
|
+
symbols.push({
|
|
7343
|
+
id: `enum:${enumName}`,
|
|
7344
|
+
kind: "enum",
|
|
7345
|
+
fqName: enumName,
|
|
7346
|
+
displayName: enumName,
|
|
7347
|
+
visibility: "public",
|
|
7348
|
+
stability: "stable",
|
|
7349
|
+
sourceKind: "generated_enum"
|
|
7350
|
+
});
|
|
7351
|
+
for (const [memberName, memberValue] of Object.entries(enumDef.members)) symbols.push({
|
|
7352
|
+
id: `enum_member:${enumName}.${memberName}`,
|
|
7353
|
+
kind: "enum_member",
|
|
7354
|
+
fqName: `${enumName}.${memberName}`,
|
|
7355
|
+
ownerFqName: enumName,
|
|
7356
|
+
displayName: `${enumName}.${memberName}`,
|
|
7357
|
+
visibility: "public",
|
|
7358
|
+
stability: "stable",
|
|
7359
|
+
sourceKind: "generated_enum",
|
|
7360
|
+
value: memberValue
|
|
6753
7361
|
});
|
|
6754
7362
|
}
|
|
6755
7363
|
return {
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
additions
|
|
7364
|
+
schemaVersion: "1",
|
|
7365
|
+
source: { extractedAt: surface.extractedAt },
|
|
7366
|
+
policies: getDefaultPolicy(language),
|
|
7367
|
+
symbols
|
|
6761
7368
|
};
|
|
6762
7369
|
}
|
|
6763
|
-
/**
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
7370
|
+
/** Convert a legacy ApiParam to a CompatParameter. */
|
|
7371
|
+
function apiParamToCompatParam(param, position, language) {
|
|
7372
|
+
const policy = getDefaultPolicy(language);
|
|
7373
|
+
return {
|
|
7374
|
+
publicName: param.name,
|
|
7375
|
+
position,
|
|
7376
|
+
required: !param.optional,
|
|
7377
|
+
nullable: false,
|
|
7378
|
+
hasDefault: param.optional,
|
|
7379
|
+
passing: param.passingStyle ?? inferPassingStyle(language),
|
|
7380
|
+
type: { name: param.type },
|
|
7381
|
+
sensitivity: {
|
|
7382
|
+
order: policy.constructorOrderMatters,
|
|
7383
|
+
publicName: policy.methodParameterNamesArePublicApi,
|
|
7384
|
+
requiredness: true,
|
|
7385
|
+
type: true
|
|
7386
|
+
}
|
|
7387
|
+
};
|
|
6780
7388
|
}
|
|
6781
|
-
|
|
6782
|
-
|
|
7389
|
+
/** Infer the default parameter passing style for a language. */
|
|
7390
|
+
function inferPassingStyle(language) {
|
|
7391
|
+
switch (language) {
|
|
7392
|
+
case "python":
|
|
7393
|
+
case "ruby":
|
|
7394
|
+
case "elixir": return "keyword";
|
|
7395
|
+
case "php":
|
|
7396
|
+
case "kotlin":
|
|
7397
|
+
case "dotnet": return "named";
|
|
7398
|
+
case "node": return "options_object";
|
|
7399
|
+
default: return "positional";
|
|
7400
|
+
}
|
|
6783
7401
|
}
|
|
6784
7402
|
//#endregion
|
|
6785
7403
|
//#region src/verify/run-compat-check.ts
|
|
6786
|
-
async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
7404
|
+
async function runCompatCheck(baseline, outputDir, lang, spec, policyOverrides) {
|
|
6787
7405
|
const extractor = getExtractor(lang);
|
|
6788
7406
|
const candidate = await extractor.extract(outputDir);
|
|
6789
7407
|
let scopedBaseline = baseline;
|
|
@@ -6798,9 +7416,12 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
|
6798
7416
|
scopedToSpec = true;
|
|
6799
7417
|
scopedSymbolCount = Object.keys(scopedBaseline.interfaces).length + Object.keys(scopedBaseline.classes).length + Object.keys(scopedBaseline.typeAliases).length + Object.keys(scopedBaseline.enums).length;
|
|
6800
7418
|
}
|
|
6801
|
-
const
|
|
7419
|
+
const baseSnap = apiSurfaceToSnapshot(scopedBaseline);
|
|
7420
|
+
const candSnap = apiSurfaceToSnapshot(candidate);
|
|
7421
|
+
const langId = lang;
|
|
7422
|
+
const diff = diffSnapshots(baseSnap, candSnap, policyOverrides ? mergePolicy(getDefaultPolicy(langId), policyOverrides) : getDefaultPolicy(langId));
|
|
6802
7423
|
return {
|
|
6803
|
-
passed: diff.
|
|
7424
|
+
passed: diff.changes.every((c) => c.severity !== "breaking"),
|
|
6804
7425
|
diff,
|
|
6805
7426
|
scopedToSpec,
|
|
6806
7427
|
scopedSymbolCount
|
|
@@ -6808,7 +7429,6 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
|
6808
7429
|
}
|
|
6809
7430
|
//#endregion
|
|
6810
7431
|
//#region src/verify/run-overlay-retry-loop.ts
|
|
6811
|
-
const PATCHABLE_CATEGORIES = new Set(["public-api", "export-structure"]);
|
|
6812
7432
|
/** Generated file header markers used by various emitters. */
|
|
6813
7433
|
const GENERATED_MARKERS = [
|
|
6814
7434
|
"Code generated by oagen",
|
|
@@ -6853,37 +7473,9 @@ function cleanGeneratedFiles(dir) {
|
|
|
6853
7473
|
} catch {}
|
|
6854
7474
|
}
|
|
6855
7475
|
}
|
|
6856
|
-
/**
|
|
6857
|
-
* Load a smoke-manifest.json from the generated SDK directory and convert
|
|
6858
|
-
* it to ManifestEntry[] for the overlay builder.
|
|
6859
|
-
*/
|
|
6860
|
-
function loadManifestFromDir(outputDir) {
|
|
6861
|
-
const manifestPath = path$1.join(outputDir, "smoke-manifest.json");
|
|
6862
|
-
if (!existsSync(manifestPath)) return void 0;
|
|
6863
|
-
try {
|
|
6864
|
-
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
6865
|
-
if (Array.isArray(parsed)) return parsed;
|
|
6866
|
-
if (typeof parsed === "object" && parsed !== null) return Object.entries(parsed).map(([httpKey, value]) => {
|
|
6867
|
-
const spaceIdx = httpKey.indexOf(" ");
|
|
6868
|
-
const httpMethod = httpKey.slice(0, spaceIdx);
|
|
6869
|
-
const httpPath = httpKey.slice(spaceIdx + 1);
|
|
6870
|
-
const v = value;
|
|
6871
|
-
return {
|
|
6872
|
-
operationId: "",
|
|
6873
|
-
sdkResourceProperty: v.service ?? "",
|
|
6874
|
-
sdkMethodName: v.sdkMethod ?? "",
|
|
6875
|
-
httpMethod,
|
|
6876
|
-
path: httpPath,
|
|
6877
|
-
pathParams: [],
|
|
6878
|
-
bodyFields: [],
|
|
6879
|
-
queryFields: []
|
|
6880
|
-
};
|
|
6881
|
-
});
|
|
6882
|
-
} catch {}
|
|
6883
|
-
}
|
|
6884
7476
|
async function runOverlayRetryLoop(opts) {
|
|
6885
7477
|
const { baseline, parsedSpec, outputDir, lang, maxRetries, namespace, onRetry } = opts;
|
|
6886
|
-
let overlay = buildOverlayLookup(baseline,
|
|
7478
|
+
let overlay = buildOverlayLookup(baseline, readManifestSync(outputDir), parsedSpec, getExtractor(lang).hints);
|
|
6887
7479
|
let prevPreserved = -1;
|
|
6888
7480
|
const patchedPerIteration = [];
|
|
6889
7481
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -6900,21 +7492,21 @@ async function runOverlayRetryLoop(opts) {
|
|
|
6900
7492
|
patchedPerIteration,
|
|
6901
7493
|
compatResult
|
|
6902
7494
|
};
|
|
6903
|
-
const patchable = compatResult.diff.
|
|
7495
|
+
const patchable = compatResult.diff.changes.filter(isPatchableChange);
|
|
6904
7496
|
if (patchable.length === 0) return {
|
|
6905
7497
|
status: "no-patchable",
|
|
6906
7498
|
attempts: attempt,
|
|
6907
7499
|
patchedPerIteration,
|
|
6908
7500
|
compatResult
|
|
6909
7501
|
};
|
|
6910
|
-
const
|
|
6911
|
-
if (attempt > 0 &&
|
|
7502
|
+
const currentBreaking = compatResult.diff.summary.breaking;
|
|
7503
|
+
if (attempt > 0 && currentBreaking >= prevPreserved) return {
|
|
6912
7504
|
status: "stalled",
|
|
6913
7505
|
attempts: attempt,
|
|
6914
7506
|
patchedPerIteration,
|
|
6915
7507
|
compatResult
|
|
6916
7508
|
};
|
|
6917
|
-
prevPreserved =
|
|
7509
|
+
prevPreserved = currentBreaking;
|
|
6918
7510
|
patchedPerIteration.push(patchable.length);
|
|
6919
7511
|
onRetry?.(attempt + 1, maxRetries, patchable.length);
|
|
6920
7512
|
overlay = patchOverlay(overlay, patchable, baseline);
|
|
@@ -7103,17 +7695,20 @@ function runSmokeCheck(opts) {
|
|
|
7103
7695
|
function summarizeCompatCheck(result) {
|
|
7104
7696
|
const violationsByCategory = {};
|
|
7105
7697
|
const violationsBySeverity = {};
|
|
7106
|
-
for (const
|
|
7107
|
-
violationsByCategory[
|
|
7108
|
-
violationsBySeverity[
|
|
7698
|
+
for (const c of result.diff.changes) {
|
|
7699
|
+
violationsByCategory[c.category] = (violationsByCategory[c.category] ?? 0) + 1;
|
|
7700
|
+
violationsBySeverity[c.severity] = (violationsBySeverity[c.severity] ?? 0) + 1;
|
|
7109
7701
|
}
|
|
7702
|
+
const { breaking, softRisk, additive } = result.diff.summary;
|
|
7703
|
+
const totalBaselineSymbols = breaking + softRisk + additive + additive;
|
|
7704
|
+
const preservedSymbols = totalBaselineSymbols - breaking - softRisk;
|
|
7110
7705
|
return {
|
|
7111
|
-
totalBaselineSymbols
|
|
7112
|
-
preservedSymbols
|
|
7113
|
-
preservationScore:
|
|
7706
|
+
totalBaselineSymbols,
|
|
7707
|
+
preservedSymbols,
|
|
7708
|
+
preservationScore: totalBaselineSymbols > 0 ? Math.round(preservedSymbols / totalBaselineSymbols * 100) : 100,
|
|
7114
7709
|
violationsByCategory,
|
|
7115
7710
|
violationsBySeverity,
|
|
7116
|
-
additions:
|
|
7711
|
+
additions: additive,
|
|
7117
7712
|
scopedToSpec: result.scopedToSpec,
|
|
7118
7713
|
...result.scopedSymbolCount !== void 0 ? { scopedSymbolCount: result.scopedSymbolCount } : {}
|
|
7119
7714
|
};
|
|
@@ -7140,9 +7735,12 @@ function printCompatResult(result, baseline) {
|
|
|
7140
7735
|
console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
|
|
7141
7736
|
}
|
|
7142
7737
|
async function verifyCommand(opts) {
|
|
7143
|
-
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
|
|
7738
|
+
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform, compatConfig, compatReport, compatExplain } = opts;
|
|
7144
7739
|
const maxRetries = opts.maxRetries ?? 3;
|
|
7145
7740
|
const diagData = {};
|
|
7741
|
+
const effectiveFailOn = opts.compatFailOn ?? compatConfig?.failOn ?? "breaking";
|
|
7742
|
+
const effectiveReportPath = compatReport ?? compatConfig?.reportPath;
|
|
7743
|
+
const effectiveExplain = compatExplain ?? compatConfig?.explain ?? false;
|
|
7146
7744
|
let stepNum = 1;
|
|
7147
7745
|
const baseline = apiSurface ? JSON.parse(readFileSync(apiSurface, "utf-8")) : void 0;
|
|
7148
7746
|
if (apiSurface && baseline) {
|
|
@@ -7174,11 +7772,13 @@ async function verifyCommand(opts) {
|
|
|
7174
7772
|
};
|
|
7175
7773
|
const compatSummary = summarizeCompatCheck(compatFlow.compatResult);
|
|
7176
7774
|
printCompatResult(compatSummary, baseline);
|
|
7177
|
-
|
|
7178
|
-
if (
|
|
7775
|
+
const classifiedDiff = compatFlow.compatResult.diff;
|
|
7776
|
+
for (const c of classifiedDiff.changes) if (c.severity !== "additive") console.log(` [${c.category}] ${c.severity}: ${c.symbol} — ${c.message}`);
|
|
7777
|
+
const additiveCount = classifiedDiff.summary.additive;
|
|
7778
|
+
if (additiveCount > 0) console.log(` + ${additiveCount} new symbols added`);
|
|
7179
7779
|
if (diagnostics) {
|
|
7180
7780
|
diagData.compatCheck = compatSummary;
|
|
7181
|
-
if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed",
|
|
7781
|
+
if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed", compatSummary.preservationScore, compatFlow.patchedPerIteration);
|
|
7182
7782
|
}
|
|
7183
7783
|
if (compatFlow.status === "passed" && compatFlow.compatResult.passed) if (compatFlow.attempts > 0) console.log(`Compat: converged after ${compatFlow.attempts} retry iteration(s)`);
|
|
7184
7784
|
else console.log("Compat: passed");
|
|
@@ -7188,9 +7788,28 @@ async function verifyCommand(opts) {
|
|
|
7188
7788
|
console.log("Diagnostics written to verify-diagnostics.json");
|
|
7189
7789
|
}
|
|
7190
7790
|
if (compatFlow.status === "no-patchable") console.log("No patchable violations — cannot self-correct. Remaining violations require emitter code changes.");
|
|
7191
|
-
else if (compatFlow.status === "stalled") console.log(`Stalled
|
|
7791
|
+
else if (compatFlow.status === "stalled") console.log(`Stalled — overlay patching is not making progress.`);
|
|
7192
7792
|
throw new CommandError("\nCompat violations found — fix the emitter and re-run `oagen verify`.", "", 1);
|
|
7193
7793
|
}
|
|
7794
|
+
if (effectiveReportPath || effectiveExplain || (compatConfig?.allow?.length ?? 0) > 0) {
|
|
7795
|
+
const langId = lang;
|
|
7796
|
+
const approvals = compatConfig?.allow ?? [];
|
|
7797
|
+
const remaining = unapprovedChanges(classifiedDiff.changes, approvals, langId);
|
|
7798
|
+
const hasFailure = remaining.some((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
|
|
7799
|
+
if (effectiveExplain) {
|
|
7800
|
+
console.log("");
|
|
7801
|
+
console.log(formatHumanSummary(classifiedDiff, { explain: true }));
|
|
7802
|
+
}
|
|
7803
|
+
if (effectiveReportPath) {
|
|
7804
|
+
const report = generateReport(classifiedDiff);
|
|
7805
|
+
writeFileSync(effectiveReportPath, JSON.stringify(report, null, 2) + "\n");
|
|
7806
|
+
console.log(`Compat report written to ${effectiveReportPath}`);
|
|
7807
|
+
}
|
|
7808
|
+
if (hasFailure && effectiveFailOn !== "none") {
|
|
7809
|
+
const unapprovedBreaking = remaining.filter((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
|
|
7810
|
+
console.log(`\n${unapprovedBreaking.length} unapproved change(s) at or above '${effectiveFailOn}' threshold`);
|
|
7811
|
+
}
|
|
7812
|
+
}
|
|
7194
7813
|
stepNum++;
|
|
7195
7814
|
}
|
|
7196
7815
|
if (oldSpec && spec && apiSurface && baseline) {
|
|
@@ -7272,10 +7891,16 @@ function packageJson(lang) {
|
|
|
7272
7891
|
type: "module",
|
|
7273
7892
|
main: "dist/index.js",
|
|
7274
7893
|
types: "dist/index.d.ts",
|
|
7275
|
-
exports: {
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7894
|
+
exports: {
|
|
7895
|
+
".": {
|
|
7896
|
+
types: "./dist/index.d.ts",
|
|
7897
|
+
import: "./dist/index.js"
|
|
7898
|
+
},
|
|
7899
|
+
"./plugin": {
|
|
7900
|
+
types: "./dist/plugin.d.ts",
|
|
7901
|
+
import: "./dist/plugin.js"
|
|
7902
|
+
}
|
|
7903
|
+
},
|
|
7279
7904
|
scripts: {
|
|
7280
7905
|
build: "tsup",
|
|
7281
7906
|
test: "vitest run",
|
|
@@ -7324,7 +7949,7 @@ export default defineConfig({
|
|
|
7324
7949
|
function tsupConfig() {
|
|
7325
7950
|
return `import { defineConfig } from 'tsup';
|
|
7326
7951
|
export default defineConfig({
|
|
7327
|
-
entry: { index: 'src/index.ts' },
|
|
7952
|
+
entry: { index: 'src/index.ts', plugin: 'src/plugin.ts' },
|
|
7328
7953
|
format: ['esm'],
|
|
7329
7954
|
dts: true,
|
|
7330
7955
|
clean: true,
|
|
@@ -7332,19 +7957,34 @@ export default defineConfig({
|
|
|
7332
7957
|
});
|
|
7333
7958
|
`;
|
|
7334
7959
|
}
|
|
7335
|
-
function oagenConfig(
|
|
7336
|
-
const varName = `${toCamelCase(lang)}Emitter`;
|
|
7960
|
+
function oagenConfig() {
|
|
7337
7961
|
return `import type { OagenConfig } from '@workos/oagen';
|
|
7338
|
-
import {
|
|
7962
|
+
import { plugin } from './src/plugin.js';
|
|
7339
7963
|
|
|
7964
|
+
// Minimal config for local emitter development.
|
|
7965
|
+
// The canonical consumer config lives in the consumer project's oagen.config.ts,
|
|
7966
|
+
// which imports the plugin bundle from this package.
|
|
7340
7967
|
const config: OagenConfig = {
|
|
7341
|
-
|
|
7968
|
+
...plugin,
|
|
7342
7969
|
};
|
|
7343
7970
|
export default config;
|
|
7344
7971
|
`;
|
|
7345
7972
|
}
|
|
7973
|
+
function srcPlugin(lang) {
|
|
7974
|
+
const varName = `${toCamelCase(lang)}Emitter`;
|
|
7975
|
+
return `import type { OagenConfig } from '@workos/oagen';
|
|
7976
|
+
import { ${varName} } from './${lang}/index.js';
|
|
7977
|
+
|
|
7978
|
+
export const plugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
|
|
7979
|
+
emitters: [${varName}],
|
|
7980
|
+
extractors: [],
|
|
7981
|
+
smokeRunners: {},
|
|
7982
|
+
};
|
|
7983
|
+
`;
|
|
7984
|
+
}
|
|
7346
7985
|
function srcIndex(lang) {
|
|
7347
7986
|
return `export { ${`${toCamelCase(lang)}Emitter`} } from './${lang}/index.js';
|
|
7987
|
+
export { plugin } from './plugin.js';
|
|
7348
7988
|
`;
|
|
7349
7989
|
}
|
|
7350
7990
|
function gitignore() {
|
|
@@ -7390,8 +8030,8 @@ export const ${`${toCamelCase(lang)}Emitter`}: Emitter = {
|
|
|
7390
8030
|
return [];
|
|
7391
8031
|
},
|
|
7392
8032
|
|
|
7393
|
-
|
|
7394
|
-
return
|
|
8033
|
+
buildOperationsMap(_spec: ApiSpec, _ctx: EmitterContext) {
|
|
8034
|
+
return {};
|
|
7395
8035
|
},
|
|
7396
8036
|
|
|
7397
8037
|
fileHeader(): string {
|
|
@@ -7417,7 +8057,8 @@ async function initCommand(opts) {
|
|
|
7417
8057
|
["tsconfig.json", tsconfigJson()],
|
|
7418
8058
|
["vitest.config.ts", vitestConfig()],
|
|
7419
8059
|
["tsup.config.ts", tsupConfig()],
|
|
7420
|
-
["oagen.config.ts", oagenConfig(
|
|
8060
|
+
["oagen.config.ts", oagenConfig()],
|
|
8061
|
+
["src/plugin.ts", srcPlugin(lang)],
|
|
7421
8062
|
["src/index.ts", srcIndex(lang)],
|
|
7422
8063
|
[`src/${lang}/index.ts`, stubEmitter(lang)]
|
|
7423
8064
|
];
|
|
@@ -7526,12 +8167,30 @@ const CONFIG_NAMES = [
|
|
|
7526
8167
|
"oagen.config.js",
|
|
7527
8168
|
"oagen.config.mjs"
|
|
7528
8169
|
];
|
|
7529
|
-
|
|
8170
|
+
/**
|
|
8171
|
+
* Load an oagen config file.
|
|
8172
|
+
*
|
|
8173
|
+
* @param configPath - Explicit path to a config file. When provided, only that
|
|
8174
|
+
* file is attempted (no fallback search).
|
|
8175
|
+
* @param cwd - Directory to search when `configPath` is omitted. Defaults to
|
|
8176
|
+
* `process.cwd()`. The loader tries each name in `CONFIG_NAMES` in order.
|
|
8177
|
+
*/
|
|
8178
|
+
async function loadConfig(configPath, cwd = process.cwd()) {
|
|
8179
|
+
if (configPath) {
|
|
8180
|
+
const resolved = path$1.resolve(cwd, configPath);
|
|
8181
|
+
if (!existsSync(resolved)) throw new ConfigLoadError(`Config file not found: ${resolved}`, "Check that the path passed to --config exists.");
|
|
8182
|
+
try {
|
|
8183
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
8184
|
+
return mod.default ?? mod;
|
|
8185
|
+
} catch (err) {
|
|
8186
|
+
throw new ConfigLoadError(`Failed to load ${resolved}: ${err instanceof Error ? err.message : String(err)}`, resolved.endsWith(".ts") ? "TypeScript config files require tsx or ts-node. Use .mjs instead, or run via `npx tsx`." : "Check that the config file is valid ESM.");
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
7530
8189
|
for (const name of CONFIG_NAMES) {
|
|
7531
|
-
const
|
|
7532
|
-
if (!existsSync(
|
|
8190
|
+
const resolved = path$1.resolve(cwd, name);
|
|
8191
|
+
if (!existsSync(resolved)) continue;
|
|
7533
8192
|
try {
|
|
7534
|
-
const mod = await import(pathToFileURL(
|
|
8193
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
7535
8194
|
return mod.default ?? mod;
|
|
7536
8195
|
} catch (err) {
|
|
7537
8196
|
throw new ConfigLoadError(`Failed to load ${name}: ${err instanceof Error ? err.message : String(err)}`, name.endsWith(".ts") ? "TypeScript config files require tsx or ts-node. Use .mjs instead, or run via `npx tsx`." : "Check that the config file is valid ESM.");
|
|
@@ -7553,14 +8212,17 @@ function handleError(err) {
|
|
|
7553
8212
|
if (message) console.error(message);
|
|
7554
8213
|
process.exit(exitCode);
|
|
7555
8214
|
}
|
|
8215
|
+
const configArgIdx = process.argv.indexOf("--config");
|
|
8216
|
+
const explicitConfigPath = configArgIdx !== -1 ? process.argv[configArgIdx + 1] : void 0;
|
|
7556
8217
|
let configSmokeRunners;
|
|
7557
8218
|
let configOperationIdTransform;
|
|
7558
8219
|
let configSchemaNameTransform;
|
|
7559
8220
|
let configDocUrl;
|
|
7560
8221
|
let configOperationHints;
|
|
7561
8222
|
let configMountRules;
|
|
8223
|
+
let configCompat;
|
|
7562
8224
|
try {
|
|
7563
|
-
const config = await loadConfig();
|
|
8225
|
+
const config = await loadConfig(explicitConfigPath);
|
|
7564
8226
|
if (config) {
|
|
7565
8227
|
applyConfig(config);
|
|
7566
8228
|
configSmokeRunners = config.smokeRunners;
|
|
@@ -7569,11 +8231,12 @@ try {
|
|
|
7569
8231
|
configDocUrl = config.docUrl;
|
|
7570
8232
|
configOperationHints = config.operationHints;
|
|
7571
8233
|
configMountRules = config.mountRules;
|
|
8234
|
+
configCompat = config.compat;
|
|
7572
8235
|
}
|
|
7573
8236
|
} catch (err) {
|
|
7574
8237
|
handleError(err);
|
|
7575
8238
|
}
|
|
7576
|
-
const program = new Command().name("oagen").description("Framework for building OpenAPI SDK emitters").version("0.0.1");
|
|
8239
|
+
const program = new Command().name("oagen").description("Framework for building OpenAPI SDK emitters").version("0.0.1").option("--config <path>", "Path to oagen config file (default: oagen.config.ts in cwd)");
|
|
7577
8240
|
program.command("parse").description("Parse an OpenAPI spec and output IR as JSON").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").action((opts) => {
|
|
7578
8241
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7579
8242
|
if (!opts.spec) {
|
|
@@ -7582,7 +8245,7 @@ program.command("parse").description("Parse an OpenAPI spec and output IR as JSO
|
|
|
7582
8245
|
}
|
|
7583
8246
|
return parseCommand(opts);
|
|
7584
8247
|
});
|
|
7585
|
-
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("--
|
|
8248
|
+
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) => {
|
|
7586
8249
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7587
8250
|
if (!opts.spec) {
|
|
7588
8251
|
console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
|
|
@@ -7609,14 +8272,29 @@ program.command("extract").description("Extract public API surface from a live S
|
|
|
7609
8272
|
opts.output ??= `sdk-${opts.lang}-surface.json`;
|
|
7610
8273
|
extractCommand(opts).catch(handleError);
|
|
7611
8274
|
});
|
|
7612
|
-
program.command("
|
|
8275
|
+
program.command("compat-extract").description("Extract a compat snapshot from a live SDK and write .oagen-compat-snapshot.json").requiredOption("--sdk-path <path>", "Path to the live SDK").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Directory to write .oagen-compat-snapshot.json into").option("--spec <path>", "Path to OpenAPI spec — enriches symbols with operationId, route, and specSha").action((opts) => {
|
|
8276
|
+
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
8277
|
+
compatExtractCommand(opts).catch(handleError);
|
|
8278
|
+
});
|
|
8279
|
+
program.command("compat-diff").description("Diff two compat snapshot files and produce a classified change report").requiredOption("--baseline <path>", "Path to the baseline compat snapshot JSON").requiredOption("--candidate <path>", "Path to the candidate compat snapshot JSON").option("--output <path>", "Write machine-readable report to this path").option("--fail-on <level>", "Fail threshold: none, breaking, or soft-risk", "breaking").option("--explain", "Include provenance explanations in output").action((opts) => {
|
|
8280
|
+
compatDiffCommand(opts).catch(handleError);
|
|
8281
|
+
});
|
|
8282
|
+
program.command("compat-summary").description("Format compat report(s) as a markdown PR comment").requiredOption("--report <path...>", "Path(s) to compat report JSON(s) — pass multiple for cross-language rollup").option("--output <path>", "Write markdown to this file instead of stdout").action((opts) => {
|
|
8283
|
+
compatSummaryCommand(opts).catch(handleError);
|
|
8284
|
+
});
|
|
8285
|
+
program.command("verify").description("Run smoke tests (and optional compat check) against an already-generated SDK").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Path to the generated SDK").option("--api-surface <path>", "Baseline API surface JSON — enables compat verification").option("--raw-results <path>", "Path to an existing smoke baseline file to diff against").option("--smoke-config <path>", "Path to smoke config JSON for skip lists and service mappings").option("--smoke-runner <path>", "Path to a custom smoke runner script (overrides built-in sdk-test.ts)").option("--scope <mode>", "Compat scope: \"full\" compares all baseline symbols, \"spec-only\" compares only symbols derivable from the OpenAPI spec (default: spec-only when --spec is provided)").option("--old-spec <path>", "Previous OpenAPI spec — enables staleness detection for removed/renamed symbols").option("--namespace <name>", "SDK namespace/package name (used by retry loop for regeneration)").option("--diagnostics", "Output verify-diagnostics.json with structured violation breakdown").option("--max-retries <n>", "Max retry iterations for self-correcting overlay loop (default: 3)", "3").option("--compat-report <path>", "Write machine-readable compat report to this path").option("--compat-fail-on <level>", "Fail threshold: none, breaking, or soft-risk").option("--compat-baseline <path>", "Path to baseline compatibility snapshot").option("--compat-explain", "Include provenance explanations in compat output").action((opts) => {
|
|
7613
8286
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7614
8287
|
opts.smokeRunner ??= configSmokeRunners?.[opts.lang];
|
|
7615
8288
|
verifyCommand({
|
|
7616
8289
|
...opts,
|
|
7617
8290
|
maxRetries: parseInt(opts.maxRetries, 10),
|
|
7618
8291
|
operationIdTransform: configOperationIdTransform,
|
|
7619
|
-
schemaNameTransform: configSchemaNameTransform
|
|
8292
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
8293
|
+
compatConfig: configCompat,
|
|
8294
|
+
compatReport: opts.compatReport,
|
|
8295
|
+
compatFailOn: opts.compatFailOn,
|
|
8296
|
+
compatBaseline: opts.compatBaseline,
|
|
8297
|
+
compatExplain: opts.compatExplain
|
|
7620
8298
|
}).catch(handleError);
|
|
7621
8299
|
});
|
|
7622
8300
|
program.command("resolve").description("Resolve operation names from spec and output a review table").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").option("--format <format>", "Output format: table or json", "table").action((opts) => {
|