diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 1dde594197..837d18dc4d 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -5,6 +5,7 @@ import { recordPredicateCounts } from "./record-predicate-guard.mjs"; import { compareCountedBaseline, recordLoweredBaseline, + reconcileBaselineFile, } from "./counted-baseline.mjs"; import { localCompletenessEnumCount } from "./completeness-enum-guard.mjs"; import { countKeyedFirstMatches } from "./keyed-first-match.mjs"; @@ -269,7 +270,8 @@ const baselinePath = join( root, "scripts/verify/architecture-guard-baselines.json", ); -const baselines = JSON.parse(await readFile(baselinePath, "utf8")); +const baselineText = await readFile(baselinePath, "utf8"); +const baselines = JSON.parse(baselineText); const shrinkBaselines = process.argv.includes("--shrink-baselines"); let baselinesShrunk = false; const checkCounted = (label, key, selected, pattern, owners) => { @@ -671,20 +673,22 @@ checkCounted( [], ); -if (baselinesShrunk) { - const sorted = Object.fromEntries( - Object.entries(baselines).map(([key, counts]) => [ - key, - Object.fromEntries( - Object.entries(counts).sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0, - ), - ), - ]), +const baselineFile = reconcileBaselineFile( + baselineText, + baselines, + shrinkBaselines, +); +if (baselineFile.action === "write") { + await writeFile(baselinePath, baselineFile.text); + console.log( + baselinesShrunk + ? `Lowered counted baselines in ${relative(root, baselinePath)}.` + : `Rewrote ${relative(root, baselinePath)} in its sorted form.`, + ); +} else if (baselineFile.action === "reject") + failures.push( + `${relative(root, baselinePath)} is not in its written form (each guard's paths sorted by code unit); rewrite it with \`node scripts/verify/architecture-guards.mjs --shrink-baselines\`.`, ); - await writeFile(baselinePath, `${JSON.stringify(sorted, null, 2)}\n`); - console.log(`Lowered counted baselines in ${relative(root, baselinePath)}.`); -} if (failures.length > 0) { console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`); diff --git a/scripts/verify/counted-baseline.d.mts b/scripts/verify/counted-baseline.d.mts index 4a10c97d7b..3635801947 100644 --- a/scripts/verify/counted-baseline.d.mts +++ b/scripts/verify/counted-baseline.d.mts @@ -27,3 +27,17 @@ export function recordLoweredBaseline( readonly lowered: boolean; }, ): boolean; + +/** The baseline file text: guard order kept, each guard's paths in code-unit order. */ +export function serializeCountedBaselines( + baselines: Readonly>>>, +): string; + +/** Leave, rewrite or reject the baseline file read as `text`. */ +export function reconcileBaselineFile( + text: string, + baselines: Readonly>>>, + shrink: boolean, +): + | { readonly action: "none" | "reject" } + | { readonly action: "write"; readonly text: string }; diff --git a/scripts/verify/counted-baseline.mjs b/scripts/verify/counted-baseline.mjs index a090377c22..6a74fefc7f 100644 --- a/scripts/verify/counted-baseline.mjs +++ b/scripts/verify/counted-baseline.mjs @@ -36,3 +36,36 @@ export const recordLoweredBaseline = (baselines, key, compared) => { else baselines[key] = compared.baseline; return true; }; + +const byCodeUnit = ([left], [right]) => + left < right ? -1 : left > right ? 1 : 0; + +/** + * The baseline file's one written form: guard keys keep their order and each + * guard's paths are sorted by code unit. The guard rejects a file in any other + * form, so a hand edit or merge cannot leave an order the next + * `--shrink-baselines` would silently rewrite in an unrelated change. + */ +export const serializeCountedBaselines = (baselines) => + `${JSON.stringify( + Object.fromEntries( + Object.entries(baselines).map(([key, counts]) => [ + key, + Object.fromEntries(Object.entries(counts).sort(byCodeUnit)), + ]), + ), + null, + 2, + )}\n`; + +/** + * What the guard does with the baseline file it read as `text`, given the + * baselines after comparison: nothing when the file is already in its written + * form; otherwise `write` that form when shrinking (even if no count was + * lowered), or `reject` the file so a hand edit is fixed in its own change. + */ +export const reconcileBaselineFile = (text, baselines, shrink) => { + const written = serializeCountedBaselines(baselines); + if (written === text) return { action: "none" }; + return shrink ? { action: "write", text: written } : { action: "reject" }; +}; diff --git a/tests/boundary/filesystem/countedBaseline.test.ts b/tests/boundary/filesystem/countedBaseline.test.ts index 9617ff763f..b6a816abd0 100644 --- a/tests/boundary/filesystem/countedBaseline.test.ts +++ b/tests/boundary/filesystem/countedBaseline.test.ts @@ -2,7 +2,9 @@ import { expect, it } from "vitest"; import { compareCountedBaseline, + reconcileBaselineFile, recordLoweredBaseline, + serializeCountedBaselines, } from "../../../scripts/verify/counted-baseline.mjs"; it("rejects a file that gains copies or is missing from the baseline", () => { @@ -90,3 +92,44 @@ it("removes a key when shrinking removes its last recorded count", () => { expect(recordLoweredBaseline(baselines, "gone", compared)).toBe(true); expect(baselines).toEqual({ kept: { "src/b.ts": 2 } }); }); + +it("writes each guard's paths in code-unit order and keeps guard order", () => { + const baselines = { + later: { "src/b.ts": 1, "src/Upper.ts": 2, "src/a.ts": 3 }, + earlier: {}, + }; + expect(serializeCountedBaselines(baselines)).toBe( + `${JSON.stringify( + { + later: { "src/Upper.ts": 2, "src/a.ts": 3, "src/b.ts": 1 }, + earlier: {}, + }, + null, + 2, + )}\n`, + ); +}); + +it("rejects an unsorted baseline file unless shrinking, which rewrites it", () => { + const baselines = { guard: { "src/a.ts": 1, "src/b.ts": 2 } }; + const written = serializeCountedBaselines(baselines); + const unsorted = `${JSON.stringify( + { guard: { "src/b.ts": 2, "src/a.ts": 1 } }, + null, + 2, + )}\n`; + expect(reconcileBaselineFile(written, baselines, false)).toEqual({ + action: "none", + }); + expect(reconcileBaselineFile(written, baselines, true)).toEqual({ + action: "none", + }); + expect(reconcileBaselineFile(unsorted, baselines, false)).toEqual({ + action: "reject", + }); + // No count was lowered; shrinking still writes the sorted form. + expect(reconcileBaselineFile(unsorted, baselines, true)).toEqual({ + action: "write", + text: written, + }); +});