Skip to content

scaffold-project.py: a leg path the project's own YAML reader would read back as another type (null, ~, true, False, 2026, 1.5) is refused before anything is created, under --dry-run alike, through the one definition adopt-project.py asks, so --spec-path 2026 no longer creates three repositories whose first bootstrap fails manifest-leg-path, and adopt-project.py execute no longer says adoption verified before that bootstrap fails (#225) - #266

Merged
brettheap merged 11 commits into
mainfrom
fix/leg-path-yaml-scalars
Oct 13, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

What

leg_path_problem in scripts/shape_materialize.py is the one leg-path rule that scaffold-project.py and adopt-project.py both ask. It now asks one more question, last: does the project's own YAML reader give the value back from every template line the value is written into? When one line does not, the value is refused, in the form the rule's other sentences use. Over today's templates, which write the leg-path lines bare, the refusal reads:

REFUSED scaffold-bad-leg-path: --spec-path is '2026', which the project would not read back as text: the assembly root writes a leg path as `path: 2026` in `project.yaml` and as `submodule_path: 2026` in `contracts/code-pin.yaml` and `contracts/spec-pin.yaml`, and the project's own YAML reader reads that as the number 2026, so its first bootstrap would fail.
  • Where it is refused. Every place the rule is already asked refuses the value before anything exists:

    • scaffold-project.py, under --dry-run alike, before a work directory or a remote is made.
    • adopt-project.py plan (adopt-bad-leg-path; no plan is written).
    • check (FINDING plan-bad-leg-path, for a plan that holds the value as quoted text).
    • execute (REFUSED plan-bad-leg-path, before any leg repository or the split branch exists).
    • setup-project.py, which passes the flag through to the scaffold.
  • The reader decides, not a list of words. The rule calls repo_shape.parse_yaml, the reader every assembly root ships. Over a bare line, today it refuses:

    • null, Null, NULL and ~, read as no value.
    • true, True, TRUE, false, False and FALSE, read as booleans.
    • -?\d+ and -?\d+\.\d+ (2026, 007, 0, 1.5, 1.50), read as numbers. A leading - was already refused.

    It keeps yes, no, on, off, 0x1F, 0o17, 1e3, .inf, .nan, +1, .5 and 1_000, which this reader reads as text. It also keeps the issue's controls (spec, legs/spec, a..b, v1.5, 1.5.2) and null-spec, 2026-docs, legs/null, legs/2026, nUll and tRue. One test teaches the reader that yes is a boolean, and the rule then refuses yes without any edit of its own.

  • The rule reads the templates themselves. It keeps no copy of a template line:

    • it finds every line of every YAML template under the shape's templates/ that holds {{SPEC_PATH}} or {{CODE_PATH}} (today project.yaml's two path: lines and each leg pin's submodule_path:);
    • it renders the value into each with render, under the file's name, as the scaffold and execute render it;
    • it reads each back with the reader, and its sentence quotes each line that misreads, as rendered.

    So once templates/assembly-root/project.yaml and the two leg pins write eight text placeholders bare (id, elected_on, visibility, tracking_branch, the two leg paths and submodule_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 quotes those lines, every value checked_value admits is read back, and the rule refuses none of them with no edit of its own. Both tools render a project from that same templates/, and shape_materialize.py is in no copy list, so no project carries the rule and no digest pin moves.

  • The rule is held to the templates. test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path finds the lines on its own, and asserts that the rule refuses each tested value exactly when a line does not read it back.

  • No spelling is offered, because nothing else names the same directory. _canonical_spelling also stops offering a spelling this rule would then refuse. On main, 2026/ was answered Write '2026'., and so were ./null, ~/ and True\. Over quoted lines, 2026/ is offered Write '2026'. again.

  • The remediation (LEG_PATH_REMEDIATION) now names this rule beside the others: the reader "reads it back as the same text from every line the project writes it into (a bare line reads null, ~, true, False, 2026 and 1.5 as something else)".

  • A template line the rule cannot read (one that is not a single key: value) is refused as template-leg-path-line, a defect in the shape's templates, rather than answered.

  • No --help text changed. docs/cli.md is unchanged, and scripts/render-cli-reference.py --check passes.

Files, against main 7a8b981 (git diff --stat 7a8b981 c11804a):

  • scripts/shape_materialize.py (+159 −6). It changes no import line: parse_yaml is the binding main already imports (see Merged main after 7a8b981).
  • tests/test_leg_path_yaml_scalars.py (new, 229 tests).

Why

Reproduced on main 39ac59c with the lane's drivers (p31_dry.py, p31_adopt.py and p31_adopt_exec.py, pointed at this checkout):

Fix (a), and how it meets (b). Option (b), quoting the template lines, is issue #259. Lane 5's PR #395 (fix/template-quoted-placeholders, head d526f76) implements it. This PR does not quote them. Since the fix round below, its rule reads the template lines themselves, so it composes with (b) in either order. I checked what quoting the four leg-path lines would do:

  • Digests.
    • project.yaml, contracts/spec-pin.yaml and contracts/code-pin.yaml are TEMPLATED files. They are rendered, and _materialize adds only the verbatim and from-shape copies to shape_files.
    • So they have no files: row in contracts/shape-pin.yaml. Quoting would move no per-file pin, and update-shape.py would report nothing: it reads only those rows and renders no templated file.
    • The only digest quoting moves is the shape tree_sha256 a new scaffold records, which every commit moves, this one included.
  • Readers. Every reader of these files loads them with repo_shape's reader, so each would get a quoted path back as text:
    • validate-manifest.py's leg-path check (isinstance(path, str));
    • validate-pins.py's submodule_path != path;
    • bootstrap.py's str(leg.get("path") or "");
    • shape-doctor.py's str(leg.get("path") or role);
    • bump-leg.py's checked_value(..., str(leg.get("path") or "")), whose rewrite_pin rewrites only the commit: and tree_sha256: lines.

So (b) is not wrong. This PR does not take it as well:

Tests

tests/test_leg_path_yaml_scalars.py (new): 229 tests.

The rule reads the templates, so the file asks it three things. It holds on either side of #259.

The new file against main 7a8b981's code: 128 failed, 101 passed, the count it gave against ab623dd. Every failure is the issue's reason, or a later round's. Since #314 (3ef059c), main refuses some of the values itself, in the validator's words, so 44 end-to-end rows now fail on the refusal's code and sentence rather than on exit 0:

failures what main does
46 the rule returns None: 45 sentence rows (assert None == <sentence>), and the row that teaches the reader yes
23 scaffold --dry-run (15 for --spec-path, 6 for --code-path, 1 naming both legs) and the issue's real scaffold exit 2, but as REFUSED scaffold-manifest-invalid: ... manifest-leg-path: leg 'spec': path is None., in #314's words and not this sentence
21 plan (15, and 6 for --code-path) refuses as adopt-manifest-invalid, in the same words
15 check exits 0 (plan ok) on a plan holding the value as quoted text
1 execute exits 0 with adoption verified on the issue's plan
15 the rule accepts a value every real template line reads back as another type
4 offers Write '2026'., 'null', '~', 'True'
1 the remediation lacks the rule
1 a template line it cannot read raises nothing
1 the sentence for a pin-only misread

The 101 that pass are the controls, plus the 18 quoted-copy rows: main's rule refuses nothing at all.

The new file against b2b42f8's code: 125 failed, 104 passed. Each test this round adds fails for its reason:

  • the quoted copy, 15 rows: b2b42f8's rule refuses 2026 over path: "2026" with "hold a leg path unquoted";
  • the pin-only sentence: the old sentence;
  • the unreadable line: DID NOT RAISE;
  • the offers, 2 rows: no Write '2026'. or Write 'null'..

The other 106 are the rows that compare the reworded sentence or remediation.

A mutant that asks one hard-coded bare line again (for tree, file, line in (("assembly-root", "project.yaml", "path: {{SPEC_PATH}}"),): in _misread_reason) gives 124 failed, 105 passed at 3410be7. All 15 quoted-copy rows are among them. On the merge with #259's 233dc71, the same mutant gives 122 failed, 200 passed, 8 skipped over this file and test_template_text_placeholders.py: the 15 guard rows and the 15 quoted-copy rows, 30 acceptance rows, and 12 of #259's own end-to-end rows (6 scaffold, 6 adoption), among others. So the tests hold the rule's silence over #259's lines, in both files.

Targeted run at 3410be7 (fix round 266's record): 1633 passed, 1 skipped, 0 failed (group A 810 passed, group B 823 passed and 1 skipped; 1634 tests in the 21 files below), with git-filter-repo 2.47.0 on PATH. The skip is test_plan_on_medxehr_matches_the_rulings, which needs SHAPE_MEDXEHR_CLONE. scripts/render-cli-reference.py --check passes.

file tests
test_leg_path_yaml_scalars.py 229
test_scaffold_leg_paths.py 27
test_scaffold_pin_and_reuse.py 20
test_scaffold_e2e.py 44
test_scaffold_leg_path_collisions.py 104
test_setup_project_py.py 84
test_cli_reference.py 13
test_windows_paths.py 38
test_repo_hygiene.py 50
test_scaffold_referent_chain_links.py 44
test_template_quoted_values.py 67
test_yaml_flow_quotes.py 17
test_yaml_quote_boundaries.py 73
test_adopt_leg_paths.py 349
test_adopt_leg_path_collisions.py 95
test_adopt_plan.py 57 (1 skipped)
test_adopt_plan_escapes.py 59
test_adopt_execute_refusals.py 37
test_adopt_verify.py 16
test_shape_doctor.py 208
test_git_output_decoding.py 3

The targeted run at this head, c11804a, is under Merged main after 7a8b981.

Composes with

At this head, c11804a. git merge-tree --write-tree <head> c11804a was run over every origin head that is in neither main 7a8b981 nor this branch: 79 heads, from the refs the lane fetched at 23:35Z.

With #259 (PR #395) at d526f76: 874 passed, 8 skipped, and 1 failed that is #395's with main, not this PR's. The scratch merge c11804a + d526f76 (merge-tree rc 0) was run over this PR's file, test_template_text_placeholders.py, test_scaffold_manifest_values.py, test_adopt_manifest_values.py, test_manifest_validator.py, test_pin_validator.py, test_adopt_submodules.py, test_scaffold_leg_paths.py, test_scaffold_e2e.py and test_reference_default.py.

Earlier, at 3410be7 (fix round 266's record). git merge-tree --write-tree 3410be7 <head> over the 60 origin heads not in main at 16:40Z: 58 gave rc 0, among them main 92e29d9, #395 at 3894aa8 (and 233dc71), #181's d38e143 and fix/adopt-pin-writes-pin-file 7a94b6b. fix/adopt-record-submodule-placement 48bdbf9 (the import list) and #358's 2745890 (shape-doctor.py, which conflicted with main by itself) gave rc 1.

With #259 at 3410be7 (PR #395 at 3894aa8, and 233dc71 before it): 0 failed, in either order. On the scratch merges 3410be7 + 233dc71 and 3410be7 + 3894aa8, the rule refuses none of the 15 values, and 2026/ is offered Write '2026'.. #259's own tests pass, its 12 scaffold and adoption end-to-end rows among them.

files on the merge 3410be7 + 233dc71 3410be7 + 3894aa8
test_leg_path_yaml_scalars.py (207 there), test_template_text_placeholders.py (123; 130 at 3894aa8), test_scaffold_e2e.py (44), test_scaffold_leg_path_collisions.py (104), test_manifest_validator.py (39), test_pin_validator.py (19) 528 passed, 8 skipped 535 passed, 8 skipped
test_shape_doctor.py (245), test_adopt_leg_paths.py (349), test_adopt_leg_path_collisions.py (95), test_adopt_plan.py (57), test_adopt_plan_escapes.py (59), test_adopt_execute_refusals.py (37), test_adopt_verify.py (16) 854 passed, 4 skipped 854 passed, 4 skipped
lane 5's request of 15:07Z: test_leg_path_yaml_scalars.py 199 passed, 8 skipped 199 passed, 8 skipped
lane 5's request: test_template_text_placeholders.py -k "a_scaffold_reads_back or an_adoption_reads_back" 12 passed (111 deselected) 12 passed (118 deselected)

The 8 skips are this file's end-to-end refusals, which have nothing to refuse over quoted lines. The 4 in the second row are 3 Windows-only PowerShell rows of test_shape_doctor.py and the MedxEHR plan test. For comparison, b2b42f8 + 233dc71 gave 27 failed, 305 passed on this file and test_template_text_placeholders.py, lane 5's figure.

With main 92e29d9: 0 failed. git merge-tree --write-tree 3410be7 92e29d9 is rc 0 (tree e8db6da). On that merge, test_leg_path_yaml_scalars.py (229), test_shape_doctor.py (258), test_scaffold_leg_path_collisions.py (104), test_adopt_leg_paths.py (349), test_adopt_leg_path_collisions.py (95) and test_adopt_plan_escapes.py (59) give 1091 passed, 3 skipped (the Windows-only PowerShell rows). The new file's count against main's code is measured again at 7a8b981, under Tests.

The rest of this section is fix round 1's record, measured at b2b42f8 and earlier.

(This table was measured at 00:37Z. The re-verification at 11:5xZ found the head clean against main 346079d, #181, #314, #316 and this lane's other heads, with fix/adopt-record-submodule-placement 3e86200 conflicting in the same import list and resolving as the union of names (393 passed). See review266-aa2be9a/reverify-1.md.)

Main was merged into this branch at b2b42f8 (main 6d8d88b). The reason: #284 (fix/yaml-quote-boundaries) landed as b30595c and added double_quoted to the same from repo_shape import (...) list in scripts/shape_materialize.py that this PR adds parse_yaml to, so the PR could no longer merge.

  • That one hunk was the only conflict. It is resolved as the union:
        CHAIN_RECORD_FIELD, SAFE_ARG_RE, NamingPolicy, Refusal, checked_value,
        double_quoted, file_sha256, parse_yaml,
    
  • Fix round 1's targeted run was on that merged head.
  • c11804a takes parse_yaml out of that list again, because main now imports it elsewhere (see Merged main after 7a8b981).

git merge-tree --write-tree b2b42f8 <head>, heads read with git ls-remote origin at 2026-10-12T00:37Z:

head rc conflicting files
main ede15ce (only shape-doctor.py changed since the merge) 0 none
#181 fix/adopt-plan-quote-yaml-keywords a0ff15b (no PR yet) 0 none
#231 fix/adopt-check-unsafe-path 0a86f61 0 none
#270 fix/adopt-template-values-utf8 7e9cdc3 0 none
#281 fix/validator-child-encoding 754803d 0 none
fix/adopt-record-submodule-placement f38d543 (was 2f5cda9; no PR yet) 1 scripts/shape_materialize.py, the import list. It conflicts with main ede15ce alone in the same file.
fix/adopt-root-kept-symlinks 04792d2 1 adopt-project.py, scripts/shape_materialize.py. The same two files conflict with main alone.
fix/doctor-template-validator-next 88c81e0 1 shape-doctor.py. It conflicts with main alone.
fix/family-add-force-past-excludes 289e542 1 scripts/family.py. It conflicts with main alone.
every other branch on origin not already in main 0 none

fix/adopt-record-submodule-placement. Every rc-1 head above conflicts with main by itself, so none of those conflicts is this PR's. That branch has since landed as 7a8b981 (#394). Its import-list conflict with this branch was resolved in c11804a by main's list, not by the union this record proposed (see Merged main after 7a8b981).

#181, exactly what changes for whichever lands second. The merge is textually clean (its branch is at c87c45f now, and merge-tree with c11804a is rc 0). This branch leaves #181's lines in shape_materialize.py alone: the not-text sentence and NOT_TEXT_KINDS[type(None)].

On a scratch merge of 39ac2c9 (this branch before main was merged) and a0ff15b, tests/test_adopt_plan_yaml_quoting.py and tests/test_leg_path_yaml_scalars.py give 27 failed, 572 passed. None of the 27 is in this PR's file. They are exactly #181's 9 leg-path rows of REFUSED / CARRIED (--spec-path null, Null, NULL, ~, true, False, 2026, 1.5, and --code-path null), in each of these 3 tests:

  • test_plan_refuses_a_value_the_projects_loader_would_misread: gets REFUSED adopt-bad-leg-path: where it expects adopt-manifest-misread.
  • test_check_reports_a_plan_carrying_one: gets FINDING plan-bad-leg-path: where it expects plan-manifest-misread.
  • test_execute_refuses_a_plan_carrying_one_before_any_leg_exists: gets REFUSED plan-bad-leg-path: where it expects plan-manifest-misread.

The leg-path rule answers first in each case: _refuse_bad_leg_paths comes before manifest_misreads in plan, and #181's _plan_fields asks the misread gate only for a leg path with no leg_path_problem.

Whichever lands second makes one of two changes to those 9 rows; nothing else in #181 changes:

  • takes them out of REFUSED (the rows feed both PLANNED and CARRIED); or
  • rewrites them to expect adopt-bad-leg-path / plan-bad-leg-path and this sentence.

Those 27 rows were measured over bare templates. Over #259's quoted lines this rule no longer refuses those values, and that combination with #181 has not been run.

After this lands, #181's leg-path fields (LEG_PATH_PLACEHOLDERS in _flag_fields and _plan_fields) can never fire. Keeping them as a backstop or removing them is #181's call.

_read_as here uses the same words as #181's _the_loader_made (no value at all, the boolean true, the number 2026), so one can be folded into the other later. Entry paths and every other plan value stay #181's.

Until #181 lands, one sentence on this branch still misleads. For a plan holding a bare spec_path: 2026, check says ... is not text: ... so quote it, and then refuses the quoted "2026" with this PR's sentence. #181 already rewrites that sentence. Over #395's quoted lines the advice holds: the rule refuses none of the values those lines read back, and check accepts each of the 15 in a plan (this file's check acceptance rows pass on c11804a + d526f76).

#259 (quote the eight bare text placeholders in project.yaml and the leg pins, including the leg paths). Lane 5's PR #395 (fix/template-quoted-placeholders, head d526f76; 3894aa8, and 233dc71 before it, when fix round 266 ran) implements it. Since fix round 266 this PR composes with it in either order, with no edit to either branch:

#258 (--id and --tracking-branch written bare by the scaffold) was the same defect for two other values, outside the leg-path rule. #314 (3ef059c, on main) closed it with a read-back of its own: at c11804a, scaffold-project.py --dry-run --id null and --dry-run --tracking-branch true exit 2 with REFUSED scaffold-manifest-misread. That read-back (manifest_misreads) asks only the placeholders its caller names, and neither tool's MANIFEST_FLAGS names SPEC_PATH or CODE_PATH, so a leg path is judged by this rule alone and gets one sentence.

Left out

Review

Lane openRepoShape-2 reviewed aa2be9a: #266 (comment). Fix round 1, below, answers it. Fix round: composes with #259 in either order, below, answers lane 5's composition result. This lane's re-verify of 3410be7 found three figures in this body stale: the file's line count, the shas under Composes with, and how the new file fails on main's code. This version corrects them. A review by another lane is welcome.

Lane openRepoShape-1: the 27 #181 rows named under Composes with are the one thing whichever of the two lands second must change.

Fix round 1

This round answers the review above (fix list at aa2be9a). Commits 5141bd5, 272c1c8 and 39ac2c9, then b2b42f8, which merges main 6d8d88b for the import-list conflict with #284. Head b2b42f8.

Fix round: composes with #259 in either order

Lane 5 composed this PR with its #259 branch, fix/template-quoted-placeholders at 233dc71: #266 (comment).

This lane promised, in #266 (comment), to take lane 5's second option here. Commits aa357fc and 3410be7 keep it. Head 3410be7.

  • The rule reads the real template lines (aa357fc), as described under What:
    • _leg_path_lines finds them the way the guard test does;
    • _misread_reason renders each with render, as the scaffold and execute do, and reads it back;
    • the sentence and the remediation no longer say the templates write the value bare.
  • On today's bare templates it refuses exactly the same values. Only the wording changed.
  • On 3410be7 + 233dc71, and on 3410be7 + 3894aa8 (lane 5's PR templates: project.yaml, the leg pins and family.yaml quote their 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 head), it refuses none of them: 0 failed (see Composes with).
  • The guard test stays. Four new tests (20 rows) hold the rule's silence over quoted lines, its sentence over a pin-only misread, its refusal of an unreadable line and its offers. Each fails at b2b42f8 for its reason.
  • This reverses fix round 1's choice not to derive the line from the templates. That choice guarded against the rule going silent once a template is quoted. Silence is now what the rule must do there. The guard test still holds the rule to the lines it finds on its own, a bare copy of the templates holds its sentence, and a quoted copy its silence.
  • No digest pin moves. shape_materialize.py is in no copy list, so no project carries it, and both tools render from the shape's own templates/.
  • The cache is a module dict, not functools.lru_cache (3410be7). import functools conflicted with import json on fix/adopt-pin-writes-pin-file, which b2b42f8 merged cleanly.

Merged main after 7a8b981

c11804a merges main 7a8b981 into 50b3950, which was GitHub's update of this branch onto main 8782333. Main had since gained ec4a7a0 (#456), 437a360 (#343), d222a12 (#397) and 7a8b981 (#394, issue #189). Only 7a8b981 touches a file of this PR, and GitHub could not update the branch because of it. The head is c11804a.

Targeted run at c11804a: 1346 passed, 4 skipped, 0 failed, with git-filter-repo 2.47.0 on PATH, over test_leg_path_yaml_scalars.py, test_scaffold_manifest_values.py, test_tracking_branch_ref.py, test_scaffold_leg_paths.py, test_scaffold_leg_path_collisions.py, test_adopt_plan.py, test_adopt_submodules.py, test_adopt_e2e.py, test_manifest_validator.py, test_shape_doctor.py, test_cli_reference.py and test_repo_hygiene.py. The skips are test_plan_on_medxehr_matches_the_rulings, which needs SHAPE_MEDXEHR_CLONE, and 3 Windows-only PowerShell rows of test_shape_doctor.py. test_template_text_placeholders.py is #395's and is not on main yet. scripts/render-cli-reference.py --check passes.

Closes #225

🤖 Generated with Claude Code

Summary by Sourcery

Prevent scaffold and adoption workflows from accepting leg paths that the generated project would read back as a different YAML type.

Bug Fixes:

  • Reject leg paths that the project's YAML reader would deserialize as null, booleans, or numbers before scaffolding or adoption creates repositories or branches.

Enhancements:

  • Derive leg-path validation from the current YAML templates and verify every rendered leg-path line round-trips as text.
  • Keep canonical path suggestions from offering spellings that would subsequently fail leg-path validation.

Tests:

  • Add comprehensive coverage for YAML scalar coercion, quoted and mixed template lines, scaffolding, adoption planning, checking, and execution refusal paths.

…ead back as another type (`null`, `~`, `true`, `False`, `2026`, `1.5`) is refused before anything is created, under `--dry-run` alike, through the one definition adopt-project.py asks, so `--spec-path 2026` no longer creates three repositories whose first bootstrap fails `manifest-leg-path`, and `adopt-project.py execute` no longer says `adoption verified` before that bootstrap fails (#225)

The templates write a leg path bare: `path: {{SPEC_PATH}}` in
`project.yaml` and `submodule_path: {{SPEC_PATH}}` in the leg's pin. The
project reads them with its own copy of `repo_shape`'s reader, which takes
`null`, `Null`, `NULL` and `~` as no value, `true`/`false` in three cases
as booleans, and `-?\d+` and `-?\d+\.\d+` as numbers. All of them are in
`checked_value`'s alphabet, so #179's `leg_path_problem` passed them: the
scaffold exited 0 with three repositories made, and adopt's `plan`,
`check` and `execute` carried `2026` to `adoption verified`.

`leg_path_problem` now asks one more question, last: does the project's
reader give the value back from the line the template writes? The reader
is asked (`parse_yaml`), not a list of its words, so `yes`, `0x1F`, `1e3`
and `.inf`, which it reads as text, stay paths. The sentence names what
the reader makes of the value, with no spelling offered, and
`_canonical_spelling` no longer offers one this rule then refuses
(`2026/` is not answered `Write '2026'.`). The remediation names the rule.

Refused rather than quoted in the templates: only the scaffold and adopt
`execute` fill those placeholders, and both ask this rule before anything
exists. The three templates are rendered, not copied, so quoting would
have moved no per-file pin, but it would change every new project's bytes
for values that can no longer reach them.

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

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

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

You can request another review in 1 day and 1 hour by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes the shared leg-path rule consult the project’s actual YAML reader, refusing values that would be written as bare scalars but read back as null, booleans, or numbers. The validation is applied consistently before side effects in scaffold and adopt flows, with canonical-spelling and remediation updates plus end-to-end regression coverage.

Sequence diagram for shared leg-path validation before materialization

sequenceDiagram
    participant Caller as ScaffoldOrAdopt
    participant Rule as leg_path_problem
    participant Reader as parse_yaml
    participant Files as ProjectFiles
    participant Bootstrap as Bootstrap

    Caller->>Rule: leg_path_problem(what, value)
    Rule->>Reader: parse_yaml(path: value)
    Reader-->>Rule: parsed path value
    alt value is not read back as text
        Rule-->>Caller: Refusal with scaffold-bad-leg-path or plan-bad-leg-path
        Note over Caller: No work directory, repository, leg, or split branch is created
    else value is read back as text
        Rule-->>Caller: None
        Caller->>Files: Write bare leg path
        Files->>Bootstrap: Bootstrap project
        Bootstrap-->>Caller: Bootstrap succeeds
    end
Loading

Flow diagram for YAML scalar leg-path validation

flowchart TD
    A[Leg path supplied] --> B[checked_value]
    B --> C{Canonical and filesystem checks pass?}
    C -- No --> D[Refuse leg path]
    C -- Yes --> E[_misread_reason]
    E --> F[parse_yaml path: value]
    F --> G{Reader returns text?}
    G -- No --> H[Refuse before side effects]
    G -- Yes --> I[Continue scaffold or adopt flow]
    I --> J[Write project files]
    J --> K[Bootstrap reads matching text path]
Loading

File-Level Changes

Change Details Files
Extend the shared leg-path validation to verify round-trip text preservation through the project’s YAML parser.
  • Parse the template’s bare path scalar with repo_shape.parse_yaml.
  • Reject null-like, boolean-like, and numeric-like values with a detailed bootstrap-failure explanation.
  • Keep existing validation precedence and suppress canonical spellings that would themselves be rejected.
  • Update the remediation text to document the YAML round-trip rule.
scripts/shape_materialize.py
Add comprehensive regression coverage for YAML scalar misinterpretation across scaffold and adoption workflows.
  • Cover refused scalar values, accepted lookalikes, parser behavior changes, and canonical-spelling behavior.
  • Verify scaffold dry-run and real execution refuse before creating work directories or remotes.
  • Verify adopt plan, check, and execute refuse consistently before writing plans or creating repositories.
  • Verify accepted paths render as text in manifests and pins and bootstrap successfully.
tests/test_leg_path_yaml_scalars.py

Assessment against linked issues

Issue Objective Addressed Explanation
#225 Reject leg-path values that the project's YAML reader would deserialize as a non-string, such as null, booleans, integers, or floats, before scaffold or adoption creates repositories or branches. ✅
#225 Apply the same leg-path validation consistently across scaffold, including --dry-run, and adopt-project.py plan, check, and execute flows, so invalid values cannot pass through to a failing bootstrap. ✅
#225 Add regression coverage for the rejected YAML scalar values, accepted lookalike/text paths, canonical spelling behavior, and the no-side-effects guarantees. ✅

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T22:48:05Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T17:39:59.937803Z 3410be7 Manual request
ℹ️ About Codex in GitHub

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

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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: aa2be9a2a3

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

This was referenced Oct 11, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T23:58:20Z)

This lane reviewed its own PR #266 at aa2be9a, before any fix round, read-only in its own detached worktrees.

MERGEABLE WITH NITS. Sequencing: whichever of this PR and #181 lands second must change #181's 9 leg-path REFUSED rows (27 tests). A #259 fix that quotes the template lines must remove or re-point this rule.

Findings:

Tests:

  • Main c3f4987 with the new file: 111 failed, 58 passed, each for the issue's reason.
  • Head merged with main: 1221 passed, 1 skipped at c3f4987, and 1055 passed, 1 skipped at 67a398a.
  • Mutants: 6 of 7 killed. The survivor is the template one above.
  • Fuzz of 32,040 values: the PR changes exactly the 205 the project misreads.
  • The issue's scaffold, setup and adopt runs fail on main and are refused on head before anything exists.

Merges:

The full report is at handoffs/xFactory/attachments/lane-openRepoShape-2/review266-aa2be9a/final-report.md in opensoft/brett-wip.

brettheap and others added 2 commits October 12, 2026 00:04
…eld to every YAML template line that holds `{{SPEC_PATH}}` or `{{CODE_PATH}}`, found by reading `templates/`, so quoting those lines as #259 proposes fails a test instead of leaving the rule refusing `2026` over a line that reads it back (#225)

The rule asks `LEG_PATH_LINE` ("path: {}\n"), a hand-kept copy of the line
the templates write a leg path into, and nothing tied the copy to the
templates. Lane openRepoShape-2's review of aa2be9a (F1) quoted the four
template lines, as open issue #259 proposes, and left the rule alone (its
mutant MU7): the PR's 169 tests stayed green while the rule went on
refusing `2026`, `null`, `~`, `true` and `1.5`, which the quoted lines read
back exactly, in a sentence saying the line is unquoted.

The new test finds, rather than lists, every line of every `.yaml` or
`.yml` file under `templates/` that holds either placeholder (today
`assembly-root/project.yaml:159,163` and
`assembly-root/contracts/{spec,code}-pin.yaml:22`). For each value of
`REFUSED` and `KEPT` it renders the value into each line with
`shape_materialize.render`, reads the line back with `repo_shape.parse_yaml`,
and asserts the rule refuses the value exactly when one line does not give
it back. A second test checks the search found the files the rule's
sentence names, so it cannot pass by finding nothing.

On aa2be9a with MU7 applied, the file gives 15 failed, 194 passed: the 15
are the new test's REFUSED rows, each reporting the rule's refusal beside
four read-backs equal to the value. At this commit it passes. The comment
on `LEG_PATH_LINE` names the test, so whoever quotes the lines knows this
rule goes or is re-pointed with them.

Deriving `LEG_PATH_LINE` from the template at import time (the review's
option b) was not taken: it would have the module read a template on every
import, it would have to follow four lines and the sentence's own wording,
and a quoted template would then switch the rule off in silence, where the
test makes the change say so.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead names the assembly root's `project.yaml` and `contracts/<role>-pin.yaml`, so it no longer reads as a file inside the leg repository (#225)

The #225 sentence said "`project.yaml` and the leg's `contracts/` pin hold a
leg path unquoted". The pin it means is the assembly root's
`contracts/spec-pin.yaml` or `contracts/code-pin.yaml`, and "the leg's
`contracts/` pin" can be read as a `contracts/` directory inside the leg
repository (lane openRepoShape-2's review of aa2be9a, nit N2). It now says
"the assembly root's `project.yaml` and `contracts/<role>-pin.yaml` hold a
leg path unquoted (`path: 2026`)", naming the pin file the way `bump-leg.py`
and docs/cli.md already do. The rule's verdicts are unchanged; adopt's
`plan`, `check` and `execute` say the same sentence, as they ask the same
rule.

`sentence()` in tests/test_leg_path_yaml_scalars.py, the oracle every
sentence check compares against, says the new wording. Against aa2be9a's
code the file then gives 105 failed, 104 passed: the 105 are every test
that compares the sentence. At this commit it gives 209 passed.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…d must close there, so `k: [it's, a]` reads two items, `k: it's fine # note` drops its comment, and an unclosed `"abc` or a mid-key `[` is refused, not misread (#249, #250) (#284)

**The reader (`scripts/repo_shape.py`).** It now has one rule for where a quoted scalar starts and one for where it ends. That replaces three separate toggles and a first-and-last-character test.

- `_unquoted(text, flow=False)` yields each character that no quoted scalar holds. A quote opens a quoted scalar only where a scalar begins: at the start of the text, or after `- `, `: `, `[`, `{`, or a `,` inside a flow, with any spaces after each. Anywhere else (`it's`, `a"b`) the quote is an ordinary character of a plain scalar. On a block line, a `,` or `[` in the middle of a plain scalar is not structure either, so `k: a, 'b # c'` is the plain scalar `a, 'b` followed by a comment, which is how YAML reads it.
- `_quote_end(text, start)` decides where the scalar ends: at a `"` that no backslash escapes, or a `'` that is not doubled.
- `_strip_comment`, `_split_key` and `_split_flow` read structure through `_unquoted`, so all three agree (#249).
- `_scalar` hands any text that begins with a quote to the new `_quoted_scalar`. The whole text must be one quoted scalar, or it raises `quoted scalar without a closing quote: …` or `quoted scalar with text after its closing quote: …` (#250). `load_yaml` turns either into `REFUSED yaml-unparsable`.
- `_unquote_key` sends every key that begins with a quote through `_scalar`, so `"a" b: 1` is refused instead of becoming the key `"a" b`.
- `_split_key` refuses a `[` or `{` later in a key, by name (see "Why `_split_key` refuses a bracket later in a key" below). A `[` or `{` that opens the text still makes a flow item.
- One paragraph is added to the reader's header comment. `_split_key_flow`, `_flow`, `_flow_value`, `_tokenise` and `_parse_*` are untouched; #252 and #248 work in those and have landed.

**The template renderer (`scripts/shape_materialize.py` `render`).** In a `.yaml`/`.yml` template, a value that sits between double quotes (`"{{ELECTED_BY}}"`) is now spelled by `double_quoted`. That is the writer this reader takes back unchanged. For any value with no quote, no backslash and no character `str.isprintable` refuses, `double_quoted` returns the value itself between quotes. So commits, digests, dates and ordinary names (`O'Brien & Co: #1 [draft] {x} é 日本 😀` included) render byte for byte as before in all 19 templated files, and README/AGENTS templates keep raw values. A name that holds a character `str.isprintable` refuses (a no-break space, U+3000, the ideographic space common in Japanese names, a zero-width joiner in an emoji sequence, or a soft hyphen) now renders as an escape in `project.yaml`, `family.yaml` and `single-repository.yaml` (`山田\u3000太郎`), and reads back equal. It is the same rule the adoption plan writer already follows.

The two issues' tables, read with each tree's own reader (`issue_tables.py`). Every head result matches PyYAML 6.0.3:

| document | 150c41d | head |
|---|---|---|
| `k: [it's, a]` | `["it's, a"]` | `["it's", "a"]` |
| `k: {a: it's, b: 1}` | `{"a": "it's, b: 1"}` | `{"a": "it's", "b": 1}` |
| `k: it's fine # note` | `"it's fine # note"` | `"it's fine"` |
| `k: [it's, a] # note` | refused: not a flow collection | `["it's", "a"]` |
| `it's: 1` | refused: expected `key: value` | `{"it's": 1}` |
| `k: [a"b, c]` | `['a"b, c']` | `['a"b', 'c']` |
| `k: [a"b\", c]` | `['a"b\\", c']` | `['a"b\\"', 'c']` |
| `created_by: "Brett Heap` | `'"Brett Heap'` | refused: no closing quote |
| `k:\n  - "a\"` | `['a\\']` | refused: no closing quote |
| `k: "a" b "c"` | `'a" b "c'` | refused: text after its closing quote |
| `k: 'a' b 'c'` | `"a' b 'c"` | refused: text after its closing quote |
| `k: ["a\", b]` | `['"a\\", b']` | refused: no closing quote |

The other five #250 rows (`k: "abc`, `k: "a\", b`, `k: "a"b`, `k: 'abc`, `k: ["a, b]`) are refused on head the same way.

**Why `_split_key` refuses a bracket later in a key.** `_split_key` has always returned `None` for any `[` or `{` ("a flow collection is not a key in this subset"). On 150c41d a quote toggle hid the bracket whenever the key held a quote pair, so `note "[1]": x` was a mapping entry by accident. Reading quotes only where a scalar begins lets that rule see the bracket. As a mapping entry that would be a refusal, but `_parse_sequence` takes `None` to mean "a scalar item", so `- note "[1]": x` would have read as the string `note "[1]": x` where YAML has a mapping: a silent misread of valid YAML. Review found it (20 of the 300 documents that regressed on one fuzz seed read that way), so the rule is now in two parts. A `[` or `{` that OPENS the text still returns `None`, so `- [a, b]` and `- {a: b}` stay flow items. One LATER in the key, before its `: `, raises `a key holding `[` or `{` is not supported: <key>`.

| document | 150c41d | head | PyYAML |
|---|---|---|---|
| `- note "[1]": x` | `[{'note "[1]"': 'x'}]` | refused, names the key | `[{'note "[1]"': 'x'}]` |
| `note "[1]": x` | `{'note "[1]"': 'x'}` | refused, names the key | `{'note "[1]"': 'x'}` |
| `- a[b]: c` | `['a[b]: c']`, a string | refused, names the key | `[{'a[b]': 'c'}]` |
| `a[b]: c` | refused: expected `key: value` | refused, names the key | `{'a[b]': 'c'}` |
| `k:\n  - a{b}: c` | `{'k': ['a{b}: c']}`, a string | refused, names the key | `{'k': [{'a{b}': 'c'}]}` |
| `k: {a[b]: c}` | refused: flow mapping entry without a key | refused, names the key | refused |
| `- [a, b]`, `- {a: b}` | flow items | flow items | flow items |

So the bracket-in-key class is now refusals, not silent misreads, and the refusal covers 150c41d's own silent misread of `- a[b]: c` too. The class is narrow in practice: no writer in this tree can emit such a key (the adoption plan and manifest writers write literal keys and put free text in values), so only a hand edit of a fixed schema reaches it. Reading `a[b]: c` as YAML does (the key `a[b]`) would mean telling a block key from a flow-entry key, which is `_split_key_flow`'s business (#252's function); accepting a mid-key bracket in `_split_key` alone newly reads 6,521 more invalid documents on one seed. That stays a follow-up (see Left out).

**Why the renderer had to change as well.** `render` pasted free text raw between a template's double quotes. So `scaffold-project.py --elected-by 'Robert "Bob" Smith'` wrote `elected_by: "Robert "Bob" Smith"`, which is not YAML. The old reader read it correctly by accident. With the reader fix alone, the scaffold would create and push all three repositories and then write a manifest its own validator refuses. A mutant of this branch without the renderer change shows exactly that: three bare remotes exist, then `REFUSED yaml-unparsable: …/project.yaml: quoted scalar with text after its closing quote: '"Atlas "the" Project"'`. On 150c41d some values were already misread rather than refused: `--reference 'docs\notes # 1'` reads back as `docs`, a line feed and `otes # 1`. I rendered every quoting template with 11 free-text values through each tree's own `render` and reader (`render_real.py`). On 150c41d 64 of 88 read back exactly. On head all 88 do. The other free-text placeholders that reach `render` are `--name`, `--reference`, `family.py --created-by`/`--name`, and the adoption plan's `elected_by`/`reference`. All three writers (scaffold, adopt `execute`, `family.py init`) render through this one function, and none pre-escapes, so nothing is escaped twice. Nothing in the tree digests or re-renders a templated file: shape-pin `files:` rows digest only the verbatim and from-shape copies.

- `tests/test_yaml_flow_quotes.py`, `DANGLING_BACKSLASH_BEFORE_THE_CLOSER` (#213's review, N5). These four rows pinned the plain-text read of a double-quoted flow item whose last character is a backslash (`k: ["a\]` read as `['"a\\']`), and their block twins reading the same. The table's own comment said that if a later change refused an unterminated double-quoted scalar, "these become refusals together with their block twins, and that is the change to make here, not a regression". They are now refusals, with their twins. The test is renamed `…_is_refused_with_its_twin` and its docstring updated. Two sentences of the module docstring that described the old toggle are reworded.
- No test pinned a "quote in a plain item" read. The single-quoted toggle rows (`['x\', 'it''s, a]']`, `{'a\': 'it''s: b'}`) read exactly as before, and their test is unchanged.

- **New tests.** The three files below hold 157 cases:
  - `tests/test_yaml_quote_boundaries.py`, 73 cases: #249's rows and their neighbours, #250's rows plus key, flow-mapping and no-space-comment forms, rows that must not change, a `double_quoted` round trip beside a plain quote, a `load_yaml` refusal, and the bracket-in-a-key rows (eight refused by name, the message quoting the key, a file read through `load_yaml`, seven flow items and values that must still read). Two rows pin that a `:` with no space after it opens no quoted scalar: `k: [a:'b, c']` reads as the two items `a:'b` and `c'`, and `k: a:'b # c'` as `a:'b`.
  - `tests/test_template_quoted_values.py`, 67 cases: every quoting template times 7 free-text values, byte identity for ordinary values, raw values in a Markdown template, and one real scaffold into local bare remotes.
  - `tests/test_yaml_flow_quotes.py`, 17 cases, of which the four N5 rows changed.

  Every expectation was checked against PyYAML 6.0.3. They disagree on eight documents, and the subset refuses each on purpose. One is `k: "a"#c`: PyYAML reads `a`, but YAML requires a space before a comment, so the subset refuses it rather than guess. The other seven are new bracket-in-key rows, which PyYAML reads as a mapping whose key holds a bracket, and which the subset refuses by name. The module docstring of `tests/test_yaml_quote_boundaries.py` still names only the first; re-verification counted all eight.
- **Fail on 150c41d.** With the three files copied onto 150c41d, 77 of the 157 cases fail (48 of 73, 25 of 67, 4 of 17) and 80 pass; all 157 pass on head. The 80 that pass are guards, and free text that 150c41d reads by accident. Of the bracket-in-a-key rows, all ten refusal rows fail on 150c41d and the seven flow-item guards pass on both.
- **Mutations.** Each of the following makes at least one new test fail: each changed function reverted to its 150c41d body (`_strip_comment` 5, `_split_key` 9, `_unquote_key` 1, `_scalar` 21, `_split_flow` 14), each rule of the new helpers mutated (comma outside a flow 2, bracket mid-scalar 1, `- ` 2, `: ` 22, `''` 6, backslash 31, text after the close 6, unclosed read as plain 15, unclosed as an ordinary character 1), and each part of the bracket rule (no raise, the start-of-text test inverted, a bracket always `None`, the flag never set: 10 to 12 each). A `:` that opens a quoted scalar with no space after it fails 2, the two rows above; review found it surviving the earlier tests. Removing the renderer change fails 41, including the real scaffold. Escaping Markdown templates too fails 1. The mutants that survive change nothing on any valid document, as review showed: the header comment; `.yml` dropped from `render` (both `.yml` templates are verbatim copies and never rendered); `_strip_comment`'s `i == 0` branch (unreachable, `_significant_text` drops a whole-line comment first); `]`/`}` no longer closing a block-line flow; and a tab after `:` or `-` (PyYAML refuses those documents itself).
- **Reach.** All 14 tracked YAML files read identically on 150c41d and head, both raw (7 ok; 7 templates refuse identically on their raw block placeholders) and with `{{X}}` rendered to bare words, and again identically between this branch with and without the bracket refusal. Nothing this repository writes holds a quote in a plain scalar, a bracket in a key, an unclosed quoted scalar, or text after a closing quote:
  - `adopt-project.py` `y()` (the adoption plan, and `shape-doctor.py`'s placement plan) writes plain only for values matching `_PLAIN_RE`, and spells everything else with `double_quoted`. Its keys are literals, and plan paths are values.
  - The pin writers (`bump-leg.py`, `update-shape.py`, `family.py`, and the shape pin's `files:` rows) quote only hex.
  - `family.py`'s member rows and every unquoted template value pass `checked_value`/naming-policy alphabets, which hold no quote.
  - The one writer that could emit the #250 construct was `render`'s quoted placeholders. That is fixed above.
- **Differential fuzz.** PyYAML 6.0.3 as the oracle only.
  - Raw text in all six contexts (flow sequence, flow mapping, value, item, key, nested item; seed 2490250; 785,646 documents, 305,393 valid), this branch against current main (c3f4987):
    - 13,752 valid documents are fixed.
    - 768 valid documents regress, and every one is a refusal, with the new message: none reads as a string. Every one has a `[` or `{` in a key. Before the refusal, against 150c41d, the same 768 were 694 refusals and 74 reads as a string.
    - 73,380 invalid documents that main read are now refused, and 5,955 that main refused are now read: 5,154 are a plain scalar that begins with a flow indicator in a key (`,": v`; main reads the quote-free twin too, follow-up 3), 775 are a flow mapping with a stray closer and an empty value (`k: {a"]:}`; main refused only because its quote toggle swallowed the `:}`, and reads the twin `k: {a]x:}` the same way, a composition with #252's empty value, follow-up 2), and 26 are in other contexts.
    - 0 crashes.
  - The refusal alone, seed 7249250 (70,941 inputs, 425,646 documents), against main c3f4987, this branch before and after it. Regressions: 300, of which 280 refused and 20 silently read before; 300 refused and 0 read after. Fixed: 7,199 both. Invalid documents newly read: 3,560 both. Misreads of valid documents that become refusals: 538 before, 2,088 after, so 1,550 more, `- a[b]: c` among them. 0 crashes.
  - Flow against block, on the merged reader: seed 2490251, 100,000 documents, 0 wrong in the flow form, 0 in its block twin, 0 where they differ; seed 2490252 with the #199 code points, 60,000 documents, 0 wrong, 0 wrong, 0 differing. (Earlier, on the uncommitted first version: seeds 2130213 and 1990213, 0 wrong in either form; review re-ran two more seeds on 21fc213, 0 wrong.)
  - `double_quoted` round trip (seed 19913, 40,000 strings in 5 positions, 0 mismatches) and #249's class densely (seed 2130213, 50,000 valid documents; 150c41d reads 19,949 wrong, head reads 0 wrong) were run on the first version, and the review re-ran the round trip on 21fc213 (seed 4925049, 30,000 strings, 0 mismatches). Neither touches `_split_key`'s new rule, and they were not re-run on the merged head.
- **Targeted runs** (git-filter-repo 2.47.0 on PATH). The five streams below ran on b764657, the head before the last merge of `origin/main`. That merge (60588d5, #251 and a `family.py` change) touched neither `repo_shape.py` nor `render`, so on the pushed head b72ab0f I re-ran the four reader files (187 passed) and `test_scaffold_referent_chain_links`, `test_template_quoted_values`, `test_cli_reference` and `test_yaml_quote_boundaries` (197 passed).
  - The three new or changed files, `test_yaml_flow_mappings`, `test_yaml_block_scalar_keys`, `test_load_yaml_refusals`, `test_adopt_plan_escapes`, `test_manifest_validator`, `test_pin_validator`, `test_neutral_pin_validator`, `test_repo_hygiene`, `test_cli_reference` and `test_adopt_plan`: 512 passed, 1 skipped (`test_adopt_plan.py:393`, `SHAPE_MEDXEHR_CLONE` unset).
  - The four reader files alone (`test_yaml_quote_boundaries`, `test_yaml_block_scalar_keys`, `test_yaml_flow_quotes`, `test_yaml_flow_mappings`): 187 passed.
  - `test_shape_doctor`: 191 passed.
  - The renderer's callers, `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_reference_default`, `test_family`, `test_windows_paths`, `test_naming_policy`: 366 passed.
  - `test_adopt_e2e`, `test_adopt_spec_only`, `test_update_shape`: 110 passed.
  - Review also ran, on 21fc213: `test_shape_advisory`, `test_bump_leg`, `test_reuse_visibility`, `test_update_shape_add`, `test_scaffold_ignored_shape_files` (97 passed) and `test_adopt_submodules` (132 passed). They were not re-run on the merged head.

This branch carries `origin/main` at 60588d5, by four merges (eb6df75, c3f4987, 5751460, 60588d5). Only the first conflicted, in one hunk of a comment: both this branch and #252 (landed as 4b2add8) add a paragraph to the reader's header comment right after "confident tone.". Both paragraphs are kept, this branch's first, with a `#` line between them. Every code hunk merged by itself, including #252's `_split_key_flow`, which calls this branch's `_split_key`, and `shape_materialize.py`. The merges of #248 (c3f4987, a block scalar's record as a pair), of #251 (23e16ae) and of the two others were clean. `repo_shape.py` is the same file after the last three merges as after the second. On the merged reader, `test_yaml_flow_mappings`, `test_yaml_block_scalar_keys` and `test_scaffold_referent_chain_links` pass beside this branch's files (see Evidence).

`git merge-tree --write-tree` of the head against `origin/main`: clean. Against another lane's open PR:

- #266 (`fix/leg-path-yaml-scalars`) conflicts in one place: the `from repo_shape import (…)` block of `scripts/shape_materialize.py`, where this branch adds `double_quoted` and #266 adds `parse_yaml`. Whichever lands second resolves it as `double_quoted, file_sha256, parse_yaml,`. There is no semantic interaction: a leg path is an unquoted template value, and `SAFE_PATH_RE` admits no quote.

- **Reading a `[` or `{` inside a plain block key as YAML does.** `a[b]: c` and `- a[b]: c` are refused by name here; YAML reads the key `a[b]`. Reading them needs block keys and flow-entry keys told apart (`_split_key_flow`'s business), and must refuse only a block key that BEGINS with `[` or `{`. A first try that accepted a mid-key bracket in `_split_key` alone fixed all 300 regressions on one seed, but newly read 6,521 more invalid documents (keys beginning `]` or `}`, and `{a[b]: c}` entries). Candidate follow-up.
- **A plain flow item followed by a nested flow, and unbalanced brackets.** `k: [x [a, b]]` reads as the one scalar `x [a, b]`, and `k: {a:}#}: b}` reads `{'a:}#}': 'b'}`, because `_flow` checks only a body's ends. PyYAML refuses both. Candidate follow-up.
- **Implicit single-pair mappings in a flow sequence.** `[k: v]` still reads as the string `'k: v'`, as #213's review (F4) found.
- **Not supported, by choice.** A quoted scalar spanning lines is outside the subset, and 150c41d did not always refuse it. When the continuation line was indented deeper, it was refused as unexpected indentation. When the continuation sat at the parent's column, it was MISREAD: `k: "a` then `b: c"` read as `{'k': '"a', 'b': 'c"'}`, and `- 'a` then `- b'` as `["'a", "b'"]`. Every such form is now refused at its first line, naming the unclosed scalar. `k: "a"#c` is refused too, as explained above.
- Not run locally: the full suite, and the macOS and Windows legs (CI ran them). The new tests write their files as bytes, pass no text to git's stdin, and pass the scaffold its arguments as a list.

Reviewed adversarially (APPROVE WITH NITS, 0 blockers). Fix round 1 ports the review's narrow `_split_key` refusal, adds the rows it asked for and merges main; its re-verification found no blocker or should-fix. CI is green on Linux, macOS and Windows.

Closes #249
Closes #250

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach line under its template's name, as the scaffold does, so on a merge with #284 a quoted leg-path line is spelled by `double_quoted` in the test as in the project (#225)

`shape_materialize.render` is told the template it renders (`rel`, such as
`project.yaml`). At this head it uses that name only in a refusal. #284
(fix/yaml-quote-boundaries, b72ab0f) also uses it: a value between a `.yaml`
or `.yml` template's double quotes is spelled by `double_quoted`. The F1
test passed the name "test", so on that merge it would have rendered a
quoted line raw while the scaffold spells it, and the line it read back
would not have been the line the project holds.

`template_lines_holding_a_leg_path` now yields `(template, line number,
line)`, and `read_back` passes the template to `render`. No verdict changes
for any value the tests ask: none holds a quote, a backslash or a character
that is not printable, so both spellings are the same bytes.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… conflict with #284

#284 (fix/yaml-quote-boundaries) landed on main as b30595c and added
`double_quoted` to the `from repo_shape import (...)` list in
scripts/shape_materialize.py, the same list this branch adds `parse_yaml` to,
so the pull request could no longer merge. The one conflicting hunk is
resolved as the union of the two sides: `double_quoted, file_sha256,
parse_yaml`. Nothing else conflicted.

The F1 test renders template lines with `render` under the template's name,
so with #284's `render` on this branch a quoted leg-path line is spelled by
`double_quoted` in the test as in the project.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…isread` says what holds of every value it refuses, that the project's reader reads back something other than what was typed, and no longer says `--tracking-branch 2026` or `1.5` fails the first bootstrap or lands the legs on a branch the remotes lack (#258)

The review of ca21b57 found the text false for exactly the values this refusal
newly takes from people. On main both scaffold, bootstrap with exit 0, and get
the upstream origin/2026 or origin/1.5, because every reader of
`tracking_branch` does `str()` and `str(2026)` is `2026` again. The refusal
stays, as #181 and #266 refuse the same class and quoting the templates is
#259's to decide; its reason is now the true one. It 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 a number which survives `str()` works only
because of the tools that read it and is still a number to the project's own
reader, and gives the way forward: a value the reader takes for text.

Tests: the wording is asserted for `--tracking-branch 2026`, `1.5` and `007`
and `--id 2026` and `null` (five rows that fail on the old text), and the claim
about `str()` is asked of the project's own reader.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-12T11:17Z)

Composition heads-up for this PR. Brett ruled on #259 before 11:14:35Z: quote the bare template placeholders. Lane 5 is quoting them on fix/template-quoted-placeholders, including both legs' path: in templates/assembly-root/project.yaml and submodule_path: in the two leg pins.

Once that lands, this PR's LEG_PATH_LINE = "path: {}\n" describes a line the template no longer writes. Its refusal would then refuse leg paths such as 2026, 1.5 or true, which the quoted template reads back exactly. A template-relative check adapts by itself: render the real template line and read it back, as #314's manifest_misreads does. Lane 5 will post the merged-tree result here once its branch is pushed.

Lane: openreposhape-5 (openRepoShape-5)

brettheap added a commit that referenced this pull request Oct 12, 2026
…ly 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)

**What was wrong (#260).** `render` replaced 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-by` and `--reference` in both tools, the scaffold's `--name`, and `family.py init`'s `--name` and `--created-by` are free text, so each could hold another placeholder's token:

- A token for a key that came later was expanded. `adopt-project.py --elected-by '{{SPEC_PATH}}'` recorded `elected_by: "spec"` in `project.yaml` and the README, and said `adoption verified`.
- A token for a key that came earlier (`{{PROJECT}}`), or one no key names (`{{NOPE}}`), stayed in the output. `render` then refused it as `template-unsubstituted`, with a remediation that blamed the tool. By then `execute` had pushed both legs, the scaffold had created all three repositories, and `family.py init` had 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`).**

- `render` reads 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`/`.yml` template the quoted form `"{{KEY}}"` is matched first and spelled by `double_quoted`, exactly as #284 made it (`YAML_PLACEHOLDER_RE`, group 1).
- The refusal asks the template, not the output: it names the placeholders the template holds that the table has no value for, and it is raised before anything is substituted. That is the defect its remediation describes, so the remediation is now true. The output now holds the person's text, so it is no longer searched. Message and code are unchanged: `REFUSED template-unsubstituted: <source>: no value for ['{{NOPE}}']`.
- `PLACEHOLDER_RE` now 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'` lists `scripts/shape_materialize.py` alone: `copy_tree` and the three calls in `_materialize`. (`scripts/render-cli-reference.py` has its own unrelated `render`.)
- `copy_tree`, `materialize_assembly_root` and `materialize_family_root` are called only from `adopt-project.py execute` (`:2549`, `:3101`), `scaffold-project.py` (`:1013`, `:1054`) and `family.py init` (`:712`). Every one of those calls runs while the tool is creating the repositories.
- `update-shape.py` imports no renderer and no `TEMPLATED` list. In `project.yaml` / `family.yaml` it rewrites only the `shape:` block's `commit:` and `tree_sha256:` lines (`rewrite_manifest`, `:869`). The README's trailing `Shape:` line is a span rewrite of the commit.
- `bump-leg.py` rewrites only a leg's two pin lines.
- `shape-doctor.py` imports only `escape_what_stdout_cannot_encode` and `lane_trailer_argument` from the materializer. It reports a leg's `AGENTS.md`/`README.md` as `rendered` and compares nothing.
- Neither `bootstrap.py` nor any shipped validator holds `{{`: `git grep -n -F '{{' -- 'templates/*.py'` finds only `^{{commit}}` in two f-strings.
- `shape_advisory.py`'s own `PLACEHOLDER_RE` is asked only of `single-repository.yaml` (`field_problems`, called only from `read_record`). That is a record a person writes by hand, never `project.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`: the `values` table, plus `_seed_legs`' four leg values and `--pin`'s `PIN_*` values for neutral pins. Same reliance; the probe after this list names the keys.
- `scripts/family.py`: `_template_values`. `FAMILY` is a key no template names, and that is harmless.
- `setup-project.py` renders nothing. It forwards `--elected-by` and `--name` to the scaffold, and its own `NAME_RE` already refuses `{`.
- `update-shape.py` and `shape-doctor.py` render nothing (above).
- Tests: `test_adopt_leg_path_collisions.py` and `test_scaffold_leg_path_collisions.py` build a table from `PLACEHOLDER_RE.findall` over `templates/assembly-root/`. They now include the digest keys and still pass. `test_shape_advisory.py` renders leg `AGENTS.md` and the record template, which have no digest keys. `test_scaffold_ignored_shape_files.py` calls `copy_tree`. `tests/test_template_quoted_values.py` built its tables with its own `[A-Z_]+`, so its tables lacked the digest keys and its own `"SHA256" in key` branch never ran. It now uses `[A-Z0-9_]+`, and #284's behaviour is otherwise held unchanged.

A probe (`ast` over each tool's dict literals, against every placeholder under `templates/`) finds that every capitals key of the scaffold's and adopt's tables is named by a template, except `ORG`, which only `family-root` and `workspace-root` name. Every key of family's table is named by a template, except `FAMILY`.

**Byte-for-byte for ordinary values.** Every text file under `templates/` (49) was rendered with b30595c's `render` and 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_substitution` keeps 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 from `project.yaml` and `family.yaml`. Every README and `AGENTS.md` holds 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.
- End to end, into local bare remotes, with a `gh` on PATH that records any call (none is made). What was pushed is read back with `git 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 pushed `project.yaml`, README and both legs' `AGENTS.md` hold the values as typed, and the project's own `validate-manifest.py` passes.
  - `adopt-project.py plan` / `check` / `execute` with `--elected-by` and `--reference` the same way. All three pass, with `adoption verified`. The split commit's `project.yaml` and README hold the values as typed. This test is skipped at its decorator without `git filter-repo`.
  - `family.py init --created-by` the same way. `family.yaml` holds the value, and `validate-family.py` passes. (The family's `--name` is 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 whatever `render` does. 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_RE` without 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`, with `git-filter-repo` 2.47.0 on PATH: 792 passed, 1 skipped (`test_adopt_plan.py:393`, `SHAPE_MEDXEHR_CLONE` unset). `tests/test_shape_doctor.py`: 208 passed.

**Composition** (`git merge-tree --write-tree` against the current tips; each merged tree ran the sibling's own test files plus this PR's two):

| Branch | Tip | Merge | Merged-tree tests |
|---|---|---|---|
| `origin/main` | c0ed615 | clean | 680 passed |
| `fix/scaffold-manifest-values` (lane 5's #258/#261, PR #314) | 7defac7 | clean | 370 passed |
| `fix/adopt-template-values-utf8` (lane 2's #270) | edfa7c6 | clean | 278 passed |
| `fix/leg-path-yaml-scalars` (lane 2's #266) | b2b42f8 | clean | 409 passed |
| `fix/adopt-check-shape-paths` (lane 5's #262/#263, PR #313) | 65a9262 | clean | 320 passed |

- #314 calls `render` in `manifest_misreads` and `manifest_invalid` with the complete table, so the template-side refusal never fires there. It now finds placeholders with this `PLACEHOLDER_RE`.
- #266 has merged main, so the import-line conflict an earlier tip had is gone.
- **Lane 1's #181 (`fix/adopt-plan-quote-yaml-keywords`, e337f44): a clean merge, one failing test, and the fix belongs to whichever lands second.** #181 alone passes (841). On the merged tree, 1 of 974 fails: `test_adopt_records_each_free_text_value_as_typed[every-key]`, refused `adopt-manifest-misread` at `plan`. #181's gate `_written` (e337f44 `adopt-project.py:1668-1669`) spells the value with `double_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 values `execute` now 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 pass `plan` and are written as typed. Neither branch is wrong against main, where `render` really would expand them. Whichever lands second makes `_written` one pass for its key: one `re.sub` over `"(\{\{KEY\}\})"|\{\{KEY\}\}`, or `render` on the line, as #314's `_misread_rows` does. Tokens of other keys (`{{SPEC_PATH}} {{NOPE}}`) pass on every tree.

**Left out.**

- No template placeholder is quoted (#259 waits on Brett), and no free-text value is refused for holding `{{`, since nothing renders it again.
- `family.py init --name` takes a display name (its help says so) that the holder's own `validate-family.py` refuses. On b30595c, `--name 'Contoso Group'` exits 0 and pushes the holder, and `validate-family.py` then exits 1 with `naming-unclassified: 'Contoso Group' matches no family in the naming policy`. This is pre-existing and not touched here; it is filed as #307, with `family.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:
- #260's rows reproduced on b30595c and pass on the head, for adopt, scaffold and `family.py init`.
- All 49 templates render byte-identically between base and head over 25 tables of awkward values, in both key orders, and 69 files pushed by the real tools are byte-identical.
- A 40,000-case fuzz of `render` against an independent single-pass reference found 0 mismatches.
- 322 hostile values round-trip through `project.yaml` and `family.yaml` under both this repository's reader and PyYAML.
- Every table holds every digest key its templates name, so the widened refusal cannot fire on a run that used to succeed.

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:
  - a `.yml` source gets the quoted spelling as a `.yaml` one does;
  - the `template-unsubstituted` list is the sorted set of what the template lacks, each token once;
  - an empty string in the table is a value that renders as empty text and is never refused.

  The seven targeted files pass on the merged tree, 541 tests.
- The `gh` stub proves nothing on Windows, where no test calls `gh`.

Reviewed adversarially: the first reviewer stalled and was replaced; the second found the code sound and asked only for this body's composition to be corrected, which it is. Fix round 1 adds tests for the three mutants that survived. After #313 landed, the branch was updated onto main 71b58a1 (9469148), and CI re-ran green on Linux, macOS and Windows, with SonarCloud passing.

Closes #260

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

Copy link
Copy Markdown
Contributor Author

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

This lane re-verified its own PR #266 at b2b42f8 after fix round 1, read-only in its own detached worktrees.

MERGEABLE WITH NITS, the nits being in the PR body. Whichever of #181 and #266 lands second changes #181's 9 leg-path rows.

Tests:

  • Head plus main c7c02fa: 790 passed, and 1041 passed with 4 environment skips.
  • The PR's file: 209 passed at head. On main it gives 126 failed for the issue's reason.
  • Mutants: 10 of 12 killed. The other two pass by design.

CI is green on all three OSes.

Merges:

The report is handoffs/xFactory/attachments/lane-openRepoShape-2/review266-aa2be9a/reverify-1.md in opensoft/brett-wip.

brettheap added a commit that referenced this pull request Oct 12, 2026
…-spec-path` and `--code-path` that it left out and sends the reader to each refusal's Remediation for the whole rule, so a value such as `spec.`, `-x`, `é` or `Project.yaml` is no longer one the help seems to allow (#340)

The first help for the two flags read as a closed list, and review (fix list
F1-F3, N1) found it short of the tool:

- F1. `plan` also refuses, under the same two codes, a value that is empty or
  `.`, begins with `-`, is longer than 255 characters, has a segment that ends
  in a dot, or has a character outside ASCII letters (`é`). The help now says
  each, says "ASCII", and says "whatever the case" of the collision clause as
  well: `Project.yaml`, `CONTRACTS` and `Shape/x` are refused, as `project.yaml`,
  `contracts` and `shape/` are.
- F3. "Nothing rewrites a value or picks another for you" was false for
  `--spec-path null`, which `plan` writes as `spec_path: null` and `check`
  mounts at the default (#181). The help now says what is true of a REFUSAL:
  it writes no plan, rewrites nothing, and its `Remediation:` line states the
  whole rule. The list never says "only", so it stays true when #266 adds a
  refusal for a leg path the project would read back as something other than
  text, whichever of the two lands first.
- F2. Each REFUSED row now carries the words of the help that say why, and
  asserts them, so a clause that drops out of the help fails the row that
  exercises it. The four mutants that passed 31 of 31 before ("whatever the
  case", the other-leg clause, "a backslash", "Windows" and `CON`) are killed.
- N1. The `plan_help` fixture passes `COLUMNS=80` (the width `docs/cli.md` is
  rendered at) and `NO_COLOR=1`, so the tests no longer depend on the shell
  that runs pytest. A new test holds each code whole on a line at that width,
  because `textwrap` breaks a hyphenated word at a line end and a grep of
  `docs/cli.md` for the string `plan` prints would then miss it.

`docs/cli.md` is regenerated by `scripts/render-cli-reference.py`; `--check`
passes.

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

Copy link
Copy Markdown
Contributor Author

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

Answering your 15:07Z request on #266 (#266 (comment)): none of the 27 still fails.

On the merge of 233dc71 and 3410be7 (merge tree 1e348c6, the one you named), with git-filter-repo on PATH:

  • tests/test_leg_path_yaml_scalars.py: 199 passed, 8 skipped, 0 failed. The 8 skips are this file's refusal rows, which have nothing to refuse once the four lines are quoted.
  • tests/test_template_text_placeholders.py -k "a_scaffold_reads_back or an_adoption_reads_back": 12 passed, 0 failed (111 deselected).

#395 has moved to 3894aa8 since. On the merge of 3894aa8 and 3410be7 (tree ba0d291) the same two commands give 199 passed, 8 skipped, and 12 passed, 0 failed. There the guard test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path passes for all 39 values, and the wider sets (this PR's scaffold, adopt and doctor files with #395's tests) give 535 passed, 8 skipped and 854 passed, 4 skipped.

So #266 needs no further edit for #259, and whichever of the two lands second just merges.

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3410be78f6

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1846 to +1849
misread = _misread_reason(value)
if misread:
return (f"{what} is {value!r}, which the project would not read back "
f"as text: {misread}.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Replace the stale advice to quote non-text leg paths

When adopt-project.py check reads a hand-edited plan containing bare spec_path: 2026 (similarly null, booleans, or floats), the earlier non-string branch tells the operator to quote the value. With this new check, changing it to "2026" immediately produces another plan-bad-leg-path, because the generated templates write it bare and read it back as a number. The prescribed remediation therefore cannot clear the finding; tell the operator to choose a different path, or quote the generated template fields.

Useful? React with 👍 / 👎.

brettheap added a commit that referenced this pull request Oct 12, 2026
…d no others, that they write no plan and rewrite nothing, so a leg path another change refuses under a third code (`null` as `adopt-manifest-invalid`) does not make the closing sentence false (#340)

The previous commit's closing sentence began "A refusal", and a test expected
a YAML word (`null`, `2026`, `true`, `1.5`) to be planned or refused as
`adopt-bad-leg-path`. Composing the head with main, #314, #397 and #400 showed
a third outcome: #314 makes `plan` refuse all four as `adopt-manifest-invalid`
("the manifest this run would write is one the project's own
validate-manifest.py refuses"), under a Remediation of its own. With #266 the
same four are `adopt-bad-leg-path`; without either they are planned (#181). So
the test guessed which of three futures lands, and failed on one of them.

- The closing sentence now begins "Either refusal", so it holds for the two
  codes the help names and claims nothing of a code it does not.
- The YAML test is removed. The REFUSED rows already hold the sentence to the
  tool for every value the help describes: no plan file, and a Remediation that
  begins "nothing here rewrites" or "nothing here picks".
- The docstring says why the help does not say what `plan` accepts, and that a
  value the YAML reader misreads is in none of the clauses.

`docs/cli.md` is regenerated by `scripts/render-cli-reference.py`; `--check`
passes.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…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>
brettheap added a commit that referenced this pull request Oct 12, 2026
…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>
… conflict with #394 (#225)

7a8b981 (#394, issue #189) moved a leg path's segment rule into
`repo_shape.path_spelling_fault` and added it, with `PATH_CLIMBS`,
`PATH_ITSELF` and `PATH_NOT_CANONICAL`, to the `from repo_shape import
(...)` list in scripts/shape_materialize.py, the list this branch had
added `parse_yaml` to. GitHub could no longer update the branch.

The one conflicting hunk is resolved as main's list. The branch's
`parse_yaml` is left out because main has imported it at the top of the
module since #380 (`from repo_shape import YamlError, _strip_comment,
parse_yaml`), and `_read_back` uses that binding. Naming it in both
imports was a redefinition that pyflakes reported. With this merge the
pull request changes no import line.

Everything else merged without a conflict and keeps both sides. From
main: the `EXCEPT THE SEGMENTS` comment, `LEG_SEGMENT_REASONS`, and
`_spelling_reason` asking `path_spelling_fault`. From this branch: the
#225 paragraph, `_leg_path_lines`, `_misread_reason`, and the read-back
step in `leg_path_problem` and `_canonical_spelling`. The read-back is
still asked last, after the spelling, Git's and Windows' names and the
alphabet.

7a8b981's `submodules:` record adds no line that holds a leg path. Each
row's `path:` is a submodule's path inside its leg, and `leg:` is `spec`
or `code`. `execute` appends the block in code instead of rendering it
from a template, and it double-quotes every path. The rule still finds
the four real template lines. An adoption at `--spec-path legs/v2
--code-path c0de` with a recorded submodule writes a leg path on those
four lines only, and its bootstrap passes 7a8b981's validator.

The merge also brings in ec4a7a0 (#456), 437a360 (#343) and d222a12
(#397). None of them touches a file of this pull request.

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sourcery assessment

Approved.

brettheap added a commit that referenced this pull request Oct 13, 2026
… writes its pin file as the scaffold does, so an adopted root passes `validate-pins.py` (#273); a `--pin-source` that cannot answer is `pin-source-unreadable` (#339) (#508)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #273
Closes #339

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… 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>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 191105a into main Oct 13, 2026
5 checks passed
@brettheap
brettheap deleted the fix/leg-path-yaml-scalars branch October 13, 2026 13:26
brettheap pushed a commit that referenced this pull request Oct 13, 2026
Merged by sha, 191105a, fetched at
13:30Z, on top of the merge of 631f087. It brings one commit, 191105a
(#266 for #225): `leg_path_problem`, the leg-path rule both tools ask,
refuses a leg path the project's own YAML reader would read back as
another type, asked of every template line that holds one, rendered by
`render`. Since #259 (47b1e06, merged here before) those lines are quoted,
so on the shipped templates the rule refuses no leg path, as its own
message says.

No conflict: the tree is `git merge-tree`'s (64f48ff).
scripts/shape_materialize.py changes on both sides in different places:
this branch's `NOT_TEXT_KINDS` entry for None and the not-text sentence of
`leg_path_problem`, and #266's rule, constants and `_canonical_spelling`.
#266's `_read_as` answers None before it reads `NOT_TEXT_KINDS`, so the
entry this branch adds does not change its words. #266's module-level
`LEG_PATH_PLACEHOLDERS` is shape_materialize.py's own; adopt-project.py
binds its own name of that spelling and imports neither from the other.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…le `execute` writes the manifest's list from, and its tests follow #259's quoted templates, so a value the template quotes now is planned, checked and written as given, and lane 2's #514 will silence the gate on pins with no edit here (#181)

Main's 47b1e06 (#259), merged in 5c04ca2, writes `id`, `elected_on`,
`visibility`, `tracking_branch`, both leg `path`s and the leg pins'
`submodule_path` between double quotes, where `render` spells a value with
`double_quoted` and the loader reads it back as typed. The gate asks the
line `execute` writes, so on those lines it finds nothing. That is the gate
working: what `execute` still writes bare is each pin's product, an item of
the manifest's `neutral_product_pins:` list, and the gate refuses that until
#514 quotes it. The gate stays whole. On the merge, 76 tests failed (75 of
the quoting file's 491 and one of this branch's in
tests/test_adopt_manifest_values.py); each is made true here.

Code:
- `_pin_field` asks `_template_table`, the table `execute`'s
  `_template_values` renders the manifest from: the table
  `_manifest_values` builds from the plan with that one pin, put into the
  template's line by `render` (`_written`). `_pin_item`, the one copy of
  how an item is spelled, which `_template_table` and the gate shared, is
  gone, and `_template_table`'s `NEUTRAL_PRODUCT_PINS` line is main's text
  again: #514 changes it on main with no conflict here, and the gate asks
  the quoted line with no edit.
- `plan` asks that question of the plan it is about to write, built once:
  `_refuse_unholdable_values` returns the plan it builds (`_flag_plan`),
  and `_refuse_misread_values` and `_flag_fields` take it. Main's
  `test_plan_asks_what_check_asks_of_the_same_keys` holds `plan` to one
  `_flag_plan`.
- `MANIFEST_MISREAD_REMEDIATION` says `execute` writes the values "into
  the project's own YAML files", not "into `project.yaml` and leg pins":
  a pin's product is in the manifest and the product's own pin file, not
  a leg pin.
- Sentences made true at the merge: the module docstring, the section
  comment (the templates quote those lines now; the follow-up it named is
  #259), `_execute_gets_to_write` (a visibility outside the choices is the
  validator's in either mode now), `_a_branch_git_creates` and
  `_flag_fields` (a branch git will not create is #479's
  `plan-tracking-branch-missing`, refused before any leg), `_misread_findings`,
  `_manifest_findings` and `_entry_findings` (no `id: "007"` example), the
  `_checked_plan_inputs` comment and the `#261` section comment (no
  `--id true` or `--tracking-branch 007` example), and a parenthetical of
  main's in the leg-path help's docstring (`null` is mounted at `null/`).
- scripts/shape_materialize.py: `leg_path_problem`'s comment says why the
  sentence does not say "quote it" now; #266's `_read_as` takes None's
  words from `NOT_TEXT_KINDS`, which this branch gives them, the same
  sentence with "no value at all" spelled twice, not three times (S1192).
- Nothing of the gate is removed: no piece is dead even to a direct call.

Tests (tests/test_adopt_plan_yaml_quoting.py):
- `REFUSED` holds the pins alone, `null`, `True` and `007` (#514's own
  example); the branches move to `READ_BACK`.
- `READ_BACK`, 17 rows: `--tracking-branch` `true`, `null`, `007`; the
  eight `--spec-path` values and `--code-path null`; `--id true` and
  `null`; `--project True` and `Null`; and a source whose default branch
  is `true`. `test_a_value_the_template_quotes_is_planned_and_checked_as_given`
  and `test_execute_writes_a_value_the_template_quotes_as_given` ask that
  each reads back exactly through `plan`, `check` and `execute
  --local-remote-dir`, with no finding.
- `bare_template`: the templates the gate reads, #259's lines bare again.
  The gate's branches that only a direct test reaches now are asked there:
  the derived id (`DERIVED_ID`, `FLAG_DEFAULTS`), a set id and a branch
  read as not text, git's and the source's questions, the default branch's
  name, the escapes `UNESCAPED` names, the validator asked about no
  manifest, and the values `execute` never writes.
- Changed in kind: `test_check_passes_an_id_it_derived_from_the_assembly_as_the_text_it_is`
  (was `..._to_the_validator`), `test_check_passes_a_plan_value_read_as_not_text_as_main_does`
  with `test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read`
  (was one test), `test_plan_names_a_tracking_branch_it_took_from_the_source`
  (in this process, over `bare_template`), the loader-learns-a-keyword and
  template-line tests (a pin's line is the bare one), the agreement test's
  pin case (rendered from the same table), and `manifest_judged`'s cases
  (`visibility: secret`, a leg path `../x`).
- New: `test_the_misread_rule_asks_nothing_of_a_value_execute_never_writes`,
  `test_the_gate_asks_the_table_execute_writes_the_pins_from` (a table
  quoting each item, as #514 means to, and the gate passes `null`), and
  `test_a_leg_path_written_bare_is_266s_finding_alone`.
- Deleted, each asked by another test now:
  `test_plan_refuses_a_value_the_validator_refuses_in_its_words_alone`,
  `test_check_leaves_a_value_the_validator_refuses_to_it` and
  `test_execute_refuses_a_value_the_validator_refuses_as_the_validators`
  (their values are `READ_BACK`'s `--spec-path`, `--code-path` and `--id`
  rows); `test_plan_leaves_an_id_it_derived_from_the_project_to_the_validator`
  (`READ_BACK`'s `--project` rows); and
  `test_the_misread_rule_names_a_set_id_by_what_was_read`
  (`test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read[id='007']`).
  `JUDGED_ONCE` loses `id: "007"`, `tracking_branch: "007"` and
  `legs.spec_path: "null"` (main's `TEXT_THE_TEMPLATE_NOW_HOLDS` and
  `READ_BACK`), and `NOT_TEXT_INVALID` loses `id: 007` (`READ_AS_NOT_TEXT`).

Other test files:
- tests/test_adopt_manifest_values.py: this branch's
  `test_a_value_the_reader_reads_as_another_kind_is_named_as_read` is
  main's `..._is_not_this_questions` again, less main's visibility row:
  `id: 007` is no finding since #259, as on main.
- tests/test_adopt_leg_paths.py: a docstring.
- tests/test_template_text_placeholders.py (main's): its comment said
  `plan` writes `true` and `null` bare, which it does not here.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…ype gate on the plan's keys comes first in `check` and `execute`, before the source is opened, and the walk that now follows #273's pin files reads only the pins `_read_pins` takes, so a `pins:` that is no list is named once and never walked

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

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

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

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

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

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
…s a runnable `scripts/family.py`, printed commands quote paths, and `add`, `bump` and `remove` refuse the tracking branch and print their branch's push (#488, #486, #487) (#507)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Earlier, with origin/main 48585e6 merged in:

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

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

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

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

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

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

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

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

Closes #488
Closes #486
Closes #487

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant