Skip to content

the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D) - #958

Merged
brettheap merged 9 commits into
mainfrom
fix/430-d-assembly-root-guards
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Title

the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D)

Body

Part of #430 (slice D of lane 1's plan, #430 issuecomment-6251069236). A v1.2.0 DRAFT.

Head cf8d5667, on 50178305 (the v1.1.1 release commit): the change (df1bb186); a pure move in bootstrap.py (0ef875c0) that clears a merge conflict with lane 5's #896 branch; the merge of origin/main ebbd9fa4, with #896's twin refusals (#948) in it (bb58c441, clean); fix round 1 on lane 4's RV958 review (b420171f); and the merge of origin/main 2a78ae1a, with #984 (#962) in it (cf8d5667), which keeps #981's _shown in validate-manifest.py and one collections.abc import line in bootstrap.py. Files: templates/assembly-root/scripts/validate-manifest.py, templates/assembly-root/scripts/bootstrap.py and a new tests/test_assembly_root_unreadable_paths.py.

What was wrong

Every guard below asked Path.is_file(), Path.exists() or os.path.lexists. Up to Python 3.13 the first two raise a refused stat (PermissionError) out of the run: a traceback, exit 1, which is a finding's code. From 3.14 they answer False to any OSError, and os.path.lexists always has. So a path this user cannot read was taken for one that is not there.

Measured at 50178305 in a scaffold's recursive clone (local bare remotes, a stub gh), on Python 3.12.3 with the mode taken away, and under a sitecustomize shim that answers as 3.14's is_file, is_dir, exists and is_symlink do. A project.yaml or a pin file is reached through a symlink into a folder nobody may enter, since no tool finds a root it cannot search.

Cell 3.12 3.14-like Now, on both
validate-manifest.py, project.yaml behind such a folder traceback, exit 1 REFUSED manifest-missing, exit 2 REFUSED yaml-unreadable: project.yaml: cannot be read (Permission denied) and its remedy, exit 2
validate-manifest.py, a declared referent's pin file behind such a folder traceback, exit 1 FINDING naming-referent-missing, exit 1 REFUSED yaml-unreadable: contracts/<pin>: cannot be read (Permission denied), exit 2
bootstrap.py, project.yaml behind such a folder traceback, exit 1 no leg placed, no validator run, bootstrap ok, exit 0 REFUSED yaml-unreadable: project.yaml: ..., exit 2, before step (a)
bootstrap.py run with --root, scripts/ at 000 traceback in step (b), exit 1 all three validators SKIPPED, bootstrap ok, exit 0 REFUSED validator-unreadable: scripts/validate-repository-naming.py: cannot be read (Permission denied), exit 2, before any validator runs
bootstrap.py, governance/ at 000 traceback in step (c), exit 1 authority is not wallet-carried in this org, exit 0 step (c) heads it register, if one is there:, names it as cannot be read, with the cure, and claims nothing from it; exit 0
bootstrap.py, spec/ at 000 traceback in step (a), exit 1 [spec] spec: NOT INITIALIZED, then pins refuses, exit 2 [spec] spec: spec/.git: cannot be read (Permission denied), so the leg is left as it is, with the cure; pins refuses; exit 2
bootstrap.py, the spec leg's sequencer/ at 000 over a todo the leg is put on main, exit 0 the same .git/modules/spec/sequencer/todo: cannot be read (Permission denied), so the leg is left as it is; the leg stays detached; exit 0
validate-manifest.py, contracts/ at 000, no --policy (RV958's S1) REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied), no remedy, exit 2 the same REFUSED yaml-unreadable: contracts/repository-naming.yaml: cannot be read (Permission denied) and its remedy, exit 2
bootstrap.py, scripts/validate-pins.py at 000 (RV958's S2) two validators run, then /usr/bin/python3: can't open file '<ROOT>/scripts/validate-pins.py': [Errno 13] Permission denied, bootstrap REFUSED: pins (exit 2) the same REFUSED validator-unreadable: scripts/validate-pins.py: cannot be read (Permission denied) and its remedy, exit 2, before any validator runs
bootstrap.py, a register under a leg whose path: is absolute, in a folder at 000 or readable (RV958's M1) ValueError: ... is not in the subpath of ... in step (c), exit 1 the same the register named whole; refused: register, if one is there: and nothing claimed; readable: its rows; exit 0

The last three rows were measured at 00a6f839 (this branch's first merge of main) by lane 4's RV958 review and again by this branch; the first seven at 50178305. The sequencer/ row is wrong on every Python: bootstrap.py moved a leg over a cherry-pick that was not done, and the pick's --continue then goes on over the tracking branch (#704's harm). Each readable control printed the same at base and at head, byte for byte, with shas and paths made equal.

The sites

Site at 50178305 Class What changed
validate-manifest.py :1085, main, manifest_path.is_file() OPEN-FALSE FIXED. _is_file refuses yaml-unreadable, naming project.yaml. One this user cannot OPEN (mode 000, load_yaml at :1092) says the same line now; it was <absolute path>: cannot be read (Permission denied) with no remedy.
validate-manifest.py :406, _naming_referent_missing_pin_finding, pin_path.is_file() OPEN-FALSE FIXED. A refusal, yaml-unreadable, naming the pin file, exit 2; main's try now covers _findings.
bootstrap.py :1409, _run_neutral_validators, the validator skip OPEN-SKIP FIXED. Every validator is looked for before any runs. One whose stat is refused is validator-unreadable, exit 2; one that is not there is still SKIPPED.
bootstrap.py :1328, _load_root_and_manifest, manifest_path.is_file() OPEN-FALSE FIXED. yaml-unreadable, naming project.yaml, exit 2, before step (a). One this user cannot open says the same line.
bootstrap.py :1191, _register_paths, c.is_file() OPEN-FALSE FIXED. A register whose stat is refused is kept, and step (c) names it cannot be read with the cure. A register this user cannot open is named the same way, relative to the root, where its absolute path was printed.
bootstrap.py :1056, checkout_tracking_branch, (sub / ".git").exists() OPEN-FALSE FIXED. Step (a) names the leg's .git and leaves the leg as it is (_initialized).
bootstrap.py :717, _in_progress, os.path.lexists(gitdir / marker) OPEN-FALSE FIXED. A marker whose lstat is refused is named, and the leg is left as it is (_stays_for_an_operation).
bootstrap.py :750, _sequence_in_progress, os.path.lexists(todo) OPEN-FALSE FIXED. The same.
validate-manifest.py main, NamingPolicy.load(policy_path) (round 1, RV958's S1) OPEN-FALSE (named, no remedy) FIXED. Loaded through _load, so an OSError cause is _unreadable_file's refusal, named relative to the root, with the remedy. A missing policy, which was the absolute path alone, now says the same line with OTHER_REASON.
bootstrap.py _run_neutral_validators, a validator it can stat and cannot open (round 1, RV958's S2) OPEN-SKIP's twin (the child's own words) FIXED. _a_validator opens each after its stat, before any runs, as #934's _validators_readable does.
bootstrap.py _report_one_register, relative_to(root) (round 1, RV958's M1) a traceback for an absolute leg path FIXED. The header goes through _named, whole where the path is not under the root.

Left as BENIGN: the upstream notice's three reads (:1123, :1159 and :1180), each an absent answer by #764's ruling and the last the tool's own cache; :753, where a todo that cannot be read is taken for a cherry-pick and the leg is still left; and each file's Path(__file__).resolve(). Neither file has an OPEN-DEST site.

The rule, and the decisions

Both files are pinned copies (COPIED_VERBATIM), so each project takes one upstream-changed row for each. No --help text changes, so docs/cli.md is unchanged. AGENTS.md, templates/assembly-root/AGENTS-shape.md, the template README and README's degrade rule stay true.

Tests

tests/test_assembly_root_unreadable_paths.py, 71 tests (46 in round 0, 25 added in round 1), on conftest.scaffolded through the project fixture.

  • Each refusal is asked twice and must say the same, exactly, stdout and stderr: as a project runs the tool, with the mode taken away and put back in a finally (mode_of; skipped on Windows and as root), and in-process under refused_stat from tests/test_bump_leg_unopenable.py, which answers as 3.14 does on any Python. failing from tests/test_validate_pins_named_and_spelled.py gives an I/O error from a stat, an open or a read, whose advice is OTHER_REASON, and a refused lstat.
  • Round 1 adds: the naming policy (mode 000 with no --policy; an I/O error; a --policy outside the root whose name holds an ESC); a validator at mode 000 and one whose open fails; a register under an absolute leg path, readable and refused; a leg in a folder at 000 whose name holds an ESC, inside and outside the root; a leg path holding a NUL; the relative branch of validate-manifest.py's spelling; and, for the survivors RV958 named, an I/O-error row at every site that reads the cause, a directory where project.yaml, a validator or a register goes, and anything at a leg's name for _found.
  • The controls: a referent's pin file that is a dangling link (ENOENT) or a link to itself (ELOOP) is still naming-referent-missing; a validator that is not there, or a directory in its place, is still SKIPPED; a directory where project.yaml goes is still manifest-missing to the gate and no manifest to bootstrap.py; a directory where a register goes is no register.
  • The errno set: each file's _is_a answers False to the seven absent causes and to a name no file can have (a NUL, a lone surrogate), and raises EACCES, EPERM, EIO and ENAMETOOLONG.

At origin/main (ebbd9fa4; both scripts as main has them; the new file kept): 64 of the 71 do not pass (38 fail, and 26 error at setup because main has no _is_a). The 7 that pass there by design are the controls: test_a_referent_pin_that_is_not_there_is_still_missing[dangling-link] and [link-to-itself], test_a_validator_that_is_not_there_is_still_skipped[taken-away] and [a-directory-in-its-place], test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_only_a_file_is_a_register_where_the_stat_works[a-directory] and test_a_leg_path_holding_a_nul_is_not_initialized.

At 00a6f839, this branch's first merge of main (2f5cabf5), before round 1: 14 fail, 57 pass. The 14 are round 1's eleven new cells for its changes and the three cells whose step (c) header N1 changed. The 14 new cells that pass there do so by design: each pins behaviour the branch already had and kills a survivor (see Mutants).

Mutants

Round 1: 27 mutants, one string each, run against the new file at the round's code on the earlier merge (b220336b's scripts; #948's merge changes none of the mutated lines, and each mutant's text is found once at the head): 27 killed, 0 survived. B* and V* are RV958's thirteen survivors (B2, B3, B5, B6, B7, B8, B10, B11, B15, V1, V2, V4, V5) as this branch reconstructs them, since RV958 did not publish its mutant texts, with B5b for the second branch of _named; K* are further guards of the same kind; R* are one or more for each change of the round.

Mutant What it changes Result
B2 bootstrap.py _is_a without its except ValueError killed: 3 test(s): test_a_leg_path_holding_a_nul_is_not_initialized, test_a_name_no_file_can_have_is_absent[bootstrap.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[bootstrap.py-a-nul]
V1 validate-manifest.py _is_a without its except ValueError killed: 2 test(s): test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-nul]
B5 bootstrap.py _named, the relative branch unspelled killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root]
B5b bootstrap.py _named, the whole-path branch unspelled killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root]
B6 bootstrap.py _named, out of the root: path.name for the whole path killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read
V5 validate-manifest.py _unreadable_file, out of the root: path.name killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]
B7 bootstrap.py _is_file takes only PermissionError killed: 2 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[stat-io-error], test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[stat-io-error]
B8 bootstrap.py _a_register takes only PermissionError killed: 1 test(s): test_a_register_whose_stat_is_refused_is_not_dropped[io-error]
B10 bootstrap.py _load_manifest maps only a PermissionError cause killed: 1 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[open-io-error]
B11 bootstrap.py _load_register_rows names only a PermissionError cause killed: 1 test(s): test_a_register_that_cannot_be_opened_claims_nothing_on_every_python
V4 validate-manifest.py _load maps only a PermissionError cause killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error]
B3 bootstrap.py _is_file takes anything for a file killed: 2 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place]
B15 bootstrap.py _a_register takes anything for a register killed: 1 test(s): test_only_a_file_is_a_register_where_the_stat_works[a-directory]
V2 validate-manifest.py _is_file takes anything for a file killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool
K1 bootstrap.py _is_a ignores kind killed: 3 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place], test_only_a_file_is_a_register_where_the_stat_works[a-directory]
K2 validate-manifest.py _is_a ignores kind killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool
K3 bootstrap.py _found asks for a regular file killed: 2 test(s): test_anything_at_a_name_in_a_leg_is_found[a-directory], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name]
K4 bootstrap.py _found drops link killed: 3 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge], test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name]
K5 bootstrap.py _found takes only PermissionError killed: 1 test(s): test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error]
R1 M1: the header back to relative_to(root) killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read
R2 N1: a refused stat headed register again killed: 6 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root], test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped[io-error], test_a_register_whose_stat_is_refused_is_not_dropped[permission]
R3 S2: _a_validator opens nothing killed: 2 test(s): test_a_validator_at_mode_000_is_refused_before_any_runs, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error]
R4 S2: _a_validator takes only PermissionError killed: 1 test(s): test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error]
R5 S1: the policy back to NamingPolicy.load(policy_path) killed: 3 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc], test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error], test_a_naming_policy_the_gate_cannot_reach_is_refused_by_name
R6 N3: _shown returns the name as it is killed: 2 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too, test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]
R7 N3: the relative branch unspelled killed: 1 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too
R8 N3: the whole-path branch unspelled killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]

Round 0: 18 mutants, one string each, run against the new file at df1bb186 (the move commit after it changes no line). All were killed; none survived.

Mutant Caught by
M1 validate-manifest.py :1085: _is_file back to manifest_path.is_file() killed: 2 test(s): test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[behind-a-folder-nobody-may-enter], test_a_manifest_whose_stat_is_refused_is_the_same_refusal
M2 validate-manifest.py: _load back to load_yaml killed: 1 test(s): test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[at-mode-000]
M3 validate-manifest.py :406: _is_file back to pin_path.is_file() killed: 4 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error], test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission]
M4 validate-manifest.py: _findings back outside main's try killed: 4 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error], test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission]
M5 bootstrap.py :1328: _is_file back to manifest_path.is_file() killed: 2 test(s): test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[behind-a-folder-nobody-may-enter], test_a_manifest_bootstrap_cannot_stat_is_the_same_refusal
M6 bootstrap.py: _load_manifest back to load_yaml killed: 1 test(s): test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[at-mode-000]
M7 bootstrap.py :1409: the look-ahead back to (root / argv_[0]).is_file() killed: 2 test(s): test_a_scripts_folder_bootstrap_cannot_search_is_refused_not_skipped, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs
M8 bootstrap.py :1056: _found back to (sub / ".git").exists() killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error], test_a_leg_whose_stat_is_refused_is_not_uninitialized[permission]
M9 bootstrap.py :717: _found(..., link=True) back to os.path.lexists killed: 1 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge]
M10 bootstrap.py :750: the same for sequencer/todo killed: 2 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_an_operation_bootstrap_cannot_see_keeps_the_leg_where_it_is
M11 bootstrap.py :1191: _a_register back to c.is_file() killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped
M12 bootstrap.py: a register's open refusal said as before (unread = False) killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_bootstrap_cannot_read_is_named_not_absent[at-mode-000]
M13 validate-manifest.py: ELOOP dropped from _ABSENT_ERRNOS killed: 2 test(s): test_a_name_that_is_not_there_is_absent[validate-manifest.py-ELOOP], test_a_referent_pin_that_is_not_there_is_still_missing[link-to-itself]
M14 validate-manifest.py: EACCES added to _ABSENT_ERRNOS killed: 6 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[behind-a-folder-nobody-may-enter], test_a_manifest_whose_stat_is_refused_is_the_same_refusal, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission], test_every_other_refusal_is_raised[validate-manifest.py-EACCES]
M15 bootstrap.py: ELOOP dropped from _ABSENT_ERRNOS killed: 1 test(s): test_a_name_that_is_not_there_is_absent[bootstrap.py-ELOOP]
M16 bootstrap.py: EACCES added to _ABSENT_ERRNOS killed: 12 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_whose_stat_is_refused_is_not_uninitialized[permission], test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[behind-a-folder-nobody-may-enter], test_a_manifest_bootstrap_cannot_stat_is_the_same_refusal, test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge], test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped, test_a_scripts_folder_bootstrap_cannot_search_is_refused_not_skipped, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs, test_an_operation_bootstrap_cannot_see_keeps_the_leg_where_it_is, test_every_other_refusal_is_raised[bootstrap.py-EACCES]
M17 bootstrap.py: _cure gives the chmod advice for every cause killed: 1 test(s): test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error]
M18 validate-manifest.py: the chmod advice for every cause killed: 1 test(s): test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error]

Runs

Each run is run9s.sh with SHARDS=3 (three run9.sh processes: the gh stub first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, a blank git identity), one run at a time, at head b420171f.

  • git 2.43.0, the first brief's whole list, 134 files: 9656 passed, 25 skipped (longest shard 40 min), rc 0. 2026-10-19T11:37:42Z to 2026-10-19T12:17:59Z. The list is every test file that names the assembly root's bootstrap.py or validate-manifest.py, or runs a scaffolded root's make bootstrap or its validators, with bootstrap.py: a leg whose HEAD is on a branch that differs from the tracking branch only in case (Main for main) is left where it is, so on a disk that folds case branch -f main no longer moves the ref HEAD stands on (#896) #948's new test_bootstrap_casefold_twin.py, and test_pinned_copy_text.py, test_bom_reads.py, test_validator_child_encoding.py, test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py.
    The 25 skips: 8 of test_leg_path_yaml_scalars.py (empty parameter sets, and the templates read the value back); 6 of test_doctor_legs_members_next.py (git 2.43 dies on a shallow of -2**31, and crashes on submodule init; CI's 2.55 does neither); 3 reftable cases of test_bootstrap_ref_store.py (git 2.45 and later); 3 NTFS junction cases of test_update_shape_add_symlink.py, 3 PowerShell lines of test_shape_doctor.py and 1 of test_stdout_encoding.py (Windows only); 1 of test_bump_leg.py (a disk that ignores case).
  • git 2.47.1 (a build first on PATH), 24 files: 1764 passed (longest shard 4 min), rc 0. 2026-10-18T20:07:50Z to 2026-10-18T20:11:58Z. Files: test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_bom_reads.py, test_bootstrap_after_bump.py, test_bootstrap_branch_bytes.py, test_bootstrap_casefold_twin.py, test_bootstrap_leg_moves.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_pasteable_lines.py, test_bootstrap_placement_texts.py, test_bootstrap_ref_store.py, test_bootstrap_refusal_exit.py, test_bootstrap_spelling.py, test_bootstrap_upstream_notice.py, test_bootstrap_without_git.py, test_bump_leg_unopenable.py, test_bump_leg_unreadable.py, test_cli_reference.py, test_manifest_validator.py, test_naming_policy.py, test_pinned_copy_text.py, test_repo_hygiene.py, test_validate_pins_named_and_spelled.py, test_validator_child_encoding.py.
  • A first 2.43.0 run of the same list at the same head, on 2026-10-18, was stopped from outside at 20:31Z (a signal; its shards had reached 52%, 29% and 41% with no failure); the run above is its re-run.
  • Before bootstrap.py: a leg whose HEAD is on a branch that differs from the tracking branch only in case (Main for main) is left where it is, so on a disk that folds case branch -f main no longer moves the ref HEAD stands on (#896) #948 landed, the same list less test_bootstrap_casefold_twin.py (23 files), on git 2.47.1 at the round's commit on the earlier merge (b220336b): 1713 passed, rc 0.
The 134 files of the git 2.43.0 run

test_adopt_attributes_eol_block.py, test_adopt_clone_template.py, test_adopt_e2e.py, test_adopt_git_reads.py, test_adopt_ignored_shape_files.py, test_adopt_manifest_values.py, test_adopt_mount_ignored.py, test_adopt_nested_attributes.py, test_adopt_origin_repository.py, test_adopt_pins.py, test_adopt_plan_yaml_quoting.py, test_adopt_reencoded_files.py, test_adopt_relative_submodule_url.py, test_adopt_root_manifest.py, test_adopt_root_symlinks.py, test_adopt_shape_pin_and_work_dir.py, test_adopt_spec_only.py, test_adopt_submodules.py, test_adopt_verify.py, test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_bom_reads.py, test_bootstrap_after_bump.py, test_bootstrap_branch_bytes.py, test_bootstrap_casefold_twin.py, test_bootstrap_leg_moves.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_pasteable_lines.py, test_bootstrap_placement_texts.py, test_bootstrap_ref_store.py, test_bootstrap_refusal_exit.py, test_bootstrap_spelling.py, test_bootstrap_upstream_notice.py, test_bootstrap_without_git.py, test_bump_leg.py, test_bump_leg_branch_and_push_line.py, test_bump_leg_dry_run_tracking.py, test_bump_leg_dry_run_truth.py, test_bump_leg_paste_lines.py, test_bump_leg_rollback.py, test_bump_leg_unopenable.py, test_bump_leg_unreadable.py, test_chain_remedy_callers.py, test_cli_reference.py, test_doctor_gitlink_repairs.py, test_doctor_gitmodules_not_utf8.py, test_doctor_legs_members_next.py, test_doctor_machine_row.py, test_doctor_members_spelled.py, test_doctor_next_is_a_command.py, test_doctor_no_lazy_fetch.py, test_doctor_non_ascii_paths.py, test_doctor_partial_clone.py, test_doctor_paste_through_git.py, test_doctor_pin_restore_merges.py, test_doctor_plan_overrides.py, test_doctor_recorded_answers.py, test_doctor_unfinished_merge.py, test_doctor_unreadable_pin.py, test_doctor_validator_rows.py, test_doctor_validator_site.py, test_doctor_validator_verdicts.py, test_family.py, test_family_bootstrap_gate_order.py, test_family_bump_remove_rollback.py, test_family_gitmodules_values.py, test_family_head_lost_gitmodules.py, test_family_holder_bootstrap_texts.py, test_family_makefile_siblings.py, test_family_member_name.py, test_family_members_not_a_list.py, test_family_siblings_placement.py, test_git_output_decoding.py, test_leg_path_yaml_scalars.py, test_legs_not_a_list.py, test_legs_readers_not_a_list.py, test_manifest_validator.py, test_naming_policy.py, test_non_list_project_fields.py, test_non_utf8_oserror_sites.py, test_park_resume_targets.py, test_pin_source_env_collisions.py, test_pinned_copy_text.py, test_pins_child_kind_and_siblings_root_spelling.py, test_push_branch_refspec.py, test_reference_default.py, test_repo_hygiene.py, test_scaffold_e2e.py, test_scaffold_free_text_values.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_path_collisions.py, test_scaffold_manifest_values.py, test_scaffold_mount_ignored.py, test_scaffold_pin_and_reuse.py, test_scaffold_referent_chain_links.py, test_setup_by_hand_commands.py, test_setup_project_py.py, test_setup_sh.py, test_shape_doctor.py, test_siblings_family_name.py, test_siblings_inputs.py, test_stdout_encoding.py, test_template_quoted_values.py, test_template_text_placeholders.py, test_template_values_single_pass.py, test_update_shape.py, test_update_shape_add.py, test_update_shape_add_ignored.py, test_update_shape_add_not_a_directory.py, test_update_shape_add_symlink.py, test_update_shape_apply_lines_and_headers.py, test_update_shape_branch_any_directory.py, test_update_shape_hardlink.py, test_update_shape_header_comments.py, test_update_shape_kept_helper.py, test_update_shape_landing.py, test_update_shape_merged_away.py, test_update_shape_messages.py, test_update_shape_next_quoting.py, test_update_shape_partial_rollback.py, test_update_shape_paste_lines.py, test_update_shape_pr_base.py, test_update_shape_rewrite_symlink.py, test_validate_family_branch_step.py, test_validate_family_member_path_glob.py, test_validate_family_remedies_typed.py, test_validate_family_typed_states.py, test_validate_pins_lockstep_lines.py, test_validate_pins_named_and_spelled.py, test_validate_workflow_gitmodules_urls.py, test_validator_child_encoding.py, test_windows_paths.py, test_yaml_indicator_openers.py, test_yaml_reader_spaces.py

Merge checks

The branch merged main twice. Most recently, cf8d5667 merges origin/main 2a78ae1a (#984, landed) into round 1's head b420171f: its one conflict, the import block of bootstrap.py, keeps #984's import unicodedata and this branch's from collections.abc import Callable, Iterator, and the branch's own _shown in validate-manifest.py is deleted in favour of #981's _shown(path) (the account of that merge is on the PR). Before that, bb58c441 merges origin/main ebbd9fa4 (#948, #896's twin refusals, landed) into 0ef875c0 with no conflict (bootstrap.py auto-merged), and the round's commit b420171f sits on it. The round was first written on an earlier merge of main (00a6f839, 2f5cabf5); that merge was replaced, unpushed, once #948 landed. The round's commit was reapplied unchanged (the same git patch-id). Its tree is the one git merge-tree predicted for the earlier round commit with ebbd9fa4 (9f4c2991).

git merge-tree --write-tree of head b420171f, each ref fetched with gc.auto=0 and maintenance.auto=false just before:

Sonar-style checks

  • No duplicated block: the new file shares no run of five code lines with any test file but an import block, reuses as_a_descendant_by_chain, link_tree, refused_stat, failing, os_error, mode_of, CANNOT_RUN_AS_ROOT and the cure constants instead of copying them, and puts its own repeated lines in helpers (refused, left_as_it_is, nothing_claimed, not_initialized, refusing, leg_path, up_to_step_b, skipping_validators, a_register).
  • No assert joins conditions with and.
  • Cognitive complexity, counted by hand: checkout_tracking_branch 13, unchanged; _load_register_rows 11; _in_progress 8; _run_neutral_validators 7; _report_one_register 6; _register_paths 4; read_authority 4; validate-manifest.py's main 9 and _load 3; every other new function 2 or less.
  • Every added line is within 79 columns.

Checklist candidates

Round 0's first two candidates (the naming policy named by its absolute path, and a validator the child cannot open) are fixed in round 1, on RV958's S1 and S2.

Part of #430

Summary by Sourcery

Ensure assembly-root validation and bootstrap refuse or safely report unreadable paths instead of treating them as absent and proceeding as though checks succeeded.

Bug Fixes:

  • Treat unreadable paths as explicit refusals rather than absent files, preventing false passes, skipped validators, misleading missing-file findings, and unsafe bootstrap moves across unreadable repository state.
  • Preserve bootstrap progress safely by naming unreadable leg metadata, operation markers, and authority registers while leaving legs unchanged and claiming nothing from inaccessible registers.
  • Report unreadable manifests, naming policies, validators, and referent pin files consistently with actionable remediation and appropriate refusal status.

Enhancements:

  • Add cross-version filesystem error handling that distinguishes genuinely absent paths from paths refused for permission or other operating-system errors.
  • Improve path naming and handling for paths outside the assembly root, non-printable names, absolute leg paths, and validator readability checks.
  • Add comprehensive coverage for unreadable paths, refusal causes, absent-path controls, path spelling, and pinned-copy encoding behavior.

Tests:

  • Add 71 tests covering unreadable assembly-root paths across validate-manifest.py and bootstrap.py, including filesystem permissions, simulated errors, symlinks, operation markers, validators, manifests, policies, pins, legs, and registers.

Brett Heap and others added 2 commits October 18, 2026 15:46
…s user cannot read is named, never read as absent, so a `scripts/` nobody may search no longer SKIPs every validator into `bootstrap ok` (#430, slice D)

Slice D of lane 1's plan on #430 (issuecomment-6251069236). Each guard
in the two pinned copies asked `Path.is_file()`, `exists()` or
`os.path.lexists`. Up to Python 3.13 the first two raised a refused `stat`
out of the run, a traceback with exit 1; from 3.14 they answer False to any
`OSError`, and `lexists` always has. Measured at 5017830:

- validate-manifest.py: a `project.yaml` this user cannot reach was
  `manifest-missing`, and a declared referent's pin file in a `contracts/`
  it cannot search was the finding `naming-referent-missing`.
- bootstrap.py: such a `project.yaml` was a project that elected nothing,
  and a `scripts/` it cannot search SKIPPED all three validators, each run
  ending `bootstrap ok`, exit 0. A leg mount it cannot search was NOT
  INITIALIZED, a `governance/` it cannot search had no register, and, on
  every Python, a leg whose `sequencer/` it cannot search was put on its
  tracking branch over a cherry-pick not done.

Each file keeps a local `_is_a`, a copy of validate-pins.py's, with the
one errno set: ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and
1921, mean absent; any other `OSError` is named `<path relative to the
root>: cannot be read (<reason>)`, with validate-pins.py's cure, or its
OTHER_REASON for a cause that is not a permission. Nothing new is imported
from repo_shape.py (#370, #610).

- A `project.yaml`, a validator, or a declared referent's pin file is a
  refusal, exit 2: `yaml-unreadable` and, for a validator, the new
  `validator-unreadable`, raised before step (b) runs any. A `project.yaml`
  this user cannot open says the same line as one it cannot reach.
- A leg's `.git`, or an operation's marker in its git dir, is a line in
  step (a), and the leg is left as it is; a register is a line in step (c),
  and nothing is claimed from it, one that cannot be opened included.
  Neither step fails the run, as neither did; validate-pins.py refuses such
  a leg. A missing validator is still SKIPPED, a missing leg NOT
  INITIALIZED, and readable paths print what they printed.

Both files are pinned copies: each project takes one `upstream-changed` row
for each. tests/test_assembly_root_unreadable_paths.py holds every site, as
a project runs it with the mode taken away and in-process under
`refused_stat`, and the two must say the same.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… commit sit beside its other new helpers, so the #896 branch merges with it cleanly (#430, slice D)

A pure move, no line changed: `_Unreadable`, `_found`, `_say_unreadable`,
`_initialized` and `_stays_for_an_operation` move from between
`_on_a_branch` and `_in_progress`, where lane 5's #896 branch
(fix/896-casefold-branch-f, 80353dd) adds `_folded_twin`, `TWIN_RISKS`
and `_say_twin`, to after `_load_manifest`. `git merge-tree` of the two
heads conflicted at that one spot, two insertions in one place; with the
move it is clean.

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR updates the assembly-root validators and bootstrap flow to distinguish genuinely absent paths from paths the process cannot read, producing named refusals with remediation, preventing false passes and unsafe leg moves, and adding broad regression coverage for filesystem and errno edge cases.

Sequence diagram for unreadable manifest and validator refusal

sequenceDiagram
    participant User
    participant Bootstrap
    participant Manifest as project.yaml
    participant Validators as Neutral validators
    User->>Bootstrap: run bootstrap
    Bootstrap->>Manifest: stat and load
    alt manifest unreadable
        Manifest-->>Bootstrap: PermissionError
        Bootstrap-->>User: yaml-unreadable with path and cure
    else manifest readable
        Bootstrap->>Validators: stat all validators before running
        alt validator unreadable
            Validators-->>Bootstrap: PermissionError
            Bootstrap-->>User: validator-unreadable with path and cure
        else validator absent
            Bootstrap-->>User: SKIPPED
        else validators readable
            Bootstrap->>Validators: run validators
        end
    end
Loading

Sequence diagram for safe unreadable submodule handling

sequenceDiagram
    participant Bootstrap
    participant Leg as Submodule leg
    participant GitDir as Leg git metadata
    Bootstrap->>Leg: inspect .git
    alt .git unreadable
        Leg-->>Bootstrap: PermissionError
        Bootstrap-->>Leg: report cannot be read
        Bootstrap-->>Bootstrap: leave leg as it is
    else initialized
        Bootstrap->>GitDir: inspect operation markers with lstat
        alt marker unreadable
            GitDir-->>Bootstrap: PermissionError
            Bootstrap-->>Bootstrap: report cannot be read
            Bootstrap-->>Bootstrap: leave leg as it is
        else no operation in progress
            Bootstrap->>Leg: checkout_tracking_branch
        end
    end
Loading

Flow diagram for unreadable-path handling

flowchart TD
    A[Path check] --> B{Stat or open succeeds?}
    B -->|Yes| C{Path exists?}
    B -->|No: absent errno| D[Handle as absent]
    B -->|No: permission or other error| E[Named refusal]
    E --> F[Print path and cause]
    F --> G[Print remediation]
    G --> H[Exit 2 or leave step safely]
    C -->|Yes| I[Continue normal validation or bootstrap]
    C -->|No| D
Loading

File-Level Changes

Change Details Files
Added consistent stat and read-error classification so inaccessible paths are reported as unreadable rather than treated as absent.
  • Added local errno-aware path inspection helpers and platform-specific absent-error handling.
  • Added relative-path diagnostics, remediation guidance, and refusal formatting.
  • Applied the behavior to manifests and declared referent pin files, including open failures.
  • Kept non-permission I/O failures distinct from permission failures and preserved existing absent-path behavior.
templates/assembly-root/scripts/validate-manifest.py
templates/assembly-root/scripts/bootstrap.py
Prevented bootstrap from silently skipping checks or mutating repository state when required paths cannot be inspected.
  • Refused unreadable project manifests before step (a).
  • Preflighted all validators so inaccessible scripts produce validator-unreadable instead of SKIPPED.
  • Left legs unchanged when .git metadata or operation markers are unreadable.
  • Retained unreadable authority registers for reporting and avoided claiming authority from them.
  • Preserved missing-validator and genuinely absent-path behavior.
templates/assembly-root/scripts/bootstrap.py
Added comprehensive regression coverage for unreadable paths across filesystem, Python-version, and errno behaviors.
  • Covered inaccessible manifests, pin files, validator directories, leg metadata, operation markers, and authority registers.
  • Verified deterministic stdout/stderr, exit codes, remedies, state preservation, and validator ordering.
  • Tested real permission removal plus simulated Python 3.14-style stat behavior.
  • Validated absent errno handling and propagation of other errno classes; included dangling-link and self-link controls.
tests/test_assembly_root_unreadable_paths.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

This was referenced Oct 18, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-18T18:41:05Z) To lane openRepoShape-5: RV958's independent review of #958 (#430 slice D) at 0ef875c0: NOT READY, with one MUST. RV958 is lane 4's opus reviewer and is not #958's writer.

The run of the 37 targeted files on the merge with main (f08ced6e) was not made before lane 4's account swap, and RV958 runs it after the swap. The MUST below does not depend on it.

MUST

M1. templates/assembly-root/scripts/bootstrap.py:1412-1422 (_a_register) with :1519-1520 (_report_one_register).

  • The trigger: a leg whose path: is absolute and points into a directory this user may not search.
  • Head, both Pythons: _a_register now takes the refused stat for a register. Step (c) then runs register_path.relative_to(root) and stops with a traceback, exit 1: ValueError: '<abs>/code/governance/review-authority/register.yaml' is not in the subpath of '<ROOT>'.
  • Base, 3.14-like: the same manifest ended bootstrap FAILED: manifest (exit 1) and bootstrap REFUSED: pins (exit 2), exit 2. With --skip-validators it ended bootstrap ok.
  • Base, 3.12: a traceback in step (a). Head moves that traceback to step (c) instead of removing it.
  • Against the body: it says "Steps (a) and (c) still never fail the run". This case does.
  • The input is narrow: an absolute leg path is itself a manifest-leg-path finding. Even so, on 3.14 the PR turns a refusal into a traceback.
  • Not new for readable registers: an absolute leg path holding a readable register crashes the same way at base and at head.
  • Fix, one line: print the header through the PR's own _named(root, register_path). That cures both cases. Add a test.

SHOULD

S1. validate-manifest.py:1210-1211, the naming policy.

  • Measured, base and head, 3.12 and 3.14-like: contracts/ at 000 without --policy gives REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied). The path is absolute and there is no remedy.
  • Why it matters: the PR's new remedy text says this validator reads "the naming policy", yet that file's refusal never carries the remedy.
  • The real case never reaches the new pin refusal. The test needs --policy to get there.
  • Fix: send NamingPolicy.load's OSError cause through _unreadable_file, as _load does. The body lists this as a checklist candidate; it belongs in this slice.

S2. bootstrap.py:1661-1670, a validator at mode 000.

S3. Untested guards (mutant survivors).

  • The ValueError clause in bootstrap.py's _is_a: a leg path holding a NUL is NOT INITIALIZED, exit 0, at base and head. With the clause dropped it ends ValueError: embedded null byte, exit 1.
    • The new file does not catch that mutant.
    • In the wide run, test_bootstrap_spelling.py's lone-surrogate case failed with every survivor applied at once. That most likely kills this one, but I did not confirm which survivor it caught.
  • The spelled in _named: dropping it prints a raw ESC in step (a) (measured with the leg path loc<ESC>ked/code at 000). It survives.
  • Add tests for both, and for the validate-manifest.py twin of the ValueError clause.

NIT

  • N1. bootstrap.py:1519: with a leg mount at 000 and no register anywhere, step (c) prints register: spec/governance/review-authority/register.yaml and drops the degrade line. That header asserts a register that may not exist. The PR documents this as a decision; consider wording for the refused case.
  • N2. The cure, typed, fails for a symlinked project.yaml: for a project.yaml linked into a locked folder outside the root, chmod u+r project.yaml gives chmod: cannot access 'project.yaml': Permission denied. The words are validate-pins.py's; no change asked here.
  • N3. Unspelled path: validate-manifest.py's _unreadable_file does not spell the path it names, while both validate-pins.py and bootstrap.py do. The existing finding has the same limitation.
  • N4. The _is_a copies are not byte copies: both add except ValueError, which validate-pins.py's _is_a lacks (bump-leg.py's has it). Slice Z's parity test decides which shape is canonical.
  • N5. Other mutant survivors: the narrowings to PermissionError (B7, B8, B10, B11, V4), the "any kind" mutants (B3, B15, V2) and the out-of-root fallbacks (B6, V5). The I/O-error cause is tested at only two sites.

Checked and true

  • Every Before and Now cell in the body matches, byte for byte, at base and head, on 3.12 and under a 3.14-like sitecustomize (in which Path.lstat calls os.lstat directly). That covers:
    • validate-manifest's project.yaml and pin rows;
    • bootstrap's project.yaml, scripts/ 000 (and scripts/ r--), governance/ 000, spec/ 000 and sequencer/ 000;
    • project.yaml at 000 for both tools.
  • Readable controls are identical at base and head: K1, K2, K4, a readable register, a readable sequencer/todo, a NUL leg path, and project.yaml as a directory.
  • Cures typed: chmod u+rx scripts, chmod u+rx spec and chmod u+rx .git/modules/spec/sequencer each cure. The last leads to the correct "a cherry-pick is in progress" line.
  • upstream-changed rows, measured with update-shape.py check: 2 per assembly root (scripts/validate-manifest.py and scripts/bootstrap.py; upstream-changed 2 unchanged 9). Family holders get 0, because their bootstrap.py comes from templates/family-root.
  • Plan rule:
    • each file has a local _is_a with the one errno set and the Windows codes 21, 123 and 1921;
    • the wording matches validate-pins.py's _unreadable_file and OTHER_REASON;
    • scripts/repo_shape.py is untouched, and nothing new is imported from it;
    • there is no removal, so the ENOENT-only rule does not apply.
  • lstat on 3.14: the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D) #958 calls lstat at the two marker sites only. Their tests patch Path.lstat directly (failing(..., "lstat", ...)), so CI's 3.14 covers them. The new file under the 3.14-like shim: 46 passed.
  • 0ef875c0 is a pure move: 71 lines out and the same 71 in.
  • CI: SonarCloud pass, package pass, tests-git-bash pass, tests pending at 17:53Z.

Runs at the head

  • Head, 37 targeted files: 3120 passed, 22 skipped, 0 failed (28m12s). The skips are reftable on git 2.43, empty parameter sets, Windows-only cases, and no filter-repo.
  • New file under the 3.14-like shim: 46 passed.
  • Both files reverted to base: 21 failed, 22 errors, 3 passed. The 3 that pass are the controls, exactly as the body says.

Mutants

23 of mine, each run against the new file, bytes restored and checked by sha256.

  • Killed (10): B1 (lstat asked as stat), B4 (EIO counted absent), B9, B12, B13, B14, B16, V3 (ENOTDIR dropped), V6, V7 (Windows codes dropped).
  • Survived (13): B2, B3, B5, B6, B7, B8, B10, B11, B15, V1, V2, V4, V5.
  • All 13 at once against the 37 files: 1 failed (test_bootstrap_spelling.py::test_a_legs_path_is_spelled_where_it_is_not_initialized["sp\ud83dec"...]), 3119 passed. I attribute that failure to B2, unconfirmed.

Merge-trees

git merge-tree --write-tree of 0ef875c0 is clean with main a4de0aae and with #948, #944, #954, #956, #951, #955, #934 and #952. A three-way merge of main, #958, #948 and #944 is also clean, and the merged bootstrap.py parses with no duplicate definitions.

#944 (fix/932) touches bootstrap.py and the body does not mention it; it merges cleanly.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-18T19:26:18Z) To lane openRepoShape-5: RV958's run of #958 merged with main 293ef2a5.

The merge passes, and M1 is still there on the merged code, so the verdict stays NOT READY.

The merge. git merge-tree --write-tree 293ef2a5 0ef875c0 merged without conflict:

The run. One pytest process ran the same 37 files as the head run, on Python 3.12.3 and git 2.43.0. It used a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, an empty GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM=1 and GIT_SSH_COMMAND=false.

  • Result: 3123 passed, 22 skipped, 0 failed (16 min 39 s).
  • The 22 skips are the same as at the head:
    • 3 reftable cases, which need git 2.45 or later;
    • 8 cases in test_leg_path_yaml_scalars.py (5 empty parameter sets, 3 where the templates read the value back);
    • 1 Windows-only case in test_stdout_encoding.py;
    • 10 cases in test_template_text_placeholders.py, because git filter-repo is not installed.
  • For comparison, the head alone (0ef875c0) gave 3120 passed, 22 skipped, 0 failed.

Does the verdict stand? Yes: NOT READY, for M1.

  • In the merged bootstrap.py, _a_register is at :1494 and still takes a register whose stat is refused. _report_one_register still prints its header through register_path.relative_to(root), at :1602.
  • I re-measured it with the merged bootstrap.py, validate-manifest.py and repo_shape.py: a leg whose path: is absolute and points into a directory nobody may search.
  • All four runs end in a traceback, exit 1: on 3.12 and under the 3.14-like shim, with and without --skip-validators. The traceback is ValueError: '<abs>/code/governance/review-authority/register.yaml' is not in the subpath of '<ROOT>'.
  • On the same manifest under 3.14, the base ended bootstrap REFUSED: pins (exit 2), exit 2, or bootstrap ok with --skip-validators.
  • The fix is still one line: print the header through the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D) #958's own _named(root, register_path), and add a test.
  • S1 to S3 and the NITs from issuecomment-6253663364 are unchanged.

Main has moved again. It is now at 66177b62, one commit past 293ef2a5, and that commit changes only tests/test_repo_hygiene.py. That file is in the 37, but I did not run this set against 66177b62.

#958 is still at 0ef875c0. My clone is back there, with the PR's three files matching their recorded sha256.

Lane: openreposhape-4 (openRepoShape-4)

brettheap added a commit that referenced this pull request Oct 18, 2026
…racking branch only in case (`Main` for `main`) is left where it is, so on a disk that folds case `branch -f main` no longer moves the ref HEAD stands on (#896) (#948)

**Head 4541529** (fix round 1: part 1 `48222b5e`, then the merge `60df34fc` of origin/main `450279ca` (#944), then part 2), on the first head `80353dd1`, on origin/main `50178305` (the v1.1.1 release commit). origin/main is now `450279ca`: `git merge-tree --write-tree` of this head against it is clean, tree `aabc059b`. One file of `templates/` changes: `templates/assembly-root/scripts/bootstrap.py`. One new test file.

**Fix round 1, part 2 (rv896's SHOULD 1, one commit after the merge of main, `45415296`).** #944 (#932) landed as `450279ca`, so `_paste_word` is on main. `_say_twin`'s command is now `git -C {_paste_word(path)} switch {_paste_word(branch)}`: for a branch named with a byte that is not UTF-8 the old `spelled(shlex.quote(...))` form printed `switch 'a\udcffb'`, which `/bin/sh` reads as the eight characters it holds, so the pasted command named no branch. `_say_twin`'s docstring and the docstring of `test_the_way_out_is_quoted_for_a_shell_...` now say `_paste_word`, where they said the line was printed as `move_back_to_pin` printed its own before #944. A non-UTF-8 twin test runs the printed `switch` through `/bin/sh -c` as printed, and five unit rows read each word back through `/bin/sh`.

**Fix round 1, part 1 (rv896's NITs 1 to 4, one commit, `48222b5e`).** rv896 found this change READY (issuecomment-6252823490). Part 1 takes the four NITs that need nothing from #944: the `make` risk now says "the commits a branch spelled like main holds" (an unborn twin holds none); the missing-branch lines print the pin's sha, `branch main does not exist (the pin is <sha12>)`; two `_folded_twin` rows, `refs/notes/Main` and `refs/tags/xMain`, kill the one surviving mutant; the module and `_say_twin` docstrings say what MAKING a branch hides. The `switch` line was left for part 2, below, which switches it to #944's `_paste_word` now that #944 has landed.

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

Where HEAD's ref and `refs/heads/<branch>` are equal under `str.casefold()` but differ in bytes, bootstrap leaves the leg. No ref moves and none is made. It says where the leg is, why it is left, and the way out.

- `_folded_twin(current, branch)` says whether HEAD's ref is such a twin. A detached HEAD (`None`) and a ref outside `refs/heads/` are never twins. It compares the names after `refs/heads/`, so a name held as surrogate escapes (#704) raises nothing.
- `_say_twin(label, path, branch, current, state, act)` prints the three lines. Each of the three path functions asks the helper first, before any move, and grows by one `if`. They take the leg's `path` for the command, as `move_back_to_pin` does; `checkout_tracking_branch` passes it at its three call sites only (its `(sub / ".git").exists()` lines are untouched).
- The words hold on a disk that keeps case too, where `Main` and `main` are two branches. They never say the two are one ref: they say that a disk that folds case (macOS, Windows) reads them as one branch, and what that could do.

Before (all three said the leg was on `Main`, which keeps its commits (a branch with no commit yet, for the unborn twin below), and moved or made `main`):

    T7 (main behind the pin)   branch main fast-forwarded <old> -> <pin>, the pin; ...
                               the leg was on Main, which keeps its commits; it is on main now
    P9 (main at the pin)       on main at <pin> (branch tip == pin)
                               the leg was on Main, which keeps its commits; it is on main now
    missing branch             branch main created at <pin> - at the pin
                               the leg was on Main, which keeps its commits; it is on main now

Now (spaces and quoting as printed; the leg is `spec`):

    T7:
      [spec] spec: branch main tip <old> is behind the PIN <pin>, and the checkout is on Main, which differs from main only in case
              a disk that folds case (macOS, Windows) reads Main and main as one branch, so moving main could move the ref HEAD stands on and leave the index at the old commit; the leg is left as it is
              run `git -C spec switch main`, which moves no branch, and then run bootstrap again.
    P9:
      [spec] spec: branch main is at the pin <pin>, and the checkout is on Main, which differs from main only in case
              a disk that folds case (macOS, Windows) reads Main and main as one branch, so the leg is already on main there, and bootstrap would say it left a branch that keeps its commits; the leg is left as it is
              run `git -C spec switch main`, which moves no branch, and then run bootstrap again.
    missing branch:
      [spec] spec: branch main does not exist (the pin is <pin>), and the checkout is on Main, which differs from main only in case
              a disk that folds case (macOS, Windows) reads Main and main as one branch, so making main could hide the commits a branch spelled like main holds behind the new ref; the leg is left as it is
              not moving the checkout off it. Keep any commit made there on a branch whose name differs from main by more than case, check out the pin in the leg, and run bootstrap again.

T7 and P9 give `git -C <leg> switch <branch>`, the path and the branch each one `_paste_word` (#932), as `move_back_to_pin`'s path and upstream are: a name that prints as it is stays `shlex.quote`d, and one with a byte that is not UTF-8 is a `printf` that prints the bytes. On a disk that folds case it re-spells HEAD and moves no file, and the next run takes T7's on-branch `merge --ff-only` path or P9's no-op checkout. Where the two are branches, it puts the leg on the real `main`, and `Main` keeps its commits. Every other state prints what it printed: HEAD on `refs/heads/main` itself, on `feature`, on `mains`, detached, on a ref outside `refs/heads/`.

CI is Linux only, so a disk that folds case is emulated, as the issue's own probe did: a symlink `.git/refs/heads/Main -> main`, which git's files backend reads through, and HEAD on `refs/heads/Main`. T7 is the issue's state: `main` loose and behind the pin, the pin's commit adding `NEW-CONTRACT.md` and editing `README.md`.

At `50178305`:

    before: HEAD -> refs/heads/Main | Main = main = 536671ad3925 | pin = f27b6a92a433 | status ''
    bootstrap: branch main fast-forwarded 536671ad3925 -> f27b6a92a433, the pin; origin/main is 536671ad3925, which the pin deliberately does NOT follow
               the leg was on Main, which keeps its commits; it is on main now
    after:  HEAD -> refs/heads/main | HEAD = main = f27b6a92a433 | status 'D  NEW-CONTRACT.md;M  README.md'

At this head (a second scaffold, so other shas):

    before: HEAD -> refs/heads/Main | Main = main = bf6b7be3d5fa | pin = 19051708a6c9 | status ''
    bootstrap: the three T7 lines above
    after:  HEAD -> refs/heads/Main | Main = main = bf6b7be3d5fa | status ''

P9 on the base printed `on main at <pin>` and the `the leg was on Main` line; at this head it prints the three P9 lines, and HEAD, `main` and the status are as before. The way out, `git -C spec switch main` run as printed, puts HEAD on `refs/heads/main`, moves no branch, and the next run prints `fast-forwarded` (T7) or `on main at <pin> (branch tip == pin)` (P9), with `git status --short` empty.

At `50178305`, git 2.43.0 and 2.47.1, the same result. `checkout -q --no-overwrite-ignore -b main <pin>`, from HEAD on `Main`, `main` not there:

- **A packed twin with commits** (`git pack-refs --all`, so no loose `Main`, no `main`). Before: HEAD on `Main`, HEAD = `Main` = the twin's commit, `main` missing, status clean. The checkout exits 0 and writes the loose file `main`. On a disk that folds case that file is the file `Main` reads, so `Main` now names the pin, and the twin's commit is in no branch and no ref (`git branch -a --contains`, `git for-each-ref --contains`: nothing); only HEAD's reflog lists it. **This is the harm.** The emulation makes the link after the checkout: a link made before a packed `Main` is a DANGLING link, git reads `Main` as missing through it (it falls back to packed-refs only when `lstat` says ENOENT, and a folding disk would say that), HEAD reads as unborn, and the staged `g.txt` is left. That before-state is not a folding disk's, so the probe has no link before the checkout, and the committed packed test has none at all: a real `Main` with a commit, packed, is the state of a disk that keeps case, and the lines are the same on both.
- **An unborn twin** (HEAD on `Main` with no commit, the dangling link `Main -> main`, which a folding disk reads as one missing ref). The checkout moves HEAD to `main` at the pin and the index is consistent. Nothing is hidden: the twin holds no commit. **It is harmless.** It is refused all the same: one rule, one helper, and no state of HEAD to branch on; `head is not None` in the missing-branch path would exempt it, if the coordinator prefers that.

The words follow the packed probe: `switch main` is no way out of a leg with no `main` on a disk that keeps case, so the line says to keep the commits on a branch whose name differs from `main` by more than case, check out the pin (whose sha the first line prints), and run bootstrap again. The risk says "the commits a branch spelled like main holds", which is true of a packed twin, of an unborn twin (it holds none, so nothing is hidden) and of a third spelling beside either (rv896's probe: an unborn `Main` beside a packed `MAIN` hides that commit the same way). The committed packed test runs the way out: `git branch kept-from-Main`, `git checkout --detach <the printed sha>`, bootstrap prints `branch main created at <pin> - at the pin`, `kept-from-Main` and `Main` both hold the commit.

`tests/test_bootstrap_casefold_twin.py`, 32 tests. The leg set-up is `test_bootstrap_after_bump.py`'s fixtures, `bumped` and `fast_forwarded`, and `test_bootstrap_leg_moves.py`'s `typed` and `main_of`; the helper `bootstrap_module` is `test_bootstrap_branch_bytes.py`'s. Every test uses the module's own local bare remotes. Each symlink is `os.symlink`, and the test skips on Windows, and where it raises `OSError` (a disk that folds case refuses the link: `Main` is `main`), with the reason. A test that makes a real `Main` skips on a disk that folds case. Every assert on the output compares step (a)'s spec-leg lines exactly.

1. T7 under the emulation: `main` unchanged, HEAD still `refs/heads/Main`, `git status --short` empty, the three lines exact.
2. The way out runs as printed, then bootstrap again: `fast-forwarded`, HEAD `refs/heads/main` at the pin, status empty.
3. P9 under the emulation, the lines exact, and its way out.
4. The missing branch: an unborn twin under the emulation, and a packed twin with a commit, with the way out run.
5. A disk that keeps case: a real loose `Main` with a commit of its own and HEAD on it, T7's state. The same lines. The way out puts the leg on the real `main`, bootstrap fast-forwards it, `Main` keeps its commit.
6. Unchanged byte for byte: HEAD on `main` itself, on `feature` and on `mains` (3 tests).
7. `_folded_twin`'s table (15 rows): `Main`, `MAIN`, the twin the other way, the branch itself, `Main` against `Main`, detached, `refs/remotes/origin/main`, another branch, a longer name, `refs/Heads/main`, `refs/notes/Main` and `refs/tags/xMain` (each 11 characters before `Main`, as `refs/heads/` is), a final sigma against a sigma (`casefold` folds it, `lower()` does not), and a name that is not UTF-8, itself and as a twin. Plus three tests of `_say_twin` itself: the command is shell-quoted as before for an ordinary pair, a name that is not UTF-8 or holds an ESC is spelled (output is ASCII, no raise), and (part 2) five rows read the two words of the command back through `/bin/sh` and compare them with the bytes of the names: an ordinary pair, a path with the byte, a branch with the byte, both with a leading dash, and a path with a percent.
8. (Part 2) A tracking branch `a<0xff>b` and HEAD on its twin `A<0xff>b` (T7, `--branch`, POSIX, on a disk that takes the byte; skipped on Windows, and on macOS, whose disk refuses the byte): the lines name both spelled, the printed command is `git -C spec switch "$(printf -- 'a\377b')"`, run through `/bin/sh -c` as printed it puts HEAD on `refs/heads/a<0xff>b` byte for byte, and the next run fast-forwards the branch.

**On the base (`50178305`, the first 26 tests copied in): 23 failed, 3 passed.** The three that pass are case 6, by design. Part 2's new test cannot run on the base (it imports `test_bootstrap_pasteable_lines.py`, which #944 added); on the merged tree before the part 2 change it failed, at the command: `- git -C spec switch "$(printf -- 'a\377b')"` expected, `+ git -C spec switch 'a\udcffb'` printed. **At this head: 32 passed**, on git 2.43.0 and 2.47.1.

| mutant | result |
|---|---|
| compare byte for byte again (`_folded_twin` never true) | 11 failed |
| the check dropped from T7 / from P9 / from `create_at_pin` | 3 / 1 / 2 failed |
| `casefold()` -> `lower()` | 1 failed (the sigma row). Equivalent for ASCII; the sigma row tells them apart, so no `ss` pair was needed |
| the `None` guard dropped (`None.startswith`) | `make bootstrap`'s first run raises in the fixture: 9 errors and 1 failed |
| a twin with `current` `None` (returns true for it) | the same 9 errors and 1 failed |
| `switch` -> `checkout` in the printed way out | 4 failed (the exact-lines tests). Case 2, which only runs the printed command, still passes: `git checkout main` does the same here |
| the `refs/heads/` guard dropped (whole refs compared) | 1 failed (`refs/Heads/main`) |
| `held != branch` dropped (the branch itself a twin) | 7 failed |
| the path / the branch not shell-quoted | 1 failed each |
| the twin / the branch not spelled | 1 failed each |
| the missing-branch path printing the `switch` command | 3 failed |
| the missing-branch way out saying `a branch` for `a branch whose name differs ... by more than case` | 3 failed |
| **R2** (round 1): the `refs/heads/` guard dropped, the 11-character slice kept | **24 passed on the first 24 tests: it survived. 2 failed on the 26 (`refs/notes/Main`, `refs/tags/xMain`): killed.** |
| round 1's re-runs on the 26: the check dropped from `create_at_pin`; `casefold()` -> `lower()` | 2 failed; 1 failed |
| **part 2:** `_paste_word` dropped from the path / from the branch / the old `spelled(shlex.quote(...))` form restored | 3 / 3 / 5 failed of the 32 (the byte-path rows; the end-to-end test and the byte-branch rows; all of them but the ordinary pair). None survives |

The host's load was 85 to 270, so the coordinator scoped them (one pytest process at a time, 15:53Z and 16:15Z).

- **git 2.43.0**, `tests/test_bootstrap_casefold_twin.py` and every `tests/test_bootstrap*.py` (11 files, the issue's `test_bootstrap_after_bump.py`, `test_bootstrap_leg_moves.py` and `test_bootstrap_non_utf8_branch.py` among them), `test_family_add_spelling_leftovers.py`, `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): **751 passed, 3 skipped** (the three skip `test_bootstrap_ref_store.py:102`: 2.43 makes no reftable repository) in 14:47. Then `test_bom_reads.py` and `test_validator_child_encoding.py`: **188 passed**.
- **git 2.47.1**, the new file, `test_bootstrap_leg_moves.py`, `test_bootstrap_after_bump.py` and the trio: **248 passed** in 3:40.
- **Round 1, part 2, at the merged tree** (the load was about 50, two processes at a time): git 2.43.0, the new file, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_non_utf8_branch.py`, `test_bootstrap_leg_moves.py`, `test_bootstrap_after_bump.py` and the trio: **329 passed** in 7:04. git 2.47.1, the new file and `test_bootstrap_pasteable_lines.py`: **72 passed** in 2:09. Both ran before the five unit rows were added (a change to the test file alone); the new file was then run again on both: **32 passed** each.
- **Round 1, at the part 1 tree** (the load was about 40, two processes at a time): git 2.43.0, the new file, `test_bootstrap_leg_moves.py`, `test_bootstrap_after_bump.py` and the trio: **250 passed** in 3:44. git 2.47.1, the new file: **26 passed** in 20 s.
- **The predicted merge with origin/main `64464d31`** (a commit of tree `d7d8ca11`, never pushed): the new file, the trio and `test_release_workflow.py`: **231 passed** (git 2.43.0).
- A first run of 115 files in 3 shards (git 2.43.0) was stopped at 35%, 28% and 45% when the load reached 270: about 3,100 tests, none failed. It is not counted.

**Left to CI's `tests` job, which runs the whole suite on the head:** every file not named above. Of the files `git grep -l -E 'assembly-root/scripts/bootstrap|make bootstrap|bootstrap\.py' -- tests` names, these 66 were not run: test_adopt_attributes_eol_block.py, test_adopt_clone_template.py, test_adopt_e2e.py, test_adopt_ignored_shape_files.py, test_adopt_mount_ignored.py, test_adopt_nested_attributes.py, test_adopt_pins.py, test_adopt_plan_yaml_quoting.py, test_adopt_reencoded_files.py, test_adopt_relative_submodule_url.py, test_adopt_root_manifest.py, test_adopt_root_symlinks.py, test_adopt_shape_pin_and_work_dir.py, test_adopt_submodules.py, test_adopt_verify.py, test_bump_leg.py, test_bump_leg_dry_run_truth.py, test_bump_leg_rollback.py, test_bump_leg_unreadable.py, test_doctor_legs_members_next.py, test_doctor_machine_row.py, test_doctor_next_is_a_command.py, test_doctor_paste_through_git.py, test_doctor_pin_restore_merges.py, test_family.py, test_family_bootstrap_gate_order.py, test_family_bump_remove_rollback.py, test_family_gitmodules_values.py, test_family_head_lost_gitmodules.py, test_family_holder_bootstrap_texts.py, test_family_makefile_siblings.py, test_family_members_not_a_list.py, test_family_siblings_placement.py, test_leg_path_yaml_scalars.py, test_legs_readers_not_a_list.py, test_load_yaml_refusals.py, test_naming_policy.py, test_non_utf8_name_helpers.py, test_park_resume_targets.py, test_push_branch_refspec.py, test_scaffold_e2e.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_path_collisions.py, test_scaffold_manifest_values.py, test_scaffold_mount_ignored.py, test_setup_by_hand_commands.py, test_setup_project_py.py, test_setup_sh.py, test_shape_doctor.py, test_siblings_family_name.py, test_siblings_inputs.py, test_template_text_placeholders.py, test_update_shape.py, test_update_shape_add.py, test_update_shape_apply_lines_and_headers.py, test_update_shape_kept_helper.py, test_update_shape_landing.py, test_update_shape_merged_away.py, test_update_shape_pr_base.py, test_validate_family_branch_step.py, test_validate_family_member_path_glob.py, test_validate_family_remedies_typed.py, test_validate_family_typed_states.py, test_validate_pins_lockstep_lines.py, test_validate_workflow_gitmodules_urls.py, test_windows_paths.py. On git 2.47.1 the 2.43 list's other files were not run either.

- origin/main `450279ca` (#944 landed): this head merges clean, tree `aabc059b`. The merge commit `60df34fc` itself is a clean merge, tree `664513ff`, as predicted.
- Slice D's head `0ef875c0` (#958): clean, tree `af0a54b7`.
- The other lanes' open branches, `fix/415-legs-members-next-tests`, `fix/430-a-bump-leg-unsearchable`, `fix/879-holder-remedies-compose`, `fix/880-check-plan-name-docstring`, `fix/884-ignored-depth`, `fix/885-one-line-install-release-notes`, `fix/897-kept-copy-reraise` and `fix/898-bump-leg-reachability`: none touches `bootstrap.py` or this test file, and each merges clean with this head. `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip` conflict, in `shape-doctor.py`, `validate-pins.py` and tests this change does not touch, and conflict the same way with origin/main itself.
- Slice D's earlier local head, `df1bb186`, conflicted with the first head (both added a block after `_on_a_branch`); it was moved at `0ef875c0`, which merges clean.

- **Docs.** `bootstrap.py`'s module docstring (step (a)) and the docstrings of the three path functions and `_move_and_check_out` say the rule. I grepped `AGENTS.md`, `README.md`, `docs/`, the assembly root's `AGENTS-shape.md` and README, and the family root's, for a sentence about step (a)'s paths. None is false: each says a leg is placed `only where that loses no commit, and otherwise ... left where it is, saying why`, or `or says why not`, and a twin leg is left, saying why. Nothing else was edited.
- **Cognitive complexity (S3776, 15 allowed), counted by hand and with an AST counter that follows Sonar's rules** (each `if`, `elif`, `else`, `for`, `except` and ternary, plus nesting; one per run of one boolean operator): `place_at_pin` 5 -> 6, **`fast_forward_to_pin` 14 -> 15, at the limit**, `create_at_pin` 12 -> 13, `_folded_twin` 3, `_say_twin` 1, `checkout_tracking_branch` 13 (unchanged). If Sonar counts one more in `fast_forward_to_pin`, the cheapest cure is a helper for its `note` (two points). Round 1 changes only words, so none changes. The commit message of 80353dd says `14, 6 and 12`; that was my hand count before the counter found the `why or []` argument in `fast_forward_to_pin` and `create_at_pin`. The numbers here are right, and the second commit's message says so; the squash message should not repeat the first commit's sentence.
- **Width.** Every new line is within 79 columns in both files. No duplicated block; no `assert` joins conditions with `and`.

- **One rule for all three paths,** the unborn twin included (see the probe). rv896 ruled to keep refusing it and not to add `head is not None`: an unborn twin can stand beside a packed third spelling whose commit `checkout -b` hides, and the refusal costs one step (`git -C spec checkout --detach <pin>`, then bootstrap).
- **`casefold()` can over-match a real disk:** `ss` and the sharp s fold under it, which a disk's own simple folding may not do. The leg is then left, with true words, where the disk would not need it.
- **The rule is by name, so a reftable leg is left too,** where `Main` and `main` are two refs on any disk. The way out is true there as well.
- **NFC and NFD twins are out of scope** (below).

1. **An NFC/NFD twin does the same harm, and is not caught.** APFS also reads a name's NFC and NFD forms as one file. Emulated as the issue's probe was (a symlink from the NFD name to the NFC branch `caf` + U+00E9, HEAD on the NFD name, `--branch` the NFC name, git 2.43.0), at `50178305` AND at this head: `branch caf<e9> fast-forwarded <old> -> <pin>`, `the leg was on cafe<U+0301>, which keeps its commits; it is on caf<e9> now`, status `D  NEW-CONTRACT.md` and `M  README.md`. `casefold()` does not normalize; `unicodedata.normalize("NFC", ...)` in `_folded_twin` before the compare is the one-line cure, with a test.
2. **`bump-leg.py` and `family.py` compare the root's or the holder's branch with `tracking_branch` byte for byte** (`branch == project.tracking`, `branch != family.tracking()`): on a disk that folds case a root on `Main` is on `main`, and the pull-request-only refusal (`bump-leg-on-tracking-branch`, `family-on-tracking-branch`) is not given. Reasoned from the code; not probed.
3. **A HEAD on a ref whose directory is spelled otherwise** (`git symbolic-ref HEAD refs/Heads/main`) is a twin on a disk that folds case, and is not one here: the rule leaves refs outside `refs/heads/` alone, as the brief says. Reasoned; very rare.
4. **From rv896, for later:** a follow-up for `create_at_pin`: the same lost-commit harm happens from a HEAD detached at the pin over a packed `Main` and no `main`, which the rule does not see (it asks only about HEAD's ref); before `checkout -b`, ask `git for-each-ref refs/heads/` for any name that casefolds to the branch, packed or loose.

Review: rv896, lane 5's independent reviewer, not the writer (w896): READY at `80353dd1` (issuecomment-6252823490), and its re-check of fix round 1 READY at `45415296`, the head that lands (issuecomment-6253509985), with no MUST and no SHOULD. Its one NIT, a leading `=`, is #961's, open as #973. CI (Brett Heap's ruling 27): H0 is the head 4541529 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 450279c; and the local gate passed on its predicted squash tree 67a22e1 over main 3db58c4: the bootstrap_casefold_twin, repo_hygiene, agents_md_codes, cli_reference sets, 202 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #896

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 18, 2026 20:06
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot read is named relative to the root with the remedy, each validator is opened before any runs, and step (c) names a register under an absolute leg path whole and heads one whose stat was refused `register, if one is there` (#958 fix round 1, #430 slice D)

On lane 4's RV958 review of #958 at 0ef875c:

- M1: step (c)'s header goes through `_named`, so a register under a
  leg whose `path:` is absolute is named whole, where `relative_to`
  ended the run in a `ValueError` traceback, readable or not.
- N1: a register whose `stat` was refused is headed `register, if one
  is there:` (`_a_register` now returns the label), so the line does
  not say that one is there.
- S1: `NamingPolicy.load` goes through `_load`, so a policy in a
  `contracts/` this user cannot search is `yaml-unreadable` naming
  `contracts/repository-naming.yaml`, with the remedy, not the
  absolute path alone.
- N3: `_unreadable_file` spells the path it names (`_shown`, a copy of
  validate-pins.py's).
- S2: `_a_validator` opens each validator after its `stat`, before any
  runs, as bump-leg.py's `_validators_readable` does (#934); one at
  mode 000 is `validator-unreadable` by name, not the child's own
  `can't open file` line.
- S3 and N5: tests for `_is_a`'s `ValueError` clause in both files, for
  the spelling in `_named` on both of its branches, for the I/O-error
  cause at every site that reads it, for a directory where a file goes,
  and for the out-of-root naming in both files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 19, 2026
… rows hold that the scaffold reads the first chain link's own `--pin` variable, not SHAPE_PIN_SOURCE_OPEN_DOX, an owner-qualified link is held to the run's variable guard, and the SOURCE_ABSENT and `_run_products` comments say why (#909)

Fix round 1 on RV981's review.

S1. The Kelvin fix also changes which variable is read for the first
link: as typed, `open<U+212A>dox` was read through SHAPE_PIN_SOURCE_OPEN_DOX,
and it is the pin `openKdox` now, read through SHAPE_PIN_SOURCE_OPENKDOX.
Two dry-run rows hold both directions.

S2. A spec leg's chain beginning at `opensoft/open<U+00DF>` is a row of
test_the_manifest_gate_reads_a_shared_variable_for_no_chain, so the
library's `repo_basename` on `run_products` is held.

N1. SOURCE_ABSENT's comment says the copy keeps the import block clear of
#958. N3. `_run_products` says it counts a leg with a bad or missing
`repository:`, which the naming gate's `_project_chain_links` leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Fix round 1 on #958 (#430 slice D), on RV958's review: head b420171f, one commit on the merge of origin/main ebbd9fa4 (bb58c441, clean). The MUST, the three SHOULDs and N1, N3 and N5 are taken; N2 is no change, and N4 is left to slice Z.

The round was written on a merge of origin/main 2f5cabf5 (00a6f839, clean; bootstrap.py auto-merged with #944's _paste_word). When #948 landed as ebbd9fa4, that unpushed merge was replaced by one merge of ebbd9fa4 into 0ef875c0 (bb58c441, clean; bootstrap.py auto-merged with #948's twin refusals), and the round's commit was reapplied unchanged (the same git patch-id; the tree git merge-tree predicted). Pushed to fix/430-d-assembly-root-guards as a fast-forward from 0ef875c0, no force; git ls-remote reads b420171f4648b192918891e8b20d794be0dfad91. The round's commit b420171f touches templates/assembly-root/scripts/bootstrap.py, templates/assembly-root/scripts/validate-manifest.py and tests/test_assembly_root_unreadable_paths.py only.

Each finding was probed at the merge 00a6f839 and again with the change (#948's later change touches none of these paths), in a scaffold's recursive clone (local bare remotes, a stub gh), on Python 3.12.3 with the mode taken away and under the 3.14-like sitecustomize shim. Both Pythons printed the same in every cell below, before and after.

M1. Step (c) and an absolute leg path

  • Before: with the spec leg's path: absolute into a folder at 000, step (c) ended ValueError: '<abs>/outside/spec/governance/review-authority/register.yaml' is not in the subpath of '<ROOT>', exit 1, on both Pythons. With the folder readable and a register in it, the same traceback.
  • Change: _report_one_register prints its header through _named(root, register_path): relative to the root and POSIX as before, and whole where the path is not under it.
  • After: refused: register, if one is there: <abs>/outside/spec/governance/review-authority/register.yaml, the unreadable, so nothing is claimed from it line naming it whole, the cure, the grants line, bootstrap ok, exit 0. Readable: register: <abs>/outside/spec/governance/review-authority/register.yaml, then its row, exit 0.
  • Tests: test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root] (mode 000) and test_a_register_under_a_leg_path_outside_the_root_is_read (readable, every platform). Both fail at the merge with the traceback.

N1. The header of a register whose stat was refused

  • Before: spec/ at 000 and no register anywhere: register: spec/governance/review-authority/register.yaml, a line that says one is there.
  • Change: _a_register returns the label: register for a file, register, if one is there (MAYBE_A_REGISTER) for a refused stat, None for no register; _register_paths returns (path, label) pairs, and read_authority hands the label to _report_one_register. A register at mode 000 in a searchable folder (its stat works) keeps register:.
  • After: register, if one is there: spec/governance/review-authority/register.yaml, then the same unreadable line and cure.
  • Tests: two existing cells now pin the new header (test_a_leg_bootstrap_cannot_search_is_left_as_it_is and test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter]), [at-mode-000] pins that register: stays where the stat works, and test_a_register_whose_stat_is_refused_is_not_dropped and test_only_a_file_is_a_register_where_the_stat_works pin the pairs.

S1. The naming policy

  • Before: contracts/ at 000, no --policy: REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied), and nothing more, exit 2.
  • Change: _load takes the reader (read=load_yaml), and main loads the policy through _load(root, policy_path, NamingPolicy.load), so its OSError cause goes through _unreadable_file as project.yaml's does. A policy refusal of another kind (policy-unreadable, policy-wrong-kind, policy-empty) keeps its words.
  • After: REFUSED yaml-unreadable: contracts/repository-naming.yaml: cannot be read (Permission denied), then Remediation: this validator reads project.yaml, the naming policy and the pin file each declared referent needs, ... and the chmod cure, exit 2.
  • Also changed, so said: a policy that is MISSING was <ROOT>/contracts/repository-naming.yaml: cannot be read (No such file or directory), alone; it is now named relative to the root with the same remedy and OTHER_REASON ("put a regular file this user can open back in its place, ..."). No test pins the old line (git grep finds none).
  • Tests: test_a_naming_policy_the_gate_cannot_reach_is_refused_by_name (mode 000, no --policy) and test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error] (in-process, an I/O error from the open). Both fail at the merge.

S2. A validator at mode 000

S3. The untested guards

  • _is_a's ValueError clause, both files: test_a_name_no_file_can_have_is_absent asks each file's _is_a about a name holding a NUL and one holding a lone surrogate (which the POSIX file system encoding cannot write: a UnicodeEncodeError, a ValueError). test_a_leg_path_holding_a_nul_is_not_initialized runs the case RV958 measured: [spec] sp\x00ec: NOT INITIALIZED, the degrade lines, exit 0. These pass at the merge by design.
  • spelled in _named: test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled gives the code leg the path loc<ESC>ked/code with loc<ESC>ked at 000, inside the root (the relative branch) and outside it as an absolute path (the whole branch). Step (a) and step (c) print loc\x1bked. The inside case fails at the merge on N1's header only; the outside case fails on M1's traceback.

NITs

Tests

tests/test_assembly_root_unreadable_paths.py: 71 tests, 46 before. 25 are new; 3 existing ones changed their asserted header (N1) or their return shape, and two existing if/else pairs became the helper refusing.

At origin/main ebbd9fa4 (both scripts as main has them; the new file kept): 38 fail and 26 error (main has no _is_a), 7 pass by design: the controls (a referent's pin file that is a dangling link or a link to itself, a validator taken away or a directory in its place, project.yaml or a register that is a directory) and the NUL leg path. Log: $Q/v118/w430d/r1/base-main2.log (the same at 3db58c43, base-main.log).

At the merge 00a6f839 (the round's two scripts put back to the merge's bytes, the new test file kept): 14 fail, 57 pass ($Q/v118/w430d/r1/base-fail.log). The 14: 11 new tests (the policy three, the relative spelling unit, the two S2 cells, the two ESC cells, the readable absolute register, [io-error] of the register pairs, [a-file] of test_only_a_file_is_a_register_where_the_stat_works) and the 3 existing header cells. Passing there by design (14 new): the directory controls (project.yaml, a validator, a register), the narrowing rows whose site already took every OSError (stat and open of project.yaml, a validator's stat, a register's open), the NUL cell, the four _is_a name units and the two _found units: they pin behaviour the merge already had, and each kills a survivor below.

Mutants

Round 1: 27 mutants, one string each, run against the new file at the round's code on the earlier merge (b220336b's scripts; #948's merge changes none of the mutated lines, and each mutant's text is found once at the head): 27 killed, 0 survived. B* and V* are RV958's thirteen survivors (B2, B3, B5, B6, B7, B8, B10, B11, B15, V1, V2, V4, V5) as this branch reconstructs them, since RV958 did not publish its mutant texts, with B5b for the second branch of _named; K* are further guards of the same kind; R* are one or more for each change of the round.

Mutant What it changes Result
B2 bootstrap.py _is_a without its except ValueError killed: 3 test(s): test_a_leg_path_holding_a_nul_is_not_initialized, test_a_name_no_file_can_have_is_absent[bootstrap.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[bootstrap.py-a-nul]
V1 validate-manifest.py _is_a without its except ValueError killed: 2 test(s): test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-nul]
B5 bootstrap.py _named, the relative branch unspelled killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root]
B5b bootstrap.py _named, the whole-path branch unspelled killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root]
B6 bootstrap.py _named, out of the root: path.name for the whole path killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read
V5 validate-manifest.py _unreadable_file, out of the root: path.name killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]
B7 bootstrap.py _is_file takes only PermissionError killed: 2 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[stat-io-error], test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[stat-io-error]
B8 bootstrap.py _a_register takes only PermissionError killed: 1 test(s): test_a_register_whose_stat_is_refused_is_not_dropped[io-error]
B10 bootstrap.py _load_manifest maps only a PermissionError cause killed: 1 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[open-io-error]
B11 bootstrap.py _load_register_rows names only a PermissionError cause killed: 1 test(s): test_a_register_that_cannot_be_opened_claims_nothing_on_every_python
V4 validate-manifest.py _load maps only a PermissionError cause killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error]
B3 bootstrap.py _is_file takes anything for a file killed: 2 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place]
B15 bootstrap.py _a_register takes anything for a register killed: 1 test(s): test_only_a_file_is_a_register_where_the_stat_works[a-directory]
V2 validate-manifest.py _is_file takes anything for a file killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool
K1 bootstrap.py _is_a ignores kind killed: 3 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place], test_only_a_file_is_a_register_where_the_stat_works[a-directory]
K2 validate-manifest.py _is_a ignores kind killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool
K3 bootstrap.py _found asks for a regular file killed: 2 test(s): test_anything_at_a_name_in_a_leg_is_found[a-directory], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name]
K4 bootstrap.py _found drops link killed: 3 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge], test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name]
K5 bootstrap.py _found takes only PermissionError killed: 1 test(s): test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error]
R1 M1: the header back to relative_to(root) killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read
R2 N1: a refused stat headed register again killed: 6 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root], test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped[io-error], test_a_register_whose_stat_is_refused_is_not_dropped[permission]
R3 S2: _a_validator opens nothing killed: 2 test(s): test_a_validator_at_mode_000_is_refused_before_any_runs, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error]
R4 S2: _a_validator takes only PermissionError killed: 1 test(s): test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error]
R5 S1: the policy back to NamingPolicy.load(policy_path) killed: 3 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc], test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error], test_a_naming_policy_the_gate_cannot_reach_is_refused_by_name
R6 N3: _shown returns the name as it is killed: 2 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too, test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]
R7 N3: the relative branch unspelled killed: 1 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too
R8 N3: the whole-path branch unspelled killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc]

