Skip to content

update-shape.py: a pinned copy, contracts/shape-pin.yaml or the manifest kept as a symlink, or under one, is refused as update-rewrite-symlink before anything is written, and check calls such a copy copy-is-link, so apply no longer writes the standard's bytes at the link's end and reports a commit that does not hold them (#286) - #347

Merged
brettheap merged 16 commits into
mainfrom
fix/update-shape-pinned-copy-symlink
Oct 13, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #286 (comment)
Merged main after #269 landed. #269 landed as the squash c1b6732, so this PR's base is main. The branch merged #269's final head 8a460e8 (merge 5cf81bb, no conflict), then origin/main a1b3a4b (merge e965ab0). The second merge conflicted in update-shape.py and tests/test_update_shape_add_symlink.py only: the squash carries 8a460e8's content of both files under a different history (git diff 8a460e8 origin/main on them is empty). Both were resolved to this branch's own version of the whole file, so git diff HEAD^1 HEAD on them is empty and git diff --stat origin/main HEAD listed only this PR's seven files. shape-doctor.py and tests/test_shape_doctor.py merged without a conflict. Main is not merged again by the fix round below; each count says the head it was run at.

Fix round (review of e965ab0)

The lane's review of e965ab0 found one P2 (codex's open P2, discussion_r4247927074, reproduced), one P3 left for a follow-up issue, and two nits. Two commits on top of e965ab0, head 9423361:

What

update-shape.py apply refuses, before it writes anything, to rewrite a file that is a symlink in the root or sits under one. That covers the three files it overwrites: a pinned copy, contracts/shape-pin.yaml and the manifest. It reuses #269's symlink_on_the_way walk and is_link_info, so a Windows junction counts as a link here too.

shape-doctor.py. Two answers:

  • A linked pinned copy: drifted_file_reasons names copy-is-link, so the verdict reads DRIFTED (1 copy-is-link), as copy-missing does. The row was already drifted through is_conflict, but copy-is-link was not among the states the verdict line names, so DRIFTED had no reason to print and the verdict fell through to the catch-all INVALID (shape-currency). A mutant run without the tuple entry gives exactly that.
  • A linked pin, contracts directory or manifest (project.yaml, or a holder's family.yaml): INVALID (shape-currency), exit 1, quoting update-shape.py's refusal, with its remedy as NEXT (the fix round's item 1). The NEXT is said, not spelled as a command: what the link points at may be shared with other trees, and removing a link, a junction above all, is a person's call.

--help is unchanged, so docs/cli.md is too (render-cli-reference.py --check ok).

Why

Rollback.write opens the file it rewrites r+b, which follows a link at the path or above it. Reproduced with the issue's steps on main a1b3a4b, which holds #269 as the squash c1b6732 (tools/repro-main-a1b3a4b.log), and before #269 landed on its head ca68458, with the same three results. Every repository was local, and gh was a shim that was never called.

  • AGENTS-shape.md committed as a link to a file outside the root:
    • check said upstream-changed.
    • apply --yes --branch shape/update exited 0 and rewrote the outside file (8694c502cdfd → 9e88c8e59159).
    • It printed committed on shape/update: AGENTS-shape.md, contracts/shape-pin.yaml, project.yaml, but the commit holds only contracts/shape-pin.yaml | 6 and project.yaml | 4.
  • The pin committed as a link outside: exit 0, and the outside pin was rewritten (6fc45ef78a23 → 1dd574789177). The commit holds AGENTS-shape.md and project.yaml, not the pin.
  • scripts as a directory link to a copy outside:
    • the copies were written through the link;
    • the validators resolved their own location through the link and failed with REFUSED not-a-git-repo at the outside directory;
    • update-validators-red;
    • ROLLED BACK, writing the old bytes back through the same link.

At the merge head e965ab0 (tools/repro-head-e965ab0.log) the three runs each exit 2 with REFUSED update-rewrite-symlink. In every case the file at the link's end is unchanged, HEAD is the operator's commit, and no branch was created.

Tests

  • tests/test_update_shape_rewrite_symlink.py (new, 29 tests; the link tests carry the suite's WINDOWS_SKIP; the junction cases run everywhere by handing the walk what lstat gives for a junction):
    • (a) check reports copy-is-link. apply refuses, with and without --branch × validators green and red, plus --accept-local. The link's end, the whole tree (bytes, modes, links, HEAD, branches, index, status) and the directories beside the root are unchanged.
    • (b) The pin as a link, the pin under a contracts directory link, and the manifest as a link. For each: check refuses on one REFUSED line, and apply refuses with and without --branch. A dangling pin link is named as a link.
    • (c) scripts as a directory link. Every copy under it is copy-is-link, and apply --branch refuses before the validators run.
    • (d) Controls: a dangling link at a pinned copy stays copy-missing / update-conflict. A root reached through a link, with an unrelated link inside it, updates and stays green.
    • Ledger:
      • a link arriving while confirm waits, at a pinned copy, at the pin, at the manifest, or as a contracts directory link above the pin, is refused, and what was written before it is restored;
      • Rollback.write alone refuses a link at the path or above it;
      • it still rewrites a plain file.
    • Junctions: a junction above a pinned copy, above the pin and at the manifest.
  • tests/test_shape_doctor.py: +5.
    • AGENTS-shape.md kept as a committed link gives DRIFTED (1 copy-is-link), exit 1, with counts {copy-is-link: 1, unchanged: 10} and conflicts [AGENTS-shape.md].
    • The pin, the contracts directory and project.yaml (parametrised), and a family holder's family.yaml, each kept as a committed link: INVALID (shape-currency), exit 1, no environment in the row, error update-rewrite-symlink, the link named in the reason, no fetch in NEXT, and no other row red. All four fail on e965ab0's code with CANNOT ANSWER, exit 3 (tools/x347/x347-item1-head-doc.log).
  • tests/test_update_shape_add_symlink.py: update-shape.py: a path the root holds a symlink or a Windows junction at, dangling or live, or under one, is present and apply --add refuses it as update-add-symlink before it writes anything, and the ledger creates an addition exclusively, 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) #269's wording asserts follow the widened sentence.
  • tests/test_update_shape.py, tests/test_update_shape_add.py: the STATES tuples ("every verdict check can print") learn copy-is-link.

Against main's code: main 92e29d9's update-shape.py and shape-doctor.py with this branch's test files (tools/x347/x347-mb-own.log, tools/x347/x347-mb-doc.log). The earlier measurements on a1b3a4b and ca68458 gave the same reasons for the 27 tests the file had then.

Mutants:

  • _classify_row's link check removed: 8 of the new tests fail. The ledger guard still refused, but only after writing scripts/validate-manifest.py and rolling it back, which "before any write" catches.
  • copy-is-link dropped from the doctor's tuple: the doctor test fails with INVALID (shape-currency).
  • At 9423361 (tools/x347/x347-mutants-driver.log), every one killed:
mutant killed by
Rollback.write's walk removed the 4 confirm-window cases, the 2 new ones included (DID NOT RAISE Refusal)
the doctor's linked_record_row call removed the 4 new doctor tests (CANNOT ANSWER, exit 3)
the doctor asks about the pin alone the project.yaml and family.yaml doctor tests
the doctor asks about project.yaml on every kind of root the family.yaml doctor test
linked_record ignores the kind (no manifest) 6: the two manifest doctor tests, check and apply (× --branch) on a linked manifest, and the junction at the manifest
the row keeps environment: True the 4 new doctor tests (CANNOT ANSWER)

Targeted runs (git-filter-repo on PATH):

run result
at 9423361: test_update_shape.py, _add, _add_ignored, _add_symlink, _landing, _pr_base, _rewrite_symlink, test_cli_reference.py, test_repo_hygiene.py 310 passed, 3 skipped (NTFS-only junctions)
at 9423361: tests/test_shape_doctor.py 259 passed, 3 skipped (the PowerShell line, Windows-only)
at 9423361: scripts/render-cli-reference.py --check ok
at e965ab0 (merge head, before the fix round): the same update-shape set 308 passed, 3 skipped
at e965ab0: tests/test_shape_doctor.py 255 passed, 3 skipped

Composes with

git merge-tree --write-tree 9423361 <head>, with the heads as this lane's object store holds them (not re-fetched):

head rc conflicts
origin/main 92e29d9 0 —
#267 8badae0 (fix/doctor-root-shape-pin-unreadable) 0 —
#346 818db36 (fix/update-shape-add-not-a-directory, lane-mate, issue #285) 0 —
#343 9e6caeb, #352 797c96b, #353 96ff0c7, #355 a03abcf, #356 0aff5ae, #266 3410be7 0 —
#401 6124cb4, #403 e2c78ee, #404 f9d420a, and the branches for #386 (d53602b) and #359 (6eb8e4e), which have no PR yet 0 —
#354 604e2d7, #357 8ad856f, lane 3's fix/update-shape-pr-rerun-pin-source f1dcd2a 0 —
#358 2745890 1 shape-doctor.py, 4 hunks in check_manifest_kinds: the same 4 hunks #358 meets against main 92e29d9 alone, so not this PR's

Merged trees, tested:

Left out

Closes #286

🤖 Generated with Claude Code

Summary by Sourcery

Refuse shape rewrites through symlinks and report linked repository records clearly before any writes occur.

New Features:

  • Prevent shape updates from writing through symlinks or junctions for pinned copies, the shape pin, and root manifests.
  • Report linked pinned copies as copy-is-link conflicts and refuse affected updates with a dedicated update-rewrite-symlink error.
  • Have shape-doctor identify linked pins and manifests as actionable shape-currency findings rather than environment errors or CANNOT ANSWER.

Bug Fixes:

  • Prevent updates and rollbacks from modifying files outside the repository through links, including links introduced while confirmation is pending.
  • Preserve existing handling for dangling pinned-copy links and roots reached through links.

Enhancements:

  • Unify linked-record detection and link refusal messaging across update and doctor workflows.
  • Extend symlink coverage to directory links and Windows junctions.

Tests:

  • Add comprehensive coverage for linked copies, pins, manifests, directory links, late-arriving links, rollback protection, and junctions.
  • Update state and wording assertions for the new copy-is-link status and command-wide symlink safety guarantee.

brettheap and others added 7 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>
…ifest kept as a symlink, or under one, is refused as `update-rewrite-symlink` before anything is written, and `check` calls such a copy `copy-is-link`, so `apply` no longer writes the standard's bytes at the link's end and reports a commit that does not hold them (#286)

`Rollback.write` opens the file it rewrites `r+b`, which follows a link at
the path or above it. A root that kept `AGENTS-shape.md` as a committed link
to a file outside itself had the standard's bytes written there; with the
validators green `apply --branch` exited 0 and printed `committed on
shape/update: AGENTS-shape.md, contracts/shape-pin.yaml, project.yaml` while
git, which holds the unchanged link, committed only the pin and the
manifest, and the pin recorded a digest no commit carries. The pin or the
manifest kept as a link were the same, and a `scripts` directory link sent
the copies outside, turned the validators red and rolled back through it.

- `_classify_row`: after `is_file()`, which follows the link, the row's
  path is walked with #269's `symlink_on_the_way` (Windows junctions
  included). A link at or above it makes the row `copy-is-link`, counted
  with the conflicts, with the link and where it points in its detail. A
  DANGLING link is still `copy-missing`, refused as before.
- `read_pin` refuses a linked pin, asked before `is_file()` so a dangling
  one is named as a link, not as a pin that does not exist; `prepare`
  refuses a linked manifest. Both raise before the upstream is opened.
- `cmd_apply` refuses every `copy-is-link` row at once, before
  `update-conflict`, whose merge and `--accept-local` remedy does not fit
  a link.
- `Rollback.write` walks the path again before it opens it, so a link that
  arrives while `confirm` waits is refused by name, and `cmd_apply`'s
  refusal arm puts back what was already written, as #269's exclusive
  create does for `--add`.
- One refusal, `update-rewrite-symlink`, names each path and its link, and
  `link_said` is the one phrase every link refusal and verdict prints.
  #269's `update-add-symlink` sentence is the whole command's again: "this
  command never writes through a symlink" (symlink, not "link": a
  hard-linked copy is still rewritten through its other name).

shape-doctor.py's `drifted_file_reasons` names `copy-is-link`. The row was
already `drifted` through `is_conflict`, but with no reason to print the
verdict fell through to the catch-all `INVALID (shape-currency)`; it now
reads `DRIFTED (1 copy-is-link)`, as `copy-missing` does.

Tests: tests/test_update_shape_rewrite_symlink.py (27) covers the issue's
(a) to (d), the confirm window, the ledger alone and a junction at each of
the three decision points; tests/test_shape_doctor.py gains the doctor's
verdict; the two `STATES` tuples learn the verdict; #269's wording asserts
follow the widened sentence.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as `update-add-symlink` does, so the doctor's verdict that quotes a linked pin's or manifest's refusal stays one line (#286)

`read_pin` and `prepare` always name exactly one path, and shape-doctor.py
quotes their refusal inside its one-line verdict (`CANNOT ANSWER (shape
currency: ...)`, or, once #267 lands, the `shape currency` row's reason). A
detail spread over a header, an indented path line and a closing paragraph
broke that verdict into three lines. One path now reads "`apply` would
rewrite <path>, but <the link>. <why>", the shape `update-add-symlink`
already has; several `copy-is-link` rows stay a list, one path a line, as
`update-conflict` lists its own.

The tests pin the one line where the doctor reads it: `check`'s refusal for
a linked pin or manifest is a single REFUSED line, ending with the sentence
that says this command never writes through a symlink.

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

Copy link
Copy Markdown
Contributor Author

@codex review

@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-12T17:55:14.032524Z 9423361 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.

@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 2 days and 6 hours 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 closes the rewrite-through-link safety gap by classifying linked pinned copies, refusing linked pins/manifests and late-arriving links before writes, and adding rollback-aware protections with consistent diagnostics; it also exposes the new state in shape-doctor and expands regression coverage across symlinks and junctions.

Sequence diagram for refusing rewrites through symlinks

sequenceDiagram
    participant Apply as cmd_apply
    participant Prepare as prepare
    participant Pin as read_pin
    participant Classify as _classify_row
    participant Guard as _refuse_linked_copies
    participant Ledger as Rollback.write

    Apply->>Prepare: prepare(args)
    Prepare->>Pin: read_pin(root)
    Pin-->>Prepare: linked pin refusal
    Prepare-->>Apply: update-rewrite-symlink

    Prepare->>Classify: classify pinned copies
    Classify-->>Prepare: copy-is-link rows
    Apply->>Guard: _refuse_linked_copies(rows)
    Guard-->>Apply: update-rewrite-symlink

    Apply->>Ledger: write(path, data)
    Ledger-->>Apply: recheck path and parents
    Ledger-->>Apply: update-rewrite-symlink if link arrived
Loading

Flow diagram for linked-copy classification and apply refusal

flowchart TD
    A["Pinned path is evaluated"] --> B{"is_file() succeeds?"}
    B -- "no" --> C["copy-missing / update-conflict"]
    B -- "yes" --> D{"symlink_on_the_way finds a link?"}
    D -- "no" --> E["Compare with upstream"]
    D -- "yes" --> F["copy-is-link"]
    F --> G["Count as a conflict"]
    G --> H["_refuse_linked_copies"]
    H --> I["REFUSED update-rewrite-symlink"]
    E --> J["Normal verdict and apply handling"]
Loading

File-Level Changes

Change Details Files
Prevent update-shape from writing through symlinks or junctions during rewrites.
  • Detect links at pinned copies and paths above them, classify non-dangling copies as copy-is-link, and count them as conflicts.
  • Reject linked pins and manifests before opening upstream data, with a shared update-rewrite-symlink refusal and consistent link-target messaging.
  • Recheck paths in Rollback.write to handle links introduced while confirmation waits, preserving rollback behavior for prior writes.
  • Keep the root itself traversable through a symlink and retain copy-missing behavior for dangling pinned-copy links.
update-shape.py
Make shape-doctor surface linked pinned copies as a specific drift reason.
  • Include copy-is-link in the named drift-reason states so verdicts report DRIFTED (N copy-is-link) instead of falling through to INVALID (shape-currency).
shape-doctor.py
Add comprehensive coverage for rewrite-link safety and update existing state and wording assertions.
  • Test check/apply refusal, pin and manifest links, directory links, junctions, links introduced during confirmation, rollback integrity, --accept-local, and unaffected controls.
  • Verify doctor output, state lists, and the command-wide symlink wording.
tests/test_update_shape_rewrite_symlink.py
tests/test_shape_doctor.py
tests/test_update_shape.py
tests/test_update_shape_add.py
tests/test_update_shape_add_symlink.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 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c9b516ba3

ℹ️ 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".

Comment thread update-shape.py Outdated
Comment on lines +1610 to +1611
if link is not None:
raise rewrite_link_refusal([(kind.manifest, link)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify linked records as repository findings

When shape-doctor.py checks a root whose manifest (or, via the analogous new read_pin check, shape pin) is a symlink, this new refusal is caught by check_shape_currency's generic except Refusal and unconditionally tagged environment: True, with remediation to fetch the standard. The doctor consequently reports CANNOT ANSWER/exit 3 even though the checkout can answer and the problem is entirely in the inspected repository; handle update-rewrite-symlink as a repository finding rather than an environment failure.

AGENTS.md reference: AGENTS.md:L197-L203

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(lane openRepoShape-2, 2026-10-12T17:49Z)

Fixed in 8869f5e (head 9423361). A pin, a contracts directory or a manifest kept as a committed symlink is now a statement about the repository: INVALID (shape-currency), exit 1, not CANNOT ANSWER.

  • update-shape.py has one function, linked_record, that names the linked record. read_pin and prepare refuse over its answer.
  • shape-doctor.py asks the same function first, before anything reads the pin or opens the standard. The row has no environment, quotes REFUSED update-rewrite-symlink: …, and its NEXT is the refusal's remedy: replace the link with what it points at, commit, run the doctor again. No fetch.
  • tests/test_shape_doctor.py covers the pin, contracts, project.yaml and a holder's family.yaml. All four fail on e965ab0 with CANNOT ANSWER, exit 3. Six mutants were killed.

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:45
…symlink

This branch was stacked on #269's head ca68458. #269 landed on main as the
squash c1b6732 after its head moved to 8a460e8 (ca68458 plus main c7c02fa), so
merging 8a460e8 first brings this branch to #269's final head before main
is merged on top (#286).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#269 landed on main as the squash c1b6732, whose content of update-shape.py
and tests/test_update_shape_add_symlink.py is 8a460e8's byte for byte
(git diff 8a460e8 origin/main on those two files is empty). This branch
had just merged 8a460e8, so both files conflicted (add/add in the test file,
content in update-shape.py) against the squash that carries the same lines
under a different history. Both are resolved by taking this branch's side
of the whole file: it is 8a460e8's content plus this branch's own #286
changes, which main lacks. Every other file 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 added a commit that referenced this pull request Oct 12, 2026
…_pull_request` spell the pin's path by the materializer's `SHAPE_PIN`, so `contracts/shape-pin.yaml` has no third copy in this module for Sonar's S1192 (#290)

`pin_rows_remediation` spelled `"contracts/shape-pin.yaml"` a third time in
update-shape.py, after `cmd_apply`'s set of paths to stage and
`_awaits_its_pull_request`; Sonar called the third copy a CRITICAL S1192 and
it was the one new issue on the PR. `scripts/shape_materialize.py` already
names the path once, as `SHAPE_PIN`, because a second spelling is how the
lists beside it would stop matching what is written, and this module already
takes its other constants from there. All three now use that name.

Two things left as they were, on purpose. The `root / "contracts" /
"shape-pin.yaml"` joins in `read_pin` and `_repin` are two short strings, not
the flagged literal, and #347 rewrites the top of `read_pin` (a merge of this
branch with it conflicts there if the join changes). The name comes in by its
own `from shape_materialize import SHAPE_PIN` line after the existing import
block, not as one more name in it, because #380 rewraps that block and a
merge conflicts there if this branch does too.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-12T16:42Z)

This lane reviewed its own PR #347 at e965ab0, read-only, in its own detached worktrees. Nothing was authored.

CHANGES NEEDED. Fix item 1 here. The doctor must answer a linked pin or manifest before #267's read_pin pass, whichever of the two lands second.

  1. P2 (codex's thread, reproduced). If contracts/shape-pin.yaml, contracts/ or project.yaml is a committed symlink, the doctor says CANNOT ANSWER, exit 3, with NEXT git fetch --all. Main says COMPLIANT.
  2. P3, follow-up. A hard-linked copy, pin or manifest is still rewritten through its other name (update-shape.py:2105). This is documented in the PR, and no issue exists for it yet.
  3. nit. The confirm-window test (tests/test_update_shape_rewrite_symlink.py:470) could add the manifest and a directory link. Both are already refused.
  4. nit. The PR body calls item 1 "the shape-doctor.py check_shape_currency takes every refusal update-shape.py prepare raises over the ROOT's own contracts/shape-pin.yaml (a merge conflict, a commit: that is not 40 hex, a deleted file) for this checkout failing to ask, so the verdict is CANNOT ANSWER, exit 3, outranking INVALID, and its NEXT git -C <standard> fetch --all blames the standard for a fault in the repository #237 class", but it is new here. The body also names 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's old head.

All four new guards' mutants are killed. Against main's code, the new file gives 24 failed and 3 passed, for the right reasons.

tree update-shape set doctor
e965ab0 308 passed, 3 skipped 255 passed, 3 skipped
merged with main ab623dd 308 passed, 3 skipped 255 passed, 3 skipped
merged with #346 818db36 172 passed, 3 skipped not run

The doctor run merged with a newer main never finished: a usage checkpoint stopped it. The fix round's head will be tested against main instead. CI is green on all three OSes, and render-cli-reference.py --check is ok.

git merge-tree is rc 0 for main 28bdc84, #245, #267, #343, #346, #354 and #357. #358 is rc 1, and the same conflict appears against main alone.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review347-e965ab0/final-report.md in opensoft/brett-wip.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sourcery assessment

Approved.

This was referenced Oct 12, 2026
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 added a commit that referenced this pull request Oct 13, 2026
…`scripts/siblings.py` is absent, and update-shape.py says the Makefile it copies calls it, so a holder updated without `--add` no longer fails on `can't open file` (#489) (#503)

A family holder cut before 2026-09-09 (#76) has no `scripts/siblings.py`. Updating it the way AGENTS.md prescribes, `update-shape.py apply` without `--add scripts/siblings.py`, still copies the changed Makefile, and that Makefile's `siblings`, `park` and `resume` targets run the script. `apply` exited 0, `validate-family.py` was green, only `note ... not added` was printed, and then all three targets died on `python3: can't open file '.../scripts/siblings.py'`.

This takes the issue's second option, which keeps per-file consent and never leaves a dead target. `apply` still refuses nothing, copies the Makefile and adds nothing it was not told to.

**What changed**
- `templates/family-root/Makefile`: each of the three targets asks make itself, `$(wildcard scripts/siblings.py)`, whether the script is there, and when it is not it refuses by name with `$(error)`: exit 2, one shared message, and no shell involved (no `test -f`, no `echo`), so the answer is the same wherever make runs. The message says what is missing, that `update-shape.py` adds a file only on the human's word, `python3 update-shape.py check --root "<this holder>"` to read the verdict, then the apply command it prints with `--add scripts/siblings.py`, and, for a holder that took the script and then deleted it (where `check` says `copy-missing` and `--add` refuses a pinned path), to restore it from the holder's history instead. With the file there, each recipe is what it was: same command line, same arguments.
- `update-shape.py`: `check` and `apply` say, on the line that already reports the file, that the Makefile calls it. `check` appends it to the `upstream-added` detail and `apply` to its `note ... not added` line. It is a table (`CALLED_BY_A_COPY`) with one row, and it speaks when the calling copy is `upstream-changed` (the one verdict `apply` writes on its own: "the Makefile that `apply` copies calls it, so `make siblings`, `make park` and `make resume` cannot run until it is added") or `unchanged` (the holder after that first `apply`, pinned at the target, where every later `check` and `apply` still finds the script `upstream-added`: "this root's Makefile already calls it, so ... cannot run until it is added"). A `locally-modified` or `both` Makefile says nothing. Nothing is refused and the exit codes are unchanged.
- `AGENTS.md`, item 6 of the family section: one clause, in place, with the same line count, so the file stays at its cap (490/490). It keeps "per file": `apply --add scripts/siblings.py` takes it on the human's word, per file, and without it `make siblings`, `park` and `resume` refuse.

**Evidence**
- Reproduced on `main` (7a8b981) with a real holder cut at 0170edd, the commit the issue names, local remotes and a stub `gh`: `check` says `Makefile` `upstream-changed` and `scripts/siblings.py` `upstream-added`; `apply` without `--add` exits 0 with `family ok`; `make siblings`, `make park` and `make resume` each print `can't open file` and exit 2.
- On this head, the same holder: `check` and `apply` carry the sentence above, `apply` still exits 0, and the three targets exit 2 with `make <verb> needs scripts/siblings.py, which this holder does not have. ...` and no `can't open file`. With `apply --add scripts/siblings.py` all three exit 0 and run the real script.
- `tests/test_family_makefile_siblings.py` (new, 31 tests; skipped only where `make` is absent) cuts a holder from a standard before #76 (a clone with no `scripts/siblings.py`, its copy-list entries stripped and the v0.4 Makefile: built, not read from history, so a shallow clone runs it), updates it to this checkout's bytes with `apply` with and without `--add`, and runs the three targets in each. It also runs the Makefile alone: the refusal is one message for the three targets, it needs no shell (`SHELL=/nonexistent` still refuses), `make -n` refuses too, the present-script path calls the script with the arguments it always had, `help`, `bootstrap`, `validate` and `pins` are untouched, and `CALLED_BY_A_COPY` is held to the template. It also covers a second `check` and a second `apply` on a holder already at the target (the `unchanged` clause), and the `copy-missing` sentence in the refusal. It carries no `WINDOWS_SKIP`, unlike `tests/test_park_resume_targets.py`: the refusal needs no shell, and `windows-latest` ships a GNU make (the runner-images install script copies `mingw32-make.exe` to `make.exe`), so the tests are expected to run on all three runners. CI's logs are quiet-mode, so they do not show whether they ran.
- Mutation checks, run in a throwaway clone of the head: with the Makefile reverted to `origin/main`'s, 13 of the 31 fail (the refusal tests, the one-message and no-shell tests, `make -n`, and every `make` run on the updated holder, which print `can't open file` again). With `update-shape.py` reverted, 9 fail (the `check` and `apply` sentence, the `left_dead_by` cases and the table). Taking only the `unchanged` branch out of `left_dead_by` fails 2 (the unit case and the second `check`/`apply`), and taking only the `copy-missing` sentence out of the Makefile fails 3 (the refusal test, once per verb).
- No static digest of the family-root Makefile exists in the repository: `git grep` of its sha256 finds nothing, and every `shape-pin.yaml` row is rendered from the bytes at `init` and `apply`. A holder's pinned digest moves with the file, as for any template change.

**Tests**
All at 49330dc (origin/main 5a25387 merged in), with a stub `gh` first on PATH, a bogus token, an empty `gh` config directory and the git identity blanked:
- `tests/test_family_makefile_siblings.py`: 31 passed.
- `tests/test_update_shape*.py` (seven files), the new file, `tests/test_park_resume_targets.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`: 438 passed, 3 skipped (the NTFS-junction cases, Windows only).
- every `tests/test_family*.py` and `tests/test_shape_doctor.py` (the doctor loads `update-shape.py` in process): 842 passed, 11 skipped (`tests/test_family_init_values.py:242`, which the file documents, and the doctor's PowerShell line, which prints only on a Windows host).

At 44000f6, after GitHub's update-branch merged main af01068 (no hand edit), the same sets ran again with the same stubs. The update-shape set, which now has eight files with #374's `tests/test_update_shape_next_quoting.py`: 535 passed, 3 skipped. Every `tests/test_family*.py` and `tests/test_shape_doctor.py`: 858 passed, 11 skipped. That head was then merged with main 47b1e06 (#395, the templates quote their text placeholders) as a local gate commit. On it, the two sets, together with `tests/test_template_text_placeholders.py`, gave 1481 passed, 25 skipped.

**Merge-tree.** At 49330dc, which contains `origin/main` 5a25387 (merged as f08129d), `git merge-tree --write-tree` is clean against `origin/main`, against every open pull-request head that touches `AGENTS.md`, `update-shape.py` or `templates/family-root/` (#347, #374, #395, #465, #474), and against lane 5's `fix/family-commands-and-branch` (10c93de, #486-#488). #491 conflicts only in `docs/handbook.html`, as it does with `origin/main`. The trees merged with #474 and with `fix/family-commands-and-branch` were run: the new test file and the neighbouring suites pass on both, and `AGENTS.md` stays at 490 lines.

**Review.** An independent review at 8643774 gave APPROVE WITH NITS (0 blockers, 0 should-fixes, 5 nits). It reproduced the defect on `main` and the fix on this head, and ran 10 mutations, all of them caught. Round 2 (49330dc) took four of the nits: the test file's docstrings say where it runs; the refusal also covers a holder that deleted a pinned `scripts/siblings.py`; AGENTS.md keeps "per file"; and a later `check` on a holder already at the target names the Makefile that calls the script. The fifth nit was this description. Re-verified at 49330dc: APPROVE WITH NITS (0/0/1). That nit was this description's merge-tree paragraph, now as above.

**Left out**
- `apply` does not refuse to take the Makefile without the script (the issue's first option), and it does not take the script on its own: both break per-file consent.
- The holder's `AGENTS-shape.md` and `README.md` are pinned copies and say nothing about this; editing them moves every holder's digests for a sentence the Makefile's own refusal already carries. README and the handbook are untouched.
- The doctor's `shape currency` row does not carry the sentence and is unchanged; the per-file detail line of `check` and `apply`'s note do, and the Makefile's own refusal covers the moment of use.
- The refusal is written for GNU make. macOS ships 3.81: the guard uses only `$(if)`, `$(call)`, `$(wildcard)` and `$(error)`, which 3.81 documents. It was run on 4.3 here. CI's `tests`, `tests-macos` and `tests-windows` and SonarCloud all passed at 8643774, and again at ca4778e (49330dc with main 48585e6 merged in by GitHub's update-branch), at 6fe17be (main 57c85e9 merged in the same way, after #474 changed `update-shape.py`; no hand edit) and at 44000f6 (main af01068 merged in the same way, after #374 changed `update-shape.py` and #491 and #505 changed `AGENTS.md`; no hand edit).

Reviewed independently at 8643774: APPROVE WITH NITS (0/0/5). Round 2 (49330dc) took the four code nits, and its re-verification gave APPROVE WITH NITS (0/0/1), the description's merge-tree paragraph, now updated. CI is green on Linux, macOS and Windows, with SonarCloud passing, at 44000f6 (main af01068 merged in by update-branch). Main 47b1e06 (#395), which landed after, touches none of its files, and the combined tree passed the family, doctor, update-shape and hygiene tests (1481 passed, 25 skipped).

Closes #489

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

sonarqubecloud Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
2 New issues
0 Accepted issues

Measures
0 Security Hotspots
No data about Coverage
1.4% Duplication on New Code

See analysis details on SonarQube Cloud

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-13T14:06Z)

SonarCloud failed this PR at 1001588 on one finding, pythonsecurity:S8705 at update-shape.py:3192 (issue AaE1ixbCnmZ6Sl3VWyVf). The head before the main merge, c2c5f3b, passed. This lane marked the finding False Positive on Brett Heap's word ("Mark FP, then land", 2026-10-13T14:05Z), and the PR's quality gate now reads OK.

Why it is a false positive:

  • The flagged line builds the NEXT line that the landing's refusal PRINTS: shlex.join(["git", "-C", str(root), "push", "-u", "--", "origin", branch]). Nothing runs it.
  • -- comes before origin and the branch, so git would read the branch as a name and never as an option.
  • checked_value("--branch", branch) (update-shape.py:2217) already refuses a branch that begins with -.

The source Sonar names is the command line (update-shape.py:3815, parse_args). The person running the tool supplies it, and every step on the path checks it.

@brettheap
brettheap merged commit 8cd7dbe into main Oct 13, 2026
5 checks passed
@brettheap
brettheap deleted the fix/update-shape-pinned-copy-symlink branch October 13, 2026 15:52
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…ype gate on the plan's keys comes first in `check` and `execute`, before the source is opened, and the walk that now follows #273's pin files reads only the pins `_read_pins` takes, so a `pins:` that is no list is named once and never walked

Merged by sha d41cc29, the nine commits
of main past af01068 (47b1e06 to d41cc29). The brief named 8cd7dbe; main
had moved to d41cc29 (#402, shape-doctor.py) by the fetch at 21:19:04Z,
so the newest was merged. No conflict: the tree is
`git merge-tree --write-tree a79936f d41cc29`'s, d0b610c.

The commits that meet this branch's files:
- f391e58 (#386 via #435): `Plan` reads `paths:` and `allow_empty_legs:`
  through `_plan_list`, `source:` through `Plan.source`, and `legs:` only
  as a mapping in `names()`; `_plan_key_findings` names a `source:` or
  `legs:` that is no mapping and a `pins:`, `allow_empty_legs:` or
  `paths:` that is no list (`plan-bad-source`, `plan-bad-legs`,
  `plan-pin-malformed`, `plan-bad-allow-empty-legs`, `plan-bad-paths`).
  `cmd_check` prints them, with #293/#294's field findings, before it
  makes the work directory or opens the source, and exits 1;
  `cmd_execute` refuses them first (`_refuse_a_mistyped_plan`).
- 191105a (#225 via #266) and d52b100 (#462 via #506) change
  scripts/shape_materialize.py: the leg-path rule refuses a leg path the
  YAML reader reads back as another type, and `RULESET_HINT` offers one
  exit. `leg_path_problems` is what `_PlannedRoot._mount` and
  `_leg_findings` ask, so a plan it refuses has no mounts in the walk, as
  before; no line this branch changed in the materializer is touched.
- 47b1e06 (#259 via #395): the templates quote their text placeholders.
- 8cd7dbe (#286 via #347): update-shape.py refuses a pinned copy, the pin
  or the manifest kept as a symlink as `update-rewrite-symlink`, and
  shape-doctor.py calls such a copy `copy-is-link`. Neither is this
  branch's file, and the lines it adds name neither the materializer nor
  adopt-project.py.
- cdcd2f0, fc48854, 631f087 and d41cc29: setup-project.py,
  tests/conftest.py, shape-doctor.py, the family Makefile and
  update-shape.py, none of this branch's files.

How they compose with this branch:
- The walk reads the plan's pins through `_declared_pins`, as
  `_leg_collision_findings` does: only the entries `_read_pins` takes as
  pins. A `pins:` that is no list is #435's first finding, so neither
  command reaches `_entry_findings`, and so the walk, with one; were it
  reached (the tests ask the walk directly), it adds no path and raises
  nothing. A list whose entry is no pin passes #435's gate and is
  `_read_pins`' finding in `_leg_findings`, and the walk adds no path for
  it.
- A `paths:` that is no list reads as no entries (`_plan_list`), and a
  `legs:` that is no mapping as the default mount paths
  (`_plan_leg_path`): both are #435's findings first, so neither reaches
  the walk from `check` or `execute`.

The order, as read in the merged code:
- `check`: #435's key findings, with #293/#294's beside them, alone and
  first; else `plan-stale`, #320's split-branch findings, then
  `_entry_findings`: #196's unsafe paths, #303's empty symlinks, coverage,
  `_leg_findings` (#273's pin findings last), #166's submodules,
  `plan-root-symlink`, #255, #261, #479, #190, #293/#294.
- `execute`: `_refuse_a_mistyped_plan` (#435) before the work directory
  is made; then `_check_names`, `_refuse_an_unrunnable_plan`,
  `adopt-name-too-long`, #255's clone URL, `_refuse_what_check_finds`,
  `adopt-symlinks-off`, seeding, `adopt-work-dir-occupied`, #479's remote
  check, `adopt-branch-exists`, #320. All before any leg.

Checked on the merged tree: no top-level name bound twice in
adopt-project.py, scaffold-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, update-shape.py or shape-doctor.py but
main's own (`YamlError`/`parse_yaml` in the materializer, `sys` in the
doctor); no name loaded and bound nowhere; each side's change reappears
line for line in the other side's diff to the merge, in every file.

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
…s a runnable `scripts/family.py`, printed commands quote paths, and `add`, `bump` and `remove` refuse the tracking branch and print their branch's push (#488, #486, #487) (#507)

Three defects from lane 3's v1.0.0 acceptance pass of AGENTS.md's "Creating a family, and adding a member", one commit each, in this order, then one review commit for #486 and one for #487. All three change `scripts/family.py` and `add`'s NEXT line, so they are one pull request.

**What was wrong.**
- AGENTS.md spelled the command `python3 scripts/family.py init --org <org> --family <Name>`, a path that runs from the standard's root. `--into` defaults to where the person stands.
- So the command, followed exactly, printed `landing <standard>/Orbit/Orbit` with no word, and `make siblings` would then clone every member into the standard's checkout too.
- `init`'s NEXT STEPS printed `python3 family.py add …`, built from `Path(__file__).name`. From the root that fails `can't open file`, exit 2.

**What changed** (commit 1).
- With neither `--into` nor `--work-dir`, `init` refuses a landing inside a checkout of openRepoShape: `family-landing-in-standard`. A checkout is a directory holding `scripts/family.py` beside `templates/family-root/`, found in the landing's ancestry, so another clone or an install counts too.
  - The refusal comes before anything is created, under `--dry-run` alike.
  - The remedy names `--into ..` from the root. From deeper inside the checkout it names the relative path that reaches the checkout's parent.
  - An explicit `--into` or `--work-dir` is not second-guessed. `family-holder-already-here` is still asked first.
- NEXT STEPS, and the `family-holder-already-here` remedy, name this file relative to the directory `init` ran in. From the root that is `scripts/family.py`, as setup-project.py, validate-family.py, AGENTS.md and README spell it. It is absolute where no relative path exists.
- AGENTS.md's init command passes `--into ..`, as README's already does, and step 1 says why. The edit is line-neutral: step 1 is one line shorter.
- `init --into --help` says it too, and docs/cli.md is regenerated.

**How tested.** `tests/test_family_init_landing.py`, 9 tests:
- the old command from the root is refused, as a dry run, so nothing can land in the checkout;
- a real run inside a fake checkout creates nothing;
- the remedy from a subdirectory names the right parent;
- an explicit `--into .` or `--work-dir .` passes;
- outside a checkout the default is unchanged;
- AGENTS.md's line, as written, lands beside the checkout;
- the printed NEXT runs, both from the root and from elsewhere.

On origin/main code 6 of them fail. Removing the refusal turns 3 red, and putting `Path(__file__).name` back turns 2 red.

**What was wrong.** `family.py` and the holder's `siblings.py` printed every path in their pasteable commands bare. Under a holder at `…/my work/Sonata/Sonata`:
- the `member-git-dir-cached` remedy `rm -rf <store>` split into `rm -rf …/my`, an absolute directory that is NOT the store, and a relative `work/…`;
- `init`'s NEXT STEPS and `add`'s NEXT split `--family-root` and `git -C` (`fatal: cannot change to '…/my'`, exit 128);
- the `mv` lines for a misnamed family folder pasted as four arguments.

**What changed** (commit 2, and the review commit after #487's).
- `scripts/repo_shape.py` gains `shell_quoted(value, platform=None)` and `shell_line(words, platform=None)`, in a new section at its end, the library `family.py` already imports.
  - It is the doctor's `quote_arg` rule (#101/#102): the same two alphabets, and the same two escapes, `sh`'s `'\''` and PowerShell's doubled apostrophe. The dialect comes from `os.name` or a `platform` argument. An ordinary word is left bare.
  - It does not flatten to ASCII, as the doctor's report does by contract. A command that deletes or moves a directory has to name that directory. So PowerShell's typographic single quotes are doubled too.
  - A word that begins with `=` is quoted, in both dialects. zsh, macOS's login shell, expands a bare `=word` to a command's path (`=ls` reaches the command as `/bin/ls`). This is the one place it differs from the doctor's rule on ASCII.
- `family.py`:
  - `init`'s NEXT STEPS print the `add` on ONE line. It used to paste only into a POSIX shell, because of its `\` continuation. `cd <holder>` and `make siblings` are two lines, with no `&&`, which Windows PowerShell 5.1 rejects between two statements.
  - Those lines and the `family-holder-already-here` remedy quote every path.
  - `add`'s NEXT quotes `git -C <holder>`, and `member-path-taken` quotes each spelling it names.
  - `member-git-dir-cached`, and `remove`'s note about the same store, print the directory alone on its own line. Then comes `rm -rf` with its one argument quoted for the POSIX shell `rm -rf` belongs to, and the sentence names that shell: Git Bash on Windows. PowerShell refuses `-rf` and deletes nothing.
- `siblings.py`: the parent-folder warning's `mv` and `mkdir -p` lines are quoted for a POSIX shell with the standard library's `shlex.join`, and the warning says which shell. It does NOT import the new quoter: a holder that kept its own `repo_shape.py` through `update-shape.py apply --accept-local` takes the new `siblings.py` without it, and an import of a name its copy lacks stopped `make siblings`, `park` and `resume` with an ImportError that `check` and the validator never reported.
- `setup-project.py`'s family hand-over quotes the holder in its `add` line (and names the branch first, see #487). It runs before any checkout of the standard exists, so it carries its own copy of the rule, and a test holds that copy to `shell_quoted`.

**How tested.** `tests/test_family_printed_quoting.py`, 69 tests:
- The quoter round-trips through `shlex.split`, and through a PowerShell reader, for a space and an apostrophe, `é` and a typographic apostrophe, shell syntax, the empty string, and every printable ASCII character alone and before, inside and after a word.
- The host's own `sh`, `bash` and `zsh` hand each quoted word back byte for byte through `printf`, a leading `=` among them (skipped where a shell is absent, and on Windows).
- It agrees with the doctor's `quote_arg` on every one of those ASCII values but a leading `=`, and its two alphabets are the doctor's two patterns.
- Under a holder in `my work` and in `Dana's work`, each of these splits into the argv it means: `init`'s NEXT (with `cd` and `make siblings` on their own lines), `add`'s NEXT, the cached-store remedy and note, the `mv` lines, and setup-project's `switch` and `add`.
  - The `rm -rf`, run as split, deletes the store, and the decoy `<tmp>/my` survives.
  - The `mv` lines, run as split, move the holder.
- The holder's own `siblings.py` runs `--help`, the misnamed-folder warning and `--make park` beside a `repo_shape.py` that lacks every name this change added.
- Three existing tests' expected lines follow the new wording.

Mutations, each turning tests red:
- a bare quoter: 35 red;
- bare `mv` lines in `siblings.py`: 3 red;
- a bare `rm -rf`, a bare `add` NEXT, a bare `init` `add` (round 1, on the 85-test file): 2 red each;
- `siblings.py` importing `shell_line` again: 1 red (the older-`repo_shape.py` test);
- `%` dropped from the POSIX alphabet: 2 red (the every-ASCII agreement and the alphabet test). The round-1 sample test passed it, 36 of 36;
- a leading `=` left bare: 10 red, among them the real-`zsh` test;
- `cd … && make siblings` back on one line: 2 red;
- setup-project's holder unquoted: 2 red.

**What was wrong.**
- AGENTS.md says each act's ONE commit lands as a pull request, and no step made the branch that pull request needs.
- After `init` the holder stands on `main`, and all three acts committed straight onto it.
- `add` printed `NEXT  git -C <holder> push -u origin <branch> && open a pull request`. No branch is called `<branch>`, so the push failed: `src refspec … does not match any`.
- `bump` and `remove` printed no NEXT at all.
- `bump-leg.py` refuses exactly this for a leg (`bump-leg-on-tracking-branch`).

**What changed** (commit 3, and the review commit after it).
- The real run of each act refuses the holder's tracking branch, the one `family.yaml` declares (`family-on-tracking-branch`), and a HEAD on no branch (`family-holder-detached`, which is what `bump-leg.py`'s `bump-leg-root-detached` refuses too).
  - The check comes after the refusals about the arguments and the holder, and before the member is read, fetched or cloned, and before anything is written.
  - The remedy is a quoted `git -C <holder> switch -c <branch>`. The branch is `family/add-<project>`, `family/bump-<id>-<sha12>` or `family/remove-<id>`, and a name that is not a plain branch word becomes `member`.
- `--dry-run` runs on any branch. On a work branch it prints where the commit would land. On the tracking branch it prints that the real run refuses there, with the refusal's code, and the `switch` command on a line of its own. The module's EXIT CODES paragraph names this as the one refusal a dry run does not make.
- Each act that commits ends with ONE pasteable line: `NEXT  git -C <holder> push -u origin <the branch it committed on>`, quoted, then `then open a pull request against <tracking>; never push to it directly`. `bump` and `remove` gain that NEXT.
- `init`'s NEXT STEPS open with the `switch` before the `add`.
- `add`, `bump` and `remove` `--help` say the branch rule, and that each ends by PRINTING the push; docs/cli.md is regenerated.
- AGENTS.md's family block gains the `switch` step before the `add`, and step 3 says the tracking branch is refused, as `family-on-tracking-branch`, and that the branch is pushed as its NEXT line says. README's InkRouter block gains the same step, and its paragraph says it. Both edits are line-neutral: AGENTS.md tightens steps 2 and 5, and README tightens the `make bootstrap` paragraph. `docs/handbook.html` mirrors the block and the sentence, and its footer names #487 and the new README blob.
- The holder's own docs, `templates/family-root/AGENTS-shape.md` and `templates/family-root/README.md`, open their add/bump/remove block with the `switch` and say the act refuses the tracking branch. `setup-project.py`'s family hand-over prints the `switch` before its `add`.
- Test fixtures that committed on `main` now commit on a branch:
  - `tests/test_family.py`'s module fixture grows the family on `family/members` and pushes that branch to the remote's `main`, as the merged pull request would leave it;
  - `test_shape_doctor.py`'s holder fixture and `test_family_add_excludes.py`'s `empty_holder` switch to a branch;
  - `test_family_add_refusals.py` changes in four places: the `NEXT` expectation, `unborn_holder`, the empty-family test, and the corrupt-HEAD test. In that last test, the tag state's branch check is answered in-process, because the test is about the rollback's reading of HEAD.

**How tested.** `tests/test_family_tracking_branch.py`, 17 tests:
- For each act, on `main`: refused, with the holder byte for byte as it was, and for `add` nothing cloned. Then the printed `switch`, run as split, makes the branch; the act goes through on it; and its printed NEXT, run as split, pushes the branch, which the remote then holds at the act's commit.
- A detached HEAD is refused.
- Dry runs on `main` and on a branch say what they should.
- A family made with `--tracking-branch trunk` refuses `add` on `trunk`; on a branch named `main` the dry run says the commit lands there for a pull request against `trunk`, the real run commits, and its NEXT pushes `main`.
- The suggested names pass `git check-ref-format --branch`.
- `init`'s NEXT STEPS open with the `switch`.
- AGENTS.md, README.md and the holder's two docs show the `switch` in the same block as, and before, the first `add`.

Mutations, each turning tests red: the branch check letting `main` through, 4 red; the NEXT line naming `<branch>` again, 3 red; `init`'s NEXT STEPS without the `switch`, 1 red; the tracking branch hard-coded to `"main"`, 1 red (the `trunk` test; every family test passed it before); the holder docs without the `switch`, 2 red; setup-project without the `switch`, 2 red.

An independent review at 10c93de gave CHANGES NEEDED (0 blockers, 1 should-fix, 6 nits). The should-fix was a holder that kept an older `scripts/repo_shape.py` through `apply --accept-local`: its new `siblings.py` could not import the shared quoter, so `make siblings`, `park` and `resume` died. The round at bb11b63 fixes it and all six nits, as described above. Re-verified at bb11b63: APPROVE WITH NITS (0 blockers, 0 should-fixes, 3 nits), with SonarCloud's quality gate OK and 0.0% new duplication. The nits were the holder README's pointer to the push (now "as its `NEXT` line says"), two Sonar smells (S1192: `refs/heads/` is now `HEADS_PREFIX`; S3415: the two expected-first assertions now put the actual value first, as the file's other 32 do), and this description's list of other pull requests. All three are fixed at 3451a56: commit 11ae0a8, then a clean merge of main 994be39. The family set gave 566 passed, 8 skipped there. GitHub's update-branch then merged main cac6bb2 as 8e1471c, and main aa4e1f5 (#508, after #490, #474 and #468) was merged as 708f3bb. There only the `docs/handbook.html` footer conflicted: it takes main's clauses, #468's (#424) included, with the README blob recomputed, and `docs/cli.md` auto-merged and passes `--check`. Main 47b1e06 (#505 and #395, after #376, #374, #384, #495, #493 and #491) was then merged as fcbc7a2, again with only the footer in conflict: main's clauses stay in main's order, this branch's (#487) moves to the end, after (#483), and the README blob is recomputed.

Every run used `/usr/bin/python3 -m pytest -q -p no:cacheprovider`, with a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR` and git's identity blanked. Run on Linux. At e42ef74 (main d41cc29, #402, merged in by GitHub's update-branch), on its tree 655c927: the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1242 passed, 22 skipped**; the second set, plus `tests/test_leg_path_yaml_scalars.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_reuse_seeded_legs.py`, `tests/test_scaffold_pin_and_reuse.py` and `tests/test_doctor_validator_rows.py`, gave **1260 passed, 12 skipped**. The skips are the same as at 5a2776d, below.

At 654df49 (main 8cd7dbe merged in, with #506 and #347): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1242 passed, 22 skipped**; the second set, plus `tests/test_leg_path_yaml_scalars.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_reuse_seeded_legs.py` and `tests/test_scaffold_pin_and_reuse.py`, gave **1231 passed, 12 skipped**. The skips are the same as at 5a2776d, below.

At 5a2776d (main f391e58 merged in, with #266, #509, #496 and #435): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1213 passed, 22 skipped** (the template-quoting skips below, and three NTFS-junction cases, Windows only); the second set, plus `tests/test_leg_path_yaml_scalars.py`, gave **1171 passed, 12 skipped** (the four Windows-only skips below, and eight in that file, main's new one, whose cases the quoted templates (#259) leave with nothing to refuse).

At 22733ce (main 631f087, #503, merged in by GitHub's update-branch): the first set listed below for fcbc7a2, plus every `tests/test_update_shape*.py`, gave **1213 passed, 22 skipped** (the template-quoting skips below, and three NTFS-junction cases, Windows only); the second set gave **944 passed, 4 skipped**.

At fcbc7a2 (origin/main 47b1e06 merged in, with #505 and #395):

- every `tests/test_family*.py`, `tests/test_validate_family*.py`, `tests/test_park_resume_targets.py`, `tests/test_free_plan_hint.py` and `tests/test_template_text_placeholders.py`: **844 passed, 19 skipped** (`tests/test_family_init_values.py:219` and `:291`, which skip because the template now quotes the placeholder and `id:`).
- `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_shape_doctor.py tests/test_setup_project_py.py tests/test_setup_sh.py tests/test_template_quoted_values.py tests/test_stdout_encoding.py tests/test_windows_paths.py tests/test_commit_trailers.py`: **944 passed, 4 skipped** (the doctor's PowerShell line and `tests/test_stdout_encoding.py:286`, Windows only).

At 708f3bb (origin/main aa4e1f5 merged in):

- every `tests/test_family*.py`, `tests/test_validate_family*.py`, `tests/test_park_resume_targets.py` and `tests/test_free_plan_hint.py`: **718 passed, 8 skipped** (`tests/test_family_init_values.py:242`).
- `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_shape_doctor.py tests/test_update_shape_landing.py tests/test_doctor_unfinished_merge.py tests/test_template_values_single_pass.py tests/test_stdout_encoding.py tests/test_windows_paths.py`: **813 passed, 4 skipped** (the doctor's PowerShell line and `tests/test_stdout_encoding.py:286`, Windows only).

Earlier, with origin/main 48585e6 merged in:

- `tests/test_family*.py tests/test_validate_family*.py tests/test_stdout_encoding.py tests/test_windows_paths.py tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_park_resume_targets.py tests/test_bootstrap_spelling.py tests/test_free_plan_hint.py tests/test_template_values_single_pass.py`: **1303 passed, 9 skipped**.
- `tests/test_shape_doctor.py tests/test_update_shape*.py tests/test_commit_trailers.py tests/test_tracking_branch_ref.py tests/test_scaffold_ignored_shape_files.py tests/test_ruleset_hint_tracking_branch.py tests/test_bump_leg_dry_run_tracking.py tests/test_setup_project_py.py`: **999 passed, 6 skipped**.
- The new files alone: `test_family_init_landing.py` 9, `test_family_printed_quoting.py` 69, `test_family_tracking_branch.py` 17, all passed.

The full suite was not run locally. CI's three jobs are the check.

#505 and #395 have since landed, and both are merged here at fcbc7a2. Each of #505's family sentences was read against this branch's code, and none is false: AGENTS.md 3 and 233; README 569, 822, 1152, 1269 and 1348; the handbook 1126 and 1506. #395 landed with its test switching to `family/members` before its real `add`, as the last bullet below says, and the first run above includes it. #503 then landed as 631f087, and GitHub's update-branch merged it here as 22733ce with no conflict. Main f391e58 (#266, #509, #496 and #435) was then merged by hand as 5a2776d, again with only the footer in conflict: main's clauses stay in main's order, this branch's (#487) moves after #509's (#498), and the README blob is recomputed; `git show --remerge-diff` shows nothing else. #509's reworded sentences in AGENTS.md, README.md and the handbook were read beside this branch's, and none contradicts or repeats another. Its `setup-project.py` changes (the clone-path check, the plan line, the step-(7) backstop and `_main`) and this branch's `shell_word` lines in the hand-over do not meet, and `docs/cli.md` passes `--check`. Main 8cd7dbe (#506 and #347) was then merged by hand as 654df49, again with only the footer in conflict: this branch's (#487) moves after #506's (#462), the README blob is recomputed, and `docs/cli.md`, which both sides changed, still passes `--check`. #506's two `scripts/family.py` hunks (the `FAMILY_RULESET_HINT` import, and its print when the holder's push is refused) sit unchanged inside this branch's code, and that hint prints no path. Under `--reuse-empty-repo`, `init` still refuses its default `--into` inside a checkout of this standard (`family-landing-in-standard`) before it reads the remote, as a dry run with a stub `gh` showed. #506's sentences in AGENTS.md, README.md and the handbook are still true beside this branch's. Main d41cc29 (#402, for #277), which rewrote the doctor's NEXT lines and `tests/test_shape_doctor.py`, was then merged by GitHub's update-branch as e42ef74, with no conflict; the `holder` fixture still switches to `family/members` before its `add`. AGENTS.md stays at 490 lines and README.md at 1422.

`git merge-tree --write-tree` of 708f3bb, at 04:47Z on 2026-10-13:

- **Clean:** #503 (`fix/family-makefile-without-siblings`) and #395. Each merged tree keeps AGENTS.md at 490 lines and README.md at 1422.
- **`docs/handbook.html` footer only:** #505 (`docs/v1-accuracy`) and #491. #468 has landed and is merged here. #506 conflicts there too, and in `scaffold-project.py`, as it does with main. README.md and AGENTS.md merge clean with #505, and `setup-project.py` merges clean with #491. Whichever lands second keeps every footer clause and recomputes the README blob.
- **#395 merges clean but one of its tests fails with this branch.** `tests/test_template_text_placeholders.py::test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id` inits its own holder and runs a real `add` on `main`, which #487 refuses with `family-on-tracking-branch`. Whichever of the two lands second adds `git("switch", "-q", "-c", "family/members", cwd=root)` after that test's `init`; lane 5 is adding it on #395's own branch now, so this branch needs no change for it. (#395's head d526f76 also conflicts with this head in `tests/test_adopt_manifest_values.py`, a file this branch does not touch: that is #395 against main, which the same round resolves.)

- On Windows `shell_quoted` speaks PowerShell, the native shell this standard documents (`setup-project.py`), as the doctor's NEXT lines do. `git -C …`, `python scripts\family.py …` and `cd …` are shell-agnostic, so they get the platform's quoting, and no printed line joins two commands with `&&`, which Windows PowerShell 5.1 rejects.
- `rm -rf`, `mv` and `mkdir -p` are POSIX commands. They are quoted for a POSIX shell on every host, and the text says "in a POSIX shell (Git Bash on Windows)". PowerShell refuses `rm -rf`'s `-rf` and deletes nothing.
- The tests split a host-quoted line with a PowerShell reader on Windows and with `shlex` elsewhere, and split a POSIX-only line with `shlex` everywhere. The real-shell test is skipped on Windows.
- On Windows the tests do not execute `rm` or the `mv` lines. They delete the split path with `shutil.rmtree`, and stop after checking the `mv` argv.
- Paths: the cached store keeps git's POSIX spelling (`as_posix`), as before. Test paths are `resolve()`d, which expands an 8.3 short name such as `RUNNER~1`.
- Nothing here was run on Windows or macOS locally. The `tests-windows` and `tests-macos` CI jobs are the check; on macOS the real-shell test runs the shipped `zsh`.

- **A shipped template that imports a newer shared helper can break a holder that kept an older copy.** This change no longer does (`siblings.py` uses `shlex.join`), but nothing stops the next one: `update-shape.py apply --accept-local scripts/repo_shape.py` keeps a `repo_shape.py` that lacks a name a newly copied file imports, and `check` and the validator then read the holder as fine. The general guard is for `apply` to refuse that, as rv456 found for `bootstrap.py` and `spelled` (#456).
- **The doctor's `quote_arg` leaves a word that begins with `=` bare**, which zsh expands. `shell_quoted` quotes it; the doctor is left as it is here, and the agreement test excludes that one case.
- **The doctor and `update-shape.py` keep their own quoters.** The doctor's `quote_arg` flattens to ASCII by its report's contract, so moving it onto `repo_shape.shell_quoted` is more than an import change. `update-shape.py`'s POSIX `shlex` lines are lane 3's open #374. A test holds `shell_quoted` and `quote_arg` to one answer on every printable ASCII value but a leading `=`.
- **`family.py`'s by-hand rollback commands (#365) and its `git config -f .gitmodules` removals (#317, #360) keep `shlex.quote`.** They were already quoted, they are POSIX-only (`printf … | git update-index`), and they are worded for a POSIX shell.
- **The holder's `validate-family.py` remedies that name `family.py add`** (`member-gitlink-absent`, `member-gitlink-unnamed`) print no branch step: they are a holder copy, and the `add` they name refuses the tracking branch with its own `switch` remedy.
- **`remove`'s store note still computes `.git/modules/<path>`** rather than asking `git rev-parse --git-path`, as `add` does. In a linked worktree it therefore prints no note. That predates this change.
- **PowerShell verbs** (`Remove-Item`, `Move-Item`) are not printed for the POSIX-only commands.
- **README's `--into` sentence gives the default but not the refusal.** README.md (861) and the handbook (1246) say `--into <dir>` is the PARENT directory, "the directory you are standing in, by default". They do not say that `init` refuses that default inside a checkout of this standard (`family-landing-in-standard`). AGENTS.md item 1 says so, and the README's own example passes `--into ..`. README.md is at its 1422-line cap, so the sentence is on #459's checklist.
- **`setup-project.py`'s own `cd <clone>` line and its step-(7) backstop lines print their paths unquoted.** This change quotes only the family lines it prints. Those two predate it and #509, and they are on #459's checklist.

Reviewed independently at 10c93de: CHANGES NEEDED (0 blockers, 1 should-fix, 6 nits). The round at bb11b63 fixed the should-fix and all six nits, and its re-verification gave APPROVE WITH NITS (0/0/3), with SonarCloud's quality gate OK; those three are fixed at 3451a56. Main was then merged by GitHub's update-branch (8e1471c), by hand at 708f3bb and at fcbc7a2, where each time only the handbook footer conflicted, and by update-branch again at 22733ce (main 631f087, #503), with no conflict. CI was green there on Linux, macOS and Windows, with SonarCloud passing, but main had moved on: main f391e58 (#266, #509, #496 and #435) was merged by hand at 5a2776d, where again only the footer conflicted. CI was green there too, but main moved on again: main 8cd7dbe (#506 and #347) was merged by hand at 654df49, where once more only the footer conflicted. CI was green there as well, and main d41cc29 (#402) then came in by update-branch at e42ef74, with no conflict. CI is green on Linux and macOS, with SonarCloud passing, at e42ef74, a clean merge of main. All three OSes and SonarCloud passed at 654df49, and under Brett Heap's landing rule of 22:08:06Z (ruling 27) macOS and Windows carry over from that head. Main e6fbd74 (#515, for #514), which landed after it, touches none of its files, and the combined tree passed `tests/test_adopt_pins.py`, `tests/test_shape_doctor.py`, `tests/test_scaffold_leg_path_collisions.py`, the hygiene, AGENTS-code and CLI-reference tests, `tests/test_family_printed_quoting.py` and `tests/test_family_tracking_branch.py` (777 passed, 3 skipped).

Closes #488
Closes #486
Closes #487

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
Merged by sha, e6fbd74, fetched at
22:23Z, on top of 180902f. It brings seven commits since 191105a, oldest
first: cdcd2f0 (#509), fc48854 (#496), f391e58 (#435, the plan's field
types), d52b100 (#506), 8cd7dbe (#347), d41cc29 (#402) and e6fbd74
(#515 for #514: `execute` writes each `neutral_product_pins:` entry so
it reads back as the text `plan` recorded).

No conflict: the tree is `git merge-tree`'s (8178281). One function
changed on both sides, `_template_table`: this branch spells three plan
keys by their constants (`ELECTED_ON_KEY`, `REFERENCE_KEY`,
`ELECTED_BY_KEY`), and #514 takes the pins' list from
`_neutral_product_pins(pins)`. Both are kept. No top-level name is bound
twice in adopt-project.py; shape_materialize.py and family.py import
`parse_yaml` and `YamlError` twice on both parents, the same objects.

#435 comes first. `check` returns on `_plan_type_findings` before any of
this branch's questions, and `execute` calls `_refuse_a_mistyped_plan`
before it opens the source. Probed: `source:` or `legs:` set to 7, true
or a list, and `allow_empty_legs:`, `paths:` or `pins:` set to something
that is not a list (16 plans). Each is one finding at `check`, and
`execute` refuses it before the work directory or a remote exists. None
prints `manifest-misread` or #181's remediation, and none ends in a
traceback.

#514 quotes the one line this gate still found a misread on. This gate
asks `_template_table` (`_pin_field`), so it asks the quoted line with no
edit here. At this merge the quoting file, main's
tests/test_adopt_manifest_values.py and tests/test_adopt_pins.py and
tests/test_template_text_placeholders.py give 14 failed and 803 passed.
They are the 14 rows round K4 named, each asking what #514 changes. The
next commit makes them true.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 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