Skip to content

update-shape.py: apply --add refuses a path under a file the root keeps as update-add-not-a-directory before it writes anything, and a write that fails after the copies is update-write-failed, rolled back, so neither leaves a rewritten copy and an unmoved pin behind a traceback (#285) - #346

Merged
brettheap merged 11 commits into
mainfrom
fix/update-shape-add-not-a-directory
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #285 (comment)

Merged main after #269 landed

#269 landed on main as the squash c1b6732 (its final head was 8a460e8, which had merged main c7c02fa), and GitHub retargeted this PR to main. gh pr update-branch refused with conflicts, because the squash and this branch's copy of #269's original commits touch the same lines. Two merges, nothing else:

At 818db36: git diff HEAD^1 HEAD -- update-shape.py tests/test_update_shape_add_symlink.py is empty; git diff --stat origin/main HEAD lists this PR's two files only (update-shape.py, tests/test_update_shape_add_not_a_directory.py); git merge-tree --write-tree origin/main HEAD is rc 0. Targeted counts at 818db36 are in Tests, below.

The numbers in the sections below were measured at the heads they name (ca68458, 3b9ed21, 961c68e, 189c30e) unless a line says 818db36 or a1b3a4b.

What

update-shape.py, the two changes #285 asks for, built on #269's link walk and its exclusive write_added (main holds both since c1b6732):

  1. A path under something that is not a directory is refused by name before anything is written.
  2. The write phase rolls back on OSError too.

Why

find_additions asked only whether something was AT the path, and a path under a plain file is not there: lexists says no, because the kernel answers ENOTDIR. So check offered the path with the --add to paste, and apply --add took it. _write_copies rewrote every upstream-changed copy, then Rollback.write_added died making the directory under the file (NotADirectoryError), or creating the file in a directory it cannot write (PermissionError). cmd_apply rolls back on Refusal and CommandFailed only, so the run ended in a traceback with the copies rewritten and the pin unmoved, and the next check read those copies as already-at-target.

Reproduced first on ca68458 (#269's head when this branch was cut; main's update-shape.py is now #269's final 8a460e8 version, byte for byte) with the issue's own steps: every repository local, gh a PATH shim that exits 97 and was never called, and .github/workflows/validate.yml's row and file dropped in one commit:

.github a plain file          check: ADDED UPSTREAM ... --add .github/workflows/validate.yml
                              apply: exit 1  NotADirectoryError: [Errno 20] Not a directory: '<C>/.github/workflows'
                                     git status:  M scripts/validate-manifest.py   pin: unmoved
.github/workflows/ mode 0555  apply: exit 1  PermissionError: [Errno 13] Permission denied: '<C>/.github/workflows/validate.yml'
                                     git status:  M scripts/validate-manifest.py   pin: unmoved
nothing at .github (control)  apply: exit 0, added

and with the fix, at 3b9ed21 (the fix's first commit, before main was merged in):

.github a plain file          check: upstream-added .github/workflows/validate.yml
                                       (the standard added this file after this pin, but .github in this root is not a directory, so --add refuses it until .github is moved)
                                     no ADDED UPSTREAM line (it was the only addition)
                              apply: exit 2  REFUSED update-add-not-a-directory: --add .github/workflows/validate.yml names a file the upstream's lists add,
                                             but .github in this root is not a directory: ...
                                     git status: clean   pin: unmoved
.github/workflows/ mode 0555  apply: exit 2  REFUSED update-write-failed: writing the copies and the --add files failed at .github/workflows/validate.yml: Permission denied
                                             ROLLED BACK: every byte this command wrote has been put back ...  restored  scripts/validate-manifest.py
                                     git status: clean   pin: unmoved
nothing at .github (control)  apply: exit 0, added

Decisions for the reviewer.

Tests

New tests/test_update_shape_add_not_a_directory.py, 13 cases:

  • the walk:
    • names the first non-directory above the path, and nothing for a directory, a missing component, the path itself or a top-level path (1);
    • leaves a symlink above the path to symlink_on_the_way (1, @WINDOWS_SKIP).
  • (1):
    • check reports the path with why and no --add for it, while the other addition keeps its --add (1);
    • (a) apply --add docs/shape-guide.md with docs a committed file, an untracked file or a FIFO: exit 2 update-add-not-a-directory, no traceback, no ROLLED BACK, and the whole tree untouched (snapshot/assert_untouched), the pinned copy B fixed included (3; the FIFO case skips where there is no mkfifo);
    • with one blocked addition among several, all are refused (1);
    • apply's note names what is in the way, not pass --add (1).
  • (2):
    • (b) docs/ an empty mode-0555 directory, and contracts/shape-pin.yaml mode 0444: exit 2 update-write-failed naming the step, the path and Permission denied, ROLLED BACK: every byte, restored scripts/validate-manifest.py, and for the pin case the --add file and the docs/ it made removed. The tree is as it was (2; skipped on Windows and as root, where the mode bits stop nothing).
    • A file arriving at docs while an interactive apply waits in confirm (in-process) is refused as update-write-failed and rolled back (1).
  • (c) the controls: nothing at docs, and docs/ a directory holding the project's own file, both add, with the row appended and the validators green (2).

Against origin/main's code (a1b3a4b, which holds #269 as c1b6732), 11 of the 13 fail for the issue's reason, and the 2 controls pass. Measured at the merge: a scratch export of origin/main plus this file gave 11 failed, 2 passed. The same 11 and 2 were first measured against ca68458 (#269's head when this branch was cut). The reasons, in both runs:

  • changed ['scripts/validate-manifest.py']: the copy is rewritten and left (the plain-file, FIFO, several-additions and read-only-directory cases);
  • written ['docs', 'docs/shape-guide.md'], changed ['scripts/validate-manifest.py'] (the read-only pin);
  • NotADirectoryError out of cmd_apply (the arrives-while-waiting case);
  • pass --add docs/shape-guide.md printed (check, and the note);
  • no not_a_directory_on_the_way (the walk).

Mutants at 961c68e, each killed:

mutant tests that fail
the update-add-not-a-directory refusal removed 4, by the refusal id. The tree is still rolled back, by (2).
writing() catches nothing 3: both read-only cases and the arrives-while-waiting case
_repin unwrapped 1: the read-only pin
the walk blocks regular files only 1: the FIFO

Targeted runs at 3b9ed21, with git-filter-repo 2.47.0 on PATH:

file result
tests/test_update_shape_add_not_a_directory.py 13 passed
tests/test_update_shape_add_symlink.py 38 passed, 3 skipped (NTFS junctions)
tests/test_update_shape_add.py 17 passed
tests/test_update_shape_add_ignored.py 26 passed
tests/test_update_shape.py 77 passed
tests/test_shape_doctor.py 181 passed
tests/test_cli_reference.py 13 passed
tests/test_repo_hygiene.py 50 passed

scripts/render-cli-reference.py --check: ok.

At 189c30e, after two docstring-only commits: tests/test_repo_hygiene.py, tests/test_cli_reference.py and the new file, 76 passed.

Targeted runs at the merge, 818db36, with git-filter-repo 2.47.0 on PATH:

file result
tests/test_update_shape_add_not_a_directory.py 13 passed
tests/test_update_shape_add_symlink.py 38 passed, 3 skipped (NTFS junctions)
tests/test_update_shape_add.py 17 passed
tests/test_update_shape_add_ignored.py 26 passed
tests/test_update_shape.py 77 passed
tests/test_update_shape_landing.py 33 passed
tests/test_update_shape_pr_base.py 27 passed
tests/test_shape_doctor.py 254 passed, 3 skipped (PowerShell, not on this host)
tests/test_cli_reference.py 13 passed
tests/test_repo_hygiene.py 50 passed

tests/test_update_shape*.py: 231 passed, 3 skipped. The other three files: 317 passed, 3 skipped. scripts/render-cli-reference.py --check: ok.

Composes with

git merge-tree --write-tree 818db36 <other>, as this lane's own review of 818db36 measured it, with origin/main at ab623dd. #270 has landed as f795cf7, which ab623dd holds, so its row is gone. The earlier table's #286 row (2c9b516) and #267 row (9f64bd0) are replaced by the heads below. Its #264, #266, #268 and #336 rows are dropped, not re-measured.

other rc notes
origin/main ab623dd (was a1b3a4b; holds #269 as c1b6732 and #270 as f795cf7) 0
#347 e965ab0 (fix/update-shape-pinned-copy-symlink, the branch the earlier table filed under #286, then at 2c9b516) 0 The same file as this PR.
#343 039530b 0
#267 8badae0 (was 9f64bd0) 0
lane 3's f1dcd2a (fix/update-shape-pr-rerun-pin-source) 0
#358 ebffb08 1 shape-doctor.py only. That is #358's own conflict with main: ab623dd against ebffb08 is also rc 1.
lane 3's dd14195 (fix/update-shape-next-quoting, no PR yet) 1 One hunk, in cmd_check. The resolution is in the first bullet below.

This lane's own review of 818db36: MERGEABLE WITH NITS (#346 (comment)); its other findings are follow-ups.

Left out

Closes #285

🤖 Generated with Claude Code

Summary by Sourcery

Make update-shape.py reject blocked additions safely and roll back writes that fail during apply.

New Features:

  • Detect additions whose paths are blocked by non-directory entries and refuse them with a dedicated diagnostic before writing.
  • Report write-phase operating-system errors as handled refusals and restore all changes made before the failure.

Bug Fixes:

  • Prevent apply --add from leaving rewritten copies behind or terminating with a traceback when an addition cannot be created or a later write fails.
  • Stop check and apply from suggesting --add for paths known to be blocked by a file or other non-directory entry.

Documentation:

  • Document the new write-failure rollback behavior in the update tool module documentation.

Tests:

  • Add coverage for blocked additions, rollback after directory and pin write failures, concurrent path changes, and valid control cases.

brettheap and others added 8 commits October 11, 2026 22:31
…, or under one, is present and `apply --add` refuses it as `update-add-symlink` before it writes anything, so a dangling link no longer has the standard's file written through it outside the root, and a red validator no longer deletes the operator's link (#239)

`find_additions` asked `(root / path).exists()`, which follows a link: one whose target is missing read as "nothing there". `check` offered the path as `upstream-added` and `apply --add` wrote the standard's file THROUGH the link, creating whatever it pointed at, outside the root included. A red validator then rolled back by unlinking the operator's symlink, left the file it had made at the link's end, and printed that the tree was exactly as it was; a green run exited 0 with the standard's bytes outside the root.

- `symlink_on_the_way(root, rel)` lstats each component of the path under the root and returns the first symlink, as `(its path, where it points)`. `find_additions` marks a path present when that finds one or `os.path.lexists` does, so anything at the path counts, and `Addition.link` carries the link to the refusal. A directory link above the path is the same write-through (a dangling one was a `FileExistsError` traceback from `write_added`'s `mkdir`), so it is covered by the same walk.
- `resolve_additions` refuses a linked path as `update-add-symlink`, ahead of `update-add-would-overwrite` and before any write: it names the link, where it points and the path, and says to leave the path alone if the link is the operator's to keep, or to remove or move it and run the same `--add` again. A live link that was `update-add-would-overwrite` is now this one, which says what it is.
- `check` and the doctor's currency row read `Addition.present`, so neither offers or counts a path the root holds a link at, as for any path it holds.
- `tests/test_update_shape_add_symlink.py` (new, 25 tests): a dangling link inside the root, outside it and into a directory that does not exist, and a live one, each with `check`'s verdict, the refusal under green and under red validators, and a whole-tree snapshot before and after (files, modes, links by `readlink`, directories, HEAD, branches, index, status, and the directories the links lead to); the refusal before `--branch`'s branch exists and before a second, good `--add` is written; a directory link above the path, live and dangling; and the controls (nothing at the path still adds, an unrelated link in the root does not stop an addition, a root named through a link still takes one, a directory or a FIFO at the path is still `update-add-would-overwrite`).
- README: the `upstream-added` paragraph names the new refusal.

Left out, and why: the issue's second change, `write_added` creating with `O_EXCL` and recording after the create. With the refusal in front of it nothing reaches `write_added` under a link, and it would turn a link that appeared mid-run into a traceback rather than a refusal.

Closes #239

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d the tree before the refusal's wording, so a failure on main names the write through the link (#239)

WIP checkpoint: the reordered tests have not been re-run at this head yet. Before the reorder all 25 passed at 73395b4 (the fix), and `tests/test_update_shape*.py` (120 tests) passed at 73395b4. Still to run: the new module at this head, test_shape_doctor.py -k currency, test_cli_reference.py, test_repo_hygiene.py, then pr-body.md and pr-title.txt.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…pdate-add-symlink`, because the README is capped at 1416 lines and `docs/handbook.html` pins its blob, so any change to it fails `tests/test_repo_hygiene.py` until the handbook is regenerated (#239)

The README paragraph made the file 1421 lines (`test_readme_is_short_enough_to_be_read`) and moved its blob away from the one `docs/handbook.html` records (`test_the_handbook_follows_the_readmes_outline`). Regenerating the handbook page for one refusal is not this change; the refusal's own text names the link, where it points and what to do, so the README is back byte for byte to 39ac59c.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…link too, and `write_added` creates the file exclusively and records it only once it exists, so a junction, or a link that arrives while `apply` waits in `confirm`, is refused by name instead of written through (#239)

Fix round 1 for PR #269, answering lane openRepoShape-2's review of
7bceeca (review269-7bceeca, items 1-5 of its fix list).

1. Junctions. `symlink_on_the_way` asked `is_symlink()`, and CPython's
   Windows `lstat` sets S_IFLNK for IO_REPARSE_TAG_SYMLINK only: a junction,
   Windows' ordinary directory link, reads as a directory. A live junction
   above an `--add` path took the standard's file outside the root (exit 0
   under green validators, #239's own row), a dangling one was a
   `FileExistsError` traceback with the copies left written, and one at the
   path was refused under the wrong id. The walk now `lstat`s each
   component and asks `is_link_info`: S_ISLNK, or `st_reparse_tag` equal to
   IO_REPARSE_TAG_MOUNT_POINT, looked up with `getattr` because `stat`
   names it on Windows only. That is what 3.12's `ntpath.isjunction` asks;
   `is_junction`/`isjunction` themselves are 3.12+, and this standard runs
   on 3.9. Not the FILE_ATTRIBUTE_REPARSE_POINT bit, which `lstat` keeps for
   a OneDrive placeholder it has traversed.

2. #239's second change. `write_added` recorded the path in `created`
   before `write_bytes` followed whatever was there, so a link arriving
   after `prepare` (an interactive `apply` waiting in `confirm`) was written
   through, and a red validator's undo then unlinked the operator's link
   and left the file at its end. It now creates with `open(path, "xb")`
   (O_CREAT|O_EXCL), records the path only after that succeeds, and turns
   a `FileExistsError` there or from `mkdir` (a dangling directory link
   above the path) into a refusal: `update-add-symlink` naming the link
   when the walk finds one, `update-add-would-overwrite` otherwise. Being a
   `Refusal`, it reaches `cmd_apply`'s arm, which puts back the copies
   already written.

3. The refusal said "this command never writes through a link", which is
   false while a pinned copy or the pin kept as a link is rewritten through
   it; it now says `--add` never does.

4. Where the link points is printed with `!r`: a target holding a newline
   printed a line of its own that read as a second refusal.

5. Sonar python:S9073: the composite assert in the `root` fixture is two.

Tests (tests/test_update_shape_add_symlink.py): `is_link_info` over six
`lstat` shapes and the walk handed a junction's `lstat`, on every platform;
real junctions (`_winapi.CreateJunction`, or `mklink /J`) live, dangling and
at the path, on Windows only; the ledger in-process with a link at the
path, a dangling directory link above it and a file that arrived, plus its
control; `cmd_apply` in-process with the link arriving in `confirm`; the
refusal's new wording and quoting, and a forged-line target. Against the
reviewed head's code they fail 20, for each finding's reason.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeps as `update-add-not-a-directory` before it writes anything, and a write that fails after the copies is `update-write-failed`, rolled back, so neither leaves a rewritten copy and an unmoved pin behind a traceback (#285)

`find_additions` asked only whether something was AT the path, and a path
under a plain file is not there: `lexists` says no, because the kernel
answers ENOTDIR. So `check` offered `docs/shape-guide.md` with the `--add`
to paste, and `apply --add` took it: `_write_copies` rewrote every
`upstream-changed` copy, then `Rollback.write_added` died making `docs/`
under the file with `NotADirectoryError` (or creating the file in a
directory it cannot write, with `PermissionError`). `cmd_apply` rolls back
on `Refusal` and `CommandFailed` only, so the run ended in a traceback with
the copies rewritten and the pin unmoved, and the next `check` read those
copies as `already-at-target`.

(1) `not_a_directory_on_the_way` walks the components above the path with
`lstat`, as #239's `symlink_on_the_way` does, and names the first one the
root holds as anything but a directory. `find_additions` keeps it on the
`Addition` as `blocked`; `resolve_additions` refuses it by that name, after
the link and before the merge, as `update-add-not-a-directory`. `check`
still reports the path (the root holds nothing there) with a detail that
says what is in the way, but no longer prints an `--add` it knows `apply`
refuses, and `apply`'s note for an untaken addition says the same instead
of "pass --add".

(2) `Rollback.writing(step)` turns an `OSError` out of a phase that writes
through the ledger into `update-write-failed`, naming the step, the path
and the error, the way `_rewrite_readme` already does for the README. It
wraps `_write_copies` and `_repin` in `cmd_apply`, so the refusal arm that
exists puts back every copy, addition and directory written before the
failure and lists them.

tests/test_update_shape_add_not_a_directory.py: 13 cases, on the
fixture and the `snapshot`/`assert_untouched` helpers of
tests/test_update_shape_add_symlink.py. Against the base (#269's ca68458)
11 fail for the issue's reason and the 2 controls pass.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s base has it, so this branch merges with main's #204 rewording of that same line instead of conflicting on it (#285)

The previous commit added "or a write" to `apply`'s exit-2 line. Main
(c7c02fa, now merged into #269's branch as 8a460e8) rewrote the same line
for #204, and `git merge-tree` against either was a conflict on that line
and nothing else. The line already says "including", so a write that fails
is covered by it, and the "Nothing is left half-done" paragraph above says
so by name (#285).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…write-failed`, rolled back, in a bullet of its own above #202's, so the sentence no longer shares a line with the bullet #286 adds after #202's and the two branches merge in either order (#285)

#286 (fix/update-shape-pinned-copy-symlink at ba373db, stacked on the same
#269 base) adds a bullet straight after the #202 paragraph's last line,
which this branch had extended with "A copy, an addition or a pin this
process cannot write is the same (#285)". `git merge-tree` was a conflict
on that line and nowhere else. Said as its own bullet before #202's, the
two edits no longer touch: merge-tree is rc=0 against #286, origin/main
c7c02fa and #269's new head 8a460e8.

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

Copy link
Copy Markdown
Contributor Author

@codex review

@sourcery-ai

sourcery-ai Bot commented Oct 12, 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 11 hours and 23 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens update-shape.py additions by rejecting paths blocked by non-directories before any write and by wrapping copy/addition and repin phases so OSError failures become descriptive refusals that roll back all writes, with extensive regression and compatibility coverage.

Sequence diagram for safe update-shape apply with additions

sequenceDiagram
    participant User
    participant Apply as cmd_apply
    participant Resolve as resolve_additions
    participant Ledger as Rollback
    participant Writes as _write_copies
    participant Repin as _repin

    User->>Apply: apply --add path
    Apply->>Resolve: resolve_additions(rows, additions)
    alt blocked by non-directory
        Resolve-->>Apply: Refusal update-add-not-a-directory
        Apply-->>User: Exit 2, no writes
    else addition is writable
        Apply->>Ledger: writing(writing the copies and the --add files)
        Ledger->>Writes: _write_copies(...)
        alt copy or addition raises OSError
            Writes-->>Ledger: OSError
            Ledger-->>Apply: Refusal update-write-failed
            Apply->>Ledger: undo()
            Ledger-->>User: Exit 2, restore every write
        else copies succeed
            Apply->>Ledger: writing(re-pinning contracts/shape-pin.yaml and project.yaml)
            Ledger->>Repin: _repin(...)
            alt repin raises OSError
                Repin-->>Ledger: OSError
                Ledger-->>Apply: Refusal update-write-failed
                Apply->>Ledger: undo()
                Ledger-->>User: Exit 2, restore copies, additions, and directories
            else repin succeeds
                Apply-->>User: Complete update
            end
        end
    end
Loading

Flow diagram for blocked additions and write rollback

flowchart TD
    A[find_additions] --> B[symlink_on_the_way]
    B --> C[not_a_directory_on_the_way]
    C --> D[Addition.blocked]
    D --> E{resolve_additions}
    E -->|blocked| F[Refusal: update-add-not-a-directory]
    E -->|accepted| G[Rollback.writing]
    G --> H[_write_copies]
    H --> I{OSError?}
    I -->|yes| J[Refusal: update-write-failed]
    J --> K[undo written copies, additions, and directories]
    I -->|no| L[_repin]
    L --> M{OSError?}
    M -->|yes| J
    M -->|no| N[rewrite README and validate]
Loading

File-Level Changes

Change Details Files
Detect blocked addition paths before writes and expose actionable refusals and diagnostics.
  • Added an lstat component walk that identifies the first non-directory above an addition while deferring symlink handling to the existing link walk.
  • Stored the blocking component on additions and reject --add with update-add-not-a-directory before merge or write processing.
  • Updated check and untaken-addition notes to omit misleading --add suggestions and explain the blocking path.
  • Added coverage for regular files, FIFOs, symlinks, multiple additions, and valid directory controls.
update-shape.py
tests/test_update_shape_add_not_a_directory.py
Convert write-phase operating-system errors into transactional refusals with rollback.
  • Added Rollback.writing to translate OSError from copy/addition and repin phases into update-write-failed messages containing the phase, path, and error.
  • Wrapped copy/addition writes and repinning in the new context manager so the existing refusal path restores all ledger-tracked changes.
  • Preserved existing handling boundaries for validation, README rewriting, post-commit operations, and non-OSError failures.
  • Tested failures caused by unwritable directories, unwritable pins, and a directory being replaced during confirmation, including rollback verification.
update-shape.py
tests/test_update_shape_add_not_a_directory.py
Document the new failure behavior and validate compatibility with existing update-shape behavior.
  • Added a module-level documentation bullet describing write failures as rollback refusals rather than tracebacks.
  • Added regression and control tests covering successful additions, pin preservation, untouched trees, diagnostics, and CLI/reference hygiene.
update-shape.py
tests/test_update_shape_add_not_a_directory.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T12:12:05.174204Z 189c30e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 189c30e449

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Base automatically changed from fix/update-shape-add-dangling-symlink to main October 12, 2026 12:27
brettheap and others added 2 commits October 12, 2026 12:44
… landed as a squash

This branch was stacked on #269 (fix/update-shape-add-dangling-symlink).
#269 landed on main as the squash c1b6732, whose final head was 8a460e8
(which had merged main c7c02fa), and GitHub retargeted this PR to main.
The squash and this branch's copy of #269's original commits touch the
same lines of update-shape.py, so `gh pr update-branch` refused with
conflicts.

8a460e8 was merged first (25e38af, no conflict). Merging origin/main then
conflicted in update-shape.py only. The resolution is this branch's own
version of the whole file: `git diff 8a460e8 origin/main -- update-shape.py
tests/test_update_shape_add_symlink.py` is empty (c1b6732 is the only main
commit touching either file since c7c02fa, and its content is 8a460e8's),
so this branch's version carries everything main has plus this PR's change.
tests/test_update_shape_add_symlink.py merged without a conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-12T15:00Z)

This lane reviewed its own PR #346 at 818db36, read-only in its own detached worktrees, with nothing authored.

MERGEABLE WITH NITS. There is no sequencing constraint. Whichever of this PR and lane 3's fix/update-shape-next-quoting (dd14195) lands second has one cmd_check hunk to resolve.

Findings, at 818db36:

  1. P3, follow-up: update-shape.py:2117-2128. A file that arrives at docs while apply waits in confirm is refused as update-write-failed and rolled back. The detail names the path and the OS error, not docs. On Windows that error is No such file or directory. The up-front refusal names the cause on every OS.
  2. P3, follow-up: update-shape.py:2196-2211. An undo whose own restore raises still ends in a traceback. This is older than the PR, and the PR's "Left out" names it.
  3. nit, docs follow-up: README.md:1081-1084 and docs/handbook.html:1388 predate update-add-symlink and update-add-not-a-directory.
  4. nit: the PR body's compose table names superseded heads.

Probes: we ran 12 cases on the real scaffold against main and head, and 7 in-process. On head, a plain file, a nested file, a FIFO or a socket in the way is refused before any write, and the tree is identical. A read-only directory, copy, pin or manifest gives update-write-failed and is fully rolled back. On main, a read-only project.yaml left the pin moved and the manifest not. The doctor and check agree. The new tests fail on main (11 failed, 2 passed), and four new mutants were each killed.

Tests:

Merges (merge-tree): rc 0 with ab623dd, #347, #343, #267 and f1dcd2a. #358 conflicts only in shape-doctor.py, which is its own conflict with main. dd14195 has one hunk.

The full report is handoffs/xFactory/attachments/lane-openRepoShape-2/review346-818db36/final-report.md in opensoft/brett-wip.

This was referenced Oct 12, 2026
This was referenced Oct 12, 2026
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit d6df36b into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/update-shape-add-not-a-directory branch October 12, 2026 21:06
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…s `origin_url`/`origin_name` and #346's `apply --add` refusal beside #291's read-only copies, so the branch is current with main and differs from it only in shape-doctor.py and tests/test_shape_doctor.py (#291)

Main after decff87 brings, among others, two changes to the files this
branch touches: 1184f67 (#404: shape-doctor.py's `origin_url` and
`origin_name` read an `origin` that is not UTF-8, plus its doctor tests)
and d6df36b (#346: update-shape.py refuses `apply --add` under a file
the root keeps). Git merged both without a conflict. Each one's lines
in the merge are its own commit's lines, one for one, and the lines
this branch adds over main are the ones it adds over f6dd11a: #404's
hunks are in `origin_url`/`origin_name`, far from `pin_restore`, and
#346 does not touch `read_pin`, `check_pin`, `Row` or `parse_yaml`,
which `pinned_copies` and `pin_at` call. `git diff --stat d6df36b`
names shape-doctor.py and tests/test_shape_doctor.py only.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…_spelled` now spells every symlink this branch names, and #283's tracking-branch question is not asked by adopt-project.py, so it meets none of this branch's refusals

Brings in, by sha, main's ten commits since 75052cb: ec8e09a (#406),
580d7df (#416), 1184f67 (#404), d6df36b (#346), 37665ff (#452), 36538fd
(#378, #329), 6c2a371 (#355, #283), 8782333 (#401, #340), ec4a7a0 (#456,
#391) and 437a360 (#343, #290).

No conflict. The tree is `git merge-tree --write-tree 437a360 95f6fc7`'s,
ae7be0a. Three files changed on both sides merged by git alone:

- adopt-project.py: 36538fd rewrites `_spelled`'s docstring and
  `_spelled_character` (a byte that is not UTF-8 is `\udcNN`, a character
  `\xNN`, by `repo_shape.double_quoted`), and two docstrings
  (`_refused_by_policy`, `_index_entries`); 8782333 adds `_leg_path_help`
  and the `help=` of `plan`'s `--spec-path` and `--code-path`. This
  branch's hunks are elsewhere.
- scripts/shape_materialize.py: 6c2a371 adds `import tempfile` and the
  branch-name question (`_ask_git_branch`, `_branch_verdict`,
  `branch_problem`, `refuse_bad_branch`) after `run`. This branch's hunks
  are elsewhere, and it adds no import of `tempfile` to either file
  (adopt-project.py has had one since before 75052cb), so no name is
  bound twice.
- tests/test_adopt_finding_spelling.py: 36538fd's `\udcfe` lines and this
  branch's `follows too many symlinks` line are more than 100 lines apart.

How main's changes meet this branch's code:

- 36538fd: every source path and symlink target this branch's findings
  and the materializer's refusals name is spelled by `_spelled` (`execute`
  hands it to the materializer as `spell`), so they now spell a byte that
  is not UTF-8 as `\udcNN`, as the plan's own `path:` does. One test of this
  branch pinned the old spelling, `nowhere\xff`; the next commit pins
  `nowhere\udcff`. Nothing in this branch has an escape table of its own.
- 6c2a371: `refuse_bad_branch` is called by scaffold-project.py and
  scripts/family.py only. adopt-project.py's `--tracking-branch` is not
  asked of git (6c2a371 leaves it as its own change), so the question
  falls nowhere among `check`'s findings or `execute`'s refusals.
- 8782333: `plan --help` text only; `plan` refuses nothing new, and the
  help names `shape/` as where `execute` writes its copy, which this branch
  does not move.

Checked on the merged tree: no top-level name in adopt-project.py,
scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py
or scripts/family.py is bound twice that was not already bound twice on
main (shape_materialize.py's `YamlError` and `parse_yaml`, as on main), no
name is loaded and bound nowhere, and each side's own change reappears
whole in the merge (no line of either side lost).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… writes its pin file as the scaffold does, so an adopted root passes `validate-pins.py` (#273); a `--pin-source` that cannot answer is `pin-source-unreadable` (#339) (#508)

**Brought up to date after #409 squash-landed** by merging origin/main (9736424, #409's squash) as f9ed46f. Its `scaffold-project.py` conflict was resolved to this branch's side once `git diff b70a07d 9736424 -- scaffold-project.py` (#409's final head against its squash) came out empty. So the file is the one tested on this branch plus main's own later changes to it, and holds nothing of #409's; details below.

This branch carries #275's adopt half. It was built on `fix/pin-source-env-collisions`, PR #409 (#275's scaffold half and its fix round), merged at 6494714 as 583f647, over the earlier 5690235 (34dbd86). After 6494714, #409 only merged main, five times, up to its final head b70a07d, and none of those merges has a hand edit (`git show --remerge-diff` is empty for each). Adopt's refusal of two products that share a source variable goes through #409's function, so this branch lands after #409. It was never rebased.

`git diff b70a07d 9736424` is empty for each file #409 touched: `scaffold-project.py`, `scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py`. What f9ed46f resolves, four files with one hunk each:
- **`scaffold-project.py`.**
  - The conflict hunk is main's block from `PIN_COLLISION_REMEDIATION` through `_pin_collision_remediation`, which #409's squash edits: it adds `PIN_VARIABLE_REMEDIATION`, `_colliding_pin_variables` and `_pin_collision_remediation` and changes `_refuse_colliding_pins`. This branch moved all of that to `scripts/shape_materialize.py`. The hunk takes this branch's side. (The merge commit's message says "re-adds"; this is the exact account.)
  - #409's auto-merged import of `PIN_SOURCE_ENV_PREFIX, pin_source_env_collisions` is dropped, because nothing in the file uses either name any more.
  - It is not `git checkout --ours` of the whole file. Main changed the file beyond #409 since this branch last merged it: #355's `--tracking-branch` check (6c2a371) and a73b4e9's `RULESET_HINT`. Those lines stay.
  - The result is the file `git merge-tree` gives for this branch with main before the squash (518010b). Its diff from the file tested here has the same patch-id as main's own 3ef059c..518010b change to it.
- **`templates/assembly-root/scripts/validate-pins.py`.** Main's side, which is #409's final blob, with main's #359 workflow reader in it. This branch had no change of its own to the file beyond #409.
- **`adopt-project.py`, `_commit_the_split`.**
  - Main: #445 wraps the function in its late-failure `try`, and #394 builds an `append` with the plan's submodule record.
  - This branch: its only edits there were a comment and `neutral_pins=_recorded_pins(plan)` on the materializer call.
  - Both now sit on main's call inside the `try`: `append=append, neutral_pins=_recorded_pins(plan)`.
- **`docs/cli.md`.** It is regenerated with `scripts/render-cli-reference.py`, and `--check` is ok. It differs from main's only in the `--pin`, `--pin-owner` and `--pin-source` help of `adopt-project.py plan`.

`scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py` are main's blobs, which are #409's: this branch adds nothing to them. Nothing of #409 is lost. Its scaffold behaviour is `shape_materialize.refuse_colliding_pins` and `pin_variable_collisions`, keyed by #409's `pin_product_key`.

**73d65c6, after the merge.**
- **The change.** `_read_pins` reads `plan.get("pins")` where it read `plan.data.get("pins")`.
- **Why.** #445's `tests/test_adopt_late_failure.py` hands `_commit_the_split` a `SplitPlan` as its plan, a `dict` that adds `entries`. On f9ed46f, three of its tests stopped at "committing the split" with `AttributeError: 'SplitPlan' object has no attribute 'data'` (3 failed, 83 passed in that file).
- **Nothing else changes.** `Plan.get` with no default returns `plan.data.get(key)` exactly, so a loaded plan reads as before.

`adopt-project.py plan --pin` took a NAME only (`--pin openGlass`) and refused a trailing `@<commit>`. `plan`, `check` and `execute` each exited 0, and `execute` printed `adoption verified`. The manifest then listed the name in `neutral_product_pins:` with no `contracts/<product>-pin.yaml` beside it, and on its first run the adopted root's own `validate-pins.py` refused it as `neutral-pin-missing`.

#273's table, reproduced on c7c02fa with the issue's own commands (local bare remotes, and a stub `gh` that exits 97):

| `--pin` | c7c02fa | this branch |
|---|---|---|
| `openGlass` | `execute` exit 0, `- openGlass`; clone's `validate-pins.py` exit 2 `neutral-pin-missing` | `plan` exit 2 `pin-malformed`, with the scaffold's remediation (`--pin openGlass@<40 hex>`); no plan is written |
| `opensoft/openGlass` | exit 0, `- opensoft/openGlass`; exit 2 `neutral-pin-missing` for `contracts/opensoft/openglass-pin.yaml` | `plan` exit 2 `pin-malformed` |
| `spec` | exit 0; `validate-pins.py` exit 0, reading the leg's `contracts/spec-pin.yaml` as the neutral pin | `spec@<sha>` is `adopt-pin-collides` at `plan` |
| `shape` | exit 0; `contracts/shape-pin.yaml` read as the neutral pin | `shape@<sha>` is `adopt-pin-collides` at `plan` |
| `openGlass@<sha>`, `opensoft/openGlass@<sha>` | `plan` exit 2 `adopt-pin-malformed` | `plan`, `check` and `execute` exit 0, and `gh` is called 0 times. `contracts/openglass-pin.yaml` is written and the manifest lists `- openGlass`. In the clone, `validate-manifest.py` exits 0, and `validate-pins.py` exits 0 with `openGlass: pin commit … digest recomputes` |

Brett ruled for decision 1's first option ("go with your recommendation on #273"): adopt takes the scaffold's pin and writes the scaffold's pin file. That reverses the NAME-only `--pin` from #6, and the comment that described it is replaced with the new contract.

- **One implementation.** The scaffold's pin code moved into `scripts/shape_materialize.py`, next to `neutral_pin_path` and the materializer that writes the file. What moved:
  - `PIN_ARG_RE`, `parse_pin`, `pin_digest_from_source` and `pin_digest_from_gh`;
  - `declared_pin_values`, `declared_pin_products` and `PIN_COLLISION_REMEDIATION` (in #309's wording);
  - from #275's branch, `PIN_VARIABLE_REMEDIATION` and the variable check;
  - `_refuse_colliding_pins`, split into the pure `pin_collisions` and `pin_variable_collisions`, and `refuse_colliding_pins(refusal=...)`, which raises one refusal for both.

  The code did not move verbatim. `_refuse_colliding_pins` became the three functions above; `pin_digest_from_source` was rewritten for #339 (below); and fix round 1 changed three of them (below): `pin_variable_collisions` keys each pin by #275's `pin_product_key`, `pin_digest_from_source` spells a path that is not printable, and `pin_digest_from_gh` refuses a missing `gh`. The scaffold's behaviour is otherwise unchanged. `scaffold-project.py` imports what it calls under its old names. Lane 5's review ran 34 scaffold cases (every pin refusal, the `gh` stub's four modes, dry and real successes) on 34dbd86 and on this branch at 7a94b6b. rc, stdout, stderr, the `gh` log and the files made were identical, apart from the shape's own commit and digest and #339's row. Its pin tests and #275's pass as they are. `tests/test_git_output_decoding.py`'s exemption for `pin_digest_from_gh` follows the function to its new file.
- **`plan`.** `--pin`, `--pin-owner` and `--pin-source` mean what they mean for the scaffold. `plan` reads the digest from `--pin-source` or `gh api`, and records product, repository, commit, digest and `digest_source` in the plan, so `check` and `execute` read no network for a pin. `plan` refuses:
  - a name with no `@<commit>`, as `pin-malformed`. That includes an empty `--pin ''`, which adopt used to drop and now refuses, as the scaffold always has;
  - `spec`, `code` and `shape`, in any case, as `adopt-pin-collides` (#273 decision 4), in the scaffold's sentence and remediation (#241, #309);
  - one product given twice, the same way, whether it is a repeat or two names that share one file (decision 5);
  - two products that one `SHAPE_PIN_SOURCE_<PRODUCT>` names, such as `open-glass` and `open_glass`, the same way, through #275's check;
  - a leg path over a pin's file, as `adopt-leg-path-collides`;
  - a `--pin-source` path that `check` would refuse in `digest_source:` (fix round 1), as `adopt-unwritable-value`. It is asked only when a `--pin` records the path;
  - with no `gh` on PATH and no `--pin-source`, `gh-not-found` (fix round 1).
- **`check` and `execute`.** These are new `check` findings, and `execute` refuses each one before any leg exists. They come from `_leg_findings`, the list `check` and `_refuse_what_check_finds` share:
  - `plan-pin-unrecorded`: a name alone (an old plan's `pins: [openGlass]`, or a hand edit), or an entry missing its commit or digest;
  - `plan-pin-malformed`;
  - `plan-pin-collides`, which covers two pins at one file, a pin at a file the shape writes, and two products that share one source variable;
  - `plan-root-holds-pin`: a source path kept in the root at the pin's file. The materializer would write the shape's pin beside it under `shape/`, and `validate-pins.py` would read the source's file instead.

  How each finding ends:
  - `plan-pin-unrecorded` and `plan-pin-malformed` end with the `plan --pin <product>@<40 hex commit>` that records the pin whole.
  - `plan-pin-collides` ends with re-running `plan` with each product once, or, for two products that share a source variable, with one product of each such group.
  - `plan-root-holds-pin` ends with the kept path's own remedy: send that entry to a leg, `drop` it, or rename it in the source and re-run `plan`.

  A leg moved over a pin's file in the plan is `plan-leg-path-collides`.
- **Also part of #386: its `pins:` row.** A hand-edited `pins:` that is not a list (a name such as `pins: openGlass`, a number, a mapping, a quoted string) is now `plan-pin-malformed` in `check` (`` `pins:` is 'openGlass', not a list of pins ``), and `execute` refuses it before any leg exists.
  - Where: `_read_pins` in `adopt-project.py`; the test row is `plan-pin-malformed-not-a-list` in `tests/test_adopt_pins.py`.
  - Before: on main, `check` said `plan ok` for the name, the mapping and the string, and gave a traceback for the number (reproduced by lane 5's review on cb7063f). #386 records that `execute` then wrote the name as eight one-letter pins.
  - Not fixed here: #386's other keys, `allow_empty_legs:`, `paths:`, `legs:` and `source:`, so #386 stays open. Lane 2's #435 takes all of #386, `pins:` included, and its `pins:` rule keeps only text items where this branch writes mappings; whichever lands second reconciles them (#435 (comment), and "Where the meaning meets" below).
- **`execute`.** It writes each pin through `materialize_assembly_root(neutral_pins=...)`. The manifest lists the product with the owner stripped (decision 3), so the entry and the file agree.
- **Docs.** `--help` is updated and `docs/cli.md` regenerated (`render-cli-reference.py --check` ok).

`pin_digest_from_source` caught only `Refusal`. A directory that is no clone is git's own failure, so it was always `pin-source-unreadable`, exit 2. A path `subprocess.run` cannot enter never reaches git: it is a `FileNotFoundError`, `NotADirectoryError` or `PermissionError` out of `cwd=`, and nothing caught it. `scaffold-project.py --pin ... --pin-source <that>` ended `--dry-run` and the real run alike in a traceback, exit 1. Since the move above, `adopt-project.py plan --pin` reads its digest through the same function and ended the same way. Both reach it through `declared_pin_values`, and `check` and `execute` read no source, so that call is the only reader.

The issue's table, run on ab623dd for the scaffold and on d806e8f for `adopt-project.py plan` (adopt has no `--pin-source` on ab623dd), then on this head. `gh` is a stub that logs and exits 97, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`; it was called 0 times in every run:

| `--pin-source` | before | this branch |
|---|---|---|
| a directory that is not a clone | `pin-source-unreadable`, exit 2 | unchanged |
| a path that does not exist | `FileNotFoundError` traceback, exit 1 | `pin-source-unreadable`, exit 2: `<path> could not answer for opensoft/openGlass @ <40 hex>: no such directory` |
| a file | `NotADirectoryError`, exit 1 | exit 2, `... it is a file, not a directory` |
| under a parent this user cannot enter | `PermissionError`, exit 1 | exit 2, `... Permission denied` |
| a symlink loop (not in the issue) | `RuntimeError` out of `Path.resolve()` on 3.12, exit 1 | exit 2, `... Too many levels of symbolic links` |

The real run and `--dry-run` give the same stdout and stderr, and nothing exists afterwards (no remote directory, no work directory; for adopt, no plan file).

What changed, in `scripts/shape_materialize.py` only:
- `pin_digest_from_source` catches `OSError` as well as `Refusal` and raises the existing `pin-source-unreadable` with its remediation unchanged. The message names the path and the reason.
- `stat()` judges the source inside the guard, as #312 does in `validate-pins.py`, and the sentences are that file's: a missing path, or one beneath a file, is "no such directory"; a file is "it is a file, not a directory"; any other `OSError`, from `stat()` or from git's `cwd=`, is the system's own reason (`strerror`). It is not `exists()`/`is_dir()`, which before 3.14 swallow only a missing path. A source `stat()` can read but `cwd=` cannot enter, a directory with no search bit, fails in `tree_digest` and the same guard answers it.
- `declared_pin_values` resolves the path through a guarded `_resolved_pin_source`. `Path.resolve()` raises a `RuntimeError` on a symlink loop before 3.13 (an `OSError` since), before the digest was asked for. As typed, `stat()` meets the loop and says so.
- A source that answers is read exactly as before: a relative path resolves against the working directory, a symlink to a clone is followed, `digest_source` reads `local-clone (<path as typed>)`, and the digest is `tree_digest`'s.

`tests/test_pin_source_unreadable.py` (46 tests in the #339 commit; fix round 1 adds 17 for S1 and moves one, below) runs each case through `scaffold-project.py --dry-run`, its real run and `adopt-project.py plan`: missing, a file, beneath a file, relative and missing, a parent and a source with no search bit (skipped on Windows and as root, by a mark, so before any fixture builds a directory), a 300-character name, a symlink loop (skipped on Windows) and a directory that is no clone. Each asserts exit 2, the refusal naming the path and the reason, its remediation, no traceback and nothing created. It also holds `--dry-run` and the real run to the same stdout and stderr, checks the path is printed escaped under an ASCII stderr and as a surrogate when it is not UTF-8, and checks that a relative source and a symlink to a clone read as before. The session `gh` guard fails any test whose run called `gh`.

origin/main ab623dd reached this branch through implQ's branch: 34dbd86 is a30091f merged with ab623dd. One file conflicted there, `templates/assembly-root/scripts/validate-pins.py`, in seven hunks, because #312 (8cdca5e) replaced `keyed_sources` and `default_source` with `PinSources` and #275 added `env_shared` and `shared`. #312's blob is the same at its PR head, at the squash and at ab623dd, so it landed the file implQ resolved against. The resolution keeps #312's side in all seven and adds #275's on top:

| # | hunk | resolution |
|---|---|---|
| 1 | `resolve_neutral_pin_source` signature | #312's `sources: PinSources` and 3-tuple return, plus #275's `env_shared: bool = False` |
| 2 | its docstring tail | #312's `Returns (path, how, typed)` text, plus #275's "Rule 2 is skipped when `env_shared`" paragraph |
| 3 | `_recompute_neutral_pin_digest` signature | #312's `sources`, plus #275's `shared: frozenset[str]` after `report` |
| 4 | the `resolve_neutral_pin_source(...)` call inside it | #312's 3-tuple unpack, plus `env_shared = product.casefold() in shared` and the extra argument |
| 5 | `_check_one_neutral_product_pin` signature | #312's `sources`, plus #275's `shared` |
| 6 | its call of `_recompute_neutral_pin_digest` | `sources, report, shared` |
| 7 | `_check_neutral_product_pins` docstring and body | #312's `PinSources(...)` and `_note_unused_pin_sources`, plus #275's paragraph, `_report_pin_source_collisions(products, report)` and `shared = frozenset(pin_source_env_shared(products))` |

This branch's own merge of 34dbd86 (5690235) had no conflict, and neither had its merge of #409's fix round, 6494714 (583f647). That fix round made `neutral-pin-source-collides` a note and keyed "one product" by `repo_shape.pin_product_key`. At 421e4e5, `templates/assembly-root/scripts/validate-pins.py`, `scripts/repo_shape.py` and `tests/test_pin_source_env_collisions.py` were byte-identical to the tree `git merge-tree` gives for 6494714 with origin/main 3ef059c (blobs 5604007, 091966b, 5220787), so this branch added nothing to those three files beyond #409. The 34dbd86 merge brought in #335, #312, #317, #270 (f795cf7), #344 and #349 with no conflict. Earlier merges of origin/main (87373a6, for #309's reworded constant, and e43fa95) are unchanged.

Fix round 1 merged origin/main three more times:
- **cd1fc79 (2bcfa69).** It brought #245, #351, #350, #345, #372, #371, #354, #380, #377 and the rest since ab623dd, with no conflict.
- **45e01f4 (4098b61).** It brought #396's `tests/test_agents_md_codes.py`. That test finds `scaffold-pin-collides` as a string constant of `scripts/shape_materialize.py`, which `SHIPPED` lists.
- **3ef059c (421e4e5), #314.** One conflict, one hunk in `adopt-project.py`. This branch's `_refuse_an_unrecordable_pin_source` and #314's section "The project must be able to hold what `execute` writes into it (#261)" were added at the same place; both are kept. Where the two meet, lane 5's earlier recipe applies:
  - `_flag_plan` builds each pin as the `PIN_FIELDS` mapping `plan` writes, not `list(pins)` of products. So the plan `plan` asks the validator about is the plan it writes.
  - #314's `test_plan_asks_what_check_asks_of_the_same_keys` plans `--pin openGlass@<commit> --pin-source <stand-in>` where it planned the name alone, which `plan` now refuses.

Then f9ed46f, after #409 squash-landed, and 73d65c6 after it (top of this description).

On the merged tree exactly one test failed: `tests/test_adopt_template_values.py::test_check_finds_and_execute_refuses_a_hand_edited_value[pins-surrogate]`. It plans, then hand-edits the plan to `pins: ["openX\ud800"]`, a name-only pin with a lone surrogate. Before this branch the refusal's headline was `REFUSED plan-unwritable-value`. A name-only pin in a plan is now `plan-pin-unrecorded` (#273), so `check` has a second, independent finding for that very plan, and the headline is the first finding's:

```
REFUSED plan-pin-unrecorded: 2 finding(s) ...
FINDING plan-pin-unrecorded: `pins:` entry 1, 'openX\ud800', is a name with no commit and no digest ...
FINDING plan-unwritable-value: a `pins:` item is 'openX\ud800', which `execute` writes into the assembly root's `project.yaml`, and it holds a lone surrogate ...
```

Nothing about #270's contract changed. The lone surrogate is still found as `plan-unwritable-value`, listed under the refusal, and `execute` still refuses before any leg exists. `check` builds `_leg_findings`, where `_pin_findings` sits, before it calls `_value_findings`, so the pin finding comes first. d806e8f changes one assertion for that row, `pins` only: the headline is `REFUSED plan-pin-unrecorded: ` where it was `PLAN_REFUSED`. The other 15 rows and the finding assertions, including the `plan-unwritable-value` line, are untouched.

Lane 5's review of 7a94b6b found 3 should-fix and 5 nits. Each is answered here:
- **#409's `pin_product_key` (review N1).** The merge of #409's fix round could not carry it, because this branch had moved the function that used it out of `scaffold-project.py`. `pin_variable_collisions` now keys each pin by that function, where it casefolded. `openß` and `openss` are two pin files and one `SHAPE_PIN_SOURCE_OPENSS`, and a casefold named each of the two pins twice. No `--pin` or recorded `pins:` entry `PIN_ARG_RE` admits can spell either, so the test asks the function directly. `refuse_colliding_pins`' docstring now says, in #409's words, that the root's `validate-pins.py` only notes such a pair.
- **S1, a `--pin-source` path that is not printable.** `pin_digest_from_source` names the path as typed unless it is not printable (`str.isprintable`), and then as Python's `repr` spells it (`shown_source`). That covers the refusal's head and git's own reason, which names the directory it ran in. Printed raw, an ESC reached the terminal, and a line break or a carriage return began a line of the path's choosing, a forged `REFUSED` line among them, from the scaffold and from adopt. The traceback #339 replaced had shown a `repr`. A printable path is named byte for byte as before.
- **S2, no `gh` on PATH.** `pin_digest_from_gh` refuses as `gh-not-found`, exit 2, as `repo_shape.tree_digest_from_gh` names it, and offers `--pin-source`. It was a `FileNotFoundError` traceback, exit 1, from the scaffold and, new with this branch, from `adopt-project.py plan --pin`.
- **S3, a hand-edited `commit:` or `tree_sha256:`.** The lane's decision is to accept it and document it (option (c)); no behaviour changed. A pair that disagrees passes `check` and `execute` (`adoption verified`), because neither reads a network for a pin, by design. The adopted root's own `validate-pins.py` then exits 1 with `neutral-pin-digest-mismatch` wherever it can read the product. `check` now says so once, after the pins it will write: "each pin as `plan` read it, not read again here: the root's own `validate-pins.py` recomputes its digest wherever it can read the product". Options (a), `execute` recomputes before it creates the legs, and (b), `check` recomputes from a clone still on the machine, are filed as #449.
- **N2, a `--pin-source` that `plan` cannot record.** `plan` asks `check`'s question (`one_printable_line`) of the path it records in each pin's `digest_source:`, before any digest is read. It refuses one that fails as `adopt-unwritable-value`, in #270's words (`unwritable_reason`), or as "an invisible formatting character" where those name nothing. Before, it was planned, exit 0, and printed raw in the report. `check` then refused it as `plan-pin-malformed` and said to re-run the same `plan`. The report's `pin` line and `check`'s `write` line now spell the digest's source with `_shown`. The scaffold half is pre-existing (a line break in the path is written into the pin file; a byte that is not UTF-8 dies after all three repositories exist) and is filed as #450.
- **N3, six hunks no test guarded.** Each now has a test that fails when it alone is reverted:
  - the two name guards (a `pins:` name holding an ESC and a forged line);
  - the lower-casing of the commit and the digest (an upper-cased hand edit);
  - the `digest_source` printability check;
  - the not-text check (`commit: 1234567` unquoted);
  - the gitlink filter (a submodule kept where the pin is written is `plan-submodule-root` alone).
- **N4.** This body.
- **N5, #339's third decision.** A `--pin-source` that nothing reads is still silent, now in adopt too. This PR does not decide it. #451, filed, lays out refuse, note and leave, so closing #339 loses nothing.

Lane 5's second review, of 421e4e5, found no blocking item, 2 should-fix items and 3 nits. The two should-fix items are this body: the landing after #409 (above) and #386 (above). The code needed no change for them. f63d1ed adds two tests and no code:
- **n1. `plan` asks nothing of a `--pin-source` no `--pin` records.** With the `args.pin and` guard dropped, such a path holding an ESC or a zero-width joiner was refused in a sentence naming no file ("`execute` writes into , and ..."), and every test still passed. `test_a_pin_source_no_pin_records_is_not_asked_about` holds the plan to exit 0 and `pins: []`, with the path named nowhere in the plan or the report. The ESC row is POSIX-only by the file's `POSIX_NAMES` mark; the zero-width joiner row runs everywhere. It holds #451's answer today, silence, so a decision there changes this test on purpose.
- **n2. A printable `--pin-source` is printed as it is.** With `_spelled` in place of `_shown` in `plan`'s `pin` line, every test still passed. `test_a_printable_pin_source_is_printed_as_it_is` holds `plan`'s `pin` line, and `check`'s `write` line for the plan `plan` wrote, to their exact text, ending `(local-clone (<path>))` with no backtick.
- **n3** needs a code change and is left out (below).

**Fix round 2, on f63d1ed.** Each run had a `gh` stub first on PATH (it logs and exits 97), `GH_TOKEN` bogus, an empty `GH_CONFIG_DIR`, `git-filter-repo` 2.47.0 next on PATH, and the identity blanked with `GIT_CONFIG_COUNT`; the session `gh` guard came first inside each run.
- **The pin set: 812 passed, 2 skipped.** It covers adopt pins, adopt plan, #339's file, #275's tests, the pin and neutral-pin validators, git output decoding, adopt template values, CLI reference, scaffold pin and reuse, scaffold pin collisions, #314's two files and #396's file. The skips are `test_adopt_plan.py:403` (no MedxEHR clone) and `test_adopt_template_values.py:223` (this host reads `git config user.name` from the repository, not from `GIT_CONFIG_GLOBAL`). That is fix round 1's 809 passed, plus the three new rows.
- **Shape doctor and scaffold leg-path collisions: 369 passed, 3 skipped** (the PowerShell line exists only on a Windows host).
- **Mutation probes**, each one line changed in a throwaway worktree, over `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` less the five end-to-end adoptions:
  - the `args.pin and` guard dropped: 2 failed, both n1 rows;
  - `plan`'s `pin` line `_spelled`: 1 failed, the n2 test;
  - `check`'s `write` line `_spelled`: 2 failed, the n2 test and `test_an_upper_cased_hand_edit_is_written_lower_cased`;
  - `plan`'s `pin` line raw: 1 failed, the no-break-space test that already guarded that hunk.

**On the merged tree, 73d65c6** (the merge f9ed46f and the `_read_pins` commit), with the same harness:
- **The pin set** above, with #445's `test_adopt_late_failure.py`, `test_adopt_git_reads.py` and `test_adopt_filter_repo_failure.py` and #387's `test_adopt_empty_symlinks.py` and `test_adopt_root_manifest.py`: **988 passed, 2 skipped**. The skips are the two above.
- **Shape doctor, scaffold leg-path collisions and the adopt suites:** **967 passed, 3 skipped** (the PowerShell line). The adopt suites are e2e, execute refusals, verify, root shape paths (#387's too), leg-path collisions, clone template, ignored shape files and submodules.
- **On the merge alone, f9ed46f** (the same tree as before the `_read_pins` commit): `tests/test_adopt_late_failure.py` gave 3 failed, 83 passed, the three `AttributeError` rows above.
- `render-cli-reference.py --check` is ok on the merged tree. pyflakes reports the same four warnings on `adopt-project.py`, `scaffold-project.py` and `scripts/shape_materialize.py` as before the merge.

**Fix round 1, on 421e4e5.** Each run had `git-filter-repo` 2.47.0 on PATH and the identity blanked with `GIT_CONFIG_COUNT`. A `gh` stub that logs and exits 97 was first on PATH, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`, and the session `gh` guard came first inside each run.
- **The pin set:** 809 passed, 2 skipped (the same files and skips as above).
- **Doctor and scaffold:** 782 passed, 3 skipped (the PowerShell line exists only on a Windows host). It covers shape doctor, scaffold leg-path collisions, scaffold e2e, referent chain links, naming policy, repo hygiene and #351's free-text values.
- **The adopt suites:** 882 passed. It covers e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template and submodules.
- **Before the #314 merge,** on 74ce4d9's tree: the first two sets gave 577 passed, 2 skipped and 782 passed, 3 skipped. On 2bcfa69's tree (the merges alone, before the fix) they gave 542 passed, 2 skipped and 782 passed, 3 skipped. After the 45e01f4 merge, `tests/test_agents_md_codes.py`, repo hygiene and CLI reference: 110 passed.
- **Lane 5's second review** also ran 421e4e5 merged with main cb7063f and with 53bee5b: the pin set, the manifest-value set, and the doctor set with #357's `tests/test_doctor_validator_rows.py`. All passed, with only the skips above.

Proof that fix round 1's tests test something:
- **On 2bcfa69's tree,** with this head's two test files laid over it, 31 of the new rows fail:
  - S1: 17;
  - N2 and its report spelling: 7;
  - S2: 3;
  - the `pin_product_key` port: 2;
  - `check`'s S3 line: 1;
  - the not-UTF-8 `adopt plan` row: 1.

  The N3 rows pass there, as they must: they guard hunks this branch already had.
- **Mutation probes.** Each hunk was reverted alone, in a throwaway worktree, and both test files were run: 18 of 18 were caught.
  - **The port:** the casefold keying (2 fail); the casefold lookup alone (2).
  - **S1:** the path named raw (13); git's reason named raw (2); every path `repr`'d, printable or not (27).
  - **S2:** no `except` (3).
  - **N2:** no `plan` check (11); only #270's question asked (1); the formatting-character reason said otherwise (1); the report's `pin` line raw (1); `check`'s `write` line raw (1).
  - **S3:** the line dropped (1).
  - **N3:** the `_pin_fix` name guard (1); the `_read_pins` path guard (1); the lower-casing (1); the `digest_source` check (1); the not-text check (1); the gitlink filter (1).
- **The issue rows reproduced.** S1, S2 and N2 were reproduced on 2bcfa69's tree and shown gone on the fix (a probe driving both tools; `gh` was called 0 times).

**Before fix round 1.** Runs were on 7a94b6b, with `git-filter-repo` 2.47.0 first on PATH and the repository-local `user.name` blanked with `GIT_CONFIG_COUNT` (a workstation whose standard checkout sets one fails `test_the_scaffold_refuses_with_no_elector` on main itself; CI sets none). 7a94b6b differs from ffd1297 in `tests/test_pin_source_unreadable.py` only, so the three suites that do not read that file ran on ffd1297:

- the new file: 46 passed;
- adopt pins, adopt plan, git output decoding: 172 passed, 1 skipped (the MedxEHR clone is absent);
- scaffold pin and reuse, pin collisions, e2e, referent chain links, neutral pin and pin validators, #275's tests, naming policy, repo hygiene, CLI reference, scaffold leg-path collisions: 571 passed;
- shape doctor: 254 passed, 3 skipped (the PowerShell line exists only on a Windows host);
- the new file with repo hygiene, git output decoding and CLI reference, once more after the last edit: 176 passed.

Earlier on 5690235 and d806e8f, before the #339 commit: the adopt suites (e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template) 740 passed, and `tests/test_adopt_template_values.py` with #273's other adopt tests 489 passed, 2 skipped. They were not re-run on 7a94b6b: the #339 diff touches `pin_digest_from_source` and the call in `declared_pin_values`, which those suites reach through `test_adopt_pins.py` only. Nothing was run on macOS or Windows.

Proof the new tests test something:
- **On d806e8f.** 38 of the 46 fail, each on the traceback and its exit 1. The 8 that pass are the unchanged behaviour: the not-a-clone refusal (4 rows) and the valid-source rows (4).
- **Mutation probes.** Ten hunks reverted one at a time, each failing at least one new test: the `OSError` catch (16 fail), the catch narrowed to `OSError` only (21), the missing-path clause (9), the clause narrowed to `FileNotFoundError` (3), the `S_ISDIR` judgement (3), the loop guard (4), no resolve at all (3), the old `.resolve()` call (4), the reason as `str(exc)` (12), and the file sentence as the OS's (3).
- #273's own 52 tests still fail on c7c02fa (51 fail or error; the one that passes checks the test's own table), and its 18 mutation probes were each caught.

**Re-verified after the merge.** An independent re-verification of f9ed46f and 73d65c6 found the code correct:
- #409 is carried whole. A 30,000-case fuzz of `--pin` lists through #409's final refusal and this branch's port differed in 0 cases.
- `scaffold-project.py` equals the tested file plus main's own changes that are not #409's (same patch-id).
- `_commit_the_split` keeps #445's `try` and #394's `append`.
- `_read_pins` reads a loaded plan as before.

Its findings were all in this description (the #506 bullet and three stale facts), now fixed. Runs on the head and on its merge with main 48585e6: 988 passed, 2 skipped; and 967 passed, 3 skipped.

`git merge-tree --write-tree` was run at 01:48Z on 2026-10-13, comparing hunks per file. Each PR head, fetched from `refs/pull/<n>/head` with no `gh`, was merged with 73d65c6 and with origin/main 9736424 alone. Two sets were asked about:
- every PR this description listed before that main does not now contain;
- every PR numbered above #468 whose head main does not contain: #473, #474, #490, #491, #493, #494, #495, #496, #497, #503, #504 and #505.

**Open state, read with `gh` by the coordinator at 02:00Z and refreshed at 02:25Z after a re-verification on main 48585e6.** Every pull request named below is open. #184, #94, #96, #98, #100 and #161 are closed, and #465 landed as 48585e6. `fix/adopt-plan-quote-yaml-keywords` has no pull request. #506 (`fix/scaffold-reuse-seeded-legs`) opened after this list was made, and the re-verification reads it.

- **Landed since the last draft of this list:** #343, #346, #355, #358, #373, #378, #382, #387, #394, #397, #401, #404, #409, #419, #452, #456, #465, #466 and #484. #394's `_commit_the_split` conflict and #401's `docs/cli.md` conflict are both resolved in f9ed46f, above.
- **Clean, 20:** #266, #347, #356, #374, #376, #384, #395, #453, #467, #468, #473, #474, #490, #491, #493, #494, #495, #497, #503 and #505. #453, #468 and #491 were updated onto main since the first read and now merge clean. #507 (lane 5's #486-#488) shares only the generated `docs/cli.md` with this branch and merges clean with it.
- **Conflicting with main alone, and this branch adds no hunk:**
  - #402: `shape-doctor.py` 5;
  - #496: `shape-doctor.py` 10 and `tests/test_shape_doctor.py` 1.

**Conflicts this branch adds:**
- **#435** (3efcc7a, lane 2's `fix/adopt-plan-field-types`, for #386).
  - It adds 2 hunks in `adopt-project.py`: 5 here, against 3 with main alone. They are `render_plan`'s `pins:` line and `Plan.pins`.
  - Keep this branch's `_pin_lines` and `_pin_products`. The rest is under "Where the meaning meets".
- **#504** (37ef29a, `fix/adopt-root-kept-symlinks`).
  - It adds 1 hunk in `adopt-project.py`: 2 here, against 1 with main alone. It is the materializer call in `_commit_the_split`.
  - Keep both arguments and both comments: `append=append, neutral_pins=_recorded_pins(plan), spell=_spelled`.
- **#506** (119d8c4, `fix/scaffold-reuse-seeded-legs`).
  - It adds 1 hunk in `scaffold-project.py`. With main alone, #506 conflicts only in `docs/handbook.html`.
  - #506 inserts `LEG_TEMPLATES` right after `declared_pin_products`, which this branch moved out, and rewrites `_reusable_remotes` further down. Keeping both sides breaks the file with an unterminated string literal.
  - Keep only #506's `LEG_TEMPLATES` block. Drop #506's `declared_pin_values`/`declared_pin_products`, which moved here, and this branch's three-line `_reusable_remotes` opener, which #506 rewrites below. Resolved that way, pyflakes is clean and #506's scaffold tests pass (88).
- **`fix/adopt-plan-quote-yaml-keywords`** (ed9968f; a branch with no pull request).
  - It adds 2 hunks in `adopt-project.py`: 3 here, against 1 with main alone.
  - One is `render_plan`'s `pins:` line. Keep `_pin_lines`, which already sends product, repository and `digest_source` through `y`.
  - The other is `_refuse_unholdable_values`' signature. Keep `pins: dict`, with that branch's `_validator_judged` and `_named_as_read` above it.
- `fix/adopt-late-failure-notice` is no longer on origin. #445, the late-failure notice, is on main and merged here.

**Where the meaning meets:**
- **#435 and #386.** The coordinator has said this on #435: #435 (comment)
  - #435's `_plan_key_findings` (`plan-bad-pins`) keeps only text items in `pins:`, and its `PINS_REMEDIATION` tells a person to write `pins: [openGlass]`.
  - This branch writes each pin as a mapping, and it makes a name alone `plan-pin-unrecorded`.
  - Read from #435's code, not run: on a tree with both, the plan `plan --pin` writes is `plan-bad-pins` in `check`.
  - Whichever lands second reconciles them, with three changes:
    - `_plan_key_findings` takes this branch's mappings, or leaves `pins:` items to `_read_pins`;
    - a `pins:` that is not a list gives one finding, not both `plan-bad-pins` and `plan-pin-malformed`;
    - `PINS_REMEDIATION` says the mapping `plan` writes.
  - Until that is done, `tests/test_adopt_pins.py::test_a_plan_with_a_whole_pin_checks_ok` fails.
- **#314.** It landed, and it is merged here (above).
- **`fix/adopt-plan-quote-yaml-keywords`.** Its pin tests are written against the name-only `--pin`. They need the `name@commit` form, and their expected `pins: [...]` line becomes `_pin_lines`'s mapping.

- **Two unwritable values are refused in two runs (lane 5's second review, n3).** `plan --elected-by 'A<LF>B' --pin openGlass@<sha> --pin-source <a path holding an ESC>` refuses the elector alone, exit 2. Re-run with the elector retyped, it refuses the source; #270's refusal names every unwritable value at once. Reproduced on 421e4e5, with `gh` called 0 times. One refusal would need a code change, not made here:
  - `_refuse_an_unrecordable_pin_source` would split into a function that returns the sentence;
  - `_checked_plan_inputs` would ask it before #270's `unwritable_values` raise, and raise one `adopt-unwritable-value` that names both, with both remediations;
  - a test would add the two-value row.

  N2's question is `one_printable_line`, not `unwritable_reason`, so it cannot simply join #270's list.
- **Docs.** The README, AGENTS.md and `docs/handbook.html` are unchanged, because none of them calls adopt's `--pin` a name. A sentence saying adopt now takes the scaffold's `--pin` is a follow-up. It costs an AGENTS.md line-cap raise, and a README edit forces the handbook's README blob to change.
- **A product spelled `null`, `true` or `false`.** The scaffold already accepts `--pin null@<sha>`: it exits 0 and builds a root whose `validate-pins.py` exits 1 `neutral-pin-entry-malformed`. Adopt's `plan` writes `product: null` through `y`, and `check` then finds `plan-pin-malformed` and refuses it before any leg exists. The one fix for both tools is in `parse_pin`, and it would change the scaffold's behaviour.
- **Wording shared with the scaffold.** `digest_source` is written bare into the pin file, so a `--pin-source` path holding ` #` is cut short there. `templates/assembly-root/contracts/neutral-product-pin.yaml` and `validate-pins.py`'s `neutral-pin-missing` remediation name only the scaffold as the writer. Two comments in `scripts/repo_shape.py` still name the scaffold's `pin_digest_from_gh`, in a digest-pinned shipped copy.
- **Filed as follow-ups:**
  - #449: S3, a hand-edited `commit:` or `tree_sha256:` that disagrees, with options (a) and (b);
  - #450: the scaffold half of N2, a `--pin-source` path no file can hold;
  - #451: #339's third decision (N5), a `--pin-source` that nothing reads.
- **`validate-pins.py` prints a `--pin-source` path raw** in `neutral-pin-source-unreadable`, the twin of S1. It is a shipped template that #409 and other open PRs edit, so it is left for its own change. No issue is filed for it yet.
- **Windows and macOS** were not run here; CI ran both (below). Two assertions that depend on what Windows answers for a path (a name past the filesystem's limit, a path beneath a file) check only the refusal and its remediation there. Fix round 1's rows that need a control character or a byte that is not UTF-8 in a path, or a symlink to `git`, are skipped on Windows by a mark, and so is fix round 2's ESC row. The rows for a no-break space and a zero-width joiner run everywhere, and so does the in-process `gh-not-found` test.

Reviewed independently by lane 5 at 7a94b6b (3 should-fix, 5 nits: fix round 1) and at 421e4e5 (0 blockers, 2 should-fix, 3 nits: fix round 2). After #409's squash, the re-verification of f9ed46f and 73d65c6 found the code correct; its 1 should-fix and 3 nits were in this description and are fixed. GitHub's update-branch then merged main twice (33bae3f, 2170526), with no hand edit. CI's tests, tests-macos and tests-windows and SonarCloud all passed at 2170526 (main cac6bb2 merged in). On that head merged with main 57c85e9 (#490, #474 and #468, none of which touches this branch's files), the pin and adopt set with `tests/test_update_shape_landing.py` gave 1112 passed, 2 skipped, and the doctor, adopt end-to-end, unfinished-merge and bootstrap set gave 1056 passed, 3 skipped (the doctor's PowerShell line, which exists only on a Windows host).

Closes #273
Closes #339

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…a directory goes is one at ANY depth above the path, and `update-add-would-overwrite` sits beside the refusal it names; the codes test's history and scan are stated as they are (#550, #429)

From the independent review of 2ca2d78.

- README.md and docs/handbook.html: "one whose directory the root holds as a
  file" is now "one with a file where a directory above it goes", which is what
  update-shape.py refuses (`Addition.blocked` is the first directory ABOVE the
  path, at any depth, that the root keeps as a file or a FIFO: `--add
  .github/workflows/x.yml` is refused when `.github` is a file), and mirrors
  "a symlink at or above it". README.md's `update-add-would-overwrite` now
  follows "one the root ALREADY has a file at", and the reason for the refusal
  follows the code, as the handbook already had it. README.md is still 1427
  lines, so tests/readme-cap.d/550.txt stays +5.
- tests/test_update_add_codes_documented.py: the history says that the
  sentence named three and that #269 and #346 (both landed 2026-10-12) each
  added a refusal, not "for months". The scan is
  `test_agents_md_codes.printed_strings` filtered by the code's shape, so a
  docstring and the pieces of an f-string are left out, as its docstring says.
- Handbook markers: README's "Keeping a project's shape current" moved again
  (9d21bfcf6a23fc23b4d2dd6a18a4f32f75ee400b). `current` carries the corrected
  sentence; `doctor` was read again and reads the doctor subsection, which did
  not move, so its marker moves alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…affold with the `--visibility` and `--elected-by` setup passes, and `apply --add` names all five refusals it gives by their codes, each held by a test (#550, #429) (#579)

README.md and the handbook printed the by-hand dry run of the scaffold
without the `--visibility` and `--elected-by` that setup-project.py's
`scaffold_args` always passes (#550). They also named three of the five
refusals of `update-shape.py apply --add`, with no codes (#429).

- Both documents now print the plan as setup runs it:
  `--visibility private --elected-by 'Your Name' --dry-run`.
- Both name all five `apply --add` refusals, each with its code:
  `update-add-already-pinned`, `update-add-not-upstream`,
  `update-add-symlink` (#239, added by PR #269),
  `update-add-not-a-directory` (#285, added by PR #346) and
  `update-add-would-overwrite`.
- README grows by 5 lines. tests/readme-cap.d/550.txt raises the cap by
  +5 and argues it.
- The handbook markers for § Starting and § Keeping are re-set, after a
  re-read of every part that reads them.
- tests/test_update_add_codes_documented.py ties the documents to the
  tool's codes. tests/test_setup_by_hand_commands.py ties them to the
  arguments setup hands the scaffold.

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

Labels

None yet

Projects

None yet

1 participant