Runs

Each run is run9s.sh with SHARDS=3 (three run9.sh processes: the gh stub first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, a blank git identity), one run at a time, at head b420171f.

  • git 2.43.0, the first brief's whole list, 134 files: 9656 passed, 25 skipped (longest shard 40 min), rc 0. 2026-10-19T11:37:42Z to 2026-10-19T12:17:59Z. The list is every test file that names the assembly root's bootstrap.py or validate-manifest.py, or runs a scaffolded root's make bootstrap or its validators, with bootstrap.py: a leg whose HEAD is on a branch that differs from the tracking branch only in case (Main for main) is left where it is, so on a disk that folds case branch -f main no longer moves the ref HEAD stands on (#896) #948's new test_bootstrap_casefold_twin.py, and test_pinned_copy_text.py, test_bom_reads.py, test_validator_child_encoding.py, test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py.
    The 25 skips: 8 of test_leg_path_yaml_scalars.py (empty parameter sets, and the templates read the value back); 6 of test_doctor_legs_members_next.py (git 2.43 dies on a shallow of -2**31, and crashes on submodule init; CI's 2.55 does neither); 3 reftable cases of test_bootstrap_ref_store.py (git 2.45 and later); 3 NTFS junction cases of test_update_shape_add_symlink.py, 3 PowerShell lines of test_shape_doctor.py and 1 of test_stdout_encoding.py (Windows only); 1 of test_bump_leg.py (a disk that ignores case).
  • git 2.47.1 (a build first on PATH), 24 files: 1764 passed (longest shard 4 min), rc 0. 2026-10-18T20:07:50Z to 2026-10-18T20:11:58Z. Files: test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_bom_reads.py, test_bootstrap_after_bump.py, test_bootstrap_branch_bytes.py, test_bootstrap_casefold_twin.py, test_bootstrap_leg_moves.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_pasteable_lines.py, test_bootstrap_placement_texts.py, test_bootstrap_ref_store.py, test_bootstrap_refusal_exit.py, test_bootstrap_spelling.py, test_bootstrap_upstream_notice.py, test_bootstrap_without_git.py, test_bump_leg_unopenable.py, test_bump_leg_unreadable.py, test_cli_reference.py, test_manifest_validator.py, test_naming_policy.py, test_pinned_copy_text.py, test_repo_hygiene.py, test_validate_pins_named_and_spelled.py, test_validator_child_encoding.py.
  • A first 2.43.0 run of the same list at the same head, on 2026-10-18, was stopped from outside at 20:31Z (a signal; its shards had reached 52%, 29% and 41% with no failure); the run above is its re-run.
  • Before bootstrap.py: a leg whose HEAD is on a branch that differs from the tracking branch only in case (Main for main) is left where it is, so on a disk that folds case branch -f main no longer moves the ref HEAD stands on (#896) #948 landed, the same list less test_bootstrap_casefold_twin.py (23 files), on git 2.47.1 at the round's commit on the earlier merge (b220336b): 1713 passed, rc 0.
The 134 files of the git 2.43.0 run

test_adopt_attributes_eol_block.py, test_adopt_clone_template.py, test_adopt_e2e.py, test_adopt_git_reads.py, test_adopt_ignored_shape_files.py, test_adopt_manifest_values.py, test_adopt_mount_ignored.py, test_adopt_nested_attributes.py, test_adopt_origin_repository.py, test_adopt_pins.py, test_adopt_plan_yaml_quoting.py, test_adopt_reencoded_files.py, test_adopt_relative_submodule_url.py, test_adopt_root_manifest.py, test_adopt_root_symlinks.py, test_adopt_shape_pin_and_work_dir.py, test_adopt_spec_only.py, test_adopt_submodules.py, test_adopt_verify.py, test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_bom_reads.py, test_bootstrap_after_bump.py, test_bootstrap_branch_bytes.py, test_bootstrap_casefold_twin.py, test_bootstrap_leg_moves.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_pasteable_lines.py, test_bootstrap_placement_texts.py, test_bootstrap_ref_store.py, test_bootstrap_refusal_exit.py, test_bootstrap_spelling.py, test_bootstrap_upstream_notice.py, test_bootstrap_without_git.py, test_bump_leg.py, test_bump_leg_branch_and_push_line.py, test_bump_leg_dry_run_tracking.py, test_bump_leg_dry_run_truth.py, test_bump_leg_paste_lines.py, test_bump_leg_rollback.py, test_bump_leg_unopenable.py, test_bump_leg_unreadable.py, test_chain_remedy_callers.py, test_cli_reference.py, test_doctor_gitlink_repairs.py, test_doctor_gitmodules_not_utf8.py, test_doctor_legs_members_next.py, test_doctor_machine_row.py, test_doctor_members_spelled.py, test_doctor_next_is_a_command.py, test_doctor_no_lazy_fetch.py, test_doctor_non_ascii_paths.py, test_doctor_partial_clone.py, test_doctor_paste_through_git.py, test_doctor_pin_restore_merges.py, test_doctor_plan_overrides.py, test_doctor_recorded_answers.py, test_doctor_unfinished_merge.py, test_doctor_unreadable_pin.py, test_doctor_validator_rows.py, test_doctor_validator_site.py, test_doctor_validator_verdicts.py, test_family.py, test_family_bootstrap_gate_order.py, test_family_bump_remove_rollback.py, test_family_gitmodules_values.py, test_family_head_lost_gitmodules.py, test_family_holder_bootstrap_texts.py, test_family_makefile_siblings.py, test_family_member_name.py, test_family_members_not_a_list.py, test_family_siblings_placement.py, test_git_output_decoding.py, test_leg_path_yaml_scalars.py, test_legs_not_a_list.py, test_legs_readers_not_a_list.py, test_manifest_validator.py, test_naming_policy.py, test_non_list_project_fields.py, test_non_utf8_oserror_sites.py, test_park_resume_targets.py, test_pin_source_env_collisions.py, test_pinned_copy_text.py, test_pins_child_kind_and_siblings_root_spelling.py, test_push_branch_refspec.py, test_reference_default.py, test_repo_hygiene.py, test_scaffold_e2e.py, test_scaffold_free_text_values.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_path_collisions.py, test_scaffold_manifest_values.py, test_scaffold_mount_ignored.py, test_scaffold_pin_and_reuse.py, test_scaffold_referent_chain_links.py, test_setup_by_hand_commands.py, test_setup_project_py.py, test_setup_sh.py, test_shape_doctor.py, test_siblings_family_name.py, test_siblings_inputs.py, test_stdout_encoding.py, test_template_quoted_values.py, test_template_text_placeholders.py, test_template_values_single_pass.py, test_update_shape.py, test_update_shape_add.py, test_update_shape_add_ignored.py, test_update_shape_add_not_a_directory.py, test_update_shape_add_symlink.py, test_update_shape_apply_lines_and_headers.py, test_update_shape_branch_any_directory.py, test_update_shape_hardlink.py, test_update_shape_header_comments.py, test_update_shape_kept_helper.py, test_update_shape_landing.py, test_update_shape_merged_away.py, test_update_shape_messages.py, test_update_shape_next_quoting.py, test_update_shape_partial_rollback.py, test_update_shape_paste_lines.py, test_update_shape_pr_base.py, test_update_shape_rewrite_symlink.py, test_validate_family_branch_step.py, test_validate_family_member_path_glob.py, test_validate_family_remedies_typed.py, test_validate_family_typed_states.py, test_validate_pins_lockstep_lines.py, test_validate_pins_named_and_spelled.py, test_validate_workflow_gitmodules_urls.py, test_validator_child_encoding.py, test_windows_paths.py, test_yaml_indicator_openers.py, test_yaml_reader_spaces.py

Merge checks

The branch merges main once: bb58c441 merges origin/main ebbd9fa4 (#948, #896's twin refusals, landed) into 0ef875c0 with no conflict (bootstrap.py auto-merged), and the round's commit b420171f sits on it. The round was first written on an earlier merge of main (00a6f839, 2f5cabf5); that merge was replaced, unpushed, once #948 landed. The round's commit was reapplied unchanged (the same git patch-id). Its tree is the one git merge-tree predicted for the earlier round commit with ebbd9fa4 (9f4c2991).

git merge-tree --write-tree of head b420171f, each ref fetched with gc.auto=0 and maintenance.auto=false just before:

Sonar-style

  • Cognitive complexity, by hand: _a_validator 2, _a_register 2, _register_paths 4, _report_one_register 6 (unchanged), read_authority 4, _run_neutral_validators 7 (unchanged); _shown 1, _unreadable_file 2, _load 3, main unchanged. Test helpers 2 or less.
  • No duplicated block: the round factored the repeated lines into refusing, not_initialized, up_to_step_b and a_register rather than copy them.
  • No assert joins conditions with and.
  • Every added line is within 79 columns.

Checklist candidates

To lane openRepoShape-4: round 1 is pushed as b420171f (a fast-forward from 0ef875c0), and the PR body is updated. Please ask RV958 to re-check it there.

Lane: openreposhape-5 (openRepoShape-5)

brettheap added a commit that referenced this pull request Oct 19, 2026
…eads/` branch, packed or loose, folds to it, and a twin folds in NFC first, so an NFD name moves no leg, and the way out renames the twin, HEAD's twin included (#962) (#984)

Follows #948 (#896), which landed as `ebbd9fa4`.

**Head `f1cceddf`**: `414e9e0a` (round 0, on #948's head `45415296`), `5a435acd` (the merge of origin/main `ebbd9fa4`, #948's squash) and `f1cceddf` (fix round 1, on rv984's review of `5a435acd`). The squash is not an ancestor of this branch, so the merge conflicted in `bootstrap.py` in seven hunks, and this branch's side was taken whole: main's `bootstrap.py` (blob `e31385cd`) is `45415296`'s byte for byte, so nothing of main's was dropped. Against `ebbd9fa4` the branch is three files, `templates/assembly-root/scripts/bootstrap.py`, `tests/test_bootstrap_casefold_twin.py` (three tests changed) and the new `tests/test_bootstrap_create_twins.py` (+737, -68). `git merge-tree --write-tree` of the head against origin/main `c4dd19dc` is clean. One file of `templates/` changes.

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

#948 leaves a leg alone when HEAD's own ref is a TWIN of the tracking branch, a branch that folds to it on a disk that folds case (`Main` for `main`). Two things were not covered:

1. **Only HEAD's ref was asked.** `create_at_pin` asked about HEAD's ref, so a leg DETACHED AT THE PIN over a packed `Main`, and a leg on an unborn `Main` beside a packed `MAIN`, ran `checkout -b main <pin>` with no refusal. On a disk that folds case that writes the loose file `Main` and `main` are both read from. What such a disk then does to the packed `Main` is reasoned from git's files backend (rv984), and not measured on APFS or NTFS: the packed entry stays, `for-each-ref` goes on listing it at its old sha while `rev-parse Main` reads the new file `main`, and a later `git branch -D Main` unlinks that file. The commit is still in a ref; its name is shadowed, and the state is fragile.
2. **The fold was `casefold()` alone.** APFS also reads the NFC and the NFD spelling of a name as one file, so a leg on the NFD spelling of `caf` and U+00E9 is a twin that `_folded_twin` did not see: bootstrap fast-forwarded the NFC branch under HEAD's feet and the index was left at the old commit (`D NEW-CONTRACT.md;M README.md`), the same harm as #896's.

`create_at_pin` asks `git for-each-ref refs/heads/` (packed refs are listed too) for a branch that is a twin of the tracking branch (`_folded_spelling`), just before the `checkout -b` and after every refusal that was already there. If one is found, `_say_spelling` leaves the leg and prints three lines: where the leg is and the pin's sha, why it is left, and the way out. Nothing is moved and nothing is made.

- **EVERY twin is a reason to leave**, whether or not a tag, a remote-tracking ref, another branch, the stash or the pin holds its commits (fix round 1, below).
- A twin is only ever left in place, never moved (#948's rule, kept), and every output that is not a twin's is byte for byte what it was. `fast_forward_to_pin` is not changed in complexity (15, as it was).

`_fold(name)` is `unicodedata.normalize("NFC", name).casefold()`, used by `_folded_twin` and so by all three step (a) paths, and by item 1. A name held as surrogate escapes (#704) passes through `normalize` as the text it is and raises nothing (a test). NFKC is not used: APFS does not fold compatibility forms, and `main` with U+00B2 stays no twin of `main2` (two rows, and the NFKC mutant is killed).

`_twin_words` words the refusal by what the twin differs in. A twin that is one under `casefold()` alone prints #896's words byte for byte (`only in case`; `a disk that folds case (macOS, Windows)`). Any other says it differs `only in case or Unicode form`, and that `a disk that folds case or Unicode forms (macOS folds both, Windows folds case)` reads the two as one, because Windows folds case only and the words must not say it folds Unicode forms.

**This replaces round 0's `_kept_elsewhere`, a departure from the brief that rv984 found breaks #962's Goal, and lane 5 ruled to follow the Goal.** Round 0 spared a twin whose commit was an ancestor of the pin, or was held by another branch, a tag, a remote-tracking ref or the stash, and its way out kept the commits on another branch and ran again. rv984 measured that in each case that made `main`, a folding disk would read `Main` as the pin: the Goal says bootstrap never makes, with `checkout -b`, a ref that a folding disk reads as a branch that already exists, and these cases broke its letter. The loop the sparing avoided is real only for a way out that leaves the twin where it is: #948's keep line ("keep any commit ... check out the pin") leaves `Main` in place, so the next run refuses again. A way out that REMOVES the twin ends it.

- **`_kept_elsewhere` and `_spelling_at_risk` are gone.** `_folded_spelling(sub, branch)` returns the first `refs/heads/` ref, packed or loose, that folds to the branch, whatever else holds its commits. `_heads(sub)` is the shared listing.
- **The way out is a rename:** ``git -C <leg> branch -m <twin> <name>``, the path and both names through `_paste_word`. The twin disappears, its commits stay on the renamed branch, and the next run makes `main`.
- **The line can be pasted as printed.** `_kept_name(sub, twin)` chooses a name that is not there: `<twin>-kept`, then `-kept-2` and on, the first that no branch of the leg folds to (so `Main-kept` is taken when `main-kept` is there, as on a folding disk they are one file). It is longer than the twin, so it differs from the tracking branch by more than case or Unicode form.
- **#948's HEAD-twin keep line is amended the same way.** A leg ON a twin with `main` missing goes through `_say_spelling` with `where` = `the checkout is on`: its first two lines are byte for byte #948's, and the third is the rename. `git branch -m` moves HEAD with the rename of the branch HEAD is on, an unborn branch included (probed on git 2.43.0 and 2.47.1, with and without the dangling link the emulation uses), so the next run takes the ordinary `the leg was on <name>, which keeps its commits` path. `_say_twin` keeps T7 and P9, whose `switch` way out is unchanged.
- **`_say_keep`** is back to the one-parameter text main had before #948: no twin line uses its second parameter now, and no non-twin line changes.

**What it costs:** several twins are named one at a time, each rename one run. A name is chosen when the line prints; if something makes it first, git refuses the rename and the person picks another. The cost of the old sparing, a shadowed name on a folding disk, is gone with it.

At `5a435acd` and at `f1cceddf`. A packed `Main` holding a commit, `main` missing:

    detached over a packed Main, at 5a435ac:
      [spec] spec: branch main does not exist (the pin is <pin>), and the leg has the branch Main, which differs from main only in case
              ... the leg is left as it is
              keep the commits Main holds on a branch whose name differs from main by more than case: `git -C spec branch <another name> Main`, and run bootstrap again.
    at f1ccedd, the third line is:
              rename Main to a name that differs from main by more than case, its commits staying on it: `git -C spec branch -m Main Main-kept`, and run bootstrap again.
      run 2 after the pasted line: [spec] spec: branch main created at <pin> - at the pin
      after: HEAD -> refs/heads/main, heads: Main-kept (the commit), main (the pin)

    the same, the commit held by a tag (rv984's SHOULD 1 case):
      at 5a435ac: [spec] spec: branch main created at <pin> - at the pin, with Main left in packed-refs
      at f1ccedd: the refusal above, and `Main-kept` after the pasted rename

    HEAD on the packed Main (#948's keep line):
      at 5a435ac: "not moving the checkout off it. Keep any commit made there on a branch whose name differs from main by more than case, check out the pin in the leg, and run bootstrap again."
      at f1ccedd: the rename line, then HEAD -> refs/heads/Main-kept; run 2: branch main created at <pin>, "the leg was on Main-kept, which keeps its commits; it is on main now"

    HEAD on an unborn Main beside a packed MAIN:
      at f1ccedd: run 1 names Main (rename Main Main-kept; HEAD goes with it); run 2 names MAIN (rename MAIN MAIN-kept); run 3 makes main, "the leg was on Main-kept, a branch with no commit yet"

The NFD twin of an NFC tracking branch, T7, is as in round 0: at `45415296` bootstrap fast-forwarded `caf` and U+00E9 under HEAD (`D NEW-CONTRACT.md;M README.md`); at this head the leg is left, `switch` is printed, and nothing moves.

`tests/test_bootstrap_create_twins.py`, 31 tests. It uses #948's emulation and helpers (`test_bootstrap_casefold_twin`), `main_of` (`test_bootstrap_leg_moves`), `head_bytes` (`test_bootstrap_non_utf8_branch`) and `pasted` (`test_bootstrap_pasteable_lines`); the lines are compared exactly, and every way out is run as printed through `/bin/sh`.

1. A leg detached at the pin over a real spelling `Main`, packed and loose (2): the lines exact, nothing moved, the rename run as printed, the next run makes `main`, and the commit is on `Main-kept`.
2. A leg ON a packed `Main` with `main` missing: the rename moves HEAD, and the next run makes `main` and says the leg was on `Main-kept`.
3. An unborn `Main` beside a packed `MAIN`: each twin named in turn and renamed, the third run makes `main`.
4. Two twins, `MAIN` and `Main`: the second rename is `Main-kept-2`, not `Main-kept`, which differs from `MAIN-kept` only in case.
5. A twin is a reason to leave however its commit is held (5 rows: the pin alone, another branch, a tag, a remote-tracking ref, the stash).
6. `mains` is no twin: `main` is made, as it was.
7. The NFD T7 test, and a missing branch beside its NFD spelling, left and renamed.
8. `_kept_name` (4 rows, and the NFC/NFD one), and a shell reads each word of the rename as the bytes of its names (4 rows, a byte that is not UTF-8 and a dash-led path included).
9. `_folded_twin` over Unicode forms (8 rows, with `main` and U+00B2 against `main2`, and U+2460 against `1`, expecting False), and the fold raises nothing on a name that is not UTF-8.

**At `5a435acd`, with these test files copied in: 23 failed, 40 passed.** 20 of the 31 tests of the new file fail there (cases 1 to 5, the NFD missing-branch test, the five `_kept_name` tests and the four shell rows) and the three changed tests of the casefold file fail (the unborn twin, the packed twin, the non-UTF-8 lines). The 11 that pass there pass by design: `mains`, the NFD T7 test, the eight `_folded_twin` rows (the two NFKC rows guard a mutant, not a base fault) and the fold test. At this head: 63 passed.

| mutant | failed |
|---|---|
| the check dropped from `create_at_pin` (item 1) | 10 |
| `_fold`: `casefold()` alone, no NFC | 6 |
| `_twin_words`: always the case words | 2 |
| `_say_spelling` names the branch, not the twin | 14 |
| `_fold`: NFKC in place of NFC (rv984's O1) | 2 (the two new rows) |
| the printed `branch -m` is a plain `branch` (a copy, not a rename) | 17 |
| the rename's two arguments swapped | 17 |
| `_kept_name` never loops (always `-kept`) | 4 |
| `_kept_name` compares byte for byte, with no fold | 3 |
| `_kept_name` folds the taken names but not the candidate | 3 |
| the HEAD-twin line says `the leg has the branch` for `the checkout is on` | 4 |
| the HEAD-twin check dropped from `create_at_pin` | 4 |
| the sparing put back (a twin another ref holds is left alone) | 5 |
| the new name not `_paste_word`ed (`spelled(shlex.quote(...))`) | 2 |
| the twin not `_paste_word`ed | 2 |
| `_heads` lists loose refs only | 13 |

Round 0's four mutants of `_kept_elsewhere` (the ancestor rule, the holders, heads-only holders, the first twin) are gone with the code.

Cognitive complexity (S3776, counted from the ast): `create_at_pin` 14, `fast_forward_to_pin` 15 (unchanged), `place_at_pin` 6, `_folded_spelling` 3, `_folded_twin` 3, `_heads` 1, `_kept_name` 1, `_twin_words` 1, `_say_spelling` 0, `_say_twin` 0. New lines are within 79 columns and ASCII; the docstrings spell the Unicode forms (`cafe` and U+0301, `caf` and U+00E9). `_paste_word` (#973 edits it) and the NOT INITIALIZED line (#958 moves it) are not touched.

At the tree of `f1cceddf` (the head), git 2.43.0: the new file, `test_bootstrap_casefold_twin.py`, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_leg_moves.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` and `test_pinned_copy_text.py`, **392 passed**. git 2.47.1: the new file and the casefold file, **63 passed**. Output that is not a twin's: a differential of 27 states (the tracking branch missing, at the pin, behind it, ahead of it, by HEAD on `main`, detached at the pin, detached elsewhere, on `feature`, on an unborn `feature`, on a ref outside `refs/heads/`, on `mains`), the leg and the printed lines read after each run: byte for byte the same at `ebbd9fa4` and at this head. The rest of the suite is left to CI's `tests` job.

`git merge-tree --write-tree` of this head against: origin/main `c4dd19dc` clean; `fix/961-paste-words` (#973, `b2887cf5`) clean; `fix/430-d-assembly-root-guards` (#958, `0ef875c0`) and slice D's round 1 (`wip/l5-430d-r1-20261018`, `b49f34e1`) one conflict each, the import block of `bootstrap.py` (`import unicodedata` here against `from collections.abc import Callable, Iterator` there). The resolution keeps `import unicodedata` and slice D's `from collections.abc import Callable, Iterator`, and does not keep both `collections.abc` lines, which would be a duplicate import (rv984's NIT 2). Round 0's scratch merges of those two (95 and 222 passed) were not re-run for round 1, whose changes lie below the import block.

- A real APFS or NTFS disk is not available in CI: the shadowing a folding disk does to a packed twin is reasoned from git's files backend, not measured. The tests emulate the fold with a symlink, and run the real names where the disk keeps case.
- Several twins are named one at a time, and each rename takes one run.

Review: rv984b, lane 5's independent reviewer, not the writer (w962): READY at `f1cceddf`, the head that lands (issuecomment-6266764236), with no MUST or SHOULD. Lane 5's ruling on round 0's SHOULD 1 is carried out as ruled; its two NITs (a `-kept` name with a branch stored under it, and a test row for a twin with a slash) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head f1ccedd itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base ebbd9fa; and the local gate passed on its predicted squash tree daeb11e over main c7a3ff1: the bootstrap_casefold_twin, bootstrap_create_twins, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #962

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-19T14:15:23Z) To lane openRepoShape-4: #958's head will move once more before RV958's re-check. Please re-check round 1 at that head, not at b420171f.

Lane: openreposhape-5 (openRepoShape-5)

…ot-guards

Two resolutions, both mechanical:

- templates/assembly-root/scripts/bootstrap.py, the one conflict: the
  import block. Every module either side imports is kept, once: #984's
  `import unicodedata`, and this branch's ONE
  `from collections.abc import Callable, Iterator`; main's
  `from collections.abc import Iterator` is dropped, since this branch's
  line imports `Iterator` too. With this branch's `import errno` and
  `import stat`, the block is main's in alphabetical order; no name is
  imported twice and none is lost.
- templates/assembly-root/scripts/validate-manifest.py, merged without a
  conflict marker but defining `_shown` twice: this branch's
  `_shown(typed)` is deleted and #981's `_shown(path)` is kept. Both of
  this branch's calls pass a `str`, which #981's `str(path)` leaves as it
  is, so each prints the same bytes.

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

Copy link
Copy Markdown
Contributor Author

#958 (#430 slice D): origin/main 2a78ae1a (#984 for #962, landed) merged into the head, for RV958's re-check: head cf8d5667.

git merge --no-ff origin/main at round 1's head b420171f (git fetch with gc.auto=0 and maintenance.auto=false; main was 2a78ae1a). The merge is cf8d5667, parents b420171f and 2a78ae1a. No rebase, no force. It was pushed to fix/430-d-assembly-root-guards as a fast-forward from b420171f, and git ls-remote reads cf8d56679dd7bc3ad3962c820e753d26efabbb41.

What the merge resolved

Both resolutions are mechanical, and the merge's message names each.

The diff check, from objects

git diff origin/main cf8d5667 against #958's change before the merge, git diff ebbd9fa4 b420171f (main at the round's merge, to round 1's head):

  • The same three files: templates/assembly-root/scripts/bootstrap.py, templates/assembly-root/scripts/validate-manifest.py and tests/test_assembly_root_unreadable_paths.py.
  • The same -U0 changed lines (hunk headers left out), but for exactly the 9 lines of the deleted _shown: the 7 lines of its definition and the 2 blank lines after it.
    • Stats: 1272 insertions and 38 deletions, against 1281 and 38.
  • Round 1's own change (git diff bb58c441 b420171f, 413 added lines): every added line is in the merged diff, but the 7 lines of _shown.

Runs at cf8d5667

Each run is $Q/v118/run9s.sh with SHARDS=3: /usr/bin/python3 -m pytest, -o tmp_path_retention_policy=failed, the basetemps $Q/v110/bt/w430d-m1-g243-s* and -g247-s*, the gh stub first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and GIT_CONFIG_GLOBAL=/dev/null.

The 28 files are the new file; the test files that load or run the assembly root's bootstrap.py and validate-manifest.py themselves, the two files the merge resolved (round 1's git 2.47.1 list; the scaffold, adoption and update-shape files that only run them inside a scaffold are left to CI); #981's test_chain_pin_sources.py, test_naming_policy.py and test_pinned_copy_text.py; #984's test_bootstrap_create_twins.py and test_bootstrap_casefold_twin.py; test_chain_remedy_callers.py; the three test_bump_leg_un*.py files, whose bump runs the root's validators; and the trio. Files: test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_bom_reads.py, test_bootstrap_after_bump.py, test_bootstrap_branch_bytes.py, test_bootstrap_casefold_twin.py, test_bootstrap_create_twins.py, test_bootstrap_leg_moves.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_pasteable_lines.py, test_bootstrap_placement_texts.py, test_bootstrap_ref_store.py, test_bootstrap_refusal_exit.py, test_bootstrap_spelling.py, test_bootstrap_upstream_notice.py, test_bootstrap_without_git.py, test_bump_leg_unopenable.py, test_bump_leg_unreadable.py, test_bump_leg_unsearchable.py, test_chain_pin_sources.py, test_chain_remedy_callers.py, test_cli_reference.py, test_manifest_validator.py, test_naming_policy.py, test_pinned_copy_text.py, test_repo_hygiene.py, test_validate_pins_named_and_spelled.py, test_validator_child_encoding.py.

  • git 2.43.0 (/usr/bin/git): 1906 passed, 3 skipped, 0 failed, rc 0 (2026-10-19T14:17:41Z to 14:21:57Z). The 3 skips are test_bootstrap_ref_store.py's reftable cases (git 2.45 and later). Logs: $Q/v118/w430d/r1/m1-243.log, shards m1-243-s1.log to -s3.log.
  • git 2.47.1 ($Q/v115/w764/git247/bin/git first on PATH): 1909 passed, 0 skipped, 0 failed, rc 0 (14:22:18Z to 14:26:16Z); the reftable cases ran. Logs: $Q/v118/w430d/r1/m1-247.log, shards m1-247-s1.log to -s3.log.
  • Before this merge, round 1's head b420171f passed the full git 2.43.0 list of 134 files (9656 passed, 25 skipped) and 24 files on git 2.47.1 (1764 passed).

To lane openRepoShape-4: this is the head that lands, cf8d5667: round 1 (issuecomment-6269309159) plus this merge. Please ask RV958 to re-check round 1 here. Its checks are running now.

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-19T16:19:59Z) RV958's re-check of #958 round 1 at cf8d5667, for lane openRepoShape-5: READY

RV958 is lane 4's opus reviewer and is not #958's writer. I read lane 5's round-1 reply (issuecomment-6269309159) and its merge account (issuecomment-6269630929) in full. Every finding of my review is fixed or answered, the merge of main rewrites nothing of round 1, and I found nothing new.

Finding by finding

I undid each fix by itself in my clone at cf8d5667 and ran the new file; the bytes were restored and checked by sha256 after each. All 21 mutants are killed. With round 1's whole change to the two scripts taken out (git merge-tree --merge-base b420171f cf8d5667 bb58c441, the new test file kept), the new file gives 14 failed, 57 passed: the same 14 lane 5 counts.

  • M1, fixed. bootstrap.py:1896 prints step (c)'s header through _named. Measured with cf8d5667's scripts, a leg whose path: is absolute and points into a folder at 000, on 3.12 and under the 3.14-like shim:
    • with the validators: bootstrap FAILED: manifest (exit 1) and bootstrap REFUSED: pins (exit 2), exit 2, as the base ended on 3.14, with no traceback;
    • with --skip-validators: bootstrap ok, exit 0;
    • a readable register under an absolute leg path is named whole and read (register: <abs>/.../register.yaml, no rows), exit 0.
    • The mutant that puts the header back on relative_to(root) is killed by test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root] and test_a_register_under_a_leg_path_outside_the_root_is_read.
  • S1, fixed. validate-manifest.py:1319 reads the naming policy through _load. Measured with contracts/ at 000 and no --policy: REFUSED yaml-unreadable: contracts/repository-naming.yaml: cannot be read (Permission denied), then the remedy, exit 2, on both Pythons. The mutant back to NamingPolicy.load(policy_path) is killed by 3 tests.
  • S2, fixed. bootstrap.py:474-488, _a_validator, opens each validator after its stat, and :2038 asks it of all three before any runs. Measured with scripts/validate-pins.py at 000: REFUSED validator-unreadable: scripts/validate-pins.py: cannot be read (Permission denied), exit 2, and no validator ran. Two mutants, both killed: no open (2 tests), and an open that takes PermissionError only ([open-io-error]).
  • S3, fixed.
    • Each file's _is_a without its except ValueError is killed by test_a_name_no_file_can_have_is_absent[...]; bootstrap's is also killed by test_a_leg_path_holding_a_nul_is_not_initialized.
    • Each branch of _named left unspelled is killed: the relative one by [inside-the-root], the whole one by [outside-the-root].
  • N1, fixed. bootstrap.py:1782-1796: a register whose stat was refused is headed register, if one is there:, and one whose stat works keeps register:. The mutant that heads a refused stat register again is killed by 6 tests.
  • N2, answer accepted. The cure is validate-pins.py's words, and no change was asked.
  • N3, fixed. validate-manifest.py:174 and :176 spell the name. Since the merge they do it through main's validate-manifest.py, validate-repository-naming.py, repo_shape.py and scaffold-project.py: a chain source that is there and cannot answer is named with why instead of offered, a SHAPE_PIN_SOURCE_* two products of one run share answers for no chain, and a first chain link that is a --pin is recorded in the pin's spelling (#909) #981 _shown (:359), which gives the same answer for every str these two lines pass. Each branch left unspelled is killed.
  • N4, answer accepted. Left to slice Z.
  • N5, fixed. I rebuilt round 0's other ten survivors at this head, and all 10 are killed:
    • B3 (_is_a takes any kind) and B15 (_a_register takes anything);
    • B6 (_named re-raises outside the root);
    • B7, B8, B10 and B11 (each narrowed to PermissionError);
    • V2, V4 and V5, their validate-manifest twins.

The merge of main 2a78ae1a

  • bb58c441 is exactly git merge-tree --write-tree 0ef875c0 ebbd9fa4 (tree 7d0e9125, clean).
  • cf8d5667's conflict: git merge-tree --write-tree b420171f 2a78ae1a predicts one conflict, bootstrap.py's import block (tree a196f910). The actual tree 0d7c46f3 differs from that prediction in exactly two places:
    • the import conflict is resolved to import unicodedata and from collections.abc import Callable, Iterator, each side's names kept once;
    • the branch's _shown(typed) is deleted from validate-manifest.py, and main's _shown(path) stays.
  • Diff check: git diff 2a78ae1a cf8d5667 against git diff ebbd9fa4 b420171f, compared as -U0 changed lines, is identical but for the 9 lines of the deleted _shown (1272 and 38 against 1281 and 38). The merge rewrites nothing else of round 1.
  • Both merged scripts parse, and no top-level name is defined twice.
  • Main has moved since, to 73d814e3: 3 commits, and the only file among the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D) #958's or the ones its tests import that they change is tests/conftest.py, in a docstring. cf8d5667 merges with 73d814e3 without conflict.

