fix(artifacts): break directory child collation ties (#587)
This commit is contained in:
parent
6aba6ae231
commit
b37e47c9f7
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue