fix(artifacts): record each case collision once at its diverging segment (#1505)

Inventory paired every path under one case spelling with every path under
each other spelling and listed all of them on each occurrence. An archive
with Foo/ and foo/ subtrees of N files each produced N-entry lists on 2N
occurrences: 4,000 such ZIP members yielded a 120 MB inspect_artifact
result and close to 1 GB of memory. The trie walks also recursed once per
path component, so a ZIP name with tens of thousands of components threw
RangeError.

A collision is now recorded at the segment where spellings diverge and
shared down that subtree by an iterative walk. Each occurrence names the
colliding prefix and the first other spelling, plus a count of further
spellings, each of which is its own occurrence.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-10 01:16:16 -07:00
1 parent 9e22da845b
commit 983b070d4d
2 files changed
+169 -61

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
annotateEnglishUnicodeCaseCollisions,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
} from "./ArtifactPaths.js";
const NOTE =
"a case-insensitive destination cannot store both. This comparison is not a filesystem observation.";
const annotate = (paths: readonly string[]) => {
const occurrences = paths.map((logical_path) => ({
logical_path,
limitations: [] as string[],
}));
const limitation = annotateEnglishUnicodeCaseCollisions(occurrences);
return {
limitation,
byPath: new Map(
occurrences.map(({ logical_path, limitations }) => [
logical_path,
limitations,
]),
),
};
};
describe("English Unicode case collisions", () => {
it("names the other spelling of a colliding file", () => {
const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]);
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
expect(byPath.get("Main.js")).toEqual([
`Logical path Main.js collides under English (en-US) Unicode case folding with main.js; ${NOTE}`,
]);
expect(byPath.get("main.js")).toEqual([
`Logical path main.js collides under English (en-US) Unicode case folding with Main.js; ${NOTE}`,
]);
expect(byPath.get("x")).toEqual([]);
});
it("records a directory collision once at the diverging segment", () => {
const { byPath } = annotate([
"res/Foo",
"res/Foo/a.txt",
"res/foo",
"res/foo/b.txt",
]);
expect(byPath.get("res/Foo")).toEqual([
`Logical path res/Foo collides under English (en-US) Unicode case folding with res/foo; ${NOTE}`,
]);
expect(byPath.get("res/foo/b.txt")).toEqual([
`Logical path res/foo/b.txt is under res/foo, which collides under English (en-US) Unicode case folding with res/Foo; ${NOTE}`,
]);
});
it("counts further spellings, each of which is its own occurrence", () => {
const { byPath } = annotate(["A.txt", "a.txt", "a.TXT"]);
expect(byPath.get("a.txt")).toEqual([
`Logical path a.txt collides under English (en-US) Unicode case folding with A.txt and 1 other spelling; ${NOTE}`,
]);
});
it("stays linear for large case-variant subtrees", () => {
const count = 5_000;
const paths = Array.from({ length: count }, (_, index) => [
`Foo/a${String(index)}.txt`,
`foo/b${String(index)}.txt`,
]).flat();
const { byPath } = annotate(paths);
for (const path of paths) {
const limitations = byPath.get(path) ?? [];
expect(limitations).toHaveLength(1);
expect(limitations[0]?.length).toBeLessThan(300);
}
});
it("walks names with more components than the call stack holds", () => {
const deep = Array.from({ length: 30_000 }, () => "a").join("/");
const { byPath } = annotate([deep, `${deep}/X`, `${deep}/x`]);
expect(byPath.get(`${deep}/X`)).toHaveLength(1);
expect(byPath.get(deep)).toEqual([]);
});
});
+86 -61
View File
@@ -115,16 +115,39 @@ export const annotateEnglishUnicodeCaseCollisions = (
occurrences.map(({ logical_path }) => logical_path),
);
if (collisions.size === 0) return undefined;
for (const occurrence of occurrences) {
const others = collisions.get(occurrence.logical_path);
if (others === undefined || others.length === 0) continue;
occurrence.limitations.push(
`Logical path ${occurrence.logical_path} collides under English (en-US) Unicode case folding with ${others.join(", ")}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`,
);
}
for (const occurrence of occurrences)
for (const collision of collisions.get(occurrence.logical_path) ?? [])
occurrence.limitations.push(
caseCollisionLimitation(occurrence.logical_path, collision),
);
return ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION;
};
/** One logical path segment whose spelling folds to another sibling's. */
interface CaseCollision {
/** Logical path through the colliding segment. */
readonly prefix: string;
/** First other sibling spelling, as a logical path, in code-point order. */
readonly other: string;
/** Count of further sibling spellings, each its own occurrence. */
readonly additional: number;
}
const caseCollisionLimitation = (
path: string,
{ prefix, other, additional }: CaseCollision,
): string => {
const others =
additional === 0
? other
: `${other} and ${String(additional)} other spelling${additional === 1 ? "" : "s"}`;
const subject =
prefix === path
? `Logical path ${path} collides`
: `Logical path ${path} is under ${prefix}, which collides`;
return `${subject} under English (en-US) Unicode case folding with ${others}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`;
};
/**
* Name the directory-listing spelling that differs from the requested segment
* only by English (en-US) Unicode case. Exact matches are the same logical name.
@@ -147,7 +170,7 @@ export const destinationCaseCollisionMessage = (
const englishUnicodeCaseCollisions = (
paths: readonly string[],
): ReadonlyMap<string, readonly string[]> => {
): ReadonlyMap<string, readonly CaseCollision[]> => {
const root = emptyCaseNode();
for (const path of paths) {
if (path.length === 0 || path === ".") continue;
@@ -166,60 +189,62 @@ const englishUnicodeCaseCollisions = (
}
node.terminals.push(path);
}
const collisions = new Map<string, Set<string>>();
recordCaseCollisions(root, collisions);
return new Map(
[...collisions.entries()].map(([path, others]) => [
path,
[...others].sort(compareUnicodeCodePoints),
]),
);
};
const recordCaseCollisions = (
node: CaseNode,
collisions: Map<string, Set<string>>,
): void => {
for (const spellings of node.spellingsByFold.values())
if (spellings.length > 1) noteFoldedSpellings(node, spellings, collisions);
for (const child of node.bySpelling.values())
recordCaseCollisions(child, collisions);
};
const noteFoldedSpellings = (
node: CaseNode,
spellings: readonly string[],
collisions: Map<string, Set<string>>,
): void => {
const groups = spellings.map((spelling) => {
const child = node.bySpelling.get(spelling);
return child === undefined ? [] : terminalsUnder(child);
});
for (const [index, group] of groups.entries())
noteCaseGroup(
group,
groups.flatMap((candidate, candidateIndex) =>
candidateIndex === index ? [] : candidate,
),
collisions,
);
};
const noteCaseGroup = (
group: readonly string[],
others: readonly string[],
collisions: Map<string, Set<string>>,
): void => {
for (const path of group) {
const set = collisions.get(path) ?? new Set<string>();
for (const other of others) if (other !== path) set.add(other);
collisions.set(path, set);
// A collision is recorded once, at the segment where spellings diverge, and
// shared down the subtree. Pairing every descendant of one spelling with
// every descendant of another grew quadratically with the subtree sizes.
// The walk is iterative because a ZIP name can hold more components than
// the call stack allows.
const collisions = new Map<string, readonly CaseCollision[]>();
const pending: {
readonly node: CaseNode;
readonly prefix: string | undefined;
readonly chain: CollisionChain | undefined;
}[] = [{ node: root, prefix: undefined, chain: undefined }];
for (let item = pending.pop(); item !== undefined; item = pending.pop()) {
const { node, prefix, chain } = item;
if (chain !== undefined)
for (const path of node.terminals)
collisions.set(path, collisionsAlong(chain));
for (const spellings of node.spellingsByFold.values())
spellings.sort(compareUnicodeCodePoints);
for (const [spelling, child] of node.bySpelling) {
const childPrefix =
prefix === undefined ? spelling : `${prefix}/${spelling}`;
const spellings =
node.spellingsByFold.get(englishCaseFold(spelling)) ?? [];
const other = spellings[0] === spelling ? spellings[1] : spellings[0];
pending.push({
node: child,
prefix: childPrefix,
chain:
other === undefined
? chain
: {
collision: {
prefix: childPrefix,
other: prefix === undefined ? other : `${prefix}/${other}`,
additional: spellings.length - 2,
},
parent: chain,
},
});
}
}
return collisions;
};
const terminalsUnder = (node: CaseNode): readonly string[] => {
const paths = [...node.terminals];
for (const child of node.bySpelling.values())
paths.push(...terminalsUnder(child));
return paths;
/** Collisions on one path, linked from the deepest segment to the root. */
interface CollisionChain {
readonly collision: CaseCollision;
readonly parent: CollisionChain | undefined;
}
const collisionsAlong = (chain: CollisionChain): CaseCollision[] => {
const collisions: CaseCollision[] = [];
let link: CollisionChain | undefined = chain;
while (link !== undefined) {
collisions.push(link.collision);
link = link.parent;
}
return collisions.reverse();
};