From 50cc65f4ddc55d002bf167b5eee4508c50205371 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 24 Oct 2026 06:34:31 +0800 Subject: [PATCH] emrg: the rotation reading asks the volume which two spellings are one file (#2559) --- scripts/check-memory-rotation.py | 37 ++++++++++++--- tests/test_check_memory_rotation.py | 73 ++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index c80bfcc8..87c466ac 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -62,7 +62,12 @@ itself: the row could never be current, and every run would report the agent changed on the strength of the file that asks the question (issue #2422). It is excluded from the scan wherever it sits - inside a scanned root, or outside every root, where the exclusion -is a no-op - and that exclusion is a stated rule rather than an invisible subtraction. +is a no-op - and that exclusion is a stated rule rather than an invisible subtraction. The +rule is an **identity** one, asked of the tree's single home for it +(`emrg/tools/file_policy.same_file`) instead of being spelled a second time here: two +spellings of one file are one file, and a comparison of resolved strings cannot say so - the +bookmark was excluded under the spelling the walk used and counted as that agent's memory +under a spelling one volume folds away (issue #2559). Each agent lands in exactly one state: @@ -124,6 +129,18 @@ from datetime import datetime from pathlib import Path +#: This file's own tree, put on `sys.path` before the one import it takes from the package. +#: The insert is unconditional and comes first for the reason `check-memory-index.py` +#: documents at length: this environment's `PYTHONPATH` can carry the **installed** package +#: ahead of the checkout, and the rule this file asks for has to be the rule of the tree the +#: file ships in rather than of a version behind it. +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +# The home of "are these two paths one file". Imported rather than re-spelled: the rule is +# stated once for the whole tree, and a second spelling of it is what produced issue #2559. +from emrg.tools.file_policy import same_file # noqa: E402 + #: The one stamp format a bookmark row may carry, and the one the shell form parses. #: Spelled as a constant because the comparison below is the only reader of it: a #: value that does not fit is unmeasurable, and this is the string that decides. @@ -542,11 +559,19 @@ def memory_roots(root: Path) -> list[Path]: def _same_file(a: Path, b: Path) -> bool: - """Whether two paths name one file, through symlinks and a relative spelling alike.""" - try: - return os.path.realpath(a) == os.path.realpath(b) - except OSError: # pragma: no cover - realpath does not raise for a missing tail - return False + """Whether two paths name one file, asked as identity rather than as spelling. + + The question has one home for the whole tree - `emrg.tools.file_policy.same_file`, which + compares `st_dev`/`st_ino` (the file-id pair on Windows) - and this asks it. It used to + compare `os.path.realpath` **strings**, and `realpath` returns the spelling it was handed + for every component that exists: on a volume that folds case, one file has two spellings, + so the bookmark was excluded under the walk's spelling and counted as the holding agent's + own memory under another (issue #2559). + + A path with no inode has no identity to compare, so the answer is False - the direction + that matters here: excluding a path that does not exist would hide a real memory file. + """ + return same_file(str(a), str(b)) def memory_files(root: Path, exclude: Path | None = None) -> list[Path]: diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index c3ce6308..6bce755b 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -37,8 +37,10 @@ Loading ------- By path (`spec_from_file_location`), the shape `test_check_memory_index.py` uses: the -filename is not importable by name (the dash), and the module stands alone - it imports -nothing from the package, so there is no tree to put on `sys.path`. +filename is not importable by name (the dash). The module puts **its own tree** on +`sys.path` before the one import it takes from the package - `emrg.tools.file_policy`, the +home of "are these two paths one file" (#2559) - so loading it by path needs nothing from +this harness, and the rule it applies is the tree's rather than a copy kept here. """ from __future__ import annotations @@ -186,6 +188,73 @@ def test_the_bookmark_does_not_count_as_memory_of_the_agent_that_holds_it(mod, t assert mod.main(["--agents", str(register), "--bookmark", str(book)]) == 0 +def test_the_bookmark_is_excluded_under_a_second_name_for_it(mod, tmp_path): + """The exclusion is by identity, so the name the clock is reached by does not matter. + + A hard link asks the question with case folding taken out: two names, one inode, and + `realpath` returns each name as written - so the string comparison this replaces + answered "not the bookmark" and the clock counted as the reading's own subject, which + is the state #2422 removed (issue #2559). No volume can help here, a hard link is one + file on every filesystem this repo runs on. + + The bookmark's mtime is newer than its own row, and the agent's memory file is not, so + "the bookmark was counted" is visible in the state itself and not only in a count. + """ + root = Path(_root(tmp_path, "clock-by-another-name")) + _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod)) + book = _bookmark(root / ".emrg/memory/bookmark.md", [("`clock-by-another-name`", BASE)]) + os.utime(book, (_stamp(mod, 60), _stamp(mod, 60))) + alias = tmp_path / "the-clock-by-another-name.md" + os.link(book, alias) + register = _agents( + tmp_path / "agents.yml", + [{"name": "clock-by-another-name", "path": str(root)}], + ) + + reading = mod.read(register, alias) + + assert reading["states"][0]["state"] == "unchanged", reading["states"][0] + + +def test_the_bookmark_is_excluded_through_a_case_variant_spelling(mod, tmp_path): + """The same question on the volume the defect was reported against. + + The volume is asked rather than assumed: where it keeps the two spellings apart they + are two files and the old answer was already right, so the arm is skipped **with its + reason** instead of passing without measuring (measured on this host: `samefile` on the + pair answers True, `realpath` on the pair answers two different strings). + """ + root = Path(_root(tmp_path, "clock-in-caps")) + _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod)) + book = _bookmark(root / ".emrg/memory/bookmark.md", [("`clock-in-caps`", BASE)]) + os.utime(book, (_stamp(mod, 60), _stamp(mod, 60))) + variant = book.with_name(book.name.upper()) + if not mod.same_file(str(variant), str(book)): + pytest.skip("this volume keeps BOOKMARK.md and bookmark.md apart as two files") + register = _agents(tmp_path / "agents.yml", [{"name": "clock-in-caps", "path": str(root)}]) + + reading = mod.read(register, variant) + + assert reading["states"][0]["state"] == "unchanged", reading["states"][0] + + +def test_an_exclusion_that_names_nothing_hides_nothing(mod, tmp_path): + """The other direction of an identity test: a path with no inode is not the bookmark. + + An exclusion that answered True for a path that is not there would remove whichever + memory file it was compared with, so this asserts the whole scan survives it. Every + file here is real, and the exclusion names one that is not. + """ + root = Path(_root(tmp_path, "absent-exclusion")) + _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod)) + _write_memory(root, ".emrg/memory/bookmark.md", _stamp(mod)) + absent = tmp_path / "no-such-file-here.md" + + found = {path.name for path in mod.memory_files(root, absent)} + + assert found == {"MEMORY.md", "bookmark.md"}, sorted(found) + + def test_a_memory_file_newer_than_the_row_still_reads_changed_beside_the_bookmark(mod, tmp_path): """The other direction, from the same tree: a real memory file is still reported.