fix(artifacts): break directory child collation ties (#587)

This commit is contained in:
Kaoru0822 2026-10-05 09:24:15 -07:00 committed by GitHub
parent 6aba6ae231
commit b37e47c9f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 158 additions and 3 deletions

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { compareDirectoryChildNames } from "./ArtifactGraphConstruction.js";
const tiedNames = ["a.txt", "a\u200b.txt", "a\u200c.txt", "a\u200d.txt"];
describe("directory child-name ordering", () => {
it("orders the tied subset lexically with reflexivity and antisymmetry", () => {
for (const left of tiedNames) {
expect(compareDirectoryChildNames(left, left)).toBe(0);
for (const right of tiedNames) {
expect(left.localeCompare(right)).toBe(0);
const compared = compareDirectoryChildNames(left, right);
const lexical = left < right ? -1 : left > right ? 1 : 0;
expect(compared).toBe(lexical);
expect(compared + compareDirectoryChildNames(right, left)).toBe(0);
}
}
});
it("is transitive across the tied subset", () => {
for (const first of tiedNames)
for (const second of tiedNames)
for (const third of tiedNames)
if (
compareDirectoryChildNames(first, second) <= 0 &&
compareDirectoryChildNames(second, third) <= 0
)
expect(
compareDirectoryChildNames(first, third),
).toBeLessThanOrEqual(0);
});
it.each([
["a.txt", "b.txt"],
["z.txt", "ä.txt"],
["Z", "a"],
["a/data.txt", "b/data.txt"],
])("preserves the current non-tied collation for %s / %s", (left, right) => {
const collated = left.localeCompare(right);
expect(collated).not.toBe(0);
expect(compareDirectoryChildNames(left, right)).toBe(collated);
expect(compareDirectoryChildNames(right, left)).toBe(
right.localeCompare(left),
);
});
});

View File

@ -61,6 +61,17 @@ export const createOccurrence = (
limitations: [...entry.limitations],
});
/** Preserve collation order and break distinct-name ties by UTF-16 code units. */
export const compareDirectoryChildNames = (
left: string,
right: string,
): number => {
const collated = left.localeCompare(right);
if (collated !== 0) return collated;
if (left < right) return -1;
return left > right ? 1 : 0;
};
export const materializeDirectoryNodes = (
occurrences: MutableOccurrence[],
nodes: Map<string, ArtifactNode>,
@ -86,9 +97,7 @@ export const materializeDirectoryNodes = (
artifact_id,
entry_kind,
}))
.sort((left, right) =>
String(left.name).localeCompare(String(right.name)),
);
.sort((left, right) => compareDirectoryChildNames(left.name, right.name));
const node = createArtifactNode({
sha256: digestCanonical({ kind: "directory", children }),
size: 0,

View File

@ -176,3 +176,102 @@ describe("virtual directory relative-path identity", () => {
);
});
});
const tiedPermutations = [
["a", "a\u200b", "a\u200c"],
["a", "a\u200c", "a\u200b"],
["a\u200b", "a", "a\u200c"],
["a\u200b", "a\u200c", "a"],
["a\u200c", "a", "a\u200b"],
["a\u200c", "a\u200b", "a"],
];
describe("virtual directory collation ties", () => {
it.each([
{ suffix: ".txt", equalBytes: false },
{ suffix: ".txt", equalBytes: true },
{ suffix: "/data.txt", equalBytes: false },
{ suffix: "/data.txt", equalBytes: true },
])(
"keeps tied $suffix names stable with equalBytes=$equalBytes",
async ({ suffix, equalBytes }) => {
const observe = (directory: string, names: readonly string[]) =>
observeZip([
[`${directory}/`, ""],
...names.map((name): readonly [string, string] => [
`${directory}/${name}${suffix}`,
equalBytes ? "identical bytes" : `bytes for ${name}`,
]),
]);
const names = ["a", "a\u200b", "a\u200c"];
const reference = await observe("pkg", names);
for (const permutation of tiedPermutations) {
const observed = await observe("pkg", permutation);
expect(occurrenceAt(observed.inventory, "pkg").artifact_id).toBe(
occurrenceAt(reference.inventory, "pkg").artifact_id,
);
for (const name of names) {
const path = `pkg/${name}${suffix}`;
expect(occurrenceAt(observed.inventory, path).artifact_id).toBe(
occurrenceAt(reference.inventory, path).artifact_id,
);
}
const comparison = compareArtifacts(
reference.evidence,
observed.evidence,
);
expect(
comparison.changes.map(({ logical_path }) => logical_path),
).toEqual(
reference.inventory.manifest.root_sha256 ===
observed.inventory.manifest.root_sha256
? []
: ["."],
);
}
const moved = await observe("moved", names);
expect(occurrenceAt(moved.inventory, "moved").artifact_id).toBe(
occurrenceAt(reference.inventory, "pkg").artifact_id,
);
},
);
it("retains the legacy non-tied Unicode ordering and exact identity", async () => {
const { inventory } = await observeZip([
["pkg/", ""],
["pkg/z.txt", "Z"],
["pkg/ä.txt", "A"],
["empty/", ""],
]);
expect("z.txt".localeCompare("ä.txt")).not.toBe(0);
expect(occurrenceAt(inventory, "pkg").artifact_id).toBe(
legacyDirectoryId(inventory, ["pkg/z.txt", "pkg/ä.txt"]),
);
expect(occurrenceAt(inventory, "empty").artifact_id).toBe(
legacyDirectoryId(inventory, []),
);
});
it("keeps distinct collating-equal names significant during a rename", async () => {
const left = await observeZip([
["pkg/", ""],
["pkg/a.txt", "same bytes"],
]);
const right = await observeZip([
["pkg/", ""],
["pkg/a\u200b.txt", "same bytes"],
]);
expect("a.txt".localeCompare("a\u200b.txt")).toBe(0);
expect(occurrenceAt(left.inventory, "pkg").artifact_id).not.toBe(
occurrenceAt(right.inventory, "pkg").artifact_id,
);
expect(
compareArtifacts(left.evidence, right.evidence).changes,
).toContainEqual(
expect.objectContaining({
logical_path: "pkg",
classification: "changed",
}),
);
});
});