diff --git a/src/domain/javascript/javascriptExportShapeComparison.ts b/src/domain/javascript/javascriptExportShapeComparison.ts index d8c8620fa..c991746bc 100644 --- a/src/domain/javascript/javascriptExportShapeComparison.ts +++ b/src/domain/javascript/javascriptExportShapeComparison.ts @@ -1,5 +1,8 @@ -import { uniqueSorted } from "../canonicalOrdering.js"; -import { digestExportShapeValue } from "./javascriptExportShapeComparisonIdentity.js"; +import { compareCodePoints, uniqueSorted } from "../canonicalOrdering.js"; +import { + canonicalExportShapeValue, + digestExportShapeValue, +} from "./javascriptExportShapeComparisonIdentity.js"; import { javaScriptExportShapeComparisonResultSchema, type JavaScriptExportShapeComparisonChange, @@ -14,7 +17,6 @@ import { } from "./javascriptExportShapeSelection.js"; import { buildJavaScriptExportShapeChanges, - compareJavaScriptExportShapeChanges, hasPartialJavaScriptExportPropertyCoverage, pairJavaScriptExportShapeVariants, type JavaScriptExportShapePairing, @@ -42,14 +44,16 @@ export const compareJavaScriptExportShapes = ( input.left.evidenceId, input.right.evidenceId, ); - const allChanges = buildJavaScriptExportShapeChanges({ - leftSelection: left, - rightSelection: right, - pairing, - leftShapes: leftRetained.shapes, - rightShapes: rightRetained.shapes, - evidenceLinks, - }).sort(compareJavaScriptExportShapeChanges); + const allChanges = sortChanges( + buildJavaScriptExportShapeChanges({ + leftSelection: left, + rightSelection: right, + pairing, + leftShapes: leftRetained.shapes, + rightShapes: rightRetained.shapes, + evidenceLinks, + }), + ); const changes = allChanges; const omittedChanges = 0; const coverage = comparisonCoverage({ @@ -78,6 +82,29 @@ export const compareJavaScriptExportShapes = ( }); }; +const sortChanges = ( + changes: readonly JavaScriptExportShapeComparisonChange[], +): JavaScriptExportShapeComparisonChange[] => + changes + .map((change, index) => ({ + change, + index, + sortKey: canonicalExportShapeValue({ + discriminant: change.discriminant, + path: change.path, + status: change.status, + left_source_range: change.left_source_range, + right_source_range: change.right_source_range, + change_id: change.change_id, + }), + })) + .sort( + (left, right) => + compareCodePoints(left.sortKey, right.sortKey) || + left.index - right.index, + ) + .map(({ change }) => change); + interface CoverageInput { readonly input: JavaScriptExportShapeComparisonProjectionInput; readonly left: SelectedJavaScriptExport; diff --git a/src/domain/javascript/javascriptExportShapeVariants.ts b/src/domain/javascript/javascriptExportShapeVariants.ts index 00c420c44..e5880aca6 100644 --- a/src/domain/javascript/javascriptExportShapeVariants.ts +++ b/src/domain/javascript/javascriptExportShapeVariants.ts @@ -112,16 +112,6 @@ export const buildJavaScriptExportShapeChanges = ( return [...paired, ...unpairedLeft, ...unpairedRight]; }; -/** Sort changes by semantic content rather than traversal order. */ -export const compareJavaScriptExportShapeChanges = ( - left: JavaScriptExportShapeComparisonChange, - right: JavaScriptExportShapeComparisonChange, -): number => - compareCodePoints( - canonicalExportShapeValue(changeSortKey(left)), - canonicalExportShapeValue(changeSortKey(right)), - ); - /** Report whether any retained shape lacks complete property coverage. */ export const hasPartialJavaScriptExportPropertyCoverage = ( shapes: readonly Shape[], @@ -429,15 +419,6 @@ const changeWithId = ( change_id: `jesc_change_${digestExportShapeValue(semantic)}`, }); -const changeSortKey = (change: JavaScriptExportShapeComparisonChange) => ({ - discriminant: change.discriminant, - path: change.path, - status: change.status, - left_source_range: change.left_source_range, - right_source_range: change.right_source_range, - change_id: change.change_id, -}); - const literalValue = ( field: Field, ): diff --git a/tests/acceptance/applications/applicationExportShapeMcp.test.ts b/tests/acceptance/applications/applicationExportShapeMcp.test.ts index 5258ef2c4..4fad71615 100644 --- a/tests/acceptance/applications/applicationExportShapeMcp.test.ts +++ b/tests/acceptance/applications/applicationExportShapeMcp.test.ts @@ -135,9 +135,13 @@ describe("source-produced export comparison inventories", () => { `export const candidate${String(index)} = ${String(index)};`, ).join("\n"); const inventorySource = `export default function parse(value) {\n${returnSites}\n throw new Error("no match");\n}\n${extraExports}`; + const pairedVariantsSource = `export default function parse(value) { +${returnSites} + throw new Error("no match"); +}`; const [inventoryLeft, inventoryRight] = await Promise.all([ analyzeSourceEvidence(inventorySource), - analyzeSourceEvidence(inventorySource), + analyzeSourceEvidence(pairedVariantsSource), ]); const variants = compareEvidence(inventoryLeft, inventoryRight); expect(variants.left).toMatchObject({ status: "selected" }); diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index aab5fb66b..05e3f8e2d 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -88,6 +88,21 @@ describe("JavaScript export return-shape comparison", () => { expect(result.coverage.status).toBe("partial"); }); + it("orders multiple changed paths deterministically across Unicode names", async () => { + const graphs = await analyzeSources({ + left: `export default () => ({ type: "item", zeta: 1, alpha: 1, "éclair": 1 });`, + right: `export default () => ({ type: "item", zeta: 2, alpha: 2, "éclair": 2 });`, + }); + const first = compare(...graphs); + const second = compare(...graphs); + + expect(new Set(first.changes.map(({ path }) => path))).toEqual( + new Set(["/alpha", "/zeta", "/éclair"]), + ); + expect(first.changes).toEqual(second.changes); + expect(first.comparison_id).toBe(second.comparison_id); + }); + it("does not pair discriminants that an unknown trailing spread can overwrite", async () => { const [left, right] = await analyzeSources({ left: `