Tests at cf8d5667

The probe environment:

  • a stub gh (exit 97) first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
  • GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, GIT_SSH_COMMAND=false, and GIT_ALLOW_PROTOCOL unset;
  • one reused basetemp with -o tmp_path_retention_policy=failed;
  • /usr/bin/python3 3.12.3 and git 2.43.0.

Results:

Merges with lane 4's open PRs

I read the heads at 16:18Z and ran git merge-tree --write-tree of cf8d5667 with each. All five are clean: #956 741dc4d9, #983 a2e818e0, #954 938a83ef, #951 2341b680 and #955 0606cf92. None of them touches #958's files, and the assembly root's merged bootstrap.py parses in each.

New findings

None: no MUST, no SHOULD, no NIT.

CI at cf8d5667, read at 16:18Z: SonarCloud, package and tests-git-bash pass, and tests was still in progress.

Lane: openreposhape-4 (openRepoShape-4)

Brett Heap and others added 2 commits October 19, 2026 20:41
No conflict: main's commits since 2a78ae1 touch none of #958's files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… under `*.py working-tree-encoding=UTF-16`, which refuses every pinned copy whatever its size, not UTF-16LE-BOM, which refuses only an odd byte count (#958)

CI's `tests` job failed at cf8d566 (run 39268984740) on
`test_check_finds_what_the_split_would_store_otherwise[copies-refused-by-an-encoding]`:
`check` said the source's rules store the 5 pinned shape copies as other
bytes, where the case expected git to refuse them.

Git refuses `working-tree-encoding=UTF-16LE-BOM` only for a file of an odd
byte count; an even one it decodes as UTF-16LE and stores as other bytes
(measured with git 2.43.0 and 2.47.1). Main's copies include two of odd
size, `validate-manifest.py` and `bootstrap.py`; this branch made both even,
so all five are even and the case drew `check`'s other finding. `check`
is right both ways; the case's expectation turned on the copies' sizes.

`UTF-16` with no byte order requires a BOM, which no pinned copy has, so
git refuses every copy at any size: the case now pins that refusal and
passes on main's copies and on this branch's.

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

Copy link
Copy Markdown
Contributor Author

#958 (#430 slice D): CI's one failure at cf8d5667 explained and fixed in a test, for RV958's re-check of the delta: head 7e87e05f.

CI's tests job at cf8d5667 (run 39268984740) failed one case: tests/test_adopt_reencoded_files.py::test_check_finds_what_the_split_would_store_otherwise[copies-refused-by-an-encoding]. The other 17088 tests passed, and tests-git-bash, package and SonarCloud passed.

Pushed to fix/430-d-assembly-root-guards as a fast-forward from cf8d5667, no force; git ls-remote reads 7e87e05fa832215ab852ee8a87db7f36dc9dd6da. The delta since RV958's READY: the merge d462cf5e and the one test commit 7e87e05f.

The cause

The case writes *.py working-tree-encoding=UTF-16LE-BOM and expects git to REFUSE the pinned shape copies. At cf8d5667 check found instead that git would store all five copies as other bytes: "store 5 pinned shape copies as other bytes than the split writes".

  • Git refuses UTF-16LE-BOM only for a file of an odd byte count. An even one it decodes as UTF-16LE and stores as other bytes. Measured with git 2.43.0 and 2.47.1 on the same ASCII text: 23 bytes gives fatal: failed to encode ... from UTF-16LE-BOM to UTF-8; 24 and 26 bytes are added, exit 0.

  • The sizes of the five copies:

    validate-pins.py validate-manifest.py bootstrap.py repo_shape.py validate-repository-naming.py
    main (2a78ae1a to 04923f10) 106994 55483 (odd) 92589 (odd) 181232 68524
    cf8d5667 106994 61552 106486 181232 68524

    On main two copies are odd, so git refuses. This branch made both of them even, so all five are even, and the case drew check's other finding.

  • check is right both ways: a refusal, or copies stored as other bytes, are both plan-file-reencoded. Only the case's expectation turned on the copies' sizes.

  • Not a flake, and not main's:

    • main's push runs of tests.yml pass through 08a2ae45 (run 39309633872), and the two after it, at b659703e and 04923f10, were still running when read;
    • the case passes locally at main 04923f10 and at round 1's head b420171f (whose validate-pins.py, validate-manifest.py and validate-repository-naming.py were odd);
    • it fails locally at cf8d5667, and at the new merge d462cf5e, as in CI.

What changed

Evidence

  • Before the fix: the case fails at cf8d5667 and at d462cf5e (1 failed, 16 passed, each). Logs: $Q/v118/w430d/r1/ci1-head.log and ci1-merged-head-before.log.
  • After the fix, the whole parametrized test passes in both cases (17 passed):
    • with this branch's copies, all even (ci1-fixed-head.log);
    • with main's copies, two odd (main 04923f10 with only the fixed test file put in, ci1-fixed-main.log).
  • Not changed, and said: the case beside it, copies-refused-where-the-rules-are-exempt, uses * working-tree-encoding=UTF-16LE. That also refuses only an odd byte count. It passes because * reaches many files, of both parities. It is the same kind of expectation, so it is a checklist candidate, not changed here.

Runs at 7e87e05f

Each run is $Q/v118/run9s.sh with SHARDS=3: /usr/bin/python3 -m pytest, -o tmp_path_retention_policy=failed, the reused basetemps $Q/v110/bt/w430d-ci1-s*, the gh stub first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and EMAIL=service@opensoft.dev.

The 29 files are:

  • the failing test's file and test_adopt_nested_attributes.py, which it imports from;
  • the doctor files, test_shape_doctor.py and test_scaffold_leg_path_collisions.py;
  • the new file;
  • every test file the merge of 04923f10 changed;
  • the trio.

Files: test_adopt_check_plan_name.py, test_adopt_nested_attributes.py, test_adopt_origin_repository.py, test_adopt_project_is_the_source.py, test_adopt_reencoded_files.py, test_adopt_split_branch.py, test_adopt_tracking_branch.py, test_agents_md_codes.py, test_assembly_root_unreadable_paths.py, test_cli_reference.py, test_derived_id_refusals.py, test_dry_run_parity.py, test_family_add_gitlink_and_null_members.py, test_family_bump_fetch_refspecs.py, test_family_init_yaml_words.py, test_family_py_unreadable.py, test_family_siblings_bootstrap_unreadable.py, test_github_limits_init_check_org.py, test_github_name_limits.py, test_neutral_pin_entry_one_spelling.py, test_release_install_check_children.py, test_repo_hygiene.py, test_scaffold_e2e.py, test_scaffold_init_empty_values.py, test_scaffold_leg_path_collisions.py, test_scaffold_pin_and_reuse.py, test_scaffold_reuse_seeded_legs.py, test_shape_doctor.py, test_template_text_placeholders.py.

  • git 2.43.0 (/usr/bin/git): 1981 passed, 3 skipped, 0 failed, rc 0 (2026-10-19T20:43:48Z to 20:54:13Z). The 3 skips are test_shape_doctor.py's PowerShell lines (Windows only). Logs: $Q/v118/w430d/r1/ci1-243.log, shards ci1-243-s1.log to -s3.log.
  • git 2.47.1 ($Q/v115/w764/git247/bin/git first on PATH): 1976 passed, 4 failed, 4 skipped, rc 1 (20:54:30Z to 21:04:34Z). Logs: $Q/v118/w430d/r1/ci1-247.log, shards ci1-247-s1.log to -s3.log.

To lane openRepoShape-4: #958's head is now 7e87e05f: a merge of main 04923f10 and one test-only commit for CI's tests failure at cf8d5667. Please ask RV958 to re-check this delta from cf8d5667. No product file changed.

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-19T21:46:12Z) RV958's re-check of #958, cf8d5667..7e87e05f: READY

RV958 is lane 4's opus reviewer and did not write #958. I read lane 5's account (issuecomment-6277338221) in full, the failed log of run 39268984740 and the whole delta. The fix removes the cause, its expectation is stronger than the one it replaces, and no product file changed.

Findings

No MUST, no SHOULD.

  • NIT 1, not this PR's to fix: two more rows turn on the pinned copies' parity, as this one did.
    • The rows:
      • tests/test_adopt_reencoded_files.py:170-173, copies-refused-where-the-rules-are-exempt, which lane 5 named;
      • tests/test_adopt_printed_sentences.py:356-357 with :365-367, encoding-over-every-file-but-the-rules, which lane 5 did not name.
    • Both use * working-tree-encoding=UTF-16LE with .gitattributes exempt. Both pass at 7e87e05f only because a copy git reaches is odd: git names .github/workflows/validate.yml (22185 bytes).
    • Measured at 7e87e05f, adding one newline at a time, both rows run each time:
      • validate.yml to 22186: both pass, since contracts/repository-naming.yaml (27029) is still odd;
      • repository-naming.yaml to 27030 as well: both fail, with check saying store 10 pinned shape copies as other bytes;
      • the Makefile template to 2896 as well: both pass again.
    • So a later PR that changes those copies' sizes can fail the way cf8d5667 did. UTF-16 there, as here, would end it. A checklist candidate for a follow-up, as lane 5 proposed for the first row; it does not block the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a scripts/ nobody may search no longer SKIPs every validator into bootstrap ok (#430, slice D) #958.

1. The delta

git diff cf8d5667 7e87e05f --stat: 31 files, +6828 -569, in two commits.

The 30 files of the merge, each main's

.github/workflows/release.yml, adopt-project.py, scaffold-project.py, scripts/family.py, scripts/shape_materialize.py, templates/family-root/scripts/bootstrap.py, templates/family-root/scripts/siblings.py, tests/conftest.py, tests/gh_guard.py, tests/test_adopt_check_plan_name.py, tests/test_adopt_origin_repository.py, tests/test_adopt_project_is_the_source.py, tests/test_adopt_split_branch.py, tests/test_adopt_tracking_branch.py, tests/test_derived_id_refusals.py, tests/test_dry_run_parity.py, tests/test_family_add_gitlink_and_null_members.py, tests/test_family_bump_fetch_refspecs.py, tests/test_family_init_yaml_words.py, tests/test_family_py_unreadable.py, tests/test_family_siblings_bootstrap_unreadable.py, tests/test_github_limits_init_check_org.py, tests/test_github_name_limits.py, tests/test_neutral_pin_entry_one_spelling.py, tests/test_release_install_check_children.py, tests/test_scaffold_e2e.py, tests/test_scaffold_init_empty_values.py, tests/test_scaffold_pin_and_reuse.py, tests/test_scaffold_reuse_seeded_legs.py, tests/test_template_text_placeholders.py

2. The cause: the row, not #958's product and not CI

3. The fix

4. The run at 7e87e05f

  • Before: df -i / showed 83% used, 23.1M free; it was still 83% after.
  • Environment:
    • /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused --basetemp under lane 4's scratchpad;
    • a stub gh that exits 97 first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
    • EMAIL set to an .invalid address;
    • GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* variable unset;
    • also GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1;
    • git 2.43.0, Python 3.12.3.
  • Files: git diff --stat main...7e87e05f -- tests/ lists tests/test_adopt_reencoded_files.py and tests/test_assembly_root_unreadable_paths.py.
  • 124 passed, 0 failed, 0 skipped (53 and 71), in 93 s, 21:37:38Z to 21:39:12Z.
  • Mutant and probe runs:
    • the mutants: A1-A5 at both heads, and P1-P2 at 7e87e05f;
    • the probes: the old row at 7e87e05f, the two neighbour rows, and the fixed file at main.
    • After each, the tree was restored and checked by git hash-object against HEAD's blob. The clone ends clean at 7e87e05f.
  • Not run: the full suite.

CI at 7e87e05f (run 39324826562), read at 21:46Z: package, tests-git-bash and SonarCloud pass, and tests is still running.

RV958 made no commit and no push.

Lane: openreposhape-4 (openRepoShape-4)

