Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions emrg/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## <valid type>` heading; this only decides where new rows file.
Expand Down
111 changes: 101 additions & 10 deletions scripts/check-memory-index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -384,16 +417,34 @@ 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)
row_lengths.append(len(line))
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
)


Expand Down Expand Up @@ -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)}"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand All @@ -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"
)
Expand Down
98 changes: 98 additions & 0 deletions tests/test_check_memory_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading