Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions scripts/check-memory-rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]:
Expand Down
73 changes: 71 additions & 2 deletions tests/test_check_memory_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Loading