Skip to content

scaffold-project.py and family.py: --tracking-branch is asked of git check-ref-format --branch before anything exists, so ~, a..b, x.lock and a/ are refused under --dry-run and no longer die at the first git init -b after gh repo create has made the repositories (#283) - #355

Merged
brettheap merged 8 commits into
mainfrom
fix/scaffold-tracking-branch-ref-format
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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

What

scaffold-project.py and scripts/family.py init now ask git whether --tracking-branch is a branch, before any work directory, remote or repository exists. They do it under --dry-run too. A name git refuses is a named refusal, exit 2, no traceback:

REFUSED scaffold-bad-tracking-branch: --tracking-branch is 'a..b', which is not a branch name git will create: `git check-ref-format --branch` refuses it (fatal: 'a..b' is not a valid branch name)
Remediation: pass a branch name git will create, such as `main` or `release/1.2`: no `..`, no `//`, no `~`, no `/` at either end, no `.` at the end, no part that starts with `.` or ends in `.lock`, and not `HEAD`. `git check-ref-format --branch <name>` is the check, and `git help check-ref-format` lists every rule.
  • The question, scripts/shape_materialize.py, in a new "A branch name, asked of git (scaffold-project.py holds --tracking-branch only to checked_value's alphabet, so a name git refuses as a branch (~, a..b, x.lock, a trailing /) passes --dry-run and, against GitHub, dies at the first git init -b after gh repo create has made all three repositories #283)" block after run():
    • branch_problem(what, value) returns None when git check-ref-format --branch accepts value exactly as written, else the sentence above.
    • refuse_bad_branch(refusal, what, value) raises Refusal(refusal, <that sentence>, <remedy>), so each tool refuses under its own code, the way refuse_bad_leg_paths lets it.
    • Git is asked, not copied. A rule git adds or relaxes is ours when the person upgrades; a name only some versions refuse (HEAD) follows the git that runs.
    • Git is asked with no repository in play. _ask_git_branch runs git --git-dir=<an empty temporary directory> check-ref-format --branch <name>, with that same directory as the working directory, and removes it afterwards. The verdict is therefore the same from every directory. That includes a directory inside a repository git cannot open, such as a .git file naming a gitdir that is gone, or a .git/config that does not parse. It also includes a directory removed under the person.
    • A git that cannot answer for any name says so; it does not blame the name. When git refuses a name, it is asked about main as well. If it refuses that too, as it does under a malformed global git config, the refusal says the name "could not be checked", with git's own words. Its remedy, BRANCH_UNCHECKED_REMEDIATION, is about git and not about the name. The helper tells the two cases apart by asking git again, not by reading git's words, which are localised.
    • A leading - is refused without asking, so the value is never an option to the question itself. checked_value has already refused it in both tools, so the guard is a second line.
    • A name git would expand rather than echo is refused as not the name written. In a repository, @{-1} becomes the branch before; asked of no repository, git has nothing to expand and refuses @{-1} itself. The alphabet keeps { out anyway. The helper still refuses any answer that differs from the name, and a test holds that guard with a git that answers another name.
    • A git that cannot run gets the "could not be checked" refusal and the unchecked remedy, not a traceback. So does a value that cannot be an argument at all (a NUL), but its remedy is the name's.
  • scaffold-project.py _scaffold: refuse_bad_branch("scaffold-bad-tracking-branch", ...). It sits after _declaration and _check_declared_names and before _shape_revision, the work directory and every remote. See "Why the call is there".
  • scripts/family.py _checked_family_identity: refuse_bad_branch("family-bad-tracking-branch", ...) right after checked_value, the first thing init does. cmd_init runs gh repo create for the holder before _create_holder_worktree's first git init -b, so the defect is the same one.

Why

checked_value holds the flag to an alphabet (SAFE_ARG_RE), which keeps an option and a shell word off a command line. The alphabet is wider than what git accepts as a branch. ~, a~1, a..b, x.lock, a/, /a, a//b, .a and a. are all inside it, and git init -b refuses each. Nothing asked git.

Reproduced on main 71b58a1 (git 2.43.0) with a stub gh that logs its calls and succeeds, so nothing reached GitHub (tools/repro.sh, repro-base.log):

== --tracking-branch '~':    --dry-run exit 0, real run exit 2, 3 x gh repo create, work dir made, REFUSED a git or gh command failed. THE EXACT COMMAND WAS:
== --tracking-branch 'a..b': --dry-run exit 0, real run exit 2, 3 x gh repo create, work dir made, REFUSED a git or gh command failed. ...
== --tracking-branch 'x.lock' and 'a/' the same, and so do a~1, /a, a//b, .a and a.
== --tracking-branch 'main': --dry-run exit 0, real run exit 2, 3 x gh repo create  (the control; it dies at the push because the stub makes no remote)
== family init --tracking-branch '~': --dry-run exit 0, real run exit 2, 1 x gh repo create

At this head, the same nine names exit 2 under --dry-run and under a real run, with 0 gh repo create, no work directory and no plan on stdout. main and feature/x still reach gh repo create (repro-fixed.log, repro-family-fixed.log). Against GitHub the old run left three repositories with no commit, and the re-run was then refused scaffold-remote-exists for the two legs until the operator deleted them by hand.

Why the call is where it is. The first targeted run found a fault in my own first placement. I had put the call beside checked_value at the top of _scaffold, and 129 existing tests failed with ProcessStarted: ['git', 'check-ref-format', '--branch', 'main']. test_scaffold_pin_collisions.py, test_scaffold_leg_path_collisions.py and test_scaffold_referent_chain_links.py hold that the refusals made before _identity and _declaration start no process. Each has a control that the first process is git config user.name, or that _declaration is reached. This check has to ask git, so it moved after _declaration and _check_declared_names and before _shape_revision. Nothing exists when it refuses, and the 129 pass again. The one thing that now runs before it is _declaration's gh api read, and only for a --pin with no --pin-source; it creates nothing.

Why no repository. --branch sets up the repository it finds in its working directory. Asked in the operator's directory, git failed for every name inside a repository it could not open, and in a directory that had been removed. From those directories both tools then refused the default main as "not a branch name git will create", where main scaffolds from all three (tools/x355/cwd-probe-main.log, cwd-probe-head.log). --git-dir alone is not enough, because git run from a removed directory dies before it reads its arguments (Unable to read current working directory). The empty directory serves as both the git directory and the working directory.

Why shape_materialize.py, not repo_shape.py. repo_shape.py is a shape copy: the materializer writes it into every project and contracts/shape-pin.yaml digests it. A function added there would put an upstream-changed scripts/repo_shape.py row in front of every project for a rule no project's own copy asks. This is the same placement, and the same reason, as refuse_bad_leg_paths (#179). No template file changes, so no digest pin moves. docs/cli.md is unchanged: no --help text changed, and render-cli-reference.py --check passes.

Tests

New file tests/test_tracking_branch_ref.py, 161 tests:

tests what they cover
24 scaffold, local route: the 12 refused names, in a --dry-run and a real run; exit 2, REFUSED scaffold-bad-tracking-branch: --tracking-branch is '<name>', ..., the remediation, no plan, nothing on disk, no temporary work directory
24 the same on the GitHub route, with a #! stub gh first on PATH: no gh call at all, not even repo view. Skipped on Windows by WINDOWS_SKIP, for the reason the existing gh stub tests carry
10 controls: ten names git creates (main, release/1.2, shape/update-v1.2, lane-3/shape+fix@eagle, a@b, ...) still pass --dry-run, with nothing created
1 control: --tracking-branch=release/1.2 scaffolds, and all three bare remotes hold exactly that branch
1 the ~ repro: the first words name the flag and the rule
1 a leading - (-x, which has nothing else for the alphabet to refuse) is still unsafe-value from checked_value, never reaching check-ref-format
24 + 8 + 2 family.py init: the 12 names in both modes; the GitHub route with the stub, 4 names in both modes (skipped on Windows); two controls
2 from inside a repository git cannot open, a .git file naming a gitdir that is gone and a malformed .git/config, with TMPDIR inside it too: scaffold and family --dry-run with the default branch exit 0, a..b is still refused by both, and the question leaves no directory behind
1 from a removed working directory (POSIX; Windows will not remove a directory a process stands in): main passes and a..b is git's refusal
1 a git that cannot answer for any name (a malformed GIT_CONFIG_GLOBAL): main and a..b are each "could not be checked", not "not a branch name", with the unchecked remedy
54 + 1 the helper says what this git says, over a 54-name corpus, and the corpus holds names of both kinds
6 helper units: the refusal's code, flag and remedy; a leading - is not put to git; a git that cannot run (the unchecked remedy); a NUL; a name git would expand; one definition imported by both tools
1 the helper is not in the shape copy repo_shape.py

The 12 refused names are the issue's nine (~, a~1, a..b, x.lock, a/, /a, a//b, .a, a.) plus a/.b, a.lock/b and +x..y.

The new file against main's code. It was committed in a scratch tree, so the checkout is clean.

  • On main 92e29d9: 145 failed, 16 passed (tools/x355/x355-main-newtests.log.gz). The passes are the controls, and the two broken-repository tests pass their main half and fail at a..b.
  • At the original 157 tests, on 71b58a1: 141 failed and 16 passed. 81 of those failed for the issue's reason: every --dry-run row printed the plan and exited 0, and every real-run row exited 2 with REFUSED a git or gh command failed after git init -b died. The other 60 failed with AttributeError, because the helpers are new (tools/new-tests-on-base-summary.txt).

Against this PR's previous head a03abcf: 5 failed, 156 passed (tools/x355/x355-old-newtests.log.gz). The 5 are the four new tests and the cannot-run unit's remedy assertion:

  • the two broken-repository rows refuse main (fatal: not a git repository, fatal: bad config line 6);
  • the removed directory refuses main (Unable to read current working directory);
  • the malformed config blames the name;
  • BRANCH_UNCHECKED_REMEDIATION did not exist yet.

One mutant per decisive line, each caught. The first four were run in scratch clones at a03abcf; the rest are commits on this head, run against this file (tools/x355/x355-mu*.log.gz):

mutant result
the scaffold's call deleted 49 fail, first test_a_name_git_would_not_create_is_refused_before_anything_is_made[tilde-dry-run]
the family call deleted 32 fail in -k "family or one_definition"
except CommandFailed returns None 111 fail
--git-dir dropped, so git may look for a repository again 2 fail: the two broken-repository rows ([gitfile], [bad-config])
the working directory no longer the empty directory 1 fails: the removed-directory test
no control question, so every refusal blames the name 1 fails: the malformed-config test
a git that cannot run gets the name's remedy 1 fails: the cannot-run unit
the answer != text guard deleted 1 fails: the expansion unit, everywhere

Targeted runs at this head (d696acc), git-filter-repo 2.47.0 on PATH, one pytest process at a time, every one green:

files result
test_tracking_branch_ref.py + the 8 test_scaffold_*.py files 490 passed (161 + 329)
test_family.py, test_family_add_excludes.py, test_family_add_refusals.py, test_git_output_decoding.py, test_cli_reference.py, test_repo_hygiene.py 225 passed
scripts/render-cli-reference.py --check ok

At a03abcf, test_git_output_decoding.py, test_adopt_leg_paths.py, test_adopt_plan.py, test_shape_doctor.py, test_cli_reference.py, test_repo_hygiene.py and the two test_family_add_* files gave 773 passed and 4 skipped. The fix round touches only the helper, which only these two tools call, so that set was not repeated.

I did not run the full suite or macOS and Windows; CI does. The Windows risk is the 32 stub-gh tests, which are skipped there, and the local-route tests, which hold no symlink, junction or text-mode stdin. The broken-repository tests run on Windows: the gitfile names its gitdir with / separators. The removed-directory test is skipped on Windows.

Fix round (review of a03abcf)

Lane openRepoShape-2's own review of a03abcf found one P2, two P3s for follow-up issues, and nits; this round takes the P2 and the nits.

  • P2: the answer depended on the operator's working directory. Fixed in e5bbf65. From a directory inside a repository git cannot open, both tools refused the default main and told the person to pass main. Main scaffolds from there. The review's form, --git-dir=<empty directory>, is taken, and the working directory is that directory too, because a removed directory failed the same way and --git-dir alone does not reach it. The review's optional rewording is taken as well, by the control question above rather than by reading git's words. Four tests, and the cannot-run unit now checks the remedy.
  • Nits, two tests. Fixed in d696acc. The leading-dash test passes -x, so it holds the dash rule alone, where --upload-pack=touch x was also refused for its space and its =. The NUL test's composite assert (Sonar python:S9073) is two asserts.
  • Nit, this body. The answer != text mutant now fails one test everywhere, the expansion unit. The @{-1} corpus row caught it only in a checkout with a previous branch, and git is no longer asked in a checkout. The count of open PRs touching adopt-project.py and the merge-tree list below are re-checked.

The review's other two findings are follow-up issues and are left out (see "Left out").

Composes with

git merge-tree --write-tree d696acc <head> at 2026-10-12T16:52:55Z (tools/x355/merge-tree-d696acc.log): rc 0 against origin/main 92e29d9, against #314 a5438a5 and #373 5ac03de, and against this lane's open heads (#267, #266, #353, #356, #343, #346, #347, #352, #401, #403, #404, and the #386 and #359 branches). The one exception is #358 2745890, rc 1 in shape-doctor.py. That conflict is #358's own: #358 conflicts in the same file with this PR's base 71b58a1 and with main 92e29d9 when #355 is not involved, and this PR does not touch shape-doctor.py.

I also merged this head into scratch trees and ran the tests that could collide:

Left out

Closes #283

🤖 Generated with Claude Code

Summary by Sourcery

Validate tracking branch names with Git before any scaffold or family resources are created, and fail clearly when the name is invalid or Git cannot verify it.

New Features:

  • Validate scaffold and family tracking branch names with Git's branch-format rules before creating workspaces, remotes, or GitHub repositories, including during dry runs.
  • Provide consistent, tool-specific refusals and remediation for invalid or unchecked branch names.,

Bug Fixes:

  • Prevent invalid tracking branches from reaching repository initialization after partial local or remote resources have already been created.
  • Make branch validation independent of the caller's working directory or repository state.

Enhancements:

  • Centralize branch validation in the materialization utilities and distinguish invalid names from failures to query Git.

Tests:

  • Add comprehensive coverage for invalid and accepted branch names, dry-run behavior, local and GitHub routes, broken working directories, Git failures, and shared helper behavior.

…it check-ref-format --branch` before anything exists, so `~`, `a..b`, `x.lock` and `a/` are refused under `--dry-run` and no longer die at the first `git init -b` after `gh repo create` has made the repositories (#283)

`checked_value` holds the flag to an alphabet (`SAFE_ARG_RE`), which keeps an
option and a shell word off a command line, and that alphabet is wider than
the names git accepts as a branch. `~`, `a~1`, `a..b`, `x.lock`, `a/`, `/a`,
`a//b`, `.a` and `a.` are all inside it and `git init -b` refuses each, but
nothing asked git. `--dry-run` printed a plan and exited 0. Against GitHub the
scaffold ran `gh repo create` for all three repositories and only then
reached the first `git init -b` in `_seed_legs`, so it exited 2 with three
repositories holding no commit, and the re-run was refused
`scaffold-remote-exists` for the two legs until the operator deleted them on
GitHub. `family.py init` ran `gh repo create` for the holder before its own
`git init -b` the same way.

`shape_materialize.branch_problem` asks git (`git check-ref-format --branch`)
and `refuse_bad_branch` raises the caller's own code with git's words and the
rules that say what to write instead. It is called right after
`checked_value` in `_scaffold` (`scaffold-bad-tracking-branch`) and in
`_checked_family_identity` (`family-bad-tracking-branch`), before a work
directory, a remote or any `gh` call exists, and under `--dry-run`. Git is the
authority and is asked, not copied, so a name that only some git versions
refuse (`HEAD`) follows the git that runs. A leading `-` is refused without
asking, a name git would expand rather than echo is refused as not the name
written, and a git that cannot run is a refusal, not a traceback.

It lives in `shape_materialize.py`, beside `refuse_bad_leg_paths`, and not in
`repo_shape.py`: that file is a shape copy every project digest-pins, and a
function added there would put an `upstream-changed` row in front of every
project for a rule no project's own copy asks.

`adopt-project.py` takes the flag at three sites (`plan`'s flag, `execute`'s
plan value, and no `check` finding at all) and is left for its own change.

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

Copy link
Copy Markdown
Contributor Author

@codex review

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

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

You can request another review in 10 hours and 53 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@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:16:38.028745Z d696acc Manual request
ℹ️ About Codex in GitHub

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

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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: a03abcf0f8

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

This was referenced Oct 12, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

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

This lane reviewed its own PR #355 at a03abcf, read-only in its own detached worktrees, and authored nothing.

CHANGES NEEDED. One finding is for the fix round. Nothing has to land first.

Findings

  1. P2, fix in this PR. scripts/shape_materialize.py:812 runs git check-ref-format --branch in the operator's working directory. Run from inside a repository git cannot open, both tools exit 2 and refuse the default main as "not a branch name git will create" (fatal: not a git repository). The case seen was a .git file naming a missing gitdir, or a malformed .git/config. Main scaffolds from the same directory. Asking git --git-dir=<empty temporary directory> check-ref-format --branch gives the same answer from every directory, and the PR's 157 tests still pass.
  2. P3, follow-up issue. --tracking-branch +x is a branch git accepts. --dry-run exits 0, then the real run creates the remotes and dies at git push -q -u origin +x, because a leading + means force. The cause is scaffold-project.py:1024, :1139 and family.py:796, and main does the same.
  3. P3, follow-up issue, not folded in. adopt-project.py --tracking-branch is never asked of git. plan and check pass a..b. scaffold-project.py holds --tracking-branch only to checked_value's alphabet, so a name git refuses as a branch (~, a..b, x.lock, a trailing /) passes --dry-run and, against GitHub, dies at the first git init -b after gh repo create has made all three repositories #283 promises the scaffold, and eleven open PRs touch adopt-project.py.
  4. nits.
    • tests/test_tracking_branch_ref.py:244 does not isolate the dash rule.
    • :384 is a composite assert.
    • The PR body's "2 fail" for the answer != text mutant is 1 outside a checkout with a previous branch.

Tests

  • Head: 710 passed, 1 failed. That failure is test_the_scaffold_refuses_with_no_elector, which is intermittent. It passed in a second head run and in four isolation runs, and this PR does not touch it.
  • a03abcf + 28bdc84: 922 passed. A clean head rerun was clean up to 81%, where a usage checkpoint stopped it. The fix round's head is tested instead.
  • The new file against main: 141 failed, 16 passed. 81 fail for the issue's reason and 60 on the missing helper.
  • Mutants: three of mine and four of the implementer's, all killed.

Merges. git merge-tree of a03abcf against main 28bdc84 and every open head that touches its files is rc 0. Those heads are #314, #266, #373, #374, #376, #380, #381, #394 and #395. #314 needs the composition note's two-line test edit: with it, 261 passed. #373 composed gives 171 passed.

Full report: opensoft/brett-wip handoffs/xFactory/attachments/lane-openRepoShape-2/review355-a03abcf/final-report.md.

brettheap and others added 2 commits October 12, 2026 16:51
…t with no repository and from an empty directory, so the default `main` is no longer refused from inside a repository git cannot open or from a removed directory, and a git that cannot answer for any name says the name could not be checked (#283)

The review of a03abcf found that `branch_problem` ran `git check-ref-format
--branch` with no `cwd`, in the operator's working directory. `--branch` sets
up the repository it finds there, so from inside one git cannot open (a `.git`
file naming a gitdir that is gone, as in a linked worktree whose main
repository moved, or a `.git/config` that does not parse) git failed for
every name. Both tools then refused the default `main` as "not a branch name
git will create" and told the person to pass `main`. Main scaffolds from the
same directories. A directory removed under the person fails the same way
(`Unable to read current working directory`), and main scaffolds from that
too. The fix list's form, `--git-dir=<empty directory>` alone, does not reach
that third case.

`_ask_git_branch` now asks `git --git-dir=<empty temporary directory>
check-ref-format --branch <name>` with that same directory as `cwd`. The
empty directory is no repository as `--git-dir` and a directory that exists
as the working directory, so the verdict is the same wherever the person
stands, and git has no reflog to expand `@{-1}` against. The directory is
removed afterwards with errors ignored, so a removal Windows delays cannot
turn git's answer into a refusal.

A malformed global or system git config still makes git fail for every
name, so the review's optional rewording is taken too. When git refuses a
name, it is asked about `main` as well. If it refuses that too, git could not
answer: the refusal says the name "could not be checked", with git's words,
and carries `BRANCH_UNCHECKED_REMEDIATION`, which is about git and not about
the name. The check asks git again instead of reading git's message, because
those messages are localised. A missing `git` gets that remedy too. A NUL in
the value is still the value's fault and keeps `BRANCH_REMEDIATION`.

The tests run both tools' `--dry-run` from the review's two directories, with
`TMPDIR` inside the broken repository, so a question that only moved its
`cwd` fails too. They also cover the removed directory (POSIX) and a
malformed `GIT_CONFIG_GLOBAL`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… holds the alphabet's dash rule alone, and the NUL test asserts in two steps (#283)

The review of a03abcf noted two test nits. `--tracking-branch=--upload-pack=touch x`
is refused `unsafe-value` for its space and its `=` as well as its leading
dash, so the test passed whether or not the dash rule held. `-x` has nothing
else for the alphabet to refuse. Sonar's python:S9073 flagged
`assert problem is not None and "could not be checked" in problem`; split
into two asserts, a failure now names the half that failed.

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

Copy link
Copy Markdown
Contributor Author

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

Fix round for the review of a03abcf, pushed as d696acc.

  • P2 fixed (e5bbf65). Git is now asked git --git-dir=<empty temporary directory> check-ref-format --branch <name>, from that same directory. The default main therefore scaffolds again from inside a repository git cannot open (a .git file naming a gone gitdir, or a malformed .git/config). It also scaffolds from a removed directory, which --git-dir alone does not reach. A git that cannot answer for any name, such as under a malformed global config, now says the name "could not be checked", with a remedy about git. The helper finds this by asking git about main too, not by reading git's localised words.
  • Test nits fixed (d696acc).
  • Follow-ups, as the review asked: +x as a forced refspec, and adopt's --tracking-branch.

Tests: the new file is 161 tests. At a03abcf's code it gives 5 failed and 156 passed. At d696acc it gives 490 passed and 225 passed across the review's 15 files, and five new mutants are each killed by their own test. merge-tree is rc 0 against main 92e29d9 and #314 a5438a5. #314 still needs the composition note's edit, now pr314-test-edits-for-composition-v2.patch: 265 passed (104 + 161).

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@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: d696acc920

ℹ️ 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 on lines +824 to +825
return run(["git", f"--git-dir={nowhere}", "check-ref-format",
"--branch", name], cwd=Path(nowhere))

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 Reject branch components whose lockfile exceeds NAME_MAX

For a single-component branch name of 251–255 characters (the new corpus explicitly includes 255), git check-ref-format --branch <branchname-shorthand> exits successfully, but the first commit after git init -b fails with fatal: cannot lock ref 'HEAD' because Git must create <name>.lock, which exceeds the common 255-byte component limit. Both GitHub workflows create their remote repository or repositories before that commit, so these accepted inputs still leave the empty remotes this change is intended to prevent. Validate by actually creating/locking the ref in the temporary repository, or reject components that cannot accommodate the .lock suffix.

Useful? React with 👍 / 👎.

This was referenced Oct 12, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

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

This lane re-verified the fix round on its own PR #355 at d696acc, read-only in its own detached worktrees, and authored nothing.

MERGEABLE WITH NITS. CI is green on all three OSes at d696acc. Whichever of #314 and #355 lands second applies pr314-test-edits-for-composition-v2.patch.

Fix list

  • The P2 is fixed. The new tests fail at a03abcf's code for the finding's reason (5 failed, 156 passed). In 12 of 13 probed environments (a missing gitdir, a bad config, a removed directory, a caller's GIT_DIR or GIT_WORK_TREE, no HOME), both tools exit as main does. Over 5,218 names, verdicts match a03abcf's, and nothing git init -b refuses is accepted.
  • The test nits are fixed.
  • Items 2 and 3 are follow-ups, as asked.

Nits (optional)

  1. scripts/shape_materialize.py:864-868: a mkdtemp failure is refused as git "could not be run", with a remedy about git. Seen with a removed directory and a relative TMPDIR.
  2. Two body claims are unpinned (the NUL's remedy, the control question's OSError); neither is reachable from the command line.
  3. The body's "Composes with" heads are stale: scaffold-project.py and adopt-project.py ask the project's YAML reader and validate-manifest.py about the manifest's values before anything exists, so --id null, --tracking-branch true and a blank --elected-by are refused (#258, #261) #314 is now c6f441b, and shape_materialize.py: RULESET_HINT names the run's tracking branch where it named main, as the pull request's base and the ref the scaffold and the family holder pushed (#324) #373 is 741cbf2.

Tests

Merges. merge-tree is rc 0 against main 45e01f4 and 36 of 37 open heads. #358's conflict in shape-doctor.py is its own.

Report: opensoft/brett-wip handoffs/xFactory/attachments/lane-openRepoShape-2/review355-a03abcf/reverify-d696acc.md.

brettheap and others added 2 commits October 12, 2026 14:02
…f-format, with the planned test edits to #314's file

Main now holds #314, which asks the project's YAML reader about the
manifest's values before anything exists. Its tests/test_scaffold_manifest_values.py
lists `--tracking-branch ~` among the values the reader misreads
(`branch-tilde`) and `.inf` among the values a tracking branch may hold
(TEXT_BRANCHES). This PR asks git whether the branch is a name it will
create, and git refuses both `~` and `.inf`, so on the merged tree those two
rows failed (2 failed in `-k "branch-tilde or inf"`).

The merge was clean (no textual conflict). The two hunks of
tools/pr314-test-edits-for-composition-v2.patch were applied to the merge's
working tree: the `branch-tilde` row leaves MISREAD_VALUES, and `.inf` leaves
TEXT_BRANCHES and its comment. `~` is still refused, by this PR's
`scaffold-bad-tracking-branch`, which tests/test_tracking_branch_ref.py pins.
Nothing else was edited by hand: scaffold-project.py and shape_materialize.py
merged without a conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…ues.py and scaffold-project.py: #314's tests compose with the quoted templates, so the 40 values it refused as misreads are asserted held as typed, `visibility: "true"` and `elected_on: "20261011"` stay refused in the validator's words, `--tracking-branch=~` is a strict xfail until #355, and `--help` no longer says `--id 007` or `--tracking-branch null` is refused (#259)

#314's misread gate (`manifest_misreads`) asks the TEMPLATE how a value is
written. With `project.yaml` quoting `id`, `elected_on`, `visibility` and
`tracking_branch`, it refuses nothing on the shipped templates, and 45 of
#314's tests failed on the merge: 36 in the scaffold file and 9 in the adopt
file, the same 45 ids the composition note counted on #314's older head.
They are not one kind:

- 40 expected a refusal the value no longer needs. Each now asserts that
  the value is ACCEPTED. A scaffold of `--id null|true|false|007|2026`, or
  of `--tracking-branch true|True|TRUE|false|False|null|Null|NULL|007|2026|1.5|1.50|0`,
  exits 0. The manifest it pushes holds the text, and each branch is the
  one every remote has. `--dry-run` prints the plan, and an id derived
  from `--project True|Null` is held as `"true"`/`"null"`. `--id=null` and
  `--tracking-branch=true` reach the forge's `gh repo create`. Adopt's
  `id: "007"` checks clean and `execute` writes it as typed. No coverage
  of the rule is dropped. A module copy of the standard writes the four
  lines bare again, as before #259, and there the rule's sentence, its
  remediation, the derived-id wording, the line-break sentence and
  `misread_placeholders` are asserted as before. The shipped templates
  are asserted to give no misread for any of those values.
- 4 are still refused, by #314's validator gate, in other words:
  `visibility: "true"` and `elected_on: "20261011"` are now text the
  validator refuses (`visibility is 'true'`, `elected_on is '20261011'`,
  no longer `True` and `20261011` the number). Their premise that a
  misread rule judges them, so that the validator is not asked again, no
  longer holds, and the test now asserts the validator's one finding with
  or without `misreads_elsewhere`.
- 1 is a real break: `--tracking-branch=~` reads back as `~` and dies at
  `git init -b ~`, after `gh repo create` against GitHub. That is #283,
  and PR #355's `git check-ref-format --branch` refuses it. No branch
  check is added here. The row is `xfail(strict=True)`; no test file used
  xfail before. It asserts the refusal #355 gives, and on a local merge
  with #355's head it reports XPASS(strict), so #355's CI says when to
  take the mark off.

The adopt file has one test fewer (78). Two `[id-007]` rows, the flipped
`..._on_this_rule_alone` and the passing `misreads_elsewhere` one, are
one test that asserts both.

`scaffold-project.py`'s `--id` and `--tracking-branch` help said a value
the reader would read as another type, `null`, `true`, `007`, is refused,
and its module docstring and `_refuse_unholdable_manifest` said the same.
On this tree each is accepted, so the sentences are dropped or reworded,
and `docs/cli.md` is regenerated.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-12T18:28Z)

Merged main after #314 landed: 6fdbe3d (main 3ef059c, merged with no textual conflict). As this PR's composition note planned, the merge commit also removes #314's branch-tilde and .inf rows from tests/test_scaffold_manifest_values.py (pr314-test-edits-for-composition-v2.patch). Both rows name a tracking branch that git refuses, and this PR now asks git. On the unpatched merge, those 2 rows fail and 104 pass.

Tests at 6fdbe3d

  • The PR's test file, both manifest-values files and the scaffold files: 786 passed.
  • The family files, decoding, test_cli_reference.py, test_repo_hygiene.py, test_gh_guard.py and test_agents_md_codes.py: 454 passed.
  • tests/test_shape_doctor.py: 265 passed, 3 skipped.
  • render-cli-reference.py --check is ok.

The body's "Composes with" section still describes #314 as open. This comment supersedes that row.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 6c2a371 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/scaffold-tracking-branch-ref-format branch October 12, 2026 21:40
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…king-branch

No conflict. #355 asks git about `args.tracking_branch` (refuse_bad_branch)
before anything exists, and refuses a value git would not echo as written,
so the value every RULESET_HINT call passes as `{local}` and `{tracking}` is
the one git accepted as a branch. adopt-project.py is still main's.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
36538fd..437a360 is #355, #401, #456 and #343; #401
adds `_leg_path_help` and the `--spec-path`/`--code-path` help to
adopt-project.py, away from every line this branch changes. The merge has
no conflict.

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

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

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

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

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

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

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

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
…where it named `main`, as the base of its pull request and as the local ref the scaffold and the family holder pushed, so a `--tracking-branch trunk` run's refused push no longer advises `push -u origin main:seed/scaffold` (a branch its work clone does not have) and `gh pr create --base main` (a base its repository does not have) (#324) (#373)

`RULESET_HINT` is what a person reads when an organisation's ruleset has just
refused a direct push: seed a branch, open a pull request. It hard-coded `main`
as the local branch of `git -C <work> push -u origin main:seed/scaffold` and as
`gh pr create --base main`. A run made with `--tracking-branch trunk` makes its
work clones with `git init -b trunk` and pushes `trunk`, so the first command
died on `error: src refspec main does not match any` and the second named a base
the repository does not have.

Two fields name a branch, and they are not one value:
- `{tracking}` is the remote's branch, the run's `--tracking-branch`, and
  stands in `--base` only. Every caller passes it.
- `{local}` is the ref in `{work}` that the refused push carried, and stands in
  the push command only. The scaffold's two pushes (`_seed_legs`,
  `_build_assembly_root`) and the family holder's (`_create_holder_worktree`)
  push the tracking branch by name from a clone made on it, and pass
  `args.tracking_branch` for both.
- No caller lacked a tracking branch to pass. Each value has been through
  `checked_value` by then (it admits only `[A-Za-z0-9._/@+~-]` and no leading
  `-`) and, since #355, `git check-ref-format --branch`, which must echo it as
  written, so it is printed as it stands, like `{repo}`.

An adoption does not print `RULESET_HINT`. Since #445, both adopt legs print
`adopt-project.py`'s own `ADOPT_RULESET_HINT`, which already names the run's
tracking branch (`<repo> has no <tracking> for that pull request to target`)
and prints no command. `adopt-project.py` is left as `main` has it, byte for
byte; the test pins #324's rule for it instead.

`tests/test_ruleset_hint_tracking_branch.py` runs every caller against local
bare remotes whose `pre-receive` hook refuses everything but `seed/*` (a
ruleset in miniature, installed through `GIT_TEMPLATE_DIR`), once with
`--tracking-branch trunk` and once with `main`:
- the scaffold's spec leg, its assembly root and the family holder: it pins
  the printed block against a literal copy of the hint as it was, asserts that
  the ref the hint names as local IS the commit the refused push carried, as
  the remote's hook recorded it, then runs the printed push and sees
  `seed/scaffold` arrive at that commit;
- an adoption's extracted and seeded leg, from a source on `master`: each
  prints `ADOPT_RULESET_HINT` for that leg's repository and the run's tracking
  branch, with no `main` for `trunk` and no `master`, and the constant itself
  holds no `main`;
- it scans every shipped `RULESET_HINT.format(` and `ADOPT_RULESET_HINT.format(`
  for a call that leaves a field out, which would raise `KeyError` inside the
  failure path that exists to explain a refusal.

Unchanged: for the default `main`, the scaffold's and the family holder's hint
is byte-identical. The three callers' `main` runs pin it against the literal
copy and pass on `main` and on this change alike. What the hint advises is the
same, and so are its two exits (seed by pull request, or ask an operator with
the bypass right; never `--admin`, per AGENTS.md); only the branches it names
move.

Left out:
- `gh pr create --base <tracking>` cannot run in any of the three places, for
  `main` as for any other branch: the repository the push was refused at is
  empty (`gh repo create` is called without a README), so the pull request has
  no base to land on. That is older than this change and is not touched here.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
…d `open_glass` share one `SHAPE_PIN_SOURCE_<PRODUCT>`, so `--pin` refuses the pair and `validate-pins.py` notes a declared one and reads the variable for neither (#275) (#409)

`pin_source_env_name` turns every character outside `[A-Z0-9_]` into `_`, so
`open-glass`, `open_glass` and `open.glass` are three legal product names, with
three pin files (`contracts/<product lowercased>-pin.yaml`), and one variable,
`SHAPE_PIN_SOURCE_OPEN_GLASS`. On ab623dd (this branch's main parent; the same
rows reproduce on c7c02fa), with the issue's own probe against local bare
remotes and a `gh` that exits 97:

| step | ab623dd | this head (6494714) |
|---|---|---|
| `scaffold-project.py --pin open-glass@<A> --pin open_glass@<B>` | exit 0, three repositories | exit 2 `scaffold-pin-collides`, nothing created |
| `validate-pins.py`, no source, on a project that already declares the pair | exit 0, both skips offer `SHAPE_PIN_SOURCE_OPEN_GLASS` | exit 0, one `note neutral-pin-source-collides`; the skips offer `--pin-source <product>=<path>` and a sibling checkout only |
| `SHAPE_PIN_SOURCE_OPEN_GLASS=<open-glass>` | `neutral-pin-source-unreadable` for `open_glass`, exit 1 | exit 0, the note; the variable is read for neither pin, so each pin is read from its own source or skipped |
| `SHAPE_PIN_SOURCE_OPEN_GLASS=<open_glass>` | `neutral-pin-source-unreadable` for `open-glass`, exit 1 | the same |
| `--pin-source open-glass=... --pin-source open_glass=...` | exit 0 | both pins recompute, exit 0, the note |

**What changed.** Every variable name is kept: an operator may have set one and
the docs name them. What is done instead:

- `scripts/repo_shape.py`: `pin_source_env_collisions(products)` is the one
  pure answer, `{variable: [products]}` for each variable two or more distinct
  products share. Distinct means distinct PIN FILES (`pin_product_key`,
  `lower()`, which is what `contracts/<product lowercased>-pin.yaml` is named
  by), so a product typed twice or in two cases is #241's file collision and
  never this one. `pin_source_env_shared` is the set of those keys.
- `scaffold-project.py`: `_refuse_colliding_pins` (left where it is) names each
  variable's `--pin` arguments as typed after #241's file sentences, in the same
  `scaffold-pin-collides` refusal. Dry run and real run say the same no, before
  any process starts and ahead of the leg-path refusal. #241's wording and
  remediation are unchanged when only a file collides. A NEW pair is still
  refused, so the situation is not created anew.
- `templates/assembly-root/scripts/validate-pins.py`: one NOTE per variable,
  `neutral-pin-source-collides`, naming every product and the variable, the
  keyed `--pin-source <product>=<path>` that reads each pin, and that which
  declaration stays is the human's decision. It is never a finding and never
  moves the exit code. A product whose variable is shared does not read it (rule
  2 of `resolve_neutral_pin_source`), and its skip hint offers the keyed
  `--pin-source <product>=<path>` and not the variable or the bare form.
  A variable one product has to itself reads and is offered exactly as before.
- `scripts/repo_shape.py`, the two chain readers: `link_pins_from_trees` does
  not read a variable two links of one chain share, and the
  `declared-unverified` hint stops naming it. Only a hand-written chain can have
  two such links, since scaffold takes every link but the first from the naming
  policy's alphanumeric `open<Product>` form.

Each reader of `pin_source_env_name`, decided:

| site | decision |
|---|---|
| `scaffold-project.py --pin` | refuse (above) |
| `validate-pins.py` rule 2 and the skip hint | a note, and the variable is neither read nor offered for a shared product |
| `repo_shape.py` `resolve_link_source` (via `link_pins_from_trees`) | skip the variable when another link of the chain shares it |
| `repo_shape.py` `_chain_link_findings` hint | omit the variable for a shared link |
| `adopt-project.py --pin`, `plan`, `check`, `execute` | not in this PR, see below |

**Fix round 1 (from the review of 34dbd86).**

**The design change: a declared pair is a NOTE and not a finding. This is the
lane coordinator's decision, and Brett may reverse it.** The first round made
`neutral-pin-source-collides` a FINDING. Main through ab623dd scaffolds such a
project (`--pin open-glass@A --pin open_glass@B` exits 0), and
`update-shape.py apply` runs the NEW `validate-pins.py` over the rewritten root,
so for a project already declaring the pair the finding ended every apply as
`REFUSED update-validators-red`, rolled back, for this update and every later
one, until a person deleted a true declaration of descent. AGENTS.md tells an
agent never to edit "a pinned file, a digest or a manifest to make a row go
green", and the finding's remediation was exactly that, so the person was
locked out. The coordinator chose the review's option (b): it is a NOTE now.
The guard stays (the variable is read for neither pin), each pin still verifies
from its own source, scaffold still refuses a new pair, and `validate-pins.py`
says the rest in one line. To put the finding back, restore 34dbd86's
`report.finding(...)` call in `_report_pin_source_collisions` (it is not a word
swap for `report.advise`: `advise` takes one string, `finding` a code and a
detail), give the finding the remediation 34dbd86 had, and turn the tests that
expect the note (and `test_a_project_declaring_a_colliding_pair_takes_a_shape_update`)
back into exit 1; the first round's versions of all of them are on 34dbd86.

- **Proved on a real old project.** Main (ab623dd) scaffolded the pair into
  local bare remotes; `update-shape.py apply` to the first round's head 34dbd86
  exited 2 `REFUSED update-validators-red` and rolled back (`git branch` shows
  only `main`); to this head it exits 0, the project's validator passes and
  prints the note, and the doctor's `pins` row is `ok` before and after. The
  doctor prints `passes` for a validator that exits 0 and does not echo its
  stdout, so the note itself is visible in `update-shape.py apply`'s output and
  in `validate-pins.py`'s, and the verdict moves for no reason but the update
  (`COMPLIANT, SHAPE BEHIND` to `COMPLIANT`). It is now a test:
  `test_a_project_declaring_a_colliding_pair_takes_a_shape_update`, over local
  stand-ins beside the project, so no `gh` is asked.
- **Collisions by the variable.** Pairs were decided by `casefold()`, but
  the pin file is `lower()`. `openß`/`openss`, `openſ`/`opens`, `openfi`/`openfi`
  and `openς`/`openσ` (the letters are U+00DF, U+017F, U+FB01 and U+03C2 beside
  U+03C3) share a variable and have two pin files, and were never found. They
  are now, from the pin-file fold (`pin_product_key`). A pool of 31
  names is checked pairwise against `neutral_pin_path` and
  `pin_source_env_name` themselves, and no single product is ever a collision.
  They are reachable only from a hand-written `project.yaml`.
- **Windows.** The guard's only test needed the `#!` `gh` stub and was
  skipped on Windows. `test_a_shared_variable_answers_for_neither_pin_with_no_gh`
  puts a checkout of each pin beside the project, names one in the variable, and
  needs no `gh`: on ab623dd it is `neutral-pin-source-unreadable`, here each pin
  reads its own sibling. The hint and the non-ASCII rows are tested in-process,
  with no skip and no filesystem.
- **The hint.** For a product two pins share, the skip hint offers the keyed
  `--pin-source <product>=<path>`: the bare form answers for every pin with no
  more specific source, so with two pins it gives the other the finding this
  issue is about.
- **Trailers and title.** The trailers of the earlier commits (a30091f and
  34dbd86 name Sonnet 5.5) are settled in this message, which carries Opus 5.5.
  The PR title now names `repo_shape.py`.
- `scaffold-project.py` is not touched this round: `fix/adopt-pin-writes-pin-file`
  moves that code, and the change would only conflict with it.

**Left out.**

- `adopt-project.py` is untouched. Its half arrives with #273's PR, which moves
  scaffold's pin machinery into a shared module and routes adopt through
  `pin_source_env_collisions`. Once it lands, the one place that still keys by
  `casefold()` (`pin_variable_collisions` in `scripts/shape_materialize.py`)
  should read `pin_product_key`; it cannot differ for an ASCII `--pin`, which
  is all `PIN_ARG_RE` takes.
- `scaffold-project.py`'s own `_colliding_pin_variables` also keys its
  argument lists by `casefold()` and one docstring there still says the
  project's validator reports the pair "as a finding"; both go with the code
  when #273 moves it. #312's `PinSources` keys `--pin-source` by `casefold()`
  too, so `openß=` and `openss=` are one key there; that is #312's.
- A product in `neutral_product_pins:` and a chain link in
  `naming.referent_chain` that share a variable are read by two validators in
  one process, and neither sees both sets. Not detected here.
- The doctor does not print a passing validator's stdout, so its `pins` row
  does not carry the note. Not changed: it would add a file to this PR.
- No `--help` text changed, so `docs/cli.md` is untouched. `README.md` is at its
  line cap and its blob is pinned by `docs/handbook.html`, so the one README
  sentence this wants needs a regenerated handbook and waits for its own change.
- `validate-pins.py` and `repo_shape.py` ship into projects, so every project's
  `shape-pin.yaml` reads `upstream-changed` for both until `update-shape.py
  apply`, which now succeeds for a project that declares such a pair.

**Evidence.** All on the head (6494714), with git-filter-repo 2.47.0 on PATH, a
`gh` that cannot be reached (a stub first on PATH, `GH_TOKEN` bogus, an empty
`GH_CONFIG_DIR`), and the repository-local `user.name` blanked at command-line
scope, as the standard's checkout sets one that two scaffold tests read.

- `tests/test_pin_source_env_collisions.py` is 137 tests and passes on this
  head. Laid over 34dbd86, 50 fail and 87 pass: the note and update tests, the
  non-ASCII rows, the hint, the sibling guard, and `pin_product_key` itself
  (which 34dbd86 does not have); the scaffold refusals, the chain readers and
  the controls hold on both.
- Targeted, run on ad6f569, whose tree differs from this head in
  `tests/test_pin_source_env_collisions.py` only (the last edits made one
  comparison read the committed blob and wrapped one line): the new file,
  `test_pin_validator`, `test_neutral_pin_validator`, `test_cli_reference`,
  `test_manifest_validator`, `test_naming_policy` and `test_repo_hygiene`
  **445 passed**; the scaffold pin, collision, referent-chain, leg-path and e2e
  files **245 passed**; `tests/test_update_shape*.py` **218 passed, 3 skipped**
  (junctions, NTFS only); `tests/test_shape_doctor.py` **254 passed, 3 skipped**
  (the PowerShell line, a Windows host only).
- On this head itself: the new file, `test_repo_hygiene`, `test_cli_reference`,
  `test_pin_validator` and `test_neutral_pin_validator` **244 passed**.
- This head merged with origin/main a983e6a, in a throwaway worktree: the new
  file, `test_pin_validator`, `test_neutral_pin_validator`, the scaffold pin,
  collision, referent-chain and e2e files, the whole doctor file and
  `test_cli_reference` **590 passed, 3 skipped** (the PowerShell line); the new
  file alone, in its final form, **137 passed**. origin/main had moved twice
  since; the merge-tree below is against it.
- Twelve mutation probes, each hunk reverted alone: all caught. The
  note-to-finding one is caught by the update test (a committed mutant, since the
  test clones the repository's HEAD) and the guards by the sibling test, run with
  every test that needs a `#!` stub deselected as a Windows runner would;
  the hint one by an in-process test.
- Nothing ran on macOS or Windows before this PR's CI.

**Merge-tree, as computed during the fix round on 2026-10-12** (every sha and
head is the one it had then; main and the other branches have moved since).
`git merge-tree --write-tree` of this head (6494714) against origin/main 1f90a0f
is clean, and so is the merge of origin/main a983e6a, which the merged-tree runs
above used. #312, #309, #270, #268, #228, #245 and #351 have landed. The rest
were computed on ad6f569 (the same tree but for one test file) against
a983e6a. Open PRs touching this branch's files, all clean against the head: #314, #355
and #373 (`scaffold-project.py`), #343 (`validate-pins.py`), #390 (`repo_shape.py`)
and #394 (`repo_shape.py`, which conflicts with origin/main by itself). The other 25 open PRs and the lane-5 branches
`fix/adopt-check-link-and-manifest`, `fix/scaffold-free-text-values`,
`fix/family-init-values`, `docs/adopt-execute-exits`,
`fix/yaml-flow-brackets-surrogates` and `fix/adopt-pin-writes-pin-file`
(#273's, 7a94b6b, also clean for this head) are clean too. #356, #382 and
`fix/adopt-record-submodule-placement` conflict with origin/main and not with
this head; #358, `fix/adopt-late-failure-notice` and
`fix/adopt-plan-quote-yaml-keywords` conflict with both, with origin/main by
themselves, so they are not this branch's. Neither are the five PRs below #200
that origin/main has not landed (#94, #96, #98, #100 and #161), which conflict
with it too. The PR set is read from the refs under `refs/pull/` that
origin/main's history has no `(#n)` subject for, not from GitHub, so a closed
one may be in it.

**One conflict, with `fix/link-source-values` (#338, 40762ca) in
`scripts/repo_shape.py`, four hunks.** Not resolved in either branch; take both
sides: (1) `resolve_link_source`'s signature, `default_source=None,
env_shared: bool = False, *, tried: list | None = None`; (2) its docstring, both
paragraphs; (3) `link_pins_from_trees`'s docstring, both paragraphs; (4) its
call, `default_source, pin_product_key(name) in shared, tried=asked)` followed
by #338's `if tried is not None: tried[name] = asked`. The auto-merged
`resolve_link_source` body already composes: a shared variable is never an
`env` candidate and so never appears in #338's `tried`.

**Known limits after the re-verification (APPROVE WITH NITS, 0 blockers, 0 should-fixes, 3 nits, at 6494714).** For the four non-ASCII pairs this change now detects (`ß`/`ss`, `ſ`/`s`, `fi`/`fi`, `ς`/`σ`), the keyed `--pin-source <product>=<path>` form does NOT separate the two pins: #312's source lookup folds both keys into one, so one pin can get a false `unreadable` finding. A sibling checkout does separate them. Only a hand-written `project.yaml` can declare such a pair. The doctor's `pins` row still prints only "passes" for a validator that passes, so it does not carry the collision note; that needs `shape-doctor.py`, and is left for a follow-up. The wording for restoring the finding is under "Fix round 1" above: it is 34dbd86's `report.finding(...)` call, not a word swap.

Reviewed adversarially at 34dbd86 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 5 nits). The first should-fix was the finding itself, which locked a project that already declares the pair out of every `update-shape.py apply`; the second was a description that had gone stale. Fix round 1 (6494714) made the pair a NOTE, which is the coordinator's decision and one Brett may reverse (the way back is above), keyed collisions by the pin file's own fold, gave the guard a test that runs on Windows, and made a shared product's skip hint offer the keyed `--pin-source`. Its re-verification at 6494714 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 3 nits: the keyed form and the non-ASCII pairs, the doctor's silence about the note, and the wording for restoring the finding, each stated above). CI is green on Linux, macOS and Windows, with SonarCloud passing, at b70a07d (main a73b4e9 merged in). Its merge with main 5a25387 passed the targeted set (the six pin and scaffold test files, `tests/test_validate_pins_unreadable_workflow.py`, `tests/test_ruleset_hint_tracking_branch.py` and `tests/test_shape_doctor.py`): 850 passed, 3 skipped.

Closes #275

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
… is `PRODUCT=PATH` only when its key names a chain link, and a link source that cannot be entered is a WARNING with the system's reason, not a traceback (#338) (#453)

**What was wrong.**

Two faults, one in the parser and one in the lookup.

- **The parser.** `_link_sources_from_args` split every `--link-source` value at its first `=`. A link tree under a directory named `a=b`, passed bare, became a source keyed to a link `a` that no chain names. Nothing asked for it, so the link stayed `declared-unverified`, exit 0. The WARNING then offered `--link-source openXdox=<path>` to the person who had passed exactly that. A mistyped key failed the same silent way.
- **The lookup.** `repo_shape.link_manifest_path` asked `Path.is_dir()` and then `is_file()`. Before Python 3.14 those swallow only ENOENT, ENOTDIR, EBADF and ELOOP. So each of these ended the naming gate in a traceback, exit 1:
  - a keyed source under a parent this user cannot enter;
  - `SHAPE_PIN_SOURCE_<LINK>` pointing there;
  - a checkout `../openXdox` at mode 000, which every `--project` run asks for;
  - a 300-character name.

**What changed.**

**How a value is read** (`LinkSources` in `scripts/validate-repository-naming.py`). This mirrors #312's `PinSources`, with a chain link where that file has a declared pin:
- The text before the first `=` is a LINK only when it casefolds to a link of a chain this run reads. Those are the entries of `--referent-chain`, and of each leg's `naming.referent_chain:` under `--project`. Both are read before any leg is classified.
- The keyed reading wins even over a directory spelled like the whole value. `./` reads that directory.
- Any other value is a bare PATH, taken whole.

**What a lookup says** (`scripts/repo_shape.py`):
- `link_manifest_lookup` asks `stat()` inside a guard, as `_digest_from_source` does in #312. It returns the system's reason when a source cannot answer, or says which of four plain cases it is (`no such file or directory`, `it holds no project.yaml`, `its project.yaml is not a file`, `it is neither a directory nor a file`), and it never raises. It replaces `link_manifest_path`, which is gone: nothing calls it. `link_declarations` gives the reason a manifest could not be read, and `declared_neutral_pins`, which it left with no caller, is removed (fix round 1).
- `resolve_link_source` and `link_pins_from_trees` take a keyword-only `tried`. It records each source asked, in order, with its reason. Nothing in `repo_shape` prints.
- `link_pins_from_trees` also takes a keyword-only `with_reasons` (fix round 2). Asked, it returns a `LinkPins`, a dict equal to the plain mapping that also remembers why a tree it reached could not be read. Only the scaffold asks.

**What the validator prints** (stderr, after each name's WARNINGs):
- `WARNING link-source-unreadable: <path> (<how>) could not answer for <link>: <reason>`. This covers a `--link-source` asked for a link that holds a pin, a `SHAPE_PIN_SOURCE_<LINK>` likewise, and a checkout beside the project that exists and still cannot answer. A keyed value that also names a directory gets a second line saying how to ask for that directory with `./`. A bare value holding `=` says it was read as a PATH.
- `NOTE --link-source <typed>: not used — <why>.` for a value no such link asked for. The why is one of five: a later value replaced it; no name reads that link's own declaration (a keyed value); no name records a chain at all; no name reads any chain link's own declaration (a bare value, where chains exist and none is read); or every link that is read had a more specific source.
- Three kinds of source are never named: a variable or a checkout asked only for a chain's LAST link (no one ever reads that link's own declaration); a variable or a checkout for a chain no classification reads (fix round 1: a spec leg's chain, a name with no referent, a chain whose shape fails; a `--link-source` value for such a chain is the NOTE); and a checkout that is absent or holds no manifest.
- `--quiet` drops both, with the WARNINGs. So `make bootstrap` and the doctor's `naming` row print nothing new.

`--help`, the module docstring and `docs/cli.md` (regenerated) say all of this.

**Fix round 1 (lane 5's review of 40762ca: 0 blockers, 2 should, 4 nits).**

Second commit, 4b21263. Each review finding, in order:

- **A link manifest whose `neutral_product_pins:` is not a list of names is a tree that cannot answer** (SHOULD-1). `link_declarations` iterated `data.get(key) or []` as it stood, against the `link_pins_from_trees` docstring this change wrote ("nothing a source does raises out of here"). `5` ended the naming gate, the scaffold and the project's `validate-manifest.py` in `TypeError: 'int' object is not iterable`. A scalar `openDox` was read as the characters `D`, `e`, `n`, `o`, `p`, `x`, so the chain broke, and under `--project` that is a FINDING. A mapping was read as its keys, so `openDox: yes` DECLARED `openDox`. Each is now `(None, why)`, as `not a mapping` is: declared-unverified, with the reason named ("`neutral_product_pins` is not a list", "holds an entry that is not text"). An absent or null value, an empty list, and a blank entry (`null`, `''`) in a list, declare nothing, as ever. The fix is in the shared function, so the scaffold and `validate-manifest.py` are reached too.
  **Three behaviours change on purpose here, and the tests of fix round 2 pin each:**
  - **A falsy value is no longer an empty list.** `neutral_product_pins: 0`, `false` or `''` was read as no pin at all (`or []`), so a chain through the tree was BROKEN, and under `--project` that is a FINDING. It is now a tree that cannot answer: the link is declared-unverified, a WARNING, exit 0.
  - **An entry that is not text is no longer read as text.** `[openInk, 0]` and `[openInk, false]` dropped the entry as a blank, and `[openInk, true]` read it as the text `True`, so a chain through the list VERIFIED. Each is now declared-unverified, with a WARNING saying the list "holds an entry that is not text". That is stricter than the link tree's own `validate-manifest.py`, which takes `str(pin)` of an entry (`_declared_pins`, `templates/assembly-root/scripts/validate-manifest.py:85`): a chain is better left unverified than verified through a name nobody wrote.
  - **The scaffold no longer refuses `chain-not-declared` over a pins value that is a scalar.** With `--pin-source` a tree whose `neutral_product_pins:` is the scalar `openInk`, the scaffold used to refuse (`REFUSED chain-not-declared: ... openXdox declares I, e, k, n, o, p, not openInk`, exit 2, from the characters). It is now the declared-unverified WARNING and the scaffold goes on, exit 0, which is its documented rule for a tree that cannot answer: "a warning, never a refusal, because the check is offline". The warning says the tree was reached and could not be read (fix round 2, below).
- **The reach wording is corrected** (SHOULD-2): "Who else this reaches" below, and the docstring of the test that made the claim. What stops crashing is the naming and manifest steps. `make validate`, `validate.yml` and `make bootstrap` still end in a traceback on the sibling row before Python 3.14, at a file this change does not touch (below). On 3.14 the sibling is passed over without a word there instead, which is #430.
- **The mutation gaps** (nit). Six of the review's ten mutations survived. Five now die, each by a new test: a `project.yaml` that is a directory; a device or a FIFO as the source; the order A, B, A; a value holding `=` with no chain at all; and every failure of `SHAPE_PIN_SOURCE_<LINK>` for a link that holds a pin (a path that is not there, a directory with no manifest, a file that is no manifest), not only a permission. The sixth, the `typed in self.used` test in `unread_lines`, could not be killed because it was dead: a value is read as one link's keyed source or is the one bare PATH, so every lookup asks it of the same path and it is used or unread, never both. That was asserted over 293 tests and the test is removed.
- **A chain no classification reads** (nit). `classify` consults a link's own declaration only for a name that claims a referent, with a recorded chain whose shape holds. For any other chain (one recorded on a spec leg; one on a name with no `open<Product>` to reach; one that does not begin at a held pin or does not end at a referent of the name) a source was still asked, so a path that was not there was reported as `link-source-unreadable` and a good tree was counted used, each for a question nobody put. `NamingPolicy.reads_link_declarations` now says when `resolve_referent` reads a chain, and `LinkSources.link_pins` asks it first: such a chain asks nothing, and a value for it is the NOTE that no name reads that link's declaration. A test runs `resolve_referent` with a mapping that records whether it was read, over every way a chain is and is not, and holds the new method to it.
- **Dead code and stale sentences** (nit). `declared_neutral_pins` is removed: `git grep` over the tree, every pushed branch and every sibling worktree finds no caller. `link_manifest_lookup`'s docstring no longer says "replaces" over a name that is gone. This description no longer says `tests/test_naming_policy.py` takes `SEARCH_DENIED` from #312: it defines a mark of its own, with the same condition and its own reason text.

**Fix round 2 (lane 5's re-review of 4b21263: 0 blockers, 0 should, 3 nits).**

Third commit, 2ac911c. Each finding, in order:

- **The three behaviours the body did not state are stated and tested** (nit 1). Round 1's not-a-list fix changed three behaviours on purpose, each listed under SHOULD-1 above, and the re-review found two of them had no test. R1 (`if declared is None` read as `if not declared`, which makes `0`, `false` and `''` declare nothing again) and R2 (`if pin is None or pin == ""` read as `if not pin`, which drops a `0` or `false` entry as a blank) both passed all 210 cases of `tests/test_naming_policy.py` at 4b21263. Six rows join `NOT_A_LIST_OF_NAMES`, which runs at the CLI and in process: `0`, `false` and `''` as the value, and `0`, `false` and `true` among the names. R1 now fails 9 cases and R2 fails 6.
- **The scaffold's warning is true for a tree it reached** (nit 1, the third behaviour). Over a `--pin-source` whose `neutral_product_pins:` is not a list, round 1 turned the `chain-not-declared` refusal into a declared-unverified WARNING that says the tree "is not reachable here (a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <LINK>=<path> would let it be read)". `--pin-source` did reach the tree, and only its declaration could not be read, so that blamed a path that was right. Now `link_pins_from_trees` takes a keyword-only `with_reasons`; asked, it returns a `LinkPins`, a dict equal to the plain mapping that also keeps `unreadable`, `{link: why}` for each link whose source answered and whose manifest could not be read. `_chain_link_findings` words such a link "which was reached but could not be read as a declaration (<why>)", in a branch of its own, and only the scaffold asks. The new text is asserted for each of the eleven not-a-list rows, with `scaffold-project.py --dry-run` run as a subprocess.
  - **Every other path's text is the bytes it was.** The sentence for a link no source reached is the same code in the same lines, and four tests hold it: the generic warning for a plain mapping, for an empty `LinkPins` and for an absent tree; the scaffold's own old warning for a clone that holds no `project.yaml`; the naming validator's two lines (its warning, and the `link-source-unreadable` line that names the reason); and `validate-manifest.py`'s. The naming validator and `validate-manifest.py` do not ask for reasons, so they print what they printed.
  - **One more scaffold path changes with it, on purpose.** A tree the scaffold reached and could not read as a declaration for any other reason (a manifest that does not parse, or is not a mapping) used to say "not reachable here" too, and was no truer. It now says what it is, with the system's reason, and a test for each of the two (a manifest that is not a mapping, one that does not parse) holds it, at the scaffold's command line and in process: narrowing the wording to the not-a-list reasons fails both.
  - `_declaration` still returns its four values, so nothing else in `scaffold-project.py` moved.
- **A qualified chain link in the spy test** (nit 2). `_chain_read_cases` had no case whose chain link is qualified, so `reads_link_declarations` could take the chain as typed and not through `_chain_links` (R3) and pass every test. With `--referent-chain testorg/openXdox,openDox` that turns a verified chain into a declared-unverified one. `("codexDox", {"openXdox"}, ("testorg/openXdox", "openDox"), True)` is a case now, and R3 fails it.
- **The description** (nit 3): the ten corrections of the re-review are applied (the reason a lookup returns, the fifth NOTE reason, the third kind of silent source, the SHOULD-1 bullet, "before Python 3.14", the `validate-pins.py` bullet, the 3.14.7 rows, the merge-tree section, the filed numbers, and the project's own `validate-manifest.py`).

The mutation proofs: all three survivors were reproduced first at 4b21263 (210 passed each) and each dies at 2ac911c. Six mutations of the new code each die too: the warning ignoring the reasons (12 cases fail), the lookup recording none (12), the scaffold not asking (11), `with_reasons` on by default (3), an absent tree called reached (3), and the reached sentence used for a tree nothing reached (17).

**Where it departs from #312, and why.**

- **An explicit source that cannot answer stays a WARNING, exit 0.** #312 made an unreadable `--pin-source` a finding. Three reasons for the difference:
  - Exit 1 here means "a name is wrong", and this module has always said an unread link tree is a warning.
  - `SHAPE_PIN_SOURCE_<PRODUCT>` also answers `validate-pins.py`'s digest question, from a checkout that need not hold a manifest.
  - The lookup falls through to the next source, which may still verify the link.
- **The fall-through is kept**, as it always was. With `--pin-source`, a keyed value is final.
- **The lines go to stderr, spelled `WARNING`/`NOTE` as this file spells them.** #312's notes are `  note` lines on stdout, where that file's report lives.

**Who else this reaches.**

The guard is in `repo_shape.py`, so the same `link_pins_from_trees` lookup stops crashing for these callers too:
- **The project's own `validate-manifest.py`.** It reaches the lookup through `SHAPE_PIN_SOURCE_<LINK>` or a sibling checkout, and now gives its usual declared-unverified WARNING instead of the traceback.
- **`scaffold-project.py --referent-chain`.** A `SHAPE_PIN_SOURCE_<LINK>` under a locked parent no longer kills the plan, and `--pin-source` verifies the link.

Neither of these prints the reason a source could not be entered; the scaffold says it only for a tree it reached and could not read (fix round 2, above). A `--pin-source` the scaffold cannot enter is still a traceback: that is #339 (`pin_digest_from_source`), and this PR does not touch it.

**What this does not reach, and what the doctor says.**
- **The project's gates still end in a traceback on the sibling row.** The NAMING and MANIFEST steps of `make validate`, `validate.yml` and `make bootstrap` stop crashing for a checkout `../openXdox` at mode 000. Those targets also run `validate-pins.py`, whose `resolve_neutral_pin_source` asks `(sibling / ".git").exists()` with no guard (`templates/assembly-root/scripts/validate-pins.py:463` on main), so before 3.14 a sibling at mode 000 still ends them in a `PermissionError`. That file is #312's, and `fix/pin-source-env-collisions` (PR #409, since landed), `fix/adopt-pin-writes-pin-file` and open PRs #343 and #419 touched it when this was written, so this PR does not edit it. The same line is listed for Python 3.14 by #430 (lane 2), where the helper that would cure both halves is asked for; the pre-3.14 traceback reproduction is added there as a comment, not filed apart. A `SHAPE_PIN_SOURCE_<LINK>` under a locked parent is different: `validate-pins.py` makes that a `neutral-pin-source-unreadable` FINDING (#312), and that stays.
- **The doctor's `naming` row** runs the STANDARD's copy of the validator, so it stops crashing at once. **Its `manifest` row** runs the project's OWN `validate-manifest.py` on the project's own `repo_shape.py`, so it changes only once the project has applied this change. Over an existing project the doctor says `INVALID (manifest, pins)` until `update-shape.py apply` lands the two `upstream-changed` rows below.
- **The verdict.** For `SHAPE_PIN_SOURCE_<LINK>` under a locked parent, on a project that carries these copies, `INVALID (naming, manifest, pins)` becomes `INVALID (pins)`, and `pins` is #312's finding for that locked variable, which is right. For a sibling at mode 000 the `pins` that remains is the traceback above, not a finding.

The two copied scripts this change edits (`scripts/repo_shape.py` and `scripts/validate-repository-naming.py`) are copied into every project and pinned there. A project takes the change through `update-shape.py` (two `upstream-changed` rows), and a family holder takes the `repo_shape.py` row. Neither file has a second copy in this repository.

**Evidence.**

- **Issue rows, main ab623dd** (`repro338.sh`, a `gh` stub first on PATH). Under 3.10.21 and 3.12.3, rows 4-7 (keyed under a mode-000 parent, `SHAPE_PIN_SOURCE_OPENXDOX` there, a sibling at mode 000, a 300-character name) are a traceback, exit 1. Rows 2-3 (`a=b`, the typo) are a silent declared-unverified, exit 0. Under 3.14.7 row 1 verifies, and rows 2-7 are the silent declared-unverified WARNING, exit 0.
- **Issue rows, this PR.** Under 3.10.21, 3.11.16, 3.12.3, 3.12.14, 3.13.15, 3.14.7 and 3.15.0rc2 (first commit), all 7 rows exit 0 with no traceback, and the output is byte-identical across versions. `a=b` verifies. The other five rows each name their source with its reason. Re-run on the fix-round head, 4b21263, under 3.10.21, 3.12.3 and 3.14.7: byte-identical to the first commit's run. Re-run on the fix-round-2 head, 2ac911c, under the same three: all 7 rows exit 0 with no traceback, the output is byte-identical across the three versions, and it is identical to the re-review's runs of the previous head over main.
- **Reach on main.** Main gives a `PermissionError` traceback, exit 1, for `scaffold --dry-run`, the project's `validate-manifest.py`, `validate-repository-naming.py --project` and its `--quiet` form, each with `SHAPE_PIN_SOURCE_OPENXDOX` under a locked parent. This PR gives exit 0 for all four. `validate-pins.py` on the sibling row is the exception said above.
- **New tests.** 17 cases (11 functions) in the first commit; 16 fail on ab623dd. The fix round adds 31 cases in `tests/test_naming_policy.py`, and 17 of them fail on 40762ca: the five shapes of pins that are not a list (each at the CLI, in-process and, for the scalar, under `--project`), and the chains nobody reads. The other 14 guard what a mutation could break (below) or the side the fix must not move (an absent, null or blank pin list still answers).
- **Targeted run on 4b21263**, files `test_naming_policy`, `test_manifest_validator`, `test_scaffold_referent_chain_links`, `test_cli_reference`, `test_repo_hygiene`, `test_neutral_pin_validator`, `test_scaffold_e2e`, `test_shape_doctor`, `test_git_output_decoding` and `test_pin_validator`: **765 passed, 3 skipped**. The 3 skips are the PowerShell-only rows. (40762ca: 734 passed, 3 skipped.) `render-cli-reference.py --check` is ok.
- **Mutation probes, first commit.** Reverting the stat guard, the parse, keyed-over-directory, the NOTE lines, the chain's-end restriction, the sibling-absence filter, the manifest reason, the `--quiet` drop, the "read as a PATH" sentence, the `./` line, the project-chain read, the WARNING lines or the env reporting each fails at least one new test.
- **Targeted runs on 2ac911c (fix round 2).** Under 3.12.3, `test_naming_policy` (238 cases: 210 and 28 new), `test_cli_reference`, `test_manifest_validator`, `test_scaffold_referent_chain_links`, `test_repo_hygiene` and `test_setup_project_py`: **468 passed**; and `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_scaffold_leg_path_collisions`, `test_scaffold_pin_collisions`, `test_neutral_pin_validator`, `test_git_output_decoding` and `test_pin_validator`: **312 passed**. Under 3.10.21 and under 3.14.7, `test_naming_policy`, `test_manifest_validator` and `test_scaffold_referent_chain_links`: **321 passed** each. `render-cli-reference.py --check` is ok, and `ast.parse(feature_version=(3, 9))` passes on the three changed files.
- **Mutation probes, fix round.** The review's ten, re-run on 4b21263: MW1, MW2, MW3, MW5 and MW11 now die (they survived), and MW4, MW7, MW12 and MW13 still die; MW6 is the dead test, removed. (Fix round 2 kills the three the re-review found, R1 to R3, and six more: see above.) Nine more for the new code (the list check, the entry check, the null and blank tolerances, `reads_link_declarations` always true, without the shape check, without the held pins, and the validator ignoring it): eight die. The ninth, `reads_link_declarations` ignoring whether the name has a referent, survives as an equivalent mutant: with no referent `_chain_shape_problem`'s last question always reports a problem, so the explicit early return is redundant, and is kept because `resolve_referent` has the same first line. The spy test holds the two together.
- **Windows.** The mode-000 rows skip on Windows and as root, using a `SEARCH_DENIED` mark this file defines, with the same condition as #312's. The new FIFO and `/dev/null` rows skip where those do not exist. `a=b` and the 300-character name run everywhere. On Windows the long-name row asserts the line, not POSIX's reason. None of it had been run on Windows or macOS when this was written; the last paragraph gives the CI result.

**Merge-tree.**

Recomputed at 19:04Z with `git merge-tree --write-tree` of 2ac911c against `origin/main` as it was then, 53bee5b, and against every one of the other 37 pushed branches that touches a file this PR changes (a scan of each head against its merge-base with main):
- **Clean:** origin/main 53bee5b (it carries #314 and #390); #394 `fix/adopt-record-submodule-placement` 563ea16 (`scripts/repo_shape.py` and `validate-manifest.py`) and #406 `fix/validate-family-unnamed-mounts` 369af2e (`scripts/repo_shape.py`); #397 abb27e6, #400 7062a47, #401 409f10b, #416 7cca975, #452 e2df9a8 and #395 1ef3310 (`docs/cli.md`); #355 6fdbe3d, #373 741cbf2 and #395 (`scaffold-project.py`).
- **Conflict, four hunks each, all in `scripts/repo_shape.py`:** `fix/pin-source-env-collisions` (PR #409) 6494714, and `fix/adopt-pin-writes-pin-file` 421e4e5 (no PR). Both also touch `scaffold-project.py`, and that merges clean with this PR's one edit there. Fix round 2's new hunks merge with neither: #409's change to the "not reachable here" sentence (the `offered` text, when a variable is shared) and the reached branch sit in different lines of `_chain_link_findings`. The four hunks, each taking both sides:
  1. the `resolve_link_source` signature: `default_source=None, env_shared: bool = False, *, tried: list | None = None`;
  2. its docstring: both paragraphs;
  3. the `link_pins_from_trees` docstring and the lines after it: both paragraphs, then `found`, this PR's `unreadable` and #409's `links` and `shared`;
  4. the call: `resolve_link_source(name, root, keyed_sources, default_source, pin_product_key(name) in shared, tried=asked)` (the same spelling on both branches), followed by `if tried is not None: tried[name] = asked`.

  Resolved that way in a throwaway and committed there, the merge with 6494714 passed **496 tests** (`test_naming_policy`, `test_pin_source_env_collisions`, `test_neutral_pin_validator`, `test_scaffold_referent_chain_links`, `test_manifest_validator`, `test_cli_reference`), and the merge with 421e4e5 passed **559 tests** (the same files and `test_pin_source_unreadable`). `render-cli-reference.py --check` is ok on both, pyflakes is clean, and the grammar parses as 3.9's. Neither branch carried the resolution when this was measured. #409 has since landed, and the merge of main into this branch (the last paragraph) takes those four hunks both ways. The earlier counts (468 and 434 from the first fix round, 430 from the re-review) are those runs' own, at their heads.

**Left out.**

Each of these was found by lane 5's reviews of this change, and each is filed or folded:
- **A bare `--link-source` answers for every link with no more specific source** (#441). In a chain of three or more links, one bare tree is read as two links' declarations, and a chain that holds is reported broken, a FINDING for a name with no direct pin on its referent. Pre-existing, the same on main; `validate-pins.py`'s `--pin-source` has the same documented rule and was not examined.
- **An empty path after a key (`--link-source openXdox=`) is `Path("")`, the current directory** (#442). Run from a project root, the project's own `project.yaml` is read as the link's declaration. Pre-existing; `validate-pins.py`'s `--pin-source openXdox=` has the same shape (there a finding naming `.`).
- **`validate-pins.py`'s `(sibling / ".git").exists()` is unguarded** (the sibling row above), folded into lane 2's #430, which already covers the Python 3.14 half: the pre-3.14 traceback and its reproduction are added there as a comment. #312's file.
- **The project's OWN manifest** with `neutral_product_pins: 5` ends in a `TypeError` in two gates before either can say anything about it (#443): `_pins_from_project` in the naming gate (`scripts/validate-repository-naming.py:302` on main), and `_declared_pins` in the project's own `validate-manifest.py` (`templates/assembly-root/scripts/validate-manifest.py:85`), which ends it before its own `manifest-neutral-product-pins` finding (`_neutral_product_pins_type_finding`, `:524` on main) can print. Reproduced on 3ef059c. That is the project's own declaration, read by the project's own gates, and not a link source, so it is outside this change; "read by the project's own gates" covers two crashing gates.

Also not done here:
- **Python 3.9 (the floor) was not run** because it is not installed here. The grammar of the three changed files was checked with `ast.parse(feature_version=(3, 9))`. Windows and macOS were not run here; CI ran them (the last paragraph).

Reviewed adversarially at 40762ca, the branch's first commit (CHANGES NEEDED: 0 blockers, 2 should-fixes, 4 nits). The head stopped every crash in the issue's table, and the should-fixes were a docstring claim the code did not keep for a `neutral_product_pins:` that is not a list, and reach claims that overstated what the project's gate does on the sibling row. Fix round 1 (4b21263) resolved every finding, and the re-review of 4b21263 read APPROVE WITH NITS (0 blockers, 0 should-fixes, 3 nits: three behaviour changes this description had not stated, two of them untested; a spy-test case for a qualified chain link; and description corrections). Fix round 2 (2ac911c) resolved those three, and its re-verification of 2ac911c read APPROVE WITH NITS (0 blockers, 0 should-fixes, 2 nits: no test of the scaffold's wider wording for a manifest that is not a mapping or does not parse, now pinned by two tests that each fail when the wording is narrowed, and a cross-reference in this description that pointed the wrong way, now corrected). Main (9736424, #409's squash) was merged into the branch as ced8fac. `scripts/repo_shape.py` conflicted in four hunks; each takes both sides, as the review of 2ac911c resolved it: `resolve_link_source(..., default_source=None, env_shared: bool = False, *, tried=None)`, both docstrings kept, `link_pins_from_trees` opening with `found`, `unreadable`, `links` and `shared`, and the call passing `pin_product_key(name) in shared` with `tried=asked`. Nothing else conflicted. After the merge, `tests/test_pin_source_env_collisions.py` and the targeted set passed (667 and 312), and `render-cli-reference.py --check` printed `[ok]`. CI is green on Linux, macOS and Windows, with SonarCloud passing, at 938a131 (main 48585e6 merged in). Its merge with main 994be39 passed the targeted set (the naming, manifest, scaffold, pin and setup test files): 983 passed.

Closes #338

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
… writes its pin file as the scaffold does, so an adopted root passes `validate-pins.py` (#273); a `--pin-source` that cannot answer is `pin-source-unreadable` (#339) (#508)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #273
Closes #339

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
tests/test_adopt_manifest_values.py, hunk 1 (the comment above PLAN_EDITS): this branch's pointer to `TEXT_THE_VALIDATOR_REFUSES` and #259, plus main's note that the rows name `said_of`'s finding for the date.
tests/test_adopt_manifest_values.py, hunk 2 (the list header): this branch's split stays (`id: "007"` in `TEXT_THE_TEMPLATE_NOW_HOLDS`, `TEXT_THE_VALIDATOR_REFUSES` for the rest), with main's note that `20261011` is a date `election_date` refuses, so its one sentence is #294's and the validator is not asked.
tests/test_adopt_manifest_values.py, hunk 3 (the `elected-on-basic-format` row): main's sentence, "the plan's `elected_on:` is the text '20261011'", which is what `check` and `execute` print on the merged tree (`FINDING`/`REFUSED election-date-malformed`), not the validator's `manifest-elected-on: elected_on is '20261011'`.
tests/test_adopt_manifest_values.py, hunk 4 (the `..._one_finding_on_this_rule_alone` docstring): this branch's wording, plus main's sentence that a date `election_date` refuses is #294's one finding.
tests/test_adopt_manifest_values.py, two lines git merged cleanly that failed on the date row: `test_check_refuses_a_text_value_the_validator_refuses` asks for `said_of(new)[0]` where it asked for the validator's `FINDING`, and `test_where_a_misread_rule_judges_the_rest_the_validator_still_answers` takes #294's one finding from `_plan_field_findings` for the date, as main's `..._on_this_rule_alone` does; the section comment and one docstring say so.
tests/test_scaffold_manifest_values.py: the `[branch-tilde]` row loses its `xfail(strict=True)`, which XPASSed (and failed) once #355 landed as 6c2a371, and now asserts `REFUSED scaffold-bad-tracking-branch:`; the comments say #355 is on main.
tests/test_bootstrap_spelling.py and tests/test_bootstrap_after_bump.py (#391, new on main): the literals in seven tests that found `    path: spec` and `tracking_branch: main` in a scaffolded `project.yaml` now find the quoted lines the template writes (`path: "spec"`, `tracking_branch: "main"`); their nine cases failed on the merge without this.

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
… text placeholders, so `--id 007`, `--tracking-branch 2026`, `--spec-path true` or `--org null` read back as typed, and `family.py add` quotes a member id alike (#259) (#395)

Brett Heap ruled on 2026-10-12: "quote #259". The templates now write each text field a tool fills from a value it was given between double quotes:

| template | fields |
|---|---|
| `templates/assembly-root/project.yaml` | `id`, `elected_on`, `visibility`, `tracking_branch`, the spec leg's `path`, the code leg's `path` |
| `templates/assembly-root/contracts/spec-pin.yaml`, `code-pin.yaml` | `submodule_path` |
| `templates/family-root/family.yaml` | `id`, `org`, `created_on`, `visibility`, `tracking_branch` |

`render` already spells a value between a YAML template's double quotes with `repo_shape.double_quoted` (#284). The single-pass `render` from #316, on main as 34ecbc2, does the same through `YAML_PLACEHOLDER_RE`. So each value reads back exactly. An ordinary value renders as it did before, plus the two quote characters.

`family.yaml`'s `org:` is quoted along with the four fields #259 names. It is the same defect in the same file: `family.py init --org 2026` and `--org null` pass `checked_value`, and on c7c02fa the holder then fails `family-org` (#307 lists the `--org null` row). Taking `org:` back out is not a one-line revert. It fails 14 of the new tests (the 8 `family.yaml:org` in-memory cases, and all 6 `family.py init` rows, because every row of `FAMILIES` passes a misread `--org`), and it would also take the `ORG` row out of `TEXT_FIELDS` and put plain `--org` values into `FAMILIES`. The lane keeps `org:` quoted, as the same defect in the same file, unless Brett says otherwise.

`scripts/family.py`: `_member_lines` writes each member's own `project.yaml` id into the holder's `members:` block. That id now reads back exactly, so a bare `007` in the row came back as 7, and `validate-family.py` would report `member-id-mismatch` for a member it had just pinned. `_member_id` quotes an id only when the reader would not read the bare text back as the same text. No row whose id reads back as text moves: `irrs` is still `id: irrs`. Two edges:

- A legacy row whose bare id the reader misreads moves once. A holder written before this change, by `family.py add` of a member scaffolded with `--id 2026`, holds `id: 2026`. The next `bump`, `add` or `remove` of that holder rewrites it as `id: "2026"`. That is harmless: `validate-family.py` compares with `str()` and passes before and after.
- Mixed versions. A `family.py` from before this change, run against a holder this branch wrote, turns `id: "007"` back into `id: 007`, and `validate-family.py` then fails `member-id-mismatch` (the member declares `007`, `family.yaml` records `7`). A current `family.py` that rewrites the holder next then writes that row as `id: "7"`. Nothing in this branch can prevent it: run `family.py` from a current checkout on a holder that has such a member.

Left unquoted on purpose: `topic` (`xf-project-<id>`), every `repository` (`<org>/<Name>`; the slash keeps it text), `SHAPE_REPOSITORY` and `DIGEST_DEFINITION` (constants), and the block or list placeholders (`NEUTRAL_PRODUCT_PINS`, `*_NAMING`, `SHAPE_FILES`).

**Why.** Every reader of these files uses `repo_shape`'s YAML subset. It reads a bare `007` as 7, `2026` and `20261011` as numbers, `1.5` as a float, `true` as True and `null` as None. The checks on the flags (`PROJECT_ID_RE`, `checked_value`, the leg-path rule) accept every one of these. Reproduced on c7c02fa, with local bare remotes only:

- `scaffold-project.py --id 2026|007|true|null|20261011` exits 0, and the project's own `validate-manifest.py` then fails `manifest-id`.
- `--spec-path 2026 --code-path 007` fails `manifest-leg-path`, and `validate-pins.py` exits 2.
- `--tracking-branch 007` pushes each leg on `007`, and the root's `bootstrap.py` then puts the legs on a local `7` with `fatal: no upstream configured`. `true` and `null` do the same on `True` and `main`.
- `adopt-project.py execute` with `--tracking-branch 007` does the same; `--id 007|20261011` gives `manifest-id`; leg paths `2026`/`007` give `manifest-leg-path`.
- `family.py init --id 2026|007|true|null|20261011` gives `family-id`; `--org 2026|null` gives `family-org`; `--tracking-branch 007|true|null` reads back as 7/True/None.

On this branch every one of those rows reads back as typed, bootstraps onto the typed branch with `origin/<branch>` as upstream, and passes the root's own validators.

**Every reader checked.** Every reader reads these fields through `load_yaml`/`parse_yaml` into a dict. None uses a line regex, `split(":")`, `startswith("<field>: ")` or a whole-line rewrite of them:

- `templates/assembly-root/scripts/bootstrap.py`: `tracking_branch`, leg `path`.
- `validate-manifest.py`: `id`, `elected_on`, `visibility`, leg `path`.
- `validate-pins.py`: `submodule_path` against the manifest's `path`. The pin side is read as it stands and the manifest side goes through `str()`, so the two differ in type exactly when a bare value is misread (that is the `pin-path-mismatch` row above). With both lines quoted they are the same text.
- `validate-repository-naming.py`: `str(id)` for the topic.
- `templates/family-root/scripts/validate-family.py`: `id`, `org`, `created_on`, `visibility`, `str()` member-id compare.
- `siblings.py`, family `bootstrap.py`.
- `scripts/bump-leg.py`: `tracking`, leg `path`. Its `rewrite_pin` rewrites only `commit:` and `tree_sha256:`.
- `update-shape.py`: `tracking_branch_of`. `rewrite_manifest` touches only the `shape:` block.
- `scripts/family.py`: `tracking()`, the member's id, and the `members:` rewrite. The member-id writer is the one fixed here.
- `shape-doctor.py`: leg `path`, member row `path`, working-clone `str(id)` compare.
- `adopt-project.py`: plan reads and `_template_values`.
- `setup-project.py`: `family.yaml` `name`/`kind` only.
- `scripts/shape_advisory.py`: the single-repository record only.

The `str()` fallbacks (`bootstrap.py`, `adopt-project.py`'s `checked_value`) stay, because projects made before this keep their bare lines.

**Pins.** The four files are TEMPLATED, not COPIED, so no row of any project's `contracts/shape-pin.yaml` names them. `Atlas` was scaffolded and `family.py init Contoso` was run on c7c02fa and on this branch. Each `contracts/shape-pin.yaml` differs only in `commit:` and `digests.tree_sha256`, which identify the standard itself and change with every commit; every per-file `sha256` row is identical. `update-shape.py check` of the c7c02fa project and holder against this branch reports every copied file `unchanged` (11 and 10 rows): "no copied file differs; `apply` would move the pin alone".

**Tests.**

- New: `tests/test_template_text_placeholders.py`, 123 tests:
  - Each of the 13 fields is rendered with each of 8 values the reader misreads (`2026 007 1.5 true null 20261011 ~ False`), and must read back exactly.
  - 6 `scaffold-project.py` runs, then a recursive clone, `bootstrap.py`, the three validators, and `bump-leg.py`, which refuses on the tracking branch and finds the leg at its path on a branch of its own.
  - 6 `adopt-project.py` `plan`/`check`/`execute` runs into local bare remotes, then a clone, `bootstrap.py` and the validators.
  - 6 `family.py init` runs.
  - One `family.py add` of members whose ids are `007` and `true`.
  - **All 123 fail on c7c02fa.**
- `true`/`null` through adopt: `plan` writes them bare into the plan, and the plan's reader then misreads them. That belongs to the plan writer, #181 (lane 1). Those two rows quote the value in the plan by hand, as #181's writer will, and test `check`/`execute`.
- Mutation probes: reverting any one of the 13 quoted lines fails 8 cases of its field. Reverting `_member_id` fails the member test (the rows read 7/True).
- The `try/except YamlError` in `_member_id` is guarded by the follow-up commit. The review found that calling the parse directly left the member test passing. Ids such as `[`, `{`, `&a`, `*a` and `!t` reach the `except` (they raise `YamlError` bare), and `_member_id` returns each one exactly, as it does 34 adversarial inputs in all (the review's in-process probe). `test_a_member_id_the_reader_refuses_bare_is_rewritten_between_quotes` (7 cases: `[`, `{`, `&a`, `*a`, `!t`, an unclosed `'` and an unclosed `"`) opens a hand-edited holder whose row holds the id between double quotes, rewrites the `members:` block with `Family.rewritten`, and asks that the id read back exactly and the text come out byte for byte. With the `try/except` removed all 7 fail with the reader's `YamlError`, and with the `except` returning the bare text all 7 fail too; the old member test still passes under the first of those mutants.
- 14 existing tests that asserted or edited a bare rendered line now spell it quoted: `test_manifest_validator` ×5, `test_pin_validator`, `test_reference_default`, `test_scaffold_e2e` ×2, `test_setup_project_py`, `test_setup_sh`, `test_family`, `test_shape_doctor` ×2.
- Targeted runs on this branch:

  | files | result |
  |---|---|
  | new file + `test_template_quoted_values`, `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_scaffold_leg_paths`, `test_scaffold_leg_path_collisions`, `test_reuse_visibility`, `test_reference_default` | 405 passed |
  | the six adopt files | 491 passed, 1 skipped (the MedxEHR clone is not set) |
  | `test_family`, `test_family_add_refusals`, `test_family_member_path`, `test_manifest_validator`, `test_pin_validator`, `test_bump_leg`, `test_update_shape`, `test_update_shape_pr_base`, `test_setup_project_py`, `test_park_resume_targets`, `test_repo_hygiene`, `test_cli_reference`, `test_setup_sh` | 603 passed |
  | `test_shape_doctor` | 241 passed, 3 skipped (Windows-only), 1 failed; that one test was fixed, and with its sibling it reran at 2 passed |

**The review.** An independent opus review at 233dc71 (the branch's first commit) found no code defect. Its verdict was CHANGES NEEDED (0 blockers, 2 should-fixes, 6 nits) because it asked for this description and its composition counts to be corrected, and they are. Its evidence:

- **Readers and rewriters.** Every reader in the shipped tree reads the 13 lines through `load_yaml`/`parse_yaml`, and no line regex or whole-line rewrite touches them. It read `bump-leg.py` `rewrite_pin`, `update-shape.py` `rewrite_shape_pin` and `rewrite_manifest`, `family.py` `rewritten`, the three validators, `siblings.py`, both `bootstrap.py`, the doctor, `setup-project.py`, the workflows, the Makefile and `setup.sh`. No test among main's 17 commits since c7c02fa (through #245's 161c6c9) edits one of these lines bare.
- **`_member_id`, the one rewriter changed.** A mutant that writes every id bare fails the member test (the rows read back as `{'IRRS': 7, 'IRSS': True}`). A mutant that quotes every id fails `b"    id: irqs" in lines`. So the minimal-churn choice is tested both ways.
- **Old projects and holders.** Against a control commit (c7c02fa plus an unrelated README edit), a project and a holder made on c7c02fa give identical `update-shape.py check` and `shape-doctor.py` output, up to the shas. `check --at 233dc71` reports all 11 project files and all 10 holder files `unchanged` and says `no copied file differs`. The doctor's verdict is `COMPLIANT, SHAPE BEHIND (... no copied file differs)` for the control and for 233dc71. This branch's `family.py add` and `bump` on a c7c02fa holder leave the old `atlas` row and the header byte for byte, apart from the legacy-id edge described at the top.
- **Tests.** All 123 new tests fail on c7c02fa (`123 failed`) and pass on 233dc71 (`123 passed`). The 14 changed existing tests pass (49 passed).
- **Not covered.** Windows and macOS were reviewed by reading and not run: the plan edit and the member-row check are in bytes, and nothing writes to git's stdin in text mode. Of `test_shape_doctor.py` and `test_family.py` the review ran only the changed tests.

**Follow-up commit.** A second commit, `3894aa8`, answers two nits from the review, and changes no behaviour. The `_member_id` docstring said "no existing holder's `members:` block moves by a byte", which was false: the next `bump`, `add` or `remove` of a holder written before this change rewrites a legacy `id: 2026` row as `id: "2026"`, harmlessly, because `validate-family.py` compares with `str()`. The docstring now says exactly that, and a grep of every comment and docstring the branch added for "by a byte" and "moves" found one more copy, in the member test's docstring, corrected the same way. The `try/except YamlError` in `_member_id` is guarded by a new test (see Tests), so the new file holds 130 tests. Every count above that says 123 (123 new tests, all failing on c7c02fa and passing on 233dc71) is for the first commit; the follow-up commit adds the 7. The `except` also carries a comment saying which ids reach it.

**Composition at the landing head (4984651).** `49846518` is GitHub's update-branch merge of origin/main `cc8f2da4` (#376, a `--trailer` value carried into the commit as typed) into `3220c9ee`. Git reported no conflict, and the merge has no hand edit. #376 changed `scripts/family.py` and `tests/test_family.py`, which this branch also changes. None of the lines it added under `tests/` names a key the templates now quote. The coordinator ran targeted tests on this head, Linux only, before CI:
- main's changed test files (`tests/test_commit_trailers.py`, `tests/test_family.py`, `tests/test_bump_leg.py`, `tests/test_update_shape.py`) with `tests/test_family_add_member_id.py`: 421 passed, 1 skipped (a case-insensitive disk only);
- this branch's template set (`tests/test_template_text_placeholders.py`, `tests/test_template_quoted_values.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_bootstrap_spelling.py`, `tests/test_bootstrap_after_bump.py`) with the hygiene, CLI-reference and AGENTS.md-codes tests: 652 passed.

**Composition at 3220c9e.** `3220c9ee` is GitHub's update-branch merge of origin/main `70d0d1b3` into `e5e768bc`. It brings in three commits: #491 (da19735), in which `setup-project.py` asks at the person's own terminal; #374 (7ab11cf), update-shape's NEXT quoting; and #384 (70d0d1b), adopt's `_spelled` plan values. Git reported no conflict, and the merge has no hand edit. #491 changed `tests/test_setup_project_py.py` and `tests/test_setup_sh.py`, which this branch also changes. A grep of the lines main added under `tests/` for the keys the templates now quote found two hits, and neither reads a rendered template line: `tests/test_update_shape_next_quoting.py` writes its own `project.yaml`, and `tests/test_adopt_question_and_submodule_spelling.py` edits a plan's `- path:`/`question:` entry, which `adopt-project.py` writes bare.

The coordinator ran targeted tests on the same tree (`e5a74d19`), Linux only, before CI:
- six of main's eight changed test files (the setup, update-shape, `openRepoShape` command and doctor-row tests): 365 passed;
- main's other two (adopt's spelling and repo hygiene), `tests/test_adopt_github_exits.py` and nine more files of this branch's sixteen-file set: 943 passed.

**Composition, refreshed again 2026-10-13 (head e5e768b).** `6d108d18` merges origin/main `56713e21` into `6cbbac60`. That brings in two commits: 5e02abe, the doctor's and `validate-pins.py`'s gitlink repairs, and 56713e2 (#479), in which adopt's `plan` takes the tracking branch from the source's default branch. Git reported no conflict and `docs/cli.md` auto-merged; the merge has no hand edit. Two kinds of test failed on it, and two commits fix them:
- **`7968f902`** changes one expected literal, at `tests/test_adopt_github_exits.py:675`, for `test_a_source_on_another_branch_opens_its_pull_request_against_it[master]` and `[trunk]`.
  - The test reads the adopted root's `project.yaml`, which this branch's template renders as `tracking_branch: "master"`, so it now expects the quoted line.
  - The look-alike lines at `:743` and `tests/test_adopt_tracking_branch.py:73-76` read a plan, which `adopt-project.py` writes bare, and they stay as they are.
- **`e5e768bc`** adds `git("branch", branch, cwd=source)` to `tests/test_template_text_placeholders.py::test_an_adoption_reads_back_every_value_it_was_given`.
  - All six rows had failed at `check` with `plan-tracking-branch-missing`: #479 refuses a tracking branch (`2026`, `007`, `1.5`, `20261011`, `true`, `null`) the source does not have, and the test's source had only `main`.
  - The source now carries each row's branch, in the flag rows and the by-hand rows alike. The test's assertions are unchanged.

Targeted runs, Linux only:
- On e5e768b: main's seven changed test files, 211 passed; this branch's sixteen-file set, 1223 passed, 2 skipped.
- On 7968f90: the same sixteen files gave 6 failed, which are the six rows e5e768b fixes. The scaffold, setup, doctor and family set gave 1094 passed, 22 skipped, and twelve further template-grep files gave 1259 passed.
- `render-cli-reference.py --check` is ok.

**Composition, refreshed 2026-10-13 (head 6cbbac6).** `d651b594` merges origin/main `aa4e1f59` (#508) into `d526f768`.

- **The one conflict.** Git conflicted in one file, `tests/test_adopt_manifest_values.py`, in four hunks, all against #356 (965420b, #293/#294). #356 refuses an `elected_on:` that `election_date` does not read, as `election-date-malformed`, before the validator is asked (`_manifest_values` returns None, adopt-project.py:2258).
- **Resolved by what the merged tree prints**, measured with `adopt-project.py plan`/`check`/`execute` into local bare remotes:
  - `id: "007"` checks clean and `execute` writes it as typed, so it stays in `TEXT_THE_TEMPLATE_NOW_HOLDS`.
  - `visibility: "true"` gets the validator's `manifest-visibility: visibility is 'true'`.
  - `elected_on: "20261011"` gets #294's one finding, "the plan's `elected_on:` is the text '20261011'", and the validator is not asked. That row keeps this branch's list, `TEXT_THE_VALIDATOR_REFUSES`, with main's sentence.
  - Two tests git merged cleanly now ask `said_of`, as main's own rows do: `test_check_refuses_a_text_value_the_validator_refuses` and `test_where_a_misread_rule_judges_the_rest_the_validator_still_answers`.
- **Two more failures that no hunk showed**, fixed in the same merge:
  - `[branch-tilde]` in `tests/test_scaffold_manifest_values.py` was this branch's own `xfail(strict=True)` until #355 landed as 6c2a371, so it XPASSed. The mark is off, and the row asserts `REFUSED scaffold-bad-tracking-branch:`.
  - #391's `tests/test_bootstrap_spelling.py` and #490's `tests/test_bootstrap_after_bump.py` matched `    path: spec` and `tracking_branch: main` in a scaffolded `project.yaml`, and nine cases failed on the merge. They now match the quoted lines.
- **The #507 interplay.** `6cbbac60` switches the holder to `family/members` before the real `add`s in `test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id`, so the test holds once PR #507's `family-on-tracking-branch` refusal lands.
  - On the clean merge of 6cbbac6 with #507's head 708f3bb, that file, all 13 `tests/test_family*.py` and `tests/test_shape_doctor.py::test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member` give 686 passed, 19 skipped.
  - Without the switch line, the member test fails `REFUSED family-on-tracking-branch`.
- **Targeted runs on 6cbbac6, Linux only:**
  - the template, adopt-plan and manifest files (14): 995 passed, 2 skipped;
  - scaffold e2e, setup, the doctor, leg-path collisions and every family file: 1094 passed, 22 skipped;
  - the 15 further files a grep for template lines found: 1503 passed;
  - `render-cli-reference.py --check`: ok.
- **Re-verified independently** at d651b59 and 6cbbac6: APPROVE WITH NITS (0/0/3), all three in this paragraph and fixed here. The re-verification also ran the three rows, five mutations, and 26 more files (834 passed; 291 passed, 1 skipped).
- **Not run locally:** the full suite, macOS and Windows; CI's three jobs are the check.

**Composition, as measured on 2026-10-12.** The merge trees below were computed for 233dc71; the follow-up commit 3894aa8 changes only `scripts/family.py` comments and adds tests. At 15:28Z main was 3ae0109 (#245, #351, #350 and #345 had landed since the merge trees): 3894aa8 merges clean with it, and the review's re-verify ran the family tests on that merge (61 passed). Every sha below is the one it had then; main and the other branches have moved since.

`git merge-tree --write-tree --name-only`, run after a fetch with automatic maintenance off on 2026-10-12:

- **origin/main 161c6c9**, 17 commits past c7c02fa (the base): clean, tree 7ba8246. #316 (34ecbc2), #317, #318, #311 and #245 were on main by then.
- **The 46 other remote branch heads** (the 30 open PR heads and 16 branches with no open PR): clean for 44. Among them #314 (4b8da3d), #266 (3410be7), #355 (a03abcf), lane 1's `fix/adopt-plan-quote-yaml-keywords` (d38e143), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-late-failure-notice` (9f1778d), #380 (73068a3), and `fix/family-init-values` (14e2d68, #307). The two that conflict, each in the same file as it does with c7c02fa and with origin/main on its own: lane 1's `fix/adopt-root-kept-symlinks` (6c44591, `adopt-project.py`) and #358 `fix/doctor-manifest-kinds-unreadable-pin` (ebffb08, `shape-doctor.py`). This branch touches neither file.
- #314 and d38e143 each conflicted with origin/main on their own, in `adopt-project.py`. Merged with this branch, neither gained a conflict.

**Composition with #314, which lands before this branch.** Its misread gate (`manifest_misreads`) reads the template, so once the template quotes a field it refuses nothing there, and some of #314's tests expected that refusal.

Composing with #314 (landed as 3ef059c), whose misread gate asks the template how a value is written. With `project.yaml` quoting `id`, `elected_on`, `visibility` and `tracking_branch`, the gate refuses nothing on the shipped templates, and 45 of #314's tests flipped on the merge. Forty expected a refusal the value no longer needs. Thirty-four of them now assert that the value is accepted, and the six whose premise was a bare placeholder now assert that no shipped template placeholder is misread. For example: a scaffold of `--id null|true|false|007|2026`, or of `--tracking-branch true|True|TRUE|false|False|null|Null|NULL|007|2026|1.5|1.50|0`, exits 0, and the manifest holds the text it was given. A dry run prints the plan. An id derived from `--project True|Null` is held as typed. `--id=null` reaches `gh repo create`. Adopt's `id: "007"` checks clean and `execute` writes it as typed. The rule's own sentence, remediation and line-break wording are still asserted, on a copy of the standard whose `project.yaml` writes the four lines bare. Four stay refused by #314's validator gate, now in its words about the text: `visibility is 'true'` and `elected_on is '20261011'`. One is a real break. `--tracking-branch=~` reads back as `~`, which git refuses as a branch name, and it dies at `git init -b ~`, after `gh repo create`. That is #283, so the row is `xfail(strict=True)` with that reason, and it turns XPASS (and fails) once PR #355's `git check-ref-format --branch` refusal lands. `--help` for `--id` and `--tracking-branch` no longer says such values are refused, and `docs/cli.md` is regenerated.

**Composition with the other branches.** Where another branch's rules meet this one, the tests were run on the merged tree. Quoting changes what some of their tests see, and the changes are not all one kind: some tests expected a refusal the value no longer needs, some are still refused under another rule, and some guard a case quoting does not fix. Whoever lands second must tell them apart, so each branch's flips are classified below.

- **#266** (lane 2, `fix/leg-path-yaml-scalars`). 27 flips, all from one cause: its rule asks a hard-coded bare line, `LEG_PATH_LINE = "path: {}\n"`, so it refuses leg paths the quoted templates now read back. 15 are `test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path` (null, Null, NULL, ~, true, True, TRUE, false, False, FALSE, 2026, 007, 0, 1.5, 1.50) and 12 are this branch's own end-to-end tests (6 scaffold rows fail with `REFUSED scaffold-bad-leg-path`, 6 adopt rows with `FINDING plan-bad-leg-path`). Counted on the merge with b2b42f8. #266's head was then 3410be7, whose rule reads each template line that holds a leg path, renders the value into it as `render` does and asks the reader, so it has no hard-coded line left. The merge with this branch was clean (tree 1e348c6); that head was read and the 27 were not re-run on it. At b2b42f8, whichever of #266 and #259 landed second had to drop or re-point the bare rule; 3410be7 re-points it already.
- **Lane 1's #181** (`fix/adopt-plan-quote-yaml-keywords`, d38e143). Its gate reads the template too, so `plan --tracking-branch 2026|007|true` exits 0 on the composed tree (exit 2 `adopt-manifest-misread` on d38e143). On the merge, `tests/test_adopt_plan_yaml_quoting.py` and this branch's 6 adopt end-to-end tests give 70 failed, 371 passed:
  - 57 refusals that are now unnecessary: `plan_refuses` ×14, `plan_names_an_id` ×2, `check_names_an_id` ×4, `check_reports` ×14 and `execute_refuses` ×14 (every value except `elected_on` and `visibility`), `text_the_loader_reads_as_other_text` ×7, and the two tests that need a bare placeholder (`test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword`, `test_the_template_line_says_whether_a_value_is_quoted`).
  - 11 guards #181 loses. See "Do not flip".
  - 2 that fail only by cascade: `execute_refuses[pins='null']` and `[pins='True']` pass alone (`2 passed`). An earlier case no longer refuses, and its `execute` pushes the split branch.
  - The writer half of #181 (`y` asking the reader, so `true`/`null`/`True` are quoted in the plan) is still needed and composes cleanly. With it, `plan --tracking-branch true` reaches the quoted template intact, and this branch's two hand-quoted adopt rows become flag-driven.
- **Do not flip these 12 to "accepted".** Each still needs a refusal or a finding, and quoting does not supply it:
  - **`~` (1, #314).** `test_a_value_the_reader_misreads_is_refused_before_anything_is_made[branch-tilde]`. `--tracking-branch=~` is not a branch name git accepts, and #314 refused it only as a misread. On the merge it passes the plan and dies at `git init -q --bare -b ~ .../Atlas-spec.git` with `fatal: invalid initial branch name: '~'`, and against GitHub that is after `gh repo create` has made all three repositories. That is lane 2's #283 (PR #355). `~` must stay refused, by #283's ref-name check, which is why #314's row for it is not flipped to "accepted".
  - **A hand-edited plan value that is not text (7, #181).** `test_check_names_a_plan_value_that_is_not_text_by_what_was_read[tracking_branch='007']`, `[tracking_branch='TRUE']`, `[tracking_branch='1.50']`, `[tracking_branch='[a]']`, `[visibility='{a: b}']`, `[id='007']` and `[elected_on='20260902']`. The plan's reader reads each as something other than text, and on the merge `check` passes. Measured on the merge: `tracking_branch: 007` and `id: 007` are adopted silently as `"7"` with every validator passing; `visibility: {a: b}` pushes both legs and then fails `manifest-visibility`; `tracking_branch: [a]` is still refused at `execute` (`unsafe-value`) before any leg exists. On d38e143 alone, `check` exits 1 `plan-manifest-misread` for each of those four. Re-point the gate at "a plan value that is not a string" and these keep their guard.
  - **A quoted value the validator refuses (4, #181).** `test_check_reports_a_plan_carrying_one[elected_on='20260902']` and `[visibility='true']`, and `test_execute_refuses_a_plan_carrying_one_before_any_leg_exists[elected_on='20260902']` and `[visibility='true']`. The value stays invalid for the validator (`DATE_RE`, the visibility choices), and #181's misread gate caught it only incidentally. On the composed tree `check` passes and `execute` pushes the legs, and the gate fails afterwards. That is what main does today, and #314's validator gate (`plan-manifest-invalid`) would catch it.
- **main** (merge with ab623dd, tree cdeec7f, run by the review): this branch's new file, `test_adopt_template_values.py` (#270), `test_template_values_single_pass.py` (#316) and `test_template_quoted_values.py` give 431 passed, 1 skipped. The skip is `test_adopt_template_values.py:223`, which reads `git config user.name` and skips where the shared repository's `.git/config` sets one; it is environmental. The changed tests with `test_family_add_refusals.py` (#317, #318), `test_pin_validator.py` and `test_manifest_validator.py` give 132 passed. #270's `unwritable_reason` is value-based, not template-relative, so it refuses as it did and nothing flips. Main then added #245 (`adopt-project.py` and `repo_shape.remote_key`), which adds no test that edits a bare quoted line; it was not run on that merge.
- **#380** (lane 3, `fix/pin-rewrite-indentation`; it landed since as 1f90a0f). It rewrites `commit:` and `tree_sha256:` only, and its tests render the pins from the templates. On the merge at dc0fa7b, `test_pin_rewrite_layout.py` and the 6 scaffold end-to-end tests gave 121 passed. Its head was then 73068a3; that merge was clean and was not re-run.
- **#397** (`fix/family-init-values`, #307; measured at 14e2d68): its tests skip themselves where the template quotes. 137 passed, 19 skipped, 0 failed. With its fix-round head abb27e6, this branch's 3894aa8, main 0f67f7b and #345 together: 442 passed, 20 skipped (the 19 rows, and one Windows-only row).
- **#345** (run at 875d802; it has since landed as 3ae0109): `test_family_dry_run.py` and the member test, 22 passed. Its `"    id: irrs"` assertion holds, because ordinary ids stay bare.

**Left out.**

- The adoption plan writer `y` still writes `true`/`null` bare into the plan. That is #181 (lane 1).
- `templates/assembly-root/contracts/neutral-product-pin.yaml` still writes `product`, `source_repository` and `digest_source` bare, and `family.py` writes a member row's `project:` bare. Neither is a #259 field.
- A plan value someone edits to a non-text type (`tracking_branch: 007` or `id: 007`, unquoted, in the plan) is read by the plan's reader as the number 7. `str()` makes it `"7"`, and the quoted template now writes it faithfully. For `id` this is a change: on c7c02fa a bare `id: 007` failed loudly (`validate-manifest: manifest-id`, manifest line `id: 7`), and on this branch it is adopted silently as id `"7"` and every validator passes. `tracking_branch: 007` was already adopted silently as `7`. The plan side is not this branch's: the guard against it belongs with #181 (adopt plan writes YAML keywords and non-text values unquoted), whose gate should refuse a plan value that is not a string. The case is narrow, because `plan --id 007` itself writes `id: "007"`; only a hand edit reaches it.
- No docs change: README, AGENTS.md, the handbook and the template comments show none of these lines; `docs/cli.md` is unchanged because no `--help` text changed.

Reviewed adversarially at 233dc71 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 6 nits; the code and the tests were sound, and the should-fixes were the composition counts and the claim about a hand-edited plan `id`). Fix round 3894aa8 changes no behaviour: it corrects the description and the two false "moves by a byte" claims, and guards `_member_id`'s `except YamlError` with a test of 7 cases. Its re-verification at 3894aa8 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 1 nit: shas that had moved since the description was written, refreshed here). What was added after that re-verification, main and the composition with #314, is described under "Composition with #314". An independent re-verification of that composition at 77cd0b2 confirmed it with nits only (0 blockers, 0 should-fixes, 5 nits): the 45 flips reproduce test for test, breaking the real rule or the real template's quoting turns the compose tests red, and the `[branch-tilde]` row XPASSes on a merge with #355.

**Composition with #381.** #395's quoted `id: "{{PROJECT_ID}}"` composes with #381's `member-duplicate-id` test (`tests/test_family_add_member_id.py`), whose id rewrite now matches the id bare or quoted and keeps its quoting, so the full suite, which failed that one test on Linux at 1ef3310, no longer fails `test_a_member_whose_id_changes_between_the_probe_and_the_mount_is_unwound` on Linux.

CI is green on Linux, macOS and Windows, with SonarCloud passing, at 4984651 (main cc8f2da merged in by update-branch). Main af01068 (#505), which landed after, touches none of its files, and the combined tree passed the template, hygiene, CLI-reference, AGENTS.md-codes and setup tests (807 passed).

Closes #259

Lane: openreposhape-5 (openRepoShape-5)
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