Skip to content

shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) - #1270

Open
brettheap wants to merge 23 commits into
mainfrom
fix/1163-agents-md-symlink

Conversation

@brettheap

@brettheap brettheap commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Was stacked on #1159. This branch started at #1159's head 5370b018, since both PRs change _leg_by_agents_md and _read in scripts/shape_advisory.py. #1159 has landed as squash 46fe6ac1, and the branch is restacked onto main by merges, with no rebase (see Restacked after #1159 landed). The diff against main is now this PR's change alone.

Closes #1163

Outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0").

What

adopt-project.py plan and check ask the Triad advisory about the commit their plan is written against. In a commit, a symlink is a blob that holds the PATH it points to. _leg_by_agents_md read that path as the file's text and found no leg's opening in it. So both commands said "This is a single repository" over a leg whose AGENTS.md is a link, whether or not the target could be read. The doctor reads the disk, where the operating system follows the link, so it called the same checkout the leg.

Measured on base 5370b018 and head 2abd8176, with Python 3.12.3 and git 2.43.0, in the probe environment. Each fixture is a clone of acme/Orbit in a folder Atlas, with the link committed. Each fixture got plan --source <Atlas> --project Orbit --org acme ..., then check --plan, then shape-doctor.py --root <Atlas> --json. The "leg's file" is templates/spec-root/AGENTS.md, rendered as the scaffold renders it.

AGENTS.md (committed) base: plan and check head: plan and check head: read(commit=) the doctor (disk), base and head alike
-> docs/AGENTS.md, the leg's file triad advisory the leg's NOTE leg-clone the leg
the same, docs/ at 000 triad advisory the leg's NOTE leg-clone no Triad row; the way in offers nothing (#1159)
the same, the file at 000 triad advisory the leg's NOTE leg-clone no Triad row; the way in offers nothing
-> ../locked/AGENTS.md, the leg's file beside the repository triad advisory nothing not-answered the leg
the same, by its absolute path triad advisory nothing not-answered the leg
the same, locked/ at 000 triad advisory nothing not-answered no Triad row; the way in offers nothing
-> ../nowhere/AGENTS.md (dangling, out of the commit) triad advisory nothing not-answered the advisory
-> docs/AGENTS.md, not committed (dangling) triad advisory triad advisory advise the advisory
-> AGENTS.md (loop) triad advisory triad advisory advise the advisory
-> docs, a directory triad advisory triad advisory advise the advisory
-> docs/AGENTS.md, a file that is no leg's triad advisory triad advisory advise the advisory
a regular file, the leg's (control) the leg's NOTE the leg's NOTE leg-clone the leg
none: a single repository (control) triad advisory triad advisory advise the advisory
  • No exit code changes. plan exits 0 in all 13 cases, on base and head. check exits 1 in 10 (its own plan-root-symlink finding over the link) and 0 in 3, the same on both.
  • The doctor's rows are the same on base and head in all 13: the Triad row, the way in, the other way in and what is here (compared on status, the first 110 characters of the reason, and the first 60 of the NEXT). The doctor reads the disk, and this PR changes no disk reading.
  • "Nothing" means no triad line and no NOTE: not-answered is silent in _print_triad_advisory.
  • Since r1163, a link spelled with a . segment is the leg too. -> ./docs/AGENTS.md, docs/./AGENTS.md and .//docs/AGENTS.md gave the triad advisory at 2abd8176, and give the leg's NOTE at 0f8dae6e (see Review round).

Why

The decision: option (b). The commit's link is followed inside the commit, and option (a) applies only to a link that leads out of it.

Rejected:

How:

  • Tree.is_link(rel) checks whether git ls-tree --full-tree <commit> -- <rel> gives mode 120000 (LINK_MODE). It is never asked of the disk, where the operating system follows a link anyway. A git that cannot answer makes it no link, so a regular file's reading stays byte for byte what it was.

  • Tree.text_through(rel) uses git's own resolution: git cat-file --batch-check --follow-symlinks, fed <commit>:<rel> on stdin. That resolution follows each link from the directory it is in, through a linked folder too (docs -> .agents), up to 40 links. But git 2.43 looks a . segment up as a name, which no tree holds, so it answers dangling for ./docs/AGENTS.md, docs/./AGENTS.md and .//docs/AGENTS.md, where open() reads the file (r1163). Where git says dangling, the link's own target is read (cat-file blob <commit>:AGENTS.md). If it is relative, has no line break, carriage return or NUL, and holds a . segment before its last, it is asked again with those segments dropped (_dot_segments_dropped). A last . or / is kept, so a link through a file stays notdir. A cleaned target that begins with .. is out of the commit, as git answers ../x; git is never asked a name that begins ../, which it reads from the working directory and refuses (exit 128, measured). Its answers map as follows:

    It goes through subprocess.run with input= because repo_shape.git_out takes no stdin, and repo_shape.py is a pinned shape copy that must not grow a helper.

  • _leg_by_agents_md asks is_link first. On a link it reads text_through. Otherwise it reads tree.text(AGENTS_MD), exactly as before. Tree.text keeps its one argument. The first cut passed text(..., follow=True), and shape_advisory.py and shape-doctor.py: a leg's AGENTS.md this user cannot read leaves whether it is a leg unknown, so the way in offers neither command and points at what is here, and the other way in and the Triad row are silent (#1110) #1159's row test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice, which stands in for Tree.text with def text(self, rel), then read TypeError where it asks for Refusal. Commit 2abd8176 moves the choice to the caller and changes no behaviour (the 13-case probe is identical at both commits).

  • In a partial clone, plan now fetches the link's target as well. check and execute fetch nothing (_lazy_fetch_off): the new git calls inherit the environment, as git_out does. Measured in a blob:none clone at 0f8dae6e: promisor packs 1 -> 3 with GIT_NO_LAZY_FETCH unset, the reading leg-clone; 1 -> 1 with it set, not-answered (Refusal). A link spelled ./docs/AGENTS.md measures the same.

  • Only the commit reading changes. shape-doctor.py and adopt-project.py are untouched.

Tests

tests/test_advisory_agents_md_symlink.py is new, with 27 rows: the first round's 16, below, r1163's 9 (see Review round) and r1163b's 2 (see Review round 2). It imports its helpers from the files that own them: #1159's no_workspace, symlink and NO_LEG_WAY_IN, and test_shape_advisory.py's adopt_plan, doctor_json, commit_all and rendered_leg_agents.

The first round's rows:

row base 5370b018 head 2abd8176
..._linked_to_its_own_file_is_the_leg_in_the_commit[beside], [through-a-linked-folder] 2 failed 2 passed
..._linked_into_a_folder_nobody_may_search[in-the-repository], [beside-it] (mode, CANNOT_RUN_AS_ROOT) 2 failed 2 passed
test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused (stand-in) failed passed
..._linked_out_of_the_commit_is_not_answered[relative], [absolute], [dangling] 3 failed 3 passed
test_a_git_that_cannot_follow_the_link_is_silence_not_advice (stand-in) failed passed
CONTROL ..._by_its_name_is_one_while_its_agents_md_links_out_of_the_commit passed passed
CONTROLS ..._linked_to_no_legs_file_is_advised_by_both_readers[dangling], [loop], [a-directory], [through-a-file], [another-file] 5 passed 5 passed
CONTROLS test_a_regular_leg_agents_md_and_a_single_repository_are_as_before passed passed
total 9 failed, 7 passed 16 passed

To get the base numbers, the base's scripts/shape_advisory.py (blob ae535edc) was laid into a scratch clone. Every base failure is the defect itself: the commit reading said advise (AssertionError: {'state': 'advise', ...}). The mode rows' doctor assertions, which come before the reading's, pass on base. The clone was then restored, and its file's blob equals HEAD's.

Mutants, first round: 14, 12 killed. Each ran alone in a scratch clone at 2abd8176, over the new file (-x). After each one, the file was restored with git checkout HEAD -- and its blob proved equal to HEAD's (a0b4ab7c).

id mutant first row failed
M1 _leg_by_agents_md reads tree.text(AGENTS_MD) alone (no link branch) ..._own_file_..._in_the_commit[beside]
M2 is_link drops its "no commit, no link" guard [beside]
M3 is_link inverted [beside]
M4 text_through takes a failed follow for success [beside]
M5 a link out of the commit is read as nothing there (return None) ..._nobody_may_search[beside-it]
M6 the blob check inverted [beside]
M7 the name not asked over a link out of the commit CONTROL ..._by_its_name_..._links_out_of_the_commit
M8 no not-answered over a link out of the commit (falls through to the advice) [beside-it]
M9 the why reworded ..._out_of_the_commit_is_not_answered[relative]
M10 is_link says "link" where git cannot answer survived: see below
M11 --full-tree dropped survived: see below
M12 the symlink answer check inverted [beside]
M13 only a directory counts as no file, so dangling and the rest are read as blob ids CONTROL ..._advised_by_both_readers[dangling]
M14 the followed read takes the link's own blob (<commit>:<rel>) [beside]
  • M10 is equivalent through plan and check, though not through read() itself (r1163's NIT-1). Measured again at 0f8dae6e with r1163's m10.py, it differs from the head in four readings: where every git call fails, whatever AGENTS.md is (a regular leg file, a link, or none; head advise, M10 not-answered), and where only ls-tree fails, over a link to the leg (head advise, M10 leg-clone). None is reachable through the tools. Both build their source with git show-ref (Source._at) before they print the advisory, so a git that fails every call stops them first, over a link as over a regular file or none; plan also lists the commit with git ls-tree -r (Source.tree) before it; and the last needs a git that fails ls-tree and then answers cat-file in the same repository. The fallback is kept on purpose: a git that cannot say leaves the reading exactly as before.
  • M11 is equivalent through read(). _read answers not-a-repository unless <root>/.git exists, so cwd is always the root, and ls-tree's paths are then root-relative with or without the flag. The flag keeps is_link naming the same entry as <commit>:<rel> for a caller that builds a Tree elsewhere. No caller in this repository does.

The gate, call by call, Python 3.12.3, in the probe environment, one basetemp per call, at most 2 at once. The branch is restacked onto main 46fe6ac1. Calls 1, 3 and 4, and the rows and mutants of Review round 2, ran on 8e54c32d's files (r1163b's fixes). Calls 2 and 5 to 8 ran at 0f8dae6e and were not re-run: 8e54c32d changes the new test file, and in scripts/shape_advisory.py one docstring and no code.

call result
1. tests/test_advisory_agents_md_symlink.py, tests/test_doctor_unsearchable_links.py, tests/test_shape_advisory.py 123 passed (27, 49 and 47) in 91.18 s, at 8e54c32d (121 in 125.80 s at 0f8dae6e)
2. tests/test_adopt_single_repository_record.py, test_bom_reads.py, test_bootstrap_non_utf8_branch.py, test_doctor_gitmodules_not_utf8.py, test_doctor_unreadable_paths.py, test_doctor_validator_verdicts.py, test_doctor_next_is_a_command.py 285 passed in 305.95 s
3. tests/test_scaffold_leg_path_collisions.py, tests/test_path_guard_hygiene.py, tests/test_no_auto_maintenance.py 283 passed, 2 skipped in 30.50 s, at 8e54c32d (34.55 s at 0f8dae6e)
4. tests/test_agents_md_codes.py, tests/test_cli_reference.py, tests/test_repo_hygiene.py 171 passed in 36.76 s, at 8e54c32d (43.43 s at 0f8dae6e)
5. tests/test_shape_doctor.py, shard 1 of 4 (113 ids) 113 passed in 182.76 s
6. tests/test_shape_doctor.py, shard 2 of 4 (113 ids) 113 passed in 248.10 s
7. tests/test_shape_doctor.py, shard 3 of 4 (113 ids) 113 passed in 225.71 s
8. tests/test_shape_doctor.py, shard 4 of 4 (110 ids) 107 passed, 3 skipped in 83.21 s

Shard 4's 3 skips are tests/test_shape_doctor.py:10516: "the PowerShell line is printed, and powershell.exe exists, only on a Windows host". The 2 in call 3 are tests/test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one". The probe environment sets GIT_CONFIG_GLOBAL=/dev/null.

The first round's own gate, at 2abd8176 before the restack, gave the same counts but for the new file's 16 rows (112 in the first call). At its first cut, 60c41528, the first call gave 1 failed, 111 passed. The failure was #1159's test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice (TypeError for Refusal), which 2abd8176 mends (see Why). The 3.14-like shim was not run in any of the three rounds (r1163b ran it at 0f8dae6e: 74 passed over the new file and test_doctor_unsearchable_links.py). The change asks no is_file, exists or stat: the disk path is unchanged, and the commit path is git alone.

Restacked after #1159 landed

#1159 landed as squash 46fe6ac1 (S). Its final head was 9e4aab8c (F), update-branch's merge of main 12122952 into 5370b018, the commit this branch was built on. The branch is restacked by merges, with no rebase. main was 46fe6ac1 at the fetch, S itself.

step commit what happened
1. merge F 6c4c8131 clean; tree fe694a20, the tree git merge-tree --write-tree 2abd8176 9e4aab8c predicted
2. merge S, ours for S's files 7029934c --no-commit. It conflicted in scripts/shape_advisory.py only, as a stacked branch does. The branch's side was taken for S's three files (scripts/shape_advisory.py, shape-doctor.py, tests/test_doctor_unsearchable_links.py) by writing HEAD's blobs (a0b4ab7c, f7dd1ba3, 10a15eb5). Then git ls-files -u was empty, and git write-tree gave fe694a20, equal to HEAD^{tree}, since S's tree is F's (863f70c7)
3. merge main none main is S, so git merge origin/main said "Already up to date"; 7029934c is the restacked head

The delta is unchanged. The +/- lines of git diff -U0 5370b018 2abd8176 (this PR as first reviewed) and of git diff -U0 46fe6ac1 7029934c are the same: 468 lines each, sha256 50dc1c81e9c549327bbf88368ccd7f09a9db5438cc08f7cbb16f03bbf3aaef10 for both. Each is 2 files changed, 467 insertions(+), 1 deletion(-).

Review round (r1163: two SHOULDs, two NITs)

SHOULD-1: a link spelled with a . segment got the advisory (commit 7121e222). git 2.43's --follow-symlinks looks a . segment up as a name, which no tree holds. Measured, each a committed AGENTS.md link (git's answer, then cat AGENTS.md):

link git 2.43 the disk
./docs/AGENTS.md, docs/./AGENTS.md, .//docs/AGENTS.md dangling the leg's file
./docs/AGENTS.md/., ./docs/AGENTS.md/ dangling ENOTDIR
docs/AGENTS.md/. notdir ENOTDIR
./../x dangling ENOENT

The fix is r1163's preferred one, described under How. Asked directly: <commit>:docs/AGENTS.md/ and <commit>:docs/AGENTS.md/. give notdir; <commit>:../x exits 128 ("outside repository"); <commit>:docs/../../x gives symlink. A name with a line break is two requests. Measured from end to end at 0f8dae6e, AGENTS.md -> ./docs/AGENTS.md: plan prints the leg's NOTE (exit 0), and so does check (exit 1, its own plan-root-symlink); the doctor says "this is a clone of the spec leg". At 2abd8176 both printed the advisory.

One case goes beyond r1163's words, on purpose. ./../locked/AGENTS.md without its . begins with .., which git answers symlink (out of the commit), so it is now out of the commit too: not-answered, as ../locked/AGENTS.md is. It is not asked again, since git would refuse that name. Left as dangling, it would have advised, against this PR's decision for a link out of the commit.

SHOULD-2: mutant R6 survived (commit 0f8dae6e). The control [not-utf-8] links to the leg's text plus one byte 0xff, written as bytes. Both readers advise, on 2abd8176 as here, and R6 (.decode("utf-8", "replace")) now fails it.

NIT-1 is answered under Tests (M10), and NIT-2 under How (the partial clone).

The reviewer's aside, measured once at 0f8dae6e. Nothing is changed for it. check over AGENTS.md committed as an absolute link gives:

  • no plan-root-symlink (exit 0) where the target exists on this machine;
  • one where it does not (exit 1): "an absolute path: a place on this machine, not in the assembly, and nothing is there".

../locked/AGENTS.md gives one (exit 1): "it climbs above the assembly root, to <TMPDIR>/openreposhape-adopt-…/locked/AGENTS.md, where nothing is". This is adopt-project.py's documented walk. check asks "an absolute target as it stands, and a climb above the root from the work directory, where execute makes the assembly's clone". Where something is at the path, place writes the shape's copy beside it and never writes through the link.

Rows, base 2abd8176 (its scripts/shape_advisory.py, blob a0b4ab7c, laid into a scratch clone at 0f8dae6e) against head 0f8dae6e:

row base 2abd8176 head 0f8dae6e
..._is_the_leg_in_the_commit[dot-slash], [dot-in-the-middle], [dot-and-a-double-slash] 3 failed (assert 'advise' == 'leg-clone') 3 passed
..._out_of_the_commit_is_not_answered[relative-after-a-dot] failed (assert 'advise' == 'not-answered') passed
CONTROL ..._advised_by_both_readers[not-utf-8] passed passed
CONTROLS ..._advised_by_both_readers[dot-then-through-the-legs-file], [dot-then-a-slash-after-the-legs-file], [dot-and-a-line-break], [dot-to-nothing-spelled-like-an-answer] 4 passed 4 passed
total 4 failed, 5 passed 9 passed

Afterwards the clone's file was written back from HEAD's blob, and its blob, 8edad0c8, equals HEAD's. linked_clone links a str target as spelled, and checks this with os.readlink, since a Path folds ./ and // away.

Mutants, r1163's round: 11, 9 killed. Each ran alone in a scratch clone at 0f8dae6e, over the new file (-x). After each one, the file was written back from HEAD's blob and proved equal to it (8edad0c8).

id mutant first row failed
X1 no second ask (the dangling branch never runs) [dot-slash]
X2 the last . dropped too [dot-then-through-the-legs-file]
X3 a trailing / dropped [dot-then-a-slash-after-the-legs-file]
X4 no .. check, so git is asked ../locked/AGENTS.md [relative-after-a-dot] (a Refusal's why)
X5 a cleaned target past the root read as nothing there [relative-after-a-dot]
X6 the blob check as before (header[1:2] != [b"blob"]) [dot-to-nothing-spelled-like-an-answer]
X7 no line-break, CR or NUL check [dot-and-a-line-break]
X8 every dangling target asked again, . segment or not survived: see below
X9 no absolute-target check survived: see below
X10 the object-name check dropped (length and blob kept) [dot-to-nothing-spelled-like-an-answer]
R6 r1163's: the followed blob decoded with errors="replace" [not-utf-8]
  • X8 is equivalent. A target with no . segment, asked from the root, takes the same steps the first ask took after its first link. Only the answer for a name missing before any link changes, from dangling to missing, and both are None. The check saves one git call for every dangling link that has no . segment: the link's own blob is read on every dangling either way, and only the second cat-file --batch-check is added (measured by r1163b: 2 calls at head, 3 under X8).
  • X9 is equivalent through git 2.43, which answers any absolute target symlink before it reads a segment, so an absolute target never reaches the second ask. The check is kept as r1163's "it is relative" condition.

Review round 2 (r1163b: one SHOULD, three NITs)

Commit 8e54c32d. It adds two rows and rewords a docstring; it changes no code.

SHOULD-1: a cleaned target that leaves the commit on the second ask was pinned by no row. For AGENTS.md -> .//../locked/AGENTS.md, git 2.43 answers the first ask dangling. The target with its . segment dropped is /../locked/AGENTS.md. That does not begin with .., so git is asked it, and answers symlink. text_through then reaches if header[:1] == [b"symlink"]: raise OutOfTheCommit on that second answer. [relative-after-a-dot] never gets there: the .. check stops it before git is asked again. So r1163b's K5 (the second answer's symlink read as nothing there) and K12 (the second answer kept only when it is a blob) survived all 25 rows. Each turns not-answered into the advisory over a leg the doctor reads, in three of r1163b's 50 probe cases (.//../locked/AGENTS.md, ./docs/../../locked/AGENTS.md, and ./out/./AGENTS.md with out -> ../locked); the base advised in all three.

The row [relative-after-a-dot-and-a-double-slash] is added to the target dict and the how list of ..._linked_out_of_the_commit_is_not_answered, with the docstring's reason. It needs no extra files. It holds what the other out-of-the-commit rows hold: not-answered with the why of the PR's decision (a), nothing refused, plan and check silent, and the doctor's Triad row "this is a clone of the spec leg". ./docs/../../locked/AGENTS.md, which r1163b offered as a second row, is not added: it needs a committed docs/ file, which this test's fixtures do not have, and the row above is killed by the same two mutants.

NIT-1: the LF/CR/NUL exclusion. The control [dot-and-a-carriage-return] (./docs/AGENTS.md\r, advised by both readers) is added. git removes a trailing CR from the name it is asked, so without the CR check git would answer for docs/AGENTS.md, and the commit would name the leg where the disk has no file of that name (ENOENT). It passes on the base and at head, and kills K1 (the CR check dropped). _dot_segments_dropped's docstring now says what git 2.43 does with the three characters, measured by r1163b: an LF splits the batch input into two requests; git removes a trailing CR (an inner CR it keeps, and the check leaves it out too); git cuts the name at a NUL. The old wording said only that git's batch input "would cut" the name into another. The case it leaves is a Known limit (below). cat-file -z is not used.

NIT-2 is fixed in the X8 bullet: "one git call", not two. NIT-3 is fixed in the M10 bullet, which now names the reading that also differs: where every git call fails, whatever AGENTS.md is (a regular leg file, a link, or none). Its reachability argument stands.

Rows, base 2abd8176's module (scripts/shape_advisory.py, blob a0b4ab7c, laid into a scratch clone at 8e54c32d) against head 8e54c32d:

row base a0b4ab7c head 8e54c32d
..._linked_out_of_the_commit_is_not_answered[relative-after-a-dot-and-a-double-slash] failed (assert 'advise' == 'not-answered') passed
CONTROL ..._advised_by_both_readers[dot-and-a-carriage-return] passed passed
total 1 failed, 1 passed in 4.56 s 2 passed in 6.63 s

Afterwards the clone's module and test file were written back from HEAD's blobs and proved equal to them (9122f59b and 62bf0a22, the blobs of 8e54c32d).

Mutants, this round: 3, 3 killed (r1163b's K1, K5 and K12, from the descriptions in its mutants.log). Each ran alone in a scratch clone at 8e54c32d, over the new file (-x), and the module was written back from HEAD's blob and proved equal to it (9122f59b) after each.

id mutant result first row failed
K1 the CR check dropped (LF and NUL kept) KILLED, 1 failed, 24 passed [dot-and-a-carriage-return]
K5 the second ask's symlink read as nothing there KILLED, 1 failed, 10 passed [relative-after-a-dot-and-a-double-slash]
K12 the second ask's answer kept only when it is a blob KILLED, 1 failed, 10 passed [relative-after-a-dot-and-a-double-slash]

On r1163b's list, K2 (the NUL check dropped: only a crafted blob holds a NUL, since no checkout can), K4 (the .. check by prefix: only the names ..foo and ... differ) and K10 (HEAD: for the commit: no fixture has them differ) stay as r1163b accepted them. X8 and X9 are equivalent.

The gate at 8e54c32d is calls 1, 3 and 4 of the table under Tests.

Known limits

  • A link out of the commit is silent in plan and check, even where the doctor reads more. The doctor finds the leg where the link reaches its file (rows [relative] and [absolute]), and the advisory where it reaches nothing (row [dangling]). The commit cannot tell these apart, and adopt reads no file outside the repository.
  • A link into a submodule reads as nothing there. For AGENTS.md -> sub/AGENTS.md with sub a gitlink, git 2.43.0 answers dangling (measured, no row), so the commit reading advises. Where the submodule is checked out and holds a leg's file, the doctor reads the leg.
  • A link that leaves the repository and comes back in by the folder's own name is out of the commit. git answers symlink for ../<folder>/AGENTS.md (measured), since the commit cannot know what the checkout's folder is called.
  • On a checkout where git makes no symlinks, the two readers now disagree the other way. With core.symlinks=false, the working-tree AGENTS.md is a plain file holding docs/AGENTS.md. The doctor reads that text and advises; the commit reading now gives the leg (measured in process: disk advise, commit leg-clone; on base both said advise). CI is Linux only.
  • A . segment in a link further along is still dangling. Only AGENTS.md's own target is asked again. With docs -> ./.agents and AGENTS.md -> docs/AGENTS.md, git answers dangling, the commit advise and the disk leg-clone (measured); with docs -> .agents both say leg-clone.
  • A target whose leg-file name holds an LF or a CR after a . segment is not asked again, so it is advised while the disk reads the leg. With AGENTS.md -> ./docs/a<LF>b or ./docs/a<CR>b, and that file the leg's, the commit reading says advise; the disk reads the leg (leg-clone) and so does the doctor (measured by r1163b with git 2.43.0). The same targets without the ./ give leg-clone in both, since git is asked them at the first ask. An LF would be two requests and a trailing CR is removed by git, so _dot_segments_dropped leaves all three out. cat-file -z reads such a name whole in git 2.43, and is not used here.
  • Measured with git 2.43.0 only. A git that follows a . segment answers the first ask, and the second is never made, since it waits on dangling.
  • Only AGENTS.md is followed. project.yaml, family.yaml and single-repository.yaml committed as links are still read as the path their blob holds. Measured for the record (single-repository.yaml -> docs/record.yaml, this repository's own record): the commit reading says advise with the NOTE it could not be read (YamlError), while the disk reading says recorded-single. Unchanged by this PR; shape_advisory.py: a single-repository.yaml committed as a symlink is read as its path, so plan and check advise with a false (YamlError) NOTE where the doctor says recorded-single (#1163) #1272 measures it and weighs the fix.

Not in this PR

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Brett Heap and others added 22 commits October 21, 2026 21:31
…he root that is a link into a folder this user cannot search is said once, `<name>: cannot be read (<reason>)` with the chmod cure, never absent, and no way in is offered over it (#1110)

Slice A of #1110: the eight sites in `shape-doctor.py` and
`scripts/shape_advisory.py` that asked `exists()` or `is_file()` of a name
directly in the root now ask `is_a` / `_is_a`, which raise a refused `stat`.

- `what is here` names each name it cannot read in validate-pins.py's
  words, with this doctor again as the NEXT and the gate's advice as the
  WHY: the one row that carries the cure.
- `the way in` offers neither command while the `.git` cannot be read, and
  points at `what is here`; `the other way in` and the Triad row are silent.
- `naming` classifies the folder's own name and says origin's was not read.
- `origin_org`, `repo_local_origin_name` and `repository_names` raise the
  refused `stat` (`carries_git`, `_is_a`) where they answered as for a folder
  with no repository.
- `shape_advisory.read()` answers `not-answered` (silent) with the `.git`'s
  line in `why`, where 3.14 said `not-a-repository`; no new state.
- `agent files` says the line with the cure: a `note` for a rendered file
  nothing pins, a FINDING for the pinned `AGENTS-shape.md`.

The eight entries leave `KNOWN_OPEN`; the three family sites stay, slice B.
New rows: tests/test_doctor_unsearchable_links.py.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le is still a repository to the doctor's `.git` questions, and the advisory names a `.git` refused for another reason than permission (#1110)

Two rows the first round of mutants showed missing: `carries_git` asked
for a directory only (D8) and `_read` catching only `PermissionError` (A2)
both passed the new rows. The `.git` questions now also answer for a
`--separate-git-dir` checkout, whose `.git` is a file, and the advisory row
runs for an I/O error as well as a permission.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he review found alive: a `.git` that loops or dangles is no repository, a pinned agent file beside an absent one is a FINDING, `naming` says origin was not read on every exit, and the advisory reads a gitdir file as a repository (#1110)

The review of #1144 (r1144, READY on condition) ran 29 mutants of the new
code against the 17 rows; R01, R07, R13, R14, R15, R27 and R28 survived.
Eight rows and one control kill them, and all 47 mutants (the writer's 18
and the review's 29) are now killed.

- `.git -> .git` (ELOOP) and `.git -> ../nowhere/.git` (ENOENT): controls
  that hold on main (`the way in` is the scaffold, `what is here` an
  ordinary directory, no `origin` asked, the advisory `not-a-repository`),
  and `carries_git` False (R01, and the hygiene scan's R29 as well).
- A pinned `AGENTS-shape.md` it cannot read beside an absent `CLAUDE.md`:
  a FINDING, the verdict `INVALID (agent-files)` (R07).
- `naming` with the `.git` refused, for a name that classifies, one the
  policy does not classify (exit 1) and a naming script that refuses
  (exit 2): the status is the policy's, `ok` for the first, and the clause
  that origin's name was not read is on every branch (R13, R14, R15).
- The advisory row gets the `--separate-git-dir` control: `_read` and
  `repository_names` take a gitdir file for a repository (R27, R28).
- Line 98 is cut to the file's 79 columns.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e split, one per condition, so SonarCloud's python:S9073 has nothing to say of the new rows (#1110)

One is a new row's `not row["why"] or PERMISSION_WHY not in row["why"]`, now
`PERMISSION_WHY not in (row["why"] or "")`; the other is the first
round's `"AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned`, now
two asserts. No behaviour changes: the 25 rows and the 47 mutants give the
same results.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…cannot read leaves whether the folder is a leg unknown, never no leg's file, so `the way in` offers neither command and points at `what is here`, which names it, and `the other way in` and the Triad row are silent (#1110)

#1110's twelfth site, found by r1144: `shape_advisory._leg_by_agents_md`
read any exception as "no leg's file" (`except Exception: return None`).
Over a clone of acme/Orbit whose AGENTS.md is the spec or the code leg's,
linked into a folder at 000 or itself at 000, #1144's head offered
`adopt-project.py plan ... --project Orbit` in `the way in`, `setup.sh` in
`the other way in`, and the Triad advice, on 3.12 and on a 3.14-like shim.

- `_leg_by_agents_md` raises a refused `stat` or read (#430's rule: only
  ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and 1921, are
  nothing there). Bytes that are not UTF-8 still declare no leg; anything
  else ends in `read()`'s net.
- `_read` answers `not-answered`, with `AGENTS.md: cannot be read
  (<reason>)` in `why` and the OSError in the new `Reading.refused`, unless
  the folder's name is a leg's, which still answers. The `.git` case
  carries `refused` too.
- `leg_clone` raises `refused`; `the way in` (`way_in_unread_row`, shared
  with the `.git` row) offers neither command, and `the other way in` is
  silent.
- `what is here` reads AGENTS.md through (`read_through`), so a file at 000
  is named there with the cure.

tests/test_doctor_unsearchable_links.py: 20 new rows (3 mode, 17 in
process), and `leg_clone` over a refused `.git` now raises in #1144's row.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ere` to reading through a regular `AGENTS.md` alone: a directory or a named pipe of that name is listed and not read, the pipe's doctor run under a timeout, and a refused `Makefile` is listed like any other name (#1110)

The review of #1159 (r1) found two mutants of `shape-doctor.py` alive
against all 45 rows:

- M3, `read_through` reading any kind of file (`is_a(path, lambda _m:
  True)`): over a repository whose `AGENTS.md` is a directory, `what is
  here` said `AGENTS.md: cannot be read (Is a directory)`, and over one
  whose `AGENTS.md` is a named pipe the doctor never returned (SHOULD-2);
- M14, `what is here` reading through every name it lists (NIT-4).

New rows, each a control that holds on #1144's head as here:

- `test_an_agents_md_that_is_no_regular_file_is_listed_and_not_read`
  [directory, named-pipe]: the doctor run as a person runs it, given
  `DOCTOR_SECONDS` (120) by `doctor_within`, so a run that opens the pipe
  fails the row instead of hanging the suite. `what is here` says `a git
  repository, an AGENTS.md`, nothing says `cannot be read`, and the clone
  is offered both ways in. The pipe row skips where `os.mkfifo` is absent,
  and nothing opens the pipe in the test's own process.
- `test_what_is_here_reads_through_agents_md_alone`: a leg's clone whose
  `Makefile` read is refused, in process: `what is here` says `a git
  repository, an AGENTS.md, a Makefile`, and no row says `cannot be read`.

`offered_both_ways_in` now reads its rows through `offers_both_ways_in`,
which a whole run's rows (with `machine`) can be given too.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lder named as a leg is still that leg, as it was before #1159 and as it is where the disk's file is refused, so `adopt-project.py plan` and `check` print the leg's note over it again (#1110)

The review of #1159 (r1, NIT-2) measured that a `Refusal` raised while
reading a commit's `AGENTS.md` (`Tree.text`, `cat-file blob`) no longer
fell back to the leg's name. #1144's head read it as no leg's file and
then asked the name, so a folder named `<P>-spec` was `leg-clone`; #1159's
head sent the `Refusal` straight to `read()`'s net, `not-answered`, and
`adopt-project.py` stopped printing the leg's note there.

`_read` now catches a `Refusal` from `_leg_by_agents_md` as it catches a
refused `OSError`: the folder's name answers where it is a leg's, and with
none the `Refusal` goes on to `read()`'s net, `not-answered`, as on #1159's
head. This is the review's mutant M17. `refused` stays None: no `OSError`
was refused.

New row, a control that holds on #1144's head as here:
`test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md`, a
committed clone named `Orbit-spec` whose `cat-file blob` of `AGENTS.md`
raises `Refusal`: `leg-clone`, by name, the spec leg. On #1159's head
`0f9aff05` it fails with `not-answered`.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot read keeps `origin_org` in its `--json` detail, as the leg's own row and the policy row keep it with no command, where it said None while `origin` was read (#1110)

The review of #1159 (r1, NIT-5) found that the `AGENTS.md` row gave
`origin_org: null` while the `naming` row of the same run read `origin`,
and that the docstring's reason, "None because no command is offered with
them", did not hold: the leg's own row keeps `origin_org` with no command,
and so does the policy row (`way_in_policy_row`).

`way_in_unread_row` now takes `org`: None for a `.git` that cannot be
read, whose `origin` is not read either (unchanged), and `origin_org(root)`
for the `AGENTS.md` row, read only where `carries_git` answered and
`leg_clone` raised. `suggested_project` stays None, since no `--project`
is offered, and the docstring says so.

`no_leg_way_in` now expects `origin_org: "acme"` (ORIGIN is
`acme/Orbit`), so the 7 rows that use it pin the value: with the doctor
of `34ed0a95` they fail, 7 failed and 42 passed.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts name where git cannot give its `AGENTS.md` no longer asks `Reading.refused`, which #1144's `Reading` does not have, so it is the control on #1144's head its docstring says it is (#1110)

Measured with the file laid over #1144's head `89486f69`, the row's state,
`by` and role checks pass there, and only `assert reading.refused is
None` failed, with `'Reading' object has no attribute 'refused'`.
`refused` is set only together with `not-answered`, so the line asked
nothing of a `leg-clone` reading. Without it the row passes on
`89486f69` and on this head, and still fails on `0f9aff05`, whose
`Refusal` skips the name (`not-answered`, at the state check).

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… row names its sibling row on one line, so a grep for the row's name finds the reference (#1110)

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#1144's final head is update-branch's merge of main bd3e9c1 into
89486f6, the tree that squash-landed as b1f45fa. This is the first of
the three merges that restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b1f45fa is #1144's squash on main. Its own files (scripts/shape_advisory.py,
shape-doctor.py, tests/test_doctor_unsearchable_links.py and
tests/test_path_guard_hygiene.py) take the branch's side: S's tree is #1144's
final head 5b38c3a's tree, and merge 1 already brought that in. The merged
tree equals HEAD's tree, c5d0bc7. This is the second of the three merges that
restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main 0d5704b is five commits past #1144's squash b1f45fa (#1169, #1223,
#1166, #1133, #1104). This is the third of the three merges that restack
#1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… through it inside the commit `adopt-project.py plan` and `check` read, and one that leads out of the commit leaves whether it is a leg unknown, so neither calls the leg a single repository (#1163)

`Tree.text(rel, follow=True)` follows a symlink at `rel` inside the commit
as git follows one there (`git cat-file --batch-check --follow-symlinks`):
to a file, which is read out of the commit's objects; to nothing that is a
file (a link that dangles, loops or runs through a file, or leads to a
directory or a submodule), which is no leg's file, as on the disk. A link
that leads out of the commit raises `OutOfTheCommit`, and `_read` answers
it as a file this user cannot read (#1110): by the leg's name, or
`not-answered`, silent. Only `_leg_by_agents_md` asks it; the doctor reads
the disk, which the operating system follows either way.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` committed as a symlink, each failing on #1159's head `5370b018`: to its own file, beside it or through a linked folder, at 000 in or beside the repository, and out of the commit; with controls for a link to nothing, a regular leg and a single repository (#1163)

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a committed link through `Tree.text_through`, and `Tree.text` keeps its one argument, so #1159's row that stands in for `Tree.text` with a refusal reads `Refusal` again, not `TypeError` (#1163)

`text(rel, follow=True)` made `_leg_by_agents_md` pass a keyword that the
stand-in in `tests/test_doctor_unsearchable_links.py`'s
`test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice`
(`def text(self, rel)`) does not take, so `read()`'s net named a
`TypeError` where the row asks for `Refusal`. The decision is unchanged.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9e4aab8 is #1159's final head: update-branch's merge of main 1212295 into
5370b01, the commit this branch was built on. This is the first of the three
merges that restack #1163 after #1159 landed as 46fe6ac.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46fe6ac is #1159's squash on main. Its own files (scripts/shape_advisory.py,
shape-doctor.py and tests/test_doctor_unsearchable_links.py) take the branch's
side: S's tree is #1159's final head 9e4aab8's tree, 863f70c, and merge 1
already brought that in. The merged tree equals HEAD's tree. This is the
second of the three merges that restack #1163 after #1159 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th a `.` segment (`./docs/AGENTS.md`, `docs/./AGENTS.md`, `.//docs/AGENTS.md`), which git 2.43's `--follow-symlinks` answers `dangling`, is asked again with those segments dropped, so `plan` and `check` name the leg there too (#1163)

r1163's SHOULD-1. git 2.43 looks a `.` segment up as a name no tree holds,
so the follow answered `dangling` and the commit reading advised over a leg
the doctor names. Where git says `dangling`, `Tree.text_through` now reads
the link's own target (`cat-file blob <commit>:AGENTS.md`), and one that
holds a `.` segment before its last is asked again with those dropped
(`_dot_segments_dropped`). A last `.` or `/` is kept, so a link through a
file stays `notdir`. A target that then begins with `..` is out of the
commit, as git answers `../x`; git is never asked a name beginning `../`,
which it reads from the working directory and refuses (exit 128, measured).
A target with a line break, a carriage return or a NUL is not asked again,
and only a line that begins with an object's name is a blob to read, since
git's line for a name it did not find begins with that name.

`text_through`'s docstring no longer says git follows as `open()` does.

Rows: `..._is_the_leg_in_the_commit[dot-slash]`, `[dot-in-the-middle]`,
`[dot-and-a-double-slash]` and `..._out_of_the_commit_is_not_answered
[relative-after-a-dot]` fail on 2abd817; four controls hold the second ask
to the disk's answer.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…link to the leg's own text and one byte 0xff, advised by both readers, so the followed blob is held to "a file that is read and is not UTF-8 declares no leg", and decoding it with `errors="replace"` fails a row (#1163)

r1163's SHOULD-2. Its mutant R6 (`.decode("utf-8", "replace")` on the blob
`Tree.text_through` reads) passed every row, yet it named the leg in the
commit where the disk advises. The control writes `docs/AGENTS.md` as bytes
(`linked_clone` takes bytes since the previous commit) and asks both
readers: each advises, on 2abd817 as here.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.1 milestone Nov 8, 2026
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Add commit-aware symlink detection and reading for AGENTS.md, including handling paths that lead outside the commit and git’s .-segment behavior.

  • scripts/shape_advisory.py L405-451, L508-575, L740-756 (+4 small edits)

Tests

Cover committed AGENTS.md symlinks to in-commit leg files, out-of-commit targets, non-leg targets, git failures, and unchanged behavior.

  • tests/test_advisory_agents_md_symlink.py L1-455

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T17:48:11Z) Independent review r1163 of the #1163 fix at 2abd8176 (before this PR was opened, while it was stacked on #1159): NOT READY, for two small SHOULDs; no MUST. The fix round 7121e222 and 0f8dae6e answers both, after the restack onto #1159's squash (7029934c); lane 1's note on the round follows, and a fresh re-review starts now.

I'm r1163, reviewing #1163 at 2abd81765fd1b773f18b8cd5fbe9aa6fbded15f3 (branch fix/1163-agents-md-symlink, stacked on #1159's head 5370b018).

Verdict: NOT READY

There is no MUST. There are two SHOULDs and both are small: one link spelling the fix misses, and one row the tests lack. The fix itself is correct for every other case I measured, and the decision it takes is the right one. The rows, the merge, the counts and the body's tables all hold up.


Findings

SHOULD-1: git 2.43 does not follow a . segment, so ln -s ./docs/AGENTS.md AGENTS.md still gets the advisory over a leg

  • Where:

    • /workspace/projects/openRepoShape-worktrees/l1-r1163/scripts/shape_advisory.py:488-492: the text_through docstring says git follows the link "as a checkout's open() does".
    • /workspace/projects/openRepoShape-worktrees/_lane1-scratch/w1163/pr-body.md:52: "follows every link on the way".
    • Known limits: this case is not listed.
  • Evidence: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/dot.sh, git 2.43.0. Each line is a commit's AGENTS.md link, then HEAD:AGENTS.md | cat-file --batch-check --follow-symlinks, then cat AGENTS.md:

    docs/AGENTS.md         git: c74cdfc0... blob 4    os: leg
    ./docs/AGENTS.md       git: dangling 14           os: leg
    docs//AGENTS.md        git: c74cdfc0... blob 4    os: leg
    docs/./AGENTS.md       git: dangling 14           os: leg
    ./AGENTS2.md           git: dangling 14           os: two
    .//docs/AGENTS.md      git: dangling 14           os: leg
    docs/x/../AGENTS.md    git: c74cdfc0... blob 4    os: leg
    

    From end to end (/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/e2e.py, case dot-slash):

    • head: plan prints the triad advisory (exit 0), and so does check (exit 1). read(commit=) gives advise.
    • The doctor's Triad row says n/a: this is a clone of the spec leg of a Triad.
  • Effect: this is the issue's own symptom, still there for a common way of writing the link. A link that leads to a file inside the commit is read as nothing there, which goes against the PR's stated decision. It is not a regression: base does the same.

  • Fix, preferred: when git answers dangling, read the link's own target with cat-file blob <commit>:AGENTS.md. If that target:

    • is relative,
    • does not start with ..,
    • and has a . segment that is not the last one,

    then ask --follow-symlinks again for <commit>:<target with those segments dropped>. AGENTS.md is at the root, so the target is relative to the root. Keep a trailing / or /., so a link through a file stays notdir. Add a row [dot-slash] beside [beside]; it fails on base.

  • Fix, minimum: list it under Known limits. Correct the docstring and line 52 of the body. Add a row that pins today's advise.

  • I did not measure a git newer than 2.43; only /usr/bin/git is allowed.

SHOULD-2: mutant R6 survives, and it changes an answer

  • Where: /workspace/projects/openRepoShape-worktrees/l1-r1163/scripts/shape_advisory.py:512 (.decode("utf-8")), and no row in /workspace/projects/openRepoShape-worktrees/l1-r1163/tests/test_advisory_agents_md_symlink.py:330-339 covers it.
  • Evidence: I changed .decode("utf-8") to .decode("utf-8", "replace"). All 16 rows still pass (16 passed in 22.76s). Yet the answer changes (/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/r6.py, where the link leads to the leg's text plus one \xff byte):
    head commit: advise | head disk: advise
    R6 commit:   leg-clone | R6 disk:   advise
    restored a0b4ab7cac391d7493c3bcea0f577929a0c22044
    
  • Why it matters: the docstring's rule "A FILE THAT IS READ AND IS NOT UTF-8 DECLARES NO LEG" is not pinned on the new read path.
  • Fix: add a control [not-utf-8] to test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers: the leg's text plus b"\xff" as docs/AGENTS.md, written as bytes. Both readers should advise.

NIT-1: the body's survivor argument for M10 is not accurate

  • Where: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/w1163/pr-body.md:99.
  • The claim: M10 is "equivalent for every regular file … differs only for a link where ls-tree fails and the next cat-file … succeeds".
  • Evidence: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/m10.py. M10 also differs where every git call fails, for a regular leg file and even with no AGENTS.md at all:
    regular-leg  every git call fails   head: advise  M10: not-answered (Refusal)
    none         every git call fails   head: advise  M10: not-answered (Refusal)
    inside       only ls-tree fails     head: advise  M10: leg-clone
    
  • Neither difference can be reached through plan or check, which read the commit before the advisory. So the survivor is equivalent through the tools; only the reason the body gives is wrong.
  • Fix: reword it to name both differences, and say why neither is reachable.

NIT-2: a partial clone fetches one more object under plan

This is not a defect, but it is worth one line under How. Measured with /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/limits.py in a blob:none clone:

tools GIT_NO_LAZY_FETCH promisor packs reading
base unset 1 -> 2 advise
base 1 1 -> 1 advise
head unset 1 -> 3 leg-clone
head 1 1 -> 1 not-answered (Refusal)
  • The new call inherits os.environ, as git_out does, so check and execute (_lazy_fetch_off) fetch nothing.
  • plan was already a lazy fetcher, as adopt-project.py:13098 documents; it now fetches the link's target as well.

What I checked and found right

1. The commit reading. Measured on git 2.43 by /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/catfile_map.py (30 cases, log at /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/map1.log). Each answer from cat-file --batch-check --follow-symlinks is handled as the decision says:

git's answer head reads it as the OS (open())
<oid> blob reads the blob same file
<oid> tree None EISDIR
symlink <n> (../x, absolute, ../Atlas/..., docs/../../Atlas/...) OutOfTheCommit reads the target
dangling None ENOENT
loop (self, A->B->A, 41 links) None ELOOP
notdir (README.md/AGENTS.md, trailing /) None ENOTDIR
missing (no AGENTS.md) None, and is_link is False ENOENT
a link into a submodule, or to the gitlink itself dangling, so None leg / EISDIR (known limit)
  • A target with non-UTF-8 bytes or a newline is followed correctly, whether it reaches a file or dangles. Only the first line of git's output is parsed, and git echoes the input name there, never the target.
  • .. segments that stay inside work: docs/../real.md, docs/sub/../../real.md. A .. after a linked folder resolves physically, as the OS does (cases 19 and 20).
  • Chains: 40 links are followed, and the 41st gives loop, the same as Linux's ELOOP.
  • is_link is False on the disk, which is right, since the OS follows the link there. Where ls-tree fails it falls back to the base reading. That is safe: it never crashes, and a regular file's reading is unchanged.

2. Conventions.

  • The new subprocess.run runs git the way git_out does (/workspace/projects/openRepoShape-worktrees/l1-r1163/scripts/repo_shape.py:1397): the same environment, which inherits GIT_NO_LAZY_FETCH; no timeout and no locale, as there; stdout parsed as bytes.
  • ls-tree goes through git_out with -- and the constant AGENTS.md, which has no glob characters, so --literal-pathspecs is not needed.
  • Its refusal text leaves out the folder and git's stderr, but only the exception's type name reaches why.
  • shape_advisory.py is in none of COPIED_VERBATIM, FAMILY_COPIED_VERBATIM, COPIED_FROM_SHAPE or FAMILY_COPIED_FROM_SHAPE (checked in Python: 0 hits). No pin bump is needed.

3. The decision: (b), with (a) for a link out of the commit. It is right. /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/probes/e2e.py covered 16 cases, on base and head, with the doctor measured for each:

case base: plan / check head: plan / check head read(commit=) the doctor (same on base and head)
-> docs/AGENTS.md (leg) advisory leg NOTE leg-clone clone of the spec leg
same, docs/ at 000 advisory leg NOTE leg-clone no Triad row; way in: AGENTS.md cannot be read
same, the file at 000 advisory leg NOTE leg-clone no Triad row; cannot be read
-> ../locked/AGENTS.md advisory nothing not-answered clone of the spec leg
the same, absolute advisory nothing not-answered clone of the spec leg
../locked at 000 advisory nothing not-answered no Triad row; cannot be read
-> ../nowhere (dangling) advisory nothing not-answered advisory
dangling inside / loop / dir / another file advisory advisory advise advisory
a regular leg file (control) leg NOTE leg NOTE leg-clone clone of the spec leg
none (control) advisory advisory advise advisory
-> ./docs/AGENTS.md advisory advisory advise clone of the spec leg (SHOULD-1)
-> sub/AGENTS.md (gitlink) advisory advisory advise clone of the spec leg (known limit)
-> ../Atlas/docs/AGENTS.md advisory nothing not-answered clone of the spec leg (known limit)

6. Known limits. Each of the five is true, measured, and acceptable:

  • A link out of the commit is silent. Measured in five cases.
  • A link into a submodule reads as nothing. git answers dangling.
  • A link back in by the folder's name is out of the commit. git answers symlink.
  • core.symlinks=false. On head the commit says leg-clone and the disk says advise; on base both said advise. The commit's answer is the true one, so this is acceptable.
  • Only AGENTS.md is followed. For single-repository.yaml -> docs/record.yaml, the commit says advise with the note it could not be read (YamlError), and the disk says recorded-single. This is the same on base and head.

The last one is a real gap and worth a low-priority issue outside v1.2.0. The note it prints is false (the record can be read; it is a link), and plan disagrees with the doctor about a project that recorded staying single. Changing it touches the record question in adopt-project.py (#528), so it should be its own issue, led by the record, with project.yaml and family.yaml named in it.

9. The body.


The rows (base 5370b018 module, laid into a clone of head; head 2abd8176)

row base head
..._linked_to_its_own_file_is_the_leg_in_the_commit[beside], [through-a-linked-folder] 2 FAILED (advise) 2 passed
..._linked_into_a_folder_nobody_may_search[in-the-repository], [beside-it] (they ran: uid 1000) 2 FAILED 2 passed
test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused FAILED passed
..._linked_out_of_the_commit_is_not_answered[relative], [absolute], [dangling] 3 FAILED 3 passed
test_a_git_that_cannot_follow_the_link_is_silence_not_advice FAILED passed
CONTROL ..._by_its_name_is_one_while_its_agents_md_links_out_of_the_commit passed passed
CONTROLS ..._advised_by_both_readers[dangling, loop, a-directory, through-a-file, another-file] 5 passed 5 passed
CONTROL test_a_regular_leg_agents_md_and_a_single_repository_are_as_before passed passed
total 9 failed, 7 passed in 18.12s 16 passed in 29.90s

Every failure on base is the defect: AssertionError: {'state': 'advise', ...} or assert 'advise' == 'leg-clone' / 'not-answered'. After the run the clone was restored with git checkout HEAD --, and its blob is a0b4ab7c, equal to HEAD's.

The mutants (my own, in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/clone, each run alone with -x over the new file)

id mutant result first row that failed
R1 LINK_MODE = "100644" KILLED [beside]
R2 is_link says True with no commit (the disk) KILLED [beside]
R3 a failed follow raises nothing KILLED ..._git_that_cannot_follow_...
R4 dangling taken as out of the commit, and symlink as nothing KILLED [beside-it]
R5 only an absolute link is out of the commit KILLED [beside-it]
R6 the followed blob decoded with errors="replace" SURVIVED SHOULD-2
R7 the two reads in _leg_by_agents_md swapped KILLED [beside]
R8 OutOfTheCommit(OSError) KILLED [relative]
R9 the except OutOfTheCommit clause removed, so read()'s net answers KILLED [relative]
R10 the out-of-commit reading names a refused KILLED [relative]
R11 a failed follow raises PermissionError, not Refusal KILLED ..._git_that_cannot_follow_...
R12 --follow-symlinks dropped KILLED [beside]
R13 any ls-tree entry counts as a link SURVIVED, equivalent: following something that is not a link gives its own blob, tree or gitlink, so every reading is the same
R14 a link to a tree read as a blob KILLED [a-directory]

12 of 14 killed. After each mutant the file's blob was a0b4ab7c, equal to HEAD's. On the writer's two survivors:

  • M11: the argument holds. _read requires <root>/.git, so git runs from the root and --full-tree makes no difference.
  • M10: equivalent through the tools, but the body's reason for it is wrong (NIT-1).

Every command's summary line

  • worktree add --detach .../l1-r1163 2abd8176: HEAD is now at 2abd81765.
  • catfile_map.py: exit 0, 30 cases.
  • dot.sh: 8 spellings, quoted above.
  • e2e.py, base and head: exit 0 and 0, 16 cases each. An earlier run used --project Thing and was void: every plan was refused with adopt-not-the-source, exit 2. It was set aside and re-run.
  • Rows at head: 16 passed in 29.90s. Rows at base: 9 failed, 7 passed in 18.12s.
  • mutants.py: killed 12/14.
  • r6.py and m10.py: quoted above.
  • limits.py: quoted above (core.symlinks, the record, the partial clone).
  • Gate call 1 (the new file, test_doctor_unsearchable_links.py, test_shape_advisory.py): 112 passed in 125.46s.
  • Gate call 2 (the seven files): 285 passed in 353.45s.
  • Gate call 3 (test_scaffold_leg_path_collisions.py, test_path_guard_hygiene.py, test_no_auto_maintenance.py): 283 passed, 2 skipped in 40.66s. Both skips are test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set".
  • Gate call 4 (the doc trio): 171 passed in 47.00s.
  • The 3.14-like shim (/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/shim314/sitecustomize.py), over the new file and test_doctor_unsearchable_links.py: 65 passed in 45.66s.
  • Merges, against current main:
  • Read-only gh:
    • pr view 1159: OPEN, head 5370b018.
    • api commits/main: 12122952.
    • Two issue list searches: no existing issue for a symlinked single-repository.yaml.

What I did not cover

  • A git newer than 2.43, so I cannot say whether newer git follows . segments or what CI's git does. I also did not test Windows or macOS.
  • tests/test_shape_doctor.py and the full suite, which the brief excludes.
  • The writer's own M1 to M14, one by one; I ran my own 14 instead.
  • tree:0 partial clones, and the GIT_*_PATHSPECS variables.
  • An aside outside this PR: check gives no plan-root-symlink finding for an AGENTS.md committed as an absolute link (exit 0), but gives one for ../locked/AGENTS.md (exit 1). This is the same on base and head, and I did not investigate it.

All my checkouts are clean: /workspace/projects/openRepoShape-worktrees/l1-r1163 is at 2abd8176, and the scratch clone and base worktree under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163/ are clean too.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T17:48:57Z) #1270's round for r1163: the branch is restacked onto #1159's squash, and both SHOULDs and both NITs are answered, at head 0f8dae6e.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T18:19:00Z) Independent re-review r1163b of #1270 at 0f8dae6e: NOT READY, for one new SHOULD (a row); no MUST. r1163's two SHOULDs are fixed. Lane 1 starts a small round now: the SHOULD's row, NIT-1's carriage-return control and wording, and NIT-2 and NIT-3 in the body.

r1163b re-review of #1270 (for #1163) at 0f8dae6ec271b57766f82c79337c09af02a6ae6e

Verdict: NOT READY

There is no MUST. SHOULD-1 and SHOULD-2 from r1163 are both fixed, and the probes confirm it. NIT-2 holds as measured. NIT-1 is still slightly off.

One new SHOULD holds the PR. A row the round does not have would pin the second ask's symlink answer. Two of my mutants that turn that answer into advice over a leg survive all 25 rows. One test-dict entry fixes it, and I checked that this entry fails on base and kills both mutants. There are three NITs as well.

The restack is exact. The diff against main holds only #1163's two files, and the merge with current main is clean.


Findings

SHOULD-1 (new): a cleaned target that leaves the commit on the second ask is not pinned

  • Where:

    • scripts/shape_advisory.py:562-565. header = self._follow(target) then reaches if header[:1] == [b"symlink"]: raise OutOfTheCommit.
    • tests/test_advisory_agents_md_symlink.py:271-295. [relative-after-a-dot] is caught by the .. pre-check at line 560 before git is asked again. So no row has a second ask that git answers with symlink.
  • Evidence: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/mutants.log. Two mutants survive with 25 passed:

    • K5: after the second ask, if header[:1] == [b"symlink"]: return None.
    • K12: keep the second answer only when it is a blob (header = again if again[1:2] == [b"blob"] else header).

    Both change three of my 50 probe answers from not-answered to advise, in each case where the disk and the doctor read the leg:

    • .//../locked/AGENTS.md. The cleaned target is /../locked/AGENTS.md, and git answers symlink 19.
    • ./docs/../../locked/AGENTS.md. git answers symlink 19.
    • ./out/./AGENTS.md, with out -> ../locked. git answers symlink 19.

    This is the issue's own symptom: the advice is printed over a leg, which goes against the PR's decision (a) for a link out of the commit. Base 7029934c also advises in all three, so a row for them fails on base.

  • Fix: add "relative-after-a-dot-and-a-double-slash": ".//../locked/AGENTS.md" to the target dict and the how list of test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered. It needs no extra files.

    • For this case I measured plan exit 0 printing nothing, check exit 1 printing nothing, and a doctor Triad row of "clone of the spec leg". So the row's existing assertions hold.
    • ./docs/../../locked/AGENTS.md works too, but it needs a committed docs/ file.
    • I tried both rows in a scratch clone (scripts/fixrows.py):
      head           3 passed, 25 deselected
      K5             2 failed, 1 passed   (both out-of-commit rows)
      K12            2 failed, 1 passed   (both out-of-commit rows)
      base a0b4ab7c  2 failed, 1 passed   (both out-of-commit rows)
      restored 8edad0c8 9d25152a
      

NIT-1: the LF/CR/NUL exclusion is only partly pinned and partly described

  • Where:
    • scripts/shape_advisory.py:427-432: the docstring says each of these "git's batch input would cut into another name".
    • Known limits in the body.
  • Evidence: git 2.43, measured by hand.
    • <commit>:docs/a\nb\n gives two requests (<commit>:docs/a missing / b missing). The LF exclusion is right, and [dot-and-a-line-break] pins it (K3 killed).
    • <commit>:docs/AGENTS.md\r\n gives afd7d436.. blob 782: git removes a trailing CR. <commit>:docs/a\rb\n gives the blob: an inner CR is kept.
    • <commit>:docs/AGENTS.md\0x\n gives the blob: git cuts the name at the NUL.
    • K1 (the CR check dropped) survives with 25 passed. It changes ./docs/AGENTS.md\r from advise to leg-clone, where open() gives ENOENT and the doctor advises.
    • Probe rows dot-lf-legname (./docs/a\nb) and dot-cr-legname (./docs/a\rb), where that file is the leg's: head says advise, while the disk and the doctor say the leg. The same targets without the ./ give leg-clone.
    • git cat-file --batch-check --follow-symlinks -z works on 2.43: docs/a\nb\0 gives afd7d436.. blob 782.
  • Fix:
    • Add a control [dot-and-a-carriage-return] with ./docs/AGENTS.md\r (both readers advise). It passes on base and head and kills K1, which fixrows.py confirms.
    • Reword the docstring: git keeps only the first line for LF, removes a trailing CR, and cuts the name at a NUL.
    • List the LF/CR leg-name case under Known limits.
    • Or use -z and keep only the NUL exclusion, if every supported git has -z. I did not check the oldest supported git.

NIT-2: X8's argument overstates what the check saves

  • Where: the body, under Review round, the X8 bullet: "The check saves two git calls".
  • Evidence: scripts/count.py, for a dangling link with no . segment:
    head None 2 ['cat-file --batch-check', 'cat-file blob']
    X8   None 3 ['cat-file --batch-check', 'cat-file blob', 'cat-file --batch-check']
    
    The link's own blob is read on every dangling, so the check saves one call.
  • Fix: say "one git call".
  • X8 is equivalent: across all 50 fixtures it changes no answer, and it passes all 25 rows.

NIT-3: the M10 text still leaves out one reading that differs

  • Where: the body, under Tests, the M10 bullet: "it differs from the head in three readings".
  • Evidence: r1163's m10.py at head. When every git call fails over a link, the answers differ too:
    plain (-> docs/AGENTS.md)   every git call fails   head: advise | M10: not-answered
    dot-slash                   every git call fails   head: advise | M10: not-answered
    regular-leg                 every git call fails   head: advise | M10: not-answered
    none                        every git call fails   head: advise | M10: not-answered
    plain                       only ls-tree fails     head: advise | M10: leg-clone
    
    The writer's m10_fixtures.sh ran the inside fixture, so this line was in their own output. The reachability argument (show-ref in Source._at before _print_triad_advisory, at adopt-project.py:2788, :6000 and :13485) covers this case too.
  • Fix: "where every git call fails, whatever AGENTS.md is (a regular leg file, a link, or none)".

What I checked and found right

1. The restack. Every check passes:

  • Trees: 6c4c8131 and 7029934c both have tree fe694a20. 9e4aab8c and 46fe6ac1 both have 863f70c7.
  • Replayed merges:
    • merge-tree --write-tree 2abd8176 9e4aab8c gives fe694a20, clean.
    • merge-tree 6c4c8131 46fe6ac1 conflicts in scripts/shape_advisory.py only.
  • Delta: git diff -U0 gives 468 +/- lines, sha256 50dc1c81e9c549327bbf88368ccd7f09a9db5438cc08f7cbb16f03bbf3aaef10, for each of 5370b018..2abd8176, 46fe6ac1..7029934c and 9e4aab8c..7029934c.
    • The patch-id is c2563953575a1f50 for all three.
    • The full-context diff of each file hashes the same.
    • 9e4aab8c..7029934c touches only the two files: 467 insertions(+), 1 deletion(-).
  • Diff against main: the merge base with origin/main is 46fe6ac1. diff origin/main...0f8dae6e lists only scripts/shape_advisory.py (+148/-1) and the new test file (+440). GitHub's file list agrees.
  • Blobs at 7029934c: shape_advisory.py a0b4ab7c, shape-doctor.py f7dd1ba3, test_doctor_unsearchable_links.py 10a15eb5, as the body says.

2. SHOULD-1's fix. It is correct, and the probe table below shows it. Every . spelling of a link to a file inside the commit now agrees with open() and with the doctor:

  • ./docs/AGENTS.md, docs/./AGENTS.md, .//docs/AGENTS.md, ././docs/AGENTS.md and ./docs/../docs/AGENTS.md.
  • A non-UTF-8 name, a tab, a space, a colon, ..foo and ....
  • A physical .. after a linked folder.
  • docs/., ./, ., ./. and every trailing /, /., /./, // and /.. after the leg's file give None, as the disk's EISDIR and ENOTDIR do.

On the details:

  • Leading slash: .//docs/AGENTS.md cleans to /docs/AGENTS.md. It works because git removes a leading / from the path in <commit>:/docs/...; that was measured, and the row is pinned (K11 killed).
  • ./../locked/AGENTS.md as out of the commit: the writer's extension is right.
    • The OS resolves it exactly as ../locked/AGENTS.md, which git answers symlink 19, so not-answered (silent) is the PR's own decision (a).
    • Asking git the cleaned name gives exit 128: fatal: '../x' is outside repository, which is a Refusal with another why (K9 shows this).
    • Leaving it dangling would advise over a leg the doctor reads.
  • ./..: it gives not-answered while the disk advises (EISDIR). That is the same as a plain .., which git answers symlink 2. It is consistent with decision (a) and is not a finding.
  • Absolute targets: git answers symlink 102 for <abs>/./locked/AGENTS.md before reading any segment. So X9 is equivalent: it changes nothing in the 50 fixtures and passes the 25 rows.
  • Answer parsing: a line for a name git did not find always begins <commit>:, so its first token holds a : and can never fully match OBJECT_NAME_RE.
    • Measured: ./docs/x blob (missing) gives advise; the same name when the file exists gives leg-clone; a 40-hex name and <hex> blob 9 behave the same way.
    • K6 (fullmatch changed to match) is killed by [dot-to-nothing-spelled-like-an-answer].
    • The -z-free input path can only become two requests through an LF, and that is excluded.
    • A SHA-256 repository with -> ./docs/AGENTS.md gives commit leg-clone and disk leg-clone at head; base gives advise.
  • Known limits: the new limit (a . in a link further along) is measured as the body says. docs -> ./.agents gives git dangling, commit advise, disk leg-clone. A gitlink still reads as nothing, as listed.

3. SHOULD-2's fix. R6 is killed. The first failure is [not-utf-8], and the file was restored to 8edad0c8. [not-utf-8] passes on base a0b4ab7c and at head.

4. NIT-2 (the partial clone). The body's text is right (scripts/partial.py, a blob:none clone over file://):

head  -> docs/AGENTS.md    GIT_NO_LAZY_FETCH=unset packs 1 -> 3 | leg-clone
head  -> docs/AGENTS.md    GIT_NO_LAZY_FETCH=1     packs 1 -> 1 | not-answered (Refusal)
head  -> ./docs/AGENTS.md  GIT_NO_LAZY_FETCH=unset packs 1 -> 3 | leg-clone
head  -> ./docs/AGENTS.md  GIT_NO_LAZY_FETCH=1     packs 1 -> 1 | not-answered (Refusal)
base  -> ./docs/AGENTS.md  GIT_NO_LAZY_FETCH=unset packs 1 -> 2 | advise

5. The body.


Probe table (git 2.43.0, Python 3.12.3, probe environment)

Each case is renamed-clone with the link committed. Base is 7029934c (module a0b4ab7c) and head is 0f8dae6e. The base and head columns are read(commit=HEAD). The doctor is shape-doctor.py --json, whose Triad row is the same on base and head.

case link (committed) git, first ask cleaned target, then git base head open() disk read() doctor
plain docs/AGENTS.md blob - leg-clone leg-clone LEG leg-clone the leg
dot-slash ./docs/AGENTS.md dangling docs/AGENTS.md: blob advise leg-clone LEG leg-clone the leg
dot-mid docs/./AGENTS.md dangling docs/AGENTS.md: blob advise leg-clone LEG leg-clone the leg
dot-dslash .//docs/AGENTS.md dangling /docs/AGENTS.md: blob advise leg-clone LEG leg-clone the leg
dot-dot-slash ././docs/AGENTS.md dangling docs/AGENTS.md: blob advise leg-clone LEG leg-clone the leg
docs-dot docs/. dangling - advise advise EISDIR advise advisory
dot-slash-only ./ dangling (empty): tree advise advise EISDIR advise advisory
dot-only . dangling - advise advise EISDIR advise advisory
dot-slash-dot ./. dangling .: missing advise advise EISDIR advise advisory
dot-slash-dotdot ./.. dangling ..: symlink 2 advise not-answered EISDIR advise advisory
dot-up-locked ./../locked/AGENTS.md dangling ../locked/...: not asked (by hand: exit 128) advise not-answered LEG leg-clone the leg
up-locked (control) ../locked/AGENTS.md symlink 19 - not-answered not-answered LEG leg-clone the leg
dot-dslash-up-locked .//../locked/AGENTS.md dangling /../locked/AGENTS.md: symlink 19 advise not-answered LEG leg-clone the leg
dot-docs-upup-locked ./docs/../../locked/AGENTS.md dangling docs/../../locked/AGENTS.md: symlink 19 advise not-answered LEG leg-clone the leg
dot-docs-up-self ./docs/../AGENTS.md dangling docs/../AGENTS.md: dangling advise advise ELOOP advise advisory
dot-docs-up-docs ./docs/../docs/AGENTS.md dangling blob advise leg-clone LEG leg-clone the leg
trailing /. after the leg's file ./docs/AGENTS.md/. dangling docs/AGENTS.md/.: notdir advise advise ENOTDIR advise advisory
trailing / ./docs/AGENTS.md/ dangling notdir advise advise ENOTDIR advise advisory
trailing /./, //, /.. (three links) dangling notdir advise advise ENOTDIR advise advisory
non-UTF-8, leg ./docs/\xff.md dangling docs/\xff.md: blob advise leg-clone LEG leg-clone the leg
non-UTF-8, none ./docs/\xfe.md dangling missing advise advise ENOENT advise advisory
LF (control) ./docs/AGENTS.md\nx dangling - (not asked) advise advise ENOENT advise advisory
LF in the leg's name ./docs/a\nb dangling - (not asked) advise advise LEG leg-clone the leg (NIT-1)
same without ./ docs/a\nb blob - leg-clone leg-clone LEG leg-clone the leg
trailing CR ./docs/AGENTS.md\r dangling - (not asked) advise advise ENOENT advise advisory
CR in the leg's name ./docs/a\rb dangling - (not asked) advise advise LEG leg-clone the leg (NIT-1)
trailing / leading space ./docs/AGENTS.md / ./ docs/AGENTS.md dangling missing advise advise ENOENT advise advisory
tab, colon, ..foo, ... in the leg's name (four links) dangling blob advise leg-clone LEG leg-clone the leg
spelled like an answer, missing ./docs/x blob dangling <commit>:docs/x blob missing advise advise ENOENT advise advisory
spelled like an answer, present ./docs/x blob dangling blob advise leg-clone LEG leg-clone the leg
40-hex name missing / <hex> blob 9 present (two links) dangling missing / blob advise advise / leg-clone ENOENT / LEG same same
chain, . in the second link docs/AGENTS.md, docs -> ./.agents dangling - advise advise LEG leg-clone the leg (Known limit)
chain, . in the first link only ./docs/AGENTS.md, docs -> .agents dangling blob advise leg-clone LEG leg-clone the leg
chain, . in both ./docs/AGENTS.md, docs -> ./.agents dangling dangling advise advise LEG leg-clone the leg (Known limit)
physical .. ./docs/../AGENTS.md, docs -> .agents/sub dangling blob advise leg-clone LEG leg-clone the leg
through a link to a file ./flink/./AGENTS.md, flink -> docs/AGENTS.md dangling notdir advise advise ENOTDIR advise advisory
through a link out of the commit ./out/./AGENTS.md, out -> ../locked dangling out/AGENTS.md: symlink 19 advise not-answered LEG leg-clone the leg
into a gitlink ./sub/AGENTS.md / sub/AGENTS.md dangling missing / - advise advise LEG leg-clone the leg (Known limit)
to the gitlink itself ./sub dangling missing advise advise EISDIR advise advisory
absolute, with . <abs>/./locked/AGENTS.md symlink 102 - not-answered not-answered LEG leg-clone the leg
crafted: NUL in the blob (index only) ./docs/AGENTS.md\0x dangling - advise advise ENOENT advise advisory
crafted: empty link blob (empty) tree - advise advise ENOENT advise advisory

End to end (plan --project Thing --org testorg, then check --plan; scripts/probe.py ... e2e):

case base: plan / check head: plan / check doctor
plain (control) 0 leg NOTE / 1 leg NOTE 0 leg NOTE / 1 leg NOTE the leg
./docs/AGENTS.md, docs/./AGENTS.md, .//docs/AGENTS.md, ././docs/AGENTS.md, ./docs/../docs/AGENTS.md 0 advisory / 1 advisory 0 leg NOTE / 1 leg NOTE the leg
./../locked/AGENTS.md, .//../locked/AGENTS.md 0 advisory / 1 advisory 0 nothing / 1 nothing the leg
./docs/AGENTS.md/. 0 advisory / 1 advisory 0 advisory / 1 advisory advisory
./docs/a\nb (the leg) 0 advisory / 1 advisory 0 advisory / 1 advisory the leg (NIT-1)
chain, . in the second link 0 advisory / 1 advisory 0 advisory / 1 advisory the leg (Known limit)

No run printed a traceback.

The rows (base: 2abd8176's module a0b4ab7c laid into a scratch clone at head; head 0f8dae6e)

row base head
..._is_the_leg_in_the_commit[dot-slash], [dot-in-the-middle], [dot-and-a-double-slash] 3 FAILED (assert 'advise' == 'leg-clone') 3 passed
..._out_of_the_commit_is_not_answered[relative-after-a-dot] FAILED (assert 'advise' == 'not-answered') passed
CONTROL ..._advised_by_both_readers[not-utf-8] passed passed
CONTROLS [dot-then-through-the-legs-file], [dot-then-a-slash-after-the-legs-file], [dot-and-a-line-break], [dot-to-nothing-spelled-like-an-answer] 4 passed 4 passed
total 4 failed, 5 passed in 1.01s 9 passed in 13.23s

Each base failure also prints AssertionError: {'state': 'advise', ...}. Afterwards the clone's file was written back from HEAD's blob: 8edad0c8, equal to HEAD's.

The mutants

They ran in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/clone, each alone with -x over the new file, then read(commit=) over the 50 fixtures was compared with head. After each one the file was written back from HEAD's blob and checked to be 8edad0c8.

id mutant (head line) result first row failed probe answers changed
K1 CR check dropped (431) SURVIVED - ./docs/AGENTS.md\r advise -> leg-clone (disk ENOENT); ./docs/a\rb advise -> leg-clone (NIT-1)
K2 NUL check dropped (431) SURVIVED - only the crafted NUL blob (no checkout can hold one): advise -> leg-clone
K3 LF check dropped (431) KILLED [dot-and-a-line-break] LF control
K4 .. check by prefix (560) SURVIVED - ./..foo/AGENTS.md and ./.../AGENTS.md leg-clone -> not-answered (contrived names)
K5 the second ask's symlink taken as nothing (563) SURVIVED - 3 cases not-answered -> advise (SHOULD-1)
K6 fullmatch -> match (567) KILLED [dot-to-nothing-spelled-like-an-answer] ./docs/x blob advise -> leg-clone
K7 the second ask asks rel again (563) KILLED [dot-slash] 17
K8 only a leading ./ dropped (434-437) KILLED [dot-in-the-middle] dot-mid
K9 the .. check on the raw target (557-560) KILLED [relative-after-a-dot] (why is the Refusal's) dot-up-locked's why
K10 _follow asks HEAD:, not <commit>: (524) SURVIVED - none: no row or probe has HEAD differ from the commit read (check reads plan.source_commit). The first round's lines share this gap. Low priority
K11 a cleaned target that starts with / not asked again (562) KILLED [dot-and-a-double-slash] 2
K12 the second answer kept only when it is a blob (563) SURVIVED - same 3 as K5 (SHOULD-1)
R6 r1163's decode("utf-8", "replace") (570) KILLED [not-utf-8] -
X8 the writer's: every dangling asked again SURVIVED, equivalent - none of 50 (NIT-2 on its cost)
X9 the writer's: no absolute check SURVIVED, equivalent - none of 50

Mutants of my own (K1-K12): 6 of 12 killed. All 15 together: 7 of 15 killed. The rows proposed in SHOULD-1 and NIT-1 kill K5, K12 and K1. That would leave K2, K4 and K10 as accepted survivors, with X8 and X9 equivalent.

Every command's summary line

  • worktree add --detach .../l1-r1163b 0f8dae6e: HEAD is now at 0f8dae6ec.
  • Read-only gh:
    • pr view 1270: OPEN, draft, head 0f8dae6e, 2 files, closing [1163], title 249 characters, MERGEABLE.
    • pr view 1159: MERGED, merge commit 46fe6ac1, head 9e4aab8c.
    • api commits/main: 9729315f, which equals the local origin/main.
    • pr checks 1270: SonarCloud pass, package pass, tests-git-bash pass, tests pending.
  • Restack: trees, delta sha256 and patch-id as quoted. merge-tree 2abd8176 9e4aab8c gives fe694a20; merge-tree 6c4c8131 46fe6ac1 conflicts in scripts/shape_advisory.py only.
  • Merge with current main: merge-tree --write-tree 0f8dae6e origin/main (9729315f) is clean, tree 7e3bbee5. It differs from main only in the two files.
  • probe.py build: 50 fixtures. measure head and base: exit 0, 50 cases each. measure ... e2e: 11 cases each, exit 0.
  • Raw git: -z reads an LF name as one request; a trailing CR is removed; an inner CR is kept; a NUL cuts the name; ../x exits 128; .. gives symlink 2; docs/AGENTS.md/. gives notdir 57.
  • partial.py, m10.py, count.py and aside.py: quoted above. SHA-256 probe: head leg-clone, base advise, disk leg-clone.
  • Rows: head 9 passed in 13.23s, base 4 failed, 5 passed in 1.01s.
  • mutants.py: killed 7/15. fixrows.py: quoted under SHOULD-1.
  • Gate call 1 (the new file, test_doctor_unsearchable_links.py, test_shape_advisory.py): 121 passed in 100.84s.
  • Gate call 2 (the seven files): 285 passed in 242.72s.
  • Gate call 3 (test_scaffold_leg_path_collisions.py, test_path_guard_hygiene.py, test_no_auto_maintenance.py): 283 passed, 2 skipped in 32.29s. Both skips are test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set".
  • Gate call 4 (the doc trio): 171 passed in 40.56s.
  • 3.14-like shim (r1163b/shim314/sitecustomize.py), over the new file and test_doctor_unsearchable_links.py: 74 passed in 50.27s.
  • Gate call 1 on the predicted merge with 9729315f (tree 7e3bbee5 read into the rows clone, then reset): 121 passed in 91.31s.

What I did not cover

  • tests/test_shape_doctor.py and the full suite, which the brief excludes; I did not re-measure the body's four shard counts.
  • Any git newer than 2.43, Windows and macOS. I also did not check whether the oldest supported git has cat-file -z.
  • The writer's X1-X7 and X10 one by one. I ran my own K1-K12, which overlap X1, X4 and X10 in spirit, and re-ran R6, X8 and X9.
  • tree:0 partial clones, and a fixture where HEAD differs from the planned commit (K10).

All four checkouts are clean: /workspace/projects/openRepoShape-worktrees/l1-r1163b at 0f8dae6ec, and clone, rows (both 0f8dae6ec) and base (7029934c2) under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/. The evidence (scripts, logs and probe JSON) is in that scratch folder.

Lane: openreposhape-1 (openRepoShape-1)

…k out of the commit spelled `.//../locked/AGENTS.md` and a leg link that ends in a carriage return get a row each, and `_dot_segments_dropped` says what git 2.43 does with an LF, a CR and a NUL in a target (#1163)

r1163b's re-review of #1270: one SHOULD and the first NIT.

SHOULD-1: no row pinned a cleaned target that leaves the commit on the
second ask, where git answers `symlink`. `[relative-after-a-dot]` is caught
by the `..` check before git is asked again. The row
`[relative-after-a-dot-and-a-double-slash]` (`.//../locked/AGENTS.md`,
cleaned to `/../locked/AGENTS.md`) is. It fails on the base module
(`a0b4ab7c`, `advise`), and it kills r1163b's K5 (the second answer's
`symlink` read as nothing there) and K12 (the second answer kept only when
it is a blob).

NIT-1: the control `[dot-and-a-carriage-return]` (`./docs/AGENTS.md\r`,
advised by both readers) kills K1, the CR check dropped: git removes a
trailing CR from the name it is asked, so it would answer for
`docs/AGENTS.md` where the disk has no file of that name. The docstring of
`_dot_segments_dropped` now says what git 2.43 does: an LF splits the batch
input into two requests, git removes a trailing CR, and cuts the name at a
NUL. No code changes; `cat-file -z` is not used.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T18:33:22Z) Lane 1's round on r1163b's review (issuecomment-6725254247): 8e54c32d answers its SHOULD and its three NITs. In the module, only a docstring changes.

  • SHOULD-1: test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered gains the row relative-after-a-dot-and-a-double-slash, which is .//../locked/AGENTS.md.
    • With module a0b4ab7c laid in (base), it fails (assert 'advise' == 'not-answered'). At the head, it passes.
    • It kills K5 and K12.
    • r1163b's optional second row, ./docs/../../locked/AGENTS.md, is not added, because this test's fixtures commit no docs/ file.
  • NIT-1:
    • The new control [dot-and-a-carriage-return] is ./docs/AGENTS.md\r, which both readers advise. It passes on base and at the head, and it kills K1.
    • _dot_segments_dropped's docstring now says what git 2.43 does with such a target: an LF splits the batch input into two requests, git removes a trailing CR, and git cuts the name at a NUL.
    • The body adds a Known limit. If a leg file's name holds an LF or a CR after a . segment, the target is not asked again, so it is advised while the disk reads the leg.
    • cat-file -z is not used.
  • NIT-2: the body's X8 bullet now says "one git call".
  • NIT-3: the M10 bullet now names the reading where every git call fails, whatever AGENTS.md is.
  • Mutants: K1, K5 and K12 are each killed, with 1 failed each. Each file was restored and proved by blob equality.
  • Gates at 8e54c32d:
    • the new file with tests/test_doctor_unsearchable_links.py tests/test_shape_advisory.py: 123 passed;
    • tests/test_scaffold_leg_path_collisions.py with the two scans: 283 passed, 2 skipped;
    • the doc trio: 171 passed.
    • The seven-file call (285 passed) and the four tests/test_shape_doctor.py shards (446 passed, 3 skipped) ran at 0f8dae6e. They were not run again, since this round changes a docstring and two test rows.
  • Next: r1163b checks the round at 8e54c32d, and CI runs there. The body is updated to match.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T18:43:15Z) Independent delta re-check r1163c (r1163b, resumed) of #1270 at 8e54c32d: READY, with no MUST, SHOULD or NIT. Lane 1 marks #1270 ready for review and asks lane 3 for its place. It lands by fast refresh once CI's tests passes at 8e54c32d.

I'm r1163b. This is my delta re-check of #1270 (for #1163) at 8e54c32de0b901d379efeb4ad569e24c0eb0fd96: one commit on 0f8dae6e by w1163c, answering my review (issuecomment-6725254247).

Verdict: READY

There is no MUST and no SHOULD left.

  • SHOULD-1 is fixed. The new row [relative-after-a-dot-and-a-double-slash] fails with the base module laid in and passes at head. It kills K5 and K12.
  • NIT-1 is fixed. The control [dot-and-a-carriage-return] passes on both and kills K1. The docstring now matches what git 2.43 does, and the case it leaves is a Known limit.
  • NIT-2 and NIT-3 are fixed in the body.
  • No code changed. scripts/shape_advisory.py changed in one docstring only. Its AST is equal to 0f8dae6e's once docstrings are blanked.
  • Main merges cleanly. The gate call passes on the predicted merge with current main.

The checks

1. The rows, base against head

Base is 2abd8176's module, blob a0b4ab7c, laid into my scratch clone at 8e54c32d.

row base a0b4ab7c head 8e54c32d
test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered[relative-after-a-dot-and-a-double-slash] FAILED (AssertionError: {'state': 'advise', ...}, assert 'advise' == 'not-answered') passed
CONTROL test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers[dot-and-a-carriage-return] passed passed
total 1 failed, 1 passed in 0.39s 2 passed in 3.06s

Afterwards the module was written back from HEAD's blob and checked: 9122f59bd22b265e055f4207f56ae06b0c9c4c8d, equal to HEAD:scripts/shape_advisory.py. git status is empty.

2. The mutants

Each ran alone in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/clone at 8e54c32d, with -x over the new file. Afterwards read(commit=) ran over my 50 probe fixtures. The module was then written back from HEAD's blob and checked equal (9122f59b) before the next one ran.

id mutant result first row failed probe answers changed
K1 the CR check dropped (LF and NUL kept) KILLED, 1 failed, 24 passed in 26.79s [dot-and-a-carriage-return] 2 (as in round 1)
K5 the second ask's symlink read as nothing there KILLED, 1 failed, 10 passed in 18.40s [relative-after-a-dot-and-a-double-slash] 3
K12 the second answer kept only when it is a blob KILLED, 1 failed, 10 passed in 17.94s [relative-after-a-dot-and-a-double-slash] 3
R6 the followed blob decoded with errors="replace" (re-run) KILLED, 1 failed, 20 passed in 26.07s [not-utf-8] 0

The result is killed 4/4, and the writer's w1163c/mutants.log gives the same counts. K2, K4 and K10 stay accepted as in round 1, and X8 and X9 stay equivalent. The code they mutate is unchanged.

3. The docstring on an LF, a CR and a NUL (scripts/shape_advisory.py:428-434)

Each clause matches git 2.43.0 as I measured it, feeding cat-file --batch-check --follow-symlinks on stdin:

  • "an LF splits it into two requests": <c>:docs/a\nb\n gave <c>:docs/a missing then b missing.
  • "git removes a trailing CR": <c>:docs/AGENTS.md\r\n gave afd7d436.. blob 782 in a repository with no file docs/AGENTS.md<CR>.
  • "it keeps an inner one": <c>:docs/a\rb\n gave the blob of docs/a<CR>b.
  • "git cuts the name at a NUL": <c>:docs/AGENTS.md\0x\n gave the blob of docs/AGENTS.md.
  • "so a leg's file named so is advised while the disk reads the leg":
    • probe ./docs/a\nb and ./docs/a\rb: commit advise, disk leg-clone, doctor "clone of the spec leg";
    • without the ./, docs/a\nb and docs/a\rb: leg-clone in both. I measured the CR case this round.

The new test docstring sentence on the CR is right too: git answers for docs/AGENTS.md, and open() gives ENOENT.

4. The body (/workspace/projects/openRepoShape-worktrees/_lane1-scratch/w1163/pr-body.md)

The live PR body already equals this file; they differ by one trailing newline.

  • Review round 2: every claim matches what I measured.
    • The three probe cases K5 and K12 flip are named rightly, and the base advises in all three.
    • The new row holds not-answered, the PR's why, nothing refused, silent plan and check, and the doctor's leg. That matches my round-1 run of the same link from end to end: plan 0 nothing, check 1 nothing, doctor the leg.
    • The rows table and the mutants table match my counts exactly: 1 failed / 1 passed, then 2 passed; 1 failed with 24 passed, 1 with 10, 1 with 10. So does the restored blob, 9122f59b.
  • "it changes no code": true. The AST comparison gives module code equal apart from docstrings: True. The commit also rewords two test docstrings, which the sentence does not count, and that is harmless.
  • NIT-2 is applied in the X8 bullet: "one git call", with my 2-against-3 count.
  • NIT-3 is applied in the M10 bullet: four readings, "whatever AGENTS.md is (a regular leg file, a link, or none)".
  • The new Known limit is true as written. On its last sentence, "cat-file -z reads such a name whole in git 2.43", I measured:
    • -z with <c>:docs/a\rb\0 gives the blob;
    • -z with <c>:docs/AGENTS.md\r\0 gives <c>:docs/AGENTS.md\r missing, which is right, since -z keeps the CR;
    • round 1 measured -z with the LF name: blob.
  • Tests: the count is 27 rows (25 + 2). Gate call 1 gives 123 passed (27, 49 and 47), which matches mine. The statement that calls 2 and 5-8 were not re-run is fair for a test-and-docstring delta.
  • Closing form: Closes #1163 on line 5 is the only one. A grep for close, fix or resolve before #N finds only it, and so does one for Part of and Covers. GitHub's closingIssuesReferences is [1163].
  • Title: 249 characters.
  • Characters: the only non-ASCII ones are … and the footer's robot.

5. The optional ./docs/../../locked/AGENTS.md row

It does not block, and I leave no NIT for it. It goes through the same line as .//../locked/AGENTS.md: the second ask, which git answers symlink 19, then the symlink check at scripts/shape_advisory.py:568. The .// row already kills both mutants that turn that answer into advice (K5 and K12). The writer's reason holds too: this parametrized test calls linked_clone(tmp_path, target) with no files, so the row would need its own docs/ file.

6. Merge with current main

  • origin/main is 51a9c5f1, read back from GitHub. Main has moved four commits past 46fe6ac1; they touch neither of the PR's files.
  • git merge-tree --write-tree 8e54c32d origin/main gives 6b939ea757d7089417aed6d99a38df4e5b6a02d8, clean (exit 0). That tree differs from main only in the PR's two files (606 insertions(+), 1 deletion(-)).
  • Main's new commits change shape-doctor.py and adopt-project.py, which the new rows run. So I re-ran the gate call on that tree as well; see below.

Findings

  • MUST: none.
  • SHOULD: none.
  • NIT: none new.

Two things for lane 1 to know, neither a finding:

  • The body's gate table still says "restacked onto main 46fe6ac1". Main is now 51a9c5f1; the merge is clean and the gate passes on it.
  • CI at 8e54c32d: SonarCloud, package and tests-git-bash pass; tests is pending.

Every command's summary line

  • fetch --no-write-fetch-head origin refs/pull/1270/head and origin main in the shared repo (gc.auto=0, maintenance.auto=false): 8e54c32d is a commit; origin/main is 51a9c5f1, which matches gh api commits/main.
  • switch --detach 8e54c32d in l1-r1163b: HEAD is now at 8e54c32de. The scratch clones clone and rows were moved the same way.
  • git diff --stat 0f8dae6e 8e54c32d: 2 files changed, 34 insertions(+), 15 deletions(-). The AST check printed module code equal apart from docstrings: True.
  • Rows: head 2 passed in 3.06s; base a0b4ab7c 1 failed, 1 passed in 0.39s; restored to 9122f59b, equal to HEAD's.
  • mutants.py K1 K5 K12 R6: killed 4/4, each restored to 9122f59b.
  • Raw git: -z and CR cases, plus the docs/a<CR>b leg read without ./ (commit leg-clone, disk leg-clone), as quoted above.
  • Gate call, in the probe environment, at 8e54c32d (tests/test_advisory_agents_md_symlink.py, tests/test_doctor_unsearchable_links.py, tests/test_shape_advisory.py): 123 passed in 100.99s.
  • The same call on the predicted merge with 51a9c5f1: tree 6b939ea7, as scratch commit f04cca3e in rows only, then switched back to 8e54c32d. Result: 123 passed in 94.73s.
  • Read-only gh:
    • pr view 1270: head 8e54c32d, draft, MERGEABLE, closing [1163];
    • pr checks 1270: 3 pass, tests pending;
    • the live body was read back.

What I did not cover

I ran nothing wider than the one gate call, since the delta is a test file and one docstring. Gate calls 2-4, tests/test_shape_doctor.py, the full suite and the 3.14 shim were not re-run at 8e54c32d. My round-1 runs of them were at 0f8dae6e, whose code is identical.

All checkouts are clean:

  • /workspace/projects/openRepoShape-worktrees/l1-r1163b at 8e54c32de;
  • /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/clone and /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/rows at 8e54c32de;
  • /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1163b/base at 7029934c2.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 18:43
@sourcery-ai

sourcery-ai Bot commented Nov 8, 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 6 days and 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant