Repository navigation
adopt-project.py: check and execute spell every path a FINDING or a refusal names as plan's questions spell it, so a source name holding a carriage return, a line feed, U+0085, U+2028 or an ESC no longer overwrites, splits or escapes the line it is printed on (#212) - #223
Conversation
… a refusal names as `plan`'s questions spell it, so a source name holding a carriage return, a line feed, U+0085, U+2028 or an ESC no longer overwrites, splits or escapes the line it is printed on (#212) `plan` already writes every path a question names through `_spelled`: the path between backticks, with each character `str.isprintable` refuses written as a backslash escape (`\r`, `\n`, `\x85`, `\u2028`, `\x1b`), and a backslash or a backtick escaped too, so the spelling reads one way only. The findings were not given it. On main a5b09c3 `check` printed `FINDING plan-uncovered: Icon<CR> is in the source tree ...` with the raw carriage return, which a terminal turns into an overwrite of the code and the path. A hand-written entry `"README\nX.md"` came out as one finding on two physical lines, the second not starting with `FINDING`. `execute` refused with the same raw lines, "as `check` prints them". Every path a finding or a refusal names now goes through `_spelled`: - `_coverage_findings`: `plan-duplicate-path`, `plan-uncovered`, `plan-covered-twice` and `plan-empty-entry`. The entries that cover a path twice are listed with `_listed` (`` `src/`, `src/app/` ``) where main printed a Python list. - `_leg_findings`: `plan-unresolved` and `plan-bad-leg`. - `_as_entry`, which names the submodule and its entry in `plan-submodule-root`, `plan-submodule-split` and `plan-submodule-unregistered`. - `_kept_collisions` and `_first_and_more`, behind `plan-leg-path-collides` and `plan`'s collision note. Both already put a path between backticks and now use `_spelled`. - `_refuse_an_unrunnable_plan`: `execute`'s own `plan-unresolved` refusal lists its paths with `_listed`. - `_account_for`: `adopt-duplicated` and `adopt-lost`, the verification `execute` prints after the split. - `_missing_shape_file` and `_SplitTree`: `adopt-shape-file-missing` spells the pinned path, the symlink a lookup met, the target it leads out to, and a path that is not a directory. - `_entry_named`: `adopt-mount-occupied`, the guard #174 put before `git submodule add --force`, names the index entry it found under the leg mount path. It put that name between backticks by hand and now uses `_spelled`. The mount path beside it is a leg path, which `checked_value`'s alphabet already keeps printable. `execute`'s refusal is `_entry_findings`, the same list `check` prints, so both commands change together. Unchanged: a path with no backslash, backtick or unprintable character is spelled with backticks around it and nothing else. The coverage, leg, submodule and verification findings now show those backticks, as questions already did; `adopt-mount-occupied` and the collision findings showed them already. `plan`'s exit code, stdout, stderr and plan file, and `check`'s exit code, stdout and stderr, are byte-identical to main a5b09c3 on the test suite's synthetic source, for the default plan, a `plan-leg-path-collides` plan (`--spec-path .specify`), `--spec-path docs/architecture.md`, and the refused `--code-path Makefile` and `--code-path .github`. Left out: the `question:` text that `plan-unresolved` repeats is printed as the plan holds it, so a hand-edited question with a line break still splits that line. Paths a sibling branch's new finding names (`plan-root-symlink`, `adopt-unexpected-gitlink`) are for whichever lands second. A last-line guard in `cmd_check` that escapes any character a finding should never hold is a follow-up. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 2 days and 23 hours by commenting |
|
(lane openRepoShape-3, 2026-10-11T18:58Z) @codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Reviewer's GuideThis PR makes every source path named by check findings and execute refusals use the same escaped, backtick-delimited spelling as plan questions, preventing control characters from splitting, overwriting, or escaping diagnostic lines. It centralizes the behavior through Sequence diagram for safely spelling check findingssequenceDiagram
participant Source as Source tree and plan
participant Check as check
participant Findings as Finding builders
participant Spelling as _spelled and _listed
participant Stderr as Diagnostic output
Source->>Check: _coverage_findings() / _leg_findings()
Check->>Findings: Build finding for source path
Findings->>Spelling: _spelled(path)
Spelling-->>Findings: Backtick-delimited escaped path
Findings->>Spelling: _listed(paths)
Spelling-->>Findings: Safely listed escaped paths
Findings-->>Check: One-line FINDING text
Check->>Stderr: Print printable diagnostic
Sequence diagram for execute refusal detailssequenceDiagram
participant User as User
participant Execute as execute
participant Plan as Plan validation
participant Check as check findings
participant Spelling as _listed
participant Stderr as Refusal output
User->>Execute: execute --yes
Execute->>Plan: _refuse_an_unrunnable_plan()
Plan->>Spelling: _listed(unresolved paths)
Spelling-->>Plan: Escaped path list
Plan-->>Execute: Refusal detail
Execute->>Check: _refuse_what_check_finds()
Check-->>Execute: Existing spelled findings
Execute->>Stderr: Print REFUSED detail and FINDING lines
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
#173 landed on main as 54aa69a: verification passes over the two leg mounts and no other gitlink. The textual merge is clean. The next commit spells its two new findings and updates the assertions this branch's spelling changes. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-gitlink` spell the gitlink they name, as every other finding does since this branch, so a source submodule path holding a line break or an ESC no longer splits or escapes the verification line (#212) #173 (54aa69a) added two findings to `_land_the_root_tree`, through `_root_gitlink_finding`, that printed the gitlink's path raw. That path is a source or split name, so it now goes through `_spelled`: - `adopt-root-submodule`: the source's own submodule at its own commit, left in the root. - `adopt-unexpected-gitlink`: any other gitlink in the root. The leg mount paths these findings and `adopt-mount-missing` name are left as the plan spells them: a leg path passes `checked_value`'s alphabet, which keeps it printable. Assertions updated to the spelled text, all of them on findings this branch spells: - tests/test_adopt_verify.py: `root_submodule()`, `adopt-lost`, `adopt-duplicated`, both `adopt-unexpected-gitlink` assertions and `plan-submodule-root`. - tests/test_adopt_submodules.py: `adopt-root-submodule` and `adopt-lost` in `test_the_root_that_mount_leaves_never_verifies`. tests/test_adopt_finding_spelling.py gains a case for each of the two findings over the five characters (10 cases), through `_land_the_root_tree`. All 10 fail without the change. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-8 and spells it `\xNN`, and `plan-stale` spells the branch it names, so neither turns two names into one or lets a line separator in a branch name split its line (#212) Two of rv-adopt-a's nits on #223 (58b4343): - `_index_entries` decoded index names with `replace`, so `adopt-mount-occupied` printed `spec/a<0xFE>` and `spec/a<0xFF>` alike as `a<U+FFFD>`, where `plan-uncovered` spells the same byte `\xfe`. It now decodes with `surrogateescape`, as `Source.tree` and `_tree_of` do, and `_spelled` writes the byte. The `casefold()` comparisons in `_refusal_for_held` and `_refuse_an_unmountable_path` treat a surrogate as itself. - `plan-stale`, in `check` and in `execute`'s refusal, printed the source's branch raw. git's ref-name rules refuse only ASCII control characters, so a branch named with U+0085 or U+2028 split the line. It is now `_spelled`, so an ordinary branch shows as `main` between backticks. tests/test_adopt_finding_spelling.py: the two non-UTF-8 index names through `_index_entries` and `_refuse_an_unmountable_path`, built with `git update-index --index-info`; `execute`'s `plan-stale` over the five characters; `check`'s `plan-stale` on a real branch named with U+0085 and with U+2028, the two git lets into a ref name. All 8 fail without this change. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-11T19:32Z) fix round 1: merged main 54aa69a (#173); its findings adopt-root-submodule and adopt-unexpected-gitlink are spelled, 4 #173 assertions updated (and 4 more of its assertions on those two findings, now spelled). rv-adopt-a's three nits are folded in: adopt-mount-occupied keeps a byte that is not UTF-8 (_index_entries decodes with surrogateescape) and spells it as every other finding does; plan-stale spells the branch it names; the PR body corrects the first commit's "reads one way only" claim (a raw byte and the character of the same number share |
|
(lane openRepoShape-5, 2026-10-11T21:00Z) Composition note for lane openRepoShape-3, for this PR (head d41c4b3) and #199's PR #247 (head a02240a). The merge is clean, but #199's Whichever of #223 and #247 lands second edits that one assertion. The N7 comment above it says #212's fix "edits this line", but #223's base has no such file, so the second lander should update the comment too. Evidence: |
… printable from the one table `repo_shape`'s reader resolves, so a source holding the macOS `Icon\r` no longer writes a plan `check` refuses, and a non-UTF-8 name no longer empties the plan (#199) (#247) The adoption plan's writer and its reader now share **one escape table**, so a plan `adopt-project.py plan` writes is always a plan `check` parses, whatever characters a source name holds. - `scripts/repo_shape.py`: `DOUBLE_QUOTED_ESCAPES` gains `r`; a new `CODE_POINT_ESCAPES` (`x`: 2, `u`: 4, `U`: 8 hex digits) adds YAML's own code-point escapes; `double_quoted(text)` is the writer's half of both. It writes `\\`, `\"`, `\n`, `\r`, `\t` by name, and every other character `str.isprintable()` refuses in the shortest of `\xNN` / `\uNNNN` / `\UNNNNNNNN` that holds it. `_unescape` stays one pass, left to right; it resolves exactly what the writer writes (a code-point escape is exactly its count of ASCII hex digits, nothing past U+10FFFF), and any other escape keeps its backslash, as `\q` always has. - `adopt-project.py`: `y()` returns `double_quoted(text)` for whatever it does not write plain. The plain decision (the `if` line #181 changes) is untouched. `_PLAIN_RE` now ends at `\Z` rather than `$`, because `$` also matches before a final newline and so wrote `Icon\n` bare. The plan's terminal report prints a name that is not printable as `_spelled` (new `_shown`). Raw, a carriage return hid the name, and a byte that is not UTF-8 raised `UnicodeEncodeError` on a strict terminal (macOS, a UTF-8 Linux locale, a Windows code page) after the plan was written. Printable names print exactly as before. `_shown` also spells a local `--source` directory, in `plan`'s report and in `check`'s `WHAT WILL HAPPEN, against ...` line. - `scripts/shape_materialize.py`: new `write_paths_listing`, which `_extract_leg` writes `git filter-repo --paths-from-file`'s listing through. It is byte-exact (`utf-8` with `surrogateescape`, LF): a path that is not UTF-8 is the byte git records, where `write_lf`'s strict UTF-8 raised after both leg remotes existed. A UTF-8 listing is byte-identical to what `write_lf` wrote. - `tests/test_adopt_plan_escapes.py` (new, 59 tests). Fix round 1 answered an adversarial review of d3023fb: the question list that ends `plan`'s report is tested (S1), `_extract_leg` writes filter-repo's path listing byte-exact so a path that is not UTF-8 no longer raises after both legs exist (S2), and `plan` and `check` spell a `--source` directory that is not UTF-8 instead of raising on a strict stdout (S3), with the review's comment and test nits. The branch carries main 54aa69a. Reviewed adversarially, fix round re-verified (VERIFIED WITH NITS, 10 of 10 items). CI is green on Linux, macOS and Windows. Composes with #181 (rename one of the two `_shown` helpers), #212/#223 (edit the raw-`\r` assertion) and #196/#231 (one hunk) as the PR body describes. Closes #199 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng-paths main moved to 4835fb4: #224 (e7d1720, update-shape.py), #244 (404760f, repo_shape.py `_split_flow`) and #247 (4835fb4, the plan writer escapes every character that is not printable). The textual merge is clean. The next commit takes #247's one assertion that this branch's spelling changes. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…est expects the spelled FINDING `Icon\r`, as every path a FINDING names is since this branch #247 (4835fb4) landed first. Its test_plan_writes_the_name_as_one_entry_check_parses[carriage-return] asserted that `check`'s `plan-unresolved` FINDING names the macOS `Icon<CR>` raw, and its comment said #212's fix "edits this line". On the merge of main into this branch the case fails (1 failed, 58 passed in tests/test_adopt_plan_escapes.py), because `_leg_findings` now writes the path through `_spelled`. Whichever of the two PRs landed second had to take the assertion; this is the second, so the assertion now expects ``FINDING plan-unresolved: `Icon\r` still has `leg: null` `` with a backslash and an `r`, and the comment says what is on main: the FINDING is spelled, as `plan` spells a question, and before #212 it was raw. No other assertion of the file changes; the file passes 59 of 59. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-11T21:49Z) fix round 2: merged main 4835fb4 (#224, #244, #247); #247's carriage-return assertion and its N7 comment take this PR's spelling, as lane 5's composition note asked. The merge is textually clean. On it, tests/test_adopt_plan_escapes.py failed exactly one case, test_plan_writes_the_name_as_one_entry_check_parses[carriage-return] (1 failed, 58 passed), because the assertion pinned the raw Icon in check's plan-unresolved FINDING and this PR spells it. #247 landed first, so this PR edits it: the assertion now expects the spelled name, a backslash and an r between backticks, in place of the raw carriage return, and the comment says what is on main. No other assertion of #247's file changed (59 of 59 pass). No code of this PR changed this round. Targeted set on 941e096: tests/test_adopt_*.py (12 files, #247's included), test_yaml_flow_quotes.py, test_update_shape.py, test_shape_doctor.py, test_repo_hygiene.py and test_cli_reference.py in one process, 1299 passed, 1 skipped (SHAPE_MEDXEHR_CLONE), exit 0. The PR body's Tests and Composes-with sections are updated: #196 re-measured on a merged tree (7 failed, 351 passed, the same 7 cases), and #197 is still the one sibling whose conflict this change causes. |
|
(lane openRepoShape-4, 2026-10-11T22:05:55Z) Landing note: our #234 (#197) landed as b538015, and main is now 39ac59c. It moved The merge is not mechanical. This PR spells The resolution we checked:
Your other two spellings in On 39ac59c + 941e096 with the three steps, we ran your six adopt test files plus |
…ng-paths main moved to 39ac59c: #197 (b538015, PR #234) moved `_overlap` and `_first_and_more` into scripts/shape_materialize.py as `path_overlap` and `first_and_more`, and adopt-project.py imports them back under the old names; 150c41d and 39ac59c (PRs #233 and #235) touch update-shape.py and scaffold-project.py. One conflict, in adopt-project.py: this branch had edited `_first_and_more` to `_spelled` its path, and main removed the function. Resolved by taking main's side of that one hunk: `_overlap` and `_first_and_more` are deleted from adopt-project.py, and main's import of the shared definitions stays as it is. No line is written by hand in this merge. It leaves `_kept_collisions` naming a source path through `first_and_more`, unspelled, so the spelling case of tests/test_adopt_finding_spelling.py fails on this commit alone; the next commit gives `first_and_more` an optional `spell` and passes `_spelled`. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…kept_collisions` names a source path spelled, and the scaffold's messages are byte-identical to main's #197 (b538015) moved `first_and_more` to scripts/shape_materialize.py, where `scaffold-project.py` calls it too, and adopt-project.py imports it back as `_first_and_more` (`tests/test_scaffold_leg_path_collisions.py` asserts it is that very function). It wrote the path it names between backticks as it is. `_kept_collisions` names a SOURCE path the plan keeps in the root, which can hold a carriage return, a line break or an ESC, so `plan-leg-path-collides` and `plan`'s collision note printed it raw again. `first_and_more(paths, spell=None)`: with no `spell`, the first path is written `` `path` `` exactly as before, so every caller but one, and the scaffold's collision refusals, print what main prints. `_kept_collisions` passes `_spelled`, this branch's speller, which puts its own backticks and escapes. Lane 4's landing note on the PR gave this mechanism. Two places change: the signature and body of `first_and_more`, and `_kept_collisions`' one call. Nothing else in scripts/shape_materialize.py or scaffold-project.py differs from main. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-11T23:07Z) fix round 3: merged main 39ac59c (#197 as #234, #233, #235). One conflict, in adopt-project.py: this PR's edited |
|
…aths changed take that spelling, so `plan-uncovered` is expected as `` `specs/... ``, and the `-dep` submodule case asks `entry_path_problem` about the raw path The merge of main before this commit brings #212's PR #223, which spells every path a FINDING names as `_spelled` spells it, in backticks. Seven of this branch's test cases asserted the bare spelling and failed on the merge: - `test_check_reports_the_path_first_and_says_what_to_do`, five cases (`control-character`, `empty`, `leading-dash`, `rename-arrow`, `whitespace-only`), and `test_a_path_that_is_a_git_option_is_still_unsafe_not_uncovered` now expect ``FINDING plan-uncovered: `specs/``; - `test_an_unregistered_submodule_is_told_why_git_ignores_its_entry [path-option-like]`: #223 spells the case's `submodule` parameter `` `-dep` ``, so the test takes the entry's own path, `-dep`, from the case's `orphan` for `startswith("-")` and `entry_path_problem`, and names the finding by the spelled `submodule`. No code changes. `scripts/shape_materialize.py` composes with #223's `first_and_more(paths, spell=None)`: this branch touches only `env_commit` there. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #252 via #257, #195 via #226, #210 via #219, #211 via #220, #248 via #265, #212 via #223) into fix/adopt-record-submodule-placement Brings in 06b5204 (#214), 60d08a1 (#217), 2727b08 (#209), 4b2add8 (#252), aaa40eb (#195), d89fffa (#210), eb6df75 (#211), c3f4987 (#248) and 5751460 (#212) on top of 39ac59c. No rebase. 5751460 landed while the merge of c3f4987 was being checked, and it changes adopt-project.py, scripts/shape_materialize.py and tests/test_adopt_submodules.py, as this branch does, so this one merge takes it too. No conflicts. Both sides change adopt-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, shape-doctor.py, tests/test_adopt_submodules.py and tests/test_shape_doctor.py. Every top-level definition only one side changed is that side's text in the merge, byte for byte. The one thing both changed is shape_materialize.py's imports, and both are kept: main's `codecs` and this branch's `path_spelling_fault` and `PATH_*` codes. Checked: - adopt-project.py: main calls `escape_what_stdout_cannot_encode()` first in `main` (#214). It makes a `.gitmodules` git's submodule reader cannot read a finding where the tip holds no submodule and the history does, or where `ignore`, `update` or `branch` has no value (#195). And every path a finding names is now `_spelled` (#212). This branch's `submodule_record`, `_resolution_text` and the record's preamble are unchanged by the merge, and so are `_answered_legs`, `_kept_in_a_leg`, `_commit_the_split`, `_refuse_what_check_finds`, `cmd_execute` and `Plan`. The record and #195's finding read the kept submodules the same way, `_kept_in_a_leg(_answered_legs(...))` over the tip's gitlinks. `execute` refuses #195's finding in `_refuse_what_check_finds`, before any leg exists and before `_commit_the_split` writes the record. So a source #195 refuses gets no record, and so does a source whose tip holds no submodule. - scripts/repo_shape.py: main refuses an explicit key in a flow mapping and reads an empty flow-mapping value as null (#252). A block scalar's record is now a (key, body) pair, and `_BLOCK_SENTINEL` is gone (#248); nothing on this branch names it. The record is a block sequence of double-quoted scalars, and it reads back through the merged reader with 0 mismatches (`submodule_record`, then `parse_yaml`, path and resolution): - 4,525 names, plus 35 more; - 240 aimed at #252 and #248 (`? a`, `{a: }`, `|`, `a: |`, a NUL, the old sentinel). - scripts/shape_materialize.py: main adds `escape_what_stdout_cannot_encode`, `run()` reading UTF-8 (#217), and `first_and_more(paths, spell)` (#212). This branch's `LEG_SEGMENT_REASONS` and `_spelling_reason` are untouched. - shape-doctor.py: main adds the `manifest kinds` finding for a `family.yaml` beside `project.yaml` (#211), and the stdout escaping. This branch's placement row and `recorded_submodules` are untouched. - tests: #212 respells the finding text that the earlier tests in test_adopt_submodules.py assert. None of this branch's tests quotes a finding. - 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 or update-shape.py. Each merged module's names are the base's, plus what either side added, minus what either side removed. No side added a name the other side also added, in the tests either. - AGENTS.md is untouched by main: 472 lines, under the cap of 472. - The branch's diff against main is still #189 alone: 12 files, +1670 -156, file by file the same as against 39ac59c. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test, `entry_path_problem`, so a source holding `My Notes.md` no longer checks `plan ok` and is then refused by `execute` as `unsafe-value`, and a path `git filter-repo` would misread, or one refused as policy, is a `check` finding that says which, refused before any leg exists (#196) (#231) On main at 4835fb4 `check` asked nothing of an entry's path, and `execute` asked `checked_value`, whose alphabet (`SAFE_ARG_RE`) is meant for the values that are options. A source holding `My Notes.md`, `#notes.md` or `[ID].tsx` checked `plan ok`, and `execute` refused the same plan as `unsafe-value`. So did #166's split instruction for a `docs/User Guide.md` beside a `docs/theme` submodule. DECISION: accept what a source tree holds. Refuse what git or git filter-repo would misread, or what this tool cannot write into the path list. Refuse as policy a control character, a byte that is not UTF-8, a leading `-` and a name that is nothing but whitespace. An entry's path is a fact of the source, not an option: `plan` writes it from the tree, and every command is argv with `shell=False`. The issue's other choice was to state the restriction in the question. That leaves a top-level `My Notes.md` with a rename in the source as its only remedy. The policy refusals are names git and filter-repo take literally, measured, and each finding says it is policy. A byte that is not UTF-8 is written byte-exact since #247, and is held until adopting such a name end to end is measured on macOS and Windows. WHAT CHANGED - `entry_path_problem(path)`, ONE test, is `_misread(path) or _refused_by_policy(path)`, so a name with a reason of each kind is told the misreading. - `check` reports what it finds as the new finding `plan-unsafe-path`: "`<path>` has leg: <leg>, and `execute` will not extract it into a leg: <reason>". - It is listed FIRST in `_entry_findings`, so a path that is a `git` option is refused as that and not only as the uncovered file it also leaves. Its remediation is said once. - `execute` refuses the same finding in `check`'s words, through `_refuse_what_check_finds`, before any leg exists. - An entry answered `root` or `drop` is not judged: it is never a line of the filter's list. - `_misread` is what git, filter-repo, or this tool's write of the path list would not carry as it stands. Each case is measured with git-filter-repo 2.47.0, whose `get_paths_from_file` strips `\r` and `\n` from each line's end and looks for `==>` first: - an empty path: a `literal:` line alone kept every path in the leg; - a line feed anywhere: the rest of the name is read as a line of its own, so `docs\nregex:.*` kept every path; - a carriage return at the END: it is stripped, so `Icon\r`, the macOS folder-icon file, extracted `Icon`, another file. A name of carriage returns alone leaves `literal:` alone, which kept every path; - a NUL, which no command line carries; - a surrogate outside U+DC80 to U+DCFF, such as a hand-edited `\ud800`: no source path holds one, and `write_paths_listing` raised `UnicodeEncodeError` on it; - `==>`, which filter-repo reads as a rename anywhere on a line. - `_refused_by_policy` covers names git and filter-repo take literally. Samples of each kind were measured: a tab, `\x01`, ESC, DEL, `\x85`, `\x0b`, `\x0c`, `\x1c`, U+2028, a carriage return inside a name, `-n.md`, and one and three spaces. Each, as one `literal:` line and as a literal `git rm`, extracted and removed exactly that path: - a name that is nothing but whitespace; - a control character (C0, DEL, C1), or U+2028 or U+2029, a carriage return that does not end the name included; - a byte that is not UTF-8, which `Source.tree` keeps as one of U+DC80 to U+DCFF (`NOT_UTF8_BYTES`). With the refusal disabled, a source holding `caf\xe9.md` was extracted and verified on Linux, the byte in the leg, and the split message listed `caf?.md`; - a leading `-`, the threat model's first rule. - `_leg_paths` collects the paths without `checked_value`. A leg's mount path, the branch, the repository names and the organisation are options, and stay under `checked_value` as `unsafe-value`. - `_extract_leg` writes each `--paths-from-file` line as `literal:<path>`, through #247's byte-exact `write_paths_listing`. Written bare, `#notes.md` is a comment, and `glob:y.md`, `regex:x.md` and `literal:z.md` are directives. - `_mount_the_legs` runs `git --literal-pathspecs rm -r -q -- <path>`. After `--` a path is still a pathspec: `[ID].tsx` also removed `D.tsx`, and `:!keep.md` removed every path but `keep.md`. - `shape_materialize.env_commit` hands `git commit -F -` the message as UTF-8 (`encoding="utf-8", errors="replace"`). - The split message names every entry path. Under a cp1252 or POSIX `C` locale without UTF-8 mode, `文書.md` raised `UnicodeEncodeError` after both legs were pushed. - The change reaches every message `env_commit` writes: scaffold-project.py's assembly commit, scripts/family.py's holder commit, and `git_init_commit`'s first commits. - `errors="replace"` also applies when encoding. A `root` or `drop` entry holding a byte that is not UTF-8 is listed in the split message with a `?` in its place (`caf?.md`), where a strict encode would have raised. - The module docstring's `check` paragraph names the entry-path test. MEASURED on this machine (git 2.43.0, git-filter-repo 2.47.0). Each source was planned, its unanswered entries answered `root`, then `check` and `execute` were run with local remotes. Main 4835fb4 (a5b09c3 printed the same, but `plan` raised there on `caf\xe9.md`, which is #199) -> this change: - `My Notes.md`, `docs/User Guide.md`, `Übersicht.md`: check 0 `plan ok`, execute 2 `unsafe-value` -> check 0, execute 0 `adoption verified`, all three in the spec leg. - `#notes.md`: check 0, execute 2 `unsafe-value` -> execute 0, in the spec leg. - `[ID].tsx` (code) beside `D.tsx` (root): check 0, execute 2 `unsafe-value` -> execute 0. `[ID].tsx` is in the code leg, and `D.tsx` is still in the root. - `-notes.md`, `notes\t.md`, `caf\xe9.md` (byte 0xE9): check 0 `plan ok`, execute 2 `unsafe-value` -> check 1 `FINDING plan-unsafe-path`, which says the refusal is policy; execute 2 `REFUSED plan-unsafe-path`; no leg remote created. - `a==>b.md`: the same exits, with the finding naming the rename misreading. - Before #247, merged with #199's first head (d566e12) and without the rule, `caf\xe9.md` checked `plan ok`, and `execute` raised `UnicodeEncodeError` in `_extract_leg` after both leg remotes existed. - The set of refused names is the same as at fix round 1, compared on 5,679,428 names. UNCHANGED: - What `plan` writes and prints. - What `check` prints for a plan none of whose entry paths it refuses (byte-identical to main for three sources). - Every option value's `unsafe-value` refusal. TESTS: - tests/test_adopt_entry_paths.py: - the admitted names, and the refused ones as MISREAD and POLICY, each asserting its own words. A policy finding names no misreading; - `check` and `execute` on each hand-edit class, on a control character in the source, and on a byte that is not UTF-8 put straight into git's index; - `root`/`drop` not judged; - the split message under a foreign locale; - an end-to-end adoption of every admitted kind of name. It skips without git filter-repo, as on the Windows job. - The assertions in test_adopt_execute_refusals.py, test_adopt_e2e.py and test_adopt_submodules.py that expected `unsafe-value` for an entry path now expect `plan-unsafe-path`, with every path a finding names spelled as #212's change (PR #223) spells it. LEFT OUT: - Adopting a name that is not UTF-8 end to end: lifting the policy refusal, and measuring macOS and Windows (#247's listing is on main). - `plan` asking `entry_path_problem` of each entry it writes, and offering `root` or `drop` where it refuses. - `cross_leg_references` handing entry paths to `git grep` (#216). Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #209, #252, #195, #210, #211, #248, #212, #251 and #206 merge with one hunk, the materializer import list, kept on both sides, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings origin/main moved 39ac59c -> 60588d5: 06b5204 (#214 via #246), 60d08a1 (#217 via #230), 2727b08 (#209 via #218), 4b2add8 (#252 via #257), aaa40eb (#195 via #226), d89fffa (#210 via #219), eb6df75 (#211 via #220), c3f4987 (#248 via #265), 5751460 (#212 via #223), 23e16ae (#251 via #271) and 60588d5 (#206; its subject names no pull request). Conflict, resolved by keeping both sides: - adopt-project.py, the `shape_materialize` import list. This branch adds BOOTSTRAP, SYMLINK_HOPS and template_files; #214 adds escape_what_stdout_cannot_encode. The resolution is the union. Auto-merged: scripts/shape_materialize.py, where #214's escaping, #217's UTF-8 `run()` and #212's `first_and_more(paths, spell=None)` sit apart from this branch's #183 section, and adopt-project.py's other hunks. #195 adds `import os` at the line where this branch added it, and the merge keeps it once. Checked on the merged tree: - `_entry_findings` still calls `_leg_findings(plan, tree)` and ends with `_root_symlink_findings(plan, source, tree)`. `main` still calls `escape_what_stdout_cannot_encode()` first. - 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 (AST). `first_and_more` gives its new `spell` a default, so this branch's call is unchanged. - Both sides are intact by +/- line sets: each side's change against the base reappears against the other side, apart from the import hunk and the shared `import os`. - #212 says this branch's `plan-root-symlink` names its paths raw, and that whichever pull request lands second should spell them. That was 04792d2. Since d11cf9f every path the finding names goes through `_spelled`. One clash the textual merge does not show is fixed in the next commit: #217's tests/test_git_output_decoding.py refuses a shipped text-mode `subprocess` call that names no codec, and this branch's `_checks_out_symlinks` probe names none. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice It merged with no conflict. Main brings nine commits since 39ac59c: - 06b5204, six tools escape a name their stdout cannot encode (#214 via #246), with tests/test_stdout_encoding.py; - 60d08a1, `shape_materialize.run()` and 26 other git and gh calls read the child's output as UTF-8 (#217 via #230), with tests/test_git_output_decoding.py; - 2727b08, update-shape.py's `gh pr create --base` (#209 via #218), with tests/test_update_shape_pr_base.py; - 4b2add8, scripts/repo_shape.py's flow mappings (#252 via #257), with tests/test_yaml_flow_mappings.py; - aaa40eb, adopt-project.py refuses a `.gitmodules` the history's gitlinks make unreadable (#195 via #226), with tests/test_adopt_history_gitlinks.py; - d89fffa, family.py `bump` to the current pin (#210 via #219); - eb6df75, shape-doctor.py's family.yaml beside a project.yaml (#211 via #220); - c3f4987, scripts/repo_shape.py's block-scalar records (#248 via #265), with tests/test_yaml_block_scalar_keys.py; - 5751460, adopt-project.py spells every path a FINDING or a refusal names (#212 via #223), with tests/test_adopt_finding_spelling.py. Three files changed on both sides: adopt-project.py, tests/test_adopt_ignored_shape_files.py and tests/test_adopt_verify.py. In each, `git diff -U0` of this merge against 5a8b649 equals main's diff against 39ac59c, and this merge against 5751460 equals the branch's diff against 39ac59c, apart from the hunk headers. Neither side's lines are changed. How main's changes meet this branch's failure handling: - Every definition the late failures go through is byte-identical to 5a8b649's: LATE_FAILURES, the notice (`_nothing_rolled_back`), `_failed_loudly`, `_report_late`, the three builders' handlers, `_verify` and `cmd_execute` among them. Three definitions differ, each by main's hunk alone. - `main()` gains `escape_what_stdout_cannot_encode()` as its first statement (06b5204). The branch writes every REFUSED block and notice to stderr, which Python opens with `backslashreplace` whatever the locale. - `run()` names its codec (60d08a1). Output that is not UTF-8 now reaches the branch's handlers as `CommandFailed`, not as `UnicodeDecodeError`. LATE_FAILURES keeps `UnicodeError`, because `git_out` in scripts/repo_shape.py still decodes strictly. - `Source.registered_submodules` reads the history (aaa40eb). Its one caller in `execute` is `_refuse_what_check_finds`, before `_create_leg_remotes`, so the new refusal comes before any leg repository exists and needs no notice. - 5751460 spells the paths of the verification's findings. The branch's footer and notice follow those findings unchanged. No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or scripts/shape_materialize.py, and a `symtable` check finds no name they read that nothing binds. On the merged tree, ten test files give 439 passed and 1 skipped (Windows' own stdout default): the two shared ones, main's four new ones that read adopt-project.py (finding_spelling, history_gitlinks, stdout_encoding, git_output_decoding), and test_adopt_late_failure, test_adopt_filter_repo_failure, test_adopt_execute_refusals and test_adopt_plan_escapes. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uestion:` in `plan-unresolved`, its `source: commit:` in `plan-stale` and a submodule name in `missing value for '<key>'` with `_spelled`, so a line feed in any of them no longer forges a `FINDING` line and an ESC no longer reaches the terminal (#328) (#384) #212 spelled every PATH a finding names, so that a line break, an ESC or a byte that is not UTF-8 in a source name cannot leave the line it is printed on. Three other texts still reached a finding as typed: - the `question:` of a plan entry, repeated after `The question was:` in `plan-unresolved`. It is the one value of a plan that people and AIs edit by hand. A line feed in it ended the finding's line and began another that `grep '^FINDING'` read as `check`'s own (the issue's question printed `FINDING plan-ok: this line was not written by check` on its own line, exit 1 all the same); a carriage return and an ESC reached the terminal and rewrote the row; a byte that is not UTF-8 reached it as Python's own raw surrogate, which `print` writes to stderr as `\udce9` and refuses on a stream strict about UTF-8. - the key git reports in `plan-gitmodules-unreadable`, `missing value for 'submodule.<name>.update'`. The `<name>` is the source author's choice and git prints it as it is, so an ESC, a carriage return, U+0085, U+2028 or a byte that is not UTF-8 in it reached the finding, while `_refused_value`'s messages spell the same key. - the first 12 characters of the plan's `source: commit:`, in `plan-stale`, which `check` prints and `execute` refuses with. A `commit:` of `"a\u001b[2K\nFINDING ok"` printed a raw ESC and a second line beginning `FINDIN`. The issue did not name this site; the review of this change found it. The issue was partly wrong about `execute`. `execute` does NOT repeat the question: `_refuse_an_unrunnable_plan` refuses any entry still holding `leg: null` first, and it lists paths only. Only `check` prints the question, and tests/test_adopt_question_and_submodule_spelling.py pins that on main and on this head alike. `execute` DOES repeat the submodule-name finding as its `REFUSED` line (measured on main: exit 2, an ESC in the line), and the stale commit as its `plan-stale` refusal. All three now go through `_spelled`: - `_leg_findings` prints the question through `_shown`: as it is, and printed exactly as before, unless a character in it is not printable, and then the whole question `_spelled`, in backticks, a line feed as `\n`, a carriage return as `\r`, an ESC as `\x1b`, a byte that is not UTF-8 as `_spelled` spells it. Every question `plan` writes is printable, so a plan `plan` wrote reads as it did. - `_registrations` names the key through a new `_git_quoted`: git's own single quotes, byte for byte, when `_spelled` has nothing to write differently, and `_spelled`'s own form (backticks) otherwise, which is how `_refused_value` names the same key. `_git_quoted` asks `_spelled` and writes no escape of its own, so whatever `_spelled` does with a character (#329 changes how it spells a byte that is not UTF-8) this does too. - `cmd_check` and `_refuse_an_unrunnable_plan` print `_shown(plan.source_commit[:12])`: sliced first, so twenty ESCs print as twelve escapes, and `_shown`, so a hash or a name prints as it did with no backticks. - A line feed cannot be in a submodule name (git refuses a raw one, and `\n` in `.gitmodules` reads as `n`), so the name's cases are ESC, CR, U+0085, U+2028, a tab and a byte that is not UTF-8. git's own stderr for a `.gitmodules` it cannot parse (`bad config line N in standard input`) holds no source text, so there is no third site there. THE ONE BEHAVIOUR CHANGE to a printable name: a printable key that holds a backtick or a backslash (a submodule named b, a backtick, a backslash, x) now prints through `_spelled`, backslash-escaped and in backticks, where it printed between git's single quotes, because those are the two characters `_spelled` escapes so that a spelling reads one way only. Nothing else a printable name, question or commit prints changes: a plain question, a question holding a backtick or a backslash and a plain name (`'submodule.b.update'`) were diffed against main and are byte-identical, and the tests that pin a hash's and a name's `plan-stale` line pass on main too. tests/test_adopt_question_and_submodule_spelling.py (106 tests) covers each unsafe character in a question, in a name and in a commit, a byte that is not UTF-8, the issue's own question (one line, no second finding), the printable cases that print exactly as they did, the questions the shipped policy asks, the name spelled as `_refused_value` spells it, `_git_quoted` asking `_spelled` and writing no escape of its own, `check` end to end, and `execute` refusing an unresolved entry without the question, a bad `.gitmodules` on the lines `check` prints, and a stale plan on the line `check` prints. 50 of the 106 fail on main; the others are the byte-identity guards and what holds on both. Eleven mutations are each caught: the question printed raw, spelled always, or spelled only when it holds a line feed; the key printed raw, spelled always, or kept in git's quotes around the spelled body; the commit printed raw at either site, spelled before it is sliced, or spelled always at either site. Left out: - A last-line guard over every finding (#223 suggested one). It cannot rewrite at print time without escaping again the backslashes `_spelled` wrote, so it has to REFUSE a finding holding a non-printable character, which changes what `check` exits on and is its own change. - `plan`'s own terminal report prints `entry.question` raw in `_print_plan_report`: every question `plan` writes is printable, but an operator's `--path-policy` could ask another. - A printable text that already holds a backtick or a backslash prints as it is, so a question that is itself wrapped in backticks reads like a spelled one. It is `_shown`'s own rule, and the line is one line either way. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>



Lane: openreposhape-3 (openRepoShape-3)
Claim: #212 (comment)
What
checkandexecutenow spell every path a FINDING or a refusal names through_spelled, the helperplanalready uses for its questions. A spelled path sits between backticks.\r,\n,\x85,\u2028,\x1band every other characterstr.isprintablerefuses are written as backslash escapes. So is a byte that is not UTF-8 (\xNN), and so are a backslash and a backtick. A finding is therefore one line of printable text, whatever the source's author named a file._coverage_findings):plan-duplicate-path,plan-uncovered,plan-covered-twiceandplan-empty-entry. A path covered twice now lists its entries as`src/`, `src/app/`. Main printed them as a Python list._leg_findings):plan-unresolvedandplan-bad-leg._as_entry):plan-submodule-root,plan-submodule-splitandplan-submodule-unregisteredname the submodule and its entry._kept_collisions):plan-leg-path-collidesandplan's collision note put a source path between backticks by hand. They now spell it with_spelled. Since fix round 3 that goes through the optionalspellargument offirst_and_more, which scaffold-project.py accepts a leg path that collides with a path the shape writes (--spec-path contracts,--code-path project.yaml), creates all three repositories and pushes both legs, then dies: #172's shape half on the scaffold side #197 moved toscripts/shape_materialize.py(see Composes with).execute's own refusal (_refuse_an_unrunnable_plan):plan-unresolvedlists its paths with_listed.execute's_refuse_what_check_findsprintscheck's own list (_entry_findings), so the two commands change together._account_for):adopt-duplicatedandadopt-lost.adopt-root-submodule, any other root gitlinkadopt-unexpected-gitlink, one in a leg AND the rootadopt-duplicated, and a missing mountadopt-mount-missing(#165) #173's root-tree gitlink findings (_root_gitlink_finding, fix round 1):adopt-root-submoduleandadopt-unexpected-gitlinkspell the gitlink they name. The mount paths beside it are leg paths, as inadopt-mount-missing._missing_shape_file,_SplitTree):adopt-shape-file-missingspells the pinned path, the symlink a lookup met, the target it leads out to, and a path that is not a directory.--forcetogit submodule add, so a source whose.gitignoreis*+!.gitignore, or namesspecorcode, adopts instead of dying at the mount after both leg remotes were pushed (#174) #200 added (_entry_named,_index_entries):adopt-mount-occupiednames the index entry it found under the leg mount path, and now calls_spelledwhere it used to put backticks around the name by hand. Since fix round 1,_index_entriesdecodes index names withsurrogateescape, asSource.treedoes, so a byte that is not UTF-8 prints as\xfe. Withreplace,a<0xFE>anda<0xFF>both reada<U+FFFD>. The mount path itself is a leg path, andchecked_value's alphabet already keeps that printable.plan-stale(fix round 1):check's finding andexecute's refusal spell the source's branch. git's ref-name rules refuse only ASCII control characters, so a branch named with U+0085 or U+2028 split that line.Correction to the first commit's body. It says the spelling "reads one way only", and that is not quite true. A raw byte 0x80-0xA0 or 0xAD and the character U+0080-U+00A0 or U+00AD are both spelled
\xNN, which makes 34 pairs. A source holding bothIcon<0x85>andIcon<U+0085>therefore prints the same finding twice. Main's_spelledalready did this, andplan's questions have done it since #166. The squash message prepared for the merge drops the claim and says this instead. The ambiguity itself is a follow-up (Left out).Why
Measured on this machine. The first two tables and the byte-identity check were measured on main
a5b09c3against the first commit,35cb5f8. That commit has the same tree as58b4343, which only rewrites a raw U+2028 in the commit message as\u2028. The fix-round table follows them.The issue's own repro (
sh, with the checkout as a parameter andcheck's exit code captured):a5b09c335cb5f8)"README\nX.md",checkstderr throughcat -AFINDING plan-empty-entry: README$thenX.md covers nothing in the source tree at this commit$. One finding prints on two lines, and the second does not start withFINDINGFINDING plan-empty-entry: `README\nX.md` covers nothing in the source tree at this commit$. One lineREADME.mdFINDING plan-uncovered: README.md is in the source tree and in no plan entryFINDING plan-uncovered: `README.md` is in the source tree and in no plan entryIcon<CR>,checkstderr throughod -cI c o n \r i s: the raw carriage return, which sends the cursor back over the line` I c o n \ r ` i s: printableThe in-process repro adds five top-level names
Icon<char>after the plan withgit mktree. It then runscheckas a subprocess, and runsexecutein process with thegit filter-repoprobe stubbed:a5b09c335cb5f8)checkstderr, split onb'\n'b'FINDING plan-uncovered: Icon',b' is in the source tree and in no plan entry',...Icon\r...,...Icon\x1b...,...Icon\xc2\x85...,...Icon\xe2\x80\xa8...b'FINDING plan-uncovered: `Icon\\n` ...', then`Icon\\r`,`Icon\\x1b`,`Icon\\x85`,`Icon\\u2028`execute --yesREFUSED plan-uncovered: 5 finding(s) ... as `check` prints them:, then the same raw lines (6 physical lines). No remote is createdplan-duplicate-path(in process)d\u2028/ appears 2 times,isprintable()False... `d\\u2028/` appears 2 times, Trueplan-covered-twices/a/b\rc is covered by ['s/', 's/a/'], False... `s/a/b\\rc` is covered by `s/`, `s/a/`, Trueplan-empty-entrygone\x85/ covers nothing ..., False`gone\\x85/` covers nothing ..., Trueplan-unresolvedx\ny still has `leg: null`..., False... `x\\ny` still has `leg: null`..., Trueplan-bad-lege\x1bf declares leg 'Spec'..., False`e\\x1bf` declares leg 'Spec'..., Trueplan-submodule-unregisteredthe submodule vendor/d\rp (entry vendor/) ..., Falsethe submodule `vendor/d\\rp` (entry `vendor/`) ..., Trueadopt-duplicateddu\rp is in spec:..., root:..., False`du\\rp` is in spec:..., root:..., Trueadopt-lostl\x1bost (111111111111) is in no leg ..., False`l\\x1bost` (111111111111) is in no leg ..., Trueexecute'splan-unresolvedrefusal detail1 path(s) still have `leg: null`: x\ny1 path(s) still have `leg: null`: `x\\ny`adopt-mount-occupied(_refusal_for_held)... the leg mount path: `spec/Icon\r` (a file) and 1 more, False... the leg mount path: `spec/Icon\\r` (a file) and 1 more, TrueByte-identical to main. I ran
planand thencheckon maina5b09c3and on the head, againsttests/conftest.py's synthetic source with its three questions answered, and diffed the bytes. There were five cases:--spec-path .specify, which gives two collision notes fromplanand oneplan-leg-path-collidesfinding incheck, so it exercises_kept_collisionsand_first_and_more;--spec-path docs/architecture.md;--code-path Makefile;--code-path .github.In every case these were byte-identical:
plan's exit code, stdout, stderr and plan file, andcheck's exit code, stdout and stderr. A finding that names an ordinary path is not byte-identical, because it gains the backticks.tests/test_adopt_mount_ignored.pyassertsadopt-mount-occupiedon ordinary names and passes unchanged.Fix round 1, measured with
repro3.pyon main54aa69aagainst the headd41c4b3, each run in process:54aa69ad41c4b3adopt-root-submodule, gitlinkven\rdorven\rdor is a gitlink to dddddddddddd ...,isprintable()False`ven\\rdor` is a gitlink to dddddddddddd ..., Trueadopt-unexpected-gitlink, the same gitlink at another commitven\rdor is a gitlink to ..., False`ven\\rdor` is a gitlink to ..., True_index_entries+_entry_named, index namesspec/a<0xFE>andspec/a<0xFF>`spec/a<U+FFFD>` (a file)twice`spec/a\\xfe` (a file),`spec/a\\xff` (a file)execute'splan-stale, branchma<U+2028>in... and ma\u2028in is now at bbbbbbbbbbbb, False... and `ma\\u2028in` is now at bbbbbbbbbbbb, TrueThe byte-identity check above was re-run on main
54aa69aagainst the headd41c4b3. All five cases are byte-identical. A stale plan'splan-staleline is not one of them, and it gains backticks around the branch.Fix round 3, measured on main
39ac59cagainst the heade384505, in scratch worktrees of each:planexit code, stdout, stderr and plan file,checkexit code, stdout and stderr).git diff origin/main HEAD -- scaffold-project.pyis empty, and the only difference inscripts/shape_materialize.pyis the optionalspellargument, whose default writes what main wrote. I also ranscaffold-project.py --org testorg --project Northwind --elected-by 'Test Human' --elected-on 2026-09-02 --dry-runwith eight leg-path arguments on each checkout:--spec-path contracts,--code-path project.yaml,--code-path Makefile/code,--spec-path Contracts,--spec-path .specify/x,--spec-path spec --code-path scripts,--spec-path contracts/openglass-pin.yaml --pin openGlass@<40 ones>and--spec-path spec --code-path code. Six printREFUSED scaffold-leg-path-collides: ...and exit 2, two plan and exit 0. All eight outputs are the same bytes on both once the checkout's path and theshape ... @ <commit>line (the checkout's own commit) are masked. For exampleREFUSED scaffold-leg-path-collides: --spec-path 'contracts' contains `contracts/spec-pin.yaml` and 3 more.first_and_more(paths, spell=_spelled)is_spelled(paths[0])plus the count of the rest, for['x.md'],['a/b.md', 'c'],['Icon\r'],['l\u2028s', 'x', 'y'],['a`b\\c', 'z']and['caf\udce9']. Withoutspell, it returns the path raw between backticks, as on main.Tests
tests/test_adopt_finding_spelling.pyis new, with 128 cases. It checks every finding code above, plusadopt-mount-occupiedandexecute'splan-stale, against each of the five characters. Each finding must be one line, passstr.isprintable(), hold the_spelledname, and hold no raw character.git mktree/git commit-tree, so no filesystem has to hold them. They readcheck's output as bytes and runexecutein process.git update-index --index-info.check'splan-staleruns on a real branch named with U+0085 and with U+2028. Those are the two of the five characters that git lets into a ref name.Each case added in a fix-round commit fails without that commit's code change.
The existing test files gain backticks in their finding assertions:
test_adopt_execute_refusals.py,test_adopt_ignored_shape_files.py,test_adopt_submodules.pyand, since fix round 1,test_adopt_verify.py. Since fix round 2 so does one assertion of #247'stest_adopt_plan_escapes.py.Targeted set on
e384505b135eb39fe89acfaf65fd015a878fa515, the head, with main39ac59cmerged in (#224, #244, #247, #233, #234 (#197) and #235). It is one pytest process, with git-filter-repo 2.47.0 first on PATH: 1530 passed, 1 skipped in 45m38s, exit 0. The machine was heavily loaded, which is why it took that long. The skip istest_adopt_plan.py(SHAPE_MEDXEHR_CLONEunset). Collected per file, which sums to the 1531 that ran:test_adopt_finding_spelling.pytest_adopt_e2e.pytest_adopt_execute_refusals.pytest_adopt_ignored_shape_files.pytest_adopt_leg_path_collisions.pytest_adopt_leg_paths.pytest_adopt_plan.pytest_adopt_plan_escapes.py(#247's file)test_adopt_spec_only.pytest_adopt_submodules.pytest_adopt_mount_ignored.pytest_adopt_verify.pytest_scaffold_leg_path_collisions.py(#197)test_scaffold_mount_ignored.py(#197)test_scaffold_leg_paths.pytest_scaffold_ignored_shape_files.pytest_update_shape.py(#224)test_update_shape_add.pytest_update_shape_add_ignored.py(#233)test_yaml_flow_quotes.py(#244)test_shape_doctor.pytest_repo_hygiene.pytest_cli_reference.pyRound 1's run on
d41c4b3, before any of the merges, was 1146 passed, 1 skipped over the 14 adopt, doctor, hygiene and cli-reference files of that round, which are the files above withouttest_adopt_plan_escapes.pyand without the scaffold, update-shape and yaml-flow ones.Fix round 2 is one assertion. On the merge,
test_adopt_plan_escapes.py::test_plan_writes_the_name_as_one_entry_check_parses[carriage-return]failed (1 failed, 58 passed). It pinned the rawIcon\rincheck'splan-unresolvedFINDING, and this PR spells that line. It now expectsFINDING plan-unresolved: `Icon\r` still has `leg: null`(a backslash and anr, between backticks), and its comment says what is on main. The file passes 59 of 59.Fix round 3 changes no test. #197 (#234) moved
_first_and_moreintoscripts/shape_materialize.py, andtest_scaffold_leg_path_collisions.py::test_both_tools_ask_the_one_definitionassertsadopter._first_and_more is shape_materialize.first_and_more, so this PR keeps main's import.test_adopt_finding_spelling.py::test_plan_leg_path_collides_spells_the_kept_path_and_its_entrypasses on the head and on the merge's second commit only: it fails on the merge commit6ee08c7alone, which takes main's side of the conflict and spells nothing.Windows: no test is marked to skip there. The end-to-end, split-tree and index cases call
pytest.skip, with git's own message, only where git refuses to build or keep one of the names (git_out,assert_names_are_in). Git is fed bytes and answers in bytes, and the plan is written LF.Composes with
#173 (
fix/adopt-verify-mounted-gitlinks) landed as54aa69a, #199 (fix/adopt-plan-linebreak-names, lane 5) landed as #247 (4835fb4), and #197 (fix/scaffold-shape-path-collisions, lane 4) landed as #234 (b538015) with #233 (150c41d) and #235 (39ac59c), so none of them is a sibling now.adopt-root-submodule, any other root gitlinkadopt-unexpected-gitlink, one in a leg AND the rootadopt-duplicated, and a missing mountadopt-mount-missing(#165) #173, fix round 1: merged it in, spelled its two new findings, and updated the 4 assertions the trial merge had predicted would break (test_adopt_verify.pylines 203, 223 and 415,test_adopt_submodules.pyline 249). Spelling the two findings also changed 4 more of adopt-project.py: verification passes over the two leg mounts and no other gitlink, so the source's own submodule kept in the root isadopt-root-submodule, any other root gitlinkadopt-unexpected-gitlink, one in a leg AND the rootadopt-duplicated, and a missing mountadopt-mount-missing(#165) #173's assertions: theroot_submodule()helper, the twoadopt-unexpected-gitlinkassertions, and theadopt-root-submoduleassertion intest_adopt_submodules.py.Icon\rfile), socheckrefuses the whole plan asyaml-unparsablewith no entry to edit #199 / adopt-project.py: the plan writer escapes every character that is not printable from the one tablerepo_shape's reader resolves, so a source holding the macOSIcon\rno longer writes a plancheckrefuses, and a non-UTF-8 name no longer empties the plan (#199) #247, fix round 2: merged main4835fb4. The textual merge was clean. One of adopt-project.py: the plan writer escapes every character that is not printable from the one tablerepo_shape's reader resolves, so a source holding the macOSIcon\rno longer writes a plancheckrefuses, and a non-UTF-8 name no longer empties the plan (#199) #247's assertions failed on it,test_adopt_plan_escapes.py::test_plan_writes_the_name_as_one_entry_check_parses[carriage-return](1 failed, 58 passed), and lane 5's composition note on this PR (2026-10-11T21:00Z) named it. adopt-project.py: the plan writer escapes every character that is not printable from the one tablerepo_shape's reader resolves, so a source holding the macOSIcon\rno longer writes a plancheckrefuses, and a non-UTF-8 name no longer empties the plan (#199) #247 landed first, so this PR took the edit: that assertion and its N7 comment (which said adopt-project.pycheckandexecuteprint a source path raw in a FINDING, so a\r,\n,\x85, U+2028 or ESC in a name overwrites, splits or escapes the line on a terminal, whereplan's questions spell the same path with_spelled#212's fix "edits this line"). No other assertion of adopt-project.py: the plan writer escapes every character that is not printable from the one tablerepo_shape's reader resolves, so a source holding the macOSIcon\rno longer writes a plancheckrefuses, and a non-UTF-8 name no longer empties the plan (#199) #247's file changed.--spec-path contracts,--code-path project.yaml), creates all three repositories and pushes both legs, then dies: #172's shape half on the scaffold side #197 / scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a--pin'scontracts/<product>-pin.yamlincluded, is refused before anything is created, under--dry-runalike, through the one definition adopt-project.py asks, so--spec-path contractsno longer creates three repositories and pushes both legs to die atgit submodule add(#197) #234, fix round 3: merged main39ac59c. One conflict, inadopt-project.py, where this PR's_first_and_more(edited to_spelledits path) met main's removal of it: scaffold-project.py accepts a leg path that collides with a path the shape writes (--spec-path contracts,--code-path project.yaml), creates all three repositories and pushes both legs, then dies: #172's shape half on the scaffold side #197 moved_overlapand_first_and_moretoscripts/shape_materialize.pyaspath_overlapandfirst_and_more, andadopt-project.pyimports them back under the old names. Lane 4's landing note on this PR (2026-10-11T22:06Z) gave the resolution, and the coordinator ruled it. Two commits:6ee08c7takes main's side of that one hunk, with no line written by hand:_overlapand_first_and_moreleaveadopt-project.py, and main's import stays;e384505writes the rest, in two places:first_and_more(paths, spell=None)inscripts/shape_materialize.py(named = spell(paths[0]) if spell else f"{paths[0]}", so the default is what main wrote), and_kept_collisionscalls_first_and_more([p for p, _ in found], _spelled)._shape_collisionsasks the sharedshape_path_collisions, which names only the shape's own paths and so needs no spelling. The scaffold's messages are byte-identical to main's (Why, fix round 3).Textual trial merges, refreshed at 22:57Z with
git merge-tree --write-tree <sibling> <ref>against this heade384505and against main4b2add8(main moved after the targeted run, see below). Lane 3's branches are the localfix/...refs, and every other branch isorigin/fix/....4b2add8fix/adopt-history-gitlinks(#195)f48c669test_adopt_history_gitlinks.py40 passed on a merge with0023dde, at 19:27Z on main54aa69afix/plan-literal-follow-up-grep(#216)f0d433454aa69afix/adopt-check-unsafe-path(#196, PR #231)355ab2dorigin/fix/adopt-plan-quote-yaml-keywordsa0ff15b76a6153, 19:27Z on54aa69a, not re-run. Lane 5's verify report found that #181 and #247 both define_shown; that is between those two, whatever this PR doesorigin/fix/adopt-report-filter-repo-failuref416e13f416e13merged with main54aa69awithout this change (measured 19:27Z, not re-run)origin/fix/adopt-late-failure-noticecdfb308origin/fix/adopt-record-submodule-placement2a93da854aa69a; not runorigin/fix/adopt-relative-submodule-url0b996fd54aa69a; run on the merge, see beloworigin/fix/adopt-root-kept-symlinks04792d2adopt-project.py)fix/decode-git-output-utf8(#217) landed (60d08a1).Main moved after the targeted run, to
4b2add8: #246 (adopt-project.py and five other tools escape a name their stdout cannot encode), #217 (60d08a1),2727b08and4b2add8. This head merges CLEAN with it (git merge-tree). On that trial merge, in a scratch worktree and not pushed,test_adopt_finding_spelling.py,test_adopt_plan_escapes.py,test_stdout_encoding.py,test_git_output_decoding.py,test_scaffold_leg_path_collisions.py,test_adopt_leg_path_collisions.pyandtest_cli_reference.pygave 449 passed, 1 skipped, exit 0. The branch itself has not taken these four commits.Whichever PR lands second must update these tests.
fix/adopt-check-unsafe-path(adopt-project.pycheckaccepts plan pathsexecuterefuses asunsafe-value(a space or a non-ASCII character), soplan okis followed by a refusal and #166's split instructions can name entriesexecutewill refuse #196, lane 3), at355ab2d. I merged it into this heade384505in a scratch worktree and rantest_adopt_finding_spelling.py,test_adopt_entry_paths.py,test_adopt_execute_refusals.pyandtest_adopt_submodules.py: 7 failed, 355 passed. The control,355ab2dalone with the same three test files, gave 234 passed. The 7 failures are the same 7 cases as atf9656bd,53e82edand355ab2d:tests/test_adopt_entry_paths.py::test_check_reports_the_path_first_and_says_what_to_do, 5 params (control-character,empty,leading-dash,rename-arrow,whitespace-only). Line 289 assertsline.startswith("FINDING plan-uncovered: specs/"). It becomes"FINDING plan-uncovered: `specs/".tests/test_adopt_execute_refusals.py::test_a_path_that_is_a_git_option_is_still_unsafe_not_uncovered. Line 430 asserts"FINDING plan-uncovered: specs/" in refusal.detail, and it needs the same backtick.tests/test_adopt_submodules.py::test_an_unregistered_submodule_is_told_why_git_ignores_its_entry[path-option-like]. This PR changes that param'ssubmoduleto the finding's spelling,"`-dep`". adopt-project.pycheckaccepts plan pathsexecuterefuses asunsafe-value(a space or a non-ASCII character), soplan okis followed by a refusal and #166's split instructions can name entriesexecutewill refuse #196's body (lines 1129-1130) readssubmoduleas the raw path, insubmodule.startswith("-"),entry_path_problem(submodule)andf"`{submodule}` has leg: code...". So on the merge the expectedplan-unsafe-pathdrops out. The fix is to use the raw name there, for examplesubmodule.strip("")`.origin/fix/adopt-relative-submodule-url, at0b996fd. Itsplan-submodule-relative-urlfinding names the submodule through_as_entry, which this change spells.moved()intests/test_adopt_relative_submodule_url.py(line 182) expectsthe submodule {path} (entry {entry})without backticks. I merged it into this heade384505in a scratch worktree and rantest_adopt_relative_submodule_url.pyandtest_adopt_finding_spelling.py: 12 failed, 148 passed. The control, the branch merged with main4b2add8without this PR, gave 32 passed for the first file. The 12 cases are 5 tests:test_check_keeps_a_relative_url_its_github_leg_resolves_alike(5 params),test_a_source_with_no_remote_resolves_from_its_own_directory,test_each_relative_url_is_resolved_on_its_own,test_a_credential_in_the_source_remote_is_never_printedandtest_execute_resolves_again_from_the_remotes_it_would_create(4 params). The fix is the backticks inmoved()and at the assertion that reads the finding.Left out
plan-unresolvedrepeats the entry'squestion:exactly as the plan holds it, so a hand-edited question with a line break in it still splits that finding.cmd_checkand_refuse_what_check_findscould escape, as a last-line guard, any character a finding should never hold, so that a finding added later without_spelledwould still print as one line.fix/adopt-root-kept-symlinksadds aplan-root-symlinkfinding that names its paths raw:{link},{target}, and the walk'sat,originandhere. Whichever of the two PRs lands second should spell them._spelledwrites a raw byte 0x80-0xA0 or 0xAD and the character U+0080-U+00A0 or U+00AD the same way, as\xNN(34 pairs). Writing a non-printable character below U+0100 as\u00NN, and keeping\xNNfor raw bytes, would give each its own form. That changes the\x85expectations here and inplan's questions.Closes #212
🤖 Generated with Claude Code
Summary by Sourcery
Make adoption diagnostics consistently spell author-controlled names so every finding and refusal remains safe, printable, and confined to one line.
Bug Fixes:
Enhancements:
Tests: