Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Libby
12e938d23c Python @directory walk asserts the directory stays empty; manifest comment names the write paths
Some checks failed
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:25:03 +00:00
Libby
e79abfbc55 Merge remote-tracking branch 'origin/hands/walk-rows-rs' into walk-rows
Some checks failed
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
2026-11-11 17:00:39 +00:00
Libby
8ec3b0a6f2 Merge remote-tracking branch 'origin/hands/walk-rows-py' into walk-rows 2026-11-11 17:00:39 +00:00
Libby
eebc5602a5 Merge remote-tracking branch 'origin/hands/walk-rows-ts' into walk-rows 2026-11-11 17:00:39 +00:00
Libby
63a5f65fe6 C# readability walks learn the @directory row; legacy-catalog rows pass as written
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:59:49 +00:00
Libby
619877f0a3 cultcache-ts: readability walk learns @directory and the legacy-catalog c0 rows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:28:09 +00:00
Libby
a401d5454e cultcache-rs readability walk: teach @directory; the three legacy-catalog c0 rows pass unchanged
Rust refuses all four new rows with no source change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:27:57 +00:00
Libby
528bc8c225 cultcache-py readability walk: learn @directory token; legacy-catalog rows pass
A directory at the store path is refused with OSError (_read_store contract) and left untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:27:46 +00:00
Libby
11c611d435 readability manifest: three legacy-catalog c0 rows and a directory-at-path row (red until each walker learns them)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:26:53 +00:00
6 changed files with 114 additions and 25 deletions

View file

@ -1567,15 +1567,19 @@ class CultCacheTests(unittest.TestCase):
# Every vector in the folder has a manifest row. # Every vector in the folder has a manifest row.
self.assertEqual( self.assertEqual(
sorted(path.name for path in root.glob("*.bin")), sorted(path.name for path in root.glob("*.bin")),
sorted(vector for vector, *_ in rows if not vector.startswith("..")), sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))),
) )
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
for vector, _, _, _, python in rows: for vector, _, _, _, python in rows:
content = (root / vector).read_bytes() # "@directory": the walker makes an empty directory at the store path; it is refused and left untouched.
content = None if vector == "@directory" else (root / vector).read_bytes()
for operation in ("pull_all", "push_all", "push", "delete"): for operation in ("pull_all", "push_all", "push", "delete"):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack" store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes(content) if content is None:
store_path.mkdir()
else:
store_path.write_bytes(content)
store = SingleFileMessagePackBackingStore(store_path) store = SingleFileMessagePackBackingStore(store_path)
run = { run = {
"pull_all": store.pull_all, "pull_all": store.pull_all,
@ -1598,6 +1602,11 @@ class CultCacheTests(unittest.TestCase):
self.assertIn(envelopes[0].key, keys, f"{vector} {operation}") self.assertIn(envelopes[0].key, keys, f"{vector} {operation}")
else: else:
self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}") self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}")
elif content is None:
# A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode.
with self.assertRaises(OSError, msg=f"{vector} {operation}"):
run()
self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory")
else: else:
with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused:
run() run()

View file

