fix(guards): refuse a counted-baseline file that is not in its written order (#11150)

* fix(guards): refuse a counted-baseline file that is not in its written order

* test(guards): decide the baseline file's rewrite or refusal in its owner and cover it
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 21:50:38 +08:00
1 parent 5e625e3139
commit 2c7c59e14e
4 files changed
+108 -14

No files matched your search

+18 -14
View File
@@ -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`);
+14
View File
@@ -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<Record<string, Readonly<Record<string, number>>>>,
): string;
/** Leave, rewrite or reject the baseline file read as `text`. */
export function reconcileBaselineFile(
text: string,
baselines: Readonly<Record<string, Readonly<Record<string, number>>>>,
shrink: boolean,
):
| { readonly action: "none" | "reject" }
| { readonly action: "write"; readonly text: string };
+33
View File
@@ -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" };
};
@@ -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,
});
});