From d02b56a69243ea9b390a6dd55e09826b4f5c729b Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:08:41 +0000 Subject: [PATCH 1/4] Unique-index vectors: pin holder identity by case (rows 23-24) Two holders of one value whose keys differ only by case are two holders; a case-folding dedupe would see one and miss the refusal. Co-Authored-By: Claude Sonnet 5.5 --- .../UniqueIndexVectorTests.cs | 8 +++++++- tests/vectors/unique-index/README.md | 13 +++++++------ .../vectors/unique-index/holder-case-store.cc | Bin 0 -> 943 bytes tests/vectors/unique-index/refusals.msgpack | Bin 2676 -> 2868 bytes 4 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 tests/vectors/unique-index/holder-case-store.cc diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index 44aef30d..e3be8c8f 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -351,6 +351,7 @@ namespace GameCult.Caching.Tests private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); private static readonly Rec[] KeyCaseRecords = Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T")); + private static readonly Rec[] HolderCaseRecords = Items_(("a", "S"), ("A", "S")); private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); @@ -360,6 +361,7 @@ namespace GameCult.Caching.Tests ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), ("alias-store.cc", AliasRecords), ("key-case-store.cc", KeyCaseRecords), ("key-punctuation-store.cc", KeyPunctuationRecords), ("surrogate-groups-store.cc", SurrogateGroupRecords), + ("holder-case-store.cc", HolderCaseRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); @@ -368,6 +370,7 @@ namespace GameCult.Caching.Tests private static readonly Refusal AliasRefusal = new("item:a", "item:b", Alias, "Sku"); private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); + private static readonly Refusal HolderCaseRefusal = new("A", "a", Item, "Sku"); private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); @@ -402,6 +405,9 @@ namespace GameCult.Caching.Tests new("load-key-case", new[] { Open("key-case-store.cc") }, KeyCaseRefusal), new("load-key-punctuation", new[] { Open("key-punctuation-store.cc") }, KeyPunctuationRefusal), new("load-surrogate-groups", new[] { Open("surrogate-groups-store.cc") }, SurrogateGroupRefusal), + // Rows 23-24: two holders of one value that differ only by case are two holders; a case-folding dedupe would see one. + new("batch-holders-by-case", new[] { Open(), Commit(HolderCaseRecords) }, HolderCaseRefusal), + new("load-holders-by-case", new[] { Open("holder-case-store.cc") }, HolderCaseRefusal), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -589,7 +595,7 @@ namespace GameCult.Caching.Tests } } - Assert.That(walked, Is.EqualTo(22)); + Assert.That(walked, Is.EqualTo(24)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index ac4a40cd..621ebe36 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -19,7 +19,7 @@ reviewed commit. Other runtimes are held to the same canonical text and the same `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. - `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, - `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`: single-file stores written like + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`: single-file stores written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus @@ -51,19 +51,20 @@ last succeeds and the last refuses with exactly the RULE text built from those f byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). -The 13 rows of the spec are a floor; rows 14-22 extend it with orderings that a code-point, case-insensitive or +The 13 rows of the spec are a floor; rows 14-24 extend it with orderings that a code-point, case-insensitive or culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, 8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), 16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case, 20 load-key-case, 21 load-key-punctuation, 22 -load-surrogate-groups. Rows 19-22 open a store holding the records of rows 18, 14, 15 and 17, so a runtime with no batch +load-surrogate-groups, 23 batch-holders-by-case (`a` and `A` holding one value: `A` before `a`), 24 load-holders-by-case. +Rows 19-22 and 24 open a store holding the records of rows 18, 14, 15, 17 and 23, so a runtime with no batch commit still walks alias and cross-group key order. A runtime performs every row its API can perform and asserts the count it walked: -- C# and Rust: all 22. -- Python: 21, every row but 3 (`put_envelopes` takes one type). -- TypeScript: 11, rows 5, 8, 9, 10, 11, 12, 13, 19, 20, 21 and 22 (no batch commit, so no row 1-4, 6, 7 or 14-18; +- C# and Rust: all 24. +- Python: 23, every row but 3 (`put_envelopes` takes one type). +- TypeScript: 12, rows 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22 and 24 (no batch commit, so no row 1-4, 6, 7, 14-18 or 23; `variants:idea:unique-index-runtimes.ts-batch-commit`). diff --git a/tests/vectors/unique-index/holder-case-store.cc b/tests/vectors/unique-index/holder-case-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..ed8f9a2ae3397342085ff4eacb7386ea87d2958d GIT binary patch literal 943 zcmcgqy>8nu5RQSoNVL`wC`zJ5ZAOP~9jc;BQ4}ZgXbX|#R-}p=MIItuQZhet zO;HbMT-bMLu)$iSIh3 Date: Wed, 11 Nov 2026 10:11:49 +0000 Subject: [PATCH 2/4] cultcache-ts: a load and an index registration name the least violation of the resulting state; walk refusals.msgpack Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/src/cult-cache.ts | 61 ++++++++--- .../cultcache-ts/test/unique-index.test.ts | 103 ++++++++++++------ 2 files changed, 117 insertions(+), 47 deletions(-) diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index fde76001..c5bb663d 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -47,6 +47,7 @@ type EntryLookups = { }; type HydratedEntry = CultCacheEnvelope & { + catalogEntry: NonNullable; value: unknown; lookups: EntryLookups; }; @@ -316,6 +317,16 @@ export class CultCache { } } + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the + // order of the stores and of the entries in them. + refuseLeastIndexSharing(function* () { + for (const entry of next.entries.values()) { + for (const [alias, value] of entry.lookups.indexes) { + yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; + } + } + }()); + this.#hydrated = next; }); } @@ -652,6 +663,7 @@ export class CultCache { async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { const home = this.#homeStore(entry.type); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); + this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); await home?.push(entry); if (home && legacy) { @@ -903,7 +915,6 @@ export class CultCache { } const lookups = this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value); - this.#refuseIndexSharing(state, entry.type, registered.catalogEntry.schemaName, entry.key, lookups); return { ...entry, payload: this.#cloneBytes(entry.payload), @@ -995,7 +1006,6 @@ export class CultCache { const entryLookups: Array<[HydratedEntry, EntryLookups]> = []; const nameMap = nameAccessor ? new Map() : undefined; const indexMaps = new Map>(); - const sharing = new Map(); for (const indexName of indexAccessors.keys()) { indexMaps.set(indexName, new Map()); } @@ -1008,21 +1018,16 @@ export class CultCache { } for (const [indexName, indexValue] of lookups.indexes) { const map = indexMaps.get(indexName)!; - const holder = map.get(indexValue); - if (holder === undefined) { - map.set(indexValue, entry.key); - } else { - sharing.set(`${indexName}\u0000${indexValue}`, [...(sharing.get(`${indexName}\u0000${indexValue}`) ?? [holder]), entry.key]); - } + if (!map.has(indexValue)) map.set(indexValue, entry.key); } } - // Judged whole: any value held by two records refuses, naming the two smallest of its keys. Held records already - // passed the judge for every alias installed before, so only the alias being registered can be shared here. - for (const [id, keys] of sharing) { - keys.sort(compareOrdinal); - throw indexSharingRefusal(keys[0], keys[1], schemaName, id.slice(0, id.indexOf("\u0000"))); - } + // Judged whole, by the same judge as a load: the least violation of the state the registration would leave. + refuseLeastIndexSharing(function* () { + for (const [entry, lookups] of entryLookups) { + for (const [alias, value] of lookups.indexes) yield { type, schemaName, alias, value, key: entry.key }; + } + }()); return { entryLookups, nameMap, indexMaps }; } @@ -1099,6 +1104,34 @@ function compareOrdinal(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } +// The whole-state judge (variants:question:unique-index-refusal-pair-rule): over every (type, alias, value) held at two +// or more distinct keys, throws for the one least by (alias, least key, second-least key, schema name), each compared by +// UTF-16 code unit. A function of the state, never of the order the rows arrive in. +function refuseLeastIndexSharing(rows: Iterable<{ type: string; schemaName: string; alias: string; value: string; key: string }>): void { + const groups = new Map(); + for (const { type, schemaName, alias, value, key } of rows) { + const id = JSON.stringify([type, alias, value]); + const group = groups.get(id); + if (group) group.keys.push(key); + else groups.set(id, { schemaName, alias, keys: [key] }); + } + let least: { schemaName: string; alias: string; first: string; second: string } | undefined; + for (const { schemaName, alias, keys } of groups.values()) { + if (keys.length < 2) continue; + keys.sort(compareOrdinal); + const candidate = { schemaName, alias, first: keys[0], second: keys[1] }; + if (!least || compareLeast(candidate, least) < 0) least = candidate; + } + if (least) throw indexSharingRefusal(least.first, least.second, least.schemaName, least.alias); +} + +function compareLeast( + a: { schemaName: string; alias: string; first: string; second: string }, + b: { schemaName: string; alias: string; first: string; second: string }, +): number { + return compareOrdinal(a.alias, b.alias) || compareOrdinal(a.first, b.first) || compareOrdinal(a.second, b.second) || compareOrdinal(a.schemaName, b.schemaName); +} + function indexSharingRefusal(keyA: string, keyB: string, schemaName: string, alias: string): Error { const [first, second] = compareOrdinal(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA]; return new Error(`Records ${first} and ${second} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`); diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 852ea08b..16ea08f8 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -119,14 +119,6 @@ test("a removed or changed holder releases its value", async () => { assert.equal(cache.getKeyByIndex(document, "sku", "T"), "a"); }); -test("the keys in a refusal are in ordinal order whichever one lands second", async () => { - const document = item("item"); - const cache = memory(document); - await cache.put(document, "m", { sku: "S" }); - await assert.rejects(cache.put(document, "z", { sku: "S" }), { message: refusal("m", "z", "tests.item", "sku") }); - await assert.rejects(cache.put(document, "B", { sku: "S" }), { message: refusal("B", "m", "tests.item", "sku") }); -}); - // The C# White_Space set, and nothing else, is blank. Every UTF-16 code unit is judged, so a rule written for a few // characters (or for JavaScript's \s, which counts U+FEFF and omits U+0085) cannot pass. const CSHARP_WHITE_SPACE = (code: number) => @@ -365,6 +357,76 @@ test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async } }); +// refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit +// (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record: +// 5, 8-13 and 19-22 of the README. +const REFUSAL_SCHEMAS: [string, string, string, Record][] = [ + ["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }], + ["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }], + ["uq_alias", "tests.uq_alias", "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", { Sku: "sku", alt: "second" }], + ["uq_alpha", "tests.uq_alpha", "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", { Sku: "sku" }], + ["uq_beta", "tests.uq_beta", "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", { Sku: "sku" }], +]; + +type RefusalOp = ["open", string | null] | ["pull", string] | ["commit", [string, string, string, string][]]; + +test("vectors refusals.msgpack: every row TS can perform", async () => { + const rows = decode(await readFile(join(vectors, "refusals.msgpack"))) as [string, RefusalOp[], [string, string, string, string] | null][]; + const definitions = new Map( + REFUSAL_SCHEMAS.map(([type, schemaName, schemaId, indexes]) => [ + schemaName, + defineDocumentType({ + type, + schemaId, + schemaName, + schemaVersion: `${schemaName}.v1`, + schema: z.object({ sku: z.string(), second: z.string() }), + codec: codecs.slots<{ sku: string; second: string }>({ 0: ["sku", codecs.string], 1: ["second", codecs.string] }, `Uq${type}`), + indexes, + }), + ]), + ); + const dir = await mkdtemp(join(tmpdir(), "uidx-refusals-")); + let walked = 0; + try { + for (const [label, ops, refused] of rows) { + if (ops.some((op) => op[0] === "commit" && op[1].length > 1)) continue; + const file = join(dir, `${walked}.cc`); + let cache: CultCache | undefined; + const perform = async (op: RefusalOp) => { + if (op[0] === "open") { + if (op[1] !== null) await copyFile(join(vectors, op[1]), file); + let builder = CultCache.builder().withGenericStore(new SingleFileMessagePackBackingStore(file)); + for (const definition of definitions.values()) builder = builder.withDocumentType(definition); + cache = builder.build(); + await cache.pullAllBackingStores(); + } else if (op[0] === "pull") { + await copyFile(join(vectors, op[1]), file); + await cache!.pullAllBackingStores(); + } else { + for (const [key, schema, sku, second] of op[1]) await cache!.put(definitions.get(schema)!, key, { sku, second }); + } + }; + for (const op of ops.slice(0, -1)) await perform(op); + const last = ops[ops.length - 1]; + if (refused === null) { + await perform(last); + } else { + const [k1, k2, schema, alias] = refused; + const before = last[0] === "commit" || (last[0] === "open" && last[1] === null) + ? (existsSync(file) ? await readFile(file) : Buffer.alloc(0)) + : await readFile(join(vectors, last[1]!)); + await assert.rejects(perform(last), { message: refusal(k1, k2, schema, alias) }, label); + assert.deepEqual(existsSync(file) ? await readFile(file) : Buffer.alloc(0), before, `${label}: the file is untouched`); + } + walked++; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + assert.equal(walked, 11); +}); + test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { const dir = await mkdtemp(join(tmpdir(), "uidx-load-")); try { @@ -487,28 +549,3 @@ test("registerIndex of a second unique alias judges it, naming the two smallest assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact"); assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined); }); - -test("verdict parity: the same stores are refused whatever the load order, and the first collision refuses (text is informational)", async () => { - const dir = await mkdtemp(join(tmpdir(), "uidx-verdict-")); - try { - // Three records, one pair colliding; whichever position the pair has in the file, the store is refused and a clean store opens. - const roles = pair({ roles: { a: "a" } }); - for (const [name, values, refused] of [ - ["pair-first", ["s", "s", "t"], true], - ["pair-last", ["t", "s", "s"], true], - ["pair-split", ["s", "t", "s"], true], - ["distinct", ["r", "s", "t"], false], - ] as const) { - const file = join(dir, `${name}.cc`); - const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); - for (const [i, a] of values.entries()) await writer.put(roles, `r${i}`, { a }); - const unique = pair({ indexes: { a: "a" } }); - const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); - if (refused) await assert.rejects(reader.pullAllBackingStores(), /both hold unique index 'a'/u, name); - else await reader.pullAllBackingStores(); - assert.equal(reader.snapshot().length, refused ? 0 : 3, name); - } - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); -- 2.47.3 From 75fabfeb503d02bbd7b17a3bf1eb744db42612b2 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:26:00 +0000 Subject: [PATCH 3/4] cultcache-ts tests: walk refusals row 24 (holders by case); 12 rows Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/test/unique-index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 16ea08f8..8c21c2c0 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -359,7 +359,7 @@ test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async // refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit // (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record: -// 5, 8-13 and 19-22 of the README. +// 5, 8-13, 19-22 and 24 of the README. const REFUSAL_SCHEMAS: [string, string, string, Record][] = [ ["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }], ["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }], @@ -424,7 +424,7 @@ test("vectors refusals.msgpack: every row TS can perform", async () => { } finally { await rm(dir, { recursive: true, force: true }); } - assert.equal(walked, 11); + assert.equal(walked, 12); }); test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { -- 2.47.3 From 812143b498634742c1a141de9ee059c3b332f454 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 11:46:55 +0000 Subject: [PATCH 4/4] cultcache-ts tests: walk the 18 rows TS can perform of refusals.msgpack rows 1-33 Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/test/unique-index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 8c21c2c0..ae296094 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -359,7 +359,7 @@ test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async // refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit // (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record: -// 5, 8-13, 19-22 and 24 of the README. +// 5, 8-13, 19-22, 24, 25, 27, 29, 31, 32 and 33 of the README (18). const REFUSAL_SCHEMAS: [string, string, string, Record][] = [ ["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }], ["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }], @@ -424,7 +424,7 @@ test("vectors refusals.msgpack: every row TS can perform", async () => { } finally { await rm(dir, { recursive: true, force: true }); } - assert.equal(walked, 12); + assert.equal(walked, 18); }); test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { -- 2.47.3