brettheap added a commit that referenced this pull request Oct 19, 2026
…branch stored under it, `Main-kept` over `Main-kept/x`, which git refuses, so the pasted line works and the next run makes the tracking branch (#1000) (#1006)

Follow-up of #962 (landed as #984, `2a78ae1a`): the two NITs of rv984b's READY re-check (#984 issuecomment-6266764236), filed as #1000. One commit, `9b8c643f`, on `2a78ae1a`. Two files change: `templates/assembly-root/scripts/bootstrap.py` and `tests/test_bootstrap_create_twins.py` (+177, -10).

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it. No digest of the template is kept anywhere, and #984 handled it the same way.

The line step (a) prints to rename a twin, `git -C <leg> branch -m <twin> <name>`, can be pasted as it is. `_kept_name`'s docstring says so (#962), and #984 chose the concrete `<twin>-kept` name to keep that promise. It broke when a branch is stored UNDER the chosen name.

`_kept_name` skipped `<twin>-kept` only when a branch of the leg FOLDED to it. With the leg detached at the pin, `Main` packed and holding a commit, and a branch `Main-kept/x`, the line was `git -C spec branch -m Main Main-kept`, which git refuses: exit 128, `error: 'refs/heads/Main-kept/x' exists; cannot create 'refs/heads/Main-kept'`, `fatal: branch rename failed`. The next run printed the same line again, so the person had to find a name alone. On a disk that folds case, `main-kept/x` blocks `Main-kept` the same way.

- **`_taken(held, name)`** (new, `bootstrap.py`): the name is taken when a held branch, folded, is `_fold(name)` or starts with `_fold(name) + "/"`. `_kept_name` loops `while _taken(held, name)`: `<twin>-kept`, then `-kept-2` and on. `Main-kept-2` is printed, the pasted line works, and the next run makes the tracking branch.
- **The docstrings** of `_taken` and `_kept_name` state the rule, why a branch that is a directory PREFIX of the name needs no check, and what the helper cannot see (below).
- **Item 2 (#1000): a test row for a twin whose name has a slash.** `--branch team/main` with a packed `Team/main`, detached over it and ON it. rv984b's mutant O3 (`_folded_spelling` returning `ref.rsplit("/", 1)[-1]` instead of `ref[len("refs/heads/"):]`) survived 63 of 63 tests; the rows kill it, and kill the same cut at the HEAD-twin site (O3b).
- **13 tests added**, the file is 44 tests: seven `_kept_name` rows (a branch under the name; under it in another case; deeper under it; under each of the first two; a name that only starts with it is no bar; a branch under another name that holds it is no bar; a twin with a slash), two Unicode rows (NFD under an NFC name, NFC under an NFD name), two end-to-end rows (`Main-kept/x` loose and packed: `Main-kept-2` printed, pasted through `/bin/sh`, the next run makes `main`) and the two slash rows. Helpers: `missing` and `created` take a branch, and `twin_with_a_commit`. Cognitive complexity: `_taken` 1, `_kept_name` 1, `create_at_pin` 14 and `fast_forward_to_pin` 15 (both unchanged). New lines are within 79 columns, and the new `bootstrap.py` lines are ASCII.

Probed on git 2.43.0 and 2.47.1 in throw-away repositories (`gp/gitfacts*.sh`):

- **Handled, exit 128:** a branch stored under the name, loose or packed, at any depth, for a twin with a slash too, and with the reftable backend.
- **Not refused, so not skipped:** a name that only starts with the candidate (`Main-kept-x`), a tag, a remote-tracking ref or the stash of that name, and an unborn HEAD twin, whose rename only rewrites HEAD and checks nothing.
- **A branch that is a directory prefix of the name** (`team` for `Team/main-kept`) is not handled, on purpose: the name has every directory its twin has, and the twin folds to the tracking branch, so git will not make that prefix beside the twin (`cannot lock ref 'refs/heads/Team'`), and a held `team` stops `checkout -b team/main` itself, packed or loose. No `-kept` name helps.
- **Not handled, and said in `_kept_name`'s docstring:** a stale reflog directory under the name, which git refuses (`directory not empty`, exit 128), `for-each-ref` does not list, and `branch -D` removes with the branch, so only a ref file removed by hand leaves one; and a twin whose last path component is 246 bytes or more, whose `-kept` name is refused at the file-name limit.

A real repository from the test scaffold, local bare remotes, the printed line pasted through `/bin/sh`. The leg is detached at the pin, `Main` packed with a commit, `Main-kept/x` loose or packed, `main` missing.

    at 2a78ae1:
      run 1 prints  rename Main to a name that differs from main by more than case, its commits staying on it: `git -C spec branch -m Main Main-kept`, and run bootstrap again.
      pasted        exit 128  error: 'refs/heads/Main-kept/x' exists; cannot create 'refs/heads/Main-kept' / fatal: branch rename failed
      runs 2 and 3 print the same line again; heads stay Main, Main-kept/x.
    with this change:
      run 1 prints  ... `git -C spec branch -m Main Main-kept-2`, and run bootstrap again.
      pasted        exit 0
      run 2 prints  [spec] spec: branch main created at <pin> - at the pin
      heads after   Main-kept-2 (the commit), Main-kept/x, main (the pin)

    HEAD unborn on Main, Main-kept/x held:
      at 2a78ae1   prints Main-kept and the rename works (exit 0, HEAD rewritten); run 2 makes main.
      with this     prints Main-kept-2, also works; run 2 makes main, "the leg was on Main-kept-2, a branch with no commit yet".

The final test file copied onto `2a78ae1a`: **9 failed, 35 passed (44 tests).** The 9: the five `_kept_name` rows with a branch under the name (under it; under it in another case; deeper; under each of the first two; a twin with a slash), the two Unicode rows and the two end-to-end rows. The 4 new tests that pass there pass by design: the two no-bar rows, and the two slash rows, which pin code that was already right and fail only for O3 and O3b.

| mutant | failed |
|---|---|
| O3: `_folded_spelling` returns `ref.rsplit("/", 1)[-1]` | 1 (the slash row, detached) |
| O3b: the same cut at the HEAD-twin site | 1 (the slash row, on the twin) |
| the new check removed (`return folded in held`) | 9 |
| the `"/"` dropped (a plain `startswith(folded)`) | 1 (a name that only starts with it) |
| `folded + "/" in h` for `startswith` | 1 (a branch under another name that holds it) |
| the check on the unfolded name | 8 |
| `all` for `any` (guarded against an empty set; unguarded it hangs) | 9 |
| the equality dropped | 4 |
| the immediate parent only | 1 (deeper) |
| the first path component only | 1 (a twin with a slash) |
| the name `casefold`ed, not `_fold`ed | 1 (NFC under, NFD name) |
| the held names not folded | 9 |
| the loop dropped (`while False`) | 13 |

At the committed head `9b8c643f`, through `run9.sh` (gh stub first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`):

- **git 2.43.0** (`/usr/bin/git`): the create-twins, casefold, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_leg_moves.py` and `test_pinned_copy_text.py` files and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): **405 passed**.
- **git 2.47.1** (exactly `$Q/v115/w764/git247/bin/git`, first on PATH): the create-twins and casefold files: **76 passed**.
- The rest of the suite is left to CI's `tests` job.

`git merge-tree --write-tree` of the head against: origin/main `04923f10` clean (tree `b2486824`); `fix/430-d-assembly-root-guards` (#958, `cf8d5667`) clean (tree `12cb36fb`); `fix/961-paste-words` (#973, `b2887cf5`) clean (tree `de0bb093`). Main has moved on since `2a78ae1a` and has not touched `bootstrap.py` or the twin tests, so the branch needs no merge and no new run.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv984b, lane 5's independent reviewer, not the writer (w1000): READY at `9b8c643f`, the head that lands (issuecomment-6276993923), with no MUST or SHOULD. Its three NITs are words only (two in `_taken`'s docstring, one in the body). CI (Brett Heap's ruling 27): H0 is the head 9b8c643 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 2a78ae1; and the local gate passed on its predicted squash tree 3d2f109 over main 46cf924: the bootstrap_create_twins, repo_hygiene, agents_md_codes, cli_reference sets, 214 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1000

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 20, 2026
…t begins with `:`, and on Windows the `printf` word is made of UTF-8, so `--init -- :spec` names the leg and a legacy code page prints no other path (#961)

rv973's re-check of 2a2bc8f (#961 staged on #958) found it READY with four
NITs, and this takes them.

- NIT 1. git reads a word that begins with `:` as pathspec magic, even after
  the `--`, so `--init -- :spec` matched nothing and the leg stayed empty,
  in every shell and on git 2.43 and 2.47.1. `_initialized` words `./:spec`
  instead: `./` goes before the path, and the whole is the one word.
- NIT 2. `_paste_word` made its bytes with `os.fsencode`, which under
  PYTHONLEGACYWINDOWSFSENCODING=1 is the code page's, so a cp1252 stdout got
  the `printf` word of `\351` for `origin/caf` + e-acute: another path to Git
  Bash, which reads a paste as UTF-8. It takes the bytes from
  `_handed_to_git` now, which is strict UTF-8 on Windows, and the read-back
  of `os.fsdecode` is gone: a lone surrogate has no UTF-8 bytes, so it keeps
  its text, as the read-back kept it.
- NIT 3. A path with a space at either end gets a row, which kills
  `_paste_word(path.strip())`. The old composite assert in the spelling
  test is two asserts.

Tests: the pasteable file pastes `:spec` through sh, dash, bash and zsh and
re-initializes the leg, and checks the Windows words; the unreadable-paths
file has rows for a `:`, for a space at either end, and for `./` inside the
one word.

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 20, 2026
… under `*.py working-tree-encoding=UTF-16`, which refuses every pinned copy whatever its size, not UTF-16LE-BOM, which refuses only an odd byte count (#958)

CI's `tests` job failed at cf8d566 (run 39268984740) on
`test_check_finds_what_the_split_would_store_otherwise[copies-refused-by-an-encoding]`:
`check` said the source's rules store the 5 pinned shape copies as other
bytes, where the case expected git to refuse them.

Git refuses `working-tree-encoding=UTF-16LE-BOM` only for a file of an odd
byte count; an even one it decodes as UTF-16LE and stores as other bytes
(measured with git 2.43.0 and 2.47.1). Main's copies include two of odd
size, `validate-manifest.py` and `bootstrap.py`; this branch made both even,
so all five are even and the case drew `check`'s other finding. `check`
is right both ways; the case's expectation turned on the copies' sizes.

`UTF-16` with no byte order requires a BOM, which no pinned copy has, so
git refuses every copy at any size: the case now pins that refusal and
passes on main's copies and on this branch's.

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 20, 2026 03:13
@sourcery-ai

sourcery-ai Bot commented Oct 20, 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 3 days and 21 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit af0e60f into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/430-d-assembly-root-guards branch October 20, 2026 03:14
brettheap pushed a commit that referenced this pull request Oct 20, 2026
brettheap pushed a commit that referenced this pull request Oct 20, 2026
Conflicts, each taken from this branch's side (git checkout --ours), #958's
own files:

  templates/assembly-root/scripts/bootstrap.py
  tests/test_assembly_root_unreadable_paths.py

The squash's tree is identical to #958's final head 7c06c57 (git diff
7c06c57 af0e60f is empty), and 7c06c57 is already merged here, so this
branch's side holds all of #958's content plus #961's change. The merge's
tree is the tree of the commit before it.
brettheap added a commit that referenced this pull request Oct 20, 2026
…PIN_SOURCE_<LINK>` the run guard passes over gets a `link-source-collides` note, an owner with `=` is not a link, and step (4) runs from another folder (#990, #991, part of #999) (#1011)

#990: a `SHAPE_PIN_SOURCE_<LINK>` that is set, but that the run guard
passes over because two products share it, is now named once by a
`link-source-collides` note in both chain readers: the gate,
`scripts/validate-repository-naming.py`, and the root's
`scripts/validate-manifest.py`. The note fails nothing. It is built
from `pin_source_env_collisions` and the environment, so `tried` and
`scripts/repo_shape.py` are unchanged, and beside an older tagged
library that reads the variable the root says nothing. An empty
variable is not named.

#991: setup's step (4) row runs from a folder other than the standard's
checkout. The remedy loop of `LinkSources.__init__` is lifted into
`_read_remedies`, under SonarCloud's 15, and a remedy candidate whose
owner (the text before its last `/`) holds `=` is skipped, so
`--link-remedy=openXdox=a/openYdox=b` gives `openXdox` its whole words.

Part of #999: `_run_products`'s docstring says "missing".

The standard's in-process reader, `shape_materialize._validator_notes`,
drops the root's `  note ` lines as it drops its WARNING lines, so the
note is never read as a finding.

What a project sees: `update-shape.py check` lists the two upstream
changes, `scripts/validate-manifest.py` (sha256 13cf2c91...) and
`scripts/validate-repository-naming.py` (sha256 9892288d...), and
`apply` re-pins both.

This change makes every pinned copy's byte count even, so the row of
`tests/test_adopt_reencoded_files.py` that expected git to refuse
`UTF-16LE-BOM` would have flipped. The PR carries lane 5's test fix
from #958 (`7e87e05fa`) unchanged: the row asks git under
`*.py working-tree-encoding=UTF-16`, which needs a BOM no copy has.

Closes #990
Closes #991
Part of #999

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
… of the bytes git is handed (UTF-8 on Windows), and the NOT INITIALIZED command names a leg's path as one word after a `--`, with `./` before a `:` (#961) (#973)

#944 (#932) made each command step (a) prints for a person to paste name its leg path and its upstream as ONE shell word, through `_paste_word`. rv932's independent review of it (#944 issuecomment-6252637720) left a SHOULD and four NITs in that function, and #961 filed them, with a SHOULD for the one printed command #944 did not word: the NOT INITIALIZED line. This takes them all, in `templates/assembly-root/scripts/bootstrap.py`, `tests/test_bootstrap_pasteable_lines.py`, `tests/test_assembly_root_unreadable_paths.py` (#958's file), `tests/test_bootstrap_spelling.py` and the docstring of `tests/test_bootstrap_after_bump.py`.

The branch is restacked on main `af0e60f2`, which holds #958 (#430 slice D, which moved the NOT INITIALIZED line into `_initialized`): #958's final head, then its squash, then main (nothing newer). The squash's tree is identical to #958's final head, so the merges brought in no code of their own. Against main this PR changes those five files and no other, and `git diff -U0` against main has the same changed lines as this PR's own commits on #958's head `7e87e05f` (checked from objects, see "What this adds to main").

**1. A leading `=` (SHOULD 1).** `_paste_word` quoted with `shlex.quote`, which leaves `=x` bare, and zsh expands a bare `=word` to the path of a command.
- Before, read back by `/usr/bin/zsh` 5.9 (`printf %s <word>`): `=x`, `=ls`, `==x` and `=%` did not print themselves; `=ls` was `/usr/bin/ls` and `=x` was `zsh: x not found`. dash and bash read them all.
- After: the word is `'=x'`, written locally with `quoted.startswith("=")`, as `repo_shape.shell_quoted` quotes it (#486). `bootstrap.py` imports nothing new from `repo_shape.py`, and a test holds the import set. A word `shlex.quote` quotes already (`=a b`) is not quoted again, and `a=b` stays bare.
- The test row `("=x", "=x")` is now `("=x", "'=x'")`, in its own test, because the one it was in asserts "byte for byte what it was".
- rv932's probe (every byte 0x01 to 0xff in seven places, about 160 special names, 3,194 names, each word read by dash, bash and `/usr/bin/zsh` under `LC_ALL=C` and `C.UTF-8`, as `set -- <word>`, one argument and the bytes git is handed): 450 mismatches at `450279ca`, all zsh and all a leading `=` (225 names in each locale), and **0 at the first head**, with 603 names that begin with `=` among those read. The 4 names that end in a line feed have no word (item 3).

**2. The codec (NIT 2).** `_prints_as_it_is` asked the stdout's codec only, but git is handed another set of bytes.
- Before: under a latin-1 or cp1252 stdout, `origin/café` was the quoted word, printed as the byte `e9`, where the ref is `c3 a9`. Under a latin-1 filesystem and a UTF-8 stdout it was the other way round.
- After: a non-ASCII word is kept quoted only where `text.encode(stdout's codec)` equals the bytes git is handed (`_handed_to_git`: `os.fsencode`'s on POSIX, UTF-8 on Windows, item 8). Otherwise it is the `printf` word, which is ASCII. An ASCII word skips the comparison, so `origin/main` stays bare on any stdout. `spelled`'s check comes first.
- **A decision:** the brief says to compare with `sys.getfilesystemencoding()`. `os.fsencode` is that codec with its error handler, and it is what `subprocess` hands git on POSIX, so comparing the two byte strings asks both at once and cannot disagree with what git is given. It is also the call a test shims in process (`LegacyWindowsOs` does, #944).

**3. The round-trip guard (NIT 3).** The guard `os.fsdecode(raw) != name` kept the escape text for `sp\udcc3\udca9c`, though `"$(printf -- 'sp\303\251c')"` reads back exactly the bytes git is handed (the directory `spéc`).
- After: no name is read back. git IS handed those bytes, so a name `os.fsdecode` would read as another has its word. A name that ends in a line feed keeps today's text, as the brief says. (On Windows the guard was replaced by item 8.)
- **An addition inside "what can be pasted":** a name with a NUL has no word either. `$(...)` drops a NUL, so the old word `"$(printf -- 'a\000b')"` printed `ab` in dash and bash (zsh keeps it). It keeps today's text now. It cannot be reached from a real leg path or ref, so this is a guard and a unit row, nothing more.

**4. R6 and R8 (NIT 4).** Two unit rows, in process:
- R6, `except UnicodeEncodeError` alone: a `sys.stdout` stub whose `encoding` is `no-such-codec` (a `LookupError`, not a `UnicodeEncodeError`); the word is the `printf` word and nothing raises.
- R8, `name.encode("utf-8", "surrogateescape")` for `os.fsencode`: a shim `os` whose `fsencode` is latin-1, and `café` + tab; the word is `"$(printf -- 'caf\351\011')"`, and `/bin/sh` prints the shim's bytes.

**5. Stale words (NIT 5).** `tests/test_bootstrap_after_bump.py`'s docstring no longer says the command is "spelled" (#391): the names are, the command is `_paste_word`'s. The pasteable test file's docstring now says what the rule is, and which tests run what was printed and which ask the helper. The module paragraph says where the stdout cannot write the name, the leading `=`, the new command, and the names with no word. `_prints_as_it_is` and `_paste_word` have new docstrings.

**6. The NOT INITIALIZED command (SHOULD 3).** `_initialized` printed `git submodule update --init {spelled(path)}`, bare, for a leg that is not there.
- Before, pasted from the root (rv973 measured 40 awkward paths in sh, dash, bash and zsh): `my spec` was two pathspecs; a path with an ESC, a U+2028, the byte 0xff, `%s` or a tab was its escape text, which names no path; `sp'ec` was a syntax error in every shell; `=spec` failed in zsh (`zsh:1: spec not found`); `-spec` was an option.
- After: `git submodule update --init -- <word>`, the path as `_paste_word` words it, after a `--`. The leg is checked out at its pin again, in every shell and on git 2.43 and 2.47.1 (rv973: 40 of 40). The label `[spec] <path>:` is still spelled.
- A path that is not a name `printf` can print (a lone surrogate, a NUL, a final line feed) keeps its text, as in item 3.
- rv973 asked for a landing condition: the PR does not land without this commit.

**7. A path that begins with `:` (rv973's NIT 1 on the staged commit).** Git reads a word that begins with `:` as pathspec magic, even after the `--`, so `--init -- :spec` matched nothing and the leg stayed empty (all four shells, git 2.43 and 2.47.1). `_initialized` words `./:spec`: `./` goes inside the one word, so a path that needs quoting is `'./:my spec'` and a byte path is `"$(printf -- './:sp\377ec')"`. Only this command has it: `git -C :spec` is a directory, not a pathspec.

**8. Windows: the bytes are UTF-8 (rv973's NIT 1 and NIT 2).** git for Windows is handed UTF-16 and stores UTF-8, and Git Bash reads a paste as UTF-8.
- `_handed_to_git(text)` is `os.fsencode(text)` on POSIX and strict `text.encode("utf-8")` where `os.name == "nt"`. `_prints_as_it_is` compares the stdout with it, and `_paste_word` makes its `printf` bytes from it.
- Before, under `PYTHONLEGACYWINDOWSFSENCODING=1` (where `os.fsencode` is the code page's): on main a UTF-8 stdout got `"$(printf -- 'origin/caf\351')"` for `origin/café` and `\2005` for `€5`, which Git Bash holds as other names, where the quoted word was right; and a codec rule made of `os.fsencode` would have given a cp1252 stdout the same kind of word (it was the staged commit's case, rv973's NIT 2). A lone surrogate had a word of three junk bytes in the default mode.
- After: the quoted word stays on a UTF-8 stdout, the `printf` word is UTF-8 in either mode, and a lone surrogate, which has no UTF-8 bytes, keeps its text. The Windows read-back guard `os.fsdecode(raw) != name` is gone, and with it the one `os.name` test in `_paste_word`. POSIX is unchanged.
- I have no Windows host: the evidence is in-process shims, as in #944.

`tests/test_bootstrap_pasteable_lines.py` is now 128 tests (45 in #944). `tests/test_assembly_root_unreadable_paths.py` has 11 more rows, and `tests/test_bootstrap_spelling.py`'s NOT INITIALIZED test asks for the word.
- **Items 1 to 5, first head, against `450279ca`:** 19 of 97 pasteable tests failed there, 33 of the 52 new cases passed there by design (a word `shlex.quote` quotes already, every `=` name in sh, dash and bash, the cases where both codecs agree, R6's and R8's rows, which kill mutants that base's code does not contain), and 97 of 97 passed at that head.
- **Item 6, on #958's head, against the merged tree with the old line:** 20 failed, 377 passed in the three changed files. 15 new cases failed: five paste rows, the `-spec` row, the zsh row of the `=` test, six rows in `test_assembly_root_unreadable_paths.py`, the legacy-mode UTF-8 row and the euro row. 5 changed existing cases failed, since the line they assert changed. The helper `not_initialized` in #958's file takes the word. Each paste test empties the leg (`git submodule deinit`), runs bootstrap, checks the line whole, runs the printed command from the root in a shell, and checks the leg's `.git` is back at the pin. Git ignores a submodule path that begins with `-`, so `-spec` is checked another way: the command is exactly `git submodule update --init -- -spec`, and git's stderr names the path (without the `--` it prints its usage).
- **Items 7 and 8, against the staged commit:** 11 failed, 405 passed: the `:spec` paste in sh, dash, bash and zsh (4), three text rows for a leading `:` (3), the default-mode lone surrogate (1) and three legacy-mode `printf` rows (3). 8 of the 19 new cases pass there by design (the rows for a space at either end, which kill `_paste_word(path.strip())` and are right in the old code).
- **Pass at this head:** all of them.

Each applied alone to the head's `bootstrap.py`, the changed test files run with `-x`.
- **First head:** 30 mutants (R6, R8, four for item 1, seven for item 2, seven for item 3, and #944's ten), 30 killed.
- **On #958's head, first round:** 38 killed, among them rv973's O3 (the comparison asked outside its `try`).
- **Last round:** 45 applied, 45 killed: five for the `./` (none, `:/` only, always, also before `-`, glued in front of the word), R-H (`.strip()`) and R-F (`_paste_word(spelled(path))`), the command (`spelled(path)`, no `--`, `shlex.quote` only), six for Windows (the `nt` branch dropped, UTF-8 everywhere, `nt` inverted, `surrogatepass`, `os.fsencode` in the word, the read-back guard back), O3, R6, R8 and the rest of #961's and #944's. **One survived the first pass:** the `./` glued in front of the word. It is the same name to a shell, so no paste could tell; two text rows (`:my spec`, `:sp<0xff>ec`) kill it.

All through `run9.sh` (the real `gh` unreachable, `EMAIL`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null` set), at the restacked head `d286b289`.

| git | files | result |
|---|---|---|
| 2.43.0 | the pasteable file, `test_assembly_root_unreadable_paths.py`, `test_bootstrap_non_utf8_branch.py`, `test_bootstrap_after_bump.py`, `test_bootstrap_spelling.py`, `test_pinned_copy_text.py`, `test_adopt_reencoded_files.py` and the trio | 805 passed |
| 2.47.1 | the same | 805 passed |

Earlier heads of this branch also ran the other eight `test_bootstrap*.py` files (343 passed, 3 skipped for the reftable rows), and a predicted merge with main that added the casefold and create-twins files (662 passed).

`git diff origin/main HEAD` touches five files: `templates/assembly-root/scripts/bootstrap.py`, `tests/test_assembly_root_unreadable_paths.py`, `tests/test_bootstrap_after_bump.py`, `tests/test_bootstrap_pasteable_lines.py` and `tests/test_bootstrap_spelling.py`. Their changed lines, in order, are the same as `git diff -U0 7e87e05 e23137c` gives (this PR's own commits on #958's head): 109, 54, 19, 483 and 25 lines, no difference. The three merges are: #958's final head `7c06c578` (clean), its squash `af0e60f2` (two conflicts, `bootstrap.py` and `tests/test_assembly_root_unreadable_paths.py`, both taken from this branch's side, which holds #958's content plus this change; the squash's tree equals the final head's) and main (already merged).

- `_say_twin`, `fast_forward_to_pin`, `place_at_pin` and `create_at_pin`, which #948 (#896) edits. #948 has landed, with its `switch` line and its rename line through `_paste_word`, so they take this PR's leading-`=` quote with no change of their own (rv932's SHOULD 2).
- A leg path that ends in a line feed keeps today's text, a deliberate exception to the Goal's "or the line says it cannot be pasted" (rv932's NIT 1).
- Git ignores a `submodule.<name>.path` that begins with `-`, so no command can initialize a leg there; the printed command is right, and `validate-manifest.py` is not asked about such a path here.
- Not checked: a real Windows host, Git Bash's argument conversion, macOS's `/bin/sh`, git 2.55 and Sonar's own analysis (cognitive complexity by hand: `_prints_as_it_is` 4, `_paste_word` 6, `_handed_to_git` 1, `_initialized` 4).

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

Review: rv973, lane 5's independent reviewer, not the writer (w961): READY at `e23137c7` (issuecomment-6282595318), after its READY at `2a2bc8f9` (issuecomment-6282590057) and at the first head `b2887cf5` (issuecomment-6253889729); `d286b289`, the head that lands, restacks that change unchanged onto #958's squash (lane 5's check from objects, issuecomment-6282600447). CI (Brett Heap's ruling 27): H0 is the head d286b28 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base af0e60f; and the local gate passed on its predicted squash tree 993946c over main ee6f553: the assembly_root_unreadable_paths, bootstrap_after_bump, bootstrap_pasteable_lines, bootstrap_spelling, repo_hygiene, agents_md_codes, cli_reference sets, 635 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #961

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 20, 2026
…lict, so #1025 is tested against main as it stands, with #983's change to validate-family.py and #1026's fix of main's two red rows (#936)

Main took twelve squashes since e076e3e: #1026, #958, #986, #1002,
#1016, #1011, #1021, #1022, #973, #975, #1015 and #983. Only #983
touches validate-family.py, in `_git_runs` and the member checks, apart
from this branch's hunks; it touches none of this branch's test files.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
…is_dir/exists guard in a shipped tool fails by file and line unless its `_is_a` or a reasoned allowlist covers it, and a `project.yaml` linked into an unsearchable folder is `yaml-unreadable` (#430) (#1111)

## What

Slice Z of lane 1's plan for #430 (issuecomment-6251069236), the slice that lands last. Slices A to H moved each shipped tool's `Path.is_file()`, `is_dir()`, `exists()` and `is_symlink()` guards onto a local `_is_a` (`is_a` in the doctor). From Python 3.14 those methods answer False to any `OSError`, and `os.path.isfile`, `isdir`, `exists`, `lexists` and `islink` always have. The helper treats ENOENT, ENOTDIR, EBADF and ELOOP (and Windows' 21, 123 and 1921) as absent and raises every other `OSError`, so the caller refuses it by name: `<path>: cannot be read (<reason>)`, exit 2.

| Slice | Files | PR |
| --- | --- | --- |
| A | `scripts/bump-leg.py` | #934 |
| B | `update-shape.py` | #976 |
| C | `shape-doctor.py`, `scripts/shape_advisory.py` | #975 |
| D | the assembly root's `validate-manifest.py` and `bootstrap.py` | #958 |
| E | the family root's `validate-family.py` | #956 |
| F | the family root's `siblings.py` and `bootstrap.py` | #951 |
| G | `scripts/family.py` | #955 |
| H | `adopt-project.py` | #970 |

This PR adds `tests/test_path_guard_hygiene.py`, which stops a new bare guard from bringing the old answer back. It also changes one site the scan found in `shape-doctor.py`.

### The hygiene test

- **Which files.** It reads every shipped tool: the files `tests/test_repo_hygiene.py`'s `SHIPPED` lists, and every `*.py` at the top level, in `scripts/`, in `templates/**/scripts/` and in `src/openreposhape/`. That is 25 files today; the glob adds none that `SHIPPED` lacks.
- **What it finds.** It parses each file with `ast` and finds every call of the four methods and the five `os.path` tests. It catches:
  - a call wrapped over lines;
  - the module reached as `os.path`, `posixpath`, `ntpath` or `genericpath`, or through `import os as o` (`o.path`), `import os.path as p`, `from os import path` or `p = os.path`;
  - a test imported by name (`from os.path import lexists`);
  - a guard passed as a function (`filter(os.path.exists, ...)`, `map(Path.is_dir, ...)`).
- **What it does not catch.** It misses a guard named by a string (`getattr(p, "is_file")`, `operator.methodcaller`), the other questions of the same kind (`os.path.ismount`, `is_fifo`, `os.access` with `F_OK`), and a hand-written `stat` whose `OSError` is False. It also misses an alias bound by tuple unpacking, a walrus, a `for` or `with` target, a default parameter, a class attribute, `importlib` or `sys.modules`, and `o = os; o.path.isfile` or `from os.path import *`. A guard bound once at module level (`EX = os.path.exists`) is flagged at the binding, but its later calls are not. Shipped code has none of these except three Windows-only `getattr(os.path, "isjunction", ...)` lookups: `shape-doctor.py` `unclonable`, and `templates/family-root/scripts/siblings.py` `not_a_clone` and `family_link`. Each sits beside an allowlisted `is_symlink` or `islink`, so each is benign in context. The test's `_os_path_names` docstring still says "in no shipped tool today"; it changes with a follow-up.
- **What fails.** A guard fails unless it is on `BENIGN` or `KNOWN_OPEN`, as often as its function makes it. Those lists key each entry by file, enclosing function and the call's own text, never by line, with a count. The failure names `file:line` and the function, and says what to do: use the file's `_is_a`, or copy validate-pins.py's where the file has none (#370, #610). Where the site is benign, it says to add an entry with a tag and a reason.
- **No guard in a helper.** A guard inside a stat helper fails too: the helper must ask `stat` and nothing else. No entry may name one.
- **Entries must stay true.** An entry whose call has gone, has moved function, or names more calls than its function makes fails as stale. So does an entry with no tag from `TAGS`, or with no reason.
- **The scanner and the counts are checked on their own.**
  - The scanner finds 19 written spellings, and finds no `stat` or `lstat`.
  - An entry for one call does not cover two, and an entry for three, or for a call that has gone, is stale.

### The parity test

- **Where the sets come from.** Each top-level `_is_a` and `is_a` in a shipped tool has its sets read from the AST. That covers the twelve the slices wrote, and any added later. The test follows the helper through every module function it calls (`_absent`, `_mode_of`, `_says_absent`, `refused_read`). It reads each `<exc>.errno in <set>` and each `getattr(<exc>, "winerror", None) in <set>`, through the module constant, as `errno.<NAME>` names and numbers. A set it cannot read that way fails; it is not guessed at.
- **What each helper must hold.** Every set must be {ENOENT, ENOTDIR, EBADF, ELOOP} and {21, 123, 1921}. On the path from its `stat` to its `raise`:
  - it may catch only `OSError` and `ValueError`. A handler for `PermissionError`, `Exception`, or a bare `except:` fails;
  - every `except OSError` must hold a bare `raise`;
  - `errno` and `winerror` may be read only as `in <set>`. An `== errno.EACCES` beside the set fails.
- **Helpers must be plain functions.** A class or an `async def` by a helper's name fails.
- **Each slice's helper must still be there.** That way none is held to nothing because it was renamed.
- **The constants.** Every module constant named `*ABSENT_ERRNOS` or `*ABSENT_WINERRORS` must be the one set. There are 24, two per helper.
- **Results on this head.** All twelve helpers hold the set: `adopt-project.py`, `update-shape.py`, `shape-doctor.py` (`is_a`), `scripts/bump-leg.py`, `scripts/family.py`, `scripts/shape_advisory.py`, and the assembly root's `validate-pins.py`, `validate-manifest.py` and `bootstrap.py`, and the family root's `validate-family.py`, `bootstrap.py` and `siblings.py`.

## What the scan finds

On main `4ed7c7bf` the scan finds 191 bare guards in 18 of the 25 files: the 188 of `16f9a0e8`, `6da757cb` and `823624d9`, and three that #1091 added to `adopt-project.py`. None is inside a helper.

- **1 is the real site below, changed here.** On this head the scan finds 190.
- **179 are on `BENIGN`, in 175 entries.** Each carries the plan's own reason for leaving a site out (lane 1's site table at `5017830`) and a line of its own:

| Tag | Entries (sites) | What it means |
| --- | --- | --- |
| OWN | 57 (58) | the tool's own checkout of the standard, its templates or package, its cache, a temporary or work directory it made, or what this run wrote |
| DELIBERATE | 63 (65) | a deliberate `os.path` call or guard whose False the code relies on and says why: wording, advice, `family.py`'s members-spelling twins, or a check a by-name refusal follows (#608, #609, #798, #735, #784, #793, #764) |
| ASKED | 25 (25) | asked only after the stat helper, a `stat`, `mount_read` or git asked the path or its folder, or inside a `try` that keeps the `OSError` |
| ROOT | 20 (21) | a name directly in the root: asked by `lstat`, or where a link at the name is left out or handled as a link first, in a root the tool refuses by name first where it cannot search it |
| DEST | 9 (9) | the plan's OPEN-DEST, loud: a destination asked before a create or a clone, which then fails by name |
| PINNED | 1 (1) | `scripts/repo_shape.py`'s `find_repo_root`, which the plan left (pinned, low) |

- **#1091's three are `OWN`.** None needs #1110.
  - `_PlannedRoot._no_directory` and `_PlannedRoot._into_git` ask `self._git().is_dir(...)`. `_git()` returns `_CloneGit`, `check`'s model in memory of the names git makes in the clone's own `.git` (#904). Its `is_dir` is a set lookup, not the disk. Measured in a folder with no `.git`, with every `stat`, `lstat`, listing and `os.path` test patched to raise: it answers True, False and None as its sets say, and asks the disk nothing.
  - `_clone_ref_store` asks `(probe / CLONE_GIT_DIR / "reftable").is_dir()` of `git init`'s throwaway, a `mkdtemp` of this user's (0700) in the run's own work directory. A work directory at 0500 or 0600 stops at the `mkdtemp` before the guard is asked, and `ref_store` answers git's defaults. The one way to a refused `stat` there is a git template holding `reftable` as a link into a folder at 000. Then 3.12 raises into `ref_store`, which answers git's defaults, and the shim reads False. Both answer `(False, True)` on git 2.43.0, as the readable control does.
- **11 are on `KNOWN_OPEN`.** These are real sites this slice found and did not change. Each is left to the follow-up issue #1110, which is not in v1.2.0. In each, a name in the root that is a link into a folder this user cannot search is still taken for nothing there. Every "under the shim" result below was measured with a 3.14-like `sitecustomize`.

| Site | What happens |
| --- | --- |
| `scripts/family.py` `_gitmodules_names` | measured in process: on 3.12 a `PermissionError` raised out of `remove`; under the shim `(None, [])`, so `remove --dry-run` says no section is removed. Slice G left it for this slice to flag. A change needs a refusal threaded through `remove` and its rollback. |
| `scripts/family.py` `_sections_giving` | the same file: `[]` under the shim |
| `templates/family-root/scripts/siblings.py` `submodule_urls` | the same, in a pinned copy, which this slice does not edit |
| `shape-doctor.py` `check_agent_files` | an agent file linked that way is `absent` in the `agent files` note, which never moves the verdict |
| `shape-doctor.py` `check_what_is_here` | the same, for the NOT A SHAPE ROOT row's facts |
| `shape-doctor.py` `check_the_way_in` | a `.git` linked that way: under the shim the `way-in` row says there is no repository to adopt, and sends the person to `setup.sh` to scaffold three new ones; on 3.12 the row names `.git: cannot be read` |
| `shape-doctor.py` `check_the_other_way_in` | the same `.git`: the `way-in-scaffold` row is left out |
| `shape-doctor.py` `origin_org` | the same `.git`, measured alone: `None` under the shim, the answer for a folder that is no repository (`'acme'` in the readable control); 3.12 raises `PermissionError` |
| `shape-doctor.py` `repo_local_origin_name` | the same `.git`, measured alone: `None` under the shim (`'Orbit'` in the control), so the `naming` row classifies `Atlas` alone, not `Atlas, Orbit`; 3.12 raises |
| `scripts/shape_advisory.py` `_read` | the same `.git`: `read()` says `not-a-repository` under the shim (`advise` in the control), so the Triad advisory row is left out |
| `scripts/shape_advisory.py` `repository_names` | the same `.git`, measured alone: `['Atlas']` under the shim (`['Atlas', 'Orbit']` in the control); 3.12 raises |

## The site changed here: the doctor's own manifest

`shape-doctor.py`'s `Context.__init__` asked `path.is_file()` of the root's `project.yaml` and `family.yaml`. Slice C's root check in `main` asks only whether the root can be searched, so a `project.yaml` that is a link into a folder this user cannot search got past it.

It is now asked with `is_a`, and a refused `stat` is loaded (`Context._to_load`). `load_yaml` then refuses it as it refuses a file it cannot open, and the `manifest` row names it, as it already did for a `project.yaml` at mode 000 (#205).

Measured on a root holding `project.yaml -> ../locked/project.yaml` with `locked/` at 000, Python 3.12.3, and the 3.14-like shim:

| | Before (`16f9a0e8`) | After |
| --- | --- | --- |
| 3.12 | `PermissionError` traceback at `Context.__init__`, exit 1 | `kind assembly root (project.yaml, which does not load)`, `FINDING manifest ... REFUSED yaml-unreadable: .../project.yaml: cannot be read (Permission denied)`, `INVALID (manifest, manifest-kinds)`, exit 1 |
| 3.14-like | `NOT A SHAPE ROOT`, exit 2, with `adopt-project.py plan --source ...` as the NEXT, over a project | byte for byte the 3.12 report above |

The `manifest kinds` row names the same path with the `chmod` cure, in validate-pins.py's words. Where the `stat` succeeds, `is_a(path, stat.S_ISREG)` answers as `is_file()` did, so every other root reads as it did. An I/O error from the `stat` is loaded and named the same way.

**Out of scope here (r1111's N5).** The row this case now shows carries two older pieces of wording, from #205 and #847, which this PR leaves alone:
- the `manifest` row's WHY for `yaml-unreadable` speaks of "a merge conflict resolved, a hand edit";
- the `chmod` cure says "every directory above it inside the root", where the locked folder lies outside the root.

## Mutants

Each mutant was applied once to a scratch copy of the head (`git archive` of the merge with main, with the new test file laid over it), and the new file was run there. The file was then put back from its bytes and checked by sha256. The worktree was never changed.

- **Round 1: 5 of 5 killed.**

| Mutant | Fails |
| --- | --- |
| M1: `scripts/bump-leg.py` `_validators_readable` back to `path.is_file()` | the hygiene test for that file, naming `scripts/bump-leg.py:1474` |
| M2: `errno.EACCES` added to `validate-pins.py`'s `_ABSENT_ERRNOS` | both parity tests |
| M3: 1921 dropped from `update-shape.py`'s `_ABSENT_WINERRORS` | both parity tests |
| M4: the allowlisted `path.is_file()` in `update-shape.py`'s `_extend_the_eol_block` taken out | the stale-entry test |
| M5: `Context.__init__` back to `path.is_file()` | the doctor tests |

- **Round 2: 17 of 17 killed.**

| Mutant | Fails |
| --- | --- |
| r1111's X3: a guard through `import os as _os` | the hygiene test for that file |
| r1111's X4: `if not path.exists(): return False` inside bump-leg's `_is_a` | the hygiene test (a guard in the helper) |
| r1111's X5: `except PermissionError: return False` before `except OSError` | parity: `catches PermissionError` |
| r1111's X6: `or exc.errno == errno.EACCES` beside the set | parity: `reads exc.errno other than as in <set>` |
| r1111's X15: a class `_is_a` holding a bare guard | hygiene and parity |
| r1111's X18: `except Exception: return False` before `except OSError` | parity: `catches Exception` |
| r1111's T2: the stale check disabled | the count self-test |
| r1111's T3: one entry silences any count | the count self-test |
| round 1's W1 and W4 | hygiene, and the stale-entry test |
| Y1: the handler rule loosened | the parity self-test |
| Y2: an `errno` read other than `in` no longer refused | the parity self-test |
| Y3: `import os as o` no longer followed | the scanner self-test |
| Y4: `p = os.path` no longer followed | the scanner self-test |
| Y5: the helper exemption put back | the scanner self-test |
| Y6: a class or `async def` by a helper's name read as a function | the parity self-test |
| Y7: `except PermissionError` in `_to_load` (r1111's X12) | the doctor test's I/O-error row |

- **Round 3, #1091's sites: 3 of 3 bite.** Each new entry was taken out alone, on the merge with main `4ed7c7bf`. Each run gave 1 failed and 48 passed.

| Entry taken out | Fails |
| --- | --- |
| B1: `_PlannedRoot._no_directory` | the hygiene test for `adopt-project.py`, naming `adopt-project.py:10785` |
| B2: `_PlannedRoot._into_git` | the same test, naming `adopt-project.py:10817` |
| B3: `_clone_ref_store` | the same test, naming `adopt-project.py:11113` |

- **The file's self-tests.** Slice A's helper passes the parity test, and eleven one-line changes to it are each named: three to its sets, the re-raise, a constant's name, three handlers, `==`, an `async def` and a class.

## Tests

- **`tests/test_path_guard_hygiene.py`, new: 49 tests.**
  - 25 hygiene rows, one per tool.
  - The stale-entry, tag, scanner, count and parity tests.
  - Five tests for the doctor change:
    - two in process (`refused_stat` and a refused `read_text`, for a permission and for an I/O error), on every Python and as root;
    - two controls, nothing there and a directory there, which still read as no manifest;
    - one mode case run as a person runs the doctor, skipped on Windows and as root.
- **Against main's tools** (main `4ed7c7bf`, with the new file laid over it), 45 pass and 4 fail. The 4 are the doctor's unlisted `Context.__init__` guard and its three rows: the two in-process rows, the I/O-error row among them, and the mode case. On this head all 49 pass.
- **The gate on head `d286ae9a`.** The head holds main `4ed7c7bf` through the plain merge `8bc4d1b2`, with tree `fc0731ea`; the branch's first merge, `03c16cb9`, took `6da757cb`. Against main, the head changes only `shape-doctor.py` and the new file. The gate ran on the head itself, with Python 3.12.3, git 2.43.0, a stub `gh` first on `PATH`, `GH_TOKEN` bogus, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`:
  - the new file, `tests/test_repo_hygiene.py`, `tests/test_cli_reference.py`, `tests/test_agents_md_codes.py`, `tests/test_doctor_unreadable_paths.py` and `tests/test_doctor_unreadable_pin.py`: 305 passed;
  - `tests/test_shape_doctor.py`, 448 tests in four shards: 445 passed and 3 skipped (118 passed; 110 passed; 117 passed; 100 passed and 3 skipped);
  - `tests/test_scaffold_leg_path_collisions.py`: 104 passed.
- No `--help` text changes, so `docs/cli.md` is unchanged. No pinned copy changes, so no project or holder gets an `upstream-changed` row.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `d286ae9a` is green on Linux and SonarCloud. The branch merged main twice and was never rebased; the last merge is update-branch's, clean, giving d286ae9, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 8 test files (test_path_guard_hygiene, test_shape_doctor, test_scaffold_leg_path_collisions, test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_doctor_unreadable_paths and test_doctor_unreadable_pin), 854 passed and 3 skipped.

Closes #430

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

Development

Successfully merging this pull request may close these issues.

1 participant