Skip to content

The register's rewrites refuse a copy or write they cannot prove and put a cut-short register back, retire-rows refuses an unpushed log rename before it writes and calls a lane closed only from its published log, and add-row refuses an archive row it cannot read and takes back a row refused after its rebase (#120, #122) - #263

Merged
brettheap merged 14 commits into
mainfrom
fix/register-hardening-120-122
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #120
Closes #122

Copilot rounds 5 and 6 on #93, filed rather than taken. Each finding was checked against main at 5e88a2c first, because landings since 09-16 had hardened nearby paths: #121 (replace_line's no-op), #221 and #230 (the sweeps' and the archive's reads). #227 changed CI only. Only what was still open is changed here.

Lane openRepoTools-4 wrote this on lane 3's consent (NOTED 2026-10-18T13:34:54Z) and on its terms: one PR, an Opus writer and an Opus review, merged only on Brett Heap's word, and no changed line on act 2's sidecar-lock lines.

The independent Opus review of 0702547 returned FIX: one MUST, two SHOULDs and seven NITs. 2015d1f fixes them; see the review round. 42e73e5 merges main 55aae16 into the branch (no force), so CI tests the moved main. The delta review of 42e73e5 returned FIX again: one new MUST, which round 1's take-back brought in, and three NITs. ae0eb92 and 4ca5104 fix them; see the delta round. d0256ff merges main 00d52ae (#252) into the branch (no force); see the merge of #252. Delta review 3 of d0256ff found the merge clean to land and returned FIX on fix round 2: one new SHOULD (S3) and one NIT. 5b460b7 fixes them; see delta round 3. Delta review 4 of 5b460b7 closed S3 and returned FIX on one new SHOULD (S4) and two NITs (N1, N3). 76b119d fixes them; see delta round 4. Delta review 5 of 76b119d returned LAND with no findings. 32d75a9 merges main 90eed74 into the branch (no force), on the coordinator's word; see the merge of main 90eed74. Main has since moved to a42946e (#265, the shell suite and its wrapper only). refs/pull/263/merge merges the two cleanly, so CI tests the PR on it; the branch is not re-merged without the coordinator's word. Line numbers below are in 76b119d's lanes-edit.sh, except in the round tables, which name their own commit.

Per finding

Finding At 5e88a2c Verdict Fixed at (76b119d)
#120 item 1: the rewrites' cat > LANES.md status is ignored The status has been checked since 4c2ffef (#81) in replace_line and since c1bac0d (#93's squash) in delete_lines. The cmp proof the issue asked for was never added. A short write that exits 0, and one that fails part-way, both leave a truncated register; the second exits 5 saying "its line N is UNCHANGED". The copy $pre that every proof is made against had no check at all. Partly settled. The status check is settled by 4c2ffef and c1bac0d (no change there). The proof, the put-back and the copy are fixed here. register_copy lanes-edit.sh:1654, register_rewrite :1658; used at :1703, :1752 (replace_line) and :1778, :1834 (delete_lines)
#120 item 2: the sweep's published-path preflight is weakened Unchanged since c1bac0d: the dead basename-vs-path clause and rr_ln2 = 0. Open, fixed. The preflight is now ensure_log's own test and sentence. lanes-edit.sh:12833
#120 item 3: retire-rows names the wrong exit for an already-closed log rr_open has kept the last lane-kind verb since c1bac0d, so the "one extra read" is settled. The sentence is one line for closed and running lanes alike, and names no exit. Rule declined, as the issue declines it; sentence fixed. lanes-edit.sh:12749
#122: an unparseable archive row is read as a non-row The scan skips a | line with no backtick and takes the first backtick anywhere as the lane. Open, fixed. RETIRED_ROWS_AWK lanes-edit.sh:3713, retired_identity_hits :3731, add_row_retirement_rescan :3774, add-row :15281
#122: the || : half The read of the archive text has failed closed since c1bac0d (#93's squash): archive_text returns 1 on a published or local copy that will not open. #230 (5e88a2c) extended that to an archive path that is not a regular file. The awk's status was still masked by the | sort -u behind it. Partly settled. The read is settled by c1bac0d and #230; the scan's own status is fixed here. lanes-edit.sh:3731

What each fix does

  • Three hardenings Copilot round 5 raised on #93, filed rather than taken: the register rewrite's unchecked status, the sweep's weakened published-path preflight, and retire-rows' wrong exit for an already-closed log #120 item 1. The register is rewritten in two places, and both now prove what they copied and what landed.

    • register_copy compares the copy with the register.
    • register_rewrite compares what landed with what was built, and refuses with 5 in one of three sentences:
      • the write never reached the register, which is byte for byte as it was;
      • the write cut it short, and its earlier bytes were put back and proved;
      • even the put-back failed, so it MAY NOW BE TRUNCATED. The exact copy from before the edit, with any uncommitted edit it held, is kept and named, with the command that puts it back. It goes into the checkout's own git directory as lanes-edit-register-before-edit.<UTC>.<pid>. It is written with mode 0600 under noclobber and proved with cmp, so a restart that empties the temporary directory leaves it. Only where that cannot be done is it renamed beside $TMPD, and then the refusal says a restart would lose it. The refusal also says to restore before the next write, because that write would commit the truncated register as a pre-existing edit.
    • Every one of these refusals says this act has committed nothing, and lists what the act had already written in this checkout. It does not say "nothing has been committed": a peer's uncommitted register edit found first is captured as a commit of its own (handle_preexisting). Where add-row's take-back is the act that cannot rewrite, the refusal says add-row HAS committed its row, and how to remove it.
    • Measured at 5e88a2c, with a cat that cuts one copy at half its lines:
      • a short copy: a 156,611-byte register was rewritten as 78,306 bytes and committed, exit 0;
      • a write that failed part-way: exit 5 with "UNCHANGED" over a half register;
      • a short write that exited 0: a half register committed, exit 0.
  • Three hardenings Copilot round 5 raised on #93, filed rather than taken: the register rewrite's unchecked status, the sweep's weakened published-path preflight, and retire-rows' wrong exit for an already-closed log #120 item 2. The state a case-only rename leaves when its commit is not pushed used to pass the scan:

    • the row's spelling here, committed;
    • the old spelling still on origin;
    • a checkout merely ahead.

    ensure_log then refused it inside the write loop, after the lanes before it had their logs created. Measured: retire-rows repoX-2 repoX-3 exited 2 with lanes/log/repoX-2.md left behind. It now refuses in the scan, in ensure_log's words, and writes nothing.

  • Three hardenings Copilot round 5 raised on #93, filed rather than taken: the register rewrite's unchecked status, the sweep's weakened published-path preflight, and retire-rows' wrong exit for an already-closed log #120 item 3. The rule stays as clause (c) has it. The message now follows the log's last lane-kind line:

    • RETIRED: the lane is already CLOSED and RETIRED, already hidden (19(b)), and lanes-edit.sh archive-rows <repo> takes its row out of the register (19(d)).
    • ENDED: the lane is already CLOSED, and nothing more is owed. Its row stays as the record (Rule 9), and archive-rows is not its exit, since it moves only rows whose cell says RETIRED.
    • Either way, the lane is called CLOSED only when origin's copy of its log ends on that line. A closing line that only this checkout's log carries is refused as exactly that: an earlier act that stopped part-way, or a line never pushed.
    • STARTED/PAUSED/RESUMED: "End it as itself when its work is done: lane-end <lane>".
    • The shell suite's pinned fragments are kept.
  • add-row's archive scan treats an unparseable archive row as a non-row, so a retired position can be reissued — and the obvious refusal would fire on the archive's own header (#93 round 6, filed rather than taken) #122. Every | line of the archive must now be one of three:

    • the rule;
    • the header: first cell lane, as archive_rows writes it;
    • a row whose first backticked token lies wholly inside its first cell.

    Any other line is named by its line number and the copy it is in (origin/main, this checkout, or the index). add-row refuses it with 1, the code it already uses for an archive it could not read. So does retired-identity, and the scan's own status is now read.

    The after-rebase rescan fails closed and takes its row back, and only its own row. add-row commits its row before pull --rebase, so a refusal there must undo that commit. Every refusal of the rescan now does: an archive it cannot read, a line it cannot take apart, or a peer's retirement. The copy taken back is the one this checkout's register adds over origin's. It is read from their -U0 diff by content, under whatever sha the rebase gave the commit:

    • none added: the rebase dropped add-row's commit because a peer had already published the byte-identical row. The copy left is the peer's, so nothing is taken back, and the refusal code is returned;
    • one added: exactly that line is removed in a compensating local commit, after checking it holds the row. The next write publishes nothing of it, wherever an identical peer copy sits;
    • more than one, or a diff that cannot be read: nothing is taken back, and the refusal says the local commit must be repaired.

    That diff is asked for with GIT_DIFF_OPTS unset and diff.interHunkContext=0, either of which would otherwise add context to -U0. Its parse counts every line of a hunk but a removed one, so any context that still arrives is counted where it stands (delta review 3, S3). It is read as text whatever classes the register as binary, and the proof that the removal took exactly one line counts lines whatever core.bigFileThreshold says (delta review 4, S4). Where the take-back itself cannot remove the row, every refusal says so in add-row's own words: the row is still committed locally and unpushed, and must be removed before any other write publishes it. Where the removal is written but cannot be committed, a note says whether it is staged, that add-row's commit still holds the row, and the command that commits the removal.

    The issue suggested telling the header apart by its | separator count. That cannot work: the header carries 6 separators, exactly like a data row (measured). "The line above the |---| rule" cannot be used either: the live register has two rows between its header and its rule (lines 76-79 on 2026-10-19). I also dry-ran the new rule, read-only, against the live register's 75 | lines. Its header, its rule and all 73 rows pass, including the three spelled *(unnamed)* `<lane>`. None are refused. No archive is published on the live estate yet.

    Two shapes are now refused that look valid on screen: an archive with CRLF line endings, and a table written without the spaces around its |. archive-rows writes neither, and nothing else in this tool reads CRLF. docs/README-lanes.md says so.

The header's exit-code table, docs/README-lanes.md's exit-code row 5, its sweep section and its archive section all say so.

One bad archive line refuses every lane-start (review S1, for lane 3)

retired_identity_hits returns 3 for a line it cannot take apart, whichever repository's row it is in, and lanes-edit.sh retired-identity <lane> exits 1 on it. lane-start asks that check about every lane before it launches, so while such a line is in the archive, every lane-start on the estate refuses, not only the bad line's lane. Measured by the review: retired-identity repoZ-1, against an archive whose bad line is a repoX row, exits 8 at 5e88a2c and 1 here.

This is fail-closed by design: an archive line nobody can read may hold the position being started. But lane-start hides the reason:

# lane-start:2301-2305 at 55aae16
retired_hits="$(LANES_NO_FETCH=1 "$LANES_EDIT" retired-identity "$LANE" 2>/dev/null)" || retired_rc=$?
...
  *) die "the lane archive could not be checked for $LANE (exit $retired_rc). Nothing was renamed or written." 1 ;;

The line is seen with lanes-edit.sh retired-identity <lane>. That prints each bad line with its number and its copy. docs/README-lanes.md's archive section now says both things.

Suggested to lane 3 (lane-start is lane 3's file, so it is not edited here): capture that call's stderr and print it in the *) refusal, or drop the 2>/dev/null there. The refusal would then name the line to fix.

The review round (0702547 → 2015d1f)

Review finding Fix Where (42e73e5) Test Mutant
M1 A refused add-row is published by the next write: the rescan's non-retirement refusals left the add-row commit at HEAD Any rescan refusal runs the compensation the retired-match branch ran: the exact row is removed in a local commit, and add-row exits 1 (2 on a retirement, as before). If the row cannot be found exactly once, add-row says the local commit must be repaired before any later push. add_row_retirement_rescan :3714 test_122_a_refusal_after_the_rebase_takes_its_row_back (repo19race's shape: a peer pushes a mangled archive row during add-row's pull; add-row exits 1; the row is gone from HEAD, from origin/main..HEAD and from origin; a later set-row-state exits 0 and publishes no repoX-4) F1, F1b
S1 One bad archive line blocks every lane-start, and the reason is hidden Documented, with how to see the line, in the README and above. The lane-start change is suggested to lane 3; that file is not touched. docs/README-lanes.md, archive section — —
S2 "Nothing was written" after the sweep wrote files The copy refusal says nothing has been committed (since the delta round: this act has committed nothing). Every register refusal lists what the act already wrote in this checkout: retire-rows' log lines and rows, archive-rows' archive, migrate-state-cells' archived register, logs and earlier cells. reg_wrote/reg_written_here :1627, register_copy :1635; callers :11941, :11957, :11964, :12735, :12742, :12957, :12960 test_120_item_1_a_sweep_refused_part_way_names_what_it_wrote_and_is_not_called_closed (first half); the copy cases of …cut_short… F2, F3
S2 The CLOSED sentence came from an uncommitted log retire-rows calls a lane CLOSED only when origin's log ends on the closing line. One that only this checkout carries is refused as unpublished. rr_pub_open :12587, the check :12603 the same test, second half (the re-run) F4
NIT 1 CRLF and space-less tables are refused One README paragraph docs/README-lanes.md — —
NIT 2 The archive reader and writer disagree about what a row is Not changed; a follow-up — — —
NIT 3 X2 survived: add-row's own refusal was not asserted Asserted: "carries a line this scan cannot take apart (above)" test …reissues_no_position (6 cases) X2
NIT 3 X4 survived: "as it was" shared its words with the put-back Its own sentence ("the write did not reach it, and it is byte for byte as it was"), apart from the put-back and the truncation register_rewrite :1642 test_120_item_1_a_write_that_never_reached_the_register_says_it_is_as_it_was (both acts, register made read-only) X4
NIT 3 X6 survived: "archive-rows repoX" matched "archive-rows repoX-2" Matched exactly, with its backticks: `lanes-edit.sh archive-rows repoX` test test_120_item_3_…_closed[RETIRED] X6
NIT 4 Line numbers do not say which copy Each bad line names the copy it is in, joined when both carry it at the same number retired_identity_hits :3683-3687 test_122_a_line_number_says_which_copy_it_is_in (line 10 on origin, line 12 here) F7
NIT 5 ENDED told "not retired a second time", offered archive-rows Its own sentence: CLOSED, nothing more owed, the row stays as the record, archive-rows is not its exit retire_rows :12609 test_120_item_3_…_closed[ENDED] F5
NIT 6 The truncation branch loses the only exact copy The copy is moved beside $TMPD, out of the trap's reach (a rename, no free space needed), and its path and restore command are named. git checkout is named only as the fallback that drops uncommitted edits. register_rewrite :1647 …cut_short…[*-write and restore fail]: the named file equals the register as it was F6
NIT 7 PR body attribution Corrected in the per-finding table: c1bac0d (#93), with #230 extending it to files that are not regular this body — —

The delta round (42e73e5 → 4ca5104)

Delta-review finding Fix Where (4ca5104) Test Mutant
D1 The take-back deletes a peer's identical row. During add-row's pull a peer publishes the byte-identical row and an unrelated bad archive line. The rebase drops add-row's commit as already upstream, the rescan refuses, the exact-line grep finds the PEER's row and deletes it, and the next write publishes that deletion. Only the copy this checkout's register adds over origin's is taken back (above). Why the diff and not "origin already has the row": that test is right one way only. If add-row's commit SURVIVED beside a peer's identical row at another place, the commit would be kept, and the next write would publish a second copy. The diff is right both ways, because it counts what this checkout adds. The retired-match branch shares the path. add_row_retirement_rescan :3743, the diff :3776, the take-back :3800 test_122_a_refusal_after_the_rebase_leaves_a_peers_identical_row_alone (p15's case, for both refusals: a bad archive line, exit 1, and a retirement, exit 2); test_122_a_refusal_after_the_rebase_takes_back_its_own_row_beside_a_peers_copy (the other direction). test_122_a_refusal_after_the_rebase_takes_its_row_back stays green. D1a, D1b, D1c
NIT "nothing has been committed" after handle_preexisting committed a peer's edit "this act has committed nothing", from reg_committed. Where add-row's take-back cannot rewrite the register, the refusal says add-row HAS committed its row, and how to remove it. reg_committed :1638; register_copy :1646; :1744, :1803; the take-back :3805 test_120_item_1_a_refusal_after_a_captured_peer_edit_says_this_act_committed_nothing; test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed; the copy cases N1a, N1b
NIT "a ENDED line" "an ENDED line" retire_rows :12673 test_120_item_3_a_closing_line_only_this_checkout_has_is_not_called_closed[ENDED] N2
NIT The kept copy sits in $TMPDIR, mode 0644 It goes into the checkout's git directory as lanes-edit-register-before-edit.<UTC>.<pid>. It is written with mode 0600 under noclobber, after checking the name is free, and proved with cmp. The fallback beside $TMPD says a restart would lose it. register_rewrite :1659 …cut_short…[*-write and restore fail]: the named file is in git rev-parse --absolute-git-dir, its name matches, its mode is 0600, and its bytes are the register as it was N3a, N3b

Red at 42e73e5: 13 of 33.

  • Both D1 identical-row cases fail where p15 measured: the next write publishes the take-back. The published register has no repoX-4 row, and origin/main..HEAD held cancel add-row ….
  • The other-direction case fails on a published second copy.
  • The other ten are this round's wording, article and kept-copy assertions.

The merge of #252 (d0256ff)

Main moved from 55aae16 to 00d52ae when #252 landed (#113, #114, #115). git merge --no-ff of 00d52ae into 4ca5104 is textually clean: tree e907343, the same tree the trial merge with #252's head e6a41b4 gave.

Semantic interplay: none found. Both PRs change lanes-edit.sh and docs/README-lanes.md, but in different places.

On d0256ff, both bash 3.2.57 and 5.2 pass bash -n, and the lock lines are main's own (94be0fc3…, 65). The sidecar lines are main's own too (591ba21d…), with no changed line naming either.

Delta round 3 (d0256ff → 5b460b7)

Delta review 3 found the merge d0256ff clean to land: no interplay with #252, and lane 3's terms held. Fix round 2 had one new SHOULD and one NIT.

Finding Fix Where (5b460b7) Test Mutant
S3 The take-back counted only the + lines of its -U0 diff, and -U0 does not hold on its own. GIT_DIFF_OPTS=-u3 overrides it, and a user's diff.interHunkContext fuses a nearby change's hunk with the row's, with the lines between as context. The row was put at the wrong line, nothing was taken back, and the next ordinary write published the refused row: M1 again (the review's p18b and p18c). Both halves. That one diff runs with GIT_DIFF_OPTS unset and -c diff.interHunkContext=0. The unset is inside the command substitution's own subshell, so nothing after it is touched. A command-line -c outranks every config file and GIT_CONFIG_* in the environment (measured on git 2.43). And the parse advances its new-file line on every hunk line except a removed one and git's \ No newline note. the diff :3805, the parse :3809 test_122_a_take_back_finds_its_row_whatever_context_the_diff_carries, 4 cases (below) S3a, S3b, S3b1, S3b2, S3c, Y3
NIT The take-back's own refusals ended in delete_lines' words, which are archive-rows': "the rows are in" the archive, and "the archive may have been appended". delete_lines takes its caller's sentences, with archive-rows' as the default. add-row's say its row is still committed locally and unpushed, and must be removed before any other write publishes it. delete_lines :1776, the take-back :3840 test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed, [copy] and [write] N4a, N4b

The S3 test's four cases each assert the published register after the next ordinary write, then how the take-back's diff was called: no GIT_DIFF_OPTS, and -c diff.interHunkContext=0.

  • GIT_DIFF_OPTS=-u3 in add-row's environment (p18c).
  • diff.interHunkContext=3 beside an unpushed edit one row above the end (p18b).
  • Context forced into that diff by a shim, beside the same edit: a source of context the suppression cannot turn off.
  • Another lane's unpushed row just above: a second + line in the row's hunk.

Red at d0256ff: 6 of 38.

  • The first three cases fail on the published register: the next write published repoX-4.
  • The fourth fails only on how the diff was called, because d0256ff's parse counts consecutive + lines right.
  • The two wording cases fail on archive-rows' words.

The two halves cover for each other, so each needs its own kill.

  • With the suppression in place, no context reaches the parse. So S3a (the parse counts only + lines) is killed only by the shim case, whose context the suppression cannot turn off.
  • With the parse in place, every context is counted right. So S3b (no suppression) changes nothing the register shows; the cases' record of how the diff was called kills it.
  • Y3, the reviewer's counter that never advances, survived all 33 of round 2's cases in delta review 3. The shim case and the second-row case kill it.

Delta round 4 (5b460b7 → 76b119d)

Delta review 4 closed S3. Under 24 diff settings and routes, and under one hostile global config holding many of them at once, the take-back still found its row's line, except where a setting classes the register as binary: that is S4. A clean filter that changes the register's line count puts the row at the wrong line, and the line check then refuses loudly. Lane 3's terms held. It returned FIX on one new SHOULD and two NITs.

Finding Fix Where (76b119d) Test Mutant
S4 Where git classes the register as binary, the take-back's diff is the one line "Binary files … differ", with no hunk to read. add-row said this checkout added no copy of the row, kept its commit, and the next write published the row: M1 again, behind a false all-clear. The review measured eight routes: a binary or -diff attribute, published or local; a diff driver with binary=true; core.attributesFile; GIT_ATTR_SOURCE; attr.tree; a NUL byte in the first 8000 bytes; and a core.bigFileThreshold below the register's size. None is live on Eagle, but the live register is 2,309,050 bytes, so one global threshold under that would be. (a) The take-back's diff is read with --text, which finds the row under all eight. (c) Its removal's numstat proof runs with -c core.bigFileThreshold=2g. Under a smaller threshold numstat printed - for both counts, which --text does not change, so the take-back refused. A command-line -c outranks the global config, GIT_CONFIG_COUNT and GIT_CONFIG_PARAMETERS (measured on git 2.43). the diff :3832; the proof :1826 test_122_a_take_back_reads_its_diff_as_text_whatever_classes_the_register_binary: -diff in .git/info/attributes, a published .gitattributes with binary, and core.bigFileThreshold=200 S4a, S4c
N2 (part of S4) delete_lines' line-count and numstat refusals named no row and no commit: "the removal added or rewrote lines (numstat: …); refusing", and the next write published the row. (b) Every refusal delete_lines makes after its copy ends as register_copy's does: the register as it was, what this act has committed, the caller's sentence, and what the act has already written. dl_as_was :1787; the refusals :1817, :1828 test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed, [line count] and [numstat] S4b1, S4b2
N1 The refusal of the pass that removes the line said "The register is as it was: no row was removed, so that row is still in it and in that commit". It named no lane and no commit, and the take-back's own note never printed. The same sentence as (b). :1813 …[awk pass] N1
N3 When the compensating git add or git commit failed, add-row said nothing of its own. The removal was left written or staged, and the next write's commit carried it under that write's caption. A note says what is staged, that add-row's commit still holds the row, and the command that commits the removal. :3878, :3882 test_122_a_removal_that_cannot_be_committed_says_so, [staging] and [commit]. Each runs the command the note gives; the next write then publishes no repoX-4, and origin's history has the removal under its own caption. N3a, N3b

Red at 5b460b7: 8 of 46.

  • The three binary cases fail on the published register: the next write published repoX-4.
  • [numstat] fails on add-row's exit: at 5b460b7 the diff read the register as binary, so add-row said there was nothing to take back.
  • [awk pass] and [line count] fail on the sentence.
  • Both N3 cases fail on the note.

Still a refusal, loud and true. An attribute on every path (* -diff in a core.attributesFile) or a NUL byte in the register makes numstat read the two copies as binary at any threshold. So the take-back refuses (exit 5) in add-row's words; [numstat] pins this. No numstat option reads such copies as text: --text and the threshold change nothing there (measured).

The merge of main 90eed74 (32d75a9)

The branch last merged main at 00d52ae (d0256ff). Since then main has taken #264, #266 and #283, the #228 release series (the openrepotools package, the package job in tests.yml, the release workflow and its docs) and #271 (#255: the handoff names every inventory record that is not a whole schema-1 one, and a leg directory this account cannot search is unreadable, never absent). Only #271 touches a file of this PR. git merge --no-ff of 90eed74 into 76b119d is textually clean: tree 44bfcae, the tree git merge-tree --write-tree gave beforehand.

Semantic interplay: none found. Of this PR's three files, main changed two, both in #271.

On 32d75a9, bash 3.2.57 and 5.2 both pass bash -n. The lock lines are main's own (94be0fc3…, 65), and so are the sidecar lines (591ba21d…, 123). No line of the PR's diff against main names either, and lane-start and lane-handoff show no diff against main.

Tests: red at 5e88a2c, green at head

The new file is tests/test_register_hardening_120_122.py, with 46 cases in the style of test_register_scan_hardening_138.py. It subclasses that file's Register harness, and its three race cases share one helper in the shell suite's repo19race shape.

Run Result
--collect-only of the five files below, at 76b119d 434 tests: 46 in this file, 27, 235, 44 and 82
New file (38) against 5e88a2c's lanes-edit.sh 38 failed / 38, each on the exit, sentence or published register it asserts
New file (46) against 5b460b7's lanes-edit.sh (fix round 3) 8 failed / 46: the cases delta round 4 added (above)
New file (38) against d0256ff's lanes-edit.sh (fix round 2, merged) 6 failed / 38: the five cases delta round 3 added and the one it tightened (above)
New file at 4ca5104 (33) against 42e73e5's lanes-edit.sh (round 1, merged) 13 failed / 33: every case the delta round added or tightened (above)
New file against 0702547's lanes-edit.sh (round 1) 18 failed / 26: every case this round added or tightened. The 8 that pass are round 1's cases it left alone: item 1's write cases (4), item 2, item 3 RETIRED and parked, and the awk failure. The race fails there on the missing compensation.
2015d1f: new file + test_register_scan_hardening_138.py + test_repo_hygiene.py 256 passed
42e73e5 (main merged): the same three 288 passed (main added cases to those files); the collect-only run again selects the same 26
4ca5104: the same three 295 passed
d0256ff (main 00d52ae merged): both PRs' stems, -k 'register_hardening_120_122 or register_scan_hardening_138 or repo_hygiene or lifecycle_fail_open_113_114_115 or lanes_index' 421 passed: 33 + 27 + 235 + 44 + 82, the collect-only run's per-file counts
5b460b7: the same five files 426 passed: 38 + 27 + 235 + 44 + 82
76b119d: the same five files 434 passed: 46 + 27 + 235 + 44 + 82
32d75a9 (main 90eed74 merged): the same five files and the nine other test files main changed since 677acc0 (test_install_pipx, test_openrepotools_command, test_package, test_package_stamp, test_release_workflow, test_test_roots, test_tests_workflow, test_triad_lane_tooling, test_triad_lane_tooling_handoff) 866 passed, 570 skipped: this PR's five files all passed (46 + 27 + 237 + 44 + 82); the 570 skips are all in main's own modules here: 494 in test_release_workflow, 43 in the two triad files, 27 in test_tests_workflow, 5 in test_package and 1 in test_test_roots

All runs went through tests/run.sh only, with every LANES_*/TMUX* variable unset, started detached with setsid -f, and never inside a flock. tests/run.sh <file> always passes tests too, so every run narrowed with -k <module stem>; the collect-only run confirms the selection. CI runs the whole suite on this PR.

bash 3.2. bash -n passes on 2015d1f's, 42e73e5's, 4ca5104's, d0256ff's, 5b460b7's, 76b119d's and 32d75a9's lanes-edit.sh under GNU bash 3.2.57 (a musl build, run through its own loader), and under 5.2. That build refuses a planted syntax error, so its -n is real. No added line uses a bash-4 construct: a grep for ${x,,}, ${x^^}, declare -A, declare -n, local -n, mapfile, readarray, |&, &>>, coproc, ;&, ;;& and ${x@Q} finds none.

Mutants: 40 at 5b460b7 and 19 at 76b119d, all killed

Each mutant is applied to the head's lanes-edit.sh, the new file is run against it, and lanes-edit.sh is restored from HEAD. The 24 below were run again at ae0eb92, with F1b, F3 and F6 re-pointed where round 2 moved their code. All are still killed. D1a to D1c, F1 and F1b were run once more on 4ca5104's reordered D1 tests. All 40 were run again at 5b460b7, with F1b and D1b re-pointed where round 3 moved their code, and every one is killed: the last column is that run's count of failed cases.

Mutant What it breaks Red when it was added Red at 5b460b7
M1a the copy is not checked at all 5 (copy fails / copy says done, both acts; the sweep refused part-way) 7
M1b the copy is checked by status only, no cmp 2 (copy says done) 2
M1c the write is checked by status only, no cmp 2 (write says done) 2
M1d no put-back 4 (write fails / write says done) 5
M1e the put-back is not proved 2 (write and restore fail) 2
M2 the preflight is 5e88a2c's condition 1 (item 2) 1
M3a a closed log gets the open sentence 3 (item 3 ENDED and RETIRED; the sweep re-run) 5
M3b an open log gets the closed sentence 1 (item 3, parked) 1
M4a a malformed row is skipped again 8 (#122 rows ×6, the copy labels, the race) 16
M4b the header is not recognised 7 (#122 rows ×6, the race) 16
M4c the token is taken anywhere in the line 4 (the lane lost its backticks ×2, the copy labels, the race) 12
M4d both scans' status is dropped 1 (#122 awk failure) 1
M4e add-row reads 3 as "no match" 6 (#122 rows) 6
X2 (review) a bad line returns 1, not 3 6 (#122 rows: add-row's own refusal) 6
X4 (review) the "as it was" branch is dead 2 (the write never reached the register, both acts) 2
X6 (review) archive-rows is given the lane, not the repository 1 (item 3 RETIRED) 1
F1 (M1) a non-retirement rescan refusal is not compensated (0702547's branch) 1 (the race) 9
F1b (M1) the compensation is announced but the row is kept 1 (the race: the row is still in HEAD) 8
F2 (S2) the local writes are never named 4 (delete_lines copy ×2 and never-reached, the sweep) 4
F3 (S2) the copy refusal says "Nothing was written" again 5 (copy ×4, the sweep) 7
F4 (S2) CLOSED is said from this checkout's line 1 (the sweep re-run) 3
F5 (NIT 5) ENDED gets the RETIRED sentence 1 (item 3 ENDED) 1
F6 (NIT 6) the exact copy is not kept 2 (write and restore fail) 2
F7 (NIT 4) a published copy is labelled "this checkout" 4 (#122 rows published ×3, the copy labels) 4
D1a (D1) the guard dropped: the diff is taken against the empty tree, which is round 1's exact-line grep 3: both identical-row cases on the published count (the peer's row gone), and the other direction on a published second copy 7
D1b (D1) "origin already has the row, so take nothing back" 3: the other direction on a published second copy; the identical-row cases only on the sentence, because that rule is right in their direction 3
D1c (D1) the line taken back is the first exact copy, not the one the diff adds 1 (the other direction: the peer's copy removed and add-row's kept) 1
N1a "nothing has been committed" again 6 (the copy cases ×4, the captured peer edit, the sweep) 6
N1b the take-back does not say add-row has committed 1 (the take-back that cannot rewrite) 1
N2 the article is always "a" 1 (closing line only here, ENDED) 1
N3a the copy is kept beside $TMPD, not in the git directory 2 (write and restore fail) 2
N3b the copy is kept with the umask's mode 2 (write and restore fail) 2
S3a (S3) the parse counts only + lines again (fix round 2's parse) 1 (the forced context, on the published register) 1
S3b (S3) the diff runs with neither suppression 4 (all four cases, on how the diff was called) 4
S3b1 (S3) GIT_DIFF_OPTS is kept 1 (GIT_DIFF_OPTS=-u3, on the GIT_DIFF_OPTS the diff saw) 1
S3b2 (S3) diff.interHunkContext is kept 4 (all four cases, on -c diff.interHunkContext=0) 4
S3c (S3) a removed line advances the counter 1 (the forced context, on the published register) 1
Y3 (S3, review) the counter never advances 2 (the forced context and another lane's row, on the published register) 2
N4a (NIT) the take-back's copy refusal is in archive-rows' words 1 ([copy]) 1
N4b (NIT) the take-back's write refusal is in archive-rows' words 1 ([write]) 1

Round 4 (76b119d)

The 12 mutants whose lines round 4 changed, or that round 3 aimed at the take-back, were run again at 76b119d, with F1b re-pointed where round 4 moved its code. The 7 below them are round 4's own. All 19 are killed.

Mutant What it breaks Red at 76b119d
D1a (D1) the guard dropped: the diff is taken against the empty tree 7
D1b (D1) "origin already has the row, so take nothing back" 3
S3a (S3) the parse counts only + lines again 1
S3b (S3) the diff runs with neither suppression 4
S3b1 (S3) GIT_DIFF_OPTS is kept 1
S3b2 (S3) diff.interHunkContext is kept 4
S3c (S3) a removed line advances the counter 1
Y3 (S3, review) the counter never advances 2
N4a (NIT) the take-back's copy refusal is in archive-rows' words 4
N4b (NIT) the take-back's write refusal is in archive-rows' words 1
N1b the take-back does not say add-row has committed 4
F1b (M1) the compensation is announced but the row is kept (re-pointed: delete, stage and commit dropped) 16
S4a (S4) the take-back's diff is not read as text 4
S4b1 (N2) the numstat refusal names no row and no commit 1
S4b2 (N2) the line-count refusal names no row and no commit 1
S4c (S4) numstat runs under the user's core.bigFileThreshold 1
N1 (N1) the pass's refusal in its old words 1
N3a (N3) a failed git add of the removal is silent 1
N3b (N3) a failed compensating commit is silent 1

Lane 3's constraint: the lock lines are unchanged

lanes-edit.sh   md5(grep 'acquire_lock\|release_lock', no -n)   count   sidecar lines md5(grep -i sidecar)       count
5e88a2c (base)  94be0fc3ab62c65bf26c793267ea460d               65      7d7f763a7492081f940c4bf5aee60bbf         108
2015d1f         94be0fc3ab62c65bf26c793267ea460d               65      7d7f763a7492081f940c4bf5aee60bbf         108
55aae16 (main)  94be0fc3ab62c65bf26c793267ea460d               65      4594bcf7575c8a75d7e8476a6e69c140         110
42e73e5         94be0fc3ab62c65bf26c793267ea460d               65      4594bcf7575c8a75d7e8476a6e69c140         110
ae0eb92 (4ca5104 changes only the test file)
                94be0fc3ab62c65bf26c793267ea460d               65      4594bcf7575c8a75d7e8476a6e69c140         110
00d52ae (main, #252)
                94be0fc3ab62c65bf26c793267ea460d               65      591ba21d91d08cf2f9c1c3ce1e61935a         123
5b460b7         94be0fc3ab62c65bf26c793267ea460d               65      591ba21d91d08cf2f9c1c3ce1e61935a         123
76b119d         94be0fc3ab62c65bf26c793267ea460d               65      591ba21d91d08cf2f9c1c3ce1e61935a         123
90eed74 (main)  94be0fc3ab62c65bf26c793267ea460d               65      591ba21d91d08cf2f9c1c3ce1e61935a         123
32d75a9 (head)  94be0fc3ab62c65bf26c793267ea460d               65      591ba21d91d08cf2f9c1c3ce1e61935a         123
changed lines naming lock or sidecar (any case), all files, 5e88a2c..2015d1f: 0
changed lines naming lock or sidecar (any case), all files, 55aae16..4ca5104: 0
changed lines naming lock or sidecar (any case), all files, 00d52ae..5b460b7: 0
changed lines naming lock or sidecar (any case), all files, 5b460b7..76b119d: 0
changed lines naming lock or sidecar (any case), all files, 90eed74..32d75a9: 0
git diff 90eed74 32d75a9 -- lane-start lane-handoff: 0 bytes

The lock lines' content and order are identical at base and head (the md5 without -n). The sidecar lines differ between 5e88a2c and the head only because #246 and #252 changed them on main; the head has exactly main's.

Trial merges (git merge-tree --write-tree)

Head With Result
2015d1f main 55aae16 clean, tree aa69fcf, and 42e73e5 is that merge
42e73e5 #252 e6a41b4 (both touch lanes-edit.sh and docs/README-lanes.md) clean, tree 3b859a7; its lanes-edit.sh parses under bash 3.2.57 and 5.2
4ca5104 #252 e6a41b4 clean, tree e907343; its lanes-edit.sh parses under bash 3.2.57 and 5.2
4ca5104 main 55aae16 already contains it; refs/pull/263/merge has parents 55aae16 and 4ca5104 and the head's own tree 96a671a
5b460b7 main 21e0829 (#264, #266 since 00d52ae; neither touches this PR's files, run.sh or conftest) clean, tree a5486b2; refs/pull/263/merge is that merge (60e6293), so CI tests it
76b119d main 677acc0 (#283 since 21e0829: lane-handoff and its test only) clean, tree 9fc8b17; refs/pull/263/merge is that merge (34f1fbb), so CI tests it
76b119d main 90eed74 (the #228 release series and #271 since 677acc0; #271 touches lanes-edit.sh and docs/README-lanes.md) clean, tree 44bfcae, and 32d75a9 is that merge
32d75a9 main a42946e (#265 since 90eed74: the shell suite and its wrapper only) clean, tree ecaeccf; refs/pull/263/merge is that merge (2df51c4), so CI tests it

Left open (outside these findings)

  • The archive reader and writer disagree about what a row is (review NIT 2). archive_rows takes the first backtick anywhere in a line as the lane, while the reader now takes it only from the first cell. Take a RETIRED register row whose lane cell lost its backticks while a later cell keeps one. archive-rows moves it, lanes lists it, and then every add-row and retired-identity refuses. That row shape is contrived, since every writer of a row puts the lane cell in backticks. Giving archive_rows the reader's rule is a candidate follow-up.

  • next-free (lane_position_rows) still skips an archive or register | line with no backtick. add-row now refuses such a position, so a retired one is still never reissued, but next-free may offer one that add-row then refuses. Applying the rule there needs care: the register carries prose tables of its own. It is a candidate follow-up.

  • The cure ensure_log names, now shared by the sweep, is "Pull first". That does not publish a rename commit that never left this checkout. lanes-edit.sh commit's publish fence refuses a rename ("a deletion (a lane rename's included)"), so that state has no one-command exit today.

  • lane-start's refusal hides retired-identity's reason (S1 above). That is lane 3's file.

  • Four defensive checks have no test that reaches them, so no mutant either:

    • the take-back's check that the line the diff names holds the row;
    • the parse's skip of git's \ No newline note, since the register always ends in a newline;
    • the kept copy's "the name is free" test;
    • the kept copy's cmp proof.

    No input reaches them short of a corrupt diff, a register whose last line lost its newline, a reused pid in the same second, or a copy cut on its way into the git directory.

  • Other diff readers on main are blind to a binary register (delta review 4, not covered here): identify_changed_lanes, warn_dirty and the commit caption. Each only loses a warning or a caption; the take-back was the one reader whose blindness published a refused row.

  • replace_line's numstat proof refuses every row rewrite (exit 5) under a core.bigFileThreshold below the register's size, as it does on main. That is every set-row-state and the sweeps' rewrites: fail-closed, and outside these findings. Giving it round 4's -c core.bigFileThreshold is a candidate follow-up.

  • One-true-awk and macOS were not run here. The new awk is POSIX; the review ran it under mawk and BusyBox awk with identical results.

Do not merge without Brett Heap's word.

🤖 Generated with Claude Code

Brett Heap and others added 5 commits October 19, 2026 12:20
…e-only rename, a closed lane's refusal, and add-row's archive scan

New tests/test_register_hardening_120_122.py, in the shape of
test_register_scan_hardening_138.py: a throwaway register in a local bare
repository, the real lanes-edit.sh against it. All 19 cases fail at
5e88a2c, each on the assertion it targets:

- #120 item 1: a register copy or rewrite cut short (a `cat` shim cuts one
  copy at half its lines) leaves a half register, committed with exit 0, or
  behind an exit 5 that calls it UNCHANGED;
- #120 item 2: a case-only log rename whose commit is not pushed passes
  retire-rows' scan, and ensure_log refuses it inside the write loop after
  the lane before it has had its log created;
- #120 item 3: the refusal for a lane whose log already closes it names no
  exit, and the one for a parked lane does not name lane-end <lane>;
- #122: an archive row whose lane cell is mangled is skipped, and an awk
  scan that failed reads as no match, so add-row reissues a retired position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd put the register back where a write cut it short (#120 item 1)

replace_line and delete_lines made every proof (line count, numstat, the
no-op) against a `cat` of the register into $pre whose status was never
read, and wrote the result with `cat -- <out> > LANES.md`, whose status was
read but not its result, while `>` truncates before cat writes. Measured
at 5e88a2c with a `cat` that cuts one copy at half its lines:

- a short COPY: the half register passed every proof and was written back,
  committed and pushed, exit 0 (set-row-state and archive-rows alike);
- a WRITE that failed part-way: exit 5 saying "its line N is UNCHANGED",
  with the register at half its bytes;
- a WRITE that came out short and said nothing: exit 0, half a register
  committed.

register_copy now compares the copy with the register, and
register_rewrite compares what landed with what was built; where it differs
the copy's bytes are put back and proved, and the refusal (5) says which of
the two the register holds, or, where even that failed, that it MAY NOW BE
TRUNCATED and how to restore it before the next write commits it as a
pre-existing edit. The status check on the write itself landed earlier
(4c2ffef, #81, for replace_line; c1bac0d, #93, for delete_lines); this
adds the proof the issue asked for, and the copy it rests on.

The header's exit-code table and the README's row 5 say so. Tests: five
cuts against each of the two acts in tests/test_register_hardening_120_122.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anded, in ensure_log's own words, before any log is written (#120 item 2)

The sweep's published-path preflight asked ensure_log's question only where
this checkout had NO log (`rr_ln2 = 0`), beside a clause comparing a
basename with a repo-relative path that could never be equal. So the state
a case-only rename leaves when its commit is not pushed (the row's spelling
here, committed; the old spelling on origin; a checkout merely ahead) passed
the scan, and ensure_log refused it inside the write loop, after the lanes
before it had had their logs created and appended: measured at 5e88a2c,
exit 2 with lanes/log/repoX-2.md left behind for a sweep of repoX-2 and
repoX-3.

The preflight is now ensure_log's test (the published path is not the
canonical one, and this checkout's file is not the one it names) and carries
ensure_log's sentence, so the two refusals read the same. The README's
sweep section names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…D and names archive-rows, and sends a running or parked one to lane-end (#120 item 3)

The rule stays clause (c)'s, as the issue declined to change it: a log that
carries a lane-kind line refuses the sweep. The sentence now follows the
LAST lane-kind line. ENDED or RETIRED: the lane is already CLOSED and hidden
(19(b)), its log is append-only, and `lanes-edit.sh archive-rows <repo>`
takes rows whose cell says RETIRED out of the register (19(d)); `lane-end`
is not named, because it is not the exit for a lane that has ended.
STARTED, PAUSED or RESUMED: "End it as itself when its work is done:
lane-end <lane>". At 5e88a2c both read the same sentence and named neither
exit.

The shell suite's pinned fragments ("RETIRED line", "ENDED line",
"carries a PAUSED line") are kept. The README's sweep section says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and an archive scan that failed, instead of reissuing a retired position (#122)

retired_identity_hits skipped every `|` line of the archive with no
backticked token, took the first backtick anywhere in a line as its lane,
and returned the status of the `sort -u` behind its awk. So a retired row
whose lane cell a hand edit had mangled, or an awk that failed, read as "no
archived row matched", and add-row reissued the position (measured at
5e88a2c: exit 0, the row pushed).

Every `|` line is now the rule, the header (first cell `lane`, as
archive_rows writes it), or a row whose first backticked token lies wholly
inside its first cell. Any other line is named by its line number and
add-row refuses with 1, the code it uses for an archive it could not read;
retired-identity and the after-rebase rescan fail closed the same way. The
header is found by its first cell and not by a ` | ` count (it carries six,
like a row) nor by the rule below it (the live register has rows between
its header and its rule). Dry-run against the live register's 75 `|` lines:
its header, its rule and all 73 rows pass, none refused. The scan's own
status is now read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 6 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens register and archive safety checks by proving file copies and rewrites, refusing unsafe log-rename and closed-lane retirements before writes, and making malformed or failed archive scans fail closed, with corresponding documentation and focused regression tests.

Sequence diagram for fail-safe register rewrite

sequenceDiagram
    participant Caller
    participant replace_line_or_delete_lines
    participant register_copy
    participant register_rewrite
    participant LANES_FILE

    Caller->>replace_line_or_delete_lines: replace_line() / delete_lines()
    replace_line_or_delete_lines->>register_copy: register_copy(pre, refusal)
    register_copy->>LANES_FILE: cat copy
    register_copy->>LANES_FILE: cmp copy
    alt copy cannot be proved
        register_copy-->>Caller: refuse exit 5
    else copy proved
        replace_line_or_delete_lines->>register_rewrite: register_rewrite(pre, out, refusal)
        register_rewrite->>LANES_FILE: cat out
        register_rewrite->>LANES_FILE: cmp landed with out
        alt rewrite differs or is truncated
            register_rewrite->>LANES_FILE: cat pre
            register_rewrite->>LANES_FILE: cmp restored with pre
            register_rewrite-->>Caller: refuse exit 5 with register state
        else rewrite proved
            register_rewrite-->>Caller: success
        end
    end
Loading

Flow diagram for archive validation before add-row

flowchart TD
    A[add-row] --> B[retired_identity_hits]
    B --> C[archive_text]
    B --> D[archive_local_text]
    C --> E[RETIRED_ROWS_AWK]
    D --> E
    E --> F{Every pipe line is readable?}
    F -- No --> G[Refuse exit 1 and name invalid lines]
    F -- Yes --> H{Lane is archived?}
    H -- Yes --> I[Refuse: retired identity cannot be reused]
    H -- No --> J[Continue add-row]
    E --> K{Archive scan succeeded?}
    K -- No --> G
Loading

Flow diagram for retire-rows preflight safety

flowchart TD
    A[retire-rows] --> B[Scan each lane]
    B --> C{Last log lane-kind line}
    C -- ENDED or RETIRED --> D[Refuse: lane already CLOSED]
    C -- STARTED, PAUSED, or RESUMED --> E[Refuse and direct to lane-end]
    C -- No lane-kind line --> F[Check published log identity]
    F --> G{Unpushed case-only rename?}
    G -- Yes --> H[Refuse and request pull --rebase]
    G -- No --> I[Continue preflight]
    I --> J[Write logs and register]
Loading

File-Level Changes

Change Details Files
Harden register rewrites against short or failed copies and writes, restoring the prior bytes when possible before refusing.
  • Added shared copy verification with byte-for-byte comparison.
  • Added rewrite-result verification and put-back proof for truncated writes.
  • Added explicit exit-5 diagnostics and recovery guidance when restoration fails.
  • Applied the safeguards to both line replacement and row deletion.
lanes-edit.sh
docs/README-lanes.md
tests/test_register_hardening_120_122.py
Move retire-rows log-rename validation into the preflight scan and improve lane-state-specific refusals.
  • Replaced the ineffective published-path condition with the same case-rename test used by ensure_log.
  • Prevented partial log creation or mutation before rejecting an unpublished case-only rename.
  • Distinguished already-closed ENDED/RETIRED logs from active or paused logs in refusal messages.
lanes-edit.sh
docs/README-lanes.md
tests/test_register_hardening_120_122.py
Make archive parsing fail closed when archive lines are malformed or the scan itself fails.
  • Added a POSIX awk parser that recognizes only the archive rule, header, or rows with a backticked lane in the first cell.
  • Reported malformed archive lines with line numbers and propagated scan failures instead of treating them as no matches.
  • Applied the behavior to retired-identity checks, add-row preflight, and post-rebase rescans.
lanes-edit.sh
docs/README-lanes.md
tests/test_register_hardening_120_122.py
Document the new refusal behavior and add focused regression and mutation coverage.
  • Updated exit-code and operational documentation for rewrite proofs, preflight refusals, closed lanes, and malformed archives.
  • Added 21 tests covering short copies, short writes, failed restoration, rename preflight, lane-state messaging, malformed rows, and awk failures.
  • Validated the new tests against the base and head, with the described full-suite and mutant results.
docs/README-lanes.md
tests/test_register_hardening_120_122.py

Assessment against linked issues

Issue Objective Addressed Explanation
#120 Make both register rewrite paths prove that the source copy is complete and that the rewritten bytes landed; if a rewrite is short or fails, restore and verify the original bytes where possible, and refuse rather than publish a truncated register. ✅
#120 Strengthen retire-rows’ published-path preflight to use ensure_log’s case-insensitive rename test, so an unpushed case-only log rename is refused during the preflight before any lane logs are written. ✅
#120 Keep the existing rule that refuses a lane whose log carries a lane-kind line, but tailor the refusal to the log’s last lane-kind event: direct already-closed lanes to archive-rows and running or parked lanes to lane-end. ✅
#122 Make add-row refuse when an archive table line cannot be parsed as a header, rule, or row with a backticked lane token in its first cell, without refusing on the archive’s own header or rule. ✅
#122 Preserve and act on the archive scan’s failure status instead of treating a failed scan as no matching retired row. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…ter harness rather than a copy of it

SonarCloud's quality gate on #263 failed on duplication alone: 72 of the
519 new lines (13.9%, gate 3%), every one of them in the Register class this
file copied from its neighbour. It now subclasses that class, as
test_lane_start_hardening_138 imports its Sandbox, and adds only what these
cases need: thirty rows of another repository below repoX's, the published
read, and the cat and awk seams. No case changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 19, 2026 15:11
…k, register refusals name what is already on disk, and a lane is called CLOSED only from a published closing line

The independent Opus review of #263 returned FIX: one MUST, two SHOULDs and
seven NITs.

M1. add_row_retirement_rescan only took the committed row back on a
retired match. When the rescan after `pull --rebase` refused for any other
reason (an archive it could not read, or a peer's archive line it could not
take apart), it returned 1 and left the add-row commit at HEAD, and the next
unrelated write from this checkout published the refused row. Every refusal
there now runs the same compensation: the exact appended line is removed in
a local commit. The new test is in repo19race's shape: a peer publishes a
mangled archive row during add-row's pull. add-row exits 1, the row is gone
from the local HEAD and from origin, and a later set-row-state publishes
nothing of it.

S2. register_copy's refusal said "Nothing was written" after retire-rows
had appended a log and archive-rows had created the archive. It now says
nothing has been committed, and lists what the act already wrote in this
checkout: the sweeps name their writes with reg_wrote. retire-rows calls a
lane CLOSED only when origin's log ends on the closing line. A closing line
that only this checkout's log carries, such as an earlier sweep's that
stopped part-way, is refused as exactly that.

S1. One unparseable archive line makes `retired-identity`, and so every
`lane-start`, refuse, and lane-start (lane 3's file, unchanged here) hides
the reason. The README says so, and how to see the line.

NITs:
- 1: the README says CRLF archives and space-less tables are refused.
- 3: add-row's own refusal is asserted. "As it was" has its own message,
  apart from the put-back and the truncation, with an unwritable-register
  case for both acts. archive-rows is matched exactly.
- 4: each bad line names the copy it is in, published or this checkout's.
- 5: an ENDED lane is not told it "is not retired a second time", and
  archive-rows is not named as its exit.
- 6: where the register MAY NOW BE TRUNCATED, the exact copy from before
  the edit is moved out of the trap's reach and its path named.

Lock lines are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved past this branch's base 5e88a2c: #259, #260, #261 and #246.
#246 touches lanes-edit.sh and docs/README-lanes.md too. The merge is
clean, the lock lines are main's own, and lanes-edit.sh parses under
bash 3.2.57 and 5.2. CI tests the moved main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title The register's rewrites refuse a copy or write they cannot prove and put a cut-short register back, retire-rows refuses an unpushed log rename before it writes and tells a closed lane it is closed, and add-row refuses an archive row it cannot read (#120, #122) The register's rewrites refuse a copy or write they cannot prove and put a cut-short register back, retire-rows refuses an unpushed log rename before it writes and calls a lane closed only from its published log, and add-row refuses an archive row it cannot read and takes back a row refused after its rebase (#120, #122) Oct 19, 2026
Brett Heap and others added 3 commits October 19, 2026 17:40
…ut adds, refusals say this act committed nothing, and the kept register copy lives in the git directory

The delta review of #263 at 42e73e5 returned FIX: one MUST and three NITs.

D1. add_row_retirement_rescan took the rejected row back by its exact
text. Suppose a peer publishes the byte-identical row during add-row's
`pull --rebase`. The rebase drops add-row's commit as already upstream,
and the copy left is the peer's. The take-back deleted it, and the next
write published the deletion, so the peer's row was gone from origin.
The take-back now removes only the copy that this checkout's register
adds over origin's. It reads that copy from their -U0 diff, by
content, so the check holds under the sha the rebase gave the commit.

- None added: nothing is taken back, and the refusal code is returned.
- One added: exactly that line, and it is checked first.
- More than one, or an unreadable diff: nothing is taken back, and the
  refusal says so.

"origin already has the row" would be wrong the other way. A commit that
survived beside a peer's identical row at another place would be kept,
and the next write would publish a second copy. The retired-match branch
shares the path.

NITs:
- A refusal now says "this act has committed nothing". handle_preexisting
  may already have captured a peer's edit as a commit of its own. Where
  add-row's take-back cannot rewrite the register, the refusal instead
  says that add-row HAS committed its row, and how to remove it.
- "an ENDED line", not "a ENDED line".
- The kept copy of the register goes to the checkout's own git
  directory, as lanes-edit-register-before-edit.<UTC>.<pid>. It is
  written with mode 0600 under noclobber and proved with cmp. Only where
  that cannot be done is it renamed beside $TMPD, and the refusal then
  says a restart would lose it.

Lock lines are untouched; lane-start and lane-handoff are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the take-back's message

Both D1 cases now run the next unrelated write and count the published
repoX-4 rows before they read add-row's stderr. A run against 42e73e5 is
then red on what the review measured, the peer's row gone from origin or
add-row's commit published as a second copy, rather than on a sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved from 55aae16 to 00d52ae with #252 (#113, #114, #115): lifecycle
reads that fail are no longer answers, set-lane-tree's fence closes with
its operation, and a lane sidecar is valid only when whole. The merge is
textually clean. #252 changes the lifecycle sidecars, lane_reconcile,
lane_tree_now, set-lane-tree and lane-state; this branch changes the
register's rewrites, retire-rows, the archive scan and add-row's
after-rebase take-back. Neither reads or writes the other's records, and
no refusal code or README row both touch now contradicts the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 19, 2026 21:27
…r context the diff carries, and its refusals speak add-row's own words

Delta review 3 of #263 (d0256ff) returned FIX: one SHOULD and one NIT.

S3. The take-back read its row's line from `git diff -U0` and counted
only `+` lines. -U0 does not hold on its own. GIT_DIFF_OPTS=-u3 in the
environment overrides it, and a user's diff.interHunkContext fuses a
nearby change's hunk with the row's, with the lines between as context.
Either way the row was put at the wrong line, the line check refused,
nothing was taken back, and the next ordinary write published the
refused row: M1 again. Both halves of the fix are in:
- that one diff runs with GIT_DIFF_OPTS unset (inside the command
  substitution's own subshell) and -c diff.interHunkContext=0, which
  outranks every config file and GIT_CONFIG_* in the environment;
- the parse advances its new-file line on every hunk line except a
  removed one (`-`) and git's "\ No newline" note, so context that
  still arrives is counted where it stands.

The tests cover four cases:
- GIT_DIFF_OPTS=-u3;
- diff.interHunkContext=3 beside an unpushed edit one row above the end;
- context forced in by a shim;
- another lane's unpushed row just above.
Each asserts the published register after the next write, and how the
diff was called.

NIT. When the take-back's own rewrite failed, the refusal ended in
delete_lines' sentences, which are archive-rows': "the rows are in"
the archive, and "the archive may have been appended". delete_lines now
takes its caller's sentences, with archive-rows' as the default.
add-row's say its row is still committed locally and unpushed, and
that it must be removed before any other write publishes it.

Lock lines are untouched; lane-start and lane-handoff are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roves its removal whatever the register's size, and every refusal or failed commit after it says add-row has committed its row

Delta review 4 of #263 (5b460b7) closed S3 and returned FIX: one SHOULD
and two NITs.

S4. Where git classes the register as binary, the take-back's diff is
the one line "Binary files ... differ", with no hunk to read. add-row
then said this checkout added no copy of the row, kept its commit, and
the next write published the row: M1 again. The review measured eight
routes: a binary or -diff attribute, published or local, a diff driver
with binary=true, core.attributesFile, GIT_ATTR_SOURCE, attr.tree, a NUL
byte in the first 8000 bytes, and a core.bigFileThreshold below the
register's size (the live register is 2,309,050 bytes). The fix:
- (a) the take-back's diff is read with --text;
- (b, the review's N2) delete_lines' line-count and numstat refusals end
  as register_copy's does: the register as it was, what this act has
  committed, the caller's sentence and what it has already written;
- (c) delete_lines' numstat proof runs with -c core.bigFileThreshold=2g.
  Under a smaller threshold numstat printed "-" for both counts, and
  --text does not change that, so the take-back refused. A command-line
  -c outranks the global config and GIT_CONFIG_* in the environment.
An attribute on every path, or a NUL byte, still makes numstat read the
copies as binary at any threshold; the take-back then refuses (exit 5)
in add-row's words.

N1. The refusal of the pass that removes the line said "The register is
as it was: no row was removed, so that row is still in it and in that
commit", naming no lane and no commit. It takes (b)'s sentence.

N3. When the compensating git add or git commit failed, add-row said
nothing of its own, and the next write's commit carried the removal
under that write's caption. A note now says whether the removal is
staged, that add-row's commit still holds the row, and the command that
commits the removal.

Tests: three binary cases (-diff in .git/info/attributes, a published
.gitattributes with binary, core.bigFileThreshold=200), each asserting
the published register after the next write; three more ways the
take-back cannot remove its row (the pass refused, a line count off,
numstat reading binary), each asserting add-row's sentence; and the
failed stage and commit, each running the command the note gives.

Lock and sidecar lines are untouched; lane-start and lane-handoff are
untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved from 677acc0 to 90eed74: the #228 release series (the
openrepotools package, the package job in tests.yml, the release
workflow and its docs) and #271 (#255: the handoff names every inventory
record that is not a whole schema-1 one, and a leg directory this
account cannot search is unreadable, never absent). The merge is
textually clean. Of this branch's files, #271 changes lanes-edit.sh only
in lane_reconcile's unreadable-leg branch and docs/README-lanes.md only
in the triad section's leg, handoff and reconcile rows; this branch
changes the register's rewrites, retire-rows, the archive scan and
add-row's after-rebase take-back, and the README's write-failure,
sweep and archive rows. Neither reads or writes the other's state, and
no refusal code or README row both touch now contradicts the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit d9065df into main Oct 21, 2026
6 checks passed
@brettheap
brettheap deleted the fix/register-hardening-120-122 branch October 21, 2026 11:28
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…rea, and add the lines for #263, #289, #294 and #296, the changes after a42946e (#228)

`docs/release-notes/v1.1.0.md` takes the fixes of lane 3's review of d8a349d (#293 comments 6314185715 and 6314529807, the second of which replaces three of the corrected texts of the first) and the lines for the changes after a42946e.

The review's fixes:
- Line 45 (#255, #271) no longer says that `lane-reconcile` and `lane-handoff` read a dependency nested in a leg: only the sweep and `lane-end` do. It says that a leg directory this account cannot search is unreadable and never `absent`, that a nested dependency is unreadable to the sweep and `lane-end` too, and that the sweep still reads a declared leg beneath such a directory as `absent` (#272, open). It also says NOT POLLED where the tool does.
- Line 46 (#280, #283): an `awk` exits, it does not return, and the sentence on a status of 0 or 8 is gone.
- Line 61 (#214): the fence never checks whose a local commit is, only that it adds or changes a register or handoff file.
- The `<other-changes>` item counts 44 of the 53, because the openRepoShape pin's line (#233) is in the list, and the other 9, not 43 and 10. The milestone item names #247 and lists the six entries of 2026-10-20.
- #209 is not tests-only: its `-e` fixes `lanes`' stderr filter under macOS's BSD grep, so it moves from the test suite to the lane area, and its one line there carries its test changes too. It is cited once.

The lines for the changes after a42946e, each in its area and worded from its squash commit and description: #263 (d9065df) and #289 (fb2cf36) under the register and `lanes-edit.sh`, #294 (acc89ea, which closes #292) under `lane`, `lane-start`, `lane-end` and the name guard, and #296, which pins every Ubuntu runner to ubuntu-24.04, under the test suite and its checks. #296 has not landed yet: lane 3 lands it right before #293. The `<other-changes>` item says the lines were added on 2026-10-21 and are not in its counts, and the placeholder stays for #293 itself.

Not touched: lane 3's optional sentence on #271 and its NIT on the tag.

Lane: openRepoTools-2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
… 1.1.0 and list, by area and from the squash titles of aaa7bc8..a42946e, what else changed on main since v1.0.0 (#228) (#293)

Part of #228. It brings `docs/release-notes/v1.1.0.md` up to date on main: first on `a42946e`, as lane 3 asked on Brett Heap's word (#228 comment 6303608345, the section for lane openRepoTools-2, task 1), then with lane 3's review of `d8a349d` (comments 6314185715 and 6314529807 on this pull request), with the lines for the three changes that landed after `a42946e`, and with the line for #296, which landed right before this pull request. It is one file, changed in two commits (`d8a349d` and `bebb77d`), 59 insertions and 4 deletions against main.

Lands last before rehearsal D's freeze; lane 3 reviews and merges it (#228 6303608345)

**What changed**
- **Lane 3's review is applied,** in the second commit:
  - **The line on #255 and #271** no longer says that `lane-reconcile` and `lane-handoff` read a dependency nested in a leg, since only the sweep and `lane-end` do. It says that a leg directory this account cannot search is unreadable and never `absent`; that a dependency nested in a leg whose directory, or one above it, it cannot search is unreadable to the sweep and `lane-end` too; and that one known gap remains, #272, where the sweep reads a declared leg beneath such a directory as `absent`, so `lane-end` does not hold on it. It also says NOT POLLED where the tool prints it, and it says that the item closes two gaps in #246, where it said "both gaps" with nothing before it to refer to.
  - **The line on #280 and #283:** an `awk` exits and does not "return", and the sentence on a status of 0 or 8 is gone, because a reader of the notes cannot act on it.
  - **The line on #214:** the fence passes a local commit only when it adds or changes a register or handoff file, and never checks whose it is, so the line no longer says "the lane tooling's own".
  - **The counts in the `<other-changes>` item:** the pin's line (#233) is in the list, so the list carries 44 of the 53, and the other 9 have their lines under "What 1.1.0 adds", not 43 and 10. The item says "the openRepoShape pin" where the runner pin of #296 could be taken for it.
  - **The milestone item** names #247, so it lists the six entries the milestone held on 2026-10-20.
  - **#209 is not tests-only.** Its `-e` fixes `lanes`' stderr filter under macOS's BSD `grep`, so it moves from the test suite to the lane area, after #208's, and its one line there also carries its test changes: the `repoG#8` assertion and the count of bash files in prose. It is cited once.
- **Lines for the changes after `a42946e`:** #263 (`d9065df`), #289 (`fb2cf36`) and #294 (`acc89ea`, which closes #292) landed on 2026-10-21, from 11:28Z to 11:30Z. Each has a line in its area, worded from its squash commit and its description: #263 and #289 under "The register and `lanes-edit.sh`", and #294 under "`lane`, `lane-start`, `lane-end`, the name guard and their helpers".
  - **#296 has its line too,** last under "The test suite and its checks": CI and the release workflow run on `ubuntu-24.04`, because `ubuntu-latest` is now Ubuntu 26.04, and `macos-latest` is unchanged. It landed right before this pull request, as `5afba4a`.
- **"What 1.1.0 adds" gains two lines,** after the annotated-tags item. They are for the three entries that closed in the v1.1.0 milestone after the draft was written:
  - **#255,** fixed by #271: the lane tools name what they could not read in a triad's legs. `lane-handoff` names every tree-inventory record that is not a whole schema-1 one, and a leg directory this account cannot search is unreadable and never `absent`. Only the sweep and `lane-end` also see a dependency nested in such a leg, since `lane-reconcile` and `lane-handoff` read declared legs only, and #272 stays open as the known gap.
  - **#280,** fixed by #283: `lane-handoff` reads the lane's lifecycle whole and parses it afterwards, so an `awk` that exits early can no longer turn `lane-state`'s SIGPIPE into a lifecycle that could not be read.
  - #283 is #280's pull request, so the three numbers make two lines. Each line cites the issue and its pull request, as the hook-pair line does for #237 and #247.
- **"What else changed since v1.0.0" is drafted** from `git log aaa7bc8..a42946e`, which is 53 squash commits, one pull request each and no merge commit. It is grouped by area, one line each, and each line ends with its pull request. It lists 44 of the 53: the pin's line (#233) at its head and 43 by area, as 4 on the estate commands, 10 on the register and `lanes-edit.sh`, 11 on `lane`, `lane-start`, `lane-end` and the name guard, 6 on the handoff and its skill, 1 on the lane tools in a triad's legs (#246), 9 on the test suite and its checks, and 2 on the triad migration's records.
  - **The other 9 are accounted for elsewhere:** the package series of #228 (#262, #267, #268, #269, #270 and #284) is covered by #228's lines, and #247, #271 and #283 have the lines above.
  - **The lines added on 2026-10-21** are for the four changes after `a42946e`: #263 and #289 on the register and `lanes-edit.sh`, #294 on `lane`, `lane-start`, `lane-end` and the name guard, and #296 on the test suite and its checks. The areas now hold 47 lines, one for each of 47 pull requests, and #233's line at the head of the list makes 48.
  - **The list is redone at the tag.** It is drafted from the squash titles only.
- **"Before the tag" is updated in three items:**
  - `<pr-count>` gives the count at `a42946e`, which is 53;
  - `<other-changes>` says what was drafted, which 9 pull requests have their lines under "What 1.1.0 adds", that the lines for #263, #289, #294 and #296 were added on 2026-10-21, and what is left to do at the tag; the placeholder's own line now stands for the changes after `a42946e` that have no line above;
  - the milestone item names the six entries the milestone held on 2026-10-20: #228 still open, #237 closed by its pull request #247, #255 closed, and #280 closed by its pull request #283.

**Not added:** nothing that has landed. #296 landed right before this pull request and has its line. This pull request itself, and whatever lands after it, is what the `<other-changes>` placeholder stands for. None of #263, #289, #294, #296 and #292 is in the v1.1.0 milestone, which still holds the six entries of 2026-10-20.

**How this keeps the draft's rules**
- **One-line paragraphs:** every added line is its own list item, and none is wrapped.
- **References:** every reference above "Before the tag" is spelled `opensoft/openRepoTools#N`, with no bare `#N`, since the notes are published from another repository after the migration.
- **Placeholders:** `<other-changes>` stays as a named placeholder in code font, as the closing line of the list and as its checklist item. `<pr-count>` is unchanged apart from the drafting-time count.
- **The file's closing lines are untouched:** the self-naming last line stays last.

**Tests**
- **T023's verify command** passes: the last line names `openRepoTools-spec` and `docs/release-notes/v1.1.0.md`, `## Before the tag` is there, and so is `pipx install --force openrepotools`.
- **Coverage:** a script over `git log aaa7bc8..acc89ea`, 56 commits and no merge, finds each pull request cited above "Before the tag" except the six of #228's package series, which #228's lines cover. That is 46 by area, #233 at the head of the list, and #247, #271 and #283 under "What 1.1.0 adds". #296 is not in that range, and its line cites it.
- **Hygiene:** by grep over `tests/`, no test names this file. The two that read its content scan every tracked file, and both are in `tests/test_repo_hygiene.py`. The only other test that names `release-notes` is in `tests/test_release_workflow.py`, and it reads `release.yml`, not the notes. All of `tests/test_repo_hygiene.py` was run on the file, through `tests/run.sh` with every `LANES_*` and `TMUX*` variable unset:

  ```text
  -k 'repo_hygiene' -v
  collected 2933 items / 2696 deselected / 237 selected
  237 passed, 2696 deselected in 2.59s
  ```
- **Not run:** the whole suite. CI's `tests` job is the suite of record. This pull request's own runs on `bebb77d` went red on `ubuntu-latest`, which is Ubuntu 26.04, for the reasons #296 gives, because #296 was not yet on main. The record is lane 3's queue run over main, #296 and `bebb77d`, on `ubuntu-24.04` (below).

**Where the draft's rules left room, and lane 3's answers (comment 6314185715)**
- **Which section holds #255 and #280:** "What 1.1.0 adds", because the milestone is Brett Heap's scope for the release and #237 with #247 already sit there.
- **`<other-changes>` stays** as a closing placeholder after the drafted list, since this pull request itself lands after `a42946e`.
- **How much to list:** the tests-only and migration-record lines stay through the tag, because the check that every pull request has a line depends on them. If Brett Heap wants a shorter body, they fold into one line that still lists every pull request number.
- **Issues and pull requests:** the milestone lines cite both, and the area lines cite the pull request only.

**Lane 3's re-check of `bebb77d`** (#293 6315668503) is MERGE AFTER FIXES.
- **What holds:** every line lane 2 listed, the recount behind line 140 (44 of the 53 pull requests in `aaa7bc8..a42946e`, and 56 in `aaa7bc8..acc89ea`), the file's rules, and T023's verify commands.
- **One SHOULD and two NITs, all wording:**
  - line 45's sentence on a dependency nested in a leg, where "one above it" reads as a directory above the leg, which is #272's case. The text was lane 3's own;
  - "One known gap" in the same line;
  - line 74's last clause.
- **Why they wait:** lane 3 merges the notes as tested and carries the three fixes into the notes' final pass at the tag. A push here would cost another CI round while lane 2 is out of usage until 15:30Z, and the notes are edited again at the tag in any case.

Lane: openRepoTools-2

Landed by lane openRepoTools-3, as queue prefix `lane3-queue-u/p2-293` (tree `9b879ca`; tests.yml run 39595166877, green on `ubuntu-24.04`), right after #296. Before the landing, lane 3 merged main into the branch, so that its own checks ran over main with #296; the content is unchanged. Brett Heap's word, of 2026-10-20T22:5xZ: "merge all when green for all merges thru v1.1.0. use this for all lanes" (#228 6303614082).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…scripts, each tool pinned by version and sha256, in a workflow of its own (#277)

This completes the WIP commit b1c59e6, which added lint.yml alone. It is a
branch for v1.2.0, with no PR and no label, and the job has not yet run on a
GitHub runner: every result below was taken on one machine with the pinned
tools, so the CI record is still to come.

No job ran either tool, so a warning on a workflow or a shell script showed
nowhere. Lane openRepoTools-2's review of #228's release.yml found three
mutants that actionlint's shellcheck rule flags and the suite lets through
(I4, I13 and N3), and nothing in CI would have said so.

What changed
- .github/workflows/lint.yml, one job `lint`, in a file of its own. tests.yml
  is the release gate's file: release.yml requires exactly its four jobs,
  tests/test_tests_workflow.py holds that, and rehearsal D rewrites it for the
  code leg. The job runs on pull requests, on main and release/v*, and by
  dispatch, and it is no part of the gate. It runs on ubuntu-24.04, as every
  other ubuntu job does since #296 (ubuntu-latest became Ubuntu 26.04 on
  2026-10-21).
- actionlint 1.7.12 and shellcheck 0.11.0, each a release archive checked with
  sha256sum --strict -c before it is unpacked. Each digest was computed from
  the downloaded archive and equals GitHub's asset digest, and actionlint's
  also equals upstream's checksums.txt. Neither tool comes from the runner
  image or from a package index, and both are static binaries.
- actionlint runs over every workflow with the pinned shellcheck given by
  path, with no exclusion, no severity floor and no config file, and the step
  fails if actionlint reports its shellcheck rule disabled, which it does
  silently when it cannot start the binary.
- shellcheck runs, one file at a time, on every tracked file whose first line
  is a bash or sh shebang, at the baseline below. That is 16 files on main,
  and 15 of them run.
- tests/test_lint_workflow.py (17 tests, 21 cases) pins the file and runs its
  three steps under fakes instead of only reading them: the install step over
  a genuine archive, over a tampered archive of each tool (nothing is
  unpacked, and after a bad actionlint archive the shellcheck one is never
  asked for) and over a tool that says another version; the actionlint step
  under a fake actionlint (a finding, a disabled shellcheck rule, a
  shellcheck that is not the pinned one); the shellcheck step in a throwaway
  git repository (selection by shebang, the baseline's flags, every file
  checked after one fails, an empty selection fails). The header's table of
  codes is held to the step's list, and the baselined scripts are held to be
  tracked bash files.

What the tools found on main a42946e
- release.yml, tests.yml and lint.yml: nothing, at any severity.
- The 16 shell scripts: no error. park, resume and status: nothing at all.
  The other twelve: 319 findings, 190 warnings and 129 notes.
  Warnings: SC2034 157, SC2088 14, SC2209 4, SC1007 4, SC2154 3, SC2069 2,
  SC2178 2, SC2128 2, SC2141 1, SC2318 1. Notes: SC2016 55, SC2018 18,
  SC2019 18, SC2295 11, SC2329 10, SC2086 5, SC2015 5, SC2094 4, SC2020 1,
  SC2012 1, SC2006 1. lanes-edit.sh holds 142 of the 319.
- tests/test_lane_helpers.sh, which the job does not run: 43 warnings and 218
  notes, and 16.2 GB of memory to find them.
- Two of the warnings are latent bugs and not style: SC2141 in
  lanes-edit.sh's session_start_block (IFS='\t' is a backslash and a t, so a
  live-fork row id<TAB>4242<TAB>interactive reads as id<TAB>4242<TAB>in,
  erac and ive) and SC2318 in openRepoTools's wip_fetch_template_file (bash
  expands $rel before the local that sets it takes effect, so path carries
  the caller's rel, which is right only because wip_init calls it from a loop
  over its own rel). Neither is fixed here.

Main has moved since, and the baseline holds on it. On main 50d05d4 (after
#263, #289, #294, #296 and #293) the job's three steps, run as written over
that tree, exit 0: actionlint finds nothing in release.yml, tests.yml and
lint.yml, and shellcheck finds the same 190 warnings, in the same ten codes
and the same counts, no error, and 132 notes (lanes-edit.sh gained two SC2016
and one SC2015). lanes-edit.sh peaks at 10.9 GB and takes about 90 to 130 s,
which a 16 GB runner holds.

What the baseline leaves out
- The workflows: nothing.
- park, resume, status and every script added later: nothing.
- The other twelve scripts are held to --severity=warning less ten codes, so
  the 129 notes and the 190 warnings of SC1007, SC2034, SC2069, SC2088,
  SC2128, SC2141, SC2154, SC2178, SC2209 and SC2318 are left out, and any
  error or warning of another code fails. Each code, its count and the reason
  it is left out are in lint.yml's header.
- tests/test_lane_helpers.sh is not run: it is the lane helpers' own suite,
  nothing ships it, and shellcheck needs 16.2 GB for it (measured), more than
  a 16 GB runner has. lanes-edit.sh is run: 10.5 GB and about two minutes.

The three mutants, applied to release.yml in scratch copies (lane 2's own
diffs), and run through the job's own actionlint step, on a42946e's file and
again on 50d05d4's
- I4 (3.2.*) made *)): SC2221 and SC2222, warning.
- I13 (the link fingerprint check made `if false`): SC2034, warning.
- N3 (exit 0 as the clean step's first line): SC2317, info.
Each makes the step exit 1. N3 is only info, which is why the workflows have
no severity floor.

Tests, run only through tests/run.sh -k
- -k 'test_lint_yml or test_no_committed_file_names_a_host_absolute_path or
  test_adoption_act_zero_is_cited_by_the_sha_that_landed': 23 passed. The
  last two scan every tracked file, so they read lint.yml and the new test.
- Before the workflow existed (lint.yml set aside), -k test_lint_yml: 19
  errors and 1 failure of its 21 cases. The 21st reads only tracked files and
  passed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment