diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index 2c1057a0..c995962e 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -71,7 +71,8 @@ a row naming no project, a row that matches more than one, or a row whose state field is none of the three forms above 2 nothing was measured: no `projects.yml`, no bookmark, or a stamp that cannot be - ordered against an mtime - which is never a pass + ordered against an mtime - a value that does not parse, or one later than the + instant of the reading - which is never a pass What it does not do ------------------- @@ -99,6 +100,20 @@ #: value that does not fit is unmeasurable, and this is the string that decides. STAMP_FORMAT = "%Y-%m-%d %H:%M:%S" + +def now() -> datetime: + """The instant this reading is taken, as one function so a test can pin it. + + It exists to answer one question a stamp cannot answer about itself: **is this row's + instant in the future?** `newest_memory` keeps the files strictly newer than a row's + stamp, so a stamp later than the clock keeps *nothing*, every project reads + `unchanged`, and the report prints "every project is at or before its stamp" with exit + 0 - a clean verdict that never changes, because the row stays in the future until + somebody edits it (issue #2421). A function rather than a `datetime.now()` call at the + one site that needs it, so the boundary can be measured instead of raced. + """ + return datetime.now() + #: The two states a row may carry instead of a stamp. Both are the reader's own note #: about the project rather than a time, so neither can be ordered against an mtime. NO_MEMORY_ROOT = "no memory root" @@ -436,6 +451,18 @@ def project_state(project: dict, row: dict | None) -> dict: f"the row for {name} carries {value!r}, which is not a {STAMP_FORMAT} stamp " f"and cannot be ordered against a file's mtime ({exc})" ) from exc + taken = now() + if stamp > taken: + # Ordered, but against nothing real: an mtime cannot be later than what a cycle has + # read, so every file on disk is older than this row's instant and the project would + # read `unchanged` for as long as the row stands (issue #2421). + raise Unmeasurable( + f"the row for {name} carries the stamp {value}, later than " + f"{taken.strftime(STAMP_FORMAT)} - the instant this reading was taken - so it " + f"records a memory modification that has not happened yet: every file on disk is " + f"older than it, and the project would read `unchanged` for as long as the row " + f"stands" + ) state["bookmark"] = stamp.strftime(STAMP_FORMAT) if not root.is_dir(): state.update(state="path missing", newest=None) diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index 669f5d3e..1fd34bb0 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -162,6 +162,56 @@ def test_the_newest_of_several_is_the_one_named(mod, tmp_path): assert reading["states"][0]["newest"]["path"] == str(newest) +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. + + `newest_memory` keeps the files strictly newer than the row's instant, so a row stamped + later than the clock keeps *nothing*: its project reads `unchanged`, and a bookmark with + a row for every project prints "every project is at or before its stamp" and exits 0 — + the silently-forever failure the module docstring already catalogues for the ISO `T` + form and for the sub-second comparison, with a stamp that parses. Measured in the cycle + that filed issue #2421: rows stamped `2099-01-01 00:00:00` answered `changed since their + bookmark: 0` with exit 0, while the same bookmark stamped in the past named both changed + files. The refusal is asserted through `main` as well, because it is the tool's own + reading and not only a function's return. + """ + from datetime import datetime, timedelta + + root = _root(tmp_path, "ahead") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + taken = datetime.strptime(BASE, mod.STAMP_FORMAT) + monkeypatch.setattr(mod, "now", lambda: taken) + ahead = (taken + timedelta(seconds=1)).strftime(mod.STAMP_FORMAT) + register = _projects(tmp_path / "projects.yml", [{"name": "ahead", "path": root}]) + book = _bookmark(tmp_path / "ahead.md", [("`ahead`", ahead)]) + with pytest.raises(mod.Unmeasurable) as excinfo: + mod.read(register, book) + message = str(excinfo.value) + assert ahead in message, message + assert taken.strftime(mod.STAMP_FORMAT) in message, message + assert "ahead" in message, message + assert mod.main(["--projects", str(register), "--bookmark", str(book)]) == 2 + + +def test_a_row_stamped_at_the_clock_is_read_normally(mod, tmp_path, monkeypatch): + """The other half of the same second: one second earlier is a reading, not a refusal. + + These two rows differ by one second and by nothing else, so a guard that refused both + would look exactly like one that refused neither. + """ + from datetime import datetime + + root = _root(tmp_path, "at-the-clock") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + taken = datetime.strptime(BASE, mod.STAMP_FORMAT) + monkeypatch.setattr(mod, "now", lambda: taken) + register = _projects(tmp_path / "projects.yml", [{"name": "at-the-clock", "path": root}]) + book = _bookmark(tmp_path / "at.md", [("`at-the-clock`", BASE)]) + reading = mod.read(register, book) + assert reading["states"][0]["state"] == "unchanged" + assert mod.main(["--projects", str(register), "--bookmark", str(book)]) == 0 + + 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")