From 3c8604d0462aa0ca8526c1f50317333de6465bed Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 21 Oct 2026 00:15:18 +0800 Subject: [PATCH] emrg: the rotation bookmark is the clock, not memory, so the scan does not count it --- scripts/check-memory-rotation.py | 55 +++++++++++++++++++++++------ tests/test_check_memory_rotation.py | 36 +++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index 2c1057a0..b6005ea1 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -48,6 +48,13 @@ `.emrg/sessions/*/memory/**/*.md` - the two places the rotation's rule names - and keeps the newest file strictly newer than that project's stamp. +**The bookmark is not memory content.** On this host it lives under the evolution +project's own memory root, so counting it would make that project's newest file the clock +itself: the row could never be current, and every run would report the project 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. + Each project lands in exactly one state: changed a memory file is newer than its stamp; the newest is printed @@ -367,24 +374,41 @@ def memory_roots(root: Path) -> list[Path]: return found -def memory_files(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 + + +def memory_files(root: Path, exclude: Path | None = None) -> list[Path]: """Every `.md` under the project's memory roots - the files the rotation's rule binds. A directory is not a reading. Measured in the cycle that filed issue #2408: three projects on this host carry an **empty** `.emrg/sessions/*/memory/` directory, and a reading that counted the directory as "this project keeps memory" reported all three as contradicting their `no memory root` note. Nothing was there to read. + + `exclude` is the bookmark itself, which is the clock and not memory content: on this + host it sits under the evolution project's own memory root, so counting it made that + project's row permanently stale and reported it changed on the strength of the file that + asks the question (issue #2422). """ found = [] for memory_root in memory_roots(root): for dirpath, _dirnames, filenames in os.walk(memory_root): for name in filenames: - if name.endswith(".md"): - found.append(Path(dirpath) / name) + if not name.endswith(".md"): + continue + path = Path(dirpath) / name + if exclude is not None and _same_file(path, exclude): + continue + found.append(path) return found -def newest_memory(root: Path, since: int) -> tuple[Path, float] | None: +def newest_memory(root: Path, since: int, exclude: Path | None = None) -> tuple[Path, float] | None: """The newest `.md` under the project's memory roots that is strictly newer than `since`. `since` is a **whole second**, and an mtime is truncated to one before it is compared. @@ -394,9 +418,12 @@ def newest_memory(root: Path, since: int) -> tuple[Path, float] | None: against stamps that already named their newest file - `.real` at `10:19:27.829` against a `10:19:27` row. Truncating both sides is what makes the reading idempotent, and it loses nothing: a file that really moved lands in a later second. + + `exclude` is passed through to `memory_files`: the bookmark is the clock, and a scan + that counted it would report its own project changed every time it was written. """ best: tuple[Path, float] | None = None - for path in memory_files(root): + for path in memory_files(root, exclude): try: mtime = path.stat().st_mtime except OSError: @@ -408,8 +435,12 @@ def newest_memory(root: Path, since: int) -> tuple[Path, float] | None: return best -def project_state(project: dict, row: dict | None) -> dict: - """One project's line: its state, and the reading behind it.""" +def project_state(project: dict, row: dict | None, bookmark_path: Path | None = None) -> dict: + """One project's line: its state, and the reading behind it. + + `bookmark_path` is excluded from both memory scans below, because the bookmark is + this reading's clock and not one of its subjects (issue #2422). + """ name = project["name"] raw_path = project.get("path") or "" state = {"name": name, "path": raw_path, "key": row["key"] if row else None} @@ -422,7 +453,8 @@ def project_state(project: dict, row: dict | None) -> dict: # A note is a reading taken on a day, so it is re-measured rather than repeated: a # project that has since written a memory root is one the note would hide for ever, # which is the same class of stale-past-state defect as the truncated stamp above. - state.update(state=NO_MEMORY_ROOT if not memory_files(root) else "row says no memory root", + state.update(state=NO_MEMORY_ROOT if not memory_files(root, bookmark_path) + else "row says no memory root", bookmark=None, newest=None) return state if value.startswith(PATH_MISSING_PREFIX): @@ -440,10 +472,10 @@ def project_state(project: dict, row: dict | None) -> dict: if not root.is_dir(): state.update(state="path missing", newest=None) return state - if not memory_files(root): + if not memory_files(root, bookmark_path): state.update(state=NO_MEMORY_ROOT, newest=None) return state - newest = newest_memory(root, int(stamp.timestamp())) + newest = newest_memory(root, int(stamp.timestamp()), bookmark_path) if newest is None: state.update(state="unchanged", newest=None) return state @@ -457,7 +489,8 @@ def read(projects_path: Path, bookmark_path: Path) -> dict: projects = load_projects(projects_path) rows = parse_bookmark(bookmark_path) by_path, orphans, ambiguous = resolve_rows(rows, projects) - states = [project_state(p, by_path.get(p.get("path") or p["name"])) for p in projects] + states = [project_state(p, by_path.get(p.get("path") or p["name"]), bookmark_path) + for p in projects] faults = [] for state in states: if state["state"] == "no bookmark row": diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index 669f5d3e..c429233b 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -162,6 +162,42 @@ def test_the_newest_of_several_is_the_one_named(mod, tmp_path): assert reading["states"][0]["newest"]["path"] == str(newest) +def test_the_bookmark_does_not_count_as_memory_of_the_project_that_holds_it(mod, tmp_path): + """The clock is not a subject: the bookmark sits under the memory root it scans. + + On this host the bookmark lives under the evolution project's own memory root, so a scan + that counted it made that project's row permanently stale — measured in the cycle that + filed issue #2422: a row written at `22:01:14` read as changed at `00:09:25`, naming the + bookmark itself. Here the project's memory file is older than its row and the bookmark + newer, so the bookmark is the only file that could make the project look changed. + """ + root = Path(_root(tmp_path, "holder")) + _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _projects(tmp_path / "projects.yml", [{"name": "holder", "path": str(root)}]) + book = _bookmark(root / ".emrg/memory/bookmark.md", [("`holder`", BASE)]) + os.utime(book, (_stamp(mod, 60), _stamp(mod, 60))) + reading = mod.read(register, book) + assert reading["states"][0]["state"] == "unchanged", reading["states"][0] + assert mod.main(["--projects", str(register), "--bookmark", str(book)]) == 0 + + +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. + + Only the two files' mtimes differ from the test above, so an exclusion that swallowed + the whole scan — or one that hid the file the reading exists to find — fails here. + """ + root = Path(_root(tmp_path, "holder-with-news")) + newer = _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod, 60)) + register = _projects(tmp_path / "projects.yml", + [{"name": "holder-with-news", "path": str(root)}]) + book = _bookmark(root / ".emrg/memory/bookmark.md", [("`holder-with-news`", BASE)]) + os.utime(book, (_stamp(mod, 30), _stamp(mod, 30))) + reading = mod.read(register, book) + assert reading["states"][0]["state"] == "changed", reading["states"][0] + assert reading["states"][0]["newest"]["path"] == str(newer) + + def test_a_project_with_no_memory_root_and_one_whose_path_is_gone(mod, tmp_path): """Two states that are not a time: neither can be ordered against an mtime.""" bare = _root(tmp_path, "bare")