@workos/oagen 0.6.0 → 0.8.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 +1339 -451
- 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 +365 -35
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-CltJMcwG.mjs → diff-BaH9qa8k.mjs} +100 -23
- package/dist/diff-BaH9qa8k.mjs.map +1 -0
- package/dist/differ-CTl98yoS.d.mts +116 -0
- package/dist/differ-CTl98yoS.d.mts.map +1 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +177 -4
- package/dist/index.mjs.map +1 -1
- package/dist/{differ-CG5ZErKl.mjs → overlay-TYxHOCow.mjs} +1112 -553
- package/dist/overlay-TYxHOCow.mjs.map +1 -0
- package/dist/scripts/smoke/baseline.mjs +176 -3
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +43 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +176 -3
- package/dist/smoke.mjs.map +1 -1
- package/dist/tree-sitter-C53w_0WE.mjs.map +1 -1
- package/dist/{types-C7g5sLuX.d.mts → types-mTtkgy_h.d.mts} +213 -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 +12 -10
- 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/diff-CltJMcwG.mjs.map +0 -1
- package/dist/differ-CG5ZErKl.mjs.map +0 -1
- package/dist/types-C7g5sLuX.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
|
|
@@ -600,7 +601,7 @@ function findSchemaByName(pascalName, schemas) {
|
|
|
600
601
|
for (const [k, v] of Object.entries(schemas)) if (resolveSchemaName(k) === pascalName) return v;
|
|
601
602
|
return null;
|
|
602
603
|
}
|
|
603
|
-
/** Walk into a schema to find a field's sub-schema, resolving allOf. */
|
|
604
|
+
/** Walk into a schema to find a field's sub-schema, resolving allOf and oneOf/anyOf. */
|
|
604
605
|
function findNestedFieldSchema(fieldName, parentSchema, schemas) {
|
|
605
606
|
if (parentSchema.properties?.[fieldName]) return parentSchema.properties[fieldName];
|
|
606
607
|
if (parentSchema.allOf) for (const sub of parentSchema.allOf) {
|
|
@@ -612,6 +613,15 @@ function findNestedFieldSchema(fieldName, parentSchema, schemas) {
|
|
|
612
613
|
}
|
|
613
614
|
if (resolved.properties?.[fieldName]) return resolved.properties[fieldName];
|
|
614
615
|
}
|
|
616
|
+
for (const variant of [...parentSchema.oneOf ?? [], ...parentSchema.anyOf ?? []]) {
|
|
617
|
+
let resolved = variant;
|
|
618
|
+
if (variant.$ref) {
|
|
619
|
+
const segments = variant.$ref.split("/");
|
|
620
|
+
const refName = segments[segments.length - 1];
|
|
621
|
+
if (refName && schemas[refName]) resolved = schemas[refName];
|
|
622
|
+
}
|
|
623
|
+
if (resolved.properties?.[fieldName]) return resolved.properties[fieldName];
|
|
624
|
+
}
|
|
615
625
|
return null;
|
|
616
626
|
}
|
|
617
627
|
/** Extract a model (and nested models) from an inline field schema. */
|
|
@@ -777,6 +787,19 @@ function extractModel(name, schema, schemas) {
|
|
|
777
787
|
if (!fieldSchema) continue;
|
|
778
788
|
fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, requiredSet));
|
|
779
789
|
}
|
|
790
|
+
if (fields.length === 0 && (schema.oneOf || schema.anyOf)) {
|
|
791
|
+
const variants = schema.oneOf ?? schema.anyOf ?? [];
|
|
792
|
+
const emptyRequired = /* @__PURE__ */ new Set();
|
|
793
|
+
const seenFieldNames = /* @__PURE__ */ new Set();
|
|
794
|
+
for (const variant of variants) {
|
|
795
|
+
if (!variant.properties) continue;
|
|
796
|
+
for (const [fieldName, fieldSchema] of Object.entries(variant.properties)) {
|
|
797
|
+
if (!fieldSchema || seenFieldNames.has(fieldName)) continue;
|
|
798
|
+
seenFieldNames.add(fieldName);
|
|
799
|
+
fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, emptyRequired));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
780
803
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object" && schema.properties) {
|
|
781
804
|
if (Object.keys(schema.additionalProperties).length > 0) {
|
|
782
805
|
const valueType = schemaToTypeRef(schema.additionalProperties, "additionalProperties", name);
|
|
@@ -800,6 +823,7 @@ function extractModel(name, schema, schemas) {
|
|
|
800
823
|
function extractAllOfModel(name, schema, schemas) {
|
|
801
824
|
const fields = [];
|
|
802
825
|
const requiredSet = /* @__PURE__ */ new Set();
|
|
826
|
+
let resultDiscriminator;
|
|
803
827
|
for (const subSchema of schema.allOf ?? []) {
|
|
804
828
|
let resolved = subSchema;
|
|
805
829
|
if (subSchema.$ref && schemas) {
|
|
@@ -816,6 +840,22 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
816
840
|
});
|
|
817
841
|
if (f.required) requiredSet.add(f.name);
|
|
818
842
|
}
|
|
843
|
+
} else if (resolved.oneOf || resolved.anyOf) {
|
|
844
|
+
const variants = resolved.oneOf ?? resolved.anyOf ?? [];
|
|
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
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
819
859
|
} else {
|
|
820
860
|
if (resolved.required) for (const r of resolved.required) requiredSet.add(r);
|
|
821
861
|
if (resolved.properties) {
|
|
@@ -832,7 +872,42 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
832
872
|
return {
|
|
833
873
|
name,
|
|
834
874
|
description: schema.description,
|
|
835
|
-
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
|
|
836
911
|
};
|
|
837
912
|
}
|
|
838
913
|
function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
|
|
@@ -1672,6 +1747,94 @@ function inferDeeperContext(path) {
|
|
|
1672
1747
|
if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
|
|
1673
1748
|
return null;
|
|
1674
1749
|
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Extract mutually-exclusive parameter groups from the `x-mutually-exclusive-parameter-groups`
|
|
1752
|
+
* operation extension. Cross-references grouped parameter names against the already-extracted
|
|
1753
|
+
* IR parameter arrays so emitters get object-identity references.
|
|
1754
|
+
*/
|
|
1755
|
+
function extractParameterGroups(op, allIRParams, operationContext) {
|
|
1756
|
+
const raw = op["x-mutually-exclusive-parameter-groups"];
|
|
1757
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
1758
|
+
const paramByName = /* @__PURE__ */ new Map();
|
|
1759
|
+
for (const p of allIRParams) paramByName.set(p.name, p);
|
|
1760
|
+
const groups = [];
|
|
1761
|
+
for (const [groupName, groupDef] of Object.entries(raw)) {
|
|
1762
|
+
if (!groupDef || typeof groupDef !== "object") throw new Error(`Malformed x-mutually-exclusive-parameter-groups.${groupName} in ${operationContext}: expected an object with "optional" and "variants".`);
|
|
1763
|
+
if (typeof groupDef.optional !== "boolean") throw new Error(`Malformed x-mutually-exclusive-parameter-groups.${groupName}.optional in ${operationContext}: expected a boolean.`);
|
|
1764
|
+
if (!groupDef.variants || typeof groupDef.variants !== "object") throw new Error(`Malformed x-mutually-exclusive-parameter-groups.${groupName}.variants in ${operationContext}: expected an object mapping variant names to parameter name arrays.`);
|
|
1765
|
+
if (Object.keys(groupDef.variants).length === 0) throw new Error(`Malformed x-mutually-exclusive-parameter-groups.${groupName} in ${operationContext}: group has zero variants.`);
|
|
1766
|
+
const variants = Object.entries(groupDef.variants).map(([variantName, paramNames]) => {
|
|
1767
|
+
if (!Array.isArray(paramNames) || paramNames.length === 0) throw new Error(`Malformed x-mutually-exclusive-parameter-groups.${groupName}.variants.${variantName} in ${operationContext}: expected a non-empty array of parameter names.`);
|
|
1768
|
+
return {
|
|
1769
|
+
name: variantName,
|
|
1770
|
+
parameters: paramNames.map((pName) => {
|
|
1771
|
+
const irParam = paramByName.get(pName);
|
|
1772
|
+
if (!irParam) throw new Error(`x-mutually-exclusive-parameter-groups.${groupName}.variants.${variantName} references parameter "${pName}" which does not exist in the operation's parameters[] (${operationContext}).`);
|
|
1773
|
+
return irParam;
|
|
1774
|
+
})
|
|
1775
|
+
};
|
|
1776
|
+
});
|
|
1777
|
+
groups.push({
|
|
1778
|
+
name: groupName,
|
|
1779
|
+
optional: groupDef.optional,
|
|
1780
|
+
variants
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
return groups.length > 0 ? groups : void 0;
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Extract mutually-exclusive body parameter groups from the
|
|
1787
|
+
* `x-mutually-exclusive-body-groups` operation extension. Unlike query
|
|
1788
|
+
* parameter groups (whose parameters live in operation.queryParams), body
|
|
1789
|
+
* group parameters are synthetic — built from the oneOf variant schemas in
|
|
1790
|
+
* the request body. Emitters use the group's presence to generate sum-type
|
|
1791
|
+
* interfaces and custom JSON marshalling instead of flat optional fields.
|
|
1792
|
+
*/
|
|
1793
|
+
function extractBodyParameterGroups(op, operationContext) {
|
|
1794
|
+
const raw = op["x-mutually-exclusive-body-groups"];
|
|
1795
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
1796
|
+
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
1797
|
+
const variantFieldSchemas = /* @__PURE__ */ new Map();
|
|
1798
|
+
if (bodySchema) {
|
|
1799
|
+
for (const sub of bodySchema.allOf ?? []) for (const variant of sub.oneOf ?? []) if (variant.properties) {
|
|
1800
|
+
for (const [name, fieldSchema] of Object.entries(variant.properties)) if (fieldSchema) variantFieldSchemas.set(name, fieldSchema);
|
|
1801
|
+
}
|
|
1802
|
+
for (const variant of bodySchema.oneOf ?? []) if (variant.properties) {
|
|
1803
|
+
for (const [name, fieldSchema] of Object.entries(variant.properties)) if (fieldSchema) variantFieldSchemas.set(name, fieldSchema);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
const groups = [];
|
|
1807
|
+
for (const [groupName, groupDef] of Object.entries(raw)) {
|
|
1808
|
+
if (!groupDef || typeof groupDef !== "object") continue;
|
|
1809
|
+
if (typeof groupDef.optional !== "boolean") continue;
|
|
1810
|
+
if (!groupDef.variants || typeof groupDef.variants !== "object") continue;
|
|
1811
|
+
if (Object.keys(groupDef.variants).length === 0) continue;
|
|
1812
|
+
const variants = Object.entries(groupDef.variants).map(([variantName, paramNames]) => {
|
|
1813
|
+
if (!Array.isArray(paramNames) || paramNames.length === 0) throw new Error(`Malformed x-mutually-exclusive-body-groups.${groupName}.variants.${variantName} in ${operationContext}: expected a non-empty array of parameter names.`);
|
|
1814
|
+
return {
|
|
1815
|
+
name: variantName,
|
|
1816
|
+
parameters: paramNames.map((pName) => {
|
|
1817
|
+
const fieldSchema = variantFieldSchemas.get(pName);
|
|
1818
|
+
return {
|
|
1819
|
+
name: pName,
|
|
1820
|
+
type: fieldSchema ? schemaToTypeRef(fieldSchema, toPascalCase(pName)) : {
|
|
1821
|
+
kind: "primitive",
|
|
1822
|
+
type: "string"
|
|
1823
|
+
},
|
|
1824
|
+
required: false,
|
|
1825
|
+
description: fieldSchema?.description
|
|
1826
|
+
};
|
|
1827
|
+
})
|
|
1828
|
+
};
|
|
1829
|
+
});
|
|
1830
|
+
groups.push({
|
|
1831
|
+
name: groupName,
|
|
1832
|
+
optional: groupDef.optional,
|
|
1833
|
+
variants
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
return groups.length > 0 ? groups : void 0;
|
|
1837
|
+
}
|
|
1675
1838
|
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
|
|
1676
1839
|
const allParams = [...pathLevelParams, ...op.parameters ?? []];
|
|
1677
1840
|
const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
|
|
@@ -1697,6 +1860,16 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform,
|
|
|
1697
1860
|
};
|
|
1698
1861
|
} else if (paginationFromParams) pagination = paginationFromParams;
|
|
1699
1862
|
const security = extractOperationSecurity(op.security);
|
|
1863
|
+
const allIRParams = [
|
|
1864
|
+
...pathParams,
|
|
1865
|
+
...queryParams,
|
|
1866
|
+
...headerParams,
|
|
1867
|
+
...cookieParams
|
|
1868
|
+
];
|
|
1869
|
+
const opLabel = op.operationId ?? `${method.toUpperCase()} ${path}`;
|
|
1870
|
+
const queryParamGroups = extractParameterGroups(op, allIRParams, opLabel);
|
|
1871
|
+
const bodyParamGroups = extractBodyParameterGroups(op, opLabel);
|
|
1872
|
+
const parameterGroups = queryParamGroups || bodyParamGroups ? [...queryParamGroups ?? [], ...bodyParamGroups ?? []] : void 0;
|
|
1700
1873
|
return {
|
|
1701
1874
|
operation: {
|
|
1702
1875
|
name: inferOperationName(method, path, op.operationId, operationIdTransform),
|
|
@@ -1716,7 +1889,8 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform,
|
|
|
1716
1889
|
injectIdempotencyKey: hasIdempotencyHeader,
|
|
1717
1890
|
deprecated: op.deprecated || void 0,
|
|
1718
1891
|
async: op["x-oagen-async"],
|
|
1719
|
-
security
|
|
1892
|
+
security,
|
|
1893
|
+
parameterGroups
|
|
1720
1894
|
},
|
|
1721
1895
|
inlineModels
|
|
1722
1896
|
};
|
|
@@ -2971,6 +3145,7 @@ const nodeMergeAdapter = {
|
|
|
2971
3145
|
const newText = newMembers.map((m) => m.text).join("\n");
|
|
2972
3146
|
for (const match of newText.matchAll(/this\.(\w+)/g)) {
|
|
2973
3147
|
const propName = match[1];
|
|
3148
|
+
if (propName === "workos") continue;
|
|
2974
3149
|
if (propName && !existingMemberKeys.has(propName)) return true;
|
|
2975
3150
|
}
|
|
2976
3151
|
return false;
|
|
@@ -3627,6 +3802,40 @@ function buildIgnoredSymbolNames(docstrings, regions) {
|
|
|
3627
3802
|
for (const [name, info] of docstrings) if (regions.some((r) => info.declStartIndex >= r.startIndex && info.declStartIndex <= r.endIndex)) ignored.add(name);
|
|
3628
3803
|
return ignored;
|
|
3629
3804
|
}
|
|
3805
|
+
function clampIndex(value, contentLength) {
|
|
3806
|
+
if (!Number.isFinite(value)) return 0;
|
|
3807
|
+
if (value < 0) return 0;
|
|
3808
|
+
if (value > contentLength) return contentLength;
|
|
3809
|
+
return value;
|
|
3810
|
+
}
|
|
3811
|
+
/**
|
|
3812
|
+
* Resolve the declaration line start for insertion using the current content,
|
|
3813
|
+
* instead of relying on parser column arithmetic. This avoids misplaced inserts
|
|
3814
|
+
* when parser columns/offsets drift.
|
|
3815
|
+
*/
|
|
3816
|
+
function declarationLineStart(content, declStartIndex) {
|
|
3817
|
+
const idx = clampIndex(declStartIndex, content.length);
|
|
3818
|
+
const prevNewline = content.lastIndexOf("\n", Math.max(0, idx - 1));
|
|
3819
|
+
return prevNewline === -1 ? 0 : prevNewline + 1;
|
|
3820
|
+
}
|
|
3821
|
+
/**
|
|
3822
|
+
* Resolve a docstring range by searching for the nearest matching text before
|
|
3823
|
+
* the declaration start. Falls back to parser-provided offsets when search fails.
|
|
3824
|
+
*/
|
|
3825
|
+
function resolveDocRange(content, docText, declStartIndex, fallbackStart, fallbackEnd) {
|
|
3826
|
+
const safeDecl = clampIndex(declStartIndex, content.length);
|
|
3827
|
+
const safeFallbackStart = clampIndex(fallbackStart, content.length);
|
|
3828
|
+
const safeFallbackEnd = clampIndex(fallbackEnd, content.length);
|
|
3829
|
+
const found = content.lastIndexOf(docText, safeDecl);
|
|
3830
|
+
if (found !== -1) return {
|
|
3831
|
+
start: found,
|
|
3832
|
+
end: found + docText.length
|
|
3833
|
+
};
|
|
3834
|
+
return {
|
|
3835
|
+
start: safeFallbackStart,
|
|
3836
|
+
end: safeFallbackEnd
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3630
3839
|
/**
|
|
3631
3840
|
* Check if a tree-sitter grammar is configured for the given language.
|
|
3632
3841
|
*/
|
|
@@ -3675,15 +3884,17 @@ async function extractClassEndLines(source, language) {
|
|
|
3675
3884
|
if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
|
|
3676
3885
|
else map.set(name, { bodyEndLine: endRow + 1 });
|
|
3677
3886
|
}
|
|
3678
|
-
|
|
3679
|
-
if (
|
|
3680
|
-
if (
|
|
3681
|
-
for (const inner of
|
|
3887
|
+
function walk(node) {
|
|
3888
|
+
if (node.type === "class" || node.type === "class_definition" || node.type === "class_declaration") processClassNode(node);
|
|
3889
|
+
if (node.type === "decorated_definition") {
|
|
3890
|
+
for (const inner of node.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
|
|
3682
3891
|
}
|
|
3683
|
-
if (
|
|
3684
|
-
for (const inner of
|
|
3892
|
+
if (node.type === "export_statement") {
|
|
3893
|
+
for (const inner of node.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
|
|
3685
3894
|
}
|
|
3895
|
+
for (const child of node.namedChildren) walk(child);
|
|
3686
3896
|
}
|
|
3897
|
+
for (const child of tree.rootNode.children) walk(child);
|
|
3687
3898
|
return map;
|
|
3688
3899
|
}
|
|
3689
3900
|
/**
|
|
@@ -3903,15 +4114,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
3903
4114
|
if (genDoc) if (existInfo.docstring) {
|
|
3904
4115
|
if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
|
|
3905
4116
|
const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
|
|
4117
|
+
const range = resolveDocRange(result, existInfo.docstring.text, existInfo.declStartIndex, existInfo.docstring.startIndex, existInfo.docstring.endIndex);
|
|
3906
4118
|
edits.push({
|
|
3907
|
-
start:
|
|
3908
|
-
end:
|
|
4119
|
+
start: range.start,
|
|
4120
|
+
end: range.end,
|
|
3909
4121
|
newText
|
|
3910
4122
|
});
|
|
3911
4123
|
docstringUpdates++;
|
|
3912
4124
|
}
|
|
3913
4125
|
} else {
|
|
3914
|
-
const lineStart = existInfo.declStartIndex
|
|
4126
|
+
const lineStart = declarationLineStart(result, existInfo.declStartIndex);
|
|
3915
4127
|
const indent = " ".repeat(existInfo.declColumn);
|
|
3916
4128
|
edits.push({
|
|
3917
4129
|
start: lineStart,
|
|
@@ -3929,15 +4141,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
3929
4141
|
if (existMember.docstring) {
|
|
3930
4142
|
if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
|
|
3931
4143
|
const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
|
|
4144
|
+
const range = resolveDocRange(result, existMember.docstring.text, existMember.declStartIndex, existMember.docstring.startIndex, existMember.docstring.endIndex);
|
|
3932
4145
|
edits.push({
|
|
3933
|
-
start:
|
|
3934
|
-
end:
|
|
4146
|
+
start: range.start,
|
|
4147
|
+
end: range.end,
|
|
3935
4148
|
newText
|
|
3936
4149
|
});
|
|
3937
4150
|
docstringUpdates++;
|
|
3938
4151
|
}
|
|
3939
4152
|
} else {
|
|
3940
|
-
const lineStart = existMember.declStartIndex
|
|
4153
|
+
const lineStart = declarationLineStart(result, existMember.declStartIndex);
|
|
3941
4154
|
const indent = " ".repeat(existMember.declColumn);
|
|
3942
4155
|
edits.push({
|
|
3943
4156
|
start: lineStart,
|
|
@@ -3982,15 +4195,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
3982
4195
|
if (existMember.docstring.text.includes("@oagen-ignore")) continue;
|
|
3983
4196
|
if (existMember.docstring.text !== genMember.docstring.text) {
|
|
3984
4197
|
const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
|
|
4198
|
+
const range = resolveDocRange(result, existMember.docstring.text, existMember.declStartIndex, existMember.docstring.startIndex, existMember.docstring.endIndex);
|
|
3985
4199
|
edits.push({
|
|
3986
|
-
start:
|
|
3987
|
-
end:
|
|
4200
|
+
start: range.start,
|
|
4201
|
+
end: range.end,
|
|
3988
4202
|
newText
|
|
3989
4203
|
});
|
|
3990
4204
|
docstringUpdates++;
|
|
3991
4205
|
}
|
|
3992
4206
|
} else {
|
|
3993
|
-
const lineStart = existMember.declStartIndex
|
|
4207
|
+
const lineStart = declarationLineStart(result, existMember.declStartIndex);
|
|
3994
4208
|
const indent = " ".repeat(existMember.declColumn);
|
|
3995
4209
|
edits.push({
|
|
3996
4210
|
start: lineStart,
|
|
@@ -4163,7 +4377,7 @@ async function overwriteWithPreservedRegions(existingContent, generatedContent,
|
|
|
4163
4377
|
});
|
|
4164
4378
|
}
|
|
4165
4379
|
if (blocks.length === 0) return generatedContent;
|
|
4166
|
-
const result =
|
|
4380
|
+
const result = stripIgnoreRegions(generatedLines);
|
|
4167
4381
|
spliceExtraImports(existingLines, result);
|
|
4168
4382
|
const classEndLines = await extractClassEndLines(result.join("\n"), language);
|
|
4169
4383
|
const blocksByClass = /* @__PURE__ */ new Map();
|
|
@@ -4230,10 +4444,33 @@ async function overwriteWithPreservedRegions(existingContent, generatedContent,
|
|
|
4230
4444
|
for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
|
|
4231
4445
|
return result.join("\n");
|
|
4232
4446
|
}
|
|
4447
|
+
/**
|
|
4448
|
+
* Remove every `@oagen-ignore-start`..`@oagen-ignore-end` region from the
|
|
4449
|
+
* supplied lines, collapsing stretches of blank lines that get left behind.
|
|
4450
|
+
* Used to drop generator-emitted seed blocks before splicing the on-disk
|
|
4451
|
+
* versions back in — otherwise every regen doubles up.
|
|
4452
|
+
*/
|
|
4453
|
+
function stripIgnoreRegions(lines) {
|
|
4454
|
+
const out = [];
|
|
4455
|
+
let skipping = false;
|
|
4456
|
+
for (const line of lines) {
|
|
4457
|
+
if (!skipping && line.includes("@oagen-ignore-start")) {
|
|
4458
|
+
skipping = true;
|
|
4459
|
+
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
4460
|
+
continue;
|
|
4461
|
+
}
|
|
4462
|
+
if (skipping) {
|
|
4463
|
+
if (line.includes("@oagen-ignore-end")) skipping = false;
|
|
4464
|
+
continue;
|
|
4465
|
+
}
|
|
4466
|
+
out.push(line);
|
|
4467
|
+
}
|
|
4468
|
+
return out;
|
|
4469
|
+
}
|
|
4233
4470
|
/** Scan backwards from `lineIdx` to find the nearest class declaration name. */
|
|
4234
4471
|
function findContainingClass(lines, lineIdx) {
|
|
4235
4472
|
for (let j = lineIdx - 1; j >= 0; j--) {
|
|
4236
|
-
const m = lines[j].match(
|
|
4473
|
+
const m = lines[j].match(/^\s*(?:export\s+)?class\s+(\w+)/);
|
|
4237
4474
|
if (m) return m[1];
|
|
4238
4475
|
}
|
|
4239
4476
|
return "";
|
|
@@ -4243,7 +4480,7 @@ function findContainingClass(lines, lineIdx) {
|
|
|
4243
4480
|
* by scanning backwards from `beforeLine`.
|
|
4244
4481
|
*/
|
|
4245
4482
|
function findClassDeclarationStart(lines, className, beforeLine) {
|
|
4246
|
-
const classPattern = new RegExp(
|
|
4483
|
+
const classPattern = new RegExp(`^\\s*(?:export\\s+)?class\\s+${className}\\b`);
|
|
4247
4484
|
for (let i = beforeLine - 1; i >= 0; i--) {
|
|
4248
4485
|
if (!classPattern.test(lines[i])) continue;
|
|
4249
4486
|
let start = i;
|
|
@@ -4712,7 +4949,8 @@ async function resolveFileToDirectoryConflicts(files, targetDir) {
|
|
|
4712
4949
|
};
|
|
4713
4950
|
}
|
|
4714
4951
|
async function integrateGeneratedFiles(opts) {
|
|
4715
|
-
const
|
|
4952
|
+
const mapped = mapFilesForTargetIntegration(opts.files, opts.language);
|
|
4953
|
+
const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapped, opts.language), opts.targetDir);
|
|
4716
4954
|
const result = await writeFiles(resolved, opts.targetDir, {
|
|
4717
4955
|
language: opts.language,
|
|
4718
4956
|
header: opts.header
|
|
@@ -4722,7 +4960,7 @@ async function integrateGeneratedFiles(opts) {
|
|
|
4722
4960
|
} catch {}
|
|
4723
4961
|
return {
|
|
4724
4962
|
...result,
|
|
4725
|
-
emittedPaths:
|
|
4963
|
+
emittedPaths: mapped.map((f) => f.path)
|
|
4726
4964
|
};
|
|
4727
4965
|
}
|
|
4728
4966
|
//#endregion
|
|
@@ -4746,6 +4984,7 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
|
4746
4984
|
if (!formatCmd) return;
|
|
4747
4985
|
const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
|
|
4748
4986
|
const batchSize = formatCmd.batchSize ?? 100;
|
|
4987
|
+
process.stderr.write(`[oagen] formatting ${absolutePaths.length} file(s) with ${formatCmd.cmd} (batch ${batchSize})\n`);
|
|
4749
4988
|
for (let i = 0; i < absolutePaths.length; i += batchSize) {
|
|
4750
4989
|
const batch = absolutePaths.slice(i, i + batchSize);
|
|
4751
4990
|
try {
|
|
@@ -4754,7 +4993,10 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
|
4754
4993
|
maxBuffer: 10 * 1024 * 1024,
|
|
4755
4994
|
timeout: 12e4
|
|
4756
4995
|
});
|
|
4757
|
-
} catch {
|
|
4996
|
+
} catch (err) {
|
|
4997
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4998
|
+
process.stderr.write(`[oagen] formatter batch failed: ${msg}\n`);
|
|
4999
|
+
}
|
|
4758
5000
|
}
|
|
4759
5001
|
}
|
|
4760
5002
|
//#endregion
|
|
@@ -4779,7 +5021,7 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
|
4779
5021
|
* one-line notice is printed — no surprise deletions.
|
|
4780
5022
|
*/
|
|
4781
5023
|
const MANIFEST_FILENAME = ".oagen-manifest.json";
|
|
4782
|
-
const MANIFEST_VERSION =
|
|
5024
|
+
const MANIFEST_VERSION = 2;
|
|
4783
5025
|
/** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
|
|
4784
5026
|
async function readManifest(dir) {
|
|
4785
5027
|
const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
|
|
@@ -4806,7 +5048,14 @@ async function writeManifest(dir, opts) {
|
|
|
4806
5048
|
const manifest = {
|
|
4807
5049
|
version: MANIFEST_VERSION,
|
|
4808
5050
|
language: opts.language,
|
|
5051
|
+
...opts.sdkName !== void 0 ? { sdkName: opts.sdkName } : {},
|
|
4809
5052
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5053
|
+
...opts.specSha !== void 0 ? { specSha: opts.specSha } : {},
|
|
5054
|
+
...opts.specPath !== void 0 ? { specPath: opts.specPath } : {},
|
|
5055
|
+
...opts.emitterSha !== void 0 ? { emitterSha: opts.emitterSha } : {},
|
|
5056
|
+
...opts.emitterVersion !== void 0 ? { emitterVersion: opts.emitterVersion } : {},
|
|
5057
|
+
...opts.configSha !== void 0 ? { configSha: opts.configSha } : {},
|
|
5058
|
+
...opts.compatSchemaVersion !== void 0 ? { compatSchemaVersion: opts.compatSchemaVersion } : {},
|
|
4810
5059
|
files: [...new Set(opts.files)].sort()
|
|
4811
5060
|
};
|
|
4812
5061
|
const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
|
|
@@ -4917,6 +5166,8 @@ async function generate(spec, emitter, options) {
|
|
|
4917
5166
|
header,
|
|
4918
5167
|
noPrune: options.noPrune
|
|
4919
5168
|
});
|
|
5169
|
+
const allOutputFiles = [...writeResult.written, ...writeResult.merged];
|
|
5170
|
+
if (allOutputFiles.length > 0) await formatTargetFiles(emitter, options.outputDir, allOutputFiles);
|
|
4920
5171
|
if (options.target) {
|
|
4921
5172
|
const targetPrevManifest = targetManifestForCtx;
|
|
4922
5173
|
const targetResult = await integrateGeneratedFiles({
|
|
@@ -5458,12 +5709,18 @@ function buildModelNameMap(surface, spec, lookup, hints, options) {
|
|
|
5458
5709
|
}
|
|
5459
5710
|
}
|
|
5460
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
|
+
}
|
|
5461
5718
|
/**
|
|
5462
|
-
* Patch overlay with
|
|
5719
|
+
* Patch overlay with classified changes from a failed verification.
|
|
5463
5720
|
* Adds explicit name mappings for symbols that were generated with wrong names.
|
|
5464
5721
|
* Returns a new OverlayLookup (immutable).
|
|
5465
5722
|
*/
|
|
5466
|
-
function patchOverlay(overlay,
|
|
5723
|
+
function patchOverlay(overlay, changes, baseline) {
|
|
5467
5724
|
const patched = {
|
|
5468
5725
|
methodByOperation: new Map(overlay.methodByOperation),
|
|
5469
5726
|
httpKeyByMethod: new Map(overlay.httpKeyByMethod),
|
|
@@ -5474,42 +5731,33 @@ function patchOverlay(overlay, violations, baseline) {
|
|
|
5474
5731
|
fileBySymbol: new Map(overlay.fileBySymbol)
|
|
5475
5732
|
};
|
|
5476
5733
|
let warnedManifest = false;
|
|
5477
|
-
for (const
|
|
5478
|
-
if (
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
}
|
|
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 smoke-manifest.json available. Method-level violations cannot be auto-patched. Implement generateManifest 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
|
+
});
|
|
5498
5754
|
}
|
|
5499
|
-
if (baseline.interfaces[className]) patched.interfaceByName.set(className, className);
|
|
5500
|
-
} else if (parts.length === 1) {
|
|
5501
|
-
const name = parts[0];
|
|
5502
|
-
if (baseline.interfaces[name]) patched.interfaceByName.set(name, name);
|
|
5503
|
-
if (baseline.typeAliases[name]) patched.typeAliasByName.set(name, name);
|
|
5504
|
-
}
|
|
5505
|
-
}
|
|
5506
|
-
if (v.category === "export-structure") {
|
|
5507
|
-
const match = v.symbolPath.match(/^exports\[(.+?)\]\.(.+)$/);
|
|
5508
|
-
if (match) {
|
|
5509
|
-
const [, path, symbol] = match;
|
|
5510
|
-
if (!patched.requiredExports.has(path)) patched.requiredExports.set(path, /* @__PURE__ */ new Set());
|
|
5511
|
-
patched.requiredExports.get(path).add(symbol);
|
|
5512
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);
|
|
5513
5761
|
}
|
|
5514
5762
|
}
|
|
5515
5763
|
return patched;
|
|
@@ -6046,6 +6294,327 @@ async function extractCommand(opts) {
|
|
|
6046
6294
|
console.log(`Extracted ${symbolCount} symbols → ${opts.output}`);
|
|
6047
6295
|
}
|
|
6048
6296
|
//#endregion
|
|
6297
|
+
//#region src/cli/compat-extract.ts
|
|
6298
|
+
const SNAPSHOT_FILENAME = ".oagen-compat-snapshot.json";
|
|
6299
|
+
async function compatExtractCommand(opts) {
|
|
6300
|
+
const extractor = getExtractor(opts.lang);
|
|
6301
|
+
console.log(`Extracting ${opts.lang} compat snapshot from ${opts.sdkPath}...`);
|
|
6302
|
+
const snapshot = await extractor.extractSnapshot(opts.sdkPath);
|
|
6303
|
+
if (opts.spec) {
|
|
6304
|
+
const specContent = readFileSync(opts.spec, "utf-8");
|
|
6305
|
+
snapshot.source.specSha = createHash("sha256").update(specContent).digest("hex");
|
|
6306
|
+
enrichWithSpecContext(snapshot, await parseSpec(opts.spec));
|
|
6307
|
+
}
|
|
6308
|
+
const outputPath = resolve(opts.output, SNAPSHOT_FILENAME);
|
|
6309
|
+
writeFileSync(outputPath, JSON.stringify(snapshot, null, 2));
|
|
6310
|
+
console.log(`Extracted ${snapshot.symbols.length} symbols → ${outputPath}`);
|
|
6311
|
+
}
|
|
6312
|
+
/**
|
|
6313
|
+
* Enrich snapshot symbols with operationId and route from the parsed spec.
|
|
6314
|
+
*
|
|
6315
|
+
* Matches callable symbols to spec operations by comparing method names
|
|
6316
|
+
* derived from the spec against the symbol's fqName.
|
|
6317
|
+
*/
|
|
6318
|
+
function enrichWithSpecContext(snapshot, spec) {
|
|
6319
|
+
const opLookup = /* @__PURE__ */ new Map();
|
|
6320
|
+
for (const service of spec.services) for (const op of service.operations) {
|
|
6321
|
+
const key = `${service.name}.${op.name}`;
|
|
6322
|
+
opLookup.set(key, {
|
|
6323
|
+
operationId: op.name,
|
|
6324
|
+
method: op.httpMethod,
|
|
6325
|
+
path: op.path
|
|
6326
|
+
});
|
|
6327
|
+
}
|
|
6328
|
+
for (const sym of snapshot.symbols) {
|
|
6329
|
+
if (sym.kind !== "callable") continue;
|
|
6330
|
+
const match = opLookup.get(sym.fqName);
|
|
6331
|
+
if (match) {
|
|
6332
|
+
sym.operationId = match.operationId;
|
|
6333
|
+
sym.route = {
|
|
6334
|
+
method: match.method,
|
|
6335
|
+
path: match.path
|
|
6336
|
+
};
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
6340
|
+
//#endregion
|
|
6341
|
+
//#region src/compat/schema.ts
|
|
6342
|
+
/** Validate the basic structure of a parsed compat snapshot. */
|
|
6343
|
+
function validateSnapshot(data) {
|
|
6344
|
+
if (typeof data !== "object" || data === null) return false;
|
|
6345
|
+
const obj = data;
|
|
6346
|
+
return typeof obj.schemaVersion === "string" && typeof obj.source === "object" && obj.source !== null && typeof obj.policies === "object" && obj.policies !== null && Array.isArray(obj.symbols);
|
|
6347
|
+
}
|
|
6348
|
+
//#endregion
|
|
6349
|
+
//#region src/compat/config.ts
|
|
6350
|
+
const BREAKING_CATEGORIES = new Set([
|
|
6351
|
+
"symbol_removed",
|
|
6352
|
+
"symbol_renamed",
|
|
6353
|
+
"parameter_removed",
|
|
6354
|
+
"parameter_renamed",
|
|
6355
|
+
"parameter_requiredness_increased",
|
|
6356
|
+
"parameter_type_narrowed",
|
|
6357
|
+
"parameter_position_changed_order_sensitive",
|
|
6358
|
+
"constructor_position_changed_order_sensitive",
|
|
6359
|
+
"named_arg_name_removed",
|
|
6360
|
+
"keyword_name_removed",
|
|
6361
|
+
"overload_removed",
|
|
6362
|
+
"union_wrapper_migration_without_compat_alias",
|
|
6363
|
+
"field_type_changed",
|
|
6364
|
+
"return_type_changed",
|
|
6365
|
+
"enum_member_value_changed"
|
|
6366
|
+
]);
|
|
6367
|
+
const SOFT_RISK_CATEGORIES = new Set([
|
|
6368
|
+
"parameter_added_non_terminal_optional",
|
|
6369
|
+
"constructor_reordered_named_friendly",
|
|
6370
|
+
"default_value_changed",
|
|
6371
|
+
"wrapper_stricter_than_previous_sdk_but_matches_spec",
|
|
6372
|
+
"doc_surface_drift"
|
|
6373
|
+
]);
|
|
6374
|
+
/** Get the default severity for a change category. */
|
|
6375
|
+
function defaultSeverityForCategory(category) {
|
|
6376
|
+
if (BREAKING_CATEGORIES.has(category)) return "breaking";
|
|
6377
|
+
if (SOFT_RISK_CATEGORIES.has(category)) return "soft-risk";
|
|
6378
|
+
return "additive";
|
|
6379
|
+
}
|
|
6380
|
+
/** Check whether a severity meets or exceeds a fail threshold. */
|
|
6381
|
+
function severityMeetsThreshold(severity, threshold) {
|
|
6382
|
+
if (threshold === "none") return false;
|
|
6383
|
+
if (threshold === "soft-risk") return severity === "breaking" || severity === "soft-risk";
|
|
6384
|
+
return severity === "breaking";
|
|
6385
|
+
}
|
|
6386
|
+
//#endregion
|
|
6387
|
+
//#region src/compat/classify.ts
|
|
6388
|
+
/**
|
|
6389
|
+
* Classify changes between a baseline and candidate symbol.
|
|
6390
|
+
* Returns one or more classified changes for the diff.
|
|
6391
|
+
*/
|
|
6392
|
+
function classifySymbolChanges(baseline, candidate, policy) {
|
|
6393
|
+
const changes = [];
|
|
6394
|
+
if (!candidate) {
|
|
6395
|
+
changes.push(makeChange({
|
|
6396
|
+
category: "symbol_removed",
|
|
6397
|
+
symbol: baseline.fqName,
|
|
6398
|
+
old: { symbol: baseline.fqName },
|
|
6399
|
+
new: { symbol: "(removed)" },
|
|
6400
|
+
message: `Symbol "${baseline.displayName}" was removed`,
|
|
6401
|
+
policy
|
|
6402
|
+
}));
|
|
6403
|
+
return changes;
|
|
6404
|
+
}
|
|
6405
|
+
if (baseline.fqName !== candidate.fqName && baseline.id === candidate.id) changes.push(makeChange({
|
|
6406
|
+
category: "symbol_renamed",
|
|
6407
|
+
symbol: baseline.fqName,
|
|
6408
|
+
old: { name: baseline.fqName },
|
|
6409
|
+
new: { name: candidate.fqName },
|
|
6410
|
+
message: `Symbol renamed from "${baseline.displayName}" to "${candidate.displayName}"`,
|
|
6411
|
+
policy
|
|
6412
|
+
}));
|
|
6413
|
+
if (baseline.parameters && candidate.parameters) changes.push(...classifyParameterChanges(baseline, candidate, policy));
|
|
6414
|
+
if (baseline.returns && candidate.returns && baseline.returns.name !== candidate.returns.name) changes.push(makeChange({
|
|
6415
|
+
category: "return_type_changed",
|
|
6416
|
+
symbol: baseline.fqName,
|
|
6417
|
+
old: { returnType: baseline.returns.name },
|
|
6418
|
+
new: { returnType: candidate.returns.name },
|
|
6419
|
+
message: `Return type changed for "${baseline.displayName}" from "${baseline.returns.name}" to "${candidate.returns.name}"`,
|
|
6420
|
+
policy
|
|
6421
|
+
}));
|
|
6422
|
+
if (baseline.typeRef && candidate.typeRef && baseline.typeRef.name !== candidate.typeRef.name) changes.push(makeChange({
|
|
6423
|
+
category: "field_type_changed",
|
|
6424
|
+
symbol: baseline.fqName,
|
|
6425
|
+
old: { type: baseline.typeRef.name },
|
|
6426
|
+
new: { type: candidate.typeRef.name },
|
|
6427
|
+
message: `Type changed for "${baseline.displayName}" from "${baseline.typeRef.name}" to "${candidate.typeRef.name}"`,
|
|
6428
|
+
policy
|
|
6429
|
+
}));
|
|
6430
|
+
if (baseline.kind === "enum_member" && candidate.kind === "enum_member" && baseline.value !== void 0 && candidate.value !== void 0 && baseline.value !== candidate.value) changes.push(makeChange({
|
|
6431
|
+
category: "enum_member_value_changed",
|
|
6432
|
+
symbol: baseline.fqName,
|
|
6433
|
+
old: { value: String(baseline.value) },
|
|
6434
|
+
new: { value: String(candidate.value) },
|
|
6435
|
+
message: `Enum value changed for "${baseline.displayName}" from "${baseline.value}" to "${candidate.value}"`,
|
|
6436
|
+
policy
|
|
6437
|
+
}));
|
|
6438
|
+
return changes;
|
|
6439
|
+
}
|
|
6440
|
+
/**
|
|
6441
|
+
* Classify parameter-level changes between two symbol versions.
|
|
6442
|
+
*/
|
|
6443
|
+
function classifyParameterChanges(baseline, candidate, policy) {
|
|
6444
|
+
const changes = [];
|
|
6445
|
+
const baseParams = baseline.parameters ?? [];
|
|
6446
|
+
const candParams = candidate.parameters ?? [];
|
|
6447
|
+
const baseByName = new Map(baseParams.map((p) => [p.publicName, p]));
|
|
6448
|
+
const candByName = new Map(candParams.map((p) => [p.publicName, p]));
|
|
6449
|
+
const isConstructor = baseline.kind === "constructor";
|
|
6450
|
+
for (const baseParam of baseParams) {
|
|
6451
|
+
const candParam = candByName.get(baseParam.publicName);
|
|
6452
|
+
if (!candParam) {
|
|
6453
|
+
const positionalMatch = candParams[baseParam.position];
|
|
6454
|
+
if (positionalMatch && !baseByName.has(positionalMatch.publicName)) {
|
|
6455
|
+
const isBreakingRename = parameterNameIsPublicApi(baseParam, policy, isConstructor);
|
|
6456
|
+
changes.push(makeChange({
|
|
6457
|
+
category: "parameter_renamed",
|
|
6458
|
+
symbol: baseline.fqName,
|
|
6459
|
+
old: { parameter: baseParam.publicName },
|
|
6460
|
+
new: { parameter: positionalMatch.publicName },
|
|
6461
|
+
message: `Parameter "${baseParam.publicName}" renamed to "${positionalMatch.publicName}" on "${baseline.displayName}"`,
|
|
6462
|
+
policy,
|
|
6463
|
+
severityOverride: isBreakingRename ? void 0 : "soft-risk"
|
|
6464
|
+
}));
|
|
6465
|
+
} else changes.push(makeChange({
|
|
6466
|
+
category: "parameter_removed",
|
|
6467
|
+
symbol: baseline.fqName,
|
|
6468
|
+
old: { parameter: baseParam.publicName },
|
|
6469
|
+
new: { parameter: "(removed)" },
|
|
6470
|
+
message: `Parameter "${baseParam.publicName}" removed from "${baseline.displayName}"`,
|
|
6471
|
+
policy
|
|
6472
|
+
}));
|
|
6473
|
+
continue;
|
|
6474
|
+
}
|
|
6475
|
+
if (!baseParam.required && candParam.required) changes.push(makeChange({
|
|
6476
|
+
category: "parameter_requiredness_increased",
|
|
6477
|
+
symbol: baseline.fqName,
|
|
6478
|
+
old: {
|
|
6479
|
+
parameter: baseParam.publicName,
|
|
6480
|
+
required: "false"
|
|
6481
|
+
},
|
|
6482
|
+
new: {
|
|
6483
|
+
parameter: candParam.publicName,
|
|
6484
|
+
required: "true"
|
|
6485
|
+
},
|
|
6486
|
+
message: `Parameter "${baseParam.publicName}" became required on "${baseline.displayName}"`,
|
|
6487
|
+
policy
|
|
6488
|
+
}));
|
|
6489
|
+
if (baseParam.type.name !== candParam.type.name) changes.push(makeChange({
|
|
6490
|
+
category: "parameter_type_narrowed",
|
|
6491
|
+
symbol: baseline.fqName,
|
|
6492
|
+
old: {
|
|
6493
|
+
parameter: baseParam.publicName,
|
|
6494
|
+
type: baseParam.type.name
|
|
6495
|
+
},
|
|
6496
|
+
new: {
|
|
6497
|
+
parameter: candParam.publicName,
|
|
6498
|
+
type: candParam.type.name
|
|
6499
|
+
},
|
|
6500
|
+
message: `Parameter type changed for "${baseParam.publicName}" on "${baseline.displayName}"`,
|
|
6501
|
+
policy
|
|
6502
|
+
}));
|
|
6503
|
+
if (baseParam.position !== candParam.position) if (isConstructor ? policy.constructorOrderMatters : baseParam.sensitivity.order) {
|
|
6504
|
+
const category = isConstructor ? "constructor_position_changed_order_sensitive" : "parameter_position_changed_order_sensitive";
|
|
6505
|
+
changes.push(makeChange({
|
|
6506
|
+
category,
|
|
6507
|
+
symbol: baseline.fqName,
|
|
6508
|
+
old: {
|
|
6509
|
+
parameter: baseParam.publicName,
|
|
6510
|
+
position: String(baseParam.position)
|
|
6511
|
+
},
|
|
6512
|
+
new: {
|
|
6513
|
+
parameter: candParam.publicName,
|
|
6514
|
+
position: String(candParam.position)
|
|
6515
|
+
},
|
|
6516
|
+
message: `Parameter "${baseParam.publicName}" moved from position ${baseParam.position} to ${candParam.position} on "${baseline.displayName}"`,
|
|
6517
|
+
policy
|
|
6518
|
+
}));
|
|
6519
|
+
} else changes.push(makeChange({
|
|
6520
|
+
category: "constructor_reordered_named_friendly",
|
|
6521
|
+
symbol: baseline.fqName,
|
|
6522
|
+
old: {
|
|
6523
|
+
parameter: baseParam.publicName,
|
|
6524
|
+
position: String(baseParam.position)
|
|
6525
|
+
},
|
|
6526
|
+
new: {
|
|
6527
|
+
parameter: candParam.publicName,
|
|
6528
|
+
position: String(candParam.position)
|
|
6529
|
+
},
|
|
6530
|
+
message: `Parameter "${baseParam.publicName}" reordered on "${baseline.displayName}" (named-friendly language)`,
|
|
6531
|
+
policy
|
|
6532
|
+
}));
|
|
6533
|
+
}
|
|
6534
|
+
for (const candParam of candParams) if (!baseByName.has(candParam.publicName)) {
|
|
6535
|
+
if (changes.some((c) => c.category === "parameter_renamed" && c.new.parameter === candParam.publicName)) continue;
|
|
6536
|
+
const isTerminal = candParam.position === candParams.length - 1;
|
|
6537
|
+
const category = candParam.required ? "parameter_requiredness_increased" : isTerminal ? "parameter_added_optional_terminal" : "parameter_added_non_terminal_optional";
|
|
6538
|
+
if (candParam.required) changes.push(makeChange({
|
|
6539
|
+
category,
|
|
6540
|
+
symbol: baseline.fqName,
|
|
6541
|
+
old: { parameter: "(absent)" },
|
|
6542
|
+
new: {
|
|
6543
|
+
parameter: candParam.publicName,
|
|
6544
|
+
required: "true"
|
|
6545
|
+
},
|
|
6546
|
+
message: `Required parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
|
|
6547
|
+
policy
|
|
6548
|
+
}));
|
|
6549
|
+
else changes.push(makeChange({
|
|
6550
|
+
category,
|
|
6551
|
+
symbol: baseline.fqName,
|
|
6552
|
+
old: { parameter: "(absent)" },
|
|
6553
|
+
new: { parameter: candParam.publicName },
|
|
6554
|
+
message: `Optional parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
|
|
6555
|
+
policy
|
|
6556
|
+
}));
|
|
6557
|
+
}
|
|
6558
|
+
return changes;
|
|
6559
|
+
}
|
|
6560
|
+
function parameterNameIsPublicApi(param, policy, isConstructor) {
|
|
6561
|
+
if (param.sensitivity.publicName) return true;
|
|
6562
|
+
if (isConstructor) return policy.constructorParameterNamesArePublicApi;
|
|
6563
|
+
return policy.methodParameterNamesArePublicApi;
|
|
6564
|
+
}
|
|
6565
|
+
/** Build a deterministic conceptual change ID. */
|
|
6566
|
+
function buildConceptualChangeId(category, symbol, match) {
|
|
6567
|
+
const parts = [
|
|
6568
|
+
"chg",
|
|
6569
|
+
category,
|
|
6570
|
+
symbol.replace(/[^a-zA-Z0-9_.]/g, "_")
|
|
6571
|
+
];
|
|
6572
|
+
if (match.parameter) parts.push(match.parameter);
|
|
6573
|
+
if (match.member) parts.push(match.member);
|
|
6574
|
+
return parts.join("_").toLowerCase();
|
|
6575
|
+
}
|
|
6576
|
+
function makeChange(opts) {
|
|
6577
|
+
return {
|
|
6578
|
+
category: opts.category,
|
|
6579
|
+
severity: opts.severityOverride ?? defaultSeverityForCategory(opts.category),
|
|
6580
|
+
symbol: opts.symbol,
|
|
6581
|
+
conceptualChangeId: buildConceptualChangeId(opts.category, opts.symbol, opts.old),
|
|
6582
|
+
provenance: opts.provenance ?? "unknown",
|
|
6583
|
+
old: opts.old,
|
|
6584
|
+
new: opts.new,
|
|
6585
|
+
message: opts.message
|
|
6586
|
+
};
|
|
6587
|
+
}
|
|
6588
|
+
/**
|
|
6589
|
+
* Classify a new symbol as additive.
|
|
6590
|
+
*/
|
|
6591
|
+
function classifyAddedSymbol(symbol) {
|
|
6592
|
+
return {
|
|
6593
|
+
category: "symbol_added",
|
|
6594
|
+
severity: "additive",
|
|
6595
|
+
symbol: symbol.fqName,
|
|
6596
|
+
conceptualChangeId: buildConceptualChangeId("symbol_added", symbol.fqName, {}),
|
|
6597
|
+
provenance: "unknown",
|
|
6598
|
+
old: { symbol: "(absent)" },
|
|
6599
|
+
new: { symbol: symbol.fqName },
|
|
6600
|
+
message: `Symbol "${symbol.displayName}" was added`
|
|
6601
|
+
};
|
|
6602
|
+
}
|
|
6603
|
+
/** Summarize a list of classified changes by severity. */
|
|
6604
|
+
function summarizeChanges(changes) {
|
|
6605
|
+
let breaking = 0;
|
|
6606
|
+
let softRisk = 0;
|
|
6607
|
+
let additive = 0;
|
|
6608
|
+
for (const c of changes) if (c.severity === "breaking") breaking++;
|
|
6609
|
+
else if (c.severity === "soft-risk") softRisk++;
|
|
6610
|
+
else additive++;
|
|
6611
|
+
return {
|
|
6612
|
+
breaking,
|
|
6613
|
+
softRisk,
|
|
6614
|
+
additive
|
|
6615
|
+
};
|
|
6616
|
+
}
|
|
6617
|
+
//#endregion
|
|
6049
6618
|
//#region src/compat/spec-filter.ts
|
|
6050
6619
|
/**
|
|
6051
6620
|
* Compute the set of symbol names that are derivable from the OpenAPI spec.
|
|
@@ -6211,378 +6780,609 @@ function filterSurface(surface, allowedNames, opts) {
|
|
|
6211
6780
|
}
|
|
6212
6781
|
//#endregion
|
|
6213
6782
|
//#region src/compat/differ.ts
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
candidate: formatSignature(candOverloads[0]),
|
|
6262
|
-
message: `Signature mismatch for "${name}.${methodName}"`
|
|
6263
|
-
});
|
|
6264
|
-
continue;
|
|
6265
|
-
}
|
|
6266
|
-
preserved++;
|
|
6267
|
-
}
|
|
6268
|
-
}
|
|
6269
|
-
for (const methodName of Object.keys(candClass.methods)) if (!baseClass.methods[methodName]) additions.push({
|
|
6270
|
-
symbolPath: `${name}.${methodName}`,
|
|
6271
|
-
symbolType: "method"
|
|
6272
|
-
});
|
|
6273
|
-
for (const [propName, baseProp] of Object.entries(baseClass.properties)) {
|
|
6274
|
-
totalBaseline++;
|
|
6275
|
-
const candProp = candClass.properties[propName];
|
|
6276
|
-
if (!candProp) {
|
|
6277
|
-
violations.push({
|
|
6278
|
-
category: "public-api",
|
|
6279
|
-
severity: "breaking",
|
|
6280
|
-
symbolPath: `${name}.${propName}`,
|
|
6281
|
-
baseline: baseProp.type,
|
|
6282
|
-
candidate: "(missing)",
|
|
6283
|
-
message: `Property "${name}.${propName}" exists in baseline but not in generated output`
|
|
6284
|
-
});
|
|
6285
|
-
continue;
|
|
6286
|
-
}
|
|
6287
|
-
if (baseProp.type !== candProp.type) {
|
|
6288
|
-
const nullableOnly = hints.isNullableOnlyDifference(baseProp.type, candProp.type);
|
|
6289
|
-
violations.push({
|
|
6290
|
-
category: "signature",
|
|
6291
|
-
severity: nullableOnly ? "warning" : "breaking",
|
|
6292
|
-
symbolPath: `${name}.${propName}`,
|
|
6293
|
-
baseline: baseProp.type,
|
|
6294
|
-
candidate: candProp.type,
|
|
6295
|
-
message: `Property type mismatch for "${name}.${propName}"`
|
|
6296
|
-
});
|
|
6297
|
-
if (nullableOnly) preserved++;
|
|
6298
|
-
continue;
|
|
6299
|
-
}
|
|
6300
|
-
preserved++;
|
|
6301
|
-
}
|
|
6302
|
-
for (const propName of Object.keys(candClass.properties)) if (!baseClass.properties[propName]) additions.push({
|
|
6303
|
-
symbolPath: `${name}.${propName}`,
|
|
6304
|
-
symbolType: "property"
|
|
6783
|
+
/**
|
|
6784
|
+
* Diff two compat snapshots, producing classified changes with
|
|
6785
|
+
* policy-aware severity.
|
|
6786
|
+
*/
|
|
6787
|
+
function diffSnapshots(baseline, candidate, policy) {
|
|
6788
|
+
const effectivePolicy = policy ?? baseline.policies;
|
|
6789
|
+
const changes = [];
|
|
6790
|
+
const candById = /* @__PURE__ */ new Map();
|
|
6791
|
+
const candByFqName = /* @__PURE__ */ new Map();
|
|
6792
|
+
for (const sym of candidate.symbols) {
|
|
6793
|
+
candById.set(sym.id, sym);
|
|
6794
|
+
candByFqName.set(sym.fqName, sym);
|
|
6795
|
+
}
|
|
6796
|
+
const baseByFqName = /* @__PURE__ */ new Set();
|
|
6797
|
+
for (const sym of baseline.symbols) baseByFqName.add(sym.fqName);
|
|
6798
|
+
for (const baseSym of baseline.symbols) {
|
|
6799
|
+
const candSym = candById.get(baseSym.id) ?? candByFqName.get(baseSym.fqName);
|
|
6800
|
+
changes.push(...classifySymbolChanges(baseSym, candSym, effectivePolicy));
|
|
6801
|
+
}
|
|
6802
|
+
for (const candSym of candidate.symbols) if (!baseByFqName.has(candSym.fqName)) changes.push(classifyAddedSymbol(candSym));
|
|
6803
|
+
return {
|
|
6804
|
+
changes,
|
|
6805
|
+
summary: summarizeChanges(changes)
|
|
6806
|
+
};
|
|
6807
|
+
}
|
|
6808
|
+
//#endregion
|
|
6809
|
+
//#region src/compat/concepts.ts
|
|
6810
|
+
/**
|
|
6811
|
+
* Group classified changes from multiple languages into conceptual changes.
|
|
6812
|
+
*
|
|
6813
|
+
* Changes are grouped by their `conceptualChangeId` — a deterministic ID
|
|
6814
|
+
* derived from the symbol, category, and match details. Changes with the
|
|
6815
|
+
* same ID across different languages represent the same conceptual change.
|
|
6816
|
+
*/
|
|
6817
|
+
function buildConceptualRollup(perLanguageResults) {
|
|
6818
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
6819
|
+
for (const { diff: result, language } of perLanguageResults) for (const change of result.changes) {
|
|
6820
|
+
const existing = grouped.get(change.conceptualChangeId);
|
|
6821
|
+
if (existing) existing.impact[language] = change.severity;
|
|
6822
|
+
else grouped.set(change.conceptualChangeId, {
|
|
6823
|
+
id: change.conceptualChangeId,
|
|
6824
|
+
symbol: change.symbol,
|
|
6825
|
+
category: change.category,
|
|
6826
|
+
impact: { [language]: change.severity },
|
|
6827
|
+
message: change.message,
|
|
6828
|
+
old: change.old,
|
|
6829
|
+
new: change.new
|
|
6305
6830
|
});
|
|
6306
6831
|
}
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
const
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6327
|
-
preserved += Object.keys(baseIface.fields).length;
|
|
6328
|
-
continue;
|
|
6329
|
-
}
|
|
6330
|
-
if (!tolerated) {
|
|
6331
|
-
const baseFieldNamesLower = new Set(Object.keys(baseIface.fields).map((f) => f.toLowerCase()));
|
|
6332
|
-
if (baseFieldNamesLower.size > 0) {
|
|
6333
|
-
for (const [, candFieldNamesLower] of candIfaceFieldSets) if (candFieldNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candFieldNamesLower.has(f))) {
|
|
6334
|
-
tolerated = true;
|
|
6335
|
-
break;
|
|
6336
|
-
}
|
|
6337
|
-
if (!tolerated) {
|
|
6338
|
-
for (const [, candPropNamesLower] of candClassPropSets) if (candPropNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candPropNamesLower.has(f))) {
|
|
6339
|
-
tolerated = true;
|
|
6340
|
-
break;
|
|
6341
|
-
}
|
|
6342
|
-
}
|
|
6343
|
-
} else tolerated = true;
|
|
6344
|
-
}
|
|
6345
|
-
if (tolerated) {
|
|
6346
|
-
preserved++;
|
|
6347
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6348
|
-
preserved += Object.keys(baseIface.fields).length;
|
|
6349
|
-
continue;
|
|
6350
|
-
}
|
|
6351
|
-
violations.push({
|
|
6352
|
-
category: "public-api",
|
|
6353
|
-
severity: "breaking",
|
|
6354
|
-
symbolPath: name,
|
|
6355
|
-
baseline: name,
|
|
6356
|
-
candidate: "(missing)",
|
|
6357
|
-
message: `Interface "${name}" exists in baseline but not in generated output`
|
|
6358
|
-
});
|
|
6359
|
-
totalBaseline += Object.keys(baseIface.fields).length;
|
|
6360
|
-
continue;
|
|
6361
|
-
}
|
|
6362
|
-
preserved++;
|
|
6363
|
-
for (const [fieldName, baseField] of Object.entries(baseIface.fields)) {
|
|
6364
|
-
totalBaseline++;
|
|
6365
|
-
const candField = candIface.fields[fieldName];
|
|
6366
|
-
if (!candField) {
|
|
6367
|
-
const baseTypeClean = baseField.type.replace(/\[\]$/, "").replace(/ \| null$/, "");
|
|
6368
|
-
const typeIsUnresolvable = NAMED_TYPE_RE.test(baseTypeClean) && !typeExistsInSurface(baseTypeClean, candidate);
|
|
6369
|
-
violations.push({
|
|
6370
|
-
category: "public-api",
|
|
6371
|
-
severity: typeIsUnresolvable ? "warning" : "breaking",
|
|
6372
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6373
|
-
baseline: baseField.type,
|
|
6374
|
-
candidate: "(missing)",
|
|
6375
|
-
message: `Field "${name}.${fieldName}" exists in baseline but not in generated output`
|
|
6376
|
-
});
|
|
6377
|
-
if (typeIsUnresolvable) preserved++;
|
|
6378
|
-
continue;
|
|
6379
|
-
}
|
|
6380
|
-
if (baseField.type !== candField.type) {
|
|
6381
|
-
if (hints.isUnionReorder(baseField.type, candField.type)) {
|
|
6382
|
-
preserved++;
|
|
6383
|
-
continue;
|
|
6384
|
-
}
|
|
6385
|
-
if (hints.isTypeEquivalent?.(baseField.type, candField.type, candidate)) {
|
|
6386
|
-
preserved++;
|
|
6387
|
-
continue;
|
|
6388
|
-
}
|
|
6389
|
-
const nullableOnly = hints.isNullableOnlyDifference(baseField.type, candField.type);
|
|
6390
|
-
const genericParam = hints.isGenericTypeParam(baseField.type);
|
|
6391
|
-
const extractionArtifact = hints.isExtractionArtifact(candField.type);
|
|
6392
|
-
const isWarning = nullableOnly || genericParam || extractionArtifact;
|
|
6393
|
-
violations.push({
|
|
6394
|
-
category: "signature",
|
|
6395
|
-
severity: isWarning ? "warning" : "breaking",
|
|
6396
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6397
|
-
baseline: baseField.type,
|
|
6398
|
-
candidate: candField.type,
|
|
6399
|
-
message: `Field type mismatch for "${name}.${fieldName}"`
|
|
6400
|
-
});
|
|
6401
|
-
if (isWarning) preserved++;
|
|
6402
|
-
continue;
|
|
6403
|
-
}
|
|
6404
|
-
preserved++;
|
|
6405
|
-
}
|
|
6406
|
-
for (const fieldName of Object.keys(candIface.fields)) if (!baseIface.fields[fieldName]) additions.push({
|
|
6407
|
-
symbolPath: `${name}.${fieldName}`,
|
|
6408
|
-
symbolType: "property"
|
|
6409
|
-
});
|
|
6832
|
+
return { conceptualChanges: [...grouped.values()] };
|
|
6833
|
+
}
|
|
6834
|
+
/** Get the highest severity across all languages for a conceptual change. */
|
|
6835
|
+
function highestSeverity(change) {
|
|
6836
|
+
const severities = Object.values(change.impact);
|
|
6837
|
+
if (severities.includes("breaking")) return "breaking";
|
|
6838
|
+
if (severities.includes("soft-risk")) return "soft-risk";
|
|
6839
|
+
return "additive";
|
|
6840
|
+
}
|
|
6841
|
+
/** Count conceptual changes by highest severity. */
|
|
6842
|
+
function summarizeConceptualChanges(rollup) {
|
|
6843
|
+
let breaking = 0;
|
|
6844
|
+
let softRisk = 0;
|
|
6845
|
+
let additive = 0;
|
|
6846
|
+
for (const change of rollup.conceptualChanges) {
|
|
6847
|
+
const sev = highestSeverity(change);
|
|
6848
|
+
if (sev === "breaking") breaking++;
|
|
6849
|
+
else if (sev === "soft-risk") softRisk++;
|
|
6850
|
+
else additive++;
|
|
6410
6851
|
}
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
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
|
-
|
|
6852
|
+
return {
|
|
6853
|
+
breaking,
|
|
6854
|
+
softRisk,
|
|
6855
|
+
additive
|
|
6856
|
+
};
|
|
6857
|
+
}
|
|
6858
|
+
//#endregion
|
|
6859
|
+
//#region src/compat/report.ts
|
|
6860
|
+
/** Generate a machine-readable compat report from a diff result. */
|
|
6861
|
+
function generateReport(diff, language) {
|
|
6862
|
+
return {
|
|
6863
|
+
schemaVersion: "1",
|
|
6864
|
+
language: language ?? "unknown",
|
|
6865
|
+
summary: diff.summary,
|
|
6866
|
+
changes: diff.changes.map((c) => ({
|
|
6867
|
+
severity: c.severity,
|
|
6868
|
+
category: c.category,
|
|
6869
|
+
symbol: c.symbol,
|
|
6870
|
+
conceptualChangeId: c.conceptualChangeId,
|
|
6871
|
+
provenance: c.provenance,
|
|
6872
|
+
old: c.old,
|
|
6873
|
+
new: c.new,
|
|
6874
|
+
...c.message ? { message: c.message } : {}
|
|
6875
|
+
}))
|
|
6876
|
+
};
|
|
6877
|
+
}
|
|
6878
|
+
/** Generate a human-readable summary string for terminal output. */
|
|
6879
|
+
function formatHumanSummary(diff, opts) {
|
|
6880
|
+
const lines = [];
|
|
6881
|
+
lines.push(`Compat report${opts?.language ? ` for ${opts.language}` : ""}:`);
|
|
6882
|
+
lines.push(` ${diff.summary.breaking} breaking, ${diff.summary.softRisk} soft-risk, ${diff.summary.additive} additive`);
|
|
6883
|
+
if (diff.changes.length === 0) {
|
|
6884
|
+
lines.push(" No compatibility changes detected.");
|
|
6885
|
+
return lines.join("\n");
|
|
6886
|
+
}
|
|
6887
|
+
const breaking = diff.changes.filter((c) => c.severity === "breaking");
|
|
6888
|
+
const softRisk = diff.changes.filter((c) => c.severity === "soft-risk");
|
|
6889
|
+
const additive = diff.changes.filter((c) => c.severity === "additive");
|
|
6890
|
+
if (breaking.length > 0) {
|
|
6891
|
+
lines.push("");
|
|
6892
|
+
lines.push(" Breaking:");
|
|
6893
|
+
for (const c of breaking) {
|
|
6894
|
+
lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
|
|
6895
|
+
if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
|
|
6449
6896
|
}
|
|
6450
|
-
preserved++;
|
|
6451
6897
|
}
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
const candEnum = candidate.enums[name];
|
|
6459
|
-
if (!candEnum) {
|
|
6460
|
-
violations.push({
|
|
6461
|
-
category: "public-api",
|
|
6462
|
-
severity: "breaking",
|
|
6463
|
-
symbolPath: name,
|
|
6464
|
-
baseline: name,
|
|
6465
|
-
candidate: "(missing)",
|
|
6466
|
-
message: `Enum "${name}" exists in baseline but not in generated output`
|
|
6467
|
-
});
|
|
6468
|
-
continue;
|
|
6898
|
+
if (softRisk.length > 0) {
|
|
6899
|
+
lines.push("");
|
|
6900
|
+
lines.push(" Soft-risk:");
|
|
6901
|
+
for (const c of softRisk) {
|
|
6902
|
+
lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
|
|
6903
|
+
if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
|
|
6469
6904
|
}
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6905
|
+
}
|
|
6906
|
+
if (additive.length > 0) {
|
|
6907
|
+
lines.push("");
|
|
6908
|
+
lines.push(` Additive: ${additive.length} new symbol(s)`);
|
|
6909
|
+
}
|
|
6910
|
+
return lines.join("\n");
|
|
6911
|
+
}
|
|
6912
|
+
//#endregion
|
|
6913
|
+
//#region src/cli/compat-diff.ts
|
|
6914
|
+
async function compatDiffCommand(opts) {
|
|
6915
|
+
const baselineData = JSON.parse(readFileSync(opts.baseline, "utf-8"));
|
|
6916
|
+
const candidateData = JSON.parse(readFileSync(opts.candidate, "utf-8"));
|
|
6917
|
+
if (!validateSnapshot(baselineData)) throw new Error(`Invalid baseline snapshot: ${opts.baseline}`);
|
|
6918
|
+
if (!validateSnapshot(candidateData)) throw new Error(`Invalid candidate snapshot: ${opts.candidate}`);
|
|
6919
|
+
const diff = diffSnapshots(baselineData, candidateData);
|
|
6920
|
+
console.log(formatHumanSummary(diff, { explain: opts.explain }));
|
|
6921
|
+
if (opts.output) {
|
|
6922
|
+
const report = generateReport(diff);
|
|
6923
|
+
writeFileSync(opts.output, JSON.stringify(report, null, 2));
|
|
6924
|
+
console.log(`Report written to ${opts.output}`);
|
|
6925
|
+
}
|
|
6926
|
+
const failOn = opts.failOn ?? "breaking";
|
|
6927
|
+
if (diff.changes.some((c) => severityMeetsThreshold(c.severity, failOn))) process.exitCode = 1;
|
|
6928
|
+
}
|
|
6929
|
+
//#endregion
|
|
6930
|
+
//#region src/cli/compat-summary.ts
|
|
6931
|
+
/**
|
|
6932
|
+
* Format compat report(s) as a markdown PR comment.
|
|
6933
|
+
*
|
|
6934
|
+
* Single report:
|
|
6935
|
+
* oagen compat-summary --report node-report.json | gh pr comment --body-file -
|
|
6936
|
+
*
|
|
6937
|
+
* Multiple reports (cross-language rollup):
|
|
6938
|
+
* oagen compat-summary --report php.json --report python.json --report go.json
|
|
6939
|
+
*/
|
|
6940
|
+
async function compatSummaryCommand(opts) {
|
|
6941
|
+
const reports = (Array.isArray(opts.report) ? opts.report : [opts.report]).map((p) => JSON.parse(readFileSync(p, "utf-8")));
|
|
6942
|
+
const md = reports.length === 1 ? formatSingleReport(reports[0]) : formatCrossLanguageRollup(reports);
|
|
6943
|
+
if (opts.output) {
|
|
6944
|
+
writeFileSync(opts.output, md);
|
|
6945
|
+
console.log(`Summary written to ${opts.output}`);
|
|
6946
|
+
} else process.stdout.write(md);
|
|
6947
|
+
}
|
|
6948
|
+
function formatSingleReport(report) {
|
|
6949
|
+
const lines = [];
|
|
6950
|
+
const { breaking, softRisk, additive } = report.summary;
|
|
6951
|
+
const total = breaking + softRisk + additive;
|
|
6952
|
+
if (breaking > 0) lines.push(`## :x: Compat check failed — ${report.language}`);
|
|
6953
|
+
else if (softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${report.language}`);
|
|
6954
|
+
else lines.push(`## :white_check_mark: Compat check passed — ${report.language}`);
|
|
6955
|
+
lines.push("");
|
|
6956
|
+
lines.push("| Severity | Count |");
|
|
6957
|
+
lines.push("| --- | --- |");
|
|
6958
|
+
lines.push(`| Breaking | ${breaking} |`);
|
|
6959
|
+
lines.push(`| Soft-risk | ${softRisk} |`);
|
|
6960
|
+
lines.push(`| Additive | ${additive} |`);
|
|
6961
|
+
lines.push(`| **Total** | **${total}** |`);
|
|
6962
|
+
if (total === 0) {
|
|
6963
|
+
lines.push("");
|
|
6964
|
+
lines.push("No compatibility changes detected.");
|
|
6965
|
+
return lines.join("\n") + "\n";
|
|
6966
|
+
}
|
|
6967
|
+
const breakingChanges = report.changes.filter((c) => c.severity === "breaking");
|
|
6968
|
+
if (breakingChanges.length > 0) {
|
|
6969
|
+
lines.push("");
|
|
6970
|
+
lines.push("### Breaking changes");
|
|
6971
|
+
lines.push("");
|
|
6972
|
+
lines.push(formatChangesTable(breakingChanges));
|
|
6973
|
+
}
|
|
6974
|
+
const softRiskChanges = report.changes.filter((c) => c.severity === "soft-risk");
|
|
6975
|
+
if (softRiskChanges.length > 0) {
|
|
6976
|
+
lines.push("");
|
|
6977
|
+
lines.push("<details>");
|
|
6978
|
+
lines.push(`<summary>Soft-risk changes (${softRiskChanges.length})</summary>`);
|
|
6979
|
+
lines.push("");
|
|
6980
|
+
lines.push(formatChangesTable(softRiskChanges));
|
|
6981
|
+
lines.push("</details>");
|
|
6982
|
+
}
|
|
6983
|
+
const additiveChanges = report.changes.filter((c) => c.severity === "additive");
|
|
6984
|
+
if (additiveChanges.length > 0) {
|
|
6985
|
+
lines.push("");
|
|
6986
|
+
lines.push("<details>");
|
|
6987
|
+
lines.push(`<summary>Additive changes (${additiveChanges.length})</summary>`);
|
|
6988
|
+
lines.push("");
|
|
6989
|
+
lines.push(formatChangesTable(additiveChanges));
|
|
6990
|
+
lines.push("</details>");
|
|
6991
|
+
}
|
|
6992
|
+
return lines.join("\n") + "\n";
|
|
6993
|
+
}
|
|
6994
|
+
function formatCrossLanguageRollup(reports) {
|
|
6995
|
+
const languages = reports.map((r) => r.language);
|
|
6996
|
+
const rollup = buildConceptualRollup(reports.map((r) => ({
|
|
6997
|
+
diff: {
|
|
6998
|
+
changes: r.changes,
|
|
6999
|
+
summary: r.summary
|
|
7000
|
+
},
|
|
7001
|
+
language: r.language
|
|
7002
|
+
})));
|
|
7003
|
+
const summary = summarizeConceptualChanges(rollup);
|
|
7004
|
+
const lines = [];
|
|
7005
|
+
if (summary.breaking > 0) lines.push(`## :x: Compat check failed — ${summary.breaking} breaking across ${languages.length} languages`);
|
|
7006
|
+
else if (summary.softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${summary.softRisk} soft-risk across ${languages.length} languages`);
|
|
7007
|
+
else lines.push(`## :white_check_mark: Compat check passed — ${languages.length} languages`);
|
|
7008
|
+
lines.push("");
|
|
7009
|
+
lines.push("| Language | Breaking | Soft-risk | Additive |");
|
|
7010
|
+
lines.push("| --- | --- | --- | --- |");
|
|
7011
|
+
for (const r of reports) lines.push(`| ${r.language} | ${r.summary.breaking} | ${r.summary.softRisk} | ${r.summary.additive} |`);
|
|
7012
|
+
if (rollup.conceptualChanges.length === 0) {
|
|
7013
|
+
lines.push("");
|
|
7014
|
+
lines.push("No compatibility changes detected.");
|
|
7015
|
+
return lines.join("\n") + "\n";
|
|
7016
|
+
}
|
|
7017
|
+
const breakingConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "breaking");
|
|
7018
|
+
if (breakingConcepts.length > 0) {
|
|
7019
|
+
lines.push("");
|
|
7020
|
+
lines.push("### Breaking changes");
|
|
7021
|
+
lines.push("");
|
|
7022
|
+
lines.push(formatConceptualTable(breakingConcepts, languages));
|
|
7023
|
+
}
|
|
7024
|
+
const softRiskConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "soft-risk");
|
|
7025
|
+
if (softRiskConcepts.length > 0) {
|
|
7026
|
+
lines.push("");
|
|
7027
|
+
lines.push("<details>");
|
|
7028
|
+
lines.push(`<summary>Soft-risk changes (${softRiskConcepts.length})</summary>`);
|
|
7029
|
+
lines.push("");
|
|
7030
|
+
lines.push(formatConceptualTable(softRiskConcepts, languages));
|
|
7031
|
+
lines.push("</details>");
|
|
7032
|
+
}
|
|
7033
|
+
const additiveConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "additive");
|
|
7034
|
+
if (additiveConcepts.length > 0) {
|
|
7035
|
+
lines.push("");
|
|
7036
|
+
lines.push("<details>");
|
|
7037
|
+
lines.push(`<summary>Additive changes (${additiveConcepts.length})</summary>`);
|
|
7038
|
+
lines.push("");
|
|
7039
|
+
lines.push(formatConceptualTable(additiveConcepts, languages));
|
|
7040
|
+
lines.push("</details>");
|
|
7041
|
+
}
|
|
7042
|
+
return lines.join("\n") + "\n";
|
|
7043
|
+
}
|
|
7044
|
+
function formatConceptualTable(concepts, languages) {
|
|
7045
|
+
const lines = [];
|
|
7046
|
+
const langHeaders = languages.map((l) => l).join(" | ");
|
|
7047
|
+
lines.push(`| Symbol | Category | ${langHeaders} |`);
|
|
7048
|
+
lines.push(`| --- | --- | ${languages.map(() => "---").join(" | ")} |`);
|
|
7049
|
+
for (const c of concepts) {
|
|
7050
|
+
const severities = languages.map((l) => severityIcon(c.impact[l])).join(" | ");
|
|
7051
|
+
lines.push(`| \`${c.symbol}\` | \`${c.category}\` | ${severities} |`);
|
|
7052
|
+
}
|
|
7053
|
+
return lines.join("\n");
|
|
7054
|
+
}
|
|
7055
|
+
function severityIcon(severity) {
|
|
7056
|
+
if (!severity) return "—";
|
|
7057
|
+
switch (severity) {
|
|
7058
|
+
case "breaking": return ":x: breaking";
|
|
7059
|
+
case "soft-risk": return ":warning: soft-risk";
|
|
7060
|
+
case "additive": return ":white_check_mark: additive";
|
|
7061
|
+
}
|
|
7062
|
+
}
|
|
7063
|
+
function formatChangesTable(changes) {
|
|
7064
|
+
const lines = [];
|
|
7065
|
+
lines.push("| Category | Symbol | Detail |");
|
|
7066
|
+
lines.push("| --- | --- | --- |");
|
|
7067
|
+
for (const c of changes) {
|
|
7068
|
+
const detail = c.message ?? formatOldNew(c.old, c.new);
|
|
7069
|
+
lines.push(`| \`${c.category}\` | \`${c.symbol}\` | ${escapeMarkdown(detail)} |`);
|
|
7070
|
+
}
|
|
7071
|
+
return lines.join("\n");
|
|
7072
|
+
}
|
|
7073
|
+
function formatOldNew(old, updated) {
|
|
7074
|
+
const parts = [];
|
|
7075
|
+
for (const key of Object.keys(old)) if (updated[key] && old[key] !== updated[key]) parts.push(`${key}: \`${old[key]}\` → \`${updated[key]}\``);
|
|
7076
|
+
return parts.length > 0 ? parts.join(", ") : "";
|
|
7077
|
+
}
|
|
7078
|
+
function escapeMarkdown(text) {
|
|
7079
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
7080
|
+
}
|
|
7081
|
+
//#endregion
|
|
7082
|
+
//#region src/compat/approvals.ts
|
|
7083
|
+
/**
|
|
7084
|
+
* Match a classified change against a list of approvals.
|
|
7085
|
+
* Returns the first matching approval, or null if none match.
|
|
7086
|
+
*/
|
|
7087
|
+
function matchApproval(change, approvals, language) {
|
|
7088
|
+
for (const approval of approvals) if (approvalMatchesChange(approval, change, language)) return approval;
|
|
7089
|
+
return null;
|
|
7090
|
+
}
|
|
7091
|
+
/**
|
|
7092
|
+
* Filter changes to only those NOT covered by approvals.
|
|
7093
|
+
* These are the remaining unapproved changes that may cause failure.
|
|
7094
|
+
*/
|
|
7095
|
+
function unapprovedChanges(changes, approvals, language) {
|
|
7096
|
+
return changes.filter((change) => !matchApproval(change, approvals, language));
|
|
7097
|
+
}
|
|
7098
|
+
function approvalMatchesChange(approval, change, language) {
|
|
7099
|
+
if (approval.approved === false) return false;
|
|
7100
|
+
if (approval.category !== change.category) return false;
|
|
7101
|
+
if (!symbolMatches(approval.symbol, change.symbol)) return false;
|
|
7102
|
+
if (!languageMatches(approval.appliesTo, language)) return false;
|
|
7103
|
+
if (approval.match && !narrowingMatches(approval.match, change)) return false;
|
|
7104
|
+
return true;
|
|
7105
|
+
}
|
|
7106
|
+
function symbolMatches(approvalSymbol, changeSymbol) {
|
|
7107
|
+
if (approvalSymbol === changeSymbol) return true;
|
|
7108
|
+
return approvalSymbol.replace(/::/g, ".").replace(/\\/g, ".") === changeSymbol.replace(/::/g, ".").replace(/\\/g, ".");
|
|
7109
|
+
}
|
|
7110
|
+
function languageMatches(appliesTo, language) {
|
|
7111
|
+
if (!appliesTo || appliesTo === "all-impacted-languages") return true;
|
|
7112
|
+
return appliesTo.includes(language);
|
|
7113
|
+
}
|
|
7114
|
+
function narrowingMatches(match, change) {
|
|
7115
|
+
if (match.parameter) {
|
|
7116
|
+
const paramInOld = change.old.parameter === match.parameter;
|
|
7117
|
+
const paramInNew = change.new.parameter === match.parameter;
|
|
7118
|
+
if (!paramInOld && !paramInNew) return false;
|
|
7119
|
+
}
|
|
7120
|
+
if (match.member) {
|
|
7121
|
+
const memberInOld = change.old.member === match.member;
|
|
7122
|
+
const memberInNew = change.new.member === match.member;
|
|
7123
|
+
if (!memberInOld && !memberInNew) return false;
|
|
7124
|
+
}
|
|
7125
|
+
if (match.oldName) {
|
|
7126
|
+
if (!Object.values(change.old).some((v) => v === match.oldName)) return false;
|
|
7127
|
+
}
|
|
7128
|
+
if (match.newName) {
|
|
7129
|
+
if (!Object.values(change.new).some((v) => v === match.newName)) return false;
|
|
7130
|
+
}
|
|
7131
|
+
return true;
|
|
7132
|
+
}
|
|
7133
|
+
//#endregion
|
|
7134
|
+
//#region src/compat/policy.ts
|
|
7135
|
+
/** Built-in language defaults. */
|
|
7136
|
+
const LANGUAGE_DEFAULTS = {
|
|
7137
|
+
php: {
|
|
7138
|
+
callerUsesParamNames: true,
|
|
7139
|
+
constructorOrderMatters: true,
|
|
7140
|
+
methodParameterNamesArePublicApi: true,
|
|
7141
|
+
constructorParameterNamesArePublicApi: true,
|
|
7142
|
+
overloadsArePublicApi: false,
|
|
7143
|
+
arityIsPublicApi: false
|
|
7144
|
+
},
|
|
7145
|
+
python: {
|
|
7146
|
+
callerUsesParamNames: true,
|
|
7147
|
+
constructorOrderMatters: true,
|
|
7148
|
+
methodParameterNamesArePublicApi: true,
|
|
7149
|
+
constructorParameterNamesArePublicApi: true,
|
|
7150
|
+
overloadsArePublicApi: false,
|
|
7151
|
+
arityIsPublicApi: false
|
|
7152
|
+
},
|
|
7153
|
+
ruby: {
|
|
7154
|
+
callerUsesParamNames: true,
|
|
7155
|
+
constructorOrderMatters: true,
|
|
7156
|
+
methodParameterNamesArePublicApi: true,
|
|
7157
|
+
constructorParameterNamesArePublicApi: true,
|
|
7158
|
+
overloadsArePublicApi: false,
|
|
7159
|
+
arityIsPublicApi: false
|
|
7160
|
+
},
|
|
7161
|
+
go: {
|
|
7162
|
+
callerUsesParamNames: false,
|
|
7163
|
+
constructorOrderMatters: true,
|
|
7164
|
+
methodParameterNamesArePublicApi: false,
|
|
7165
|
+
constructorParameterNamesArePublicApi: false,
|
|
7166
|
+
overloadsArePublicApi: false,
|
|
7167
|
+
arityIsPublicApi: true
|
|
7168
|
+
},
|
|
7169
|
+
kotlin: {
|
|
7170
|
+
callerUsesParamNames: true,
|
|
7171
|
+
constructorOrderMatters: false,
|
|
7172
|
+
methodParameterNamesArePublicApi: true,
|
|
7173
|
+
constructorParameterNamesArePublicApi: true,
|
|
7174
|
+
overloadsArePublicApi: true,
|
|
7175
|
+
arityIsPublicApi: false
|
|
7176
|
+
},
|
|
7177
|
+
dotnet: {
|
|
7178
|
+
callerUsesParamNames: true,
|
|
7179
|
+
constructorOrderMatters: false,
|
|
7180
|
+
methodParameterNamesArePublicApi: true,
|
|
7181
|
+
constructorParameterNamesArePublicApi: true,
|
|
7182
|
+
overloadsArePublicApi: true,
|
|
7183
|
+
arityIsPublicApi: false
|
|
7184
|
+
},
|
|
7185
|
+
elixir: {
|
|
7186
|
+
callerUsesParamNames: true,
|
|
7187
|
+
constructorOrderMatters: false,
|
|
7188
|
+
methodParameterNamesArePublicApi: true,
|
|
7189
|
+
constructorParameterNamesArePublicApi: false,
|
|
7190
|
+
overloadsArePublicApi: false,
|
|
7191
|
+
arityIsPublicApi: true
|
|
7192
|
+
},
|
|
7193
|
+
rust: {
|
|
7194
|
+
callerUsesParamNames: false,
|
|
7195
|
+
constructorOrderMatters: true,
|
|
7196
|
+
methodParameterNamesArePublicApi: false,
|
|
7197
|
+
constructorParameterNamesArePublicApi: false,
|
|
7198
|
+
overloadsArePublicApi: false,
|
|
7199
|
+
arityIsPublicApi: true
|
|
7200
|
+
},
|
|
7201
|
+
node: {
|
|
7202
|
+
callerUsesParamNames: false,
|
|
7203
|
+
constructorOrderMatters: false,
|
|
7204
|
+
methodParameterNamesArePublicApi: false,
|
|
7205
|
+
constructorParameterNamesArePublicApi: false,
|
|
7206
|
+
overloadsArePublicApi: false,
|
|
7207
|
+
arityIsPublicApi: false
|
|
7208
|
+
}
|
|
7209
|
+
};
|
|
7210
|
+
/** Get the built-in default policy for a language. */
|
|
7211
|
+
function getDefaultPolicy(language) {
|
|
7212
|
+
return { ...LANGUAGE_DEFAULTS[language] };
|
|
7213
|
+
}
|
|
7214
|
+
/** Merge user overrides onto language defaults. Only provided keys are overridden. */
|
|
7215
|
+
function mergePolicy(defaults, overrides) {
|
|
7216
|
+
return {
|
|
7217
|
+
...defaults,
|
|
7218
|
+
...overrides
|
|
7219
|
+
};
|
|
7220
|
+
}
|
|
7221
|
+
//#endregion
|
|
7222
|
+
//#region src/compat/ir.ts
|
|
7223
|
+
/**
|
|
7224
|
+
* Convert a legacy ApiSurface to a CompatSnapshot.
|
|
7225
|
+
*
|
|
7226
|
+
* This bridge allows existing extractors (which produce ApiSurface) to feed
|
|
7227
|
+
* into the new classified diff engine without requiring immediate migration.
|
|
7228
|
+
*/
|
|
7229
|
+
function apiSurfaceToSnapshot(surface) {
|
|
7230
|
+
const language = surface.language || "node";
|
|
7231
|
+
const symbols = [];
|
|
7232
|
+
for (const [className, cls] of Object.entries(surface.classes)) {
|
|
7233
|
+
symbols.push({
|
|
7234
|
+
id: `class:${className}`,
|
|
7235
|
+
kind: "service_accessor",
|
|
7236
|
+
fqName: className,
|
|
7237
|
+
displayName: className,
|
|
7238
|
+
visibility: "public",
|
|
7239
|
+
stability: cls.deprecationMessage ? "deprecated" : "stable",
|
|
7240
|
+
sourceKind: "generated_service_wrapper"
|
|
7241
|
+
});
|
|
7242
|
+
if (cls.constructorParams.length > 0) symbols.push({
|
|
7243
|
+
id: `ctor:${className}`,
|
|
7244
|
+
kind: "constructor",
|
|
7245
|
+
fqName: `${className}.constructor`,
|
|
7246
|
+
ownerFqName: className,
|
|
7247
|
+
displayName: `new ${className}`,
|
|
7248
|
+
visibility: "public",
|
|
7249
|
+
stability: "stable",
|
|
7250
|
+
sourceKind: "generated_resource_constructor",
|
|
7251
|
+
parameters: cls.constructorParams.map((p, i) => apiParamToCompatParam(p, i, language))
|
|
7252
|
+
});
|
|
7253
|
+
for (const [methodName, overloads] of Object.entries(cls.methods)) for (let oi = 0; oi < overloads.length; oi++) {
|
|
7254
|
+
const method = overloads[oi];
|
|
7255
|
+
const suffix = overloads.length > 1 ? `#${oi}` : "";
|
|
7256
|
+
symbols.push({
|
|
7257
|
+
id: `method:${className}.${methodName}${suffix}`,
|
|
7258
|
+
kind: "callable",
|
|
7259
|
+
fqName: `${className}.${methodName}`,
|
|
7260
|
+
ownerFqName: className,
|
|
7261
|
+
displayName: `${className}.${methodName}`,
|
|
7262
|
+
visibility: "public",
|
|
7263
|
+
stability: "stable",
|
|
7264
|
+
sourceKind: "generated_service_wrapper",
|
|
7265
|
+
parameters: method.params.map((p, i) => apiParamToCompatParam(p, i, language)),
|
|
7266
|
+
returns: { name: method.returnType }
|
|
6522
7267
|
});
|
|
6523
|
-
enumMatch = false;
|
|
6524
7268
|
}
|
|
6525
|
-
|
|
7269
|
+
for (const [propName, prop] of Object.entries(cls.properties)) symbols.push({
|
|
7270
|
+
id: `prop:${className}.${propName}`,
|
|
7271
|
+
kind: "property",
|
|
7272
|
+
fqName: `${className}.${propName}`,
|
|
7273
|
+
ownerFqName: className,
|
|
7274
|
+
displayName: `${className}.${propName}`,
|
|
7275
|
+
visibility: "public",
|
|
7276
|
+
stability: "stable",
|
|
7277
|
+
sourceKind: "generated_service_wrapper",
|
|
7278
|
+
typeRef: { name: prop.type }
|
|
7279
|
+
});
|
|
6526
7280
|
}
|
|
6527
|
-
for (const
|
|
6528
|
-
|
|
6529
|
-
|
|
7281
|
+
for (const [ifaceName, iface] of Object.entries(surface.interfaces)) {
|
|
7282
|
+
symbols.push({
|
|
7283
|
+
id: `iface:${ifaceName}`,
|
|
7284
|
+
kind: "alias",
|
|
7285
|
+
fqName: ifaceName,
|
|
7286
|
+
displayName: ifaceName,
|
|
7287
|
+
visibility: "public",
|
|
7288
|
+
stability: "stable",
|
|
7289
|
+
sourceKind: "generated_resource_constructor"
|
|
7290
|
+
});
|
|
7291
|
+
for (const [fieldName, field] of Object.entries(iface.fields)) symbols.push({
|
|
7292
|
+
id: `field:${ifaceName}.${fieldName}`,
|
|
7293
|
+
kind: "field",
|
|
7294
|
+
fqName: `${ifaceName}.${fieldName}`,
|
|
7295
|
+
ownerFqName: ifaceName,
|
|
7296
|
+
displayName: `${ifaceName}.${fieldName}`,
|
|
7297
|
+
visibility: "public",
|
|
7298
|
+
stability: "stable",
|
|
7299
|
+
sourceKind: "generated_resource_constructor",
|
|
7300
|
+
typeRef: { name: field.type }
|
|
7301
|
+
});
|
|
7302
|
+
if (iface.hasCustomConstructor) symbols.push({
|
|
7303
|
+
id: `ctor:${ifaceName}`,
|
|
7304
|
+
kind: "constructor",
|
|
7305
|
+
fqName: `${ifaceName}.constructor`,
|
|
7306
|
+
ownerFqName: ifaceName,
|
|
7307
|
+
displayName: `new ${ifaceName}`,
|
|
7308
|
+
visibility: "public",
|
|
7309
|
+
stability: "stable",
|
|
7310
|
+
sourceKind: "generated_resource_constructor"
|
|
7311
|
+
});
|
|
7312
|
+
}
|
|
7313
|
+
for (const [aliasName] of Object.entries(surface.typeAliases)) symbols.push({
|
|
7314
|
+
id: `alias:${aliasName}`,
|
|
7315
|
+
kind: "alias",
|
|
7316
|
+
fqName: aliasName,
|
|
7317
|
+
displayName: aliasName,
|
|
7318
|
+
visibility: "public",
|
|
7319
|
+
stability: "stable",
|
|
7320
|
+
sourceKind: "generated_resource_constructor"
|
|
6530
7321
|
});
|
|
6531
|
-
for (const [
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
message: `Export "${exp}" from "${path}" not found in generated output`
|
|
7322
|
+
for (const [enumName, enumDef] of Object.entries(surface.enums)) {
|
|
7323
|
+
symbols.push({
|
|
7324
|
+
id: `enum:${enumName}`,
|
|
7325
|
+
kind: "enum",
|
|
7326
|
+
fqName: enumName,
|
|
7327
|
+
displayName: enumName,
|
|
7328
|
+
visibility: "public",
|
|
7329
|
+
stability: "stable",
|
|
7330
|
+
sourceKind: "generated_enum"
|
|
7331
|
+
});
|
|
7332
|
+
for (const [memberName, memberValue] of Object.entries(enumDef.members)) symbols.push({
|
|
7333
|
+
id: `enum_member:${enumName}.${memberName}`,
|
|
7334
|
+
kind: "enum_member",
|
|
7335
|
+
fqName: `${enumName}.${memberName}`,
|
|
7336
|
+
ownerFqName: enumName,
|
|
7337
|
+
displayName: `${enumName}.${memberName}`,
|
|
7338
|
+
visibility: "public",
|
|
7339
|
+
stability: "stable",
|
|
7340
|
+
sourceKind: "generated_enum",
|
|
7341
|
+
value: memberValue
|
|
6552
7342
|
});
|
|
6553
7343
|
}
|
|
6554
7344
|
return {
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
additions
|
|
7345
|
+
schemaVersion: "1",
|
|
7346
|
+
source: { extractedAt: surface.extractedAt },
|
|
7347
|
+
policies: getDefaultPolicy(language),
|
|
7348
|
+
symbols
|
|
6560
7349
|
};
|
|
6561
7350
|
}
|
|
6562
|
-
/**
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
7351
|
+
/** Convert a legacy ApiParam to a CompatParameter. */
|
|
7352
|
+
function apiParamToCompatParam(param, position, language) {
|
|
7353
|
+
const policy = getDefaultPolicy(language);
|
|
7354
|
+
return {
|
|
7355
|
+
publicName: param.name,
|
|
7356
|
+
position,
|
|
7357
|
+
required: !param.optional,
|
|
7358
|
+
nullable: false,
|
|
7359
|
+
hasDefault: param.optional,
|
|
7360
|
+
passing: param.passingStyle ?? inferPassingStyle(language),
|
|
7361
|
+
type: { name: param.type },
|
|
7362
|
+
sensitivity: {
|
|
7363
|
+
order: policy.constructorOrderMatters,
|
|
7364
|
+
publicName: policy.methodParameterNamesArePublicApi,
|
|
7365
|
+
requiredness: true,
|
|
7366
|
+
type: true
|
|
7367
|
+
}
|
|
7368
|
+
};
|
|
6579
7369
|
}
|
|
6580
|
-
|
|
6581
|
-
|
|
7370
|
+
/** Infer the default parameter passing style for a language. */
|
|
7371
|
+
function inferPassingStyle(language) {
|
|
7372
|
+
switch (language) {
|
|
7373
|
+
case "python":
|
|
7374
|
+
case "ruby":
|
|
7375
|
+
case "elixir": return "keyword";
|
|
7376
|
+
case "php":
|
|
7377
|
+
case "kotlin":
|
|
7378
|
+
case "dotnet": return "named";
|
|
7379
|
+
case "node": return "options_object";
|
|
7380
|
+
default: return "positional";
|
|
7381
|
+
}
|
|
6582
7382
|
}
|
|
6583
7383
|
//#endregion
|
|
6584
7384
|
//#region src/verify/run-compat-check.ts
|
|
6585
|
-
async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
7385
|
+
async function runCompatCheck(baseline, outputDir, lang, spec, policyOverrides) {
|
|
6586
7386
|
const extractor = getExtractor(lang);
|
|
6587
7387
|
const candidate = await extractor.extract(outputDir);
|
|
6588
7388
|
let scopedBaseline = baseline;
|
|
@@ -6597,9 +7397,12 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
|
6597
7397
|
scopedToSpec = true;
|
|
6598
7398
|
scopedSymbolCount = Object.keys(scopedBaseline.interfaces).length + Object.keys(scopedBaseline.classes).length + Object.keys(scopedBaseline.typeAliases).length + Object.keys(scopedBaseline.enums).length;
|
|
6599
7399
|
}
|
|
6600
|
-
const
|
|
7400
|
+
const baseSnap = apiSurfaceToSnapshot(scopedBaseline);
|
|
7401
|
+
const candSnap = apiSurfaceToSnapshot(candidate);
|
|
7402
|
+
const langId = lang;
|
|
7403
|
+
const diff = diffSnapshots(baseSnap, candSnap, policyOverrides ? mergePolicy(getDefaultPolicy(langId), policyOverrides) : getDefaultPolicy(langId));
|
|
6601
7404
|
return {
|
|
6602
|
-
passed: diff.
|
|
7405
|
+
passed: diff.changes.every((c) => c.severity !== "breaking"),
|
|
6603
7406
|
diff,
|
|
6604
7407
|
scopedToSpec,
|
|
6605
7408
|
scopedSymbolCount
|
|
@@ -6607,7 +7410,6 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
|
|
|
6607
7410
|
}
|
|
6608
7411
|
//#endregion
|
|
6609
7412
|
//#region src/verify/run-overlay-retry-loop.ts
|
|
6610
|
-
const PATCHABLE_CATEGORIES = new Set(["public-api", "export-structure"]);
|
|
6611
7413
|
/** Generated file header markers used by various emitters. */
|
|
6612
7414
|
const GENERATED_MARKERS = [
|
|
6613
7415
|
"Code generated by oagen",
|
|
@@ -6699,21 +7501,21 @@ async function runOverlayRetryLoop(opts) {
|
|
|
6699
7501
|
patchedPerIteration,
|
|
6700
7502
|
compatResult
|
|
6701
7503
|
};
|
|
6702
|
-
const patchable = compatResult.diff.
|
|
7504
|
+
const patchable = compatResult.diff.changes.filter(isPatchableChange);
|
|
6703
7505
|
if (patchable.length === 0) return {
|
|
6704
7506
|
status: "no-patchable",
|
|
6705
7507
|
attempts: attempt,
|
|
6706
7508
|
patchedPerIteration,
|
|
6707
7509
|
compatResult
|
|
6708
7510
|
};
|
|
6709
|
-
const
|
|
6710
|
-
if (attempt > 0 &&
|
|
7511
|
+
const currentBreaking = compatResult.diff.summary.breaking;
|
|
7512
|
+
if (attempt > 0 && currentBreaking >= prevPreserved) return {
|
|
6711
7513
|
status: "stalled",
|
|
6712
7514
|
attempts: attempt,
|
|
6713
7515
|
patchedPerIteration,
|
|
6714
7516
|
compatResult
|
|
6715
7517
|
};
|
|
6716
|
-
prevPreserved =
|
|
7518
|
+
prevPreserved = currentBreaking;
|
|
6717
7519
|
patchedPerIteration.push(patchable.length);
|
|
6718
7520
|
onRetry?.(attempt + 1, maxRetries, patchable.length);
|
|
6719
7521
|
overlay = patchOverlay(overlay, patchable, baseline);
|
|
@@ -6902,17 +7704,20 @@ function runSmokeCheck(opts) {
|
|
|
6902
7704
|
function summarizeCompatCheck(result) {
|
|
6903
7705
|
const violationsByCategory = {};
|
|
6904
7706
|
const violationsBySeverity = {};
|
|
6905
|
-
for (const
|
|
6906
|
-
violationsByCategory[
|
|
6907
|
-
violationsBySeverity[
|
|
7707
|
+
for (const c of result.diff.changes) {
|
|
7708
|
+
violationsByCategory[c.category] = (violationsByCategory[c.category] ?? 0) + 1;
|
|
7709
|
+
violationsBySeverity[c.severity] = (violationsBySeverity[c.severity] ?? 0) + 1;
|
|
6908
7710
|
}
|
|
7711
|
+
const { breaking, softRisk, additive } = result.diff.summary;
|
|
7712
|
+
const totalBaselineSymbols = breaking + softRisk + additive + additive;
|
|
7713
|
+
const preservedSymbols = totalBaselineSymbols - breaking - softRisk;
|
|
6909
7714
|
return {
|
|
6910
|
-
totalBaselineSymbols
|
|
6911
|
-
preservedSymbols
|
|
6912
|
-
preservationScore:
|
|
7715
|
+
totalBaselineSymbols,
|
|
7716
|
+
preservedSymbols,
|
|
7717
|
+
preservationScore: totalBaselineSymbols > 0 ? Math.round(preservedSymbols / totalBaselineSymbols * 100) : 100,
|
|
6913
7718
|
violationsByCategory,
|
|
6914
7719
|
violationsBySeverity,
|
|
6915
|
-
additions:
|
|
7720
|
+
additions: additive,
|
|
6916
7721
|
scopedToSpec: result.scopedToSpec,
|
|
6917
7722
|
...result.scopedSymbolCount !== void 0 ? { scopedSymbolCount: result.scopedSymbolCount } : {}
|
|
6918
7723
|
};
|
|
@@ -6939,9 +7744,12 @@ function printCompatResult(result, baseline) {
|
|
|
6939
7744
|
console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
|
|
6940
7745
|
}
|
|
6941
7746
|
async function verifyCommand(opts) {
|
|
6942
|
-
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
|
|
7747
|
+
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform, compatConfig, compatReport, compatExplain } = opts;
|
|
6943
7748
|
const maxRetries = opts.maxRetries ?? 3;
|
|
6944
7749
|
const diagData = {};
|
|
7750
|
+
const effectiveFailOn = opts.compatFailOn ?? compatConfig?.failOn ?? "breaking";
|
|
7751
|
+
const effectiveReportPath = compatReport ?? compatConfig?.reportPath;
|
|
7752
|
+
const effectiveExplain = compatExplain ?? compatConfig?.explain ?? false;
|
|
6945
7753
|
let stepNum = 1;
|
|
6946
7754
|
const baseline = apiSurface ? JSON.parse(readFileSync(apiSurface, "utf-8")) : void 0;
|
|
6947
7755
|
if (apiSurface && baseline) {
|
|
@@ -6973,11 +7781,13 @@ async function verifyCommand(opts) {
|
|
|
6973
7781
|
};
|
|
6974
7782
|
const compatSummary = summarizeCompatCheck(compatFlow.compatResult);
|
|
6975
7783
|
printCompatResult(compatSummary, baseline);
|
|
6976
|
-
|
|
6977
|
-
if (
|
|
7784
|
+
const classifiedDiff = compatFlow.compatResult.diff;
|
|
7785
|
+
for (const c of classifiedDiff.changes) if (c.severity !== "additive") console.log(` [${c.category}] ${c.severity}: ${c.symbol} — ${c.message}`);
|
|
7786
|
+
const additiveCount = classifiedDiff.summary.additive;
|
|
7787
|
+
if (additiveCount > 0) console.log(` + ${additiveCount} new symbols added`);
|
|
6978
7788
|
if (diagnostics) {
|
|
6979
7789
|
diagData.compatCheck = compatSummary;
|
|
6980
|
-
if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed",
|
|
7790
|
+
if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed", compatSummary.preservationScore, compatFlow.patchedPerIteration);
|
|
6981
7791
|
}
|
|
6982
7792
|
if (compatFlow.status === "passed" && compatFlow.compatResult.passed) if (compatFlow.attempts > 0) console.log(`Compat: converged after ${compatFlow.attempts} retry iteration(s)`);
|
|
6983
7793
|
else console.log("Compat: passed");
|
|
@@ -6987,9 +7797,28 @@ async function verifyCommand(opts) {
|
|
|
6987
7797
|
console.log("Diagnostics written to verify-diagnostics.json");
|
|
6988
7798
|
}
|
|
6989
7799
|
if (compatFlow.status === "no-patchable") console.log("No patchable violations — cannot self-correct. Remaining violations require emitter code changes.");
|
|
6990
|
-
else if (compatFlow.status === "stalled") console.log(`Stalled
|
|
7800
|
+
else if (compatFlow.status === "stalled") console.log(`Stalled — overlay patching is not making progress.`);
|
|
6991
7801
|
throw new CommandError("\nCompat violations found — fix the emitter and re-run `oagen verify`.", "", 1);
|
|
6992
7802
|
}
|
|
7803
|
+
if (effectiveReportPath || effectiveExplain || (compatConfig?.allow?.length ?? 0) > 0) {
|
|
7804
|
+
const langId = lang;
|
|
7805
|
+
const approvals = compatConfig?.allow ?? [];
|
|
7806
|
+
const remaining = unapprovedChanges(classifiedDiff.changes, approvals, langId);
|
|
7807
|
+
const hasFailure = remaining.some((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
|
|
7808
|
+
if (effectiveExplain) {
|
|
7809
|
+
console.log("");
|
|
7810
|
+
console.log(formatHumanSummary(classifiedDiff, { explain: true }));
|
|
7811
|
+
}
|
|
7812
|
+
if (effectiveReportPath) {
|
|
7813
|
+
const report = generateReport(classifiedDiff);
|
|
7814
|
+
writeFileSync(effectiveReportPath, JSON.stringify(report, null, 2) + "\n");
|
|
7815
|
+
console.log(`Compat report written to ${effectiveReportPath}`);
|
|
7816
|
+
}
|
|
7817
|
+
if (hasFailure && effectiveFailOn !== "none") {
|
|
7818
|
+
const unapprovedBreaking = remaining.filter((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
|
|
7819
|
+
console.log(`\n${unapprovedBreaking.length} unapproved change(s) at or above '${effectiveFailOn}' threshold`);
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
6993
7822
|
stepNum++;
|
|
6994
7823
|
}
|
|
6995
7824
|
if (oldSpec && spec && apiSurface && baseline) {
|
|
@@ -7071,10 +7900,16 @@ function packageJson(lang) {
|
|
|
7071
7900
|
type: "module",
|
|
7072
7901
|
main: "dist/index.js",
|
|
7073
7902
|
types: "dist/index.d.ts",
|
|
7074
|
-
exports: {
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7903
|
+
exports: {
|
|
7904
|
+
".": {
|
|
7905
|
+
types: "./dist/index.d.ts",
|
|
7906
|
+
import: "./dist/index.js"
|
|
7907
|
+
},
|
|
7908
|
+
"./plugin": {
|
|
7909
|
+
types: "./dist/plugin.d.ts",
|
|
7910
|
+
import: "./dist/plugin.js"
|
|
7911
|
+
}
|
|
7912
|
+
},
|
|
7078
7913
|
scripts: {
|
|
7079
7914
|
build: "tsup",
|
|
7080
7915
|
test: "vitest run",
|
|
@@ -7123,7 +7958,7 @@ export default defineConfig({
|
|
|
7123
7958
|
function tsupConfig() {
|
|
7124
7959
|
return `import { defineConfig } from 'tsup';
|
|
7125
7960
|
export default defineConfig({
|
|
7126
|
-
entry: { index: 'src/index.ts' },
|
|
7961
|
+
entry: { index: 'src/index.ts', plugin: 'src/plugin.ts' },
|
|
7127
7962
|
format: ['esm'],
|
|
7128
7963
|
dts: true,
|
|
7129
7964
|
clean: true,
|
|
@@ -7131,19 +7966,34 @@ export default defineConfig({
|
|
|
7131
7966
|
});
|
|
7132
7967
|
`;
|
|
7133
7968
|
}
|
|
7134
|
-
function oagenConfig(
|
|
7135
|
-
const varName = `${toCamelCase(lang)}Emitter`;
|
|
7969
|
+
function oagenConfig() {
|
|
7136
7970
|
return `import type { OagenConfig } from '@workos/oagen';
|
|
7137
|
-
import {
|
|
7971
|
+
import { plugin } from './src/plugin.js';
|
|
7138
7972
|
|
|
7973
|
+
// Minimal config for local emitter development.
|
|
7974
|
+
// The canonical consumer config lives in the consumer project's oagen.config.ts,
|
|
7975
|
+
// which imports the plugin bundle from this package.
|
|
7139
7976
|
const config: OagenConfig = {
|
|
7140
|
-
|
|
7977
|
+
...plugin,
|
|
7141
7978
|
};
|
|
7142
7979
|
export default config;
|
|
7143
7980
|
`;
|
|
7144
7981
|
}
|
|
7982
|
+
function srcPlugin(lang) {
|
|
7983
|
+
const varName = `${toCamelCase(lang)}Emitter`;
|
|
7984
|
+
return `import type { OagenConfig } from '@workos/oagen';
|
|
7985
|
+
import { ${varName} } from './${lang}/index.js';
|
|
7986
|
+
|
|
7987
|
+
export const plugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
|
|
7988
|
+
emitters: [${varName}],
|
|
7989
|
+
extractors: [],
|
|
7990
|
+
smokeRunners: {},
|
|
7991
|
+
};
|
|
7992
|
+
`;
|
|
7993
|
+
}
|
|
7145
7994
|
function srcIndex(lang) {
|
|
7146
7995
|
return `export { ${`${toCamelCase(lang)}Emitter`} } from './${lang}/index.js';
|
|
7996
|
+
export { plugin } from './plugin.js';
|
|
7147
7997
|
`;
|
|
7148
7998
|
}
|
|
7149
7999
|
function gitignore() {
|
|
@@ -7216,7 +8066,8 @@ async function initCommand(opts) {
|
|
|
7216
8066
|
["tsconfig.json", tsconfigJson()],
|
|
7217
8067
|
["vitest.config.ts", vitestConfig()],
|
|
7218
8068
|
["tsup.config.ts", tsupConfig()],
|
|
7219
|
-
["oagen.config.ts", oagenConfig(
|
|
8069
|
+
["oagen.config.ts", oagenConfig()],
|
|
8070
|
+
["src/plugin.ts", srcPlugin(lang)],
|
|
7220
8071
|
["src/index.ts", srcIndex(lang)],
|
|
7221
8072
|
[`src/${lang}/index.ts`, stubEmitter(lang)]
|
|
7222
8073
|
];
|
|
@@ -7325,12 +8176,30 @@ const CONFIG_NAMES = [
|
|
|
7325
8176
|
"oagen.config.js",
|
|
7326
8177
|
"oagen.config.mjs"
|
|
7327
8178
|
];
|
|
7328
|
-
|
|
8179
|
+
/**
|
|
8180
|
+
* Load an oagen config file.
|
|
8181
|
+
*
|
|
8182
|
+
* @param configPath - Explicit path to a config file. When provided, only that
|
|
8183
|
+
* file is attempted (no fallback search).
|
|
8184
|
+
* @param cwd - Directory to search when `configPath` is omitted. Defaults to
|
|
8185
|
+
* `process.cwd()`. The loader tries each name in `CONFIG_NAMES` in order.
|
|
8186
|
+
*/
|
|
8187
|
+
async function loadConfig(configPath, cwd = process.cwd()) {
|
|
8188
|
+
if (configPath) {
|
|
8189
|
+
const resolved = path$1.resolve(cwd, configPath);
|
|
8190
|
+
if (!existsSync(resolved)) throw new ConfigLoadError(`Config file not found: ${resolved}`, "Check that the path passed to --config exists.");
|
|
8191
|
+
try {
|
|
8192
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
8193
|
+
return mod.default ?? mod;
|
|
8194
|
+
} catch (err) {
|
|
8195
|
+
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.");
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
7329
8198
|
for (const name of CONFIG_NAMES) {
|
|
7330
|
-
const
|
|
7331
|
-
if (!existsSync(
|
|
8199
|
+
const resolved = path$1.resolve(cwd, name);
|
|
8200
|
+
if (!existsSync(resolved)) continue;
|
|
7332
8201
|
try {
|
|
7333
|
-
const mod = await import(pathToFileURL(
|
|
8202
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
7334
8203
|
return mod.default ?? mod;
|
|
7335
8204
|
} catch (err) {
|
|
7336
8205
|
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.");
|
|
@@ -7352,14 +8221,17 @@ function handleError(err) {
|
|
|
7352
8221
|
if (message) console.error(message);
|
|
7353
8222
|
process.exit(exitCode);
|
|
7354
8223
|
}
|
|
8224
|
+
const configArgIdx = process.argv.indexOf("--config");
|
|
8225
|
+
const explicitConfigPath = configArgIdx !== -1 ? process.argv[configArgIdx + 1] : void 0;
|
|
7355
8226
|
let configSmokeRunners;
|
|
7356
8227
|
let configOperationIdTransform;
|
|
7357
8228
|
let configSchemaNameTransform;
|
|
7358
8229
|
let configDocUrl;
|
|
7359
8230
|
let configOperationHints;
|
|
7360
8231
|
let configMountRules;
|
|
8232
|
+
let configCompat;
|
|
7361
8233
|
try {
|
|
7362
|
-
const config = await loadConfig();
|
|
8234
|
+
const config = await loadConfig(explicitConfigPath);
|
|
7363
8235
|
if (config) {
|
|
7364
8236
|
applyConfig(config);
|
|
7365
8237
|
configSmokeRunners = config.smokeRunners;
|
|
@@ -7368,11 +8240,12 @@ try {
|
|
|
7368
8240
|
configDocUrl = config.docUrl;
|
|
7369
8241
|
configOperationHints = config.operationHints;
|
|
7370
8242
|
configMountRules = config.mountRules;
|
|
8243
|
+
configCompat = config.compat;
|
|
7371
8244
|
}
|
|
7372
8245
|
} catch (err) {
|
|
7373
8246
|
handleError(err);
|
|
7374
8247
|
}
|
|
7375
|
-
const program = new Command().name("oagen").description("Framework for building OpenAPI SDK emitters").version("0.0.1");
|
|
8248
|
+
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)");
|
|
7376
8249
|
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) => {
|
|
7377
8250
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7378
8251
|
if (!opts.spec) {
|
|
@@ -7408,14 +8281,29 @@ program.command("extract").description("Extract public API surface from a live S
|
|
|
7408
8281
|
opts.output ??= `sdk-${opts.lang}-surface.json`;
|
|
7409
8282
|
extractCommand(opts).catch(handleError);
|
|
7410
8283
|
});
|
|
7411
|
-
program.command("
|
|
8284
|
+
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) => {
|
|
8285
|
+
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
8286
|
+
compatExtractCommand(opts).catch(handleError);
|
|
8287
|
+
});
|
|
8288
|
+
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) => {
|
|
8289
|
+
compatDiffCommand(opts).catch(handleError);
|
|
8290
|
+
});
|
|
8291
|
+
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) => {
|
|
8292
|
+
compatSummaryCommand(opts).catch(handleError);
|
|
8293
|
+
});
|
|
8294
|
+
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) => {
|
|
7412
8295
|
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7413
8296
|
opts.smokeRunner ??= configSmokeRunners?.[opts.lang];
|
|
7414
8297
|
verifyCommand({
|
|
7415
8298
|
...opts,
|
|
7416
8299
|
maxRetries: parseInt(opts.maxRetries, 10),
|
|
7417
8300
|
operationIdTransform: configOperationIdTransform,
|
|
7418
|
-
schemaNameTransform: configSchemaNameTransform
|
|
8301
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
8302
|
+
compatConfig: configCompat,
|
|
8303
|
+
compatReport: opts.compatReport,
|
|
8304
|
+
compatFailOn: opts.compatFailOn,
|
|
8305
|
+
compatBaseline: opts.compatBaseline,
|
|
8306
|
+
compatExplain: opts.compatExplain
|
|
7419
8307
|
}).catch(handleError);
|
|
7420
8308
|
});
|
|
7421
8309
|
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) => {
|