Skip to content

adopt-project.py: plan's follow-up git grep takes a spec directory as its pattern and each code entry as a literal path, so a spec directory named -notes/ or a code entry named :cache/ no longer loses its follow-up, a code entry named */ no longer names a spec-leg file, a name outside ASCII is spelled instead of C-quoted, and a spec directory named -O<cmd>/ no longer runs a command (#216) - #228

Merged
brettheap merged 4 commits into
mainfrom
fix/plan-literal-follow-up-grep
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #216 (comment)

What

plan's follow-ups come from one git grep per directory the plan sends to the spec leg, restricted to the entries it sends to the code leg. Both inputs are names the source chose, and git read each as something else. The call is now git --literal-pathspecs grep --no-color -z -l -I -F -e <spec dir> <commit> -- <code entries>:

  • -e: the spec directory is always the pattern. -notes/ was a usage error (exit 129), and its empty stdout was read as "no file names it". -O<cmd>/ was --open-files-in-pager, which git runs through a shell.

  • --literal-pathspecs: each code entry is the path it spells. :cache was searched as the pathspec cache. * matched every path, so it named a file that moves to the spec leg.

  • -z: each hit is decoded the way Source.tree decodes a name, then spelled the way a question spells a path (\n, \xe9), on one line of UTF-8. -l used to print git's C-quoted form.

  • --no-color: before this, color.ui=always in the source's own config wrote \x1b[m into the plan.

  • A failed grep is reported. When git grep exits above 1, the follow-up gives the exit code, git's message on one line, and what to look for by hand. It is no longer read as "no hits".

  • A spec directory whose name holds a line feed is left to a human (fix round 1). -F reads a pattern holding a line feed as one pattern per line, so a<LF>b/ was searched as a OR b/ and named files that never mention it, the way * did. No grep is asked for such a name, and the by-hand follow-up says why.

  • The spec directory's own name is spelled in every follow-up, the way the hit beside it is (fix round 1). An ordinary name is unchanged.

  • The split's forced add is git --literal-pathspecs add -f. A shape file written through a symlink the plan kept in the root lands at the link's target, which is a name the source chose. Without the flag:

    • :ci/... was pathspec magic;
    • :!ci/... was an exclude;
    • ga[0]/... was a glob.

    On main, execute refuses those plan paths as unsafe-value before anything is made. This hunk is reached once adopt-project.py check accepts plan paths execute refuses as unsafe-value (a space or a non-ASCII character), so plan ok is followed by a refusal and #166's split instructions can name entries execute will refuse #196 admits them. adopt-project.py check accepts plan paths execute refuses as unsafe-value (a space or a non-ASCII character), so plan ok is followed by a refusal and #166's split instructions can name entries execute will refuse #196 changes git rm and filter-repo's path list but leaves this add alone.

Unchanged: plan over conftest's SYNTHETIC_TREE plus four plain files that name contracts/ and docs/ is byte-identical to main a5b09c3 in exit code, stdout, stderr and the plan file (measured by diffing both runs, on 115f012 and again on f0d4334). The git add -A -- . before the forced add is untouched, and no --help text changes.

Why

The issue's repro, re-measured on this machine. plan runs over local sources, and the follow-ups are what plan wrote. Main is a5b09c3. The head column was measured on 115f012 and again on f0d4334, with identical output.

case main a5b09c3 head
spec dir -Otouch PWNED;/, src/scratch.txt in the index holding the planned commit's hash plan exit 0, new files in the source dir: ['PWNED'], so git ran a command plan exit 0, new files in the source dir: [], plus the follow-up for src/reader.py
same source, clean checkout / untracked scratch.txt [] / [] [] / []
spec dir -notes/ follow-ups (1): the pull-request line only. By hand: exit 129, error: unknown switch `t' follow-ups (2): code/src/reader.py names `-notes/` ...
spec dir -Otouch/ (no hash in the tree) follow-ups (1), nothing named code/src/reader.py names `-Otouch/` ...
code dir :cache/ holding the file that names notes/ follow-ups (1), nothing named code/:cache/a.py names `notes/` ...
code dir */, spec dir notes/ whose own notes/two.md says "see notes/" follow-ups (3), the first naming code/notes/two.md, a spec-leg file follow-ups (2): code/src/reader.py only
code file src/Übersicht.py naming notes/ code/"src/\303\234bersicht.py" names ... code/src/Übersicht.py names ...
code dir s?c/ beside spec dir sec/ (control) code/src/reader.py only same
notes/ (control) code/src/reader.py same
spec dir a<LF>b/ beside src/other.py whose only text is a = 1 (cross_leg_references, since #199 writes that plan) code/src/other.py names `a<LF>b/` ..., a false follow-up with a raw line feed git could not search ... for `a\nb/` (`git grep` reads each line of a pattern as a pattern of its own ...); find the ones that name it by hand ...

The forced add, end to end (plan, then answer .github and <target>/ as root, then check, then execute into local bare remotes). The source's .gitignore hides validate.yml and ga0/. "#196 + this head" is a trial merge of this head with fix/adopt-check-unsafe-path 5a8df76, whose tree is identical to its current head f9656bd.

link target main a5b09c3 (and this head alone) #196 alone 5a8df76 (same tree as its current f9656bd) #196 + this head
:ci exit 2, REFUSED unsafe-value, nothing made exit 2 after both leg remotes are pushed: fatal: pathspec ':ci/workflows/validate.yml' did not match any files exit 0, adoption verified; the split holds :ci/workflows/validate.yml
:!ci exit 2, unsafe-value exit 1, FINDING adopt-shape-file-missing: the split lacks the workflow exit 0, adoption verified
ga[0] exit 2, unsafe-value exit 0 exit 0

For ga[0], the glob only goes wrong when a file it also matches is ignored and already present in the clone (ga0/...). execute's fresh clone never holds one, so that case is covered by the unit test, which writes it.

Tests

  • New: tests/test_adopt_literal_names.py, 14 cases, one per name class above.
    • On main a5b09c3: 12 failed, 2 passed in 4.31s. The two passes are the controls notes/ and s?c/. Each failure is the defect its docstring names.
    • The line-feed case also fails on 115f012, with the false follow-up.
    • The failing-grep case checks git's exit and fatal: rather than git's exact wording, which differs between git versions.
  • Changed: tests/test_adopt_ignored_shape_files.py: its recorded argv for the forced add gains --literal-pathspecs.
  • Targeted run: tests/test_adopt_*.py (10 files), tests/test_shape_doctor.py, tests/test_repo_hygiene.py, tests/test_cli_reference.py, with git-filter-repo 2.47.0 on PATH.
    • On f0d4334: 1015 passed, 1 skipped in 538.50s (exit 0).
    • On 115f012: 1014 passed, 1 skipped in 526.49s.
    • The skip is test_adopt_plan.py:393, which needs SHAPE_MEDXEHR_CLONE, unset here.
  • Windows skips: the job skips a-code-entry-starting-with-a-colon, a-code-entry-named-star, control-a-code-entry-with-a-question-mark, the line-break / non-UTF-8 case, the line-feed spec directory, and the three forced-add cases. NTFS cannot hold :, *, ? or a line break in a name, and git for Windows checks a symlink out as a plain file. The remaining six run there.
  • Not measured: macOS. The Übersicht.py case relies on git's core.precomposeunicode returning the name in NFC there.

Depends on

#214 (fix/plan-report-stdout-encoding, local 3006303, no PR yet) should land first. This was found by the independent review.

Measured with PYTHONIOENCODING=cp1252 over notes/one.md, notes/two.md, src/日本.py (which names notes/) and src/other.py:

tree plan
main a5b09c3 exit 0; plan written to printed; follow-up code/"src/\346\227\245\346\234\254.py" names ...
this head f0d4334 alone (#228 landing first) exit 1, UnicodeEncodeError: 'charmap' codec can't encode characters in position 13-14. The plan file is written, but plan written to and the questions are not printed.
#214 3006303 alone exit 0, as main
#214 + this head (fd3bfad; merge-tree CLEAN in both orders) exit 0; stdout code/src/\u65e5\u672c.py names ...; the plan file holds code/src/日本.py names ...

On that merged tree, test_adopt_literal_names.py + #214's test_stdout_encoding.py give 25 passed.

Composes with

Trial merges (git merge-tree --write-tree), in both orders, against every branch that touches a file this one touches:

sibling sha sibling, head head, sibling notes
fix/plan-report-stdout-encoding (#214) 3006303 CLEAN CLEAN See Depends on. On the merged tree: 25 passed.
fix/adopt-check-unsafe-path (#196) f9656bd (tree = 5a8df76) CLEAN CLEAN On the merged tree with 115f012, test_adopt_literal_names.py + #196's test_adopt_entry_paths.py: 68 passed. The forced-add end-to-end table above is this merge, and fix round 1 does not touch the forced add.
fix/decode-git-output-utf8 (#217, PR #230) b6f7cfd CLEAN CLEAN #217 dropped its cross_leg_references hunk and lists the call in its guard's EXEMPT table. On the merged tree with f0d4334 (2b83baf), test_git_output_decoding.py + test_adopt_literal_names.py: 17 passed. The call is bytes, so the exemption is unused, and the guard does not fail on an unused one.
fix/adopt-history-gitlinks (#195) 0023dde CLEAN CLEAN
fix/adopt-spell-finding-paths (#212) d41c4b3 (origin 58b4343) CLEAN CLEAN Also touches tests/test_adopt_ignored_shape_files.py.
fix/scaffold-shape-path-collisions (#197) 87fd767 CLEAN CLEAN
fix/adopt-report-filter-repo-failure (#171) f416e13 CLEAN CLEAN
fix/adopt-late-failure-notice 8d4fef4 CLEAN CLEAN Its tests stub _stage_the_split out, so they do not depend on the argv.
fix/adopt-plan-quote-yaml-keywords a0ff15b (origin 76a6153) CLEAN CLEAN
fix/adopt-record-submodule-placement e167d8b CLEAN CLEAN
fix/adopt-root-kept-symlinks 04792d2 CLEAN CLEAN
origin/fix/adopt-mount-past-gitignore (merged as #200) a28c3b9 CLEAN CLEAN
fix/adopt-plan-linebreak-names (#199) d5fdc22 (origin d566e12) CONFLICT CONFLICT The same single hunk, the repo_shape import list, conflicts with main a5b09c3 alone. This head adds no conflict hunk.
fix/adopt-relative-submodule-url b0c959e CONFLICT CONFLICT The same 5 hunks conflict with main a5b09c3 alone. This head adds none.

The rows were re-checked against f0d4334.

Base. Tested on a5b09c3. Main has since moved to 54aa69a (#173), and git merge-tree --write-tree origin/main f0d4334 is CLEAN.

None of these siblings' tests assert on the git grep argv, on cross_leg_references' output or on the forced add's argv. Checked by scanning every sibling's added test lines.

Left out

Closes #216

🤖 Generated with Claude Code

Summary by Sourcery

Treat all source-selected paths as literal names when generating adoption follow-ups and staging split files.

Bug Fixes:

  • Make plan follow-ups correctly handle source paths that resemble Git options, pathspec magic, glob patterns, non-ASCII names, colors, and unusual bytes.
  • Report failed or unsupported searches instead of treating them as having no matches.
  • Prevent forced staging from interpreting source-selected paths as Git pathspecs.

Enhancements:

  • Spell follow-up paths consistently and safely, including names containing line feeds and bytes outside UTF-8.

Tests:

  • Add coverage for literal Git names, malformed searches, unusual path spellings, and forced staging through symlinks.

…y as its pattern and each code entry as a literal path, so a spec directory named `-notes/` or a code entry named `:cache/` no longer loses its follow-up, a code entry named `*/` no longer names a spec-leg file, a name outside ASCII is spelled instead of C-quoted, and a spec directory named `-O<cmd>/` no longer runs a command (#216)

`cross_leg_references` ran `git grep -l -I -F <spec dir> <commit> --
<code entries>` once per directory the plan sends to the spec leg, and wrote
each file it listed into the plan's `follow_ups:`. Both inputs are names the
source chose, and git read each as something else. It now runs
`git --literal-pathspecs grep --no-color -z -l -I -F -e <spec dir> <commit>
-- <code entries>`:

- `-e`: the spec directory is the pattern wherever it sits. `-notes/` was a
  usage error (exit 129) whose empty stdout read as "no file names it", and
  `-O<cmd>/` was `--open-files-in-pager`, which git runs through a shell:
  measured on main a5b09c3, a local source whose index holds a file carrying
  the planned commit's hash got a `PWNED` file from `plan`. Now it gets the
  follow-up and nothing runs.
- `--literal-pathspecs`: each code entry is the path it spells. `:cache` was
  the pathspec `cache`, so `:cache/a.py` got no follow-up, and `*` matched
  every path, so the spec leg's own `notes/two.md` was written up as
  `code/notes/two.md`.
- `-z`: each record is split at the first `:` after the hex commit and
  decoded as `Source.tree` decodes a name, and the hit is spelled as a
  question spells a path (`\n`, `\xe9`), one line of UTF-8. `-l` had written
  `code/"src/\303\234bersicht.py"`.
- `--no-color`: `color.ui=always` in the source's own config put `\x1b[m`
  into the plan.
- A grep that exits past 1 is said, as a follow-up naming the exit, git's
  words on one line and what to find by hand, instead of being read as no
  hits.

`_stage_the_split`'s forced add is `git --literal-pathspecs add -f`. A
shape file written through a symlink the plan kept in the root lands at the
link's target, a source name: `:ci/...` was magic and died with `fatal:
pathspec ... did not match any files` after both legs were pushed,
`:!ci/...` was an exclude that left an ignored workflow out of the split
(`adopt-shape-file-missing`), and `ga[0]/...` was a glob that also forced
an ignored `ga0/...`. On main a5b09c3 `execute` refuses those plan paths as
`unsafe-value` before anything is made, so the hunk is reached once #196
(fix/adopt-check-unsafe-path) admits them; #196 changes `git rm` and
filter-repo's path list but not this add. Measured end to end: #196 alone
exits 2 after both legs are pushed, #196 with this change says `adoption
verified`.

Unchanged: `plan` over conftest's SYNTHETIC_TREE plus four plain files
naming `contracts/` and `docs/` is byte-identical to main a5b09c3 in exit,
stdout, stderr and plan file. The `git add -A -- .` before the forced add
is as it was. No `--help` text changes.

tests/test_adopt_literal_names.py: 13 cases, 11 failing on main a5b09c3
and the two controls (`notes/`, `s?c/`) passing there. The cases whose
names NTFS cannot hold, and the symlink cases, skip on Windows.
tests/test_adopt_ignored_shape_files.py's recorded argv gains
`--literal-pathspecs`.

Left out: the spec directory's own name in a follow-up is written raw, so
one holding a line break or a byte outside UTF-8 is not spelled there.

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

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T19:03Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens plan and split staging against Git option, pathspec, quoting, color, and encoding interpretation of source-chosen names, while adding focused regression coverage for follow-up discovery, grep failures, and symlink-target staging.

Sequence diagram for literal follow-up discovery

sequenceDiagram
    participant Plan
    participant Git
    participant Source
    Plan->>Git: grep --no-color -z -l -I -F -e spec_dir commit -- code_paths
    Git-->>Plan: NUL-delimited commit:path hits
    Plan->>Source: _git_path(record)
    Source-->>Plan: decoded path
    Plan->>Plan: _spelled_character(hit)
    Plan-->>Plan: report follow-up or grep failure
Loading

Flow diagram for literal split staging

flowchart LR
    A[Materialized shape files] --> B[_where_it_landed]
    B --> C[git --literal-pathspecs add -f]
    C --> D[Staged split preserves source-chosen paths]
    C --> E[Ignored files are force-added]
Loading

File-Level Changes

Change Details Files
Make cross-leg reference discovery treat source-controlled names as literal Git inputs and preserve their exact spelling in follow-ups.
  • Pass the spec directory with -e and code entries under --literal-pathspecs.
  • Use NUL-delimited, raw Git output with color disabled, decode paths consistently, and spell non-ASCII/control bytes for plan output.
  • Report git grep failures above exit code 1 instead of interpreting them as no matches.
adopt-project.py
tests/test_adopt_literal_names.py
Make forced staging honor literal paths for files materialized through retained symlinks.
  • Add --literal-pathspecs to the forced git add -f invocation.
  • Update the argument assertion and cover colon, exclude, and glob-like target names end to end.
adopt-project.py
tests/test_adopt_ignored_shape_files.py
tests/test_adopt_literal_names.py

Assessment against linked issues

Issue Objective Addressed Explanation
#216 Make the spec directory argument to git grep an explicit pattern so names beginning with - cannot be interpreted as options or trigger commands. ✅
#216 Treat every code entry passed to git grep as a literal path rather than a pathspec, preventing names such as :cache/ and */ from being reinterpreted or matching files outside the code leg. ✅
#216 Consume git grep results without Git's quoting or color transformations so non-ASCII and other unusual filenames are correctly represented in follow-ups, while reporting grep failures instead of silently treating them as no matches. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

…ine feed to a human, and spells the spec directory in its follow-ups, so `a<LF>b/` is no longer searched as `a` OR `b/` and written up as named by a file that never mentions it (#216)

Fix round 1 on #228 (independent review rv-adopt-a, finding 2).

- `git grep -F -e <pattern>` reads a pattern holding a line feed as one
  pattern per line. Measured on main a5b09c3 and on 115f012 alike: a spec
  directory `a<LF>b/` beside `src/other.py`, whose only text is `a = 1`,
  got the follow-up `code/src/other.py names ...`, a false follow-up like
  the one `*` gave. `cross_leg_references` now asks no grep for such a
  name and writes the by-hand follow-up it already writes for a failed
  grep, saying why. The two share `_search_by_hand`.
- The spec directory's own name in every follow-up is spelled the way the
  hit beside it is (`_spelled_character`), so a line feed in it no longer
  splits the line. An ordinary name is unchanged.

tests/test_adopt_literal_names.py gains the line-feed case (posix-only,
built in the tree and never on the disk), failing on main a5b09c3 and on
115f012 with the false follow-up.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T19:35Z) fix round 1 (independent review, codex is out of quota): cross_leg_references asks no grep for a spec directory whose name holds a line feed (-F split a<LF>b/ into a OR b/ and wrote a false follow-up) and writes the by-hand follow-up instead, with a new test; the spec directory is spelled in every follow-up like the hit beside it; the cp1252-stdout finding is left to #214, now a ## Depends on section with the case measured on main, this head, #214 alone and #214 + this head; targeted set 1015 passed, 1 skipped on f0d4334.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T19:41:55Z)

Lane openRepoShape-2 reviewed PR #228 at 115f012, read-only, in its own detached worktrees. Nothing was authored and this lane holds no claim.

MERGEABLE WITH NITS: it must take main 54aa69a (#173) first. The merge is clean and its tests pass (59 passed). No finding is above nit.

Update at 2026-10-11T19:41:55Z: the branch has since moved to f0d4334, one commit: plan leaves a spec directory whose name holds a line feed to a human and spells the spec directory in its follow-ups, which answers the first two nits below. It does not yet contain main 54aa69a. This lane did not review it.

Findings:

Tests:

  • The issue's rows through plan, on main:
    • -notes/ and :cache/ lose their follow-up.
    • */ names code/notes/two.md.
    • Übersicht.py comes back C-quoted.
    • -Otouch PWNED;/ with the commit hash in an indexed file creates PWNED.
  • On head, each row is right and creates nothing. The controls are unchanged.
  • Adversarial:
    • --open-files-in-pager=touch PWNED2;/ created PWNED2 on main and nothing on head.
    • color.grep=always and core.quotePath: main wrote \x1b[m and a C-quoted name. Head is clean.
    • :(exclude)src/ as a code entry, an ESC or tab in a hit name, and a spec directory -dash/ beside notes/: all correct on head.
  • The PR's 2 test files on main's code: 12 failed, 31 passed. 11 of 13 new cases fail, the 2 controls pass, and the argv test fails, each on the defect its docstring names.
  • The same files on head: 43 passed.
  • 7 mutants: all killed (-e, both --literal-pathspecs, --no-color, the hit's spelling, the failure branch, the surrogate decoding).
  • Targeted run at head (tests/test_adopt_*.py, shape_doctor, repo_hygiene, cli_reference, windows_paths): 1052 passed, 1 skipped. That is the body's 1014 plus windows_paths' 38. render-cli-reference.py --check is ok.
  • plan and check output is byte-identical to main on the synthetic source with 5 follow-ups.

Merges (merge-tree --write-tree):

The full report is handoffs/xFactory/attachments/lane-openRepoShape-2/review228-115f012/final-report.md in opensoft/brett-wip.

This was referenced Oct 11, 2026
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 346079d into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/plan-literal-follow-up-grep branch October 12, 2026 11:42
brettheap added a commit that referenced this pull request Oct 12, 2026
…rences` rows, which exempt nothing now that #196 and #216 are on main

The branch merged main at 346079d (#228, issue #216; #196 landed earlier as
6f45bf9). The two rows were kept "until" those landed:

- `scripts/shape_materialize.py` `env_commit`: its `subprocess.run` names
  `text=True, encoding="utf-8", errors="replace"`, so the default rule is met
  and the row exempted nothing (since #231).
- `adopt-project.py` `cross_leg_references`: #216 made its `git grep` read
  BYTES (`capture_output=True`, no `text=`, the record split on NUL and each
  path decoded by hand), so it is no longer a text-mode call at all. Measured
  on c7c02fa the row was needed (`adopt-project.py:1080 asks for text and does
  not name ['encoding', 'errors']` without it); on 346079d the guard finds no
  text-mode call in that function.

EXEMPT keeps the two `gh api` digest rows (`pin_digest_from_gh`,
`tree_digest_from_gh`: UTF-8 yes, `replace` no), which still exempt `errors`.
With both rows gone the guard flags 0 offenders over the shipped Python at
346079d: 51 spawner calls, 32 text-mode (33 before #216).

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
Main brings two commits since 71b58a1:

- c7c02fa (#299 via #315): family.py's `bump` and `remove` refuse
  `member-path`;
- 346079d (#216 via #228): `plan`'s follow-up `git grep` takes each name
  literally, and `_stage_the_split` adds with `git --literal-pathspecs`.

It merged with no conflict. adopt-project.py and
tests/test_adopt_ignored_shape_files.py changed on both sides. In each,
the merge's diff against this branch is main's own diff (72 and 4
lines), and its diff against main is this branch's own diff (784 and 16
lines). Every other file is one side's.

Main's hunks in adopt-project.py, by where they land:

1-7. `cross_leg_references`: names are literal (#216); the spec
     directory and each hit are spelled; `git --literal-pathspecs grep`;
8. after it, the new `_search_by_hand`;
9-10. `_stage_the_split`: its docstring, and `git --literal-pathspecs
      add -f`.

Main's hunk in tests/test_adopt_ignored_shape_files.py is in
`test_the_forced_paths_are_the_written_ones_in_posix_spelling`, which
this branch does not change: the forced add is
`git --literal-pathspecs add -f`.

This branch changes none of these definitions. `_stage_the_split` runs
inside `_commit_the_split`, after both legs are pushed, where this branch
already reports any failure with the notice. No top-level name is bound
twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, scripts/family.py or tests/conftest.py.

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 12, 2026
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement

Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1,
merged by sha. No rebase. Among the files this branch changes:
346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260)
changes scripts/shape_materialize.py; 87373a6 (#306) changes
shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes
AGENTS.md and tests/test_repo_hygiene.py.

Two conflicts, each resolved keeping both sides:

- adopt-project.py, the module docstring's `execute` paragraph. #243 ends
  "the root's only submodules are the two leg mounts, each at the commit
  pushed to its leg."; this branch follows "two leg mounts." with "The
  split's `project.yaml` records the leg each source submodule kept in a
  leg was answered into, under `submodules:`, for `shape-doctor.py` to
  check it against." The merge has #243's sentence, then this branch's,
  re-wrapped to the paragraph's width. The module docstring is not in
  `--help` (docs/cli.md holds neither sentence).
- tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`.
  Each side moved the AGENTS.md cap from 464 and wrote a paragraph
  saying why: #288 to 477, for thirteen lines, and this branch to 472,
  for eight. AGENTS.md itself merged cleanly and holds both: 485 lines,
  main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph
  word for word, then this branch's, renumbered "2026-10-12: 477 -> 485"
  because it now follows #288's, and asserts `<= 485`.

`git merge-tree --write-tree` of the two parents gives the same tree in
every file but these two.

Checked:

- adopt-project.py: `_commit_the_split` was changed by both sides, in
  hunks that do not touch. #243 clones the assembly with `--template=`;
  this branch builds `append` with the `submodules:` record. The merged
  function holds both, and each side's text is unchanged. Every other
  definition one side changed is that side's text.
- shape-doctor.py: #306 changes `validator_row` and `run_validator`.
  This branch's placement functions, F1's
  `gitmodules_the_record_answers` among them, are this branch's text.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py,
  scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py,
  update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged
  module's names are the base's plus what either side added.
- AGENTS.md is main's plus exactly this branch's changed lines.
- The branch's diff against main is #189 alone: 12 files, +1694 -157. That
  is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line
  the docstring re-wrap adds.

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 12, 2026
…elector's fallback are refused before anything exists when no file can hold them, so a byte that is not UTF-8 no longer creates repositories before a traceback (#282) (#351)

**What #282 still gets wrong on main (c7c02fa).** #284 (b30595c) spells each of the three quoted placeholders in `project.yaml` with `repo_shape.double_quoted`. So the manifest now reads back whatever was typed. The README, each `AGENTS.md` and each first commit's message still take the value as it stands. Every row of the issue was re-run on c7c02fa with local bare remotes only: 76 cases across `--elected-by`, `--reference`, `--name`, `GIT_AUTHOR_NAME` and `git config user.name` (given as `GIT_CONFIG_COUNT` config). In every case that scaffolded, the project's reader and PyYAML 6.0.3 read the manifest back as typed (0 misreads, 0 raises). What was still wrong:

- **A byte that is not UTF-8.** The scaffold exits 1 with a `UnicodeEncodeError` traceback from `write_lf`. Through `--elected-by`, `--reference` or `GIT_AUTHOR_NAME`, both legs are pushed by then and the assembly root is created empty. Through `--name`, all three repositories are created first. Through `git config user.name` it raises `UnicodeDecodeError` from `git_out`'s strict decode in `_elector`. That one happens under `--dry-run` too, before anything is created. The issue does not list this case.
- **A line break: LF, CR LF, U+0085, U+2028, U+2029.** The scaffold exits 0. The value goes raw into the README, which gains a line. Through `--name` it also goes raw into the root `AGENTS.md` (2 extra lines) and each leg's `AGENTS.md` (1). With LF or CR LF, each commit message's first line becomes `Seed the spec leg of Ada`; U+0085, U+2028 and U+2029 sit in that line raw. The `manifest ok:` line that the project's own `validate-manifest.py` prints is split as well.
- **A control character (U+0001, ESC, DEL, TAB), or a bidi embedding, override or isolate (U+202E, U+2066/U+2069).** The scaffold exits 0, and the value is raw in the README (and, for `--name`, in each `AGENTS.md` and each commit message).

**The change.**

- `scaffold-project.py` `_identity` asks `unwritable_reason` about the three values, beside `election_date`. Any value that fails is refused as `scaffold-unwritable-value`. The refusal is one sentence per value: the flag, the value as Python's `repr` spells it, the files it is written into, and why. All failing values go into one refusal. It comes before a work directory or a remote exists, and `--dry-run` refuses the same. The only process that can run first is the read-only `git config user.name` that finds the elector when neither `--elected-by` nor `GIT_AUTHOR_NAME` gives one:

  ```
  REFUSED scaffold-unwritable-value: --name is 'Jos\udce9', which the scaffold writes into the assembly root's `project.yaml` and `README.md`, each repository's `AGENTS.md` and each first commit's message, and it holds a byte that is not UTF-8.
  ```

  An elector the flag did not supply is named by its source: `--elected-by (not given, so GIT_AUTHOR_NAME)` or ``--elected-by (not given, so `git config user.name`)`` (`_elector_named`, in `_elector`'s order). The second is the wording #270's `plan` uses.
- `_elector` decodes `git config user.name` with `surrogateescape`, the way argv and the environment are decoded. A byte that is not UTF-8 there is then refused by name, not raised. `adopt-project.py` already reads git with this pattern (`git_out(..., binary=True).decode("utf-8", "surrogateescape")`).
- `scripts/shape_materialize.py` gains `BIDI_CONTROLS` and `unwritable_reason`: see "Composition with PR #270" below.

**Each row of the issue.**

| row | `project.yaml` | README / `AGENTS.md` / commit messages | where it stands |
|---|---|---|---|
| a backslash (`CORP\nick`, also through `GIT_AUTHOR_NAME` and `git config user.name`; `C:\docs\notes\new.md`) | fixed on main by b30595c (#284): `"CORP\\nick"`, read back exactly | raw, as typed, on its own line (Markdown keeps a backslash before a letter literal, and the path is inside backticks) | fixed on main by b30595c |
| a double quote (`Joe "JJ" Smith`) | fixed on main by b30595c: `"Joe \"JJ\" Smith"`, read back exactly | raw, as typed | fixed on main by b30595c |
| a line break: LF, CR LF, U+0085, U+2028, U+2029 | parses on main since b30595c (escaped, read back exactly) | split on main | **fixed by this branch**: refused |
| a byte that is not UTF-8 (all five ways in) | escaped on main since b30595c; not where it failed | traceback on main: `write_lf` raised on the root README or, for `--name`, the spec leg's `AGENTS.md`; `git config user.name` raised in `git_out` | **fixed by this branch**: refused |
| a control character: U+0001, ESC, DEL (TAB too) | PyYAML's `ReaderError` fixed on main by b30595c (escaped) | raw on main | **fixed by this branch**: refused |
| a bidi control: U+202E, U+2066, U+2069 | escaped on main since b30595c | raw on main (`README.md:9`) | **fixed by this branch**: refused |

No row is left open. On this head, the 60 cases with an unwritable value exit 2 under both `--dry-run` and a real run. Each prints one `scaffold-unwritable-value` refusal and makes no bare remote, with 0 tracebacks. The other 16 cases scaffold, exit 0: the backslash, the Windows path, the double quote, and a Persian name joined with U+200C ZERO WIDTH NON-JOINER. Each reads back exactly, and the README gains no line.

**Files outside YAML.** `README.md` (`:1` name, `:9` elector, `:10` reference in backticks), the root `AGENTS.md` (`:3`, `:13`), each leg's `AGENTS.md` (`:1`), and the three first commits' messages take the value raw. A backslash and a double quote break none of them: the file holds the typed text, and the line count is unchanged. Every other row is now refused before any of them is written. `--name` also goes into the GitHub description via `gh repo create`, which is not run here.

**Composition with #270 (lane 2, #255), now landed as f795cf7.** #270 added `unwritable_reason` and `BIDI_CONTROLS` to `scripts/shape_materialize.py`, and this branch adds the same hunk byte for byte at the same place (after `is_trailer`). The merge of this branch with main ab623dd (e5804e0, made by `gh pr update-branch`) is clean, and its `scripts/shape_materialize.py` holds one `def unwritable_reason` and one `BIDI_CONTROLS`. The two tools refuse the same values in the same words, and the two comments in `scaffold-project.py` that say `adopt-project.py` asks the same question are now true.

**Composition with PR #314 (lane 5, `fix/scaffold-manifest-values`).** No duplication. #314 asks the project's reader and `validate-manifest.py` about the rendered manifest, after the values table is built. This branch asks whether a value is text a file can hold, earlier, in `_identity`. Each refuses values the other passes: a blank `--elected-by ' '` is #314's, and `Ada\nLovelace` is this branch's. The merge with ab66bc0 is clean. On the merged tree, #314's own tests fail in 5 places, all on a value both PRs refuse: `--elected-by` of a bare TAB, `\x0b`, `\x1c` or U+2028, which is blank to the validator and unwritable here. This branch refuses those first, as `scaffold-unwritable-value`, where #314's tests expect `scaffold-manifest-invalid` or `scaffold-manifest-`. The failing tests are `test_a_value_the_validator_refuses_is_refused_before_anything_is_made[elected-by-tab]`, `test_a_dry_run_refuses_what_the_validator_refuses[elected-by-tab]` and `test_a_blank_that_is_also_a_line_break_is_refused_too[\x0b|\x1c|\u2028]`. Whichever of the two lands second applies a four-line patch to `tests/test_scaffold_manifest_values.py` (lane 5 holds it), which expects `scaffold-unwritable-value` for those four values. With it applied, the merged tree passes: see Evidence.

**Merge-tree** (`git merge-tree --write-tree HEAD <ref>`, fetched 2026-10-12):

| ref | tip | result |
|---|---|---|
| origin/main | c7c02fa, and 346079d (#228 landed since: adopt files only) | clean |
| PR #270 | 72bb869 | clean, one `unwritable_reason` |
| PR #314 | ab66bc0 | clean (5 of #314's tests need the patch above) |
| PR #309 (lane 4) | 2ea1717 | clean |
| PR #316 (lane 5, `render` single pass) | 9469148 | clean |
| PR #266 (lane 2) | b2b42f8 | clean |
| lane 1 `fix/adopt-record-submodule-placement` | 3e86200 | clean |
| lane 1 `fix/adopt-late-failure-notice` | 918e8f2 | clean |
| lane 1 `fix/adopt-root-kept-symlinks` | 6c44591 | CONFLICT in `adopt-project.py`, which this branch does not touch; the branch conflicts with origin/main alone the same way |
| lane 1 `fix/adopt-plan-quote-yaml-keywords` | e337f44 | the same: CONFLICT in `adopt-project.py`, also against origin/main alone |
| lane 5 siblings `fix/pin-source-env-collisions` c32a228, `fix/family-init-values` 14e2d68, `fix/yaml-flow-brackets-surrogates` 38738cf, `docs/adopt-execute-exits` d3f71d6 | | clean |
| lane 2 local `fix/scaffold-tracking-branch-ref-format` (#283) | a03abcf | clean |

**Evidence.**

- New `tests/test_scaffold_free_text_values.py`, 113 cases:
  - each of the 12 unwritable values through each flag, dry and real (72), under a guard that fails the test if any process starts;
  - all three bad values named in one refusal;
  - each value through `GIT_AUTHOR_NAME` (12), with no process started;
  - `git config user.name` with LF, U+202E, ESC and a byte that is not UTF-8, dry and real (8). The last is skipped on Windows, whose environment is UTF-16;
  - the flag winning over the fallbacks;
  - 15 dry runs that must not refuse;
  - one real scaffold of a backslash, a quote and U+200C, through `--name`, `--reference` and `GIT_AUTHOR_NAME` (the test does not use the `--elected-by` flag). It reads back exactly, and the README lines, each leg's `AGENTS.md` and the commit subjects stay single (the root `AGENTS.md` is not checked by this test);
  - a byte that is not UTF-8 through each flag as a child process's argv (3).
- That file on head: 113 passed. On c7c02fa: 97 failed, 16 passed. The 16 are the 15 dry runs and the real scaffold of text, which hold on main since #284 by design.
- Mutation probes (each reverts one part, in a throwaway worktree; every one makes at least two tests fail):
  - no refusal call: 97 failed;
  - strict decode of `git config user.name`: 2;
  - `_elector_named` always `--elected-by`: 20;
  - elector field dropped: 47;
  - `--reference` dropped: 26;
  - `--name` dropped: 26;
  - no line-break check: 39;
  - no surrogate branch: 13;
  - no bidi branch: 17;
  - no control branch: 30;
  - refusing every `Cf` (over-refusal): 4, the U+200C cases.
- Targeted on head 8f43c96 (`git-filter-repo` 2.47.0 on PATH): `tests/test_scaffold_free_text_values.py tests/test_scaffold_e2e.py tests/test_scaffold_pin_and_reuse.py tests/test_scaffold_leg_paths.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_pin_collisions.py tests/test_scaffold_referent_chain_links.py tests/test_scaffold_mount_ignored.py tests/test_scaffold_ignored_shape_files.py tests/test_template_quoted_values.py tests/test_naming_policy.py tests/test_reference_default.py tests/test_reuse_visibility.py tests/test_stdout_encoding.py tests/test_repo_hygiene.py tests/test_cli_reference.py`: 801 passed, 1 skipped (`test_stdout_encoding.py:286`, Windows' own default).
- Merged trees:
  - with #270, this file plus `test_adopt_template_values.py`: 191 passed;
  - with #314 plus the compose patch, this file plus `test_scaffold_manifest_values.py`, `test_scaffold_e2e.py`, `test_template_quoted_values.py` and `test_cli_reference.py`: 343 passed. Without the patch (on a5ef3f5, which differs from 8f43c96 only in an import's place and a docstring): 5 failed, 338 passed, the five above;
  - with #316, this file plus `test_template_quoted_values.py` and `test_template_values_single_pass.py`: 344 passed.

**Left out, with follow-up candidates.**

- **`adopt-project.py plan` dies the same way on a `git config user.name` that is not UTF-8.** With no `--elected-by`, it exits 1 with `UnicodeDecodeError` from `git_out` (c7c02fa, and with #270 merged). `_elector` in `adopt-project.py` reads it strictly. It is adopt's half and #270's ground, so it is not touched here.
- **A `--local-remote-dir` path that is not UTF-8.** The scaffold creates the three bare remotes, then raises in `write_lf` on `{{ASSEMBLY_CLONE_URL}}` in a leg's README (reproduced on this head). It is the test path only. #270 refuses the same for adopt's clone URL (`_refuse_an_unwritable_clone_url`). Here it is a separate, small issue.
- **A `GIT_AUTHOR_NAME` of whitespace alone.** The scaffold falls past it to `git config user.name` for the elector. `env_commit` then hands it to git unchanged, and git refuses to commit (`fatal: name consists only of disallowed characters`) after the remotes are created. Seen on c7c02fa while testing; not this issue's input.
- **Markdown syntax in the values** (`*`, `` ` ``, `[`, `\*`) renders as Markdown in the README and `AGENTS.md`. The files hold the typed text, and escaping it would print backslashes in a plain read, so it is not changed.
- No `--help` text changed, so `docs/cli.md` is not regenerated. The module docstring's first line, the help description, is unchanged.

**Review.** An adversarial review of 8f43c96 found 0 blockers, 0 should-fix and 5 nits (APPROVE WITH NITS):
- **#270 copy:** it re-checked the copied hunk byte for byte (same sha256) and found one definition in every merge, in either landing order.
- **Probes:** 63 values went through all five sources, dry and real; 320 cases were refused with no work directory, remote or traceback, and nothing was found landing broken.
- **Order with #314:** the #314 patch is complete (219 passed with it), and the order of the two refusals is sensible.
- **Nits handled in this body:** the "before any process" claim (N1) and wording (N5).
- **Nits left for a fix round:**
  - three mutants of this branch's copy of `unwritable_reason` that only #270's unit table catches (N2);
  - the argv child-process test with no Windows skip (N3), which Windows CI then passed;
  - naming the elector's fallback the same way as #314 on the merged tree (N4).

Reviewed adversarially: no blocker or should-fix, five nits (two corrected in this message's text, one settled by Windows CI, two left as small follow-ups). CI is green on Linux, macOS and Windows, with SonarCloud passing, at e5804e0 (8f43c96 merged with main ab623dd); main's later #245 (161c6c9) touches none of this branch's files.

Closes #282

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 12, 2026
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py

origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the
coordinator asked for e43fa95 or later:
- c7c02fa (#299 via #315), scripts/family.py;
- 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up
  `git grep`;
- 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render`
  substitutes in one pass;
- 86da808 (#253 via #264), scripts/bump-leg.py;
- 6f4ce47 (#302 via #318), scripts/family.py;
- 7491530 (#288 via #309), AGENTS.md, README, docs and
  scaffold-project.py's --help;
- 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default
  identity as `-c` config;
- 87373a6 (#306 via #311), shape-doctor.py;
- 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py;
- c1b6732 (#239 via #269), update-shape.py;
- e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned
  with `--template=`, and the verification refuses a mount whose gitlink
  is not the commit pushed to its leg (`adopt-mount-commit`);
- a1b3a4b (#321 via #335), tests/test_git_output_decoding.py;
- 8cdca5e (#295 via #312), the template's scripts/validate-pins.py.

`git merge-tree` and this merge give the same tree, 07e6b49.
adopt-project.py, scripts/shape_materialize.py and
tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these
main's 346079d and this branch's 86a5e30 change different cases.

Checked on the merged tree:
- AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Every sentence 86a5e30 changed is still there: in `_through`, in
  `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's
  `adopt-mount-commit` leaves as it was), and in the three test files.
- `_commit_the_split` now clones the assembly with `--template=`. The
  next commit, F3, reads what that clone holds in its own `.git`, which
  is why F3 comes after this merge.

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 12, 2026
…not extract as it is named, with the reason and `root` or `drop` as its only answers, so `-notes.md` is no longer planned `leg: spec` for `check` to refuse, and #166's split question says the same of such an entry (#319) (#377)

Since #196 `check` and `execute` ask one test of an entry's path,
`entry_path_problem`, and `plan` asked it nothing. A source holding
`-notes.md`, `notes\t.md`, `a\rb.md`, `a\nb.md`, `a==>b.md` or a name that
is not UTF-8 was planned `leg: spec`, `review_required: false`, with no
question; `check` then refused that answer as `plan-unsafe-path`, and
`execute` refused it before any leg. `Icon\r` and `   `, which no rule
places, were asked the policy's question, which offers `spec` and `code`.
#166's split question named such a path as an entry to write, with any
`leg:`.

- `ask_about_refused_paths` runs after `ask_about_submodules`. Each entry
  the test refuses that is not answered `root` is written `leg: null`,
  `review_required: true`, confidence `review`, the classifier's rule and
  reason kept. Its `question:` says where the rules put it, or keeps the
  policy's question first; then that `execute` will not extract it into a
  leg as it is named, with `entry_path_problem`'s reason; that `root` or
  `drop` is the only answer `execute` takes; and that renaming it in the
  source and re-running `plan` is the way into a leg.
- An entry that also holds a submodule is told `drop` is its only answer,
  because `root` is `plan-submodule-root`.
- `_asked_about`'s split sentence names each replacement entry the test
  refuses, with its reason, and says `root` or `drop` (`drop` for a
  submodule). Past SPLIT_ENTRIES_NAMED of them it counts the rest.
- What the rules imply (follow-ups, predicted collisions, the `seeding:`
  record) is read with such an entry asked, because no answer puts it in
  a leg. A leg only that path fed is recorded as seeded, with the
  `--allow-empty-leg` warning, which matches what `check` says of the plan
  as written and once it is answered.
- `_asked_about` sets its question as written. `Verdict` collapsed each run
  of spaces, so `src/a  b.md` was named `src/a b.md`, an entry that covers
  nothing.

Unchanged: `check` and `execute` (no line of either is touched);
`shape-doctor.py`'s placement plan (it calls `walk` only); `--help`. The
plan file and `plan`'s report are byte-identical on main and here for the
synthetic tree, a source with a registered submodule, and one holding
admitted names (`My Notes.md`, `#notes.md`, `[ID].tsx`, ` lead.md`),
diffed.

Changed with it: #228's two tests of a spec directory that begins with
`-` (`-notes/`, and `-Otouch pager-ran;/`, named like `git grep`'s pager
option) read `leg: spec` and the follow-up from the plan. `plan` now asks
about such a directory and writes no follow-up for it, because no answer
`execute` takes moves it to the spec leg. Each test now asserts the
plan's question and that the plan names no file for it, and still asks
#228's literal `git grep`, of the rules' own answer `spec` through
`cross_leg_references`: the same follow-up, and nothing run. Every other
assertion is kept, the `leg: spec` one as the rules' answer.

Left out: an entry the rules answer `root` whose path the test refuses
stays `root` with no note, because `check` and `execute` accept it.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…\udcNN` and a character as `\xNN`, as the plan's own `path:` does, so the byte 0x85 and the character U+0085 no longer both read `\x85`, and 34 pairs of different names no longer read as one in a question or a finding (#329) (#378)

`_spelled_character` wrote a lone surrogate U+DC80 to U+DCFF, which is how
`Source.tree` keeps a byte that is not UTF-8, as `\xNN`, and any other
character that is not printable and lies below U+0100 as `\xNN` too. The 32
C1 controls U+0080 to U+009F, U+00A0 and U+00AD therefore read exactly as the
bytes 0x80 to 0x9F, 0xA0 and 0xAD: 34 pairs of different names. In YAML `\x85`
is the character, so a person who copied the byte's spelling out of a finding
into the plan wrote the character's entry; the plan's own writer spells the
byte `\udc85` (#199, #247).

Now a character that is not printable is spelled by `repo_shape.double_quoted`,
the one table of escapes the plan's writer uses and its reader resolves, and
no table of this file's own: a finding and the written plan spell one name the
same way, and a byte and a character never share an escape because each escape
names a code point.

- The byte 0x85 is `\udc85`; the character U+0085 is `\x85`. Every byte
  0x80 to 0xFF is `\udcNN`, the 94 whose character prints as itself included,
  because `\xe9` for the byte 0xE9 was the character U+00E9 in the plan.
- A backslash and a backtick are escaped with a backslash, as before: the
  backticks delimit the name. A line feed, carriage return and tab are
  `\n`, `\r` and `\t`; any other character below U+0100 is `\xNN`, below
  U+10000 `\uNNNN`, above it `\UNNNNNNNN`, as the plan writes them.
- The messages #255 added name a value, not a path, with `repr`, which
  escapes by code point already: a byte is `\udcNN` and a character `\xNN`
  there too. `repr` and `_spelled` agree on every character but the backtick,
  which only `_spelled` escapes, so no message of either spells a byte as
  `\xNN` and no table is added for them.
- Tests that pinned a byte as `\xNN` (the policy refusal, `plan-unsafe-path`,
  the mount refusal, a `.gitmodules` value, a submodule question, the source
  directory in `plan`'s report, a hand-edited `code_path` that `check` prints
  under WHAT WILL HAPPEN (#255), and the follow-up `git grep` names #228 spells
  through the same function) pin `\udcNN` now.
- `tests/test_adopt_spelled_unambiguous.py` is new: the 34 pairs one by one,
  every byte and every code point of the BMP spelled apart, the spelling equal
  to `double_quoted`'s and read back by the plan's reader as the same name, a
  finding and a question naming a byte and its character differently, the
  messages of #255 doing the same, and `repr` and `_spelled` agreeing, and
  `plan` and `check` run on a source holding both.

Unchanged, diffed over all 1,114,112 code points: only the 128 characters
U+DC80 to U+DCFF are spelled differently; every other character, a backslash,
a backtick and the whole of `_spelled`'s framing are byte for byte what they
were. A spelling is only printed: in a finding, a refusal or the report, and
in a question the plan carries as prose that nothing parses. Of the 28
functions that name `_spelled`, `_spelled_character` or `_shown`, one runs
git, `cross_leg_references`'s `git grep`, and it is given the source's own
directory name, never the spelling, which only the follow-up's text holds; none
opens a file or builds a command line from one. Nothing here changes what git
is asked.

Left out: a `\` + backtick in a finding is still the finding's own delimiter
escape and is not a YAML escape, so a name holding a backtick is not
copy-and-paste into the plan as it stands.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment