From 0819646b162c6224206499558759faf9e4748ad6 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 27 Oct 2026 01:23:33 +0800 Subject: [PATCH] emrg: a table row's cells are counted against its header --- emrg/memory.py | 47 +++++++++++++ scripts/check-memory-index.py | 111 ++++++++++++++++++++++++++++--- tests/test_check_memory_index.py | 98 +++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 10 deletions(-) diff --git a/emrg/memory.py b/emrg/memory.py index 273ab5fa..bb65847d 100644 --- a/emrg/memory.py +++ b/emrg/memory.py @@ -245,6 +245,53 @@ def is_index_row(line: str) -> bool: return bool(_INDEX_ROW_TABLE.match(line)) and not _INDEX_ROW_TABLE_DELIMITER.match(line) +def index_row_cells(line: str) -> tuple[str, ...] | None: + """The cells of a table-shaped row, or ``None`` when `line` is not one. + + ``None`` and an empty tuple are different answers: ``None`` is "this line is not a + table row" (a ``- `` list row, prose, the delimiter row), while a table row always + carries at least one cell. + + The split is written out rather than ``str.split("|")`` because a pipe may be + **escaped** — ``\\|`` is a literal pipe inside a cell, Markdown's own escape — and + counting it as a separator reads one cell as two. An inline code span does **not** + protect a pipe: GitHub's table syntax splits the row before it renders any span, so a + cell that quotes ``||`` inside backticks still breaks the table. Measured 2026-10-27 + on this host: ``.emrg/memory/MEMORY.md``'s ``sandbox-shell`` row quotes ``||`` inside + a code span and reads as **7 cells against its 5-cell header** — the row this function + exists to make countable, and the pair `scripts/check-memory-index.py` reports. + + Both outer pipes are optional, as they are to GitHub: ``| a | b`` and ``| a | b |`` + are the same two cells. The indent is stripped by the same expression `is_index_row` + strips, so the two cannot disagree about which lines they are talking about. + """ + line = _INDEX_ROW_INDENT.sub("", line, count=1).rstrip() + if not _INDEX_ROW_TABLE.match(line) or _INDEX_ROW_TABLE_DELIMITER.match(line): + return None + cells: list[str] = [] + current: list[str] = [] + index = 0 + while index < len(line): + char = line[index] + if char == "\\" and index + 1 < len(line): + current.append(line[index:index + 2]) + index += 2 + continue + if char == "|": + cells.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + cells.append("".join(current)) + if cells and not cells[0].strip(): + cells = cells[1:] + if cells and not cells[-1].strip(): + cells = cells[:-1] + return tuple(cells) + + # Order of the `## type` sections when the index has to be rendered from # entries alone (a rebuild, or entries the document never had). The parser # accepts any `## ` heading; this only decides where new rows file. diff --git a/scripts/check-memory-index.py b/scripts/check-memory-index.py index 2b93da2f..099b9f07 100644 --- a/scripts/check-memory-index.py +++ b/scripts/check-memory-index.py @@ -63,7 +63,7 @@ drew no note at all while four of that index's rows were past the bound (measured 2026-10-01 on this host). -Four readings, each from its own source +Five readings, each from its own source --------------------------------------- The line **cap** is `MEMORY_INDEX_ROW_CAP` and the row **bound** is `INDEX_TITLE_MAX_CHARS`, both imported here rather than spelled again: they are @@ -99,6 +99,21 @@ shapes above are what closes it, and the budget reading below is what covers the file whatever shape its rows are in. +The **cell count** is the fifth, and its source is the table syntax rather than this +rule's text. A row is a row, but a row whose cells do not match its header's is not the +row a reader sees: GitHub splits a table line on every unescaped pipe *before* it renders +anything, so a cell that quotes `||` inside a code span ends that cell early and pushes +the rest of the line into cells the header never declared. Measured 2026-10-27 on this +host: `.emrg/memory/MEMORY.md`'s `sandbox-shell` row carries 8 bare pipes against a +5-cell header - **7 cells against 5** - and this tool printed `rows 10, longest 511 +chars, over 512: 0` and `OK`, because neither a length nor a line count can see a cell +boundary. The cells are split by `emrg.memory.index_row_cells`, in the module that owns +the row shape, so "which lines are rows" and "how many cells does this row have" are one +definition rather than two that can drift. **Both directions fault**: too many cells (a +pipe that needed escaping) and too few (a cell that was dropped). An escaped `\\|` is +content and does not split, which is what lets the finding name the repair instead of +only the symptom. + Scope, named rather than implied -------------------------------- * This tool **measures; it never repairs.** Compaction needs a reader's judgement @@ -162,8 +177,9 @@ rows carry none is a clean verdict about the three numbers and says so, rather than a claim about links nothing looked at. ``1`` at least one index is over a number the rule names (the line cap, the embed - budget, or a row past the bound), or carries a row link that resolves to no - file; each finding is printed with the line it is on. + budget, or a row past the bound), or carries a row whose cell count does not + match its header's, or a row link that resolves to no file; each finding is + printed with the line it is on. ``2`` nothing could be measured: no index under the tree, or a named index could not be read. An unreadable subject makes the reading incomplete, which is reported as such even when the other indexes were read - a partial reading @@ -243,6 +259,10 @@ def _resolve_root() -> Path: # reading this tool prints and the instruction an agent receives count the same # lines. Spelled separately once - both as `- ` - a table index fell between them. is_index_row = _memory_module.is_index_row + # The cells are the same module's answer for the same reason: a row's cell boundaries + # are part of what "a table row" means, and a second splitter here would be a second + # spelling of the escape rule, free to disagree with the shape definition beside it. + index_row_cells = _memory_module.index_row_cells THRESHOLD_SOURCE = str(Path(_memory_module.__file__).resolve()) if not Path(THRESHOLD_SOURCE).is_relative_to(REPO_ROOT): THRESHOLD_ERROR = ( @@ -258,6 +278,10 @@ def is_index_row(line: str) -> bool: # pragma: no cover - main() returns first """Unreachable: `main()` reports the import failure before any index is read.""" raise RuntimeError(THRESHOLD_ERROR) + def index_row_cells(line: str): # pragma: no cover - main() returns first + """Unreachable: `main()` reports the import failure before any index is read.""" + raise RuntimeError(THRESHOLD_ERROR) + THRESHOLD_ERROR = f"{type(exc).__name__}: {exc}" # The row predicate itself is `emrg.memory.is_index_row`, imported above with the rule's @@ -313,6 +337,13 @@ class Reading(NamedTuple): :param row_targets: every file link a row carries, as `(file line number, target)`, in file order - the resolution reading's subject. Excludes what `NON_FILE_TARGET` names. + :param cell_header: the file's table header, as `(file line number, cell count)`, + or `None` when the file carries no table row at all - which is the cell + reading's statement that it had no subject here. + :param cell_faults: the table rows whose cell count differs from the header's, as + `(file line number, that row's cells, the header's cells)`, in file order. + Both directions are faults: an unescaped pipe adds a cell, and a dropped one + removes a cell, and either way the row a reader sees is not the row written. """ path: Path @@ -321,6 +352,8 @@ class Reading(NamedTuple): row_lines: tuple[int, ...] row_lengths: tuple[int, ...] row_targets: tuple[tuple[int, str], ...] = () + cell_header: Optional[tuple[int, int]] = None + cell_faults: tuple[tuple[int, int, int], ...] = () @property def rows(self) -> int: @@ -384,6 +417,7 @@ def measure(path: Path) -> Reading: row_lines: list[int] = [] row_lengths: list[int] = [] row_targets: list[tuple[int, str]] = [] + table_rows: list[tuple[int, int]] = [] for number, line in enumerate(lines, 1): if is_index_row(line): row_lines.append(number) @@ -391,9 +425,26 @@ def measure(path: Path) -> Reading: for target in LINK.findall(CODE_SPAN.sub(" ", line)): if not NON_FILE_TARGET.match(target): row_targets.append((number, target)) + cells = index_row_cells(line) + if cells is not None: + table_rows.append((number, len(cells))) + # The first table row is the header, and it is the one that states the file's width. + # One header per index is the shape this reading assumes, because an index is one + # table: a file carrying two tables of different widths is reported, and that + # report is a reader's question, not a fault this tool invents the answer to. + cell_header = table_rows[0] if table_rows else None + cell_faults = ( + tuple( + (number, cells, cell_header[1]) + for number, cells in table_rows[1:] + if cells != cell_header[1] + ) + if cell_header is not None + else () + ) return Reading( path, len(lines), len(text), tuple(row_lines), tuple(row_lengths), - tuple(row_targets) + tuple(row_targets), cell_header, cell_faults ) @@ -448,6 +499,26 @@ def _report(reading: Reading) -> list[str]: f" row at line {number} is {length} chars, " f"over the {INDEX_TITLE_MAX_CHARS} bound" ) + if reading.cell_header is None: + out.append( + " no table row, so the cell reading had no subject here - a `- ` list " + "index carries no header to count a row against" + ) + else: + header_line, header_cells = reading.cell_header + out.append( + f" table cells: header at line {header_line} has {header_cells}, " + f"rows mismatched: {len(reading.cell_faults)}" + ) + for number, cells, expected in reading.cell_faults: + remedy = ( + "a bare pipe splits a cell, and a literal one is written \\|" + if cells > expected + else "a cell is missing, and a table row carries every column its header does" + ) + out.append( + f" row at line {number}: cells {cells}, header {expected} - {remedy}" + ) unresolved = reading.unresolved() out.append( f" row links {len(reading.row_targets)}, unresolved: {len(unresolved)}" @@ -494,12 +565,14 @@ def main(argv: Optional[list[str]] = None) -> int: else "the rule's numbers are unreadable from this interpreter, so " "every run reports that instead of a count" ) - + ", and every row link resolving to a file beside it." + + ", every table row's cells matching its header, and every row link " + "resolving to a file beside it." ), epilog=( - f"Exit 0: every index is within every number and every row link " - f"resolves. Exit 1: at least one is over one of them, or points at a " - f"file that is not there. Exit 2: nothing could be measured (no index " + f"Exit 0: every index is within every number, every table row's cells match " + f"its header, and every row link resolves. Exit 1: at least one is over one " + f"of them, carries a row whose cells do not match its header's, or points at " + f"a file that is not there. Exit 2: nothing could be measured (no index " f"under the tree, or a named index could not be read). Example: " f"{RUNNER} scripts/check-memory-index.py " "~/some/tree/.emrg/memory/MEMORY.md" @@ -575,6 +648,7 @@ def main(argv: Optional[list[str]] = None) -> int: if reading.lines > MEMORY_INDEX_ROW_CAP or reading.chars > INDEX_SIZE_WARN or reading.over(INDEX_TITLE_MAX_CHARS) + or reading.cell_faults or reading.unresolved() ] if not findings: @@ -588,8 +662,24 @@ def main(argv: Optional[list[str]] = None) -> int: summary = ( f"OK: {len(readings)} index(es) within the three numbers the rule names " f"({MEMORY_INDEX_ROW_CAP} lines, {INDEX_SIZE_WARN} chars, " - f"{INDEX_TITLE_MAX_CHARS} chars per row); " + f"{INDEX_TITLE_MAX_CHARS} chars per row)" ) + # The cell reading carries its coverage for the reason the link clause below + # does: a summary is the line a reader takes, so it may not leave a reading it + # did not take looking like one it took. Measured 2026-10-27, the index this + # host embeds was a table and the summary said nothing about cells at all. + tables = [reading for reading in readings if reading.cell_header is not None] + if tables: + summary += ( + f"; every table row's cell count matches its header " + f"({len(tables)} of {len(readings)} index(es) carry a table)" + ) + else: + summary += ( + "; the cell reading had no subject - no index read carries a table row, " + "so this is not a statement that any row's cells match its header's" + ) + summary += "; " if not links_read: summary += ( "row links read: 0 - not one index read carries a ](target) link, so the " @@ -612,7 +702,8 @@ def main(argv: Optional[list[str]] = None) -> int: print( f"{len(findings)} of {len(readings)} index(es) over a number the rule names, " - "or carrying a row link that resolves nowhere; the rule is compacted in " + "carrying a row whose cells do not match its header's, or carrying a row link " + "that resolves nowhere; the rule is compacted in " "place, by the agent itself, and a stale row is rewritten or dropped when " "the detail file is gone" ) diff --git a/tests/test_check_memory_index.py b/tests/test_check_memory_index.py index 492c01b7..7e78c4a6 100644 --- a/tests/test_check_memory_index.py +++ b/tests/test_check_memory_index.py @@ -778,3 +778,101 @@ def test_an_index_over_the_cap_is_not_rewritten(mod, tmp_path, capsys) -> None: assert mod.main([str(path)]) == 1 assert path.read_bytes() == before capsys.readouterr() + + +# ── a table row's cells match its header's ─────────────────────────────────── + + +def _cell_index(tmp_path: Path, name: str, body: list[str]) -> Path: + """A two-column table index whose rows are exactly `body`. + + Two columns on purpose: the defect this reading exists for is a row carrying more + cells than its header declares, and the narrowest header that can show one wrong cell + is two. The rows carry no `](target)` link, so the resolution reading has no subject + here and cannot be the reason a fixture exits 1. + """ + return _index( + tmp_path, name, ["# Memory Index", "", "| id | note |", "| --- | --- |"] + body + ) + + +def test_a_pipe_in_a_code_span_still_splits_the_row(mod, tmp_path, capsys) -> None: + """Backticks protect nothing: the table is split before any span is rendered. + + GitHub splits a table line on every unescaped pipe *before* it renders an inline + span, so a cell quoting `||` ends early and the rest of the line spills into cells + the header never declared. Measured 2026-10-27 on this host: `.emrg/memory/MEMORY.md` + carries 8 bare pipes in its `sandbox-shell` row against a 5-cell header, and this + tool printed `rows 10, longest 511 chars, over 512: 0` and `OK` — neither a length + nor a line count can see a cell boundary, which is why the reading had to be added. + """ + path = _cell_index(tmp_path, "bare.md", ["| a1 | `||` is not a pipe in cmd |"]) + + assert mod.main([str(path)]) == 1 + out = capsys.readouterr().out + assert "row at line 5: cells 4, header 2" in out, out + assert "a bare pipe splits a cell" in out, out + assert "OK:" not in out + + +def test_an_escaped_pipe_is_content_and_does_not_split(mod, tmp_path, capsys) -> None: + """The counter-arm, differing by two backslashes: the repair the finding prescribes. + + Without this direction the reading could not distinguish "a row with a literal pipe + in it", which is correct Markdown, from "a row that breaks the table" — and the + remedy it prints would be advice it refuses to follow itself. + """ + path = _cell_index(tmp_path, "escaped.md", ["| a1 | `\\|\\|` is not a pipe in cmd |"]) + + assert mod.main([str(path)]) == 0 + out = capsys.readouterr().out + assert "rows mismatched: 0" in out, out + assert "OK:" in out + + +def test_a_row_with_a_cell_too_few_is_a_fault_too(mod, tmp_path, capsys) -> None: + """Both directions, so the reading is not "a row may not be wider than its header". + + A dropped cell is the same reader-visible break the other way, and a reading that + only catches extra pipes would call it clean. + """ + path = _cell_index(tmp_path, "short.md", ["| a1 |", "| a2 | ok |"]) + + assert mod.main([str(path)]) == 1 + out = capsys.readouterr().out + assert "row at line 5: cells 1, header 2" in out, out + assert "a cell is missing" in out, out + assert "row at line 6" not in out, "the well-formed row beside it must not be named" + assert "OK:" not in out + + +def test_a_line_with_no_pipe_is_not_a_cell_subject(mod, tmp_path, capsys) -> None: + """Prose beside a table is not a row, so it is not measured against the header. + + The control for the three above: a fixture that differs by carrying a line with no + pipe in it, where the reading must stay silent rather than count a paragraph as a + one-cell row. + """ + path = _cell_index( + tmp_path, "prose.md", ["| a1 | ok |", "", "A paragraph with no pipe in it."] + ) + + assert mod.main([str(path)]) == 0 + out = capsys.readouterr().out + assert "rows mismatched: 0" in out, out + assert "OK:" in out + + +def test_a_list_index_says_the_cell_reading_had_no_subject(mod, tmp_path, capsys) -> None: + """No header, no cell reading — and the summary says so instead of implying one. + + The same discipline the link clause follows: a summary is the line a reader takes, so + a reading with no subject must not be printed as a reading that passed. + """ + _detail(tmp_path, "f.md") + path = _index(tmp_path, "list.md", ["# Memory Index", "", "- [a](f.md) one"]) + + assert mod.main([str(path)]) == 0 + out = capsys.readouterr().out + assert "no table row, so the cell reading had no subject here" in out, out + assert "the cell reading had no subject" in out.split("OK:")[-1], out