@ -6140,16 +6140,22 @@ mod tests {
for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) { for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) {
let cells: Vec<&str> = line.split_whitespace().collect(); let cells: Vec<&str> = line.split_whitespace().collect();
let (vector, reads) = (cells[0], cells[2] == "reads"); let (vector, reads) = (cells[0], cells[2] == "reads");
if !vector.starts_with("..") { if !vector.starts_with("..") && !vector.starts_with('@') {
listed.push(vector.to_string()); listed.push(vector.to_string());
} }
let bytes = std::fs::read(root.join(vector))?; // `@directory`: the walker puts an empty directory at the store path; it is refused and left untouched.
let directory = vector == "@directory";
let bytes = if directory { Vec::new() } else { std::fs::read(root.join(vector))? };
// A v3 store keeps its marker through a rewrite; every other file that reads is written v1. // A v3 store keeps its marker through a rewrite; every other file that reads is written v1.
let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 }; let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 };
for operation in ["open", "soft_push_all", "push_all", "push"] { for operation in ["open", "soft_push_all", "push_all", "push"] {
let temp = tempfile::tempdir()?; let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack"); let path = temp.path().join("store.msgpack");
std::fs::write(&path, &bytes)?; if directory {
std::fs::create_dir(&path)?;
} else {
std::fs::write(&path, &bytes)?;
}
let mut store = SingleFileMessagePackBackingStore::new(&path); let mut store = SingleFileMessagePackBackingStore::new(&path);
let outcome = match operation { let outcome = match operation {
"open" => store.pull_all().map(|_| ()), "open" => store.pull_all().map(|_| ()),
@ -6159,6 +6165,10 @@ mod tests {
}; };
let what = format!("{vector} {operation}"); let what = format!("{vector} {operation}");
assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}"); assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}");
if directory {
assert!(path.is_dir() && std::fs::read_dir(&path)?.next().is_none(), "{what} touched the directory");
continue;
}
if operation == "soft_push_all" && reads { if operation == "soft_push_all" && reads {
// A soft flush leaves a store it reads byte for byte. // A soft flush leaves a store it reads byte for byte.
assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads"); assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads");

View file

@ -2582,17 +2582,24 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
.filter((line) => line.length > 0 && !line.startsWith("#")) .filter((line) => line.length > 0 && !line.startsWith("#"))
.map((line) => line.split(/\s+/u)); .map((line) => line.split(/\s+/u));
// Every vector in the folder has a manifest row. // Every vector in the folder has a manifest row.
const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort(); const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..") && !vector.startsWith("@")).sort();
assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed); assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed);
const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-")); const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-"));
const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"); const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack");
const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll(); const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll();
for (const [vector, , , typescript] of rows) { for (const [vector, , , typescript] of rows) {
const reads = typescript === "reads"; const reads = typescript === "reads";
const bytes = await readFile(join(root, vector)); // "@directory": the walker makes an empty directory at the store path; the runtime refuses to open or replace it and leaves it be.
const isDirectory = vector === "@directory";
const bytes = isDirectory ? Buffer.alloc(0) : await readFile(join(root, vector));
for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) {
const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`);
await writeFile(file, bytes); if (isDirectory) {
assert.equal(typescript, "refuses", "a directory is never a store");
await mkdir(file);
} else {
await writeFile(file, bytes);
}
const store = new SingleFileMessagePackBackingStore(file); const store = new SingleFileMessagePackBackingStore(file);
const run = () => const run = () =>
operation === "pullAll" operation === "pullAll"
@ -2627,6 +2634,10 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
} else { } else {
assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`); assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
} }
} else if (isDirectory) {
await assert.rejects(run, `${vector} ${operation}`);
assert.ok((await lstat(file)).isDirectory(), `${vector} ${operation} replaced the directory`);
assert.deepEqual(await readdir(file), [], `${vector} ${operation} wrote into the directory`);
} else { } else {
await assert.rejects( await assert.rejects(
run, run,

View file

@ -7,6 +7,7 @@ using System.Linq;
using GameCult.Caching.MessagePack; using GameCult.Caching.MessagePack;
using MessagePack; using MessagePack;
using NUnit.Framework; using NUnit.Framework;
using NUnit.Framework.Constraints;
using static GameCult.Caching.Tests.ElementIdTests; using static GameCult.Caching.Tests.ElementIdTests;
namespace GameCult.Caching.Tests namespace GameCult.Caching.Tests
@ -61,6 +62,39 @@ namespace GameCult.Caching.Tests
.Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) .Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
.Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}")); .Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}"));
// The row `@directory` has no bytes: the walker makes an empty directory at the store path, and a runtime must refuse it untouched.
private const string DirectoryRow = "@directory";
private static byte[] Place(string path, string vector)
{
if (vector != DirectoryRow)
{
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
File.WriteAllBytes(path, bytes);
return bytes;
}
if (File.Exists(path))
File.Delete(path);
Directory.CreateDirectory(path);
return Array.Empty<byte>();
}
// A directory at the store path is refused as IOException ("not a store file", CultCache.cs): not a store that is unreadable.
private static IResolveConstraint Refusal(string vector) =>
vector == DirectoryRow ? Throws.TypeOf<IOException>() : Throws.TypeOf<CultStoreUnreadableException>();
private static void AssertUntouched(string path, string vector, byte[] bytes, string message)
{
if (vector == DirectoryRow)
{
Assert.That(Directory.Exists(path), message);
Assert.That(Directory.EnumerateFileSystemEntries(path), Is.Empty, message);
}
else
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), message);
}
private static bool HoldsAVariant(byte[] bytes) => private static bool HoldsAVariant(byte[] bytes) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null); CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null);
@ -90,14 +124,17 @@ namespace GameCult.Caching.Tests
public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads)
{ {
var path = Path.Combine(_directory, "open.cc"); var path = Path.Combine(_directory, "open.cc");
File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); var bytes = Place(path, vector);
void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose();
if (reads) if (reads)
Assert.DoesNotThrow(Open); Assert.DoesNotThrow(Open);
else else
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); {
Assert.That(Open, vector == DirectoryRow ? Refusal(vector) : Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
AssertUntouched(path, vector, bytes, "a store this runtime cannot open was changed by the attempt");
}
} }
[TestCaseSource(nameof(Vectors))] [TestCaseSource(nameof(Vectors))]
@ -105,8 +142,7 @@ namespace GameCult.Caching.Tests
{ {
var path = Seed("flush.cc"); var path = Seed("flush.cc");
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); var bytes = Place(path, vector);
File.WriteAllBytes(path, bytes);
var store = cache.BackingStores[0]; var store = cache.BackingStores[0];
if (reads && HoldsAVariant(bytes)) if (reads && HoldsAVariant(bytes))
@ -126,8 +162,8 @@ namespace GameCult.Caching.Tests
return; return;
} }
Assert.That(() => store.PushAll(), Throws.TypeOf<CultStoreUnreadableException>()); Assert.That(() => store.PushAll(), Refusal(vector));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten");
} }
[TestCaseSource(nameof(Vectors))] [TestCaseSource(nameof(Vectors))]
@ -135,8 +171,7 @@ namespace GameCult.Caching.Tests
{ {
var path = Seed("commit.cc"); var path = Seed("commit.cc");
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); var bytes = Place(path, vector);
File.WriteAllBytes(path, bytes);
if (reads && HoldsAVariant(bytes)) if (reads && HoldsAVariant(bytes))
{ {
@ -153,8 +188,8 @@ namespace GameCult.Caching.Tests
return; return;
} }
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<CultStoreUnreadableException>()); Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Refusal(vector));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten");
} }
private string DirectoryStore(string name) private string DirectoryStore(string name)
@ -679,7 +714,7 @@ namespace GameCult.Caching.Tests
[Test] [Test]
public void EveryVectorInTheFolderHasAManifestRow() public void EveryVectorInTheFolderHasAManifestRow()
{ {
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal);
var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
Assert.That(rows, Is.EqualTo(files)); Assert.That(rows, Is.EqualTo(files));
} }

View file

@ -16,6 +16,8 @@ namespace GameCult.Mesh.Tests;
public sealed class CultMeshSingleFileReadabilityTests public sealed class CultMeshSingleFileReadabilityTests
{ {
private const int CSharp = 1; private const int CSharp = 1;
// The row `@directory` has no bytes: the walker makes an empty directory at the store path, and the write must refuse it untouched.
private const string DirectoryRow = "@directory";
private string _root = null!; private string _root = null!;
[SetUp] [SetUp]
@ -67,8 +69,15 @@ public sealed class CultMeshSingleFileReadabilityTests
foreach (var (name, write) in writes) foreach (var (name, write) in writes)
{ {
var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc"); var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc");
var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); var isDirectory = vector == DirectoryRow;
File.WriteAllBytes(path, bytes); var bytes = Array.Empty<byte>();
if (isDirectory)
Directory.CreateDirectory(path);
else
{
bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector));
File.WriteAllBytes(path, bytes);
}
if (reads) if (reads)
{ {
@ -96,8 +105,18 @@ public sealed class CultMeshSingleFileReadabilityTests
continue; continue;
} }
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), name); // A directory at the path is refused as IOException ("not a store file"), not as an unreadable store.
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); if (isDirectory)
Assert.That(() => write(path), Throws.TypeOf<IOException>(), name);
else
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), name);
if (isDirectory)
{
Directory.Exists(path).Should().BeTrue(name + " replaced a directory");
Directory.EnumerateFileSystemEntries(path).Should().BeEmpty(name + " wrote into a directory");
}
else
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read");
} }
} }
@ -115,7 +134,7 @@ public sealed class CultMeshSingleFileReadabilityTests
[Test] [Test]
public void EveryVectorInTheFolderHasAManifestRow() public void EveryVectorInTheFolderHasAManifestRow()
{ {
var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal);
var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
rows.Should().Equal(files); rows.Should().Equal(files);
} }

View file

@ -1,6 +1,7 @@
# Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not. # Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not.
# A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys. # A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys.
# Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store. # Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store.
# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it and to write over it (flush, commit, delete), leaving the directory empty.
../v3-base.msgpack reads reads reads reads ../v3-base.msgpack reads reads reads reads
zero-byte.bin refuses refuses refuses refuses zero-byte.bin refuses refuses refuses refuses
empty-array.bin reads reads reads reads empty-array.bin reads reads reads reads
@ -38,3 +39,7 @@ dup-key-different-payload-v3.bin refuses refuses refuses refuses
distinct-keys-case-v3.bin reads reads reads reads distinct-keys-case-v3.bin reads reads reads reads
distinct-keys-nfd-v3.bin reads reads reads reads distinct-keys-nfd-v3.bin reads reads reads reads
../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses ../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses
../../document-variants-c0/legacy-catalog-plain.msgpack refuses refuses refuses refuses
../../document-variants-c0/legacy-catalog-extra-slot.msgpack refuses refuses refuses refuses
../../document-variants-c0/legacy-catalog-trailing.msgpack refuses refuses refuses refuses
@directory refuses refuses refuses refuses