Skip to content

bump-leg.py: a root, a leg's checkout or a scripts/ this user cannot search is refused by name and never read as absent, and an unreadable validator is refused before anything is written, dry run included, so Python 3.14 no longer commits a bump with both validators SKIPPED (part of #430) - #934

Merged
brettheap merged 4 commits into
mainfrom
fix/430-a-bump-leg-unsearchable
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #430 (comment) (lane 1 holds slices A, H and Z)

Slice A of #430's plan (#430, comment). A v1.2.0 draft, authored on the v1.1.1 release commit 5017830; this body is for head 0142bfce, two fix rounds on 27ec441a after lane 1's reviews r934 and r934b. It changes scripts/bump-leg.py and adds tests/test_bump_leg_unsearchable.py.

Why

From Python 3.14, Path.is_file(), is_dir(), exists() and is_symlink() answer False to a refused stat; up to 3.13 they raise. Three guards in bump-leg.py asked them. Lane 1's scoping of #430 found the issue's worst case here: on 3.14, with scripts/ at mode 444, bump-leg.py printed SKIPPED for both of the project's validators, committed the bump, and exited 0. The scoping measured 444 only; the writer measured 000 afterwards, and the table holds both.

What

Each now asks the file's own _is_a (#383). It raises a stat the operating system refused, and answers "absent" for _ABSENT_ERRNOS (ENOENT, ENOTDIR, EBADF, ELOOP, and Windows' errors 21, 123 and 1921) and for a name the system cannot take; for the two S_ISREG guards, a name that is not a regular file also reads as absent, as it did through is_file().

  • run_validator. A new _validators_readable stats and opens each of the project's two validators that is there, after nothing to do and before the dry-run return, so before anything is written. It refuses every one it cannot read in one bump-leg-validator-unreadable, exit 2. One that is not there is still SKIPPED. A stat refused mid-run raises, and _apply_rewrite rolls it back as bump-leg-rewrite-failed.
  • _initialized_submodule. A leg's checkout this user cannot search is bump-leg-submodule-unreadable, naming <path>/.git, never bump-leg-uninitialized.
  • Project.open. A root, or a directory above it, that this user cannot search is bump-leg-manifest-unreadable, naming project.yaml by its whole path, never bump-leg-root-not-a-project.

Each line is in _cannot_open's form, <path>: cannot be read: <reason>: the validator lines come from _cannot_open, and the .git and manifest lines are built by hand in that form. The remedy for a refused permission is _cannot_open_advice's chmod one.

Two fix rounds change the words of the remedies, and add tests.

Round 1, from r934 (27ec441a to 1f7b1b71):

  • S4. A validator refused for a cause that is not a permission (an I/O error) is told to restore it from the project's history and to make sure nothing is changing scripts/, not to "take away one nobody wants", which would make it SKIPPED. It names only the files refused for that cause. _cannot_open_advice gained keywords other (the validators') and above (the manifest's); its defaults, which test_doctor_unreadable_pin.py compares, are unchanged.
  • N4, N5. A refused stat of a validator says which directory is at fault ("here that is scripts/ or a directory above it"), as the manifest's does; a refused open does not. The manifest's advice drops "inside the root" from "every directory above it", since the one at fault may be above the root.
  • S1, S2, S3. The real-run tests assert the refusal carries no rolled-back sentence (one raised inside _apply_rewrite has it), which pins "before anything is written". Each refusal is asked an I/O error: its code, its reason, no chmod. SonarCloud's six are fixed (S1192, S108, four S9073).

Round 2, from r934b (1f7b1b71 to 0142bfce):

  • The restore command. The remedy printed git checkout -- <files> with no -C. This tool is run from a checkout of the standard with --root naming the project, and that checkout does not track the project's own validators (r934b measured git ls-files --error-unmatch scripts/validate-pins.py failing there), so the line failed where it was pasted. It is now git -C <root> checkout -- <files> through shlex.join, as the tool's other git lines are (Printed commands are POSIX-only: quote the bare paste lines in bump-leg.py and update-shape.py, and say in the docs that pasted commands are for a POSIX shell #532), so it restores the files in the project's root from wherever the tool was run.
  • UNCHANGED. _workflows_dir_refusal ends with the constant, output identical, so it is the closing sentence of all four refusals that give it.
  • Pins. The two real-mode validator tests assert the ROLLED_BACK constant is not in stderr, not a literal. The I/O test asserts the remedy ends with the closing sentence spelled out and holds UNCHANGED; the constant alone would follow a changed copy. A new test has both validators failing for an I/O error: one command names both files.
  • Docstrings. _cannot_open_advice says each keyword serves one refusal. Where the module, the validator-at-000 test and _validators_readable say a validator at mode 000 ended bump-leg-validators-red, they say that holds with --assume-unchanged; see the known limits.

Measured

On a scaffolded root, local remotes, a stub gh, Python 3.12.3, at 27ec441a; the two rounds change words and tests, not codes or exit codes. "3.14-like" is a sitecustomize shim that makes is_file, is_dir, exists and is_symlink answer False to any OSError. Real runs.

Case Before, 3.12 / 3.14-like After, both
scripts/ at 444 or 000 bump-leg-rewrite-failed after the leg moved, rolled back / both validators SKIPPED, committed, exit 0; a dry run exit 0 bump-leg-validator-unreadable naming both, exit 2, HEAD unchanged; a dry run the same
spec/ or the root at 000 PermissionError traceback, exit 1 / bump-leg-uninitialized or bump-leg-root-not-a-project bump-leg-submodule-unreadable or bump-leg-manifest-unreadable, exit 2

Tests

tests/test_bump_leg_unsearchable.py, 32 cases (25 at 27ec441a, 31 at 1f7b1b71).

  • Real modes (444 and 000, real run and dry run) go through test_bump_leg_unopenable's made_unopenable, and the root's through unsearchable_root, which asks git nothing while the root cannot be searched; skipped on Windows and as root. The 3.14 answer (refused_stat) and an I/O error (stat_fails) are tested on every Python, root included.
  • Controls: an absent validator is still SKIPPED, nothing to do asks none, an absent leg is still uninitialized, no manifest is still not a project.
  • Without the fix: with bump-leg.py put back at 5017830, 26 of the 32 cases fail and the 6 controls pass (r934b measured 25 and the same 6 on the 31).
  • Mutants: 22 hand-made in round 1, run on 1f7b1b71, all killed by that file and test_bump_leg_unopenable.py (63 tests then), the five that survived at 27ec441a among them. r934b made 13 more at 1f7b1b71; X4, X5b and X7 survived them. Round 2 kills X5b (the command names the first file only: the new both-validators test), X7 (the closing sentence says nothing was fetched: the I/O test) and X11 (the real run's check moved after the write, with ROLLED_BACK reworded, which at 1f7b1b71 only the in-process case failed; now four cases fail, three of them real runs). X11a, the rewording alone, breaks no test, as r934b's control expected. X4 (statted not reset for each file) still survives: telling it apart needs one validator's stat refused and the other's not, which a real scripts/ cannot do without a symlink.

Python 3.12.3, a stub gh first on PATH, a bogus GH_TOKEN, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, at 0142bfce: every tests/test_bump_leg*.py, test_doctor_unreadable_pin.py, test_validate_pins_unreadable_workflows_dir.py, test_repo_hygiene.py, test_cli_reference.py and test_agents_md_codes.py, 495 passed and 1 skipped; the new file with test_bump_leg_unopenable.py, 64 passed. SonarCloud analyzed 0142bfce: 0 issues, quality gate OK. CI's tests job runs Python 3.x, 3.14.8 when last checked, so it runs the 3.14 behaviour for real; it passed at 27ec441a, was cancelled at 1f7b1b71 when 0142bfce was pushed, and was still running at 0142bfce at 18:28Z.

Known limits

Part of #430

🤖 Generated with Claude Code

Summary by Sourcery

Refuse inaccessible project paths and validators explicitly before a bump can write or commit changes.

New Features:

  • Refuse unreadable validators, manifests, and submodule checkouts with specific errors instead of treating them as absent.
  • Validate project validators before any writes, including dry runs, while preserving SKIPPED behavior for genuinely absent validators.

Bug Fixes:

  • Prevent Python 3.14 from committing bumps when validator access is denied and filesystem checks incorrectly report files as absent.
  • Preserve rollback and refusal behavior when filesystem access fails during a bump.

Enhancements:

  • Improve refusal diagnostics and remediation guidance for permission and non-permission filesystem errors, including root-aware validator restore commands.
  • Standardize unchanged-tree messaging across filesystem access refusals.

Tests:

  • Add comprehensive coverage for unreadable validator directories and files, inaccessible submodule checkouts, inaccessible roots, Python 3.14-style refused stats, I/O errors, dry runs, and unchanged-tree guarantees.

…t search is refused by name and never read as absent, and an unreadable validator is refused before anything is written, dry run included, so Python 3.14 no longer commits a bump with both validators SKIPPED (part of #430)

Slice A of #430, from lane openRepoShape-1's plan there, at the v1.1.1
release commit 5017830. Three guards in `scripts/bump-leg.py` still asked
`Path.is_file()` or `Path.exists()`. Up to Python 3.13 those raise a
refused `stat`; from 3.14 they answer False to it. Each now asks the file's
own `_is_a` (#383), which answers "absent" only for `_ABSENT_ERRNOS`:

- `run_validator`, of the project's own `scripts/validate-pins.py` and
  `scripts/validate-manifest.py`. A new `_validators_readable` stats and
  opens each validator that is there. It runs after `nothing to do` and
  before the dry-run return, so before anything is written, and refuses
  every validator it cannot read in one `bump-leg-validator-unreadable`,
  exit 2. A validator that is not there is still SKIPPED by
  `run_validator`, as before. A `stat` refused in `run_validator` itself
  (a mode changed mid-run) now raises, and `_apply_rewrite` rolls it back
  as `bump-leg-rewrite-failed`.
- `_initialized_submodule`: a leg's checkout this user cannot search is
  `bump-leg-submodule-unreadable`, naming `<path>/.git` as
  validate-pins.py's `pin-submodule-unreadable` does, and never
  `bump-leg-uninitialized`.
- `Project.open`: a root, or a directory above it, that this user cannot
  search is `bump-leg-manifest-unreadable`, naming project.yaml by its
  whole path, and never `bump-leg-root-not-a-project`.

Each line takes the shape of the file's own `_cannot_open`,
`<path>: cannot be read: <reason>`, and each remedy is its
`_cannot_open_advice`: `chmod u+r` on a file, `chmod u+rx` on a directory
and on every directory above it. The module docstring's refusal list, the
VALIDATORS comment and `cmd_bump`'s order now say so. `--help` (the first
docstring line) and docs/cli.md are unchanged, and no doc lists bump-leg's
codes.

Measured on a scaffolded root (local remotes, a stub gh), on Python 3.12.3
and on 3.12.3 under a 3.14-like shim (`is_file`, `is_dir`, `exists` and
`is_symlink` answering False to any OSError). Real run / --dry-run:

  scripts/ at 444 or 000
    before, 3.12:      bump-leg-rewrite-failed after the leg moved, rolled
                       back / exit 0
    before, 3.14-like: `scripts/validate-pins.py is absent; SKIPPED`, the
                       same for validate-manifest.py, `committed`, exit 0 /
                       exit 0
    after, both:       bump-leg-validator-unreadable naming both, exit 2,
                       HEAD unchanged / the same
  scripts/validate-pins.py at 000
    before, both:      bump-leg-validators-red after the rewrite, rolled
                       back / exit 0
    after, both:       bump-leg-validator-unreadable naming it, exit 2 /
                       the same
  spec/ at 000
    before:            PermissionError traceback, exit 1 (3.12);
                       bump-leg-uninitialized (3.14-like)
    after, both:       bump-leg-submodule-unreadable: spec/.git: cannot be
                       read: Permission denied
  the root at 000
    before:            PermissionError traceback, exit 1 (3.12);
                       bump-leg-root-not-a-project, "project.yaml does not
                       exist" (3.14-like)
    after, both:       bump-leg-manifest-unreadable: <root>/project.yaml:
                       cannot be read: Permission denied

The in-process `refused_stat` stand-in for 3.14 gave the same before: the
real run printed both SKIPPED lines, committed and exited 0.

tests/test_bump_leg_unsearchable.py holds them in 25 cases. Real modes
(444 and 000, real run and dry run, skipped on Windows and as root) go
through test_bump_leg_unopenable's `made_unopenable`. The 3.14 answer is
tested on every Python through its in-process `refused_stat`, with
`main()` itself as the real run. The controls: a validator that is not
there is still SKIPPED and the bump commits; a run with nothing to do asks
no validator; an absent leg is still uninitialized; and a directory with
no manifest is still not a project. With bump-leg.py put back at 5017830
and the tests kept, 19 cases fail and the 6 controls pass.

Gate, on Python 3.12.3 with a stub gh:
- every tests/test_bump_leg*.py, with test_repo_hygiene.py and
  test_cli_reference.py: 361 passed, 1 skipped (a test that needs a
  case-insensitive disk);
- the ten other files that run bump-leg or assert its codes: 987 passed,
  1 skipped (a test of a Windows default).
Those ran on the bytes before the last edit, which rewrapped one docstring
paragraph. On the final bytes, the new file, test_repo_hygiene.py and
test_cli_reference.py: 126 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.0 milestone Oct 18, 2026
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates bump-leg.py to treat inaccessible filesystem paths as refusals rather than absence, adds a validator readability preflight before writes and dry-run completion, and introduces regression tests covering real permissions, Python 3.14 semantics, rollback safety, and preserved absent/no-op behavior.

Sequence diagram for inaccessible path refusal handling

sequenceDiagram
    participant User
    participant BumpLeg
    participant Filesystem
    participant Git
    User->>BumpLeg: cmd_bump
    BumpLeg->>Filesystem: _is_a(project.yaml)
    Filesystem-->>BumpLeg: stat refused
    BumpLeg-->>User: bump-leg-manifest-unreadable
    User->>BumpLeg: cmd_bump
    BumpLeg->>Filesystem: _is_a(validator)
    Filesystem-->>BumpLeg: stat/open refused
    BumpLeg-->>User: bump-leg-validator-unreadable
    Note over Git,BumpLeg: No fetch, write, or commit occurs
Loading

State diagram for validator availability during bump

stateDiagram-v2
    [*] --> ValidatorCheck
    ValidatorCheck --> Absent: _is_a returns false
    ValidatorCheck --> Readable: stat and open succeed
    ValidatorCheck --> Unreadable: stat or open raises OSError
    Absent --> Skipped
    Readable --> Rewrite
    Unreadable --> Refused
    Skipped --> Rewrite
    Refused --> [*]
    Rewrite --> DryRunComplete: --dry-run
    Rewrite --> ApplyRewrite: real run
    DryRunComplete --> [*]
    ApplyRewrite --> [*]
Loading

Flow diagram for bump-leg readability preflight

flowchart TD
    A["cmd_bump"] --> B["fetch_leg"]
    B --> C["nothing to do"]
    C --> D["_validators_readable"]
    D --> E{"--dry-run?"}
    E -->|yes| F["Return without writes"]
    E -->|no| G["_apply_rewrite"]
    D -->|unreadable validator| H["Refuse: bump-leg-validator-unreadable"]
    D -->|validator absent| I["run_validator: SKIPPED"]
Loading

File-Level Changes

Change Details Files
Replace filesystem presence checks with refused-stat-aware detection so inaccessible paths are distinguished from absent paths.
  • Use _is_a and _ABSENT_ERRNOS semantics for the project manifest, validator files, and submodule .git marker.
  • Report dedicated refusal codes for unreadable manifests and submodule checkouts while preserving absent-path behavior.
  • Provide path-specific read/search remediation and consistent cannot-read refusal wording.
scripts/bump-leg.py
Preflight all present project validators before any write, including dry runs, while preserving skipped-validator and no-op behavior.
  • Stat and open each present validator before the dry-run return, aggregating unreadable validators into one refusal.
  • Keep validators that are genuinely absent as SKIPPED.
  • Allow a validator becoming inaccessible during execution to propagate through the existing rollback failure path.
scripts/bump-leg.py
Add comprehensive regression coverage for inaccessible paths and Python 3.14 refused-stat behavior.
  • Test real permission modes and an in-process refused-stat shim for validators, submodule checkouts, and project roots.
  • Verify dry-run and real-run refusal codes, unchanged repository state, rollback behavior, remediation text, and preserved controls.
  • Cover absent validators, absent/uninitialized legs, missing manifests, and runs with nothing to do.
tests/test_bump_leg_unsearchable.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

… is told to restore the file, not to take it away, which would make it SKIPPED; a refused `stat` of a validator names `scripts/`; the manifest's advice no longer stops at the root; and the tests pin "before anything is written" for the real run and an I/O error for each of the three refusals (part of #430)

The fix round for PR #934 from lane 1's review r934.

S1. Nothing in the tests failed a real run that checked the validators
after the write: the rollback puts the tree back, so the whole-tree
comparison passes either way. A refusal raised inside the rewrite carries
the rolled-back sentence, so the real-mode cases and the in-process one now
assert that it is not there (`rolled back`, `ROLLED_BACK`).

S2. The three new refusals were asked only for a permission. An `OSError`
with errno EIO from `Path.stat` is now asked of `validate-pins.py` and
`validate-manifest.py`, of a leg's `.git` and of `project.yaml`: its code,
`cannot be read: Input/output error`, no `chmod`, and for the manifest no
"a directory above it".

S3. SonarCloud's six: the sentence "Then run the same command again.
Nothing in the working tree, the index or the leg's checkout was changed."
is the module constant `UNCHANGED` at its three places (S1192); the empty
`with path.open("rb"): pass` is `path.open("rb").close()` (S108); the four
compound asserts are split (S9073).

S4. The validator refusal's remedy for a cause that is not a permission
said "or take away one nobody wants", and a validator taken away is
SKIPPED. It now says to restore the file named from the project's history
(`git checkout -- <file>`, naming only the files refused for that cause) and
that nothing may be changing `scripts/` while this runs.
`_cannot_open_advice` takes `other`, which replaces that one sentence for
one refusal; its defaults, which `validate-pins.py` and the doctor give word
for word, are unchanged.

N1. The `_validators_readable` docstring said `_is_a` answers False only
for the `_ABSENT_ERRNOS`; it also answers False for something that is not a
regular file and for a name the operating system cannot take.
N2. The test module's docstring says the root's real-mode cases go through
`unsearchable_root`, not `made_unopenable`.
N3. It says every refused run on a real mode is run again with the mode
back, not every refused bump.
N4. A validator `stat` the operating system refused is told which directory
was at fault ("here that is `scripts/` or a directory above it"), as the
manifest's is, and a validator whose open was refused is not.
N5. `_cannot_open_advice` takes `above`; the manifest refusal gives "" so
its permission advice no longer says "every directory above it inside the
root" of a directory that may be above the root.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T18:17:20Z) Independent re-review of #934 at 1f7b1b71, by lane 1's reviewer r934b (opus), who is not its writer: READY, with no MUST and one SHOULD that lane 1 takes now.

  • The first review's S1 to S4 and N1 to N5 are fixed, and fixed right.
    • SonarCloud analyzed 1f7b1b71: 0 issues, quality gate OK.
    • The 14 files: 494 passed, 1 skipped.
    • The new file against 5017830's bump-leg.py: 25 failed, and 6 passed (the controls).
  • SHOULD-1: the validator remedy prints git checkout -- <file> with no -C <root> (scripts/bump-leg.py:1185-1191).
  • NITs, taken in the same round:
    • UNCHANGED in _workflows_dir_refusal too;
    • ROLLED_BACK instead of the literal in two tests;
    • an assert that pins UNCHANGED's second sentence;
    • _cannot_open_advice's docstring;
    • the 000-validator sentences, which hold only with --assume-unchanged. A real root stops at bump-leg-root-dirty, before and after this PR (measured on git 2.43).
  • Mutants:
    • Killed: X1, X2, X3, X6, X8, X9, X10 and X11.
    • Survived: X5b, X7 and X11a. The round's tests close X5b and X7.
    • X4 is close to equivalent.
  • The body: seven corrections (B1 to B7) go into the body with the round.

Lane 1's writer w934f makes SHOULD-1 and the NITs now, and r934b re-checks the new head.

Lane: openreposhape-1 (openRepoShape-1)

…n prints `git -C <root> checkout --` through `shlex.join`, as the tool's other git lines do (#532), so it restores the project's own validator from wherever the tool was run, and the tests pin it for both validators, the rolled-back sentence by the constant, and the closing sentence (part of #430)

A second round on PR #934, from lane 1's re-review r934b of 1f7b1b7.

SHOULD-1. The remedy said `git checkout -- <files>` with no `-C`. This
tool is run from a checkout of the standard with `--root` naming the
project, and that checkout does not track the project's own
`scripts/validate-pins.py`, so the line failed where it was pasted, and in
another project's root it would have thrown away that root's edits to its
own validator. It is now `shlex.join(["git", "-C", str(root), "checkout",
"--", *restore])`. The asserts at the I/O test and the mixed-cause test
follow, and a new test has both validators failing for an I/O error: one
command names both files.

N-a. `_workflows_dir_refusal` ended with the sentence spelled out; it ends
with `UNCHANGED`, output identical, so the constant's comment holds for its
four refusals.
N-b. The two real-mode validator tests assert `ROLLED_BACK` is not in
stderr, not the literal "rolled back", so a rewording of the constant cannot
leave the refusal unpinned.
N-d. The I/O test asserts the refusal's remedy ends with the sentence
"Nothing in the working tree, the index or the leg's checkout was changed."
spelled out, and holds `UNCHANGED`. The constant alone is not enough to
pin the sentence, since a changed constant would still be in the remedy.
N-e. The `_cannot_open_advice` docstring says each keyword serves one
refusal: `above` the manifest's, `other` the validators'.
N-g. Where the module and the validator-at-000 test docstrings, and
`_validators_readable`'s, say a validator at mode 000 ended
`bump-leg-validators-red`, they say it holds with `--assume-unchanged`: on
a real root plain `git status` shows the file modified, and the run stops at
`bump-leg-root-dirty`, before and after this change (git 2.43.0).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T18:34:50Z) Re-check of #934 at 0142bfce, by r934b (opus), who is not its writer: READY, with no MUST and no SHOULD.

  • Round 2 makes SHOULD-1: the validator remedy is git -C <root> checkout -- <files> through shlex.join, as the tool's other git lines are (Printed commands are POSIX-only: quote the bare paste lines in bump-leg.py and update-shape.py, and say in the docs that pasted commands are for a POSIX shell #532).
  • It also makes N-a, N-b, N-d, N-e and N-g. N-d carries a literal endswith that kills X7. N-c is closed by the new test where both validators fail.
  • Measured at 0142bfce:
    • The two test files and the doctor parity file: 116 passed.
    • The new file against 50178305's bump-leg.py: 26 failed, and the 6 controls passed.
    • X5b, X7 and X11 are killed.
    • On a real root, with no --assume-unchanged:
      • a validator at 000 ends bump-leg-root-dirty (under git's default core.trustctime);
      • scripts/ at 000 or 444 ends bump-leg-validator-unreadable, and spec/ at 000 or 444 ends bump-leg-submodule-unreadable, each with exit 2 and HEAD unchanged.
  • SonarCloud at 0142bfce: 0 issues, gate OK. CI's tests, the real 3.14 run, is running.
  • Optional NIT: the three N-g sentences hold under git's default core.trustctime. The body says so.
  • The body now describes 0142bfce: both rounds, the counts, the remedy command and the limits, with r934b's corrections.

#934 asks for a turn once its tests are green at 0142bfce, gated over main as it then stands.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 19:55
@sourcery-ai

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

@brettheap
brettheap merged commit 3db58c4 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/430-a-bump-leg-unsearchable branch October 18, 2026 19:56
brettheap added a commit that referenced this pull request Oct 20, 2026
…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) (#958)

# 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`.

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.

| 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.

- **One rule.** 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 (<strerror>)`. The cure is `validate-pins.py`'s (`chmod u+r` on a file, `chmod u+rx` on a directory and every directory above it, or fix who owns it), or its `OTHER_REASON` for a cause that is not a permission. Nothing new is imported from `repo_shape.py` (#370, #610), and `scripts/repo_shape.py` is untouched. bootstrap.py's `_is_a` takes `link=True` for the two `lexists` sites, which ask with `lstat`.
- **Refusals.** `yaml-unreadable`, the id `load_yaml` and `validate-pins.py` already give such a file, for `project.yaml` in both files and for the referent's pin file. One new id, `validator-unreadable`, for a validator bootstrap cannot reach or open. Each remedy says what the file is read for, then the cure, then `Then run the same command again.`
- **A referent's pin file is a refusal, not a finding.** The check cannot run, so the run is refused, exit 2, as `validate-pins.py` refuses the same file.
- **Steps (a) and (c) still never fail the run.** A leg or a register this user cannot read is a line in its step, and the step goes on. In a normal run `validate-pins.py` refuses such a leg (`pin-submodule-unreadable`), so the run ends `bootstrap REFUSED: pins (exit 2)`. Under `--skip-validators` the run ends `bootstrap ok`, as it did for a leg NOT INITIALIZED.
- **A register whose `stat` is refused is kept, and not said to be there.** Whether one is there cannot be told, so step (c) does not say the degrade line, and heads it `register, if one is there: <leg>/governance/review-authority/register.yaml` (RV958's N1). A register whose `stat` works and whose open is refused keeps `register:`.
- **The open as well as the stat.** A validator is opened, and closed unread, before any runs (RV958's S2). A `project.yaml`, the naming policy (RV958's S1) or a register this user cannot open is read off the refusal's `__cause__`, as `validate-pins.py`'s `_load` reads it (#712), so the two causes say one line. Every other `load_yaml` refusal keeps its words; `tests/test_manifest_validator.py`'s not-UTF-8 case, which pins the absolute path, passes.
- **Every path is spelled.** bootstrap.py names each through `spelled` (#391); validate-manifest.py's `_unreadable_file` through a local `_shown`, validate-pins.py's (RV958's N3). A path not under the root (a leg's absolute `path:`, a `--policy` given elsewhere) is named whole.
- **Readable paths print what they printed**, which lane 1's slice A (`fix/430-a-bump-leg-unsearchable`) relies on when it runs `validate-manifest.py`.

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/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).

**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] |

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 #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 #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.

<details><summary>The 134 files of the git 2.43.0 run</summary>

`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`

</details>

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:

- origin/main `c7a3ff16`, re-fetched on 2026-10-19 after the head was pushed: clean as text (tree `977f0980`), and NOT merged here: the landing pilot refreshes the head under the fast-refresh ruling. Since `ebbd9fa4`, main's #981 (`ca6a0b40`) changes `validate-manifest.py`, and its own `_shown(path)` (`text = str(path)`, then `isprintable` or `repr`) lands beside this branch's `_shown(typed)`: the merged file defines `_shown` twice. The two give the same answer for every string this branch passes, the later definition wins, and the new file passes on the merged tree (71 passed, `$Q/v118/w430d/r1/m981.log`), but the refresh should delete this branch's `_shown` and keep #981's. Main's other changes in the files near this slice are to `validate-pins.py` and `tests/conftest.py`, which this branch does not touch.
- #948 at `45415296`: clean (tree `9f4c2991`, the head's own).
- #973 at `b2887cf5` (`fix/961-paste-words`): clean. #973 does not yet change the NOT INITIALIZED line; when it does, the new file's `not_initialized` helper is the one place to follow it.
- Each clean merge's `bootstrap.py` parses, with no top-level name defined twice.
- #962's `wip/l5-962-create-twins-20261018` at `414e9e0a`: conflicts in `bootstrap.py`, 8 hunks. 7 of them are with main alone: #962 is stacked on #948's head before #948 was squashed, and `git merge-tree` of origin/main with it gives the same 7. The 8th is this branch's only one, the import lines: `from collections.abc import Callable, Iterator` here against #962's `import unicodedata` and `from collections.abc import Iterator`. Taking both resolves it.

- 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.

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.

- **The assembly root's `validate-repository-naming.py` names a naming policy it cannot read by its absolute path, with no remedy.** Measured with `contracts/` at 000 in a full `bootstrap.py` run with round 1's change: `REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied)`, while `validate-manifest.py`'s refusal just after it names `contracts/repository-naming.yaml` with the remedy. Not this slice's file.
- **`OTHER_REASON` tells a validator's I/O error to "take away one nobody wants".** A validator taken away is `SKIPPED`. #934 gives bump-leg.py a `git checkout --` line instead. These are validate-pins.py's words, kept as the plan rule asks.
- **The cure's "(and on every directory above it inside the root)"** reads oddly for a folder outside the root (an absolute leg path, a `--policy` given elsewhere). validate-pins.py's words, as RV958's N2 found for a symlinked `project.yaml`.
- **Seen in the code, not measured:** validate-manifest.py prints `project.yaml`'s absolute path raw in `manifest-missing`, `manifest-unreadable` and `<n> finding(s) in <path>`, where `validate-pins.py` and `bootstrap.py` spell every path they print (#391, #712).

Part of #430

Review: RV958, lane 4's independent reviewer, not the writer (w430d): READY at `7e87e05f` (issuecomment-6278103469), the reviewed head, after its READY on round 1 at `cf8d5667` (issuecomment-6271860975); the delta was a merge of main and one test-only commit for CI. CI (Brett Heap's ruling 27): H0 is the head 7c06c57 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 051176c; and the local gate passed on its predicted squash tree 194700b over main 051176c: the adopt_reencoded_files, assembly_root_unreadable_paths, repo_hygiene, agents_md_codes, cli_reference sets, 294 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".


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