Skip to content

adopt-project.py: check names each case twin at the shape's copy, above it and at the manifest, before a link there or after it, and at a pin's file whose exact spelling holds nothing, as _KeptTree keeps every row, not the first (#1138) - #1170

Merged
brettheap merged 16 commits into
mainfrom
fix/1138-case-twin-after-link
Nov 8, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1138

Was stacked on #1118 (branch fix/1089-named-link-skip-tests, head 2307036a). #1118 has landed, as the squash 9ec0dc79 on main, from its final head f4eef876 (update-branch's merge of main 46fe6ac1 into 2307036a). This branch now holds that squash and main at 9729315f, by three merges and no rebase (see "Restacked after #1118 landed" below). Outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0"): this PR lands after the v1.2.0 tag, on a later word.

What

r1118 found, as NIT-5, that a case twin that sorts AFTER a link at a shape copy was never named while the link stayed. _KeptTree._at kept the first row at a path, so a place was one group and its first row in tree order stood for it. On a disk that ignores case, shape/README.md and shape/readme.md are one place, and which of them a clone holds is git's checkout, not the plan's. The plan keeps both, and both are in the way.

Against #1118's head the diff is adopt-project.py (+124, -63) and tests/test_adopt_symlink_target_bytes.py (+276, -10), in 5 commits: the code, a first row, the other rows, a comment-only reword of the tests, and a docstring-only reword of _kept_pin_finding (r1170's SHOULD-3). Every caller of the first-row read is changed in this one PR, each with a row, by lane 1's scope ruling.

  • _KeptTree holds a list of rows per key. every(path) answers all of them in tree order, at(path) the first (what it always answered, for the two readers of ONE row: the root's .gitattributes, whose bytes are one file's, and _links_at_a_pin, whose walks meet one twin). A disk that keeps case has one row at a path at most, so it answers as before. file_above is now files_above, a list.
  • _kept_there answers a list of (group, rows): for EACH row at the place the group ("at", that row's own path). A twin is a thing in the way of its own, so it is named by its own finding, and the skip for a named link hides the link's group and no twin's.
  • _shape_blocks gives a block for each row files_above and _kept_there answer: above the shape's directory, above scripts/, at the copy and at the manifest.
  • _kept_pin_finding names each row at a pin's file that is not a named link, where it was asked of one row. A named link among them is left out and no twin with it. Rows under a directory there stay the first only. The disk that ignores case is asked only where the disk that keeps case found nothing (adopt-project.py check: refuse a kept file at the shape's pin path, and an unwritable --work-dir, before any leg exists #524, unchanged), so a pin's file with any row at its exact spelling leaves the twins at other spellings unasked: the third known limit.
  • _links_at_a_copy, _links_at_the_manifest, _holds_a_pin read the groups and rows the same way. _links_at_a_copy asked group == ("at", copy), which a twin spelled otherwise at the copy's place no longer meets, so it asks group[0] == "at".
  • Docstrings: the _KeptTree class, _kept_pin_findings, _root_shape_findings, _links_at_a_copy, _links_at_the_manifest and _holds_a_pin say it, _kept_pin_finding's also says when the disk that ignores case is asked, and the comment above _KeptTree names every case twin as in the way.

What check says, base against head

The matrix is 30 sources and kept-in-root sets, each run through check at base (2307036a) and at head, with each run's own path and each hash of 12 to 40 hex digits masked. 29 ran; dir-twin-after-good-link is refused by plan at both ("no plan entry for shape/"), as the matrix builds it.

  • Every stdout is the same, but for the source commit hash.
  • 14 cases ADD exactly one finding, and none loses a line.
  • 1 case EXTENDS a line. The named link Project.yaml, after the file PROJECT.yaml, has the manifest tail on its plan-bad-symlink-target line at head ("The plan keeps it in the assembly root, at project.yaml once case is ignored, ... (plan-root-blocks-manifest, not said again here): a target of its own would not end that, ..."). At base it lacked it. The finding count stays at 2.
  • 14 cases are unchanged.
Case The source Head adds
p4-after-bad shape/readme.md after a bad link at shape/README.md, shape/ kept (the issue) plan-root-blocks-shape-copy
p4-after-good the same, the link's target good plan-root-blocks-shape-copy
p5-before-good Shape/README.md before a good link at shape/README.md plan-root-blocks-shape-copy
mk-after-bad, mk-after-good shape/makefile after a bad, or a good, link at shape/Makefile plan-root-blocks-shape-copy each
lk-twin-after-file-dangling a link twin with a target of its own at shape/readme.md, after the file shape/README.md plan-root-blocks-shape-copy
lk-twin-after-good-dangling the same, after a good link plan-root-blocks-shape-copy
fa-link-then-file a link SHAPE, then the file Shape, above the copy plan-root-blocks-shape-copy
fa-file-then-file the files SHAPE and Shape above the copy plan-root-blocks-shape-copy
fa-scripts-link-then-file a link SCRIPTS, then the file Scripts, above scripts/ plan-root-blocks-shape
gl-then-file a submodule at shape/README.md, then the file shape/readme.md plan-root-blocks-shape-copy
mf-good-link-twin-file a good link at project.yaml, and the file Project.yaml plan-root-blocks-manifest
mf-bad-link-then-file a bad link PROJECT.yaml, then the file Project.yaml (tree order) plan-root-blocks-manifest
pin-bad-link-then-file a bad link contracts/SHAPE-pin.yaml, then the file contracts/Shape-pin.yaml plan-root-holds-pin
mf-file-then-bad-link the file PROJECT.yaml, then a bad link Project.yaml (tree order) no new finding: the link's line extends, as above

The 14 unchanged: dir-twins, fa-exact-file, fa-file-alone, ga-twins, lk-alone-dangling, lk-twin-alone-dangling, mf-bad-link-twin-file, mf-file-alone, p4-after-nolink, p5-before-bad (#1118's case 1), p5-before-nolink, pin-exact-bad-link-twin, pin-file-alone and pin-file-then-bad-link.

Why

Tests

The runs used a stub gh first on PATH, GH_TOKEN=bogus-w1138, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false and EMAIL, under env -u LANES_LANE, with one basetemp each and -o tmp_path_retention_policy=failed.

The rows: 17 new, 3 of #1118's beside them

tests/test_adopt_symlink_target_bytes.py goes from 133 tests to 150. The 20 rows of 9 test functions, base (2307036a, given head's test file) against head 326daf9a:

Test Rows
test_a_named_link_leaves_out_its_own_finding_and_no_other #1118's three, and new a-case-twin-after-the-readme-copys-link
test_a_case_twin_beside_a_link_git_makes_at_the_copy_is_named_too a-twin-before-the-link, a-twin-after-the-link
test_a_link_twin_at_the_copy_is_named_once_by_the_copys_finding a-link-twin-alone (control, passes at base), a-link-twin-after-a-file, a-link-twin-after-a-link
test_each_file_twin_above_a_place_the_shape_writes_is_named a file after a named link, and two files, above the copy (SHAPE/Shape) and above scripts/ (SCRIPTS/Scripts)
test_a_case_twin_after_a_submodule_at_the_copy_is_named one row
test_each_case_twin_at_the_manifest_is_named a-file-twin-after-a-link-git-makes, a-file-twin-after-a-named-link, a-named-link-after-a-file-twin (asserts the manifest tail)
test_a_case_twin_after_a_named_link_at_a_pin_file_is_named check level
test_each_file_twin_at_a_pin_file_is_named unit, _pin_findings
test_a_file_twin_after_a_submodule_at_the_pin_holds_the_pin unit: _pin_findings names contracts/Shape-pin.yaml, and _root_shape_findings == [] (for _holds_a_pin)
Tree Result over the 20 rows
base 2307036a 16 failed, 4 passed: #1118's three cases, and the control a-link-twin-alone
head 326daf9a 20 passed
head 4df784ac (a comment-only commit after it) 20 passed, inside the whole file: 150 passed (gate call A)
head 24ee133a (a docstring-only commit after that) 20 passed, inside the whole file: 150 passed in 238.35s (call A again)

Each of the 16 failures is for the twin the row names, or for the missing manifest tail; the pin-submodule row finds nothing at base. No row asserts text that depends on the Python version.

Mutants

Each was written into a scratch git archive copy, restored in a finally and proved by sha256 (eddc09f41bd0e955 each time, which is adopt-project.py at both 326daf9a and 4df784ac: the one commit between them is comment-only, in the test file). M1, M12, M13p and M14 ran against the whole of tests/test_adopt_symlink_target_bytes.py (150 tests) without -x, at 326daf9a. M2 to M11 and M7b ran at 4df784ac against the test function written for each, which is the first kill that matters (a mutant that fails a row is killed): each failure is an AssertionError at the row, not an import or a syntax error. None of the 12 passed its function, so none needed the whole-file run to prove it survives.

adopt-project.py at the head 24ee133a has the sha256 cbc42517033907c0: only _kept_pin_finding's docstring differs from 4df784ac's. ast.unparse of the two files with every docstring emptied is equal, so no mutant, which changes code, needs a re-run.

r1070's mutants, M13 ported onto #1091's _kept_pin_finding as in #1118:

Mutant At #1118's head At head
M12, the copy skip marks the group seen KILLED by a-case-twin-of-the-readme-copy SURVIVED (150 passed): equivalent, as each twin's group is its own
M13p, the pin skip returns KILLED by 1 row KILLED by 1: a-file-at-the-shape-pin-after-a-declared-pins-link
M14, the copy skip breaks KILLED by 1 row KILLED by 3: a-case-twin-after-the-readme-copys-link, a-file-at-the-makefile-copy-after-the-readme-copys-link and a-file-twin-after-a-named-link

The comment above LINK_BESIDE_A_SECOND_PATH said the first case kills M12. It now says M12 survives as an equivalent mutant and the first case is a control.

This change's own mutants:

Mutant What it does Result
M1 _KeptTree keeps the first row at a place only KILLED by 14 rows of 150: the twin-after rows of every group
M2 files_above asks the first twin above only KILLED by 4 of 4 rows of test_each_file_twin_above_a_place_the_shape_writes_is_named: a file after a named link and two files, above the copy and above scripts/
M3 files_above returns the first file only KILLED by 2 of the 4: two-files-above-the-copy and two-files-above-the-shapes-scripts (the one-file rows pass, as they should)
M4 _kept_there asks the first row at the place only KILLED by 2: a-twin-after-the-link and a-case-twin-after-the-readme-copys-link (of 6 run: the twin-before rows pass)
M5 _kept_there groups each twin by the place, not its own path KILLED by 2 of 3: a-link-twin-after-a-file and a-link-twin-after-a-link (the control a-link-twin-alone passes)
M6 _kept_there answers nothing where the FIRST row is a submodule KILLED by its 1 row, test_a_case_twin_after_a_submodule_at_the_copy_is_named
M7, M7b _shape_blocks asks the first file above the shape path (M7), or above the copy (M7b, an extra), only M7 KILLED by 1 of 4: two-files-above-the-shapes-scripts. M7b KILLED by 1 of 4: two-files-above-the-copy. Each of the two rows kills one and not the other
M8 _links_at_a_copy names a link only at the copy's exact spelling KILLED by 1 of 3: a-link-twin-alone, the control that passes at base (the twin-after rows hold a file at the exact spelling, so they do not meet it)
M9 _kept_pin_finding asks the first row at the pin's file only KILLED by its 1 row, test_a_case_twin_after_a_named_link_at_a_pin_file_is_named
M10 _kept_pin_finding keeps the first row left after the named links KILLED by its 1 row, test_each_file_twin_at_a_pin_file_is_named
M11 _holds_a_pin asks the first row at the pin's file only KILLED by its 1 row, test_a_file_twin_after_a_submodule_at_the_pin_holds_the_pin

M1's 14 failed rows are: a-case-twin-after-the-readme-copys-link, a-twin-after-the-link, a-link-twin-after-a-file, a-link-twin-after-a-link, the four ABOVE rows, test_a_case_twin_after_a_submodule_at_the_copy_is_named, a-file-twin-after-a-named-link, a-named-link-after-a-file-twin, test_a_case_twin_after_a_named_link_at_a_pin_file_is_named, test_each_file_twin_at_a_pin_file_is_named and test_a_file_twin_after_a_submodule_at_the_pin_holds_the_pin.

The gate

The gate ran in the worktree at 4df784ac, in the probe environment above, one pytest call per row, one basetemp each, never more than two calls at once.

Call Files Result
A tests/test_adopt_symlink_target_bytes.py 150 passed in 186.82s
B tests/test_adopt_root_shape_paths.py, tests/test_adopt_leg_path_collisions.py, tests/test_scaffold_leg_path_collisions.py 322 passed in 161.02s
C test_adopt_git_rm_bytes, test_adopt_make_reads_first, test_adopt_mount_ignored, test_adopt_pins, test_adopt_root_manifest, test_adopt_root_symlinks, test_adopt_shape_pin_and_work_dir The first call reached its 530 s limit at about 80%, with every test so far passing. Rerun as two calls: test_adopt_root_symlinks gave 205 passed, 3 skipped (301.0 s), and the other six gave 260 passed (272.9 s)
D test_adopt_verify, test_adopt_nested_attributes, test_adopt_clone_claimed, test_non_utf8_git_reads, test_reader_refusals_named, test_shape_advisory, test_update_shape 385 passed (321.9 s)
E the doc trio: test_repo_hygiene, test_agents_md_codes, test_cli_reference 170 passed (36.6 s)
tests/test_shape_doctor.py, 4 shards of 112 of its 448 tests shard 0 111 passed, 1 skipped (240.2 s)
shards 1, 2 and 3 112 passed (194.2 s); 111 passed, 1 skipped (191.2 s); 111 passed, 1 skipped (206.8 s)

The gate again, at 24ee133a

The docstring commit, in the same environment, one basetemp per call, never more than two calls at once.

Call Files Result
A tests/test_adopt_symlink_target_bytes.py 150 passed in 238.35s
B tests/test_adopt_root_shape_paths.py, tests/test_adopt_leg_path_collisions.py, tests/test_scaffold_leg_path_collisions.py 322 passed in 206.80s
E the doc trio 170 passed in 50.41s
hygiene tests/test_path_guard_hygiene.py, over the merge into origin/main (below) 49 passed in 21.02s
tests/test_shape_doctor.py, shard 0 112 of its 448 tests 111 passed, 1 skipped in 219.77s
shard 1 112 tests 112 passed in 210.28s
shards 2 and 3 224 tests 111 passed, 1 skipped; and 111 passed, 1 skipped
C and D the files above C: 205 passed, 3 skipped (reftable needs git 2.45), and 260 passed. D: 385 passed. These are the same counts as at 4df784ac
  • tests/test_path_guard_hygiene.py is on origin/main, not on this branch, so it ran over the merge. git merge-tree --write-tree origin/main 4df784ac is clean (tree 9d3a2aa8; origin/main was 961640c6, which the remote still held when it ran), and a commit of that tree in a scratch clone gives 49 passed in 16.19s; its adopt-project.py is identical to the head's. At 24ee133a the merge is tree 652c11ee and gives 49 passed in 21.02s. The diff adds no bare guard to adopt-project.py either: none of its added lines holds is_file, is_dir, .exists(, is_symlink or os.path.
  • 55 of the 68 test_adopt_*.py files were left out of the local gate. Calls A to D hold 13 of them. In all, the local gate ran 22 of the 309 tests/test_*.py files: those 13, test_scaffold_leg_path_collisions, test_non_utf8_git_reads, test_reader_refusals_named, test_shape_advisory, test_update_shape, the doc trio and test_shape_doctor. CI covers the rest: its full tests job passed on CPython 3.14 at 4df784ac, the head before the docstring commit, and again at 24ee133a (run 39859853515: 19672 passed, 385 skipped, read by r1170b).

Restacked after #1118 landed

Three merges and no rebase, each pushed and read back. main moved once after #1118's squash, to #1123's squash, before the third merge.

Merge Commit Parents Tree
1. #1118's final head F, f4eef876 (update-branch's merge of main 46fe6ac1 into 2307036a): clean ca6154989e8dcfb44da0159367ffd0d9ab2578c3 24ee133a, f4eef876 9ce681b71d53cfe112f0b84c61ebbaac15f7363a
2. #1118's squash S, 9ec0dc79c5725ff9d8bc4fcd69de3db272ab97ed, with --no-commit and the branch's side for S's own four files 1b3dbfe6b15facf6b1b2496e85f7c9feef9b7b9a ca615498, 9ec0dc79 9ce681b71d53cfe112f0b84c61ebbaac15f7363a
3. main at 9729315f1db1285fbd566f66e330f4d2732fd420 (#1123's squash, for #1061): clean, adopt-project.py merged by git 05fa55e050a20e7b564b03195275e0f41e919a3d 1b3dbfe6, 9729315f cc39cd8f2c82f2ebf310a289b68c1fb733f4033e
  • Merge 2's tree check. S's own files (git diff --name-only S^ S) are adopt-project.py, scripts/shape_materialize.py, tests/test_adopt_root_shape_paths.py and tests/test_adopt_symlink_target_bytes.py. Git stopped on one conflict, in tests/test_adopt_symlink_target_bytes.py. Each of the four files was written from the branch's blob (git cat-file blob HEAD:<path>) and added. git ls-files -u was then empty, and git write-tree gave 9ce681b7, merge 1's tree, as it must: S's tree equals F's (9737190e), since main did not move between update-branch and the squash.
  • The PR's own lines are unchanged. The + and - lines of git diff -U0 2307036a 24ee133a (the reviewed delta) and of git diff -U0 9729315f 05fa55e0 (main against the new head) are byte for byte the same: 400 added and 73 removed lines each, sha256 1b3dfac35738eef107daa59a4c22f910897fb4f32936fc701605270b97f3d278 each. Both diffs touch adopt-project.py and tests/test_adopt_symlink_target_bytes.py only, so no other file differs from main.
  • tests/test_path_guard_hygiene.py is now on this branch, from merge 1, so it ran on the head itself (call C below), not over a scratch merge.

The gate at 05fa55e0

The probe environment above (with GH_TOKEN=bogus-w1170r), one basetemp per slot, never more than two calls at once.

Call Files (tests) Result
A tests/test_adopt_symlink_target_bytes.py (150), tests/test_adopt_root_shape_paths.py (123) 273 passed in 260.53s
B tests/test_adopt_check_throwaway_repos.py (152) 151 passed, 1 skipped in 161.95s
C tests/test_scaffold_leg_path_collisions.py (104), tests/test_path_guard_hygiene.py (121), tests/test_no_auto_maintenance.py (60) 283 passed, 2 skipped in 33.46s. Both skips are test_a_persons_own_global_config_is_still_read_beneath_the_two, which skips by design when the session begins with GIT_CONFIG_GLOBAL set, as the probe environment sets it
D the doc trio: tests/test_agents_md_codes.py (47), tests/test_cli_reference.py (23), tests/test_repo_hygiene.py (101) 171 passed in 42.69s
tests/test_shape_doctor.py, 4 shards of its 449 tests shard 0 (113), shard 1 (112) 112 passed, 1 skipped in 183.78s; 111 passed, 1 skipped in 166.00s
shard 2 (112), shard 3 (112) 112 passed in 180.36s; 111 passed, 1 skipped in 178.80s

Known limits

  • Directory twins are one group. ("dir", path) names the rows below a directory at the place, so shape/readme.md/y beside shape/README.md/x is one finding, and a directory twin beside a row AT the place is not named while the row is: _kept_there answers the rows at the place when it finds any, and the directory only when it finds none. _kept_pin_finding does the same at a pin's file: a directory twin beside the file contracts/Shape-pin.yaml is not named (r1170b's z-pin-other-dir-twin-file, the same at base and head).
  • .gitattributes twins are read through at(), the first row: one file's bytes are read, and ga-twins is unchanged.
  • A pin's file with any row at its exact spelling leaves the twins at other spellings unasked. _kept_pin_findings asks the disk that keeps case first and the one that ignores it only where that found nothing (adopt-project.py check: refuse a kept file at the shape's pin path, and an unwritable --work-dir, before any leg exists #524), so a file, a link (one named_links holds, where adopt-project.py: check passes a symlink target of 1024 to 4095 bytes that macOS and XFS cannot make, and a bad link at shape/README.md or contracts/shape-pin.yaml is also named by plan-root-blocks-shape-copy or plan-root-holds-pin with a remedy that cannot help it (follow-ups of #908) #979's rule ends the question too, or one it does not) or the rows of a directory at the pin's exact spelling end the question, and a twin at another spelling is not named, as before this PR. Measured at base (2307036a) and at head alike, one finding each and the twin never named: contracts/shape-pin.yaml and the file contracts/Shape-pin.yaml (x-pin-exact-file-twin-file); a good link at the exact spelling and a file twin (x-pin-exact-goodlink-twin-file); a directory at the exact spelling and a file twin (y-pin-exact-dir-twin-file); and pin-exact-bad-link-twin of the matrix. A submodule at the exact spelling does not end it (y-pin-exact-submodule-twin-file: 2 findings at both, the twin's and plan-submodule-root's). A twin of a row at ANOTHER spelling is asked, before the row or after it (pin-bad-link-then-file adds a finding; x-pin-file-then-good-link: base 1, head 2). At a copy the same shape does get its twin named (x-copy-exact-file-twin-file: base 1, head 2). _kept_pin_finding's docstring says so.

Not in this PR

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Report all kept case-insensitive path twins that can obstruct project adoption while retaining the existing handling for single-row readers.

Bug Fixes:

  • Update check to report every case-insensitive path twin that can block shape copies, manifests, or pin files, including twins beside links and submodules.

Enhancements:

  • Preserve single-row behavior for consumers that require one entry while allowing collision analysis to process all rows at a case-insensitive path.
  • Refine link exclusion and grouping so named links suppress only their own findings and do not hide neighboring case twins.

Tests:

  • Add coverage for case twins at copies, paths above shape destinations, manifests, and pin files, including ordering around links and submodules.

Brett Heap and others added 10 commits October 21, 2026 10:58
…ings docstring (N3), shape_materialize.py's two mentions of a bad link at the copy (N4) and their test; N1 left, mutants and gate not yet run

Work as it stands at a coordinator checkpoint, not a finished change. Built: three cases in tests/test_adopt_symlink_target_bytes.py (the case twin of the README copy, a file at the shape's own pin after a declared pin's link, a file at the Makefile copy after the README copy's link), each asserting the second finding is still made; they pass on head and on base 2a119f2. The _root_symlink_findings docstring names the #979 exception. scripts/shape_materialize.py (not pinned or copied: in no copy list, no byte check) says in copy_in_the_way's docstring and in materialize-copy-blocked's remediation that a link plan-empty-symlink or plan-bad-symlink-target names is that finding's alone; a test in tests/test_adopt_root_shape_paths.py pins both. Not run yet: M12-M14 against this head, the gate. N1 is left (see the PR body when it is written).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er takes any other path's finding with it, a case twin of the copy, a later pin file or a later copy, and the `_root_symlink_findings` docstring and shape_materialize.py name the #979 exception (#1089)

On top of the checkpoint 702ce0f: the Makefile-copy case answers the link's own entry (shape/README.md) root, as plan proposes spec for it by its .md extension, so the link is kept in the root and the copy skip that M14 breaks is reached; M14 is killed now. A lost second finding fails with the findings listed, not an unpack error.

Part of #1089: SHOULD-2, N3 and N4. N1 (a location tail on the link's own finding) is left.

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

Main at 4ed7c7b (#1091, #1101, #1102). tests/test_adopt_root_shape_paths.py
conflicted where both sides added a test after
test_the_materializer_never_writes_a_copy_over_what_is_there: main's
test_the_materializer_stops_at_a_link_at_the_copy (#904) and this branch's
test_the_materializer_names_a_bad_link_at_the_copy_by_its_own_finding
(#1089, N4). Both are kept whole, main's first. adopt-project.py merged
by itself. On the merge, tests/test_adopt_root_shape_paths.py gave 122
passed and tests/test_adopt_symlink_target_bytes.py 133 passed.

Part of #1089

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a bad link at the copy is given a target of its own, or removed, and committed BEFORE `plan` is re-run, and that, kept there with a target of its own, it is then `plan-root-blocks-shape-copy`'s, whose answer is the one that helps; it and `copy_in_the_way` say "names one by one"; the N4 test holds a link (#1089)

SHOULD-1 of r1118: the remediation read as if a new target were enough,
and left out "commit". Measured: a link at `shape/README.md` with a
target of its own is still refused by the materializer; once committed
and re-planned with `shape/` kept in the root, `check` names it as
`plan-root-blocks-shape-copy`; with `shape/` dropped, sent to a leg, or
left to `plan`'s own `spec`, `plan ok`. A fix in the index, not
committed, is still `plan-bad-symlink-target`; a fix committed against
the old plan is `plan-stale`.

NIT-1: "names one by one", as N3 says, in the remediation and in
`copy_in_the_way`'s docstring: a 21st empty link at the copy, only
counted, is still `plan-root-blocks-shape-copy`'s.

NIT-2: the N4 test now sets up the link at `shape/README.md` in both
forms a clone holds a late-NUL target `spec\0.md` in: a link to `spec`
where git makes links (under `needs_disk_symlinks`), and a file of the
blob's 8 bytes where `core.symlinks` is false. It asserts the refusal,
that the link or file is as it was, and nothing written where it leads,
and pins the remediation from "reports a plan" to "Then run `execute`
again" as one run of words.

Part of #1089

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t is one (#1089)

NIT-3 of r1118, in tests/test_adopt_symlink_target_bytes.py: "each case
is a mutant of them" becomes "each case kills a mutant of them". The
paragraph is reflowed; no other word changed.

Part of #1089

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pe writes, the code only, no test rows yet (#1138)

Work in progress, stopped at a lane checkpoint. Code only: no test rows,
no mutants and no gate yet.

- `_KeptTree._at` keeps every row at a path (`every`); `at` is the first
  row, kept for the root's `.gitattributes` reads and `_links_at_a_pin`.
- `files_above` (was `file_above`) returns each FILE twin at the topmost
  place holding one; `_kept_there` gives a group `("at", row path)` per
  row at a place, so each twin is its own finding; `_shape_blocks` emits
  them all; `_links_at_a_copy` reads `group[0] == "at"`.
- `_kept_pin_finding` names each row at a pin's file but a named link, and
  `_holds_a_pin` asks every row.

Measured by probe (matrix.py, base 2307036 vs this tree): each change is
one added finding naming a twin base left unnamed (the issue's twin after a
bad or good link, a twin before a good link, the Makefile copy, a file twin
above the copy, the manifest, a pin's file, a submodule twin); no base
finding is lost in its first 300 characters. Full-line comparison not done.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… case 1's twin before it (#1138)

`a-case-twin-after-the-readme-copys-link` keeps `shape/readme.md` beside
a named link at `shape/README.md`. At #1118's head 2307036 it fails
"the second finding is lost": `_KeptTree` kept the first row at a place,
the link, so the twin was never asked. At dfacd5d it passes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…link git makes at the copy, above the copy, at the manifest and at a pin's file (#1138)

One row per caller of `_KeptTree`'s first row (lane 1's scope ruling):
the copy's twin beside a link git makes, before and after it; a link
twin at the copy, named once by the copy's finding; a file after a named
link, or a second file, above the copy and above `scripts/`; a file
after a submodule at the copy; the manifest's twins, a named link after a
file twin keeping its tail; a file after a named link at a pin's file,
two file twins there, and a file after a submodule there, whose copy
under `shape/` is not asked again.

At #1118's head 2307036, 16 of the 20 rows of these tests fail, each
for the twin it does not name; the 4 that pass are #1118's three cases
and the lone link twin. At dfacd5d all 20 pass.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ves as an equivalent mutant and the twin-after case kills M14 too, as measured at head (#1138)

#1138 gives each case twin at a place a group of its own, `("at", its own
path)`, so the copy skip's marking the link's group seen hides no twin.
Measured at 326daf9 against the whole of
tests/test_adopt_symlink_target_bytes.py, without -x, in a scratch copy:
M12 SURVIVED (150 passed), M13p KILLED by the declared-pin row alone, M14
KILLED by 3 rows (the twin-after row, the Makefile row and the manifest's
a-file-twin-after-a-named-link). The comment still said the first case
kills M12. Comment only; no code or row changed.

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

sourcery-ai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

Make _KeptTree retain every case-folded row and expose all rows at a path, while keeping first-row reads for callers that need one row; group each row at a written place independently.

  • adopt-project.py L7308-7321, L7329-7341, L7350-7363 (+2 small edits)

Implementation

Use the all-row view to report every non-named-link row at a pin’s file, while preserving the existing disk-selection and directory-row behavior.

  • adopt-project.py L726-728, L764-800

Emit shape-block findings for every file twin above a destination and every row at the copy or manifest, and update link and pin helpers to recognize the new per-row groups.

  • adopt-project.py L7258-7266, L7386-7396, L7515-7524 (+6 small edits)

Tests

Add coverage for case twins beside links, above shape destinations, at the manifest and at pin files, including submodules and the findings that should remain uniquely attributed to links.

  • tests/test_adopt_symlink_target_bytes.py L38-44, L823-842, L912-1155 (+1 small edits)

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-10-22T21:21:44Z) Independent review r1170 of #1170 at 4df784ac, partial, stopped at the shared window's LAST STEP: NOT READY. It found no MUST and three SHOULDs:

  • Gate ruling, test_adopt_* files: the ruling on the 29 files is false.
  • Gate ruling, test_path_guard_hygiene.py: the claim that it is not in the tree is wrong. It is on main, and it gives 49 passed on the merged tree.
  • Known limit 3: it is narrower than what the code does. Any row at a pin's exact spelling leaves its twins unasked.

The code does what the body says: the matrix was re-derived, 13 of 14 of the reviewer's mutants are killed, and M12 is shown equivalent. Lane 1 runs the fix round in a later window.

r1170 review of PR #1170 (head 4df784ac, base 2307036a). This is the review so far: lane 1 sent LAST STEP partway through the mutant runs.

Verdict: NOT READY (no MUST so far; three SHOULDs)

The code does what the body says, as far as I measured. The three SHOULDs are about the body: two gate rulings are wrong, and the third known limit is narrower than what the code does.

What I re-derived

Matrix. I wrote my own harness (/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1170/tools/matrix_r.py). It runs the same 30 cases at base (cb) and head (ch), masks hashes and run paths, and diffs the output.

  • 14 cases add exactly one finding, with the code the table gives for each.
  • 1 extends: mf-file-then-bad-link gains the manifest tail; the count stays at 2.
  • 14 are byte-identical.
  • 1 is refused at both: dir-twin-after-good-link gives PLAN REFUSED: no plan entry for shape/.
  • No FINDING line is lost; the only other changed line is the N finding(s) in count. Every stdout is identical once masked.

The 20 rows. The head test file run against base adopt-project.py gives 16 failed, 4 passed in 28.71s. The 4 that pass are #1118's three and a-link-twin-alone. Each failure is the twin missing or the tail missing. The submodule-at-the-pin row fails with ValueError: not enough values to unpack (expected 1, got 0), which matches the body's "finds nothing at base".

Whole file at head. 150 passed in 192.10s. That is 133 tests at base, 17 new ids and none dropped.

3.14-like shim (shim314/sitecustomize.py): the 20 rows give 20 passed at head and 16 failed, 4 passed at base. The diff adds no path check.

M12 is an equivalent mutant.

  • Measured: it SURVIVED the whole file (150 passed in 190.41s), and the file was restored to blob ae19e3e528ac, which is HEAD's.
  • Why it can never differ: _kept_there is the only maker of ("at", X) groups, and each such group carries rows [row] with row[0] == X.
    • So a later (code, rel, group) that M12 marks as seen has rows[0][0] == X, which is in named_links, and the original skip already drops it.
    • seen is read nowhere else.

My own mutants (tools/mut_r.py, each restored and checked against HEAD's blob). 14 run, 13 killed:

Mutant Rows failed of 20
R1, _KeptTree keeps the first row only 14
R2, files_above returns the last file only 2
R3, files_above counts a link as a file 2
R4, a twin grouped by the place (M5) 5
R5, _kept_there keeps a submodule twin 1
R6, _kept_there keeps the last twin only 5
R7, the manifest asks its first group only 2
R8, the copy asks its first group only 4
R9, the last file above the shape path only 1
R10, the last file above the copy only 1
R11, _links_at_the_manifest asks the exact spelling only 2
R12, _links_at_a_copy asks the case-keeping disk only 1
R13, a named first row ends the pin's answer, as at base 1

The one survivor is M12, which is equivalent.

Body facts that check out:

  • numstat: 115 63 adopt-project.py, 276 10 tests/..., in 4 commits.
  • 326daf9a..4df784ac changes comments only.
  • The sha256 of adopt-project.py is eddc09f41bd0e955 at both 326daf9a and 4df784ac.
  • The title is 246 characters.
  • The body has exactly one closing line, Closes #1138.
  • The local ref origin/fix/1089-named-link-skip-tests is 2307036a.
  • The CI run on 4df784ac (pull_request) passed: tests, tests-git-bash, package and SonarCloud.

Findings

SHOULD-1: the ruling that 29 test_adopt_* files "cannot be reached" is false, and the counts do not add up. (Body, "The gate".)

  • Evidence: conftest.py:853: ADOPT = REPO / "adopt-project.py".
    • 28 of the 29 files import ADOPT.
    • The 29th, test_adopt_submodule_url_answers.py, imports its helpers from test_adopt_relative_submodule_url.py, which uses ADOPT.
  • 6 of the 29 are in the PR's own calls A, C and D, including test_adopt_symlink_target_bytes.py itself. Counted: never-name & gated: 6, never-name & not gated: 23, names it & not gated: 32, not gated total: 55. So "13 + 29 + 26 = 68" double-counts.
  • The real gap is covered by CI: the full-suite tests job passed on 3.14 at 4df784ac.
  • Fix: drop the reach argument. Say that 55 files were left out locally and that CI's full tests job passed at 4df784ac.

SHOULD-2: "tests/test_path_guard_hygiene.py is not in this tree (it is on another branch)" is wrong.

  • Evidence: git cat-file -e origin/main:tests/test_path_guard_hygiene.py says it is on origin/main. It came in with 038dafba9, which the local fetch saw at 2026-10-21 18:42 UTC, before the head commit.
  • I built the merge of the head into origin/main (merge-tree --write-tree 961640c68 4df784ac gives 9d3a2aa8, clean). Its adopt-project.py is identical to the head's, and the guard test there gives 49 passed in 17.01s.
  • Fix: replace the ruling with that run.

SHOULD-3: known limit 3 is narrower than what the code does, and the title's "each case twin" overclaims for a pin's file. (adopt-project.py:749-755, the _kept_pin_findings loop that breaks once the case-keeping disk answers.)

  • Any row at the pin's exact spelling ends the question, not only a bad link. My extra probes, at base and at head alike:
Probe Findings at base and at head What is named
x-pin-exact-file-twin-file 1 only contracts/shape-pin.yaml; contracts/Shape-pin.yaml is never named
x-pin-exact-goodlink-twin-file 1 only the link at the exact spelling

NIT-1: closingIssuesReferences is currently []. That is expected while the base is fix/1089-... rather than the default branch. Check that it links #1138 once the PR is retargeted to main after #1118 lands.

The other two known limits are honest, measured with tools/unit_r.py:

  • Limit 1: a directory twin beside a file at the copy gives 1 finding at both trees, and directory twins are one group.
  • Limit 2: ga-twins is unchanged and exits 0 at both.

Not covered (stopped at LAST STEP)

  • Mutants R14 to R18 were not run:

    • R14: every row under a directory at a pin's file;
    • R15: the first row's mode in each pin line;
    • R16: _holds_a_pin asking the last twin only;
    • R17: twins in reverse tree order;
    • R18: dropping the twins after a named link.

    R14 had just started when I stopped it. I killed its runner and the leftover pytest by PID, and cm1/adopt-project.py is back at HEAD's blob ae19e3e5.

  • I did not re-run the writer's M1 to M11, M13p or M14 as written.

  • I did not re-run gate calls B to E or the four test_shape_doctor.py shards locally; I relied on CI's full suite at 4df784ac.

  • I did not run any of the 55 left-out files locally.

Everything is in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1170: logs/, probes/m-base, probes/m-head and tools/. The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1170 is clean.

Lane: openreposhape-1 (openRepoShape-1)

… with any row at its exact spelling leaves the twins at other spellings unasked, from the disk order of #524, and a submodule there is no row (#1138)

r1170 SHOULD-3: the disk that ignores case is asked only where the one that
keeps case found nothing, so a file, a link or a directory's rows at the pin's
exact spelling end the question. Measured at base and at head alike
(`x-pin-exact-file-twin-file`, `x-pin-exact-goodlink-twin-file`,
`y-pin-exact-dir-twin-file`: one finding each, the twin never named), and a
submodule at the exact spelling does not end it (`y-pin-exact-submodule-twin-file`:
the twin is named). Docstring only: `ast.unparse` with docstrings stripped is
equal to 4df784a's. Lane 1's ruling is option (b): no code change.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title adopt-project.py: check names each case twin at a place the shape writes, before a link there or after it, because _KeptTree keeps every row at a path and not the first, so shape/readme.md beside a link at shape/README.md is named (#1138) adopt-project.py: check names each case twin at the shape's copy, above it and at the manifest, before a link there or after it, and at a pin's file where no row is at its exact spelling, as _KeptTree keeps every row, not the first (#1138) Oct 22, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T08:01:58Z) Independent re-review r1170b of #1170 at 24ee133a: READY. No MUST and no SHOULD; four NITs, all optional.

r1170b: targeted re-review of PR #1170 at 24ee133a (base 2307036a). Verdict: READY. No MUST, no SHOULD, four NITs.

All three of r1170's SHOULDs are fixed as claimed. The code is unchanged since 4df784ac, and every new claim in the body matched what I measured.

The three SHOULDs

SHOULD-1 (CI full-suite fact): fixed.

  • Searching the body for reach|cannot|29 |13 \+|= 68|not in this tree finds no gate ruling. The only "reach" left is the unrelated "the skip's reach is exactly the link's".
  • The counts hold at 24ee133a. ls-tree gives test_adopt files: 68 and tests/test_*.py: 309. All 13 adopt files in calls A to D exist, so 55 were left out. 13 + 1 + 4 + 3 + 1 = 22 files ran, and all 9 non-adopt names exist.
  • CI's tests job at 4df784ac (run 39828602874) shows Successfully set up CPython (3.14.8), python3 -m pytest tests -q, and 19672 passed, 385 skipped in 6200.63s.

SHOULD-2 (hygiene over the merge with main): fixed.

  • merge-tree --write-tree 961640c6 24ee133a gives 652c11ee, exit 0. For 4df784ac it gives 9d3a2aa8. Both match the body.
  • origin/main is still 961640c6 (read with gh api).
  • In a scratch clone, the merge commit's adopt-project.py is byte-equal to the head's (cmp passes).
  • tests/test_path_guard_hygiene.py there gives 49 passed in 14.34s.
  • None of the diff's added lines in adopt-project.py holds a bare guard (count 0).

SHOULD-3, option (b): fixed.

  • The fix commit changes only adopt-project.py, +12 −3, all inside _kept_pin_finding's docstring.
  • I confirmed the ast equality myself. With all 541 docstrings emptied, both ast.unparse and ast.dump are True against 4df784ac; without emptying, the dump differs (False).
  • sha256 is eddc09f41bd0e955 at 4df784ac and cbc42517033907c0 at 24ee133a, as the body says.
  • The new docstring matches the code at adopt-project.py:749-755 and :777-786: the case-ignoring disk is asked only when the case-keeping one returns None, and a submodule-only place returns None.

Limit 3's probes, base 2307036a against head 24ee133a

I wrote my own tool (tools/limit3_b.py) with the same case definitions as matrix_f.py and r1170's matrix_r.py, run in the probe environment.

Probe Base Head Twin named at head?
x-pin-exact-file-twin-file 1 1 no
x-pin-exact-goodlink-twin-file 1 1 no
y-pin-exact-dir-twin-file 1 1 no
pin-exact-bad-link-twin 1 1 no
y-pin-exact-submodule-twin-file 2 2 yes
pin-bad-link-then-file 1 2 yes
x-pin-file-then-good-link 1 2 yes
x-copy-exact-file-twin-file 1 2 yes
  • Every row matches the body.
  • In all 11 cases (3 are my extras), stdout is equal at base and head once masked.

Title, closing form and stacking

Findings

NIT-1: the title still overclaims for a directory at a pin's exact spelling. (PR title)

  • The title says the twin is named "at a pin's file where no row is at its exact spelling". In y-pin-exact-dir-twin-file, no row is AT contracts/shape-pin.yaml. The finding's own words are "the file contracts/shape-pin.yaml/x … inside contracts/shape-pin.yaml".
  • Yet contracts/Shape-pin.yaml is never named: 1 finding at head.
  • The body and the docstring do state this case.
  • Fix: change "where no row is at its exact spelling" to "whose exact spelling holds nothing" (240 characters). That form understates the submodule case, which is safe.
  • The same class of overclaim: "each case twin at the shape's copy" is bounded by limit 1 for a directory twin. r1170 accepted that.

NIT-2: two phrases in the new docstring are loose.

  • adopt-project.py:765: "None where it keeps nothing there but a submodule" reads as "only a submodule". The code also returns None where nothing at all is there (:781-782).
  • adopt-project.py:774: "A submodule there is no row" contradicts _KeptTree.every at :7135: "Every kept row AT path, a file, symlink or submodule".
  • Fix (docstring only, optional): "None where it keeps nothing there, or only a submodule", and "a submodule there ends nothing".

NIT-3: the body's "(the run on 24ee133a is not read here)" is now stale.

  • CI run 39859853515 at 24ee133a passed: tests gives Successfully set up CPython (3.14.8) and 19672 passed, 385 skipped in 5206.01s. tests-git-bash, package and SonarCloud all passed.
  • Fix: say that CI's full tests job passed at 24ee133a too.

NIT-4: limit 1 names only _kept_there; the pin's file has the same directory-twin rule.

  • In z-pin-other-dir-twin-file (contracts/SHAPE-pin.yaml/x beside the file contracts/Shape-pin.yaml), base and head each give 1 finding, and the directory is never named.
  • It is the same at base, through _kept_pin_finding's at or disk.under(...).
  • Fix (optional): add "as _kept_pin_finding does at a pin's file" to limit 1.

r1170's NIT-1 still applies: closingIssuesReferences is [] while the base is not main. Check it once the PR is retargeted after #1118 lands.

Not covered (by design: targeted re-review)

  • No mutant re-runs. The code's ast is equal to 4df784ac's.
  • No local re-run of gate calls A to E or the four test_shape_doctor.py shards. CI's full suite at 24ee133a covers them.
  • No 3.14 shim run. The fix adds no path check.

Everything is in /tmp/claude-1000/-workspace-projects-openRepoShape/300f8f46-004a-4e72-8587-4f192be2e108/scratchpad/r1170b:

  • tools/limit3_b.py and astcmp.py;
  • probes-base/ and probes-head/;
  • logs/hyg-merged-24e.log, logs/ci-39828602874.log and logs/ci-39859853515.log;
  • mclone, the merge-probe clone;
  • cb and ch, archives of base and head.

The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1170 is at 24ee133a and clean (status --short shows 0 lines).

Lane: openreposhape-1 (openRepoShape-1)

@brettheap brettheap changed the title adopt-project.py: check names each case twin at the shape's copy, above it and at the manifest, before a link there or after it, and at a pin's file where no row is at its exact spelling, as _KeptTree keeps every row, not the first (#1138) adopt-project.py: check names each case twin at the shape's copy, above it and at the manifest, before a link there or after it, and at a pin's file whose exact spelling holds nothing, as _KeptTree keeps every row, not the first (#1138) Oct 23, 2026
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
Base automatically changed from fix/1089-named-link-skip-tests to main November 8, 2026 17:30
Brett Heap and others added 3 commits November 8, 2026 17:48
F = f4eef87, update-branch's merge of
main 46fe6ac into #1118's head 2307036, the head #1118 landed from.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S = 9ec0dc7, #1118's squash on main.
The branch's side was taken for #1118's own files (git diff --name-only
S^ S): adopt-project.py, scripts/shape_materialize.py,
tests/test_adopt_root_shape_paths.py and
tests/test_adopt_symlink_target_bytes.py. The merged tree is
9ce681b, the same as the first merge's,
since S's tree equals #1118's final head f4eef87's tree (9737190).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main = 9729315, #1123's squash (#1061)
on top of #1118's squash 9ec0dc7.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T18:05:50Z) Lane 1's delta check of #1170 after #1118 landed: the restack to 05fa55e0 leaves this PR's own change byte for byte as reviewed, so READY carries to 05fa55e0.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 20:01
@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 17 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 40cb808 into main Nov 8, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1138-case-twin-after-link branch November 8, 2026 20:02
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