Repository navigation
shape_materialize.py: render substitutes in one pass and refuses only a placeholder its table lacks, so --elected-by '{{SPEC_PATH}}' is no longer recorded as spec and {{NOPE}} no longer refuses after the legs are pushed (#260) - #316
Conversation
…ne pass and refuses only a placeholder the template holds with no value, so `--elected-by '{{SPEC_PATH}}'` is no longer recorded as `elected_by: "spec"`, and `{{PROJECT}}` or `{{NOPE}}` in a free-text value no longer refuses `template-unsubstituted` after the legs are pushed (#260)
`render` replaced the keys one after another, each over the whole text, so
the value an earlier key put in was searched by every later key. A token for
a later key was expanded; one for an earlier key, or one no key names, was
left in the output and refused as a defect in the tool, after
`adopt-project.py execute` had pushed both legs, after the scaffold had
created all three repositories, and after `family.py init` had created the
holder's.
Now one regular-expression substitution reads the template once and looks
each placeholder up in the table, so a value is written as typed. In a YAML
template the quoted form `"{{KEY}}"` is matched first and spelled by
`double_quoted`, as #284 made it. The refusal asks the template which
placeholders the table lacks, which is the defect its remediation names.
`PLACEHOLDER_RE` now takes digits. `{{SPEC_TREE_SHA256}}` and the three
other digest placeholders were substituted only because the old loop replaced
every key by name; the single pass replaces what the pattern finds, and a
digest placeholder a table lacked had been left in the output with nothing
said. `tests/test_template_quoted_values.py` builds its tables with the same
pattern and takes digits with it.
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-12T02:24Z) @codex review |
|
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 21 hours and 5 minutes by commenting |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Reviewer's GuideThe PR fixes placeholder substitution so values containing placeholder-like text are written literally, while genuinely missing placeholders are rejected from the source template before repository creation or pushing. It also recognizes digest placeholders containing digits, retains YAML quoted-value handling, and adds broad unit and end-to-end regression coverage across scaffold, adoption, and family initialization. Sequence diagram for safe placeholder substitutionsequenceDiagram
participant Tool
participant Renderer
participant Template
participant Values
participant Repository
Tool->>Renderer: render(text, values, source)
Renderer->>Template: Read placeholders once
Renderer->>Values: Look up each template placeholder
alt Missing placeholder in template
Renderer-->>Tool: Refusal: template-unsubstituted
else All placeholders have values
Renderer->>Renderer: Substitute in one pass
Note over Renderer: Inserted value text is never searched again
Renderer-->>Tool: Rendered text
Tool->>Repository: Create and push repositories
end
Flow diagram for single-pass template renderingflowchart LR
T[Template source] --> M{Missing placeholders?}
M -- yes --> R[Refuse before substitution]
M -- no --> S[Single regex substitution]
S --> O[Rendered output]
V[Values table] --> S
O --> P[Repository creation and push]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
A clean merge, no conflict: origin/main had moved past this branch's base (b30595c) by the family, adopt, update-shape and doctor changes that landed since. Both sides are kept as they stand. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…sorted refusal list and an empty value as a value are each held by a test that fails on the mutant that breaks it (#260) The review of 552763d (N2) ran seven mutants of `shape_materialize.render`; three survived every test: - `.yml` dropped from the YAML test, so only `.yaml` got the quoted spelling. Latent, since no `.yml` template holds a placeholder today (the two `validate.yml` workflows hold none). A synthetic `workflow.yml` now renders a quoted placeholder with a value holding `"`, `\` and both, and the YAML reader takes back what was typed. It renders byte for byte as `.yaml` does and not as `.md` does. - The refusal's list unsorted, reversed or with every sighting kept. The template holds ZULU, ALPHA and MIKE in that order, ZULU and ALPHA twice, so neither the order met nor its reverse is the sorted one, and the refusal must name `['{{ALPHA}}', '{{MIKE}}', '{{ZULU}}']`, each once, as markdown and as YAML. - An empty value treated as missing. The leg commit and digest are `""` until the legs exist, so an empty string in the table is a value: it renders as empty text, bare or between a YAML template's quotes, in every template that holds a placeholder (19 of them), and the real spec pin reads back `commit: ""` and `tree_sha256: ""`. Each new test passes on this head and fails on its mutant: 3 failed on the `.yml` mutant, 2 on each of four refusal-list mutants (first-seen order, reversed sort, duplicates kept, unsorted with duplicates), 23 on each of two empty-value-is-missing mutants. The mutants were applied one at a time in a throwaway worktree, and no other test in the file or in test_template_quoted_values.py fails on any of them. Only tests change; no file is written, no git stdin is used, and nothing is skipped. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
…, #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>
…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>
… 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>
…r and validate-manifest.py about the manifest's values before anything exists, so `--id null`, `--tracking-branch true` and a blank `--elected-by` are refused (#258, #261) (#314) Both tools fill `templates/assembly-root/` from values a person typed or edited into a plan, and the new project then reads the result with two programs of its own: its YAML reader (`repo_shape`) and its `scripts/validate-manifest.py`. Neither tool asked either of them. Now each is asked, before a work directory or a remote exists and under `--dry-run` alike, and a value the project cannot hold is refused with the value named and the reason given. - **#258, the scaffold.** `id:` and `tracking_branch:` are written bare. `--id null` and `--id true` scaffolded three repositories whose first bootstrap failed `manifest-id`; `--tracking-branch true`, `007` or `null` pushed every repository on the branch as typed while bootstrap read the manifest back as `True`, `7` or `main` and put each leg on a local branch the remotes do not have, with no upstream, exit 0. Now `scaffold-manifest-misread` names the value and what the reader makes of it. - **#261, both tools.** A blank `--elected-by`, `--reference` or `--name`, `--elected-on 2026-W41-6` or `20261011` (read by `date.fromisoformat` from Python 3.11), `--org test+org`, and a plan edited to `visibility: secret` or `elected_by: null` were accepted, and `adoption verified` or a scaffold exit 0 followed. Now the scaffold says `scaffold-manifest-invalid`, `adopt-project.py plan` says `adopt-manifest-invalid`, `check` makes a `plan-manifest-invalid` FINDING of each, and `execute` refuses it through `check`'s own list (#168), before a leg repository exists. The sentence quotes the validator's own finding, so the words are its words. - **Names that cannot be directories.** A `--project` of 247 to 250 characters died in `Path.exists` with `OSError: File name too long` and a traceback under `--local-remote-dir` (scaffold from 247, adopt `execute`); `plan` admitted 251 to 255 that `check` refused as `unsafe-value: legs.spec`. `scaffold-name-too-long` and `adopt-name-too-long` refuse the first before anything exists, and `plan` now holds the three derived names to `checked_value` as `check` does. - **A validator that cannot be asked.** A copy of the standard whose `templates/assembly-root/scripts/validate-manifest.py` is missing, does not load, lacks what is asked of it (`DATE_RE`, `_findings`) or raises, is `manifest-validator-unusable` and not a traceback: it names the file, says the defect is the copy's and that nothing was created. A failed load is not cached. - `scripts/shape_materialize.py` (the one module both tools import, in no copy list): `manifest_misreads` renders each template line that carries a value and reads it with `parse_yaml`; `manifest_invalid` renders `project.yaml` from the whole substitution table and runs the validator the template ships, loaded from that file (no bytecode written into the template tree, `sys.path` put back); `refuse_manifest_values` and `manifest_value_problems` compose them; `misread_placeholders` names the placeholders `manifest_misreads` would refuse; `election_date` admits only what the validator's own `DATE_RE` admits, on every Python; `refuse_unstorable_names` holds a name to 255 bytes, `.git` included under `--local-remote-dir`, and shows a name too long at both ends. A placeholder is what `PLACEHOLDER_RE` finds in a line, not a second copy of its alphabet. - `scaffold-project.py`: `_refuse_unholdable_manifest` after the substitution table is built and before the plan is printed, the name rule before anything asks `gh api`; `--help` for `--id`, `--elected-by` and `--tracking-branch` says the rule. - `adopt-project.py`: `_flag_plan`, `_manifest_values` (the table `execute` renders from, asked of `_template_values` and not copied), `_manifest_findings` in `_entry_findings`, `_refuse_unholdable_values` in `cmd_plan`, the name rule in `_repository_urls`. - `docs/cli.md`: regenerated (four help paragraphs). - `tests/test_scaffold_manifest_values.py` (106 tests), `tests/test_adopt_manifest_values.py` (79). The templates write `id:`, `elected_on:`, `visibility:`, `tracking_branch:` and the leg paths bare, and the project's reader reads `null`, `true` and `007` as None, True and 7. Either the tool refuses what the project would misread, or the template quotes the placeholder (#259, which Brett has not decided). This refuses, because: 1. Every neighbouring fix refuses and none quotes: #225 (`--spec-path 2026`: "refused rather than quoted in the templates"), #181 (`adopt-manifest-misread`), #266, #251 (a chain link that is not a name). A scaffold that quoted while `adopt-project.py plan` refuses `--tracking-branch 007` would disagree with it about whether `007` is a branch. 2. Quoting changes the bytes of every new project's `project.yaml` and leg pins for every tool that renders them (the scaffold and adopt `execute`), and #259 asks for that to be decided once. (`family.py init` renders `templates/family-root/`, not these lines; it has the same defects in its own file and is #307.) 3. The question is asked of the TEMPLATE: each line is rendered and read as the project reads it, so the day a placeholder is quoted those values read back and nothing here refuses them, with no edit. `test_quoting_the_template_would_make_the_refusal_go_away` holds that. 4. A value that is a legal git branch name and that YAML reads as a boolean or null is not silently retyped: it is refused, with the reading named. **A behaviour change for input that used to work.** `--tracking-branch 2026` and `1.5` worked by accident (`str(2026)` gives `2026` back, and every reader of `tracking_branch` does `str()`: `bootstrap.py`, `update-shape.py`, `bump-leg.py`); they are refused as `adopt-project.py plan` refuses them on #181. #259's quoting would allow them again. The refusal's remediation says so in the true terms: it says only what holds of every value refused (the project's reader reads back something other than what was typed), names the failures that do hold (an id that is not text fails `validate-manifest.py`; `007` as a branch reads back as `7`), says that `2026` or `1.5` as a branch work today only because the tools that read it turn the number back into text and are still numbers to the project's reader, and gives the way forward, a value the reader takes for text (`atlas`, `release-2026`, `v2`). It no longer says the first bootstrap would fail, which is false of exactly those two values. The rule is the validator's own: `manifest_validator()` loads `templates/assembly-root/scripts/validate-manifest.py` from the template it ships, and `election_date` asks its `DATE_RE`. The alternative, moving the predicates into `scripts/repo_shape.py`, is a shape copy: every project in the estate would meet an `upstream-changed` row on its next `update-shape.py check` for a question no project asks of itself (the argument `shape_materialize` already makes for the leg-path rule and the commit trailers). Loading the file leaves the validator the one place the rule is written, and any rule it learns is asked here with no edit (`test_the_election_date_asks_the_validator_not_a_copy_of_its_rule`). Reproduction scripts: the issues' own loops (`repro258.sh`, `repro261.sh`), local remotes only. ``` base c3f4987 == --id null: scaffold exit 0 ... bootstrap exit 1 FINDING manifest-id: id is None == --tracking-branch 007: scaffold exit 0 ... bootstrap exit 0 spec leg on 7, upstream: fatal: no upstream configured for branch '7' == scaffold --elected-by ' ': exit 0, bootstrap exit 1: FINDING manifest-elected-by: elected_by is empty == adopt plan --org=test+org: check plan ok, execute exit 0, 1 x 'adoption verified'; bootstrap exit 1: manifest-leg-repository == adopt --project of 247 characters: execute exit 1 OSError: [Errno 36] File name too long this branch == --id null: scaffold exit 2, REFUSED scaffold-manifest-misread: --id is 'null', which is written into project.yaml as `id: null`, where the project's own YAML reader reads it back as no value at all and not as that text. == --tracking-branch 007: scaffold exit 2, REFUSED scaffold-manifest-misread: --tracking-branch is '007' ... reads it back as the number 7 == scaffold --elected-by ' ': exit 2 REFUSED scaffold-manifest-invalid: --elected-by is ' ', which the project's own validate-manifest.py refuses: manifest-elected-by: elected_by is empty. ... == adopt plan --org=test+org: plan exit 2 REFUSED adopt-manifest-invalid: --org is 'test+org' ... manifest-leg-repository ... == adopt --project of 247 characters: execute exit 2 REFUSED adopt-name-too-long ``` Each row is in the logs named in the report; every row of the two issues, and the extra rows lane 2 added to #258 (`--id 2026` and `007`, `--project Null` and `True`, `--tracking-branch TRUE`, `Null` and `1.50`), is a test that fails on c3f4987 (130 of the 170 new tests fail there: the rest assert what must still work, such as `release-1` and `v1.2.3`, or hold the API). Targeted runs on this head, all with the venv `git-filter-repo` 2.47.0 on PATH: | run | result | |---|---| | test_scaffold_e2e, _pin_and_reuse, test_naming_policy, test_manifest_validator, test_adopt_plan, test_adopt_execute_refusals, test_repo_hygiene, test_cli_reference, test_adopt_entry_paths, test_adopt_finding_spelling, test_adopt_gitmodules_values, test_yaml_flow_quotes, test_load_yaml_refusals, test_adopt_plan_escapes, test_pin_validator, test_neutral_pin_validator, and the two new files (170 of the tests) | 1087 passed, 1 skipped (`SHAPE_MEDXEHR_CLONE` unset) | | `tests/test_shape_doctor.py`, its own stream | 242 passed, 3 skipped (the PowerShell line, a Windows host only) | Mutation probes: each hunk reverted in a throwaway worktree, at least one new test fails for each of the eighteen of the first round and the ten of the second (the date rule, the scaffold gate, the misread scan, the validator question, the bytecode and `sys.path` guards, both name rules, adopt `plan`, the one list `check` and `execute` read, the grouping by value, the attribution, the leg-path and non-text filters, the list-`legs` guard, the no-revision guard, the unclassifiable-names guard; and the remediation wording, `misreads_elsewhere`, the placeholder pattern, `_line_shown`, the name tails and the three validator guards). Reach: every tracked YAML file (14) loaded with `repo_shape.load_yaml` on c3f4987 and on this head, parsed results identical (nothing here touches the reader; 7 are unrendered templates the reader refuses on both). Writers of the constructs: `scaffold-project.py` and `adopt-project.py execute` (assembly root); the family holder's writer is #307's. Nothing ran on Windows or macOS before this PR's CI: the new tests pass bytes and never `text=True` to git, write plans with `newline="\n"`, and the ones that copy the standard (`a_copy_of_the_standard`) copy a tree the way `tests/test_family.py` does. | against | result | the merged tree, run in a throwaway worktree and never committed | |---|---|---| | origin/main d82a83e | clean: this head contains it. Main was merged five times (49c21fa b421e6b, 567ee20 c0ed615, f45edf4 34ecbc2, 4b8da3d ab623dd, c578557 d82a83e); each conflict at the tail of `_entry_findings` was resolved by keeping every group, and on this head the tail is `findings + split + unwritable_findings + value_findings + moved`, with the remediations in the same order (see the merges below) | the runs below the merges are this tree | | origin/fix/render-single-pass 552763d (#260) | clean | 364 passed: the two new files, `test_template_values_single_pass.py`, `test_template_quoted_values.py` | | origin/fix/adopt-check-shape-paths 8a496d5 (P) | clean | 284 passed: the two new files, `test_adopt_root_shape_paths.py` | | origin/fix/leg-path-yaml-scalars b2b42f8 (#266) | clean | 722 passed: the two new files, `test_leg_path_yaml_scalars.py`, `test_adopt_leg_paths.py` | | origin/fix/adopt-plan-quote-yaml-keywords e337f44 (#181) | CONFLICT, two hunks in `adopt-project.py` `_entry_findings`: its docstring and its last statement | keep both and `misreads_elsewhere=True` (below): 1050 passed, 1 skipped: the two new files, `test_adopt_plan_yaml_quoting.py`, `test_adopt_leg_paths.py`, `test_adopt_plan.py`, `test_adopt_execute_refusals.py`, `test_cli_reference.py` | **With #181, and who does what.** Both branches add a findings group to `_entry_findings`. Keeping both, with #181's misread group just before this branch's (on this head, `findings + split + unwritable_findings + misread + value_findings + moved`), gives two findings for a plan value that is TEXT and that the template writes bare, which the reader reads back as a number or a boolean (`elected_on: "20261011"`, `visibility: "true"`, `id: "007"`): #181's `plan-manifest-misread` and this branch's `plan-manifest-invalid`, the second saying the retyped `id is 7`. `_manifest_findings(plan, misreads_elsewhere=True)` leaves such a value to the misread rule, by the placeholders `shape_materialize.manifest_misreads` refuses, so it is judged once. It is False here because with nothing else asking the misread question the validator's finding is the only one: always leaving such a value out makes this branch alone accept `id: "007"` at `check`, which it refuses, and 13 tests of this branch fail with it on. **Whoever lands second passes `misreads_elsewhere=True` at the one call in `_entry_findings`**; nothing is lost in either order, and `test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_prints` fails for each of the three values on a merge that has not (two findings), and passes on one that has. Run on a throwaway merge with #181's e337f44: the three pairs are three single findings, and the other 16 rows of the 19-row plan and `check` driver are unchanged. Re-pointing #181's own gate at `shape_materialize.manifest_misreads` (one definition for both tools, which #258 asks) is the same person's job and is not done here: lane 1's branch is not this PR's. `#266` fires first at the scaffold (its leg-path rule, before the manifest gate), at adopt `plan` and at `check`, so the two messages about a leg path never disagree once it lands. `#260` makes a value holding a `{{KEY}}` acceptable as typed (see the first left-out item). - `adopt-project.py`'s misread side is #181's: `--tracking-branch 007` at `plan`, a plan edited to a non-text value (`id: 007` read as the number 7), and a value carrying a line break (`elected_on: "2026-09-02\nfoo: bar"`). This branch already refuses `--id true` and `--id 007` at `plan`, and `id: "007"`, `visibility: "true"` and `elected_on: "20261011"` at `check` and `execute`, through the validator's `manifest-id`, `manifest-visibility` and `manifest-elected-on`. - Leg paths (`--spec-path 2026`): #225's, now #266. `plan` refuses one through the validator's `manifest-leg-path` (it passes no `ignore`), and `check` ignores that code because it reports a leg path in the leg-path rule's own words (`ignore=LEG_PATH_CODES`), so a hand-edited `spec_path: "2026"` still checks `plan ok` on this branch as it did on main. #266 judges it first everywhere. - `scripts/family.py init`: the same defects in the holder; verified here (`--created-by ' '`, `--created-on 20261011` and `--id null` exit 0 and the holder's `validate-family.py` is red; `--tracking-branch true` is silent). **#307.** - `--pin null@<sha>` and a `--pin` product the reader misreads (the scaffold writes `neutral_product_pins` and a pin file product bare): not asked; #181 does adopt's. - `--org`: the scaffold's `gh repo create` still fails for a name longer than GitHub's 100 characters, at the first leg, before anything exists; that is GitHub's limit and not the validator's. - A refusal that depends on `--pin` without `--pin-source` is reached after `_declaration` has read the forge with `gh api` (a read); the leg-path and name rules come before it (the review's NIT-8). Remaining nits of the adversarial review that are not fixed here, one line each: - NIT-1: `plan` and `check` disagree about `--spec-path 2026` on this branch alone (see the second item above), and the scaffold's sentence names no flag for `--spec-path true`; #266's rule fires first everywhere once it lands. - NIT-2: `check` exits 2 with one REFUSED and no findings for a bad `org:` (`unsafe-value`) or an unparseable `elected_on:` in a plan with no `reference:` (`election-date-malformed`, naming `--elected-on` about a plan key); both are better verdicts than main's `plan ok`, but refusals and not FINDINGs. - NIT-6: before #260 lands, a value that holds a `{{KEY}}` is refused as `template-unsubstituted` (a defect of the tool), under `--dry-run` too; main accepted the dry run and wrote the wrong text or died after the push. With #260 both are accepted as typed. - NIT-8: with `--pin` and no `--pin-source`, a misread `--id null` is reached only after the forge read (above). - Two mutants of the review survive and are judged equivalent: `_reading_of`'s `len(parsed) == 1` and `key in values` in `_sentences_of`. An adversarial review of ca21b57 approved with nits. It had three should-fixes, none a code defect: - The misread remediation gave a false reason for `--tracking-branch 2026` and `1.5`. - A conflict with main in `_entry_findings`, and a stale body. - Double findings once lane 1's #181 lands. Fix round 1 (7defac7) handles all three and four nits. Its re-verification found no blocker or should-fix. It confirmed that taking the review's #181 suggestion unconditionally would drop this branch's only finding for `id: "007"`, `visibility: "true"` and `elected_on: "20261011"`. The `misreads_elsewhere` flag is therefore the handover: whichever of this PR and #181 lands second passes `True` at the one call in `_entry_findings`, and the guard test fails if it does not. A seeded run of 1324 edited plans on the flagged merge with #181 left no value with no finding and none with two. Two new nits are left: - A template validator whose `_findings` returns something other than a list still ends in a traceback. - The flag's call site in `_entry_findings` has no comment pointing at the handover. Tests on all three OSes passed at 7defac7, but SonarCloud's quality gate failed on its security rating for new code: `pythonsecurity:S8705` at `shape_materialize.run()`. That is the agentic argument-injection family #119 marked a false positive. The only new call into `run()` was `git rev-parse HEAD` in this tool's own checkout, with constant arguments. Fix round 2 (cf3e8d3) removes it, and a second git call with it. **SonarCloud still flags the same S8705 on cf3e8d3**, so that call was not its cause. The finding sits wholly inside `run()`, and PR #316 carries the same one with no new caller of `run()`. It needed no disposition in the end: main itself carried the identical finding until 13:01:12Z on 2026-10-12, opened by a `bump-leg.py`-only commit and closed by a `validate-pins.py`-only commit, and this PR's one analysis (11:55:50Z) was charged with it as new. SonarCloud passes from 4b8da3d on, with no change to `run()` or its callers. - `plan` and `check` ask the project's own validator about the manifest with a fixed, well-formed placeholder commit and tree digest. The question starts no process; the commands still read the source with `git`. The validator holds both to their form and compares them with nothing. - A second call went with it: `_template_values` also ran `tree_digest(SHAPE_ROOT, commit)` (`git ls-tree -r` over the whole standard) on every `plan` and `check`. The table body moved to `_template_table`, which runs nothing. `execute` still builds its table with the real commit and digest, which a new test asserts. - Both commands now refuse a blank `--elected-by` from a tree with no `.git` exactly as in a checkout, which fixes the review's NIT-9. There are 16 new tests: 14 fail on the previous `adopt-project.py`. - Main c0ed615 is merged (567ee20), with no conflict. - A fresh re-verification of the round found the code correct. `execute`'s 29-key table and its 50 pushed files are identical to 7defac7's. An audit hook saw 0 process starts in the manifest question. 14 of the 16 tests fail on 7defac7, and all nine mutants of the round are caught. Fix round 3 (ab66bc0) changes no behaviour: - It corrects a stale comment that pointed at `_template_values`. - It replaces two lane-private review ids that had been committed in a test. - It splits eight composite assertions SonarCloud flagged (`python:S9073`). - It defines the validator's path once (`MANIFEST_VALIDATOR`, `python:S1192`). Targeted runs: 289, 102 and 176 passed, and the suite collects (3499). **Four later merges of main, and the #351 compose commit.** - **4b8da3d merges main ab623dd.** It brought #270 (f795cf7), which asks whether a file can hold a value at all: `adopt-unwritable-value` at `plan`, and `plan-unwritable-value` at `check` and `execute`. - Some values are judged by both #270 and this branch. Examples: a lone surrogate, a line break or a bidi control in `visibility:` or `elected_on:`, and a control character or a line break in `elected_by` or `reference`, which the validator then reads as blank. - For such a value, #270's finding comes first, so `execute` refuses under #270's code. Both findings are listed, and both refuse before any leg exists. - #270's own hand-edited-value tests pin that order: three of them fail when it is reversed. - Why this order: #270 names the byte, the break or the control the person must retype. The validator describes the manifest that value would be written into, which is a consequence of it. - #309's `--spec-path` and `--code-path` help is kept beside this branch's `--tracking-branch` help. - **c578557 merges main d82a83e.** It brought #245 (161c6c9). #245 splits the submodule findings, puts its relative-url findings last, and returns `check`'s NOTEs as a third value. - This branch's group stays after #270's and before #245's, so the tail of `_entry_findings` is `findings + split + unwritable_findings + value_findings + moved`. - Two of this branch's tests now unpack that third value. - `_repository_urls` keeps `adopt-name-too-long` in #245's shape. - **The same merge brought #351 (#282).** Its `_identity` refuses an `--elected-by`, `--reference` or `--name` that no file can hold, as `scaffold-unwritable-value`, before this branch asks the validator. So the scaffold and adopt refuse in the same order. - a5438a5 applies the compose patch written with #351, unchanged. - Five of this branch's scaffold test cases now expect #351's refusal: `--elected-by` TAB in two tests, `\x0b`, `\x1c` and U+2028. - Without the patch, the merged tree fails exactly those five. - **ba58838 and c6f441b merge main 1f90a0f and 92e29d9** through `gh pr update-branch`, with no conflict. They bring #371 (a983e6a), #354 (8325d05), #380 (1f90a0f) and #377 (92e29d9). **A behaviour change since the head first approved (f45edf4).** `plan --reference $'\t'` and `plan --elected-by ' \n'` were refused as `adopt-manifest-invalid` and are now refused as `adopt-unwritable-value`. `plan --elected-by 'Bob\u202e'`, which f45edf4 planned, is now refused too. The same holds for `check` and `execute` on a value both rules judge: the code is now #270's. No test of this branch pins the old code, and the new order is the one #270's tests require. Targeted runs on a5438a5, with `git-filter-repo` 2.47.0 on PATH and the checkout's git identity blanked, are below. | run | files | result | |---|---|---| | this branch and #351 | `test_adopt_manifest_values.py`, `test_scaffold_manifest_values.py`, `test_scaffold_free_text_values.py` | 298 passed | | #270 | `test_adopt_template_values.py` | 77 passed, 1 skipped (`:223`: this checkout's own git config names a user, which outranks the test's `GIT_CONFIG_GLOBAL`; it passes in an export of the tree) | | #245 | `test_adopt_relative_submodule_url.py`, `test_adopt_gitmodules_values.py`, `test_adopt_submodules.py` | 352 passed | | the doctor | `test_shape_doctor.py` | 255 passed, 3 skipped (the PowerShell line, a Windows host only) | | neighbours | `test_scaffold_leg_path_collisions.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_scaffold_pin_collisions.py`, `test_adopt_clone_template.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_literal_names.py`, `test_template_quoted_values.py`, `test_template_values_single_pass.py` | 506 passed | The same five runs were repeated on c6f441b, the landing head. #377's `test_adopt_entry_paths.py` and `test_adopt_plan_escapes.py` were added to the neighbours. Results: 298 passed; 77 passed and 1 skipped; 361 passed; 255 passed and 3 skipped; 644 passed. Reviewed adversarially (APPROVE WITH NITS, 0 blockers, 3 should-fixes); fix round 1 handles the three and four nits, and its re-verification found no blocker or should-fix. Fix round 2 validates with a placeholder commit, so the manifest question starts no process; its re-verification found the code correct. Fix round 3 is nits only. CI is green on Linux, macOS and Windows, with SonarCloud passing, at c6f441b (main 92e29d9 merged in). The combined tree with main 45e01f4 passed `test_agents_md_codes.py`, `test_shape_doctor.py` and `test_adopt_manifest_values.py` locally (391 passed, 3 skipped). A re-verification of the merge with ab623dd (4b8da3d) confirmed the merge, the refusal order and the composition. Its one nit, two stale sentences about the order, is fixed here. Closes #258 Closes #261 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is refused and a name holding a lone surrogate is a finding, so `pins: [a, b]]` is not the pin `b]` and `name: "a\ud83db"` no longer kills `make validate` (#301, #280) (#390) Two reader-side defects the lane filed, in one commit on `fix/yaml-flow-brackets-surrogates`, cut from main c7c02fa. Below, "this head" is that commit, 21fbea4, the one the review approved; main was merged into the branch afterwards (last paragraph). **#301, `scripts/repo_shape.py`.** In YAML a plain scalar inside a flow collection ends at any of `[ ] { } ,`. `_split_flow` counts brackets to find its commas and never asked whether the count went below zero or ended above it, so a stray bracket was kept as text: - `pins: [a, b]]` read `['a', 'b]']`; - `k: [x [a, b]]` read `['x [a, b]']`; - since #284, `k: {a"]:}` read the key `a"]`. PyYAML refuses each. A new `_refuse_flow_indicator` refuses a PLAIN item or value (called from `_flow_value`) and a PLAIN key (called from `_split_key_flow`) of a flow collection that holds `[`, `]`, `{` or `}`. The refusal is a `YamlError`, so `load_yaml` turns it into `REFUSED yaml-unparsable` and names the scalar: ``a plain scalar in a flow collection holds `]`, which ends it in YAML: 'b]'``. These still read as before: - a quoted scalar's brackets (`k: ["a]", {"b}": "c]"}]`); - nested and empty flow collections; - a bracket in a BLOCK plain scalar (`k: a[b]`, `- see [1]`); - a bracket in a comment after a flow. No other function changed. **#280, `templates/assembly-root/scripts/validate-manifest.py`.** `_name_finding` now reports a display name holding any of U+D800 to U+DFFF as `manifest-name`: ``` FINDING manifest-name: name is 'a\ud83db', which holds U+D83D, a lone surrogate: no UTF-8 file, commit message or console can hold it. Write each character as itself, or as one `\U` escape (`\U0001F600`, not the pair `\uD83D\uDE00`). ``` The finding goes to stderr with the name escaped by `repr`, before the `manifest ok` line could raise. **Why the #280 refusal is in the validator and not in the reader.** Two places could refuse a lone surrogate, the reader and the validator. The evidence rules out the reader: - **The writer emits a lone-surrogate escape on purpose.** The escape is `\uDXXX`, a `\u` and four hex digits from D800 to DFFF. `double_quoted('caf\udce9')` is `"caf\udce9"`. That is how `adopt-project.py`'s plan keeps a path byte that is not UTF-8 (#199), and `check` must read it back. `tests/test_adopt_entry_paths.py` holds that round trip (120 passed with `test_adopt_plan.py` on this head). Lane 1's pending #189 branch (70f4d29, `submodule_record`) also writes `\udce9` escapes into `project.yaml`'s `submodules:` block, and its docstring relies on the reader resolving them. A reader refusal would break both. - **PyYAML 6.0.3 agrees with the reader.** The pure-Python `SafeLoader` reads `name: "a\ud83db"` as the lone surrogate. Only libyaml (`CSafeLoader`) refuses it, and it refuses even the escape pair `"\ud83d\ude00"`, which the Python loader reads as two surrogates, as this reader does. - **The defect is in a value, not in the YAML.** A display name is text a person chose, and no UTF-8 stream holds a surrogate. On c7c02fa and on this head, with local bare remotes only: | launcher | c7c02fa | 21fbea4 | |---|---|---| | `scripts/validate-manifest.py` | exit 1, `UnicodeEncodeError` traceback | exit 1, the `manifest-name` finding | | `make validate` | exit 2 on that traceback | exit 2 on the finding | | `shape-doctor.py --root .` | `INVALID (manifest)`, the row quotes `UnicodeEncodeError: ...` | `INVALID (manifest)`, the row quotes `FINDING manifest-name: ...` | | a real `bump-leg.py` | exit 0, prints `manifest ok: a\ud83db`, and COMMITS the subject `Bump spec leg to <sha> in a?b` (`errors="replace"`) | exit 2 `bump-leg-validators-red`, every byte rolled back, 0 commits | | `update-shape.py apply` | exit 0, prints `manifest ok: a\ud83db`, commits | exit 2 `update-validators-red`, restored, 0 commits | So all four launchers now agree, and each names the problem. **Readers and callers this changes.** - **#301:** every `parse_yaml`/`load_yaml` caller, and only for a document whose flow collection holds a plain scalar with a bracket. Such a document was read, or misread, before and is refused now. Every tracked YAML file loads the same on c7c02fa and on this head (14/14 raw, 18/18 rendered templates). So do the 6 YAML files of a scaffolded project (`project.yaml`, four `contracts/*.yaml` and `validate.yml`): 6/6 on a c7c02fa scaffold and 6/6 on a 21fbea4 scaffold. No writer emits such a scalar: - every flow sequence written in the tree is bare names or `[]` (adopt's `pins: [...]` and `allow_empty_legs: [...]`, `naming_block`'s `also_matches` and `referent_chain`); - `double_quoted` quotes any value holding a bracket. - **#280:** the project's own `scripts/validate-manifest.py` (a digest-pinned shape copy; it reaches existing projects through `update-shape.py apply`, which reports it `upstream-changed`). Every caller of that copy now stops at the finding: - `make validate`; - the CI step in `validate.yml`; - `shape-doctor.py`'s `manifest` row; - `bump-leg.py` and `update-shape.py apply` (their red-validator refusal and rollback); - a family holder's `make validate`, which runs each member's. `bootstrap.py` runs the same copy in its step (b), but on such a name it dies at its own first `print`, before step (b) (see Left out). With #314 merged as well, the scaffold's pre-check asks this validator about `--name`. In the tree of this head and #314 alone, `scaffold-project.py --name $'caf\xe9' --dry-run` is refused `scaffold-manifest-invalid` with this finding before anything is created (checked at #314's head 4b8da3d). With #351 (landed as d82a83e) and without #314, that `--name` is already refused, as `scaffold-unwritable-value`. **Evidence.** - **Issue rows.** All ten #301 documents reproduce on c7c02fa and are refused on this head. 8 are documents PyYAML refuses. 2 (`k: {a:}#}: b}`, `k: {}#":}`) PyYAML reads only by taking a `#` with no space before it for a comment, and they are refused by design, as `k: "a"#c` is. Every #280 row is in the table above. - **Seeded oracle fuzz.** Corpus: an earlier review's, seed 2490250, 785,646 documents in six contexts. PyYAML 6.0.3 is the oracle only. | | c7c02fa | 21fbea4 | |---|---|---| | read the way PyYAML reads it | 254,629 | 254,629 | | read, where PyYAML refuses | 179,999 | 112,562 | | refused, where PyYAML reads | 48,434 | 49,492 | | read, but not as PyYAML reads it | 2,330 | 1,272 | | the flow contexts alone: read where PyYAML refuses / refused where PyYAML reads | 77,073 / 15,413 | 10,306 / 16,465 | Only two kinds of document change: - 67,437 invalid documents c7c02fa read are refused; - 1,058 documents c7c02fa MISREAD are refused. 1,037 of these are PyYAML's `#` leniency, and 21 are a single-pair mapping in a flow sequence (`k: [a:[]]`), which the subset never read as a mapping. Nothing c7c02fa read correctly changes, nothing is newly read, and there are 0 crashes. Against the issue's two-line `depth < 0` sketch, run on the same corpus, this head refuses everything the sketch refuses, plus 21,631 more: 21,608 invalid documents (the opener class, such as `k: [a[]` and `k: [x [a, b]]`) and 23 misreads. The fuzz ran on 38738cf, whose `scripts/` and `templates/` are byte-identical to 21fbea4's. - **Tests fail on c7c02fa.** With the four test files copied onto c7c02fa: 45 failed, 191 passed. The failures are 31 bracket rows, 10 validator rows and the 4 launchers. - **Mutation probes.** Each hunk was reverted or weakened on its own, and each mutant fails new tests: - M1, no check on items and values: 23 failed; - M2, no check on keys: 8; - M3, no quoted-scalar exemption: 14; - M4, no quoted-key gate: 11; - M5, closers only: 3; - M6, no surrogate finding: 10; - M7, upper bound excludes U+DFFF: 2; - M8, lower bound excludes U+D800: 2. **Tests.** - New: `tests/test_yaml_flow_brackets.py`, with the ten issue rows, sixteen more positions, the refusal's wording, a file refusal, fifteen still-read rows and the writer round trip. - In `tests/test_manifest_validator.py`: five surrogate spellings, each under a pipe and under `PYTHONIOENCODING=utf-8`, plus four still-ok controls on either side of the range. - In `tests/test_validator_child_encoding.py`: a real bump, `update-shape.py apply`, the doctor and `make validate`, each on `name: "a\ud83db"`. - `tests/test_yaml_quote_boundaries.py`: its docstring said `k: "a"#c` was the only PyYAML disagreement. There are eight; seven are `BRACKET_IN_A_KEY` rows refused by design. Targeted runs on 21fbea4, Linux, Python 3.12.3, git-filter-repo 2.47.0, each in the background: - the reader and validator files (`test_yaml_flow_brackets`, `test_yaml_flow_quotes`, `test_yaml_flow_mappings`, `test_yaml_quote_boundaries`, `test_yaml_block_scalar_keys`, `test_load_yaml_refusals`, `test_adopt_plan_escapes`, `test_manifest_validator`, `test_pin_validator`, `test_neutral_pin_validator`, `test_template_quoted_values`): **462 passed**; - `test_validator_child_encoding`, `test_stdout_encoding`, `test_repo_hygiene`, `test_cli_reference`: **165 passed, 1 skipped** (`test_stdout_encoding.py:286`, a Windows-only default); - `test_adopt_plan`, `test_adopt_entry_paths`: **120 passed, 1 skipped** (`test_adopt_plan.py:393`, `SHAPE_MEDXEHR_CLONE` unset); - `test_shape_doctor`: **242 passed, 3 skipped** (`test_shape_doctor.py:5330`, PowerShell on a Windows host only); - this head merged with origin/main 86da808, which carries #316: `test_template_quoted_values`, `test_template_values_single_pass`, `test_yaml_flow_brackets`, `test_yaml_quote_boundaries`, `test_manifest_validator`: **412 passed**; - this head merged with #314 (ab66bc0): `test_scaffold_manifest_values`, `test_adopt_manifest_values`, `test_manifest_validator`, `test_yaml_flow_brackets`: **293 passed**. The full suite and the macOS and Windows legs did not run before this PR's CI. **Merge-tree, as of 2026-10-12T15:12Z** (`git merge-tree --write-tree --name-only`), against origin/main d82a83e and the heads named here; each sha is the one it had then, and main has moved since. - **Clean with this commit:** - origin/main d82a83e, which carries #316 (34ecbc2), #245 (head a0272b2, landed at 14:55Z as 161c6c9) and #351 (landed as d82a83e). #245's one change to `scripts/repo_shape.py` is in `remote_key`, away from the YAML reader, and #351 touches `scaffold-project.py` only; - #314 (4b8da3d). With it merged, the `--name` refusal above still holds (checked at 4b8da3d); - lane 1's `fix/adopt-late-failure-notice` (9f1778d) and `fix/adopt-plan-quote-yaml-keywords` (d38e143); - the sibling `fix/pin-source-env-collisions` (34dbd86, #275); - every other open PR head but one: 28 of the 29 open, all but #358. - **Conflicts that are not this commit's.** Each conflicts the same way with origin/main alone, in a file this commit does not touch: - #358 (ebffb08), in `shape-doctor.py`; - lane 1's `fix/adopt-root-kept-symlinks` (6c44591), in `adopt-project.py`. - **Five heads already conflict with origin/main itself, and merge clean with this commit:** #314, #356 (5a5011d), #382 (7e55d00) and lane 1's `fix/adopt-plan-quote-yaml-keywords` in `adopt-project.py`, and lane 1's `fix/adopt-late-failure-notice` in `tests/conftest.py`. - **One conflict is this commit's:** `fix/adopt-record-submodule-placement` (70f4d29, lane 1, #189), in one hunk, the import block of `tests/test_manifest_validator.py`. Both branches add to it: that branch adds `import os` and `import sys`, and this one adds `import subprocess` between them, so the resolution is all three. Its `repo_shape.py` and `validate-manifest.py` changes auto-merge. Not resolved in either branch. **The review.** An independent opus review at 21fbea4 approved it with nits: 0 blocker, 0 should-fix, 5 nit. All five are in text (this description, the author's report and the commit message), none in the code or the tests. Four were fixed in the description; the fifth, the commit message's spelling of the surrogate escape as a six-digit one, is fixed in this message: the escape is `\uDXXX`, a `\u` and four hex digits, D800 to DFFF. Its own seeded structured fuzz (seed 7301280: 120,000 nested flows in five block contexts, 600,000 documents, PyYAML 6.0.3 as the oracle only) found 0 changed reads: no document c7c02fa read the way PyYAML reads it changed, and nothing crashed. An instrumented reader returned no plain scalar holding `[ ] { }` inside a flow on this head, against 76,955 on c7c02fa. The author's seed 2490250 counts reproduced row for row, and all 13 mutants it tried were killed. **664 passed** on this head merged with origin/main ab623dd (16 test files; 361 on the head alone, 8 files). The one conflict, with lane 1's #189 branch, is the `tests/test_manifest_validator.py` import block, and resolves to all three imports. Three follow-ups it confirmed are not in this change: the bootstrap's raw name print and the flow entries still read (both under Left out), and a block value that opens with `]` or `}`, which c7c02fa and this head both read where PyYAML refuses. **Left out.** - **A plain flow entry that opens with an indicator, and an empty flow entry.** Most of what this head still reads where PyYAML refuses is one of two classes: - an entry opening with `:`, `#`, `?` or `-` (`k: {:":}` reads `{':"': None}`, `k: [:]`; 6,286 flow documents); - an empty entry from a leading or doubled comma (`k: [,a]` reads `['a']`, `k: [a,,b]` reads `['a', 'b']`; 3,858). Neither is a bracket, and #301 does not claim them. - **A single-pair mapping in a flow sequence.** `k: [a: b]` still reads as the string `'a: b'`, where PyYAML reads `[{'a': 'b'}]`. Only the ones holding a bracket are now refused. - **`templates/assembly-root/scripts/bootstrap.py`.** It prints the display name raw (`bootstrap: <name> (<root>)`) before its validators run. On a project named `"a\ud83db"`, `make bootstrap` still dies there with `UnicodeEncodeError`, on c7c02fa and on this head (filed as #391). - **`elected_by` and `reference`.** A surrogate in either is still accepted. Neither is printed by the validator, so nothing raises; the scaffold side of such values is #282. - **The escape pair `"\ud83d\ude00"`.** It is still two surrogates, not one character, as PyYAML's Python loader reads it. The finding asks for the character itself or its one `\U` escape instead of the reader inventing a joining rule. Reviewed adversarially at 21fbea4 (APPROVE WITH NITS: 0 blockers, 0 should-fixes, 5 nits, all in text and none in the code or the tests). The code and the tests needed no fix round; the nits are fixed in this message. Main was merged into the branch after the review with no conflict: at that merge (c1a119d, main 45e01f4 merged in) the branch's own diff against main is the one reviewed, the same 6 files with 422 lines added and 6 removed. CI is green on Linux, macOS and Windows, with SonarCloud passing, at c1a119d (main 45e01f4 merged in). The combined tree with main 3ef059c (#314 landed) was run locally before landing. Closes #301 Closes #280 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut family.yaml before creating anything, `--dry-run` too, so `--id null`, `--tracking-branch true`, a blank `--created-by` and `--created-on 20261011` are refused (#307) (#397) `family.py init` rendered `templates/family-root/family.yaml` from `--id`, `--org`, `--name`, `--created-by`, `--created-on` and `--tracking-branch` and pushed it. The holder reads that file with its own YAML reader and its own `scripts/validate-family.py`, and `init` asked neither anything. Every row of #307 reproduces on c7c02fa (the issue's own script, with a stub `gh`): `--dry-run` and the real run both exit 0 for all 15 rows, and - `--created-by ' '`, `--created-on 20261011` / `2026-W41-6` / `tomorrow`, `--id null|true|false`, `--org null`, `--name 'Contoso Group'` leave a holder whose `validate-family.py` exits 1 from its first commit. `init` cannot be run again over it (`family-remote-exists`, no `--force`). - `--tracking-branch true`, `007`, `null`, `2026` exit 0 with `validate-family.py` exit 0 (it has no rule about `tracking_branch`), the holder pushed on that branch, `family.yaml` reading back as `True`, `7`, `None`, `2026`, and `update-shape.py apply --branch` printing `gh pr create --base True` / `--base 7` / `--base main` (for `null`) / `--base 2026`. **What changed.** `scripts/family.py` asks two questions about the manifest `init` is about to write, right after the substitution table is built: before the plan is printed, before the `--dry-run` return, before `_create_holder_remote` (which is where `gh repo create` runs against GitHub). Both refuse, name the flag, and say nothing was created. - `family_manifest_misreads` renders each line of the TEMPLATE that carries one of the values, reads it back with the holder's own reader (`repo_shape.parse_yaml`) and refuses a value that does not come back as the same text: `family-manifest-misread`. This is what catches `--id null`, `--org null`, `--created-on 20261011` and the silent `--tracking-branch true|007|null|2026`. A line the refusal quotes is shown the way Python spells it where it holds a line break or an escape, so a value cannot forge a second line of the refusal. - `family_manifest_invalid` renders the whole `family.yaml` and runs the validator the template ships over it, loaded from `templates/family-root/scripts/validate-family.py` (`Report` and `_check_envelope`), never re-implemented: `family-manifest-invalid`, in the validator's own words. This is what catches a blank `--created-by`, `--created-on 2026-W41-6` / `tomorrow` and `--name 'Contoso Group'`, and whatever the validator learns next. A misread is reported first and alone, because the validator's finding about it (`id is None`) is its consequence. A validator that is missing, broken, raising or exiting, whose `Report` has no `findings`, or that answers with a finding that is not text, is `family-validator-unusable`, not a traceback and not a zero. - A value no flag supplied is named by where it came from (`--id (not given, so --family lowercased)`). - `init --help` and `docs/cli.md` (regenerated, `render-cli-reference.py --check` ok) say what is held to what. No template is edited. A refused value is refused, not quoted or corrected. **Fix round 1 (the review of 14e2d68).** The first review found one broken test elsewhere in the suite and four claims no test guarded. This round is one commit on top of 14e2d68 (tests, plus one change to `scripts/family.py` for the validator that cannot be asked); the first commit is not amended. - **`tests/test_stdout_encoding.py` (the blocker).** The `family-init-dry-run` row ran `init --name <non-ASCII> --dry-run` and asserted the name is printed in the plan. The holder's own `validate-family.py` refuses that name (`naming-unclassified`: a family holder is one CamelCase token), so `init` now refuses it before the plan prints. That is this PR working as intended, so the TEST changed and the refusal did not: the row drops `--name`. The plan still prints the non-ASCII `--created-by` on its `created by` line, so the constrained stdout still has a value to escape (`created by 日本 ...` under `cp1252`, the characters themselves under UTF-8), and the row still fails if `family.py main` stops calling `escape_what_stdout_cannot_encode`. On the first head the old row failed (`1 failed`); the row as it is now passes on this head, and on this head merged with origin/main. - **A validator that cannot be asked.** The in-process call now sits in one `try` that also catches `SystemExit` and reads `report.findings` there, and a finding that is not text is refused by name. Three cases used to escape the refusal: a `Report` with no `findings` (an `AttributeError` traceback), a finding that is not a string (a `TypeError` from the regular expression) and a `_check_envelope` that calls `sys.exit(0)` (`init` exited 0, having printed and created nothing). Each is now `family-validator-unusable`, exit 2, nothing created; one test per case over a deliberately broken copy of the template, and one run at the command line for the exit. - **`_line_shown`.** A value with a line break, an ESC and a would-be forged line (`x\nFINDING forged: y\x1b[2J`, and a CR, U+2028 and U+0085 variant) is one escaped sentence: asserted on `family_manifest_misreads` itself, through `main` and what it prints (in process, so no argv crosses to Windows), and at the command line (skipped on Windows before any call, since argv is UTF-16 there; the in-process rows hold it on every OS). - **Two more guarded hunks.** The `sys.path` restore (also when the validator fails to load) and the comment-line skip each had a surviving mutant; each now has a test that kills it. The bytecode flag's restore is guarded by the same tests. - **The bytecode test** asked the REAL template's `__pycache__` before and after, so any stale `validate-family*.pyc` there failed it whatever the code did. It runs over a copy of the template made without a `__pycache__`, with the flag cleared first so that it is `family_validator` that must set it, and a control shows that importing the same file that way does write a `.pyc`. **How this composes with #259 (Brett ruled to quote the placeholders).** The misread check never says which lines are bare: it renders the real template line and reads it back. So once `fix/template-quoted-placeholders` (PR #395, first head 233dc71) quotes `id:`, `org:`, `created_on:`, `visibility:` and `tracking_branch:`, `--id null`, `--org null` and `--tracking-branch true|007|null|2026` read back exactly, the first question refuses nothing, and no edit is needed here. What the validator refuses stays refused: `--created-on 20261011` becomes `family-manifest-invalid` (`family-created-on`) instead of a misread, and a blank `--created-by` and `--name 'Contoso Group'` are unchanged. Proved three ways, all in `tests/test_family_init_values.py`: - the same values through a copy of the template with the placeholder forced quoted: accepted, and `parse_yaml(render(...))` reads the exact text back; - an end-to-end run of a forged one-commit checkout of `scripts/`, `templates/` and `contracts/` whose `family.yaml` is forced quoted: `init` exits 0, the holder's own `validate-family.py` exits 0, `family.yaml` reads back as typed, and the branch pushed is the typed one (a second forged checkout forced bare: refused, nothing created); - the tests that read the real template's own quoting to say what to expect: run here with the real template as it is, and on a tree with #259's branch merged in (below), so the file passes whichever of this and #259 lands first. **Once #259 is on main, 19 rows skip for good and one sentence goes unreached.** With the template quoted, the 16 end-to-end rows of `test_init_asks_the_holders_own_programs_before_anything_is_created` whose quoted answer is "the run is fine" and the 3 of `test_an_id_the_family_name_would_derive_is_named_by_where_it_came_from` skip with a message that says why (the 8 `test_a_quoted_value_is_held_as_typed` rows un-skip and pass), and nothing fails. `MANIFEST_MISREAD_REMEDIATION`'s "family.yaml writes a value like these unquoted" is then text no real template reaches; a template that writes one bare (the forged bare checkout the tests use) still does. Nothing is changed here for it: whichever of this and #259 lands second can drop the dead rows and reword that sentence in the same PR. **Same helpers as #314 (#261)?** #314's `manifest_misreads` / `manifest_invalid` are hard-wired to the assembly root (`project.yaml`, its templated list, `validate-manifest.py`'s `_findings`) and were not on main when this PR was written, so this PR writes the smallest version the family needs, in `scripts/family.py` (not `shape_materialize.py`, which #266, #270, #314 and #316 all edit). They differ only in the template, the validator's entry point and the sentence that names the file. #314 landed first (3ef059c), and this PR leaves both sets of helpers as they are: **unifying them is left to a follow-up on #459's checklist (#459 (comment) **Evidence, refreshed when this PR was opened (head abb27e6).** Main had then moved to 28bdc84: #345 landed as 3ae0109, #350 as 0f67f7b, and lane 3's `single-repository.yaml` as 28bdc84. The review's re-verify of abb27e6 ran on the head merged with 28bdc84: clean, with the six init, encoding, CLI-reference and family files at 305 passed / 9 skipped, and `test_family.py` with #345's `test_family_dry_run.py` at 126 passed. Merge-tree was also clean against #259's 3894aa8 (PR #395) and #314's 4b8da3d. The results below were taken before that; main has moved since. - Reproduction, before and after, with the issue's script: on c7c02fa every row exits 0; on this branch all 13 failing rows now exit 2 on `--dry-run` and on the real run, with no holder, no remote directory and nothing pushed; the two controls (`--tracking-branch main`, `--name ContosoGroup`) are unchanged. `--tracking-branch 2026` is refused too, as #314 does for the scaffold: it worked only because every reader calls `str()` on the number. - `tests/test_family_init_values.py`: 125 tests, 103 of them fail on c7c02fa; on this branch 117 pass and 8 skip (the 8 rows that need the real template to be quoted, which they run against the day it is). On 14e2d68, the first head, the 4 tests of this round that guard a code change are the ones that fail (the three validator cases and the exit at the command line); the rest guard what 14e2d68 already did. - Mutations: fifteen of the change in the first round (drop the call, move it after the `--dry-run` return, move it after the remote is created, empty either question, ignore the template's quoting, ignore the template's validator, drop the not-given naming, drop a finding no flag owns, write bytecode into the template, let a validator's `SystemExit` through at load, ask the validator first, drop the unusable-validator checks), and eight more in this round, each killed by a new test: `_line_shown` always in backticks (9 fail), the `sys.path` restore dropped (2), the bytecode flag's restore dropped (2), the comment skip dropped (1), `findings` read outside the `try` (1), the non-text check dropped (1), `SystemExit` let through the run (2, one a zero at the command line), and `main` without `escape_what_stdout_cannot_encode` (the `family-init-dry-run` row of `test_stdout_encoding.py`). The write-bytecode mutation is now killed by the new bytecode test. - Targeted runs on the head of this branch, abb27e6 (Linux, Python 3.12.3, git 2.43.0, git-filter-repo 2.47.0): `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py`: 276 passed, 9 skipped (the 8 rows that need a quoted template, and one Windows-only row). - Merged with origin/main at 161c6c9 (which has #316, #317, #318, #245 and #349): `test_family_init_values.py test_family.py test_family_add_refusals.py test_family_add_excludes.py test_family_member_path.py test_stdout_encoding.py test_cli_reference.py` 407 passed, 9 skipped; `test_shape_doctor.py` 255 passed, 3 skipped (the PowerShell row, which needs a Windows host); `test_update_shape_add.py test_update_shape_pr_base.py test_template_values_single_pass.py test_scaffold_ignored_shape_files.py` 223 passed (with the git identity blanked, because the standard's own checkout sets a `user.name` that scaffold and adopt tests read). Main then moved to 0f67f7b (#350 landed, which changes the validator this reads, and a scaffold PR): `test_family_init_values.py test_stdout_encoding.py test_cli_reference.py test_validate_family_duplicates.py` on this branch merged with it: 197 passed, 9 skipped. - Merge-tree (`git merge-tree --write-tree`), all clean: origin/main at 0f67f7b; #345 (abaaf3c, which landed as 3ae0109); #314 (4b8da3d); and `fix/template-quoted-placeholders` (3894aa8, #259). This branch + origin/main + #345 + #259's branch together are clean too, and `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py test_family_dry_run.py test_validate_family_duplicates.py test_template_text_placeholders.py` there: 442 passed, 20 skipped (the 19 rows above, which skip because #259 quotes the template, and one Windows-only row). #345 changes `cmd_bump`, `cmd_remove` and `build_parser`, not `cmd_init`; #350 (landed) added member-name checks to the validator and left `_check_envelope` and `Report`, the two names this change asks, as they are. #314 at 4b8da3d did not merge with origin/main by itself then (`adopt-project.py`, from what landed after its last merge), so it was not composed with the rest, but it was clean against this branch alone. #309, #312, #316, #317, #318 and #350, which the first round merged against or named, have landed. - `--created-by '{{ORG}}'`: on c7c02fa `init` created the remote and then died with `template-unsubstituted` once the worktree was rendered. Merged with main, #316's one-pass `render` writes the value as typed: `init` exits 0, `family.yaml` records `created_by: "{{ORG}}"` and the holder's own `validate-family.py` is green. This change does not touch it and does not refuse it. (On this branch alone, without #316, the pre-flight render refuses it before anything exists, with the same `template-unsubstituted` text.) **Left out.** - `AGENTS.md`, `README.md`, `docs/handbook.html`: no prose change (#309 has landed since; `init --help` carries the sentence). - A calendar check of `--created-on` (`2026-13-45` passes the validator's `DATE_RE`): the validator's rule, not copied here. - A branch git itself refuses (`a..b`, `x.lock`): #283. - `--org test+org` passes `validate-family.py`; not claimed. - `--created-by` with an ESC or OSC is printed raw by the plan's `created by` line on c7c02fa and here: not part of this change. - No run against GitHub (the `gh repo create` ordering is proved in-process, with every step that would reach GitHub replaced by one that fails the test), none on macOS or Windows. Reviewed adversarially at 14e2d68 (CHANGES NEEDED: 1 blocker, 1 should-fix, 6 nits). The blocker was a test elsewhere in the suite that the new refusal turned red, `tests/test_stdout_encoding.py`'s `family-init-dry-run` row; the should-fix was the line-escaping hunk (`_line_shown`), which no test guarded. Fix round 1 (abb27e6) changed that test and not the refusal, guarded `_line_shown` and the two other surviving mutants, closed the three ways a broken validator escaped `family-validator-unusable`, and rewrote the bytecode test over a copy of the template. Its re-verification at abb27e6 found every finding closed: APPROVE WITH NITS (0 blockers, 0 should-fixes, 2 nits, both about this description and the commit trailer and not about the code: the trailer names Opus 5.5 below, and the description's merge-tree and composition paragraphs are refreshed above). CI is green on Linux, macOS and Windows, with SonarCloud passing, at d216ba8 (main 8782333 merged in). Closes #307 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… exists, a source symlink with an empty target and the source's own `project.yaml` kept in the root, and `plan` no longer offers `root` for that file (#303, #305) (#387) Two ways `check` said `plan ok` to a source `execute` could not adopt, both reproduced on c7c02fa with the issues' own scripts. **#303, a symlink whose target is empty.** A `120000` entry whose blob is the empty blob passed `check`, which never reads a link's target. - With symlinks on (git's default where the disk can hold one), `execute` created both leg repositories, then failed in its first clone of the source: exit 2, nothing on stderr, two empty legs left behind. Linux refuses a symlink to nothing. This happened whatever the plan answered: `docs/empty-link` sent to spec, and a top-level `empty-link` answered `root` or `drop`. - With `core.symlinks=false`, it said `adoption verified`. The spec leg held the empty link, a clone of that leg exited 128, and the assembly's recursive clone exited 1. The top-level link answered `root` or `code` verified the same way. Only `drop` gave an assembly that clones. **#305, the source's own `project.yaml` answered `root`.** `plan` asked about it under `ambiguous-top-level-yaml`, whose question offers `root`. Answered `root`, `check` said `plan ok`. `execute` wrote the shape's manifest beside the source's file as `shape/project.yaml`, said "nothing was overwritten" and `adoption verified`. `shape-doctor.py` then said NOT A SHAPE ROOT (exit 2) and pointed at `adopt-project.py plan`. `make bootstrap` failed `naming (exit 2), manifest (exit 1), pins (exit 2)`. Answered `code` or `spec`, the doctor said COMPLIANT and `bootstrap ok`. **What changed.** - **`plan-empty-symlink` (#303).** `_empty_symlink_findings` reports each `120000` row of size 0 in `Source.tree()`. The size `ls-tree -l` gives a symlink is the length of its target, so no target is read; a SHA-256 repository reports size 0 too. - It is listed right after #196's `plan-unsafe-path`, through `_source_path_findings`, on the line `_entry_findings` already had. So `check` prints it, and `execute` refuses it through `_refuse_what_check_finds` before any leg exists (#168). - `EMPTY_SYMLINK_REMEDIATION` is said once after the findings, in `check` and in `execute`'s refusal. The fix is in the source: give the link a target, or remove it, commit, and re-run `plan`. - `plan` notes each such link in `check`'s words, and still writes the plan. - **`ambiguous-project-yaml` (#305).** A new rule in `contracts/path-classification.yaml`, above `ambiguous-top-level-yaml`, for the file `project.yaml`. Its question asks spec, code or drop, and says `root` is not an answer for this file. `release.yaml` keeps the old question. - **`plan-root-blocks-manifest` (#305).** #313's machinery is extended, not copied: `_shape_blocks`, which `_root_shape_findings` runs over each disk, now refuses the root holding `project.yaml` outright, with no copy question after it. It fires for a kept row at that path (file or symlink), or a kept directory there, on a disk that keeps case or one that ignores it. The finding's remediation is #313's `_fix_the_kept_path`. - A submodule there stays `plan-submodule-root`'s, said once. - `_kept_there` is the at-or-under lookup the copy branch already did, factored out for both. - `execute` refuses it before any leg exists, through the same list. - **Files.** `adopt-project.py` (+205/-13), `contracts/path-classification.yaml` (+28), the two new test files (399 and 366 lines) and `tests/test_adopt_root_shape_paths.py` (+6/-2). **Decisions.** - **#303 is refused whatever the plan answers, and whatever `core.symlinks` says.** - Where symlinks are made, no answer adopts. The run fails at the first clone of the source, which checks the whole tree out before anything is taken out. Measured with the link in `docs/` (sent to spec), at the top level answered `root` and `drop`, and with every path rooted or dropped and both legs seeded: there the seeded legs were pushed and the assembly's clone exited 128. - `check` cannot know where `execute` will run. A check that passed `drop` with symlinks off would pass a plan the next Linux machine cannot adopt, and the root's own `validate` workflow runs on `ubuntu-latest`. - The remediation costs one commit in the source. - **#305 covers the manifest only.** The issue asked whether other rendered root paths need the same rule. Measured on c7c02fa, with the source's own file kept in the root at each of the 18 `assembly_root_paths()`: - `check` passes all 18. - These verify over a broken assembly: `contracts/spec-pin.yaml` and `contracts/code-pin.yaml` (DRIFTED, pins fail); `scripts/validate-pins.py`, `scripts/validate-manifest.py`, `scripts/repo_shape.py` and `contracts/repository-naming.yaml` (INVALID); `scripts/validate-repository-naming.py` (COMPLIANT, naming fails); `scripts/bootstrap.py` (COMPLIANT, and `make bootstrap` runs the source's own script). - `contracts/shape-pin.yaml` fails verification after both legs exist. - README.md, AGENTS.md, CLAUDE.md, .gitignore, .gitattributes, AGENTS-shape.md and the workflow come out COMPLIANT with the copy beside them. `Makefile` is rooted by default, and the follow-up merge is its designed exit. - `project.yaml` is the one path `plan`'s own question offered `root` for. The others are reached only by hand-editing a `contracts/` or `scripts/` answer to `root`. They are left for a follow-up issue (under Left out), not folded in here. - **A symlink at `project.yaml` is refused too.** On c7c02fa: - a link to the source's own file verified and was NOT A SHAPE ROOT; - a link to nothing had the manifest written through it, and came out COMPLIANT on Linux. A clone that makes no symlinks holds that link as a file naming its target, so it is refused anyway, and #183 refuses a link to nothing at a shape path regardless; - a directory `project.yaml/` verified and was NOT A SHAPE ROOT. - **A case twin `Project.yaml` kept in the root is refused, with the case aside.** It adopts COMPLIANT on Linux. On a disk that ignores case, `place` writes the manifest beside it. This is the trade #172 and #313 already make. **Evidence.** In this section and in the two below it, "this head" is 69c54c9, the branch's own commit and the one the review approved; main and #314 were merged into the branch afterwards (last paragraphs). - **Reproductions, on this head** (exits as printed): - #303 run 1: `check exit 1`, one `plan-empty-symlink` finding; `execute exit 2`, `REFUSED plan-empty-symlink`, `leg repositories left:` empty. - #303 run 2 (`core.symlinks=false`): the same finding and refusal; no leg, no verification. - Top-level `empty-link`, symlinks off and answered root, code or drop, and symlinks on and answered root or drop: each `check exit 1`, `execute exit 2`, no leg. - #305 root: `check exit 1`, one `plan-root-blocks-manifest` finding; `execute exit 2`, `leg repositories:` empty. - #305 code, spec and drop: `plan ok`, `adoption verified`, the root's `project.yaml` begins `schema_version: 1`, doctor COMPLIANT, `bootstrap ok`. - The plan's entry for `project.yaml` now reads `rule: ambiguous-project-yaml`, with no `(root)` in its question. - `project.yaml` as a link to a file, a link to nothing, a directory, and the twin `Project.yaml`: each `check exit 1` and `REFUSED plan-root-blocks-manifest`. - No row needs real GitHub or a cloud tool. - **New tests.** `tests/test_adopt_empty_symlinks.py` (28) and `tests/test_adopt_root_manifest.py` (27). - The symlinks are made with `git update-index --cacheinfo`, so no disk symlink is needed. The one test that checks a link out (a link given a target, end to end) is skipped on Windows by its marker, before it runs. - On c7c02fa: 37 failed, 18 passed. The 18 are the controls that must pass on both sides: a symlink with a target, the remediations adopting, `project.yaml` sent to a leg or dropped, a submodule there, other shape paths keeping their copy, and `release.yaml`'s question. - **One existing test changed.** `test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding` in `tests/test_adopt_root_shape_paths.py` held that a kept file at any shape path is no finding. It now holds that for every path but the manifest, which is `plan-root-blocks-manifest`. - **Mutation probes:** 14 of 14 hunks reverted one at a time, each killed by at least one new test. - **Targeted runs.** The adopt list and the doctor ran on a local state of this branch (7a22a21) whose files were this head's but for `tests/test_adopt_root_shape_paths.py`. That file was re-run on this head, so the adopt list is two runs, not one sum. - The 15 adopt files (`test_adopt_empty_symlinks`, `_root_manifest`, `_root_shape_paths`, `_leg_path_collisions`, `_execute_refusals`, `_plan`, `_e2e`, `_submodules`, `_verify`, `_mount_ignored`, `_ignored_shape_files`, `_entry_paths`, `_finding_spelling`, `_history_gitlinks`, `_spec_only`) on 7a22a21: 837 passed, 1 failed, 1 skipped (a MedxEHR clone the plan test needs). The one failure is the #313 test this head changes. - `tests/test_adopt_root_shape_paths.py` alone, on this head: 120 passed. - `tests/test_shape_doctor.py`, as its own stream: 242 passed, 3 skipped (PowerShell lines only a Windows host prints). - `tests/test_scaffold_leg_path_collisions.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 167 passed. - This head merged with origin/main at 87373a6, which brings `tests/conftest.py`'s new `git()` identity: the two new files, `tests/test_adopt_root_shape_paths.py` and the three above, 342 passed. **The review.** An independent opus review at 69c54c9 approved with nits: 0 blocker, 0 should, 8 nit. It reproduced every row of both issues on c7c02fa and on this head, and killed 6 of 6 mutants: two of the author's, re-run, and four of its own. Its targeted runs on this head merged with origin/main ab623dd (`git merge-tree` clean, with #349's session `gh` guard in place): - the new files, `tests/test_adopt_root_shape_paths.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 379 passed; - the other adopt files, in two streams: 303 passed, 1 skipped (the MedxEHR clone), and 323 passed; - the clone-template, literal-names, template-values, plan-escapes, stdout-encoding and Windows-path tests, which cover #268 and #270: 266 passed, 2 skipped (an operator `user.name` set; Windows only); - `tests/test_shape_doctor.py`: 254 passed, 3 skipped (PowerShell lines only a Windows host prints). c7c02fa's own `tests/test_adopt_root_shape_paths.py` against this head's code gave 1 failed (the `project.yaml` row, `:191`) and 119 passed, so no other #313 behaviour changed. - **A cross-lane composition, with lane 1's #183.** On the tree merged with lane 1's `fix/adopt-root-kept-symlinks` (#183, 6c44591), `tests/test_adopt_root_symlinks.py::test_a_link_with_an_empty_target_is_fixed_in_the_source` fails: `AssertionError: assert 'plan-empty-symlink' == 'plan-root-symlink'`. An empty link at or above a shape path is reported by both findings, and `execute` refuses with the first, which is this branch's. The two remediations agree (fix it in the source). Whichever of the two lands second removes the duplicate report. The same file also fails 3 other tests on origin/main with #183 and without this branch (`[a-link-at-the-copy-itself]` and `test_the_materializer_refuses_before_it_writes`, two cases), because #313's `plan-root-blocks-shape-copy` and `materialize-copy-blocked` answer first; those are not this branch's. - **Two known nits, left for a later round.** - An empty link AT `project.yaml` (or at a #313 copy path) gets `plan-root-blocks-manifest`, whose fix offers `drop`, and `plan-empty-symlink`, which says `drop` is no fix. - `plan` notes every empty link (25 links print 25 NOTEs), where `check` names 20 and counts the rest. - The other nits are smaller. A symlink target that begins with a NUL byte passes `check` and fails like #303, and the issue's `_verify` bullet, are follow-up issues; the case twin `Project.yaml` is under Left out. The new tests' `SYMLINKS_OFF` replaces the Windows job's own `GIT_CONFIG_COUNT` settings (`core.autocrlf=false`, `core.longpaths=true`) for those child processes, which is harmless while none of them checks anything out. **Merge-tree, as of 2026-10-12T15:14Z.** `git merge-tree --write-tree --name-only` of this head (69c54c9), against heads fetched then; every sha below is the one it had then, and main has moved since (see the merge paragraph below). It is a textual merge only; the semantic meeting with #183 was tested by the review (the first item above). - origin/main: clean at 0f67f7b. That includes #316 (34ecbc2), #268 (e43fa95), #270 (f795cf7) and #245 (161c6c9), which have landed since this branch was cut. It was clean at 161c6c9 and at ab623dd, the review's merge, too. - Open PRs touching `adopt-project.py`: #184 (f416e13), #353 (dcbc37b), #356 (5a5011d), #371 (34b9500), #373 (5ac03de), #377 (c35c896), #378 (2da8691), #382 (7e55d00) and #384 (a96297e) are all clean. #356 and #382 conflict with origin/main alone (2 and 5 blocks, from #245's landing), not with this head. - #314 (4b8da3d) was NOT clean: one block, in `_entry_findings`' docstring, where each side adds a paragraph after the same one (this head's "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314's "A VALUE NO FILE CAN HOLD"). Keep both. It had been clean at f45edf4, and it had 3 blocks against origin/main alone since #245 landed, so it needed a merge of main in any case. - Lane 1's branches without a PR: `fix/adopt-late-failure-notice` (9f1778d), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-plan-quote-yaml-keywords` (d38e143) were clean. `fix/adopt-plan-quote-yaml-keywords` was 3 blocks against this head at e337f44, and conflicts with origin/main alone in 4. - `fix/adopt-root-kept-symlinks` (6c44591, #183) conflicts in 3 blocks of `adopt-project.py`: the module docstring's `check` paragraph, `_entry_findings`' docstring and its return line. They are the same 3 that c7c02fa itself has with that branch, and this branch's one added docstring line sits inside the first. It has 5 blocks against origin/main alone. - Lane 5's `fix/adopt-pin-writes-pin-file` (d806e8f): clean. `docs/adopt-execute-exits` is #371, above. - When #183 lands, an empty symlink kept at or above a shape path is reported twice, by `plan-empty-symlink` and by #183's `plan-root-symlink` (the first review item above). A dangling link kept at `project.yaml` is reported twice too, by `plan-root-blocks-manifest` and `plan-root-symlink`, and an empty one three times. Whichever lands second should keep one. **Brought onto main past #314 (bbbb541).** Since this branch was cut from c7c02fa, main took #245 (relative `.gitmodules` urls), #270 (`plan-unwritable-value`), #371 (`execute` verifies before it exits on a refused `gh pr create`), #377 (`plan` asks about every path `execute` will not extract) and #314 (`plan-manifest-invalid`, 3ef059c), all in `adopt-project.py`. The merge of origin/main 3ef059c has ONE conflict, in `_entry_findings`' docstring, where this branch adds "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314 adds "A VALUE NO FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261)" after the same paragraph: both are kept, this branch's first, because it describes the head of the list and #314's its tail. Nothing in code conflicted. `check` and `execute` read one list, and it composes as each feature says: a source's unsafe paths (#196), its empty symlinks (#303), coverage, `_leg_findings` with `plan-root-blocks-manifest` (#305), #166's split, #270's `plan-unwritable-value`, #314's `plan-manifest-invalid`, then #245's relative urls last. So an empty symlink beside an invalid value is still refused wearing `plan-empty-symlink`, with no leg created; probed on the merged tree with an empty link plus `visibility: secret`, plus a line break in `visibility`, and with `project.yaml` answered `root` plus `visibility: secret`, each listing the findings in that order. No test expectation or order changed: the merged tree was rehearsed before #314 landed (69c54c9 + 45e01f4 + c6f441b) and is the same tree object as the pushed merge, and `git diff 3ef059c bbbb541` is this PR's five files and nothing else (+1004/-15). Targeted runs on bbbb541: the new files, `test_adopt_root_shape_paths`, `test_adopt_manifest_values`, `test_scaffold_leg_path_collisions`, `test_agents_md_codes` and `test_cli_reference` 418 passed; `test_shape_doctor` and #377's, #245's and #270's adopt tests 595 passed, 4 skipped (PowerShell lines only a Windows host prints; one operator `user.name`); the other adopt files 234 passed, 1 skipped (the MedxEHR clone) and the guards, literal-names, clone-template, plan-escapes, gitmodules, leg-paths, stdout and Windows-path tests 1101 passed, 1 skipped. **Left out.** - `_verify` and the materializer do not themselves refuse a root `project.yaml` that is not the shape's manifest. This is the issue's second bullet. Once `execute` refuses the plan before any leg, through the same list `check` prints, nothing reaches them with one. It is a follow-up issue. - A top-level file named `project.yaml` only once case is ignored (`Project.yaml`) is still asked `ambiguous-top-level-yaml`'s question, which offers `root`. `check` refuses that answer with the case aside, before any leg. - #171 (a failed clone exits 2 silently) is lane 1's PR #184. This PR only keeps the empty-symlink run from reaching that clone. - Follow-up issues proposed by this PR (two since filed): - the source's own file kept in the root at one of the other shape paths the root's tools read (the pins, the validators, `scripts/repo_shape.py`, the naming policy, `scripts/bootstrap.py`, `contracts/shape-pin.yaml`), each measured above; - a symlink whose target begins with a NUL byte (`\0spec.md`, size 8), which `plan-empty-symlink`'s size test does not see: it fails like #303 (filed as #388); - `_verify` asking whether the split's root `project.yaml` is the shape's manifest (filed as #389); - an entry git cannot check out where `execute` runs, other than an empty symlink or a NUL-led one (a target longer than the platform allows, say), which is still found only by the first clone, after both legs exist. #303 leaves this as a note. Reviewed adversarially at 69c54c9 (APPROVE WITH NITS: 0 blockers, 0 should-fixes, 8 nits). The review reproduced every row of both issues on c7c02fa and on that head, and killed 6 of 6 mutants. No fix round followed: the branch's own commit is as reviewed, and what was added after it is the merge described above. CI is green on Linux, macOS and Windows, with SonarCloud passing, at fce86fa (main 7a8b981 merged in). Its merge with main a73b4e9 passed the targeted set (the three adopt test files, `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`): 597 passed, 3 skipped. Closes #303 Closes #305 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… text placeholders, so `--id 007`, `--tracking-branch 2026`, `--spec-path true` or `--org null` read back as typed, and `family.py add` quotes a member id alike (#259) (#395) Brett Heap ruled on 2026-10-12: "quote #259". The templates now write each text field a tool fills from a value it was given between double quotes: | template | fields | |---|---| | `templates/assembly-root/project.yaml` | `id`, `elected_on`, `visibility`, `tracking_branch`, the spec leg's `path`, the code leg's `path` | | `templates/assembly-root/contracts/spec-pin.yaml`, `code-pin.yaml` | `submodule_path` | | `templates/family-root/family.yaml` | `id`, `org`, `created_on`, `visibility`, `tracking_branch` | `render` already spells a value between a YAML template's double quotes with `repo_shape.double_quoted` (#284). The single-pass `render` from #316, on main as 34ecbc2, does the same through `YAML_PLACEHOLDER_RE`. So each value reads back exactly. An ordinary value renders as it did before, plus the two quote characters. `family.yaml`'s `org:` is quoted along with the four fields #259 names. It is the same defect in the same file: `family.py init --org 2026` and `--org null` pass `checked_value`, and on c7c02fa the holder then fails `family-org` (#307 lists the `--org null` row). Taking `org:` back out is not a one-line revert. It fails 14 of the new tests (the 8 `family.yaml:org` in-memory cases, and all 6 `family.py init` rows, because every row of `FAMILIES` passes a misread `--org`), and it would also take the `ORG` row out of `TEXT_FIELDS` and put plain `--org` values into `FAMILIES`. The lane keeps `org:` quoted, as the same defect in the same file, unless Brett says otherwise. `scripts/family.py`: `_member_lines` writes each member's own `project.yaml` id into the holder's `members:` block. That id now reads back exactly, so a bare `007` in the row came back as 7, and `validate-family.py` would report `member-id-mismatch` for a member it had just pinned. `_member_id` quotes an id only when the reader would not read the bare text back as the same text. No row whose id reads back as text moves: `irrs` is still `id: irrs`. Two edges: - A legacy row whose bare id the reader misreads moves once. A holder written before this change, by `family.py add` of a member scaffolded with `--id 2026`, holds `id: 2026`. The next `bump`, `add` or `remove` of that holder rewrites it as `id: "2026"`. That is harmless: `validate-family.py` compares with `str()` and passes before and after. - Mixed versions. A `family.py` from before this change, run against a holder this branch wrote, turns `id: "007"` back into `id: 007`, and `validate-family.py` then fails `member-id-mismatch` (the member declares `007`, `family.yaml` records `7`). A current `family.py` that rewrites the holder next then writes that row as `id: "7"`. Nothing in this branch can prevent it: run `family.py` from a current checkout on a holder that has such a member. Left unquoted on purpose: `topic` (`xf-project-<id>`), every `repository` (`<org>/<Name>`; the slash keeps it text), `SHAPE_REPOSITORY` and `DIGEST_DEFINITION` (constants), and the block or list placeholders (`NEUTRAL_PRODUCT_PINS`, `*_NAMING`, `SHAPE_FILES`). **Why.** Every reader of these files uses `repo_shape`'s YAML subset. It reads a bare `007` as 7, `2026` and `20261011` as numbers, `1.5` as a float, `true` as True and `null` as None. The checks on the flags (`PROJECT_ID_RE`, `checked_value`, the leg-path rule) accept every one of these. Reproduced on c7c02fa, with local bare remotes only: - `scaffold-project.py --id 2026|007|true|null|20261011` exits 0, and the project's own `validate-manifest.py` then fails `manifest-id`. - `--spec-path 2026 --code-path 007` fails `manifest-leg-path`, and `validate-pins.py` exits 2. - `--tracking-branch 007` pushes each leg on `007`, and the root's `bootstrap.py` then puts the legs on a local `7` with `fatal: no upstream configured`. `true` and `null` do the same on `True` and `main`. - `adopt-project.py execute` with `--tracking-branch 007` does the same; `--id 007|20261011` gives `manifest-id`; leg paths `2026`/`007` give `manifest-leg-path`. - `family.py init --id 2026|007|true|null|20261011` gives `family-id`; `--org 2026|null` gives `family-org`; `--tracking-branch 007|true|null` reads back as 7/True/None. On this branch every one of those rows reads back as typed, bootstraps onto the typed branch with `origin/<branch>` as upstream, and passes the root's own validators. **Every reader checked.** Every reader reads these fields through `load_yaml`/`parse_yaml` into a dict. None uses a line regex, `split(":")`, `startswith("<field>: ")` or a whole-line rewrite of them: - `templates/assembly-root/scripts/bootstrap.py`: `tracking_branch`, leg `path`. - `validate-manifest.py`: `id`, `elected_on`, `visibility`, leg `path`. - `validate-pins.py`: `submodule_path` against the manifest's `path`. The pin side is read as it stands and the manifest side goes through `str()`, so the two differ in type exactly when a bare value is misread (that is the `pin-path-mismatch` row above). With both lines quoted they are the same text. - `validate-repository-naming.py`: `str(id)` for the topic. - `templates/family-root/scripts/validate-family.py`: `id`, `org`, `created_on`, `visibility`, `str()` member-id compare. - `siblings.py`, family `bootstrap.py`. - `scripts/bump-leg.py`: `tracking`, leg `path`. Its `rewrite_pin` rewrites only `commit:` and `tree_sha256:`. - `update-shape.py`: `tracking_branch_of`. `rewrite_manifest` touches only the `shape:` block. - `scripts/family.py`: `tracking()`, the member's id, and the `members:` rewrite. The member-id writer is the one fixed here. - `shape-doctor.py`: leg `path`, member row `path`, working-clone `str(id)` compare. - `adopt-project.py`: plan reads and `_template_values`. - `setup-project.py`: `family.yaml` `name`/`kind` only. - `scripts/shape_advisory.py`: the single-repository record only. The `str()` fallbacks (`bootstrap.py`, `adopt-project.py`'s `checked_value`) stay, because projects made before this keep their bare lines. **Pins.** The four files are TEMPLATED, not COPIED, so no row of any project's `contracts/shape-pin.yaml` names them. `Atlas` was scaffolded and `family.py init Contoso` was run on c7c02fa and on this branch. Each `contracts/shape-pin.yaml` differs only in `commit:` and `digests.tree_sha256`, which identify the standard itself and change with every commit; every per-file `sha256` row is identical. `update-shape.py check` of the c7c02fa project and holder against this branch reports every copied file `unchanged` (11 and 10 rows): "no copied file differs; `apply` would move the pin alone". **Tests.** - New: `tests/test_template_text_placeholders.py`, 123 tests: - Each of the 13 fields is rendered with each of 8 values the reader misreads (`2026 007 1.5 true null 20261011 ~ False`), and must read back exactly. - 6 `scaffold-project.py` runs, then a recursive clone, `bootstrap.py`, the three validators, and `bump-leg.py`, which refuses on the tracking branch and finds the leg at its path on a branch of its own. - 6 `adopt-project.py` `plan`/`check`/`execute` runs into local bare remotes, then a clone, `bootstrap.py` and the validators. - 6 `family.py init` runs. - One `family.py add` of members whose ids are `007` and `true`. - **All 123 fail on c7c02fa.** - `true`/`null` through adopt: `plan` writes them bare into the plan, and the plan's reader then misreads them. That belongs to the plan writer, #181 (lane 1). Those two rows quote the value in the plan by hand, as #181's writer will, and test `check`/`execute`. - Mutation probes: reverting any one of the 13 quoted lines fails 8 cases of its field. Reverting `_member_id` fails the member test (the rows read 7/True). - The `try/except YamlError` in `_member_id` is guarded by the follow-up commit. The review found that calling the parse directly left the member test passing. Ids such as `[`, `{`, `&a`, `*a` and `!t` reach the `except` (they raise `YamlError` bare), and `_member_id` returns each one exactly, as it does 34 adversarial inputs in all (the review's in-process probe). `test_a_member_id_the_reader_refuses_bare_is_rewritten_between_quotes` (7 cases: `[`, `{`, `&a`, `*a`, `!t`, an unclosed `'` and an unclosed `"`) opens a hand-edited holder whose row holds the id between double quotes, rewrites the `members:` block with `Family.rewritten`, and asks that the id read back exactly and the text come out byte for byte. With the `try/except` removed all 7 fail with the reader's `YamlError`, and with the `except` returning the bare text all 7 fail too; the old member test still passes under the first of those mutants. - 14 existing tests that asserted or edited a bare rendered line now spell it quoted: `test_manifest_validator` ×5, `test_pin_validator`, `test_reference_default`, `test_scaffold_e2e` ×2, `test_setup_project_py`, `test_setup_sh`, `test_family`, `test_shape_doctor` ×2. - Targeted runs on this branch: | files | result | |---|---| | new file + `test_template_quoted_values`, `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_scaffold_leg_paths`, `test_scaffold_leg_path_collisions`, `test_reuse_visibility`, `test_reference_default` | 405 passed | | the six adopt files | 491 passed, 1 skipped (the MedxEHR clone is not set) | | `test_family`, `test_family_add_refusals`, `test_family_member_path`, `test_manifest_validator`, `test_pin_validator`, `test_bump_leg`, `test_update_shape`, `test_update_shape_pr_base`, `test_setup_project_py`, `test_park_resume_targets`, `test_repo_hygiene`, `test_cli_reference`, `test_setup_sh` | 603 passed | | `test_shape_doctor` | 241 passed, 3 skipped (Windows-only), 1 failed; that one test was fixed, and with its sibling it reran at 2 passed | **The review.** An independent opus review at 233dc71 (the branch's first commit) found no code defect. Its verdict was CHANGES NEEDED (0 blockers, 2 should-fixes, 6 nits) because it asked for this description and its composition counts to be corrected, and they are. Its evidence: - **Readers and rewriters.** Every reader in the shipped tree reads the 13 lines through `load_yaml`/`parse_yaml`, and no line regex or whole-line rewrite touches them. It read `bump-leg.py` `rewrite_pin`, `update-shape.py` `rewrite_shape_pin` and `rewrite_manifest`, `family.py` `rewritten`, the three validators, `siblings.py`, both `bootstrap.py`, the doctor, `setup-project.py`, the workflows, the Makefile and `setup.sh`. No test among main's 17 commits since c7c02fa (through #245's 161c6c9) edits one of these lines bare. - **`_member_id`, the one rewriter changed.** A mutant that writes every id bare fails the member test (the rows read back as `{'IRRS': 7, 'IRSS': True}`). A mutant that quotes every id fails `b" id: irqs" in lines`. So the minimal-churn choice is tested both ways. - **Old projects and holders.** Against a control commit (c7c02fa plus an unrelated README edit), a project and a holder made on c7c02fa give identical `update-shape.py check` and `shape-doctor.py` output, up to the shas. `check --at 233dc71` reports all 11 project files and all 10 holder files `unchanged` and says `no copied file differs`. The doctor's verdict is `COMPLIANT, SHAPE BEHIND (... no copied file differs)` for the control and for 233dc71. This branch's `family.py add` and `bump` on a c7c02fa holder leave the old `atlas` row and the header byte for byte, apart from the legacy-id edge described at the top. - **Tests.** All 123 new tests fail on c7c02fa (`123 failed`) and pass on 233dc71 (`123 passed`). The 14 changed existing tests pass (49 passed). - **Not covered.** Windows and macOS were reviewed by reading and not run: the plan edit and the member-row check are in bytes, and nothing writes to git's stdin in text mode. Of `test_shape_doctor.py` and `test_family.py` the review ran only the changed tests. **Follow-up commit.** A second commit, `3894aa8`, answers two nits from the review, and changes no behaviour. The `_member_id` docstring said "no existing holder's `members:` block moves by a byte", which was false: the next `bump`, `add` or `remove` of a holder written before this change rewrites a legacy `id: 2026` row as `id: "2026"`, harmlessly, because `validate-family.py` compares with `str()`. The docstring now says exactly that, and a grep of every comment and docstring the branch added for "by a byte" and "moves" found one more copy, in the member test's docstring, corrected the same way. The `try/except YamlError` in `_member_id` is guarded by a new test (see Tests), so the new file holds 130 tests. Every count above that says 123 (123 new tests, all failing on c7c02fa and passing on 233dc71) is for the first commit; the follow-up commit adds the 7. The `except` also carries a comment saying which ids reach it. **Composition at the landing head (4984651).** `49846518` is GitHub's update-branch merge of origin/main `cc8f2da4` (#376, a `--trailer` value carried into the commit as typed) into `3220c9ee`. Git reported no conflict, and the merge has no hand edit. #376 changed `scripts/family.py` and `tests/test_family.py`, which this branch also changes. None of the lines it added under `tests/` names a key the templates now quote. The coordinator ran targeted tests on this head, Linux only, before CI: - main's changed test files (`tests/test_commit_trailers.py`, `tests/test_family.py`, `tests/test_bump_leg.py`, `tests/test_update_shape.py`) with `tests/test_family_add_member_id.py`: 421 passed, 1 skipped (a case-insensitive disk only); - this branch's template set (`tests/test_template_text_placeholders.py`, `tests/test_template_quoted_values.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_bootstrap_spelling.py`, `tests/test_bootstrap_after_bump.py`) with the hygiene, CLI-reference and AGENTS.md-codes tests: 652 passed. **Composition at 3220c9e.** `3220c9ee` is GitHub's update-branch merge of origin/main `70d0d1b3` into `e5e768bc`. It brings in three commits: #491 (da19735), in which `setup-project.py` asks at the person's own terminal; #374 (7ab11cf), update-shape's NEXT quoting; and #384 (70d0d1b), adopt's `_spelled` plan values. Git reported no conflict, and the merge has no hand edit. #491 changed `tests/test_setup_project_py.py` and `tests/test_setup_sh.py`, which this branch also changes. A grep of the lines main added under `tests/` for the keys the templates now quote found two hits, and neither reads a rendered template line: `tests/test_update_shape_next_quoting.py` writes its own `project.yaml`, and `tests/test_adopt_question_and_submodule_spelling.py` edits a plan's `- path:`/`question:` entry, which `adopt-project.py` writes bare. The coordinator ran targeted tests on the same tree (`e5a74d19`), Linux only, before CI: - six of main's eight changed test files (the setup, update-shape, `openRepoShape` command and doctor-row tests): 365 passed; - main's other two (adopt's spelling and repo hygiene), `tests/test_adopt_github_exits.py` and nine more files of this branch's sixteen-file set: 943 passed. **Composition, refreshed again 2026-10-13 (head e5e768b).** `6d108d18` merges origin/main `56713e21` into `6cbbac60`. That brings in two commits: 5e02abe, the doctor's and `validate-pins.py`'s gitlink repairs, and 56713e2 (#479), in which adopt's `plan` takes the tracking branch from the source's default branch. Git reported no conflict and `docs/cli.md` auto-merged; the merge has no hand edit. Two kinds of test failed on it, and two commits fix them: - **`7968f902`** changes one expected literal, at `tests/test_adopt_github_exits.py:675`, for `test_a_source_on_another_branch_opens_its_pull_request_against_it[master]` and `[trunk]`. - The test reads the adopted root's `project.yaml`, which this branch's template renders as `tracking_branch: "master"`, so it now expects the quoted line. - The look-alike lines at `:743` and `tests/test_adopt_tracking_branch.py:73-76` read a plan, which `adopt-project.py` writes bare, and they stay as they are. - **`e5e768bc`** adds `git("branch", branch, cwd=source)` to `tests/test_template_text_placeholders.py::test_an_adoption_reads_back_every_value_it_was_given`. - All six rows had failed at `check` with `plan-tracking-branch-missing`: #479 refuses a tracking branch (`2026`, `007`, `1.5`, `20261011`, `true`, `null`) the source does not have, and the test's source had only `main`. - The source now carries each row's branch, in the flag rows and the by-hand rows alike. The test's assertions are unchanged. Targeted runs, Linux only: - On e5e768b: main's seven changed test files, 211 passed; this branch's sixteen-file set, 1223 passed, 2 skipped. - On 7968f90: the same sixteen files gave 6 failed, which are the six rows e5e768b fixes. The scaffold, setup, doctor and family set gave 1094 passed, 22 skipped, and twelve further template-grep files gave 1259 passed. - `render-cli-reference.py --check` is ok. **Composition, refreshed 2026-10-13 (head 6cbbac6).** `d651b594` merges origin/main `aa4e1f59` (#508) into `d526f768`. - **The one conflict.** Git conflicted in one file, `tests/test_adopt_manifest_values.py`, in four hunks, all against #356 (965420b, #293/#294). #356 refuses an `elected_on:` that `election_date` does not read, as `election-date-malformed`, before the validator is asked (`_manifest_values` returns None, adopt-project.py:2258). - **Resolved by what the merged tree prints**, measured with `adopt-project.py plan`/`check`/`execute` into local bare remotes: - `id: "007"` checks clean and `execute` writes it as typed, so it stays in `TEXT_THE_TEMPLATE_NOW_HOLDS`. - `visibility: "true"` gets the validator's `manifest-visibility: visibility is 'true'`. - `elected_on: "20261011"` gets #294's one finding, "the plan's `elected_on:` is the text '20261011'", and the validator is not asked. That row keeps this branch's list, `TEXT_THE_VALIDATOR_REFUSES`, with main's sentence. - Two tests git merged cleanly now ask `said_of`, as main's own rows do: `test_check_refuses_a_text_value_the_validator_refuses` and `test_where_a_misread_rule_judges_the_rest_the_validator_still_answers`. - **Two more failures that no hunk showed**, fixed in the same merge: - `[branch-tilde]` in `tests/test_scaffold_manifest_values.py` was this branch's own `xfail(strict=True)` until #355 landed as 6c2a371, so it XPASSed. The mark is off, and the row asserts `REFUSED scaffold-bad-tracking-branch:`. - #391's `tests/test_bootstrap_spelling.py` and #490's `tests/test_bootstrap_after_bump.py` matched ` path: spec` and `tracking_branch: main` in a scaffolded `project.yaml`, and nine cases failed on the merge. They now match the quoted lines. - **The #507 interplay.** `6cbbac60` switches the holder to `family/members` before the real `add`s in `test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id`, so the test holds once PR #507's `family-on-tracking-branch` refusal lands. - On the clean merge of 6cbbac6 with #507's head 708f3bb, that file, all 13 `tests/test_family*.py` and `tests/test_shape_doctor.py::test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member` give 686 passed, 19 skipped. - Without the switch line, the member test fails `REFUSED family-on-tracking-branch`. - **Targeted runs on 6cbbac6, Linux only:** - the template, adopt-plan and manifest files (14): 995 passed, 2 skipped; - scaffold e2e, setup, the doctor, leg-path collisions and every family file: 1094 passed, 22 skipped; - the 15 further files a grep for template lines found: 1503 passed; - `render-cli-reference.py --check`: ok. - **Re-verified independently** at d651b59 and 6cbbac6: APPROVE WITH NITS (0/0/3), all three in this paragraph and fixed here. The re-verification also ran the three rows, five mutations, and 26 more files (834 passed; 291 passed, 1 skipped). - **Not run locally:** the full suite, macOS and Windows; CI's three jobs are the check. **Composition, as measured on 2026-10-12.** The merge trees below were computed for 233dc71; the follow-up commit 3894aa8 changes only `scripts/family.py` comments and adds tests. At 15:28Z main was 3ae0109 (#245, #351, #350 and #345 had landed since the merge trees): 3894aa8 merges clean with it, and the review's re-verify ran the family tests on that merge (61 passed). Every sha below is the one it had then; main and the other branches have moved since. `git merge-tree --write-tree --name-only`, run after a fetch with automatic maintenance off on 2026-10-12: - **origin/main 161c6c9**, 17 commits past c7c02fa (the base): clean, tree 7ba8246. #316 (34ecbc2), #317, #318, #311 and #245 were on main by then. - **The 46 other remote branch heads** (the 30 open PR heads and 16 branches with no open PR): clean for 44. Among them #314 (4b8da3d), #266 (3410be7), #355 (a03abcf), lane 1's `fix/adopt-plan-quote-yaml-keywords` (d38e143), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-late-failure-notice` (9f1778d), #380 (73068a3), and `fix/family-init-values` (14e2d68, #307). The two that conflict, each in the same file as it does with c7c02fa and with origin/main on its own: lane 1's `fix/adopt-root-kept-symlinks` (6c44591, `adopt-project.py`) and #358 `fix/doctor-manifest-kinds-unreadable-pin` (ebffb08, `shape-doctor.py`). This branch touches neither file. - #314 and d38e143 each conflicted with origin/main on their own, in `adopt-project.py`. Merged with this branch, neither gained a conflict. **Composition with #314, which lands before this branch.** Its misread gate (`manifest_misreads`) reads the template, so once the template quotes a field it refuses nothing there, and some of #314's tests expected that refusal. Composing with #314 (landed as 3ef059c), whose misread gate asks the template how a value is written. With `project.yaml` quoting `id`, `elected_on`, `visibility` and `tracking_branch`, the gate refuses nothing on the shipped templates, and 45 of #314's tests flipped on the merge. Forty expected a refusal the value no longer needs. Thirty-four of them now assert that the value is accepted, and the six whose premise was a bare placeholder now assert that no shipped template placeholder is misread. For example: a scaffold of `--id null|true|false|007|2026`, or of `--tracking-branch true|True|TRUE|false|False|null|Null|NULL|007|2026|1.5|1.50|0`, exits 0, and the manifest holds the text it was given. A dry run prints the plan. An id derived from `--project True|Null` is held as typed. `--id=null` reaches `gh repo create`. Adopt's `id: "007"` checks clean and `execute` writes it as typed. The rule's own sentence, remediation and line-break wording are still asserted, on a copy of the standard whose `project.yaml` writes the four lines bare. Four stay refused by #314's validator gate, now in its words about the text: `visibility is 'true'` and `elected_on is '20261011'`. One is a real break. `--tracking-branch=~` reads back as `~`, which git refuses as a branch name, and it dies at `git init -b ~`, after `gh repo create`. That is #283, so the row is `xfail(strict=True)` with that reason, and it turns XPASS (and fails) once PR #355's `git check-ref-format --branch` refusal lands. `--help` for `--id` and `--tracking-branch` no longer says such values are refused, and `docs/cli.md` is regenerated. **Composition with the other branches.** Where another branch's rules meet this one, the tests were run on the merged tree. Quoting changes what some of their tests see, and the changes are not all one kind: some tests expected a refusal the value no longer needs, some are still refused under another rule, and some guard a case quoting does not fix. Whoever lands second must tell them apart, so each branch's flips are classified below. - **#266** (lane 2, `fix/leg-path-yaml-scalars`). 27 flips, all from one cause: its rule asks a hard-coded bare line, `LEG_PATH_LINE = "path: {}\n"`, so it refuses leg paths the quoted templates now read back. 15 are `test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path` (null, Null, NULL, ~, true, True, TRUE, false, False, FALSE, 2026, 007, 0, 1.5, 1.50) and 12 are this branch's own end-to-end tests (6 scaffold rows fail with `REFUSED scaffold-bad-leg-path`, 6 adopt rows with `FINDING plan-bad-leg-path`). Counted on the merge with b2b42f8. #266's head was then 3410be7, whose rule reads each template line that holds a leg path, renders the value into it as `render` does and asks the reader, so it has no hard-coded line left. The merge with this branch was clean (tree 1e348c6); that head was read and the 27 were not re-run on it. At b2b42f8, whichever of #266 and #259 landed second had to drop or re-point the bare rule; 3410be7 re-points it already. - **Lane 1's #181** (`fix/adopt-plan-quote-yaml-keywords`, d38e143). Its gate reads the template too, so `plan --tracking-branch 2026|007|true` exits 0 on the composed tree (exit 2 `adopt-manifest-misread` on d38e143). On the merge, `tests/test_adopt_plan_yaml_quoting.py` and this branch's 6 adopt end-to-end tests give 70 failed, 371 passed: - 57 refusals that are now unnecessary: `plan_refuses` ×14, `plan_names_an_id` ×2, `check_names_an_id` ×4, `check_reports` ×14 and `execute_refuses` ×14 (every value except `elected_on` and `visibility`), `text_the_loader_reads_as_other_text` ×7, and the two tests that need a bare placeholder (`test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword`, `test_the_template_line_says_whether_a_value_is_quoted`). - 11 guards #181 loses. See "Do not flip". - 2 that fail only by cascade: `execute_refuses[pins='null']` and `[pins='True']` pass alone (`2 passed`). An earlier case no longer refuses, and its `execute` pushes the split branch. - The writer half of #181 (`y` asking the reader, so `true`/`null`/`True` are quoted in the plan) is still needed and composes cleanly. With it, `plan --tracking-branch true` reaches the quoted template intact, and this branch's two hand-quoted adopt rows become flag-driven. - **Do not flip these 12 to "accepted".** Each still needs a refusal or a finding, and quoting does not supply it: - **`~` (1, #314).** `test_a_value_the_reader_misreads_is_refused_before_anything_is_made[branch-tilde]`. `--tracking-branch=~` is not a branch name git accepts, and #314 refused it only as a misread. On the merge it passes the plan and dies at `git init -q --bare -b ~ .../Atlas-spec.git` with `fatal: invalid initial branch name: '~'`, and against GitHub that is after `gh repo create` has made all three repositories. That is lane 2's #283 (PR #355). `~` must stay refused, by #283's ref-name check, which is why #314's row for it is not flipped to "accepted". - **A hand-edited plan value that is not text (7, #181).** `test_check_names_a_plan_value_that_is_not_text_by_what_was_read[tracking_branch='007']`, `[tracking_branch='TRUE']`, `[tracking_branch='1.50']`, `[tracking_branch='[a]']`, `[visibility='{a: b}']`, `[id='007']` and `[elected_on='20260902']`. The plan's reader reads each as something other than text, and on the merge `check` passes. Measured on the merge: `tracking_branch: 007` and `id: 007` are adopted silently as `"7"` with every validator passing; `visibility: {a: b}` pushes both legs and then fails `manifest-visibility`; `tracking_branch: [a]` is still refused at `execute` (`unsafe-value`) before any leg exists. On d38e143 alone, `check` exits 1 `plan-manifest-misread` for each of those four. Re-point the gate at "a plan value that is not a string" and these keep their guard. - **A quoted value the validator refuses (4, #181).** `test_check_reports_a_plan_carrying_one[elected_on='20260902']` and `[visibility='true']`, and `test_execute_refuses_a_plan_carrying_one_before_any_leg_exists[elected_on='20260902']` and `[visibility='true']`. The value stays invalid for the validator (`DATE_RE`, the visibility choices), and #181's misread gate caught it only incidentally. On the composed tree `check` passes and `execute` pushes the legs, and the gate fails afterwards. That is what main does today, and #314's validator gate (`plan-manifest-invalid`) would catch it. - **main** (merge with ab623dd, tree cdeec7f, run by the review): this branch's new file, `test_adopt_template_values.py` (#270), `test_template_values_single_pass.py` (#316) and `test_template_quoted_values.py` give 431 passed, 1 skipped. The skip is `test_adopt_template_values.py:223`, which reads `git config user.name` and skips where the shared repository's `.git/config` sets one; it is environmental. The changed tests with `test_family_add_refusals.py` (#317, #318), `test_pin_validator.py` and `test_manifest_validator.py` give 132 passed. #270's `unwritable_reason` is value-based, not template-relative, so it refuses as it did and nothing flips. Main then added #245 (`adopt-project.py` and `repo_shape.remote_key`), which adds no test that edits a bare quoted line; it was not run on that merge. - **#380** (lane 3, `fix/pin-rewrite-indentation`; it landed since as 1f90a0f). It rewrites `commit:` and `tree_sha256:` only, and its tests render the pins from the templates. On the merge at dc0fa7b, `test_pin_rewrite_layout.py` and the 6 scaffold end-to-end tests gave 121 passed. Its head was then 73068a3; that merge was clean and was not re-run. - **#397** (`fix/family-init-values`, #307; measured at 14e2d68): its tests skip themselves where the template quotes. 137 passed, 19 skipped, 0 failed. With its fix-round head abb27e6, this branch's 3894aa8, main 0f67f7b and #345 together: 442 passed, 20 skipped (the 19 rows, and one Windows-only row). - **#345** (run at 875d802; it has since landed as 3ae0109): `test_family_dry_run.py` and the member test, 22 passed. Its `" id: irrs"` assertion holds, because ordinary ids stay bare. **Left out.** - The adoption plan writer `y` still writes `true`/`null` bare into the plan. That is #181 (lane 1). - `templates/assembly-root/contracts/neutral-product-pin.yaml` still writes `product`, `source_repository` and `digest_source` bare, and `family.py` writes a member row's `project:` bare. Neither is a #259 field. - A plan value someone edits to a non-text type (`tracking_branch: 007` or `id: 007`, unquoted, in the plan) is read by the plan's reader as the number 7. `str()` makes it `"7"`, and the quoted template now writes it faithfully. For `id` this is a change: on c7c02fa a bare `id: 007` failed loudly (`validate-manifest: manifest-id`, manifest line `id: 7`), and on this branch it is adopted silently as id `"7"` and every validator passes. `tracking_branch: 007` was already adopted silently as `7`. The plan side is not this branch's: the guard against it belongs with #181 (adopt plan writes YAML keywords and non-text values unquoted), whose gate should refuse a plan value that is not a string. The case is narrow, because `plan --id 007` itself writes `id: "007"`; only a hand edit reaches it. - No docs change: README, AGENTS.md, the handbook and the template comments show none of these lines; `docs/cli.md` is unchanged because no `--help` text changed. Reviewed adversarially at 233dc71 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 6 nits; the code and the tests were sound, and the should-fixes were the composition counts and the claim about a hand-edited plan `id`). Fix round 3894aa8 changes no behaviour: it corrects the description and the two false "moves by a byte" claims, and guards `_member_id`'s `except YamlError` with a test of 7 cases. Its re-verification at 3894aa8 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 1 nit: shas that had moved since the description was written, refreshed here). What was added after that re-verification, main and the composition with #314, is described under "Composition with #314". An independent re-verification of that composition at 77cd0b2 confirmed it with nits only (0 blockers, 0 should-fixes, 5 nits): the 45 flips reproduce test for test, breaking the real rule or the real template's quoting turns the compose tests red, and the `[branch-tilde]` row XPASSes on a merge with #355. **Composition with #381.** #395's quoted `id: "{{PROJECT_ID}}"` composes with #381's `member-duplicate-id` test (`tests/test_family_add_member_id.py`), whose id rewrite now matches the id bare or quoted and keeps its quoting, so the full suite, which failed that one test on Linux at 1ef3310, no longer fails `test_a_member_whose_id_changes_between_the_probe_and_the_mount_is_unwound` on Linux. CI is green on Linux, macOS and Windows, with SonarCloud passing, at 4984651 (main cc8f2da merged in by update-branch). Main af01068 (#505), which landed after, touches none of its files, and the combined tree passed the template, hygiene, CLI-reference, AGENTS.md-codes and setup tests (807 passed). Closes #259 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #260 (comment)
What was wrong (#260).
renderreplaced the placeholders one key at a time, each over the whole text. The value an earlier key put in was then searched by every later key.--elected-byand--referencein both tools, the scaffold's--name, andfamily.py init's--nameand--created-byare free text, so each could hold another placeholder's token:adopt-project.py --elected-by '{{SPEC_PATH}}'recordedelected_by: "spec"inproject.yamland the README, and saidadoption verified.{{PROJECT}}), or one no key names ({{NOPE}}), stayed in the output.renderthen refused it astemplate-unsubstituted, with a remediation that blamed the tool. By thenexecutehad pushed both legs, the scaffold had created all three repositories, andfamily.py inithad created the holder's bare repository.Reproduced on b30595c with the issue's own script, and the same for
family.py init --created-by.The change (
scripts/shape_materialize.py).renderreads the template once. One regular-expression substitution looks each placeholder up in the table, so a value goes in where its placeholder stood and is never searched again. In a.yaml/.ymltemplate the quoted form"{{KEY}}"is matched first and spelled bydouble_quoted, exactly as repo_shape.py: a quote opens a quoted scalar only where one begins and must close there, sok: [it's, a]reads two items,k: it's fine # notedrops its comment, and an unclosed"abcor a mid-key[is refused, not misread (#249, #250) #284 made it (YAML_PLACEHOLDER_RE, group 1).REFUSED template-unsubstituted: <source>: no value for ['{{NOPE}}'].PLACEHOLDER_REnow takes digits,\{\{[A-Z0-9_]+\}\}, a superset of the old[A-Z_]+. The four digest placeholders ({{SPEC_TREE_SHA256}},{{CODE_TREE_SHA256}},{{SHAPE_TREE_SHA256}},{{PIN_TREE_SHA256}}) were substituted only because the loop replaced every key by name. The single pass replaces only what the pattern finds, so it needs the digits. A digest placeholder that a table lacked used to be left in the output with nothing said; it is now refused like any other.Nothing downstream renders these files again, so a
{{a person typed is never expanded later, and no tool needs to refuse{{in these values. On this head:git grep -n -E '\brender\(' -- '*.py' ':!tests'listsscripts/shape_materialize.pyalone:copy_treeand the three calls in_materialize. (scripts/render-cli-reference.pyhas its own unrelatedrender.)copy_tree,materialize_assembly_rootandmaterialize_family_rootare called only fromadopt-project.py execute(:2549,:3101),scaffold-project.py(:1013,:1054) andfamily.py init(:712). Every one of those calls runs while the tool is creating the repositories.update-shape.pyimports no renderer and noTEMPLATEDlist. Inproject.yaml/family.yamlit rewrites only theshape:block'scommit:andtree_sha256:lines (rewrite_manifest,:869). The README's trailingShape:line is a span rewrite of the commit.bump-leg.pyrewrites only a leg's two pin lines.shape-doctor.pyimports onlyescape_what_stdout_cannot_encodeandlane_trailer_argumentfrom the materializer. It reports a leg'sAGENTS.md/README.mdasrenderedand compares nothing.bootstrap.pynor any shipped validator holds{{:git grep -n -F '{{' -- 'templates/*.py'finds only^{{commit}}in two f-strings.shape_advisory.py's ownPLACEHOLDER_REis asked only ofsingle-repository.yaml(field_problems, called only fromread_record). That is a record a person writes by hand, neverproject.yaml.Every caller, and what it relies on.
adopt-project.py:_template_values(ORG,REFERENCE,ELECTED_BY, and so on) is rendered into seeded legs and the assembly root. It relies on every key a template names being in the table (it is: the four leg commit/digest keys are""until the legs exist). Its free text is now written as typed.scaffold-project.py: thevaluestable, plus_seed_legs' four leg values and--pin'sPIN_*values for neutral pins. Same reliance; the probe after this list names the keys.scripts/family.py:_template_values.FAMILYis a key no template names, and that is harmless.setup-project.pyrenders nothing. It forwards--elected-byand--nameto the scaffold, and its ownNAME_REalready refuses{.update-shape.pyandshape-doctor.pyrender nothing (above).test_adopt_leg_path_collisions.pyandtest_scaffold_leg_path_collisions.pybuild a table fromPLACEHOLDER_RE.findallovertemplates/assembly-root/. They now include the digest keys and still pass.test_shape_advisory.pyrenders legAGENTS.mdand the record template, which have no digest keys.test_scaffold_ignored_shape_files.pycallscopy_tree.tests/test_template_quoted_values.pybuilt its tables with its own[A-Z_]+, so its tables lacked the digest keys and its own"SHA256" in keybranch never ran. It now uses[A-Z0-9_]+, and repo_shape.py: a quote opens a quoted scalar only where one begins and must close there, sok: [it's, a]reads two items,k: it's fine # notedrops its comment, and an unclosed"abcor a mid-key[is refused, not misread (#249, #250) #284's behaviour is otherwise held unchanged.A probe (
astover each tool's dict literals, against every placeholder undertemplates/) finds that every capitals key of the scaffold's and adopt's tables is named by a template, exceptORG, which onlyfamily-rootandworkspace-rootname. Every key of family's table is named by a template, exceptFAMILY.Byte-for-byte for ordinary values. Every text file under
templates/(49) was rendered with b30595c'srenderand with this head's, over two tables: plain values, and values holding",',\,#,[x],{y}, U+3000 and a tab. That is 0 differences in 2 x 49 files.test_an_ordinary_value_renders_as_a_plain_substitutionkeeps this as a test for the 19 templates that hold a placeholder.Tests (
tests/test_template_values_single_pass.py, 133).render: each key's token (every key of the three tools' tables and every key a template names, 49 keys) and{{NOPE}}, bare and between double quotes, as every free-text value. Each reads back unchanged fromproject.yamlandfamily.yaml. Every README andAGENTS.mdholds it exactly where it holds an ordinary value. Neither of two keys reads the other's value, in either order. A placeholder the template lacks is still refused, naming only the template's missing tokens and never a value's; this includes a digit key.${{ github.x }}and an f-string's{{commit}}are left alone.ghon PATH that records any call (none is made). What was pushed is read back withgit show, so no checkout's line endings are compared:scaffold-project.py --name/--elected-by/--reference, each holding every key's token (bare and quoted), and each as{{NOPE}}. The run exits 0. The pushedproject.yaml, README and both legs'AGENTS.mdhold the values as typed, and the project's ownvalidate-manifest.pypasses.adopt-project.py plan/check/executewith--elected-byand--referencethe same way. All three pass, withadoption verified. The split commit'sproject.yamland README hold the values as typed. This test is skipped at its decorator withoutgit filter-repo.family.py init --created-bythe same way.family.yamlholds the value, andvalidate-family.pypasses. (The family's--nameis held by the unit tests only. Its validator requires the holder's name to be one CamelCase token, so a free-text name is refused there whateverrenderdoes. See the last item under Left out.)On b30595c this file is 113 failed, 20 passed. The 20 are the byte-for-byte guard (19), which describes base behaviour by design, and the sanity test that the templates are found.
Mutation probes, each a revert of one part of the fix: the sequential loop with the template check (98 failed); single pass with the output check (109 failed);
PLACEHOLDER_REwithout digits (69 failed, the scaffold e2e among them, because the pins keep{{SHAPE_TREE_SHA256}}); no quoted branch (94 failed, #284's tests among them); no template check (4 failed).Targeted runs on this head:
tests/test_template_values_single_pass.py tests/test_template_quoted_values.py tests/test_scaffold_e2e.py tests/test_scaffold_pin_and_reuse.py tests/test_adopt_plan.py tests/test_adopt_execute_refusals.py tests/test_adopt_e2e.py tests/test_family.py tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_adopt_leg_path_collisions.py tests/test_scaffold_leg_path_collisions.py tests/test_shape_advisory.py tests/test_scaffold_ignored_shape_files.py, withgit-filter-repo2.47.0 on PATH: 792 passed, 1 skipped (test_adopt_plan.py:393,SHAPE_MEDXEHR_CLONEunset).tests/test_shape_doctor.py: 208 passed.Composition (
git merge-tree --write-treeagainst the current tips; each merged tree ran the sibling's own test files plus this PR's two):origin/mainfix/scaffold-manifest-values(lane 5's #258/#261, PR #314)fix/adopt-template-values-utf8(lane 2's #270)fix/leg-path-yaml-scalars(lane 2's #266)fix/adopt-check-shape-paths(lane 5's #262/#263, PR #313)--id null,--tracking-branch trueand a blank--elected-byare refused (#258, #261) #314 callsrenderinmanifest_misreadsandmanifest_invalidwith the complete table, so the template-side refusal never fires there. It now finds placeholders with thisPLACEHOLDER_RE.null,~,true,False,2026,1.5) is refused before anything is created, under--dry-runalike, through the one definition adopt-project.py asks, so--spec-path 2026no longer creates three repositories whose first bootstrap failsmanifest-leg-path, andadopt-project.py executeno longer saysadoption verifiedbefore that bootstrap fails (#225) #266 has merged main, so the import-line conflict an earlier tip had is gone.null,trueand other YAML keywords unquoted, soplan --spec-path nullsilently mounts the leg at the defaultspec#181 (fix/adopt-plan-quote-yaml-keywords, e337f44): a clean merge, one failing test, and the fix belongs to whichever lands second. adopt-project.py plan writesnull,trueand other YAML keywords unquoted, soplan --spec-path nullsilently mounts the leg at the defaultspec#181 alone passes (841). On the merged tree, 1 of 974 fails:test_adopt_records_each_free_text_value_as_typed[every-key], refusedadopt-manifest-misreadatplan. adopt-project.py plan writesnull,trueand other YAML keywords unquoted, soplan --spec-path nullsilently mounts the leg at the defaultspec#181's gate_written(e337f44adopt-project.py:1668-1669) spells the value withdouble_quoted(lane 1's 4bf2cc4), then runs.replace(token, value), which re-reads the value it just put in. That is the per-key re-scan this PR removes. So with both, the gate refuses valuesexecutenow writes as typed:--elected-by 'x{{ELECTED_BY}}'(the gate claims"xx{{ELECTED_BY}}"),'"{{ELECTED_BY}}"', and--reference 'see {{REFERENCE}}'(it claims"see see {{REFERENCE}}"). On this PR alone all three passplanand are written as typed. Neither branch is wrong against main, whererenderreally would expand them. Whichever lands second makes_writtenone pass for its key: onere.subover"(\{\{KEY\}\})"|\{\{KEY\}\}, orrenderon the line, as scaffold-project.py and adopt-project.py ask the project's YAML reader and validate-manifest.py about the manifest's values before anything exists, so--id null,--tracking-branch trueand a blank--elected-byare refused (#258, #261) #314's_misread_rowsdoes. Tokens of other keys ({{SPEC_PATH}} {{NOPE}}) pass on every tree.Left out.
id,elected_on,visibility,tracking_branch, the two legpaths andsubmodule_paths), so a checked value YAML reads as another type (007,2026,true,null) is misread by the project or refused by the tool; quoting them reads every one back exactly and lets numeric branch names adopt #259 waits on Brett), and no free-text value is refused for holding{{, since nothing renders it again.family.py init --nametakes a display name (its help says so) that the holder's ownvalidate-family.pyrefuses. On b30595c,--name 'Contoso Group'exits 0 and pushes the holder, andvalidate-family.pythen exits 1 withnaming-unclassified: 'Contoso Group' matches no family in the naming policy. This is pre-existing and not touched here; it is filed as family.py init exits 0 and pushes a holder its own validate-family.py refuses for--id null,--created-by ' ',--created-on 20261011or--name 'Contoso Group', and puts--tracking-branch trueon a branch its manifest reads back as True #307, withfamily.py init's other values.Review. The first reviewer stalled at its first checkpoint and was replaced. The second review of 552763d found the code sound and asked only that this body's composition be corrected (above). Its #181 paragraph described an earlier #181 tip, and every other tip had moved.
What the review checked:
--elected-by '{{SPEC_PATH}}'is recorded aselected_by: "spec") or, after the legs are pushed, refused astemplate-unsubstitutedwith "a defect in the substitution table of the tool you ran" #260's rows reproduced on b30595c and pass on the head, for adopt, scaffold andfamily.py init.renderagainst an independent single-pass reference found 0 mismatches.project.yamlandfamily.yamlunder both this repository's reader and PyYAML.Nits kept:
Three mutants survived the first head. Fix round 1 (321e511, tests only, with main c0ed615 merged as 7d6c66c) gives each a test that fails on it:
.ymlsource gets the quoted spelling as a.yamlone does;template-unsubstitutedlist is the sorted set of what the template lacks, each token once;The seven targeted files pass on the merged tree, 541 tests.
The
ghstub proves nothing on Windows, where no test callsgh.Closes #260
🤖 Generated with Claude Code
Summary by Sourcery
Make template materialization substitute placeholders once and validate missing template values before writing repositories.
Bug Fixes:
Enhancements:
Tests: