diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 6a2e2668..08660975 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -680,6 +680,7 @@ unambiguously at the first space after it; payload sub-fields are separated by |---|---| | issue | `CLAIMED`, `RELEASED`, `TAKEOVER ← `, `CLOSED → owner/repo#pr` | | PR | `OPENED ← owner/repo#i [owner/repo#j …]`, `LANDING`, `LANDED → `, `WITHDRAWN`, `CLOSED` | +| issue or PR | `TAKEOVER ← lane:` | | lane | `STARTED`, `PAUSED`, `RESUMED`, `ENDED`, `RETIRED` | | narrative | `NOTED`, `RULED [→ ]` (Amendment 13(b)) | | race | `CLAIM-LOST → lane:` | diff --git a/lane-end b/lane-end index cde8c280..f6748d50 100755 --- a/lane-end +++ b/lane-end @@ -27,8 +27,11 @@ # # trees, branches, scratch and caches # # stay where they are, and the row and # # the ENDED line say so (#163) -# lane-end --retire # retire a live FORK (pid or uuid) or a -# # DUPLICATE (pid); ends nothing +# lane-end --retire # retire a live FORK: writes nothing and +# # kills nothing +# lane-end --retire # a DUPLICATE of the lane's id: TERMs a +# # background holder's pair, only reports +# # any other; the row is not written # lane-end --retire-dormant [--reason ""] [--yes] # # the DORMANT rows of one repository, # # dry run by default; --yes is ONE @@ -1720,6 +1723,18 @@ scrub_cell() { # the second exactly an instant, and a third part after it. That is # `mig_cell_is_phrase`'s rule in `lanes-edit.sh`; the states are the # amendment's own list, and this is the second place that list is written. +# THE LINE KEEPS DECISION O1's GRAMMAR TOO (opensoft/openRepoTools#90, part +# (a); Copilot's last review of #82). `set-row-state` refuses a line of more +# than 240 characters or one carrying `|` or CR/LF, so a cell with any of them +# was not written by it, and narrowing there dropped its prose from the scan +# all the same: an open LANDING in that prose closed the lane. That cell is +# scanned whole, like a diary. The cap is in CHARACTERS and `${#x}` is BYTES in +# a C locale, where a line of `—` and `→` inside it would read as past it and +# a lane merely waiting for a LANDING would be held by it: past 240, a line +# that is valid UTF-8 is recounted with its continuation bytes taken out, the +# same number in every locale. Text that is not valid UTF-8 has continuation +# bytes that follow nothing and are no characters, so it keeps the first count, +# which can only over-count, and an over-count reads more of the cell. cell_for_scan() { # — the part of it that says where the lane is cfs="$1" case "$cfs" in @@ -1737,6 +1752,24 @@ cell_for_scan() { # — the part of it that says where the lane i case "$cfs_three" in '' | *' · '*) printf '%s' "$cfs"; return 0 ;; esac + # O1's OWN RULES FOR THE LINE, which `mig_cell_is_phrase` does not test + # and `set-row-state` does (#90): no `|`, no CR/LF, at most 240 characters. + case "$cfs_three" in + *'|'* | *$'\n'* | *$'\r'*) printf '%s' "$cfs"; return 0 ;; + esac + # `iconv -c` drops what is not UTF-8, and its exit status is not to be + # trusted (musl's is 0 either way), so the text is UTF-8 when it comes + # back unchanged; with no `iconv` it never does. A count that cannot be + # made is a long line, and BSD `wc` pads, hence the `tr`. + cfs_len="${#cfs_three}" + if [ "$cfs_len" -gt 240 ]; then + cfs_utf8="$(printf '%s' "$cfs_three" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)" || : + if [ "$cfs_utf8" = "$cfs_three" ]; then + cfs_len="$(printf '%s' "$cfs_three" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d ' ')" || + cfs_len=241 + fi + fi + [ "$cfs_len" -le 240 ] || { printf '%s' "$cfs"; return 0; } cfs_ok=0 case "$cfs_one" in LIVE|PAUSED|LANDED|ENDED|RETIRED|'HANDED OFF'|MIGRATED) cfs_ok=1 ;; diff --git a/tests/test_precision_90a_96.py b/tests/test_precision_90a_96.py new file mode 100644 index 00000000..32258b76 --- /dev/null +++ b/tests/test_precision_90a_96.py @@ -0,0 +1,286 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`lane-end`'s two precision defects: its synopsis says a DUPLICATE HOLDER +retire "ends nothing" (opensoft/openRepoTools#96), and its narrowed state-cell +scan skips ratified decision O1's cap and its `|` and CR/LF rules, so a cell +that is not the phrase can hide an open LANDING (#90, part (a)). + +EVERY RUN HERE IS A REAL `lane-end` IN A SANDBOX: `test_lane_start_claude_current`'s +workstation (a bare origin, a workspace clone whose register is a header, +`workspace.yaml` pointing at it) with the lane's row written into it and +pushed, so the published row `register-row` reads is the same row. The lane +has NO object log, which is the one path that still decides a merge hold from +prose, and the close-out gate's `lane-worktrees` is a stub that finds nothing, +so a cell that wrongly reads clear is a lane that ENDS rather than one that +trips over the gate. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from test_lane_start_claude_current import Sandbox, LANE, _write, pytestmark +from conftest import REPO + +UTC = "2026-10-11T10:00:00Z" +UUID = "aaaa0090-9090-4000-8000-aaaa00909090" + + +# ------------------------------------------------------------------- #96 + +def _synopsis(help_text: str) -> list[str]: + """The USAGE block of `--help`, one string per synopsis entry: a line that + opens ` lane-end` and the `#` continuation lines under it.""" + block = help_text.split("\nUSAGE\n", 1)[1].split("\n\n", 1)[0] + entries: list[str] = [] + for line in block.splitlines(): + if line.startswith(" lane-end"): + entries.append(line) + else: + entries[-1] += " " + line + return [" ".join(entry.split()) for entry in entries] + + +@pytest.fixture +def synopsis(tmp_path: Path) -> list[str]: + result = subprocess.run(["bash", str(REPO / "lane-end"), "--help"], + env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin"}, + capture_output=True, text=True, timeout=30, check=False) + assert result.returncode == 0, result.stderr + return _synopsis(result.stdout) + + +def test_the_synopsis_says_what_a_duplicate_holder_retire_does(synopsis): + """`--retire ` on a DUPLICATE HOLDER TERMs the bg-pty-host pair, and + the one-line summary said `ends nothing`: true of a FORK, the opposite of + the act on a duplicate holder (#96).""" + duplicate = [e for e in synopsis if "--retire <" in e and "DUPLICATE" in e] + assert len(duplicate) == 1, synopsis + assert "TERM" in duplicate[0], duplicate[0] + assert "ends nothing" not in duplicate[0], duplicate[0] + + +def test_the_synopsis_keeps_the_fork_and_the_duplicate_on_their_own_lines(synopsis): + """The split is the fix: the FORK entry still says it writes and kills + nothing, and names no DUPLICATE, whose words differ.""" + fork = [e for e in synopsis if "--retire <" in e and "FORK" in e] + assert len(fork) == 1, synopsis + assert "kills nothing" in fork[0], fork[0] + assert "DUPLICATE" not in fork[0], fork[0] + + +# ------------------------------------------------------------------ #90 (a) + +def _line(length: int, tail: str) -> str: + """A phrase line of exactly `length` characters ending in `tail`.""" + filler = length - len(tail) + assert filler > 0 + return "x" * filler + tail + + +def _wide_line(length: int, tail: str) -> str: + """The same, but the filler is `—` and `→`: three bytes a character, so a + line inside the cap by characters is far past it by bytes.""" + line = ("—→" * length)[:length - len(tail)] + tail + assert len(line) == length and len(line.encode()) > 2 * length + return line + + +def _utf8_locale() -> str | None: + """A UTF-8 locale this host has, or None. `locale -a` spells them + `C.utf8` or `en_US.UTF-8` by platform.""" + try: + out = subprocess.run(["locale", "-a"], capture_output=True, text=True, + timeout=10, check=False).stdout + except OSError: + return None + names = [n for n in out.split() if n.lower().replace("-", "").endswith(".utf8")] + return names[0] if names else None + + +UTF8 = _utf8_locale() + +#: `${#x}` counts CHARACTERS in a UTF-8 locale and BYTES in `C`, and the cap is +#: O1's 240 CHARACTERS in both: every `lane-end` case runs under each. +LOCALES = ["C", pytest.param(UTF8, id="UTF-8", marks=pytest.mark.skipif( + UTF8 is None, reason="this host has no UTF-8 locale"))] + + +@pytest.fixture(params=LOCALES) +def ended(tmp_path: Path, request): + """`ended(cell)` runs `lane-end` on a lane whose register row carries + `cell` as its state, in this checkout's row and in the published one, and + returns the process and the row as the register has it afterwards.""" + box = Sandbox(tmp_path) + shutil.copy2(REPO / "lane-end", box.bin / "lane-end") + (box.bin / "lane-end").chmod(0o755) + stub = _write(box.bin / "lane-worktrees", "#!/usr/bin/env bash\nexit 0\n") + register = box.wip / "lanes" / "LANES.md" + seed = register.read_bytes() + + def row(cell: str | bytes) -> bytes: + """A register row; a `bytes` cell is how a hand-edited row that is not + UTF-8 gets in.""" + cell = cell.encode("utf-8") if isinstance(cell, str) else cell + return (f"| `{LANE}` | harness `{UUID}` | Eagle / test / brett | " + f"2026-10-11T00:00Z | none | handoffs/repoZ/x.md | ".encode() + + cell + b" |\n") + + def run(cell: str | bytes, published: str | bytes | None = None, + env: dict[str, str] | None = None): + """`published` is the cell origin/main carries when it is not this + checkout's own: pushed first, then this checkout's row put back. + `env` is added to the environment `lane-end` runs in.""" + if published is not None: + register.write_bytes(seed + row(published)) + box.git("-C", str(box.wip), "commit", "-q", "-am", "the published row") + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + register.write_bytes(seed + row(cell)) + if published is None: + box.git("-C", str(box.wip), "commit", "-q", "-am", "the row") + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + full = dict(box.env, LANE_WORKTREES=str(stub), LC_ALL=request.param, + **(env or {})) + result = subprocess.run([str(box.bin / "lane-end"), LANE], env=full, + cwd=str(box.lane_dir), capture_output=True, + encoding="utf-8", errors="replace", + timeout=120, check=False) + after = next(l for l in register.read_bytes().decode( + "utf-8", errors="replace").splitlines() + if l.startswith(f"| `{LANE}`")) + return result, after + + run.box, run.locale = box, request.param + return run + + +HOLD = "LANDING #7 into repoZ main" +READ = "the last LANDING in its state cell" + + +def test_a_line_over_the_cap_cannot_hide_an_open_landing(ended): + """THE DEFECT: `LIVE · · <241 characters ending in LANDING #7>` had + its scan narrowed to `LIVE`, so the lane ENDED over a hold other lanes' + merges wait on. O1's cap is on the line; a cell past it is not the phrase + and is read whole (#90, part (a)).""" + line = _line(241, " " + HOLD) + assert len(line) == 241 + result, after = ended(f"LIVE · {UTC} · {line}") + assert result.returncode == 2, (result.stdout, result.stderr) + assert READ in result.stderr, result.stderr + assert "lane-end on" not in after, after + + +@pytest.mark.parametrize("line", [ + pytest.param(f"held up | {HOLD}", id="a pipe"), + pytest.param(f"held up\r{HOLD}", id="a carriage return"), +]) +def test_a_line_with_a_pipe_or_a_carriage_return_cannot_hide_an_open_landing(ended, line): + """O1's other two rules: the line holds no `|` and no CR/LF. A cell that + breaks either is not the phrase, and its words are all read.""" + result, after = ended(f"LIVE · {UTC} · {line}") + assert result.returncode == 2, (result.stdout, result.stderr) + assert READ in result.stderr, result.stderr + assert "lane-end on" not in after, after + + +def test_the_published_row_is_read_whole_when_it_is_not_the_phrase(ended): + """The fallback reads TWO cells and either one showing a hold refuses: this + checkout's row says nothing, the one on origin/main is past the cap.""" + published = f"LIVE · {UTC} · {_line(241, ' ' + HOLD)}" + result, after = ended(f"LIVE · {UTC} · all quiet", published=published) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "the published row on origin/main" in result.stderr, result.stderr + assert "lane-end on" not in after, after + + +def test_a_line_exactly_at_the_cap_is_still_prose(ended): + """THE BOUNDARY IS INCLUSIVE and the phrase is unchanged: a valid line that + says `LANDING #7` is a lane waiting for one, not a hold (Amendment 13(a)), + and 240 characters is the longest line `set-row-state` accepts.""" + line = _line(240, " waiting for " + HOLD) + assert len(line) == 240 + result, after = ended(f"LIVE · {UTC} · {line}") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "| ENDED · " in after, after + + +def test_a_wide_line_exactly_at_the_cap_is_still_prose(ended): + """THE CAP IS COUNTED IN CHARACTERS IN EVERY LOCALE. A valid 240-character + line of `—` and `→` is some 680 bytes: counted as bytes under `C` it read as + past the cap, the cell was scanned whole, and the lane waiting for a + LANDING was held by it (the false hold Amendment 13(a)'s narrowing exists + to prevent).""" + line = _wide_line(240, " waiting for " + HOLD) + result, after = ended(f"LIVE · {UTC} · {line}") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "| ENDED · " in after, after + + +def test_a_wide_line_one_past_the_cap_cannot_hide_an_open_landing(ended): + """...and one character more is past it in every locale, wide or not.""" + line = _wide_line(241, " " + HOLD) + result, after = ended(f"LIVE · {UTC} · {line}") + assert result.returncode == 2, (result.stdout, result.stderr) + assert READ in result.stderr, result.stderr + assert "lane-end on" not in after, after + + +def _lines_that_are_not_utf8() -> list: + """Three lines past the cap by BYTES whose characters cannot be counted, + each naming a LANDING: a stray continuation byte is no character, and + taking continuation bytes out undercounts them.""" + hold = b" " + HOLD.encode() + return [ + pytest.param(b"\x80" * 241 + hold, id="241 stray continuation bytes"), + pytest.param(b"x" * (240 - len(hold)) + "°".encode("latin-1") + hold, + id="Latin-1 with one degree sign"), + pytest.param(_wide_line(240, hold.decode()).encode() + b"\x80", + id="a valid 240-character line and one stray byte"), + ] + + +@pytest.mark.parametrize("line", _lines_that_are_not_utf8()) +def test_a_line_that_is_not_utf8_cannot_hide_an_open_landing(ended, line): + """THE COUNT IS ONLY EXACT FOR VALID UTF-8. Past 240 bytes the characters + are counted by taking the continuation bytes out, and text that is not + UTF-8 has continuation bytes that follow nothing: a hand-edited cell of + them was counted short, narrowed, and its LANDING closed the lane. It + falls back to the byte count, which can only over-count (#90, part (a)).""" + assert len(line) > 240 + result, after = ended(f"LIVE · {UTC} · ".encode() + line) + assert result.returncode == 2, (result.stdout, result.stderr) + assert READ in result.stderr, result.stderr + assert "lane-end on" not in after, after + + +def test_a_count_that_cannot_be_made_is_a_line_over_the_cap(ended): + """A `wc` that fails (the one place the count is read from) leaves the + cell scanned whole: a valid 240-character line of `—` and `→`, which + narrows when its count is made, is read for its LANDING instead. It is the + `|| cfs_len=241` that does it: without it the count comes back empty, the + cell is still read whole, but only because `[ "" -le 240 ]` is an error, + which `lane-end` prints. (A UTF-8 locale counts a line of 240 characters as + 240 and never reaches `wc`.)""" + if ended.locale != "C": + pytest.skip("only a byte count past 240 reaches the `wc` recount") + stub = ended.box.root / "failing-wc" + _write(stub / "wc", f'#!/bin/sh\n[ "$*" = -c ] && exit 1\nexec {shutil.which("wc")} "$@"\n') + line = _wide_line(240, " waiting for " + HOLD) + result, after = ended(f"LIVE · {UTC} · {line}", + env={"PATH": f"{stub}{os.pathsep}{ended.box.env['PATH']}"}) + assert result.returncode == 2, (result.stdout, result.stderr) + assert READ in result.stderr, result.stderr + assert "integer expression" not in result.stderr, result.stderr + + +def test_a_line_over_the_cap_that_holds_nothing_still_ends(ended): + """Reading the whole cell is a refusal only where the cell holds + something: a malformed one with no LANDING or CLAIMED in it closes.""" + result, after = ended(f"LIVE · {UTC} · {_line(300, ' and all of it quiet')}") + assert result.returncode == 0, (result.stdout, result.stderr) + assert "| ENDED · " in after, after