diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index c995962e..66d816a1 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 @@ -382,24 +389,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. @@ -409,9 +433,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: @@ -423,8 +450,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} @@ -437,7 +468,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): @@ -467,10 +499,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 @@ -484,7 +516,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 1fd34bb0..a570e0fc 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_row_stamped_later_than_the_clock_is_unmeasurable(mod, tmp_path, monkeypatch): """A row stamped after the reading refuses, because it would keep every file for ever.