From 8098f3834b615ee32deb3047f873a70d489c71f1 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 09:38:12 +0800 Subject: [PATCH] emrg: a bookmark write is checked for its structure, not only its rows --- scripts/check-memory-rotation.py | 100 +++++++++++++++++++++++++-- tests/test_check_memory_rotation.py | 102 ++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 4 deletions(-) diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index 66d816a1..f4b7dbd3 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -228,6 +228,17 @@ def _value_of(cells: list[str]) -> str | None: return None +#: The heading that opens a bookmark's per-cycle narration section. +#: +#: The bookmark keeps **exactly one** such section, replaced in place each cycle. That is +#: the file's own rule ("Update this file in place every cycle - never append a second +#: entry") and it has a measured reason: 2026-10-21 this file had grown to 359 lines +#: carrying **eight** sections, one of them duplicated. No row is harmed by a second +#: section, which is why nothing noticed - every row still parses, so the coverage reading +#: answers "32 of 32" while the file it is embedded from grows every cycle. +CYCLE_SECTION = re.compile(r"^##\s+Cycle\b") + + def parse_bookmark(path: Path) -> list[dict]: """Every body row of the bookmark's table, in file order, as key/value pairs. @@ -286,11 +297,30 @@ def parse_bookmark(path: Path) -> list[dict]: hint = key_cell.group("hint") rows.append({"key": f"{key} ({hint})" if hint else key, "value": value}) if not saw_table: + sections = [line.strip() for line in lines if CYCLE_SECTION.match(line)] + if sections: + # The shape a section rewrite leaves when it runs past the table header + # instead of stopping at it. It is *this* file's own damage rather than an + # unfamiliar format, and the two need different remedies, so they get + # different sentences: measured 2026-10-22, a write that looked complete + # deleted every row, and the reader could then only answer `not measurable`. + named = ", ".join(sections[:3]) + (", and more" if len(sections) > 3 else "") + detail = ( + f"the file opens a cycle section ({named}) and carries no `| ... |` row " + f"anywhere - the shape a section rewrite leaves when it runs past the table " + f"header instead of stopping at it, which on 2026-10-22 deleted every row. " + f"Restore the table, and replace a section from its `##` heading to the next " + f"`##` heading or the table header, never to end of file" + ) + else: + detail = ( + "not one of them is a `| ... |` row - so there are no rows here to read: a " + "bookmark in another shape is not an empty bookmark, and reading it as one " + "would report every registered project as never visited" + ) raise Unmeasurable( f"the bookmark at {path} carries no markdown table - {len(lines)} line(s) read " - f"and not one of them is a `| ... |` row - so there are no rows here to read: a " - f"bookmark in another shape is not an empty bookmark, and reading it as one " - f"would report every registered project as never visited" + f"and {detail}" ) if keyless: raise Unmeasurable( @@ -309,6 +339,56 @@ def parse_bookmark(path: Path) -> list[dict]: return rows +def cycle_sections(path: Path) -> list[str]: + """The `## Cycle ...` headings in ``path``, in file order - empty when it cannot be read. + + An absent or unreadable bookmark answers `[]` here rather than raising: `parse_bookmark` + already names that absence with its own sentence, and a structure reading that threw a + second time would replace a precise complaint with a vaguer one. + + :param path: the bookmark to read. + :returns: the heading lines, stripped, in the order the file carries them. + """ + if not path.is_file(): + return [] + try: + text = path.read_text(encoding="utf-8") + except (OSError, ValueError): + return [] + return [line.strip() for line in text.splitlines() if CYCLE_SECTION.match(line)] + + +def structure_faults(headings: list[str]) -> list[str]: + """The faults in the bookmark's structure **above** its table, not in its rows. + + A separate list from the coverage faults because they are a different subject with a + different remedy: a coverage fault is repaired by writing a row, this one by writing a + *section boundary*. Mixing them was rejected for the reason the file itself states - + the reader's remedy sentence describes rows, and a structural fault given that remedy + sends the writer to the wrong place. + + Deliberately **not** a refusal: with a second section appended every row still parses + and the rotation reading is still exactly right, so refusing would throw away a valid + measurement to punish a file-shape fault. The reading survives, the exit code does not. + + A bookmark carrying **no** section is not a fault - a minimal or first-ever bookmark is + only a table, which is the shape the tests' fixtures use. + + :param headings: the section headings, from :func:`cycle_sections`. + :returns: one sentence per fault, empty when the structure is the file's own contract. + """ + if len(headings) < 2: + return [] + named = ", ".join(headings[:5]) + (f", and {len(headings) - 5} more" if len(headings) > 5 else "") + return [ + f"the bookmark carries {len(headings)} cycle sections ({named}): it keeps exactly one, " + f"replaced in place - a second section is the growth the file's own rule exists to " + f"avoid (measured 2026-10-21: 359 lines, eight sections, one duplicated), and this " + f"file is embedded in every cycle's prompt, so each appended section is paid for on " + f"every round that reads it" + ] + + def _segments(path: str) -> list[str]: """A path's segments, host and platform separators alike, empty ones dropped.""" return [s for s in re.split(r"[\\/]+", path) if s] @@ -519,6 +599,8 @@ def read(projects_path: Path, bookmark_path: Path) -> dict: states = [project_state(p, by_path.get(p.get("path") or p["name"]), bookmark_path) for p in projects] faults = [] + sections = cycle_sections(bookmark_path) + structure = structure_faults(sections) for state in states: if state["state"] == "no bookmark row": faults.append(f"{state['name']} ({state['path']}) has a line in the register and none in " @@ -544,6 +626,8 @@ def read(projects_path: Path, bookmark_path: Path) -> dict: "row_count": len(rows), "states": states, "faults": faults, + "cycle_sections": len(sections), + "structure": structure, } @@ -580,6 +664,14 @@ def render(reading: dict, show_all: bool) -> str: else: out.append(f"coverage: every project has a row and every row names a project " f"({reading['project_count']} of {reading['project_count']})") + if reading["structure"]: + out.append(f"structure faults: {len(reading['structure'])}") + out.extend(f" {fault}" for fault in reading["structure"]) + out.append("the rows above are still readable - this is the prose above them: keep one " + "cycle section and replace it in place, never append a second") + else: + out.append(f"structure: {reading['cycle_sections']} cycle section(s) above the table - " + f"the file keeps at most one, replaced in place") return "\n".join(out) @@ -606,7 +698,7 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(reading, indent=2, ensure_ascii=False)) else: print(render(reading, args.all)) - return 1 if reading["faults"] else 0 + return 1 if (reading["faults"] or reading["structure"]) else 0 if __name__ == "__main__": diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index a570e0fc..5bcdc09f 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -624,3 +624,105 @@ def test_json_is_one_document_that_names_its_subjects(mod, tmp_path, capsys): assert document["project_count"] == 1 assert document["states"][0]["state"] == "changed" assert document["projects_file"] == str(register) + + +# --------------------------------------------------------------------------- its structure + +#: The first words of a bookmark's per-cycle narration heading, as the file spells them. +#: The reader matches the heading and nothing else, so the fixture spells it, not a stand-in. +SECTION = "## Cycle cyc20261020-160624" + + +def _sectioned(path: Path, rows: list[tuple[str, str]], sections: int) -> Path: + """A bookmark carrying ``sections`` cycle headings above its table.""" + lines = ["# Bookmark", ""] + for n in range(sections): + lines += [f"{SECTION}-{n}", "", "one line of narration", ""] + lines += ["| project | newest read | files |", "| --- | --- | --- |"] + lines += [f"| {key} | {value} | 0 |" for key, value in rows] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def test_a_second_cycle_section_is_a_fault_the_rows_cannot_show(mod, tmp_path): + """The arm measured on 2026-10-23, before this reading existed. + + A second section appended to the real bookmark answered **rc=0, "32 of 32", entirely + green**: every row still parses, so the coverage reading - the one this tool was + written for - has nothing to say about growth above the table. The file's own prose + forbids it ("never append a second entry") and, measured 2026-10-21, eight of them had + accumulated over 359 lines. So the fault is asserted here **beside** an empty + `faults` list: the two readings are different subjects, and a fix for one must not be + read as a fix for the other. + """ + root = _root(tmp_path, "growing") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _projects(tmp_path / "projects.yml", [{"name": "growing", "path": root}]) + one = _sectioned(tmp_path / "one.md", [("`growing`", BASE)], 1) + two = _sectioned(tmp_path / "two.md", [("`growing`", BASE)], 2) + + single = mod.read(register, one) + assert single["structure"] == [], single["structure"] + assert single["cycle_sections"] == 1 + + doubled = mod.read(register, two) + assert doubled["faults"] == [], doubled["faults"] # every row still parses... + assert doubled["cycle_sections"] == 2 + assert len(doubled["structure"]) == 1, doubled["structure"] # ...and the file is wrong + assert "2 cycle sections" in doubled["structure"][0] + + assert "structure faults: 1" in mod.render(doubled, False) + assert mod.main(["--projects", str(register), "--bookmark", str(two)]) == 1 + assert mod.main(["--projects", str(register), "--bookmark", str(one)]) == 0 + + +def test_one_cycle_section_is_the_contract_and_none_is_legitimate(mod, tmp_path): + """The other two directions: the rule is *at most one*, not *exactly one*. + + A bookmark that is only a title and a table is what every fixture here writes and what + a first-ever bookmark looks like, so reading it as a fault would redden the whole file + and teach the writer to ignore the line. A `##` heading that is not a cycle section is + a different subject - the file's rule is about its cycle narration, not about prose. + """ + root = _root(tmp_path, "shapes") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _projects(tmp_path / "projects.yml", [{"name": "shapes", "path": root}]) + bare = _bookmark(tmp_path / "bare.md", [("`shapes`", BASE)]) + noted = tmp_path / "noted.md" + noted.write_text("# Bookmark\n\n## Notes\n\n- the qualifier matters\n\n" + "| project | newest read | files |\n| --- | --- | --- |\n" + f"| `shapes` | {BASE} | 0 |\n", encoding="utf-8") + + for path in (bare, noted): + reading = mod.read(register, path) + assert reading["structure"] == [], (path.name, reading["structure"]) + assert reading["cycle_sections"] == 0, path.name + assert "structure: 0 cycle section(s)" in mod.render(reading, False), path.name + assert mod.main(["--projects", str(register), "--bookmark", str(path)]) == 0 + + +def test_a_bookmark_truncated_at_its_section_is_diagnosed_as_truncation(mod, tmp_path): + """A rewrite that ran past the table header, measured 2026-10-22: every row gone. + + Before this, the reader answered "a bookmark in another shape" - the sentence for a + format it does not recognise - which sends the writer to look for a different file + format instead of at the rewrite that damaged this one. The two causes need different + remedies, so the section-carrying case gets its own sentence, and the table-less file + with no section keeps the original one. + """ + register = _projects(tmp_path / "projects.yml", [{"name": "cut", "path": str(tmp_path)}]) + cut = tmp_path / "cut.md" + cut.write_text(f"# Bookmark\n\n{SECTION}\n\nthe rewrite stopped here\n", encoding="utf-8") + with pytest.raises(mod.Unmeasurable) as excinfo: + mod.read(register, cut) + message = str(excinfo.value) + assert "carries no `| ... |` row" in message + assert "deleted every row" in message # names the damage, not the format + assert "Restore the table" in message # and what to do about it + assert "another shape" not in message + + shapeless = tmp_path / "shapeless.md" + shapeless.write_text("# Bookmark\n\nnothing tabular here\n", encoding="utf-8") + with pytest.raises(mod.Unmeasurable) as excinfo: + mod.read(register, shapeless) + assert "another shape" in str(excinfo.value)