Skip to content

Commit 44dfcb7

Browse files
Prithvi-Webclaude
andcommitted
fix(store): childPath reads a parent stored as a byte-order mark alone as the empty name, as name() does - so a child under it is joined as joinPath and the old rule joined it, with one separator, and the cleanup walk and the query walk spell it alike (CI4 review F1.1)
CI4 made childPath choose the separator from the parent's stored bytes: empty, or ending in the separator's byte. name() decodes with a TextDecoder, which reads a leading byte-order mark as nothing, so a folder stored as the mark alone (EF BB BF) is the empty name: its path ends with the separator. The byte rule saw three bytes ending in 0xBF and joined a second one - "/r//a" where the old endsWith rule, and joinPath, which the cleanup walk joins a packed store's names with, give "/r/a". One node had two spellings. A parent whose stored name begins with the mark is now decided from its name as name() reads it - empty, or ending with the separator - so "/" is the separator alone and "" is a name that does not end with one. It still never reads the parent path: decoding a name costs the name's length, not the path's. Test (tests/packedStore.test.ts, CI4's byte-for-byte differential): the names now include "", "" + sep, "a" and "", under every root and separator; RED before the fix on "a" under "/r//" ("/r///a" against "/r//a"). The cleanup walk's comment now cites the differential. Mutants, each RED for the right reason, restored and checked by SHA-256: no mark branch ("a" under "/r//"); no empty clause (same); no ends-with clause ("a" under "/r///"); a mark-led name always empty ("a" under "/r/"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent edba986 commit 44dfcb7

3 files changed

Lines changed: 31 additions & 8 deletions

File tree

‎src/services/cleanupRules.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ class SuggestionWalk implements SlicedWalk {
110110
private readonly suppressed: PreparedSuppression[];
111111
private readonly locations: Map<string, LocationRule>;
112112
/**
113-
* PackedScanStore.childPath is `joinPath(parentPath, sep, name(child))`;
113+
* PackedScanStore.childPath is `joinPath(parentPath, sep, name(child))` for
114+
* every parent path the store spells, a parent stored as a byte-order mark
115+
* alone included (tests/packedStore.test.ts, CI4's byte-for-byte test);
114116
* joining the name this walk already read saves reading it twice. Any other
115117
* store, a subclass included, answers `childPath` itself.
116118
*/

‎src/services/scanStore.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,8 +1491,9 @@ export class PackedScanStore implements ScanStore {
14911491
* 30,000-deep chain ran a 2 GB heap out). Byte for byte what endsWith
14921492
* answered for every spelling the store gives a parent (`rootPath`,
14931493
* `path()`, `childPath()`): the root's path is the one it was given, and
1494-
* any other folder's ends with its own name, so it ends with the separator
1495-
* exactly when that name is empty or ends with it.
1494+
* any other folder's ends with its own name as name() reads it, so it ends
1495+
* with the separator exactly when that name is empty or ends with it — and
1496+
* so it is joinPath's answer, which the cleanup walk joins names with.
14961497
*/
14971498
childPath(child: number, parentPath: string): string {
14981499
const name = this.name(child);
@@ -1506,11 +1507,22 @@ export class PackedScanStore implements ScanStore {
15061507
// before it ("::" + ":"). The path is read.
15071508
endsWithSep = parentPath.endsWith(this.sep);
15081509
} else {
1509-
// The parent's stored name is empty or ends in the separator's byte
1510-
// (UTF-8: an ASCII byte is never part of a longer character).
15111510
const from = this.nameOff[parent];
15121511
const to = this.nameOff[parent + 1];
1513-
endsWithSep = to === from || this.nameBytes[to - 1] === this.sepByte;
1512+
const bytes = this.nameBytes;
1513+
if (to - from >= 3 && bytes[from] === 0xef && bytes[from + 1] === 0xbb && bytes[from + 2] === 0xbf) {
1514+
// A leading byte-order mark, which name()'s TextDecoder reads as
1515+
// nothing (CI4 review F1.1): '\uFEFF' alone is the empty name though
1516+
// its three bytes are stored, and '\uFEFF/' is the separator alone.
1517+
// Decided from the parent's name as name() reads it — its name, never
1518+
// its path.
1519+
const parentName = this.name(parent);
1520+
endsWithSep = parentName.length === 0 || parentName.endsWith(this.sep);
1521+
} else {
1522+
// The parent's stored name is empty or ends in the separator's byte
1523+
// (UTF-8: an ASCII byte is never part of a longer character).
1524+
endsWithSep = to === from || bytes[to - 1] === this.sepByte;
1525+
}
15141526
}
15151527
return endsWithSep ? parentPath + name : parentPath + this.sep + name;
15161528
}

‎tests/packedStore.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,12 @@ test('CI4: childPath reads nothing of the parent path it is given — down a dee
148148
);
149149
});
150150

151-
/** How childPath chose the separator before CI4 — by reading the end of the parent path — kept as the oracle. */
151+
/**
152+
* How childPath chose the separator before CI4 — by reading the end of the
153+
* parent path — kept as the oracle. It is joinPath's rule too, which the
154+
* cleanup walk joins a packed store's names with in place of childPath
155+
* (cleanupRules.ts, joinsNames): one node, one spelling.
156+
*/
152157
const joinedByReading = (parentPath: string, sep: string, name: string): string =>
153158
parentPath.endsWith(sep) ? parentPath + name : parentPath + sep + name;
154159

@@ -159,7 +164,11 @@ test('CI4: childPath answers byte for byte what reading its parent path answered
159164
for (const [rootPath, sep] of roots) {
160165
// '' after 'a': an empty name's stored bytes sit after a name that does
161166
// not end in the separator, so only its emptiness can say it does.
162-
const names = ['a', '', `a${sep}`, 'é', `é${sep}`, 'b\\', 'c/', `${sep}${sep}`, ':', 'b:', 'z::', 'Ʀ'];
167+
// A byte-order mark (CI4 review F1.1): name() reads a leading one as
168+
// nothing, so '\uFEFF' alone is the empty name though three bytes are
169+
// stored, and '\uFEFF' + sep is the separator alone; one anywhere else,
170+
// or a second, is part of the name.
171+
const names = ['a', '', `a${sep}`, 'é', `é${sep}`, 'b\\', 'c/', `${sep}${sep}`, ':', 'b:', 'z::', 'Ʀ', '\uFEFF', `\uFEFF${sep}`, 'a\uFEFF', '\uFEFF\uFEFF'];
163172
const packed = new PackedScanStore(rootPath, sep, folderInput('r'));
164173
// Every name under the root, under every name, under every name: a parent
165174
// of each kind under the root and under a parent of each kind.

0 commit comments

Comments
 (0)