Skip to content

shape-doctor.py and shape_advisory.py: a path in the root this user cannot reach is named on every Python, never read as absent or stood in for by the template, a root it cannot search is refused first, and a row that could not ask is never DRIFTED - #975

Merged
brettheap merged 5 commits into
mainfrom
fix/430-c-doctor-unreadable
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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

A v1.2.0 DRAFT, slice C of lane 1's plan for #430 (issuecomment-6251069236): shape-doctor.py and scripts/shape_advisory.py. Branch fix/430-c-doctor-unreadable, from main 50178305, two commits: 5e7a0440 (a WIP: usage-window checkpoint holding all the code) and 76ebca2e (one test's assertion; the code is 5e7a0440's). Nothing is merged into it. run_checks (decision 10) also names a refused read out of any other row in the gate's words, so the rule reaches a site this PR did not list. A leg file the row cannot read is cannot be read (<reason>) in leg shape files. Slice B (lane 2's other writer, update-shape.py) is a separate branch, and no test here depends on it.

Decisions

The plan's rule, held for every site: stat the path; ENOENT, ENOTDIR, EBADF and ELOOP (and Windows 21, 123 and 1921) are absent; any other OSError is named in validate-pins.py's words, <path relative to the root>: cannot be read (<reason>) (or cannot be listed for a folder, #434), with the gate's advice for the cause. The doctor asks with its own is_a (#847); scripts/shape_advisory.py gets a local _is_a with the same errno set (a parity test holds it to the gate's). scripts/repo_shape.py is untouched.

scripts/shape_advisory.py is not a shape copy. It is in none of shape_materialize.py's copy lists, and the pins of a scaffolded project (11 rows) and a holder (10 rows) name neither it nor shape-doctor.py. No project or holder gets an upstream-changed row from this PR.

Each site (lines at 50178305):

  1. main (:11768, and one new check): REFUSES. A root whose stat is refused (a folder above it this user cannot search), or that this user cannot SEARCH (stat of <root>/.), is REFUSED shape-doctor-root-unreadable, exit 3, before any row. The code is the one main already gives a root resolve() refuses, so no new code. Exit 3, not the rule's 2: the doctor's exit 2 is NOT A SHAPE ROOT, a verdict about the tree, and 3 is its documented "usage or environment". Only search is asked: a root at mode 111 (searchable, not listable) is COMPLIANT on both Pythons, measured, and is not refused.
  2. validator_for (:2000): REFUSES for its row. An own copy whose stat is refused raises; the manifest, pins and family rows name it (unreadable_gate_row) and run nothing, never the standard's template in its place. unloaded_manifest_row names it beside the manifest's refusal; gate_line gives no line, so lockstep_next and the agent-files confirm clause name no gate that cannot run.
  3. check_legs (:7247/:7248): REFUSES for the leg. One helper, mount_read, asks the mount's stat, its listing and its .git's stat, for the four rows that read a mount. A refused one is the gate's line, kept in the leg's mount_cannot_be_read, routed to no checkout and no lockstep line; the NEXT is this doctor again where no other leg has a line, and the advice is the WHY, or follows the line's, as shape-doctor.py: for a pin or a contracts/ this user cannot read, the legs, manifest kinds and shape currency rows disagree with validate-pins.py's named refusal, and manifest kinds names a git checkout HEAD -- that discards work #847 does for a pin. Only a missing gitlink is said before it.
  4. member_state (:9792/:9793): REFUSES for the member, through the same helper, key and words; members_verdict routes no such member and gives the advice.
  5. leg_shape_comparison (:7514) and shape_file_state (:7486): the leg's state is the mount's line, and a leg file whose stat or read is refused is cannot be read (<reason>), never absent with a cp over it. The row is a note, never ok and never a FINDING (the legs row asserts the mount).
  6. audit_leg (:8454): the leg's state is the mount's line, an unread leg, so placement is a note (n/a where no leg could be read), as for an unpopulated one; never git's it is not a git repository for a mount it could not enter.
  7. own_bootstrap (:6277): BENIGN, by decision: a refused stat answers "cannot run" (False) on every Python. It chooses only which line a row prints, and the manifest and pins rows name an unreadable scripts/.
  8. The low git-directory sites. kept_gitdir (:6715), keeps_modules (:6803) and checkout_reach's git_dir/modules (:6872) ask with is_a: a refused stat raises on every Python and its row names it (10), where 3.14 called a kept repository gone. BENIGN: checkout_reach :6859/:6870, detached :9368 and origin_url :9446 (reached only after the same mount's, or sibling's, listing and .git stat succeeded); root / ".git" at :10355, :10386, :10854, :10977, git_dir_holds :11135, Context :1327, check_agent_files :9306 and check_what_is_here :10473-:10478 (a name directly in the root, or in a git directory git has just read, is statable by anyone who can search the root, which main now requires).
  9. BENIGN, the standard's own checkout: :404/:11759, :1492, :2199/:2370/:6301 (a copy validator_for just stated, or the template), :7490, :7516, :7657, :8533, :10600; :1591 (repo_shape.py beside a copy just stated); :6393 (os.path.lexists, root level), :9608 (a remote's path), :9715-:9752 (unclonable/sibling_slot, shape-doctor.py members row: its green note says make siblings places every member with no working clone, a directory make siblings refuses included, so one row says both that it places IRRS and that it leaves IRRS exactly as it is #669's unlistable).
  10. run_checks: an OSError out of any check that is a refused read of a path inside the root (refused_in_root, shape-doctor.py: for a pin or a contracts/ this user cannot read, the legs, manifest kinds and shape currency rows disagree with validate-pins.py's named refusal, and manifest kinds names a git checkout HEAD -- that discards work #847) is that row's FINDING in the gate's words, NEXT this doctor again, WHY the advice, detail cannot_be_read and path. Any other OSError keeps could not be run: <exc>.
  11. scripts/shape_advisory.py Tree.kind (:355/:357): asks _is_a, so a refused stat raises and each reader's net reads the file as there and unreadable (the record: it could not be read (PermissionError), which silences nothing) on every Python, where 3.14 called it absent.

Which verdict a "could not be run" row gives. INVALID, naming it; never DRIFTED and never CANNOT ANSWER. drifted_reasons counts drift only from a row's own readable entries: a legs row with none (an exception out of check_legs) and a leg whose mount or pin cannot be read are no leg off its pin, and a members row is drift only where a member this user CAN reach has a problem. Such a row falls through to invalid_verdict (a red gate) or the catch-all, which names it. Not CANNOT ANSWER: exit 3 says nothing is a statement about the tree, and an unreadable path in the root is one (#430's "What it should do" 2, and #847's ruling for an unreadable pin). The ladder's order is unchanged.

What changed

Measured by a probe on Python 3.12.3 and a 3.14-like sitecustomize shim (is_file, is_dir, exists, is_symlink answer False to any OSError), in a project and a holder scaffolded into local bare remotes from a committed copy of the branch, with a stub gh, GH_TOKEN=bogus and an empty GH_CONFIG_DIR. Controls (nothing chmod'd) are COMPLIANT before and after, on both.

cell before, 3.12 before, 3.14-like after, both
scripts/ 444 INVALID (manifest, pins), both rows could not be run: [Errno 13] ... DRIFTED (5 copy-missing), ok manifest the standard's templates/assembly-root/scripts/validate-manifest.py passes both rows name the own copy (below); 3.12 INVALID (manifest, pins); 3.14-like still DRIFTED (5 copy-missing), slice B's
.github/workflows/ 444 INVALID (pins) DRIFTED (1 copy-missing) unchanged: no doctor site reads there; slice B's
spec/ (a leg mount) 000 DRIFTED (1 leg(s) not at the pin); legs, leg shape files and placement could not be run the same INVALID (pins); legs names the mount, the other two notes name it
spec/ 444 DRIFTED (...); legs and leg shape files could not be run DRIFTED (...); spec: populated, but with no .git of its own with a git submodule update NEXT, and every leg file absent with a cp NEXT INVALID (pins); spec/.git: cannot be read in all three rows
holder members/ 000 DRIFTED (a member is not at its pin), members could not be run the same verdict, members/IRRS: not populated -- the pinned copy is an unbootstrapped submodule INVALID (family); members names the mount
holder members/IRRS 000 DRIFTED (...), could not be run the same INVALID (family); members/IRRS: cannot be listed
the root 000, the root 444 a traceback out of Context NOT A SHAPE ROOT, exit 2 the refusal below, exit 3
the root's parent 000 a traceback at root.is_dir() REFUSED shape-doctor-root-missing: ... is not a directory on this machine the refusal below, exit 3
the root 111 COMPLIANT COMPLIANT COMPLIANT (not refused)

The new lines:

FINDING  manifest   scripts/validate-manifest.py: cannot be read (Permission denied), so the project's own gate was not run, and the standard's copy is not run in its place
         NEXT       python3 <shape>/shape-doctor.py --root <root>
         WHY        Make what this user may not open readable by this user: `chmod u+r` on a file, `chmod u+rx` on a directory (and on every directory above it inside the root), or fix who owns it. Then run this doctor again
FINDING  legs       spec: cannot be listed (Permission denied), so no HEAD in it is compared with the pin
FINDING  legs       spec/.git: cannot be read (Permission denied), so no HEAD in it is compared with the pin
note     leg shape files  spec: cannot be listed (Permission denied); code: AGENTS.md rendered, CLAUDE.md identical, .gitignore identical
note     placement  nothing is in the wrong leg in what could be read, and not everything could be: spec: cannot be listed (Permission denied); code: 1 path(s) over 1 of 5 tracked file(s)
FINDING  members    members/IRRS: cannot be read (Permission denied), so no HEAD in it is compared with the pin
FINDING  <row>      <path>: cannot be read (<reason>)

REFUSED shape-doctor-root-unreadable: --root <root>: cannot be read (Permission denied)
  Remediation: Make the root searchable by this user: `chmod u+rx` on it (and on every directory above it), or fix who owns it. Then run this doctor again

For a cause other than permission, each says the gate's other advice (OTHER_REASON, or OTHER_REASON_FOLDER for a mount it cannot list, and for the root). The --json entry of a leg or member carries mount_cannot_be_read. shape_advisory.py's record reader says it could not be read (PermissionError) for a record whose stat is refused, on every Python.

Tests

tests/test_doctor_unreadable_paths.py, 34 tests. MODE cases chmod a real path (skipped on Windows and as root, restored in a finally by mode_of) and run the doctor as a person does; STAND-IN cases use refused_stat (#383, which answers is_file() as 3.14 does) and #847's refusing, in process, on every platform. It imports its helpers from the files that own them (conftest, test_shape_doctor, test_doctor_unreadable_pin, test_doctor_legs_members_next, test_bump_leg_unopenable, and the two test_validate_pins_unreadable_* files): nothing is copied.

test kind what it pins kills
test_an_own_gate_this_user_cannot_reach_is_named_never_the_template mode, scripts/ 444 manifest and pins rows name the own copy, NEXT, WHY, own_copy, cannot_be_read; no passes; verdict not asserted (slice B) the cell; on 3.12 M01's raise is caught, so the stand-in kills it
test_a_leg_mount_this_user_cannot_reach_is_named_and_no_drift [spec-000, spec-r--] mode legs names the mount and keeps mount_cannot_be_read; leg shape files and placement are notes naming it; verdict INVALID (pins) the cell, on both Pythons
test_a_member_this_user_cannot_reach_is_named_and_no_drift [members-000, member-000, member-r--] mode, holder members names the mount, NEXT, WHY, never "unbootstrapped"; verdict INVALID (family) M29
test_a_root_this_user_cannot_search_is_refused_by_name [root-000, root-r--, above-000] mode exit 3, empty stdout, no traceback, the refusal line and its chmod u+rx remediation M02
test_a_root_this_user_can_search_and_not_list_is_not_refused mode, root 111 control: only search is asked; exit 0
test_validator_for_raises_a_refused_stat_and_names_the_own_copy stand-in validator_for raises; the manifest row names the copy, runs nothing (exit None) M01, M24
test_another_reason_for_the_own_copy_gets_the_other_advice stand-in, EIO the system's reason and OTHER_REASON
test_an_own_copy_that_is_not_there_still_falls_back_to_the_template control an absent own copy still gets the template
test_no_lockstep_line_is_named_for_a_gate_this_user_cannot_reach stand-in gate_line gives no command; confirm_digest says nothing M23
test_an_unloaded_manifest_names_an_own_gate_it_cannot_reach stand-in the manifest's refusal, then the copy's line; NEXT, WHY M25
test_a_leg_mount_refused_is_named_on_every_python [mount-stat, mount-listing, listing-io-error] stand-in each refusal's line, NEXT, the advice for the cause (OTHER_REASON_FOLDER for EIO), no drift M04, M05, M07, M19, M26
test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git stand-in, .git refused legs, leg shape files (note) and placement (n/a) all say <leg>/.git: cannot be read M06, M09, M10
test_beside_a_checkout_line_the_unread_mount_gets_the_advice stand-in the other leg's checkout line stays the NEXT; its WHY ends on the advice
test_a_member_mount_refused_is_named_on_every_python stand-in, holder members names the mount, NEXT this doctor, WHY, no drift M08, M21, M22
test_a_leg_file_this_user_cannot_read_is_said_not_called_absent stand-in a refused stat and a refused read are cannot be read; the row is a note with no copy M11, M12
test_a_bootstrap_this_user_cannot_reach_cannot_run_on_every_python stand-in own_bootstrap is False for a refused stat, True otherwise M13
test_a_git_directory_this_user_cannot_reach_is_raised [keeps_modules, kept_gitdir, checkout_reach] stand-in each raises the refused stat M14, M15, M16
test_a_root_whose_stat_is_refused_is_refused_on_every_python stand-in, main in process the root's own refused stat is the same refusal, never root-missing M03
test_a_refused_read_out_of_a_check_is_named_by_run_checks stand-in inside the root: the gate's line, NEXT, WHY, detail; elsewhere: could not be run M17
test_a_row_that_could_not_be_run_is_invalid_never_drift verdict a legs or members row with no entry is INVALID (<row>); a leg off its pin is still DRIFTED M18
test_a_member_this_user_cannot_reach_is_no_drift_beside_one_that_is verdict drift only from a member that can be read M20
test_a_record_this_user_cannot_reach_silences_nothing_on_every_python stand-in, advisory Tree.kind raises; the record silences nothing, it could not be read (PermissionError) M27
test_a_record_in_a_folder_this_user_cannot_search mode, advisory the same under a real r-- folder
test_a_record_that_is_not_there_is_still_absent control, advisory no file, a name under a file: None; a file, a folder: file, dir
test_the_advisory_calls_nothing_there_what_the_gate_calls_so parity _ABSENT_ERRNOS and _ABSENT_WINERRORS equal the gate's M28

Runs (/usr/bin/python3 -m pytest, Python 3.12.3, one process at a time):

set on main 50178305 (before) on 76ebca2e (after)
tests/test_shape_doctor.py 445 passed, 3 skipped 445 passed, 3 skipped
tests/test_doctor_*.py, files 1-9 (gitlink_repairs .. partial_clone) 478 passed, 6 skipped 478 passed, 6 skipped
tests/test_doctor_*.py, files 10-18 (paste_through_git .. validator_verdicts) 304 passed 304 passed
the trio, test_stdout_encoding.py, test_scaffold_leg_path_collisions.py, and the 8 files naming a changed function (test_adopt_finding_spelling, test_adopt_single_repository_record, test_bom_reads, test_bootstrap_non_utf8_branch, test_refusal_default_remediation, test_shape_advisory, test_validate_family_duplicates, test_validate_family_unnamed_mounts) 648 passed, 1 skipped 648 passed, 1 skipped
tests/test_doctor_unreadable_paths.py (new) 34 passed

render-cli-reference.py --check: [ok] (no help text changed). The before-runs of the doctor files ran from a clean clone of 50178305; the first test_shape_doctor.py run from the worktree before any edit.

Mutants (each one line, applied alone to the fixed file, only its killer run with -k, the fixed bytes restored and checked by sha256):

mutant the change killed by
M01 validator_for: is_a(own_path, S_ISREG) -> own_path.is_file() test_validator_for_raises_a_refused_stat_and_names_the_own_copy
M02 main: the search os.stat(<root>/.) -> pass test_a_root_this_user_cannot_search_is_refused_by_name [root-000, root-r--]
M03 main: is_a(root, S_ISDIR) -> root.is_dir() test_a_root_whose_stat_is_refused_is_refused_on_every_python
M04 mount_read: is_a(mount, S_ISDIR) -> mount.is_dir() test_a_leg_mount_refused_is_named_on_every_python [mount-stat]
M05 mount_read: a refused listing read as empty test_a_leg_mount_refused_is_named_on_every_python [mount-listing, listing-io-error]
M06 mount_read: .git asked with .exists() test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git
M07 check_legs: mount_read -> the two bare guards of 50178305 test_a_leg_mount_refused_is_named_on_every_python (all three)
M08 member_state: the same test_a_member_mount_refused_is_named_on_every_python
M09 leg_shape_comparison: the bare guard back test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git
M10 audit_leg: the bare guard back the same
M11 shape_file_state: here.is_file() test_a_leg_file_this_user_cannot_read_is_said_not_called_absent
M12 check_leg_shape_files: ok for an unread leg or file the same
M13 own_bootstrap: the refused stat raised test_a_bootstrap_this_user_cannot_reach_cannot_run_on_every_python
M14 kept_gitdir: path.is_dir() test_a_git_directory_this_user_cannot_reach_is_raised [kept_gitdir]
M15 keeps_modules: .is_dir() the same [keeps_modules]
M16 checkout_reach: (git_dir / "modules").is_dir() the same [checkout_reach]
M17 run_checks: named = None test_a_refused_read_out_of_a_check_is_named_by_run_checks
M18 drifted_reasons: drift from a legs row with no entry test_a_row_that_could_not_be_run_is_invalid_never_drift
M19 drifted_reasons: a leg whose mount cannot be read counted test_a_leg_mount_refused_is_named_on_every_python (all three)
M20 drifted_reasons: a member that cannot be read counted test_a_member_this_user_cannot_reach_is_no_drift_beside_one_that_is
M21 members_verdict: an unread member routed to a line test_a_member_mount_refused_is_named_on_every_python
M22 member_problem: the unread mount not said first the same
M23 gate_line: the refused stat raised test_no_lockstep_line_is_named_for_a_gate_this_user_cannot_reach
M24 validator_row: the refused stat raised test_validator_for_raises_a_refused_stat_and_names_the_own_copy
M25 unloaded_manifest_row: the refused stat raised test_an_unloaded_manifest_names_an_own_gate_it_cannot_reach
M26 check_legs: the unread-mount branch dropped test_a_leg_mount_refused_is_named_on_every_python [mount-listing, listing-io-error]
M27 Tree.kind: path.exists() test_a_record_this_user_cannot_reach_silences_nothing_on_every_python
M28 shape_advisory._ABSENT_ERRNOS + EACCES test_the_advisory_calls_nothing_there_what_the_gate_calls_so
M29 member_state: detached() asked of an unread mount test_a_member_this_user_cannot_reach_is_named_and_no_drift [member-r--] (a real mode: 3.12)

All 29 KILLED (S/w430c/mutants.log); the fixed bytes restored after each and checked by sha256.

Not changed

Part of #430

🤖 Generated with Claude Code

Summary by Sourcery

Make unreadable paths explicit and consistent across shape validation, refusing inaccessible roots early and preventing inaccessible data from being mistaken for absence or drift.

New Features:

  • Report unreadable paths consistently across the shape doctor and advisory tooling, including clear remediation guidance and JSON details.
  • Refuse roots that the current user cannot search before evaluating any repository rows.

Bug Fixes:

  • Prevent unreadable gates from falling back to standard templates, unreadable mounts and files from being treated as absent, and failed checks from being reported as DRIFTED.
  • Preserve refused-stat errors across Python versions and ensure advisory records distinguish unreadable files from missing files.

Enhancements:

  • Route unreadable leg and member mounts to INVALID findings with no misleading checkout or lockstep commands, while retaining applicable advice and rerun guidance.
  • Update CLI documentation to describe unreadable roots as an exit-3 environment condition.

Documentation:

  • Document the doctor's handling of roots that the current user cannot search in the README, CLI reference, and generated handbook.

Tests:

  • Add comprehensive cross-platform and permission-mode coverage for unreadable roots, gates, mounts, files, git directories, members, advisory records, and verdict classification.

Brett Heap and others added 2 commits October 18, 2026 16:32
…ser cannot reach is named on every Python, a root it cannot search is refused first, and a row that could not ask is never DRIFTED (#430)

Part of #430, slice C (lane 1's plan, issuecomment-6251069236), a
v1.2.0 draft. Checkpointed at a usage window: the code is written and
probed, the new tests are written and NOT YET RUN.

What changed, measured by a probe on Python 3.12.3 and a 3.14-like shim
(S/w430c/cells-before.txt, cells-after.txt):

- validator_for asks the own copy with is_a and raises a refused stat;
  the manifest, pins and family rows name the copy
  (`scripts/validate-manifest.py: cannot be read (Permission denied),
  so the project's own gate was not run, ...`) and never run the
  standard's template in its place. gate_line names no line for it.
- mount_read asks a leg's or member's mount (stat, listing, .git stat)
  for the legs, leg shape files, placement and members rows: a refused
  one is the gate's line (`spec: cannot be listed (...)`,
  `spec/.git: cannot be read (...)`, `members/IRRS: cannot be read
  (...)`), kept as mount_cannot_be_read, routed to no line.
- drifted_reasons counts drift only from a row's own readable entries,
  so a row that could not be run, or a mount that cannot be read, gives
  INVALID, never DRIFTED. `spec/` 000: DRIFTED (1 leg(s) not at the
  pin) -> INVALID (pins) on both; holder `members/` 000: DRIFTED ->
  INVALID (family) on both.
- run_checks names a refused read of a path in the root in the gate's
  words; any other OSError keeps "could not be run".
- main refuses a root this user cannot search (or whose stat is
  refused): REFUSED shape-doctor-root-unreadable: --root <root>: cannot
  be read (Permission denied), exit 3, before any row.
- own_bootstrap answers False for a refused stat on every Python;
  kept_gitdir, keeps_modules and checkout_reach ask with is_a.
- shape_advisory.py: Tree.kind asks a local _is_a with the rule's errno
  set, so a record it cannot stat silences nothing, on every Python.

Not yet done: run tests/test_doctor_unreadable_paths.py; the targeted
batches after the change; the 29 mutants (S/w430c/tools/mutants.py).
Baseline on 5017830: test_shape_doctor.py 445 passed, 3 skipped; the
first nine doctor files 478 passed, 6 skipped; the second nine running.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…annot reach is named on every Python, never read as absent or stood in for by the template, a root it cannot search is refused first, and a row that could not ask is never DRIFTED (#430)

Up to Python 3.13 `Path.is_file()`, `is_dir()` and `exists()` raise a
`stat` the operating system refuses; from 3.14 they answer False to any
`OSError`. The doctor asked them of the root's own paths, so one tree got
two wrong answers. A `scripts/` this user cannot search made the manifest
and pins rows `could not be run` on 3.12, and on 3.14 the standard's
template ran in the project's place and "passed". A leg mount at mode 000
made three rows `could not be run` and the verdict DRIFTED (1 leg(s) not
at the pin), on both; a holder's `members/` at 000 was DRIFTED with the
member `could not be run`, or "unbootstrapped" on 3.14; and a root this
user cannot search was a traceback, or NOT A SHAPE ROOT.

Each path is now asked as validate-pins.py asks it (the doctor's `is_a`;
a local `_is_a` with the same errno set in shape_advisory.py) and named
in its words, `<path>: cannot be read (<reason>)`, or `cannot be listed`
for a folder, with this doctor again as the NEXT and the gate's advice
for the cause as the WHY:

- validator_for raises a refused stat of the root's own copy: the
  manifest, pins and family rows name it and run nothing, never the
  template; gate_line names no line for it.
- mount_read asks a leg's or a member's mount, its listing and its
  `.git` for the legs, leg shape files, placement and members rows; an
  unread mount is kept as `mount_cannot_be_read` and routed to no line.
  A leg file whose stat or read is refused is `cannot be read`, not
  `absent`.
- drifted_reasons counts drift only from a row's own readable entries,
  so a row that could not be run, or a mount or member that cannot be
  read, gives INVALID naming the row, never DRIFTED.
- run_checks names a refused read of a path inside the root out of any
  check; any other OSError keeps "could not be run".
- main refuses a root whose stat, or whose search, is refused:
  `REFUSED shape-doctor-root-unreadable: --root <root>: cannot be read
  (Permission denied)`, exit 3, before any row. Only search is asked: a
  root at mode 111 stays COMPLIANT.
- own_bootstrap answers "cannot run" for a refused stat on every Python;
  kept_gitdir, keeps_modules and checkout_reach ask with is_a.
- shape_advisory.py's Tree.kind raises a refused stat, so a record it
  cannot reach silences nothing on every Python.

Measured on 3.12.3 and a 3.14-like shim: `spec/` at 000 or 444 is
INVALID (pins) on both; a holder's `members/` at 000 is INVALID
(family); the root at 000 or 444, or under a parent at 000, is refused
by name, exit 3. shape_advisory.py is not a shape copy, so no root gets
an upstream-changed row. The `copy-missing` rows 3.14 gives for an
unreadable `scripts/` or `.github/workflows/` come from update-shape.py
and are slice B's.

Tests: tests/test_doctor_unreadable_paths.py, 34 tests, real modes and
the in-process refused_stat stand-in; each of 29 one-line mutants that
puts a bare guard back fails one of them.

This commit, on the WIP checkpoint 5e7a044: the members stand-in test
holds the member's own sentence, which comes first, because the
stand-in refuses the sibling ../IRRS by name too and the row then
says what stands beside the holder. The code is 5e7a044's.

Part of #430

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

The PR introduces consistent, errno-aware unreadable-path semantics across shape-doctor.py and shape_advisory.py: roots that cannot be searched are refused up front, paths inside reachable roots are named with gate-aligned diagnostics, templates are never substituted for inaccessible project gates, and unreadable rows are INVALID or notes rather than false DRIFTED/absent results. A broad regression suite validates behavior across Python versions and both real permission modes and simulated OS refusals.

Sequence diagram for unreadable root handling

sequenceDiagram
    participant User
    participant Doctor as shape-doctor.py
    participant OS
    User->>Doctor: main(--root)
    Doctor->>OS: is_a(root, S_ISDIR)
    OS-->>Doctor: directory or refused OSError
    alt root stat refused
        Doctor-->>User: REFUSED shape-doctor-root-unreadable
    else root is searchable but not listable
        Doctor->>OS: stat(root/.)
        OS-->>Doctor: success
        Doctor-->>User: run checks
    else root search refused
        Doctor->>OS: stat(root/.)
        OS-->>Doctor: refused OSError
        Doctor-->>User: REFUSED shape-doctor-root-unreadable
    end
Loading

Sequence diagram for unreadable project gates

sequenceDiagram
    participant Doctor as shape-doctor.py
    participant Validator
    participant OS
    Doctor->>Validator: validator_for(ctx, own, template)
    Validator->>OS: is_a(own_path, S_ISREG)
    alt own gate readable
        OS-->>Validator: true
        Validator-->>Doctor: project gate
        Doctor->>Validator: run gate
    else own gate absent
        OS-->>Validator: false
        Validator-->>Doctor: standard template
        Doctor->>Validator: run template
    else own gate stat refused
        OS-->>Validator: refused OSError
        Validator-->>Doctor: raise OSError
        Doctor-->>Doctor: unreadable_gate_row()
        Doctor-->>Doctor: no template substitution
    end
Loading

Entity relationship diagram for unreadable check details

erDiagram
    CHECK_ROW ||--o{ LEG_ENTRY : contains
    CHECK_ROW ||--o{ MEMBER_ENTRY : contains
    LEG_ENTRY }o--|| MOUNT_READ_RESULT : records
    MEMBER_ENTRY }o--|| MOUNT_READ_RESULT : records
    CHECK_ROW {
        string status
        string next_command
        string why
    }
    LEG_ENTRY {
        string cannot_be_read
        string mount_cannot_be_read
        string head
        string pin
    }
    MEMBER_ENTRY {
        string mount_cannot_be_read
        string head
        string pin
    }
    MOUNT_READ_RESULT {
        string gate_line
        string reason
        boolean listing_refused
    }
Loading

Flow diagram for unreadable mount verdicts

flowchart TD
    A[Read leg or member mount] --> B{mount_read succeeds?}
    B -->|No| C[Record mount_cannot_be_read]
    C --> D[Name gate-aligned cannot-be-read line]
    D --> E[No checkout or lockstep command]
    E --> F[Run doctor again]
    B -->|Yes| G[Read mount listing and .git]
    G --> H{Readable mount data has a problem?}
    H -->|Yes| I[Evaluate pin or member drift]
    H -->|No| J[Continue normal checks]
    F --> K[INVALID, never DRIFTED]
    I --> L[DRIFTED only for readable entries]
Loading

File-Level Changes

Change Details Files
Normalize unreadable-path handling across the doctor so refused reads are explicitly reported, never confused with absence, and do not produce false drift or fallback behavior.
  • Added explicit root readability/searchability checks with exit-3 refusal diagnostics.
  • Replaced path predicates at key doctor sites with errno-aware stat handling that propagates unexpected read failures.
  • Named unreadable validator copies and prevented template substitution or gate execution.
  • Added shared mount-read classification for legs and members, including unreadable listings and .git directories.
  • Propagated mount/file refusal details into leg shape, placement, member, bootstrap, and low-level git-directory rows.
  • Updated check exception handling to name refused paths inside the root while preserving generic failures elsewhere.
  • Excluded unreadable or unevaluated entries from drift calculations so affected rows become INVALID rather than DRIFTED.
  • Preserved the existing absent-path behavior for ENOENT, ENOTDIR, EBADF, ELOOP, and specified Windows errors.
shape-doctor.py
Make advisory record classification consistent with the gate across Python versions by distinguishing absent paths from paths whose stat operation was refused.
  • Added a local errno-aware _is_a helper with parity to the gate's absent-error sets.
  • Changed Tree.kind to raise non-absent stat errors so unreadable records are reported as unreadable instead of absent.
  • Added parity coverage for errno and Windows-error classifications.
scripts/shape_advisory.py
Add comprehensive regression coverage for unreadable roots, gates, mounts, files, members, git directories, advisory records, and verdict behavior.
  • Added real-mode and platform-independent stand-in tests for refused stat, listing, and file reads.
  • Verified diagnostics, remediation, NEXT/WHY routing, JSON detail, template non-fallback, and no-drift semantics.
  • Covered controls for truly absent paths, missing own copies, searchable-but-unlistable roots, and readable drift.
  • Added mutation coverage expectations for the principal refusal and verdict branches.
tests/test_doctor_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

Brett Heap and others added 2 commits October 19, 2026 14:04
…ADME.md and the handbook: `member_state` is back under Sonar's 15 (16 -> 14), and every exit-3 list names a root this user cannot search (#975 review, fix round 1) (#430)

SHOULD 1 (S3776): `member_state`'s `refused` was optional, and the
nested `if refused is not None` took it from 13 to 16. Its one caller,
`check_members`, always passes its list, so the parameter is required
and the append unconditional: 14 by the review's own counter, and the
`--json` entry and the row's advice are the same bytes.

NIT 2: a root this user cannot search exits 3 since 5e7a044, and the
doctor's EXIT CODES said so; the four other lists of exit-3 causes did
not. Both launchers hand the path to `--root` unchecked
(openRepoShape:1069, cli.py:436), so the cause is theirs to name too:
"a <path> this user cannot search" in the `--doctor` help of
`openRepoShape` and `src/openreposhape/cli.py` (the paragraph re-wrapped
at its own 77 columns) and in `docs/cli.md` as the generator renders it,
"a path this user cannot search" in README.md's exit-3 sentence and the
handbook's copy of it. README.md keeps its line count (one line under
its cap), and the handbook's two records of the README section it reads
move to the section's new blob.

Part of #430

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-19T21:58:40Z) Independent review of #975 (#430 slice C) at 3fd80cbe, for lane openRepoShape-2: READY

Fixed since 76ebca2e, none open:

  • SHOULD (fixed): S3776 on shape-doctor.py:9966 member_state. It went from 13 to 16 at 76ebca2e; refused is now required, which brings it to 14.
  • NIT (fixed): the new exit-3 cause, a root this user cannot search, is now in README.md, the handbook, the openRepoShape and src/openreposhape/cli.py --doctor help, and docs/cli.md.

Checked and true

Could not verify

Lane 2's note: #975 is green at 3fd80cbe (tests and SonarCloud), and CI's full tests job covers tests/test_openreposhape_command.py. It lands after #976 and #986, one at a time, each gated again on the main of its turn. The order-dependent failure on main (test_a_hand_committed_gitlink_names_both_bump_leg_commands with test_doctor_partial_clone.py collected) is not this PR's.

Lane: openreposhape-2 (openRepoShape-2)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-19T22:39:47Z) Addendum to the independent review of #975 (#430 slice C) at 3fd80cbe, for lane openRepoShape-2: READY stands

This closes the items my READY left under "Could not verify". It was run on the merge of 3fd80cbe with main 46cf9240b. #976 has not landed: ls-remote shows main at 46cf9240b and #976's branch at 59b12500. Nothing new is a MUST, so #975 can land as it is. The three items below are for follow-ups.

Findings

  1. SHOULD, as a follow-up issue (not a MUST, does not hold the landing): a leg whose git directory this user cannot read still gives DRIFTED. This is the .git/modules/spec at mode 000 cell.
    • The pins row names the path correctly: REFUSED pin-submodule-unreadable: .git/modules/spec: cannot be read (Permission denied).
    • The legs row says spec: populated, but git cannot read a HEAD in it (shape-doctor.py:7444). drifted_reasons counts that leg as off its pin, and drift outranks INVALID.
    • So the verdict is DRIFTED (1 leg(s) not at the pin) for a leg that is at its pin, and placement says spec: it is not a git repository.
    • It is the same before and after this PR, on 3.12 and under a 3.14-like shim. It is git's own read, not a stat site, and not in slice C's site list, so it is not a regression.
    • Fix: where own_git holds and head is None, read the gitdir: line of the mount's .git file and ask that path with is_a. A refusal becomes the leg's mount_cannot_be_read line, so the leg is not counted as drift and the row gives the cure.
  2. NIT (not a MUST): 5 of my 17 mutants survive. All five sit in branches the tests never build, and the code is right by reading:
    • MI, :7428: a leg with no gitlink and a mount this user cannot reach loses the mount's line.
    • MJ, :10315: the .gitmodules guard for a member this user cannot reach. Without it, 3.12 raises out of the members row when another member needs make bootstrap.
    • MK1, :10324, and MK2, :10344: the cure beside another member's bootstrap or checkout line.
    • ML, :10285: rerun. This one is close to equivalent, because the advice already ends with "Then run this doctor again".
    • Fix: add a holder with two members, one this user cannot reach and one off its pin, as test_beside_a_checkout_line_the_unread_mount_gets_the_advice already does for legs. Add a leg with no gitlink over a mount this user cannot reach.
  3. NIT (not a MUST): a leg file at mode 000 is COMPLIANT with a note but no cure. I accept the COMPLIANT.
    • The cell is spec/CLAUDE.md at 000. Before this PR it was INVALID (leg-shape-files) on both Pythons; now it is COMPLIANT, exit 0, on both, with the note CLAUDE.md cannot be read (Permission denied).
    • Why I accept it:
      • The leg shape files row gates nothing: an absent or differing leg file is also only a note under COMPLIANT.
      • So no content of that file could change the verdict.
      • The old INVALID came from the row crashing ("could not be run"), not from a judgment.
    • The PR's stated reason ("the legs row asserts the mount") covers a whole mount, not one file.
    • Fix: every other path in this PR that cannot be read carries the gate's advice as its WHY (cannot_read_why). Give this note the same WHY (shape-doctor.py:7832).

Checked and true

  • tests/test_openreposhape_command.py: 461 passed.
  • 12 of my 17 mutants are killed:
    • MA: the advisory's _is_a errno filter dropped;
    • MB: the doctor's is_a errno filter dropped;
    • MC: os.stat(root) asked in place of root/.;
    • MD: the root refusal always given the folder advice;
    • ME and MF: mount_read swallowing the .git refusal, then the mount's own;
    • MG: the NEXT dropped for a member this user cannot reach;
    • MH: a refused listing counted as a refused file;
    • MM: the file half of the leg shape files note check dropped;
    • MO: an empty legs row counted as drift;
    • MP: no NEXT on the run_checks row;
    • MR: validator_for falling back to the template on a refused stat.
  • Probes: verdicts with the base's doctor (50178305) and with the head's, each on 3.12 and under a 3.14-like sitecustomize shim:
cell base head
spec/ at 000 DRIFTED (1 leg(s) not at the pin), both INVALID (pins), both
spec/ at 444 DRIFTED, both INVALID (pins), both
spec/ at 111 DRIFTED, both INVALID (legs), both, with spec: cannot be listed
holder's members/ at 000 DRIFTED (a member is not at its pin), both INVALID (family), both
holder's members/IRRS at 000 DRIFTED, both INVALID (family), both
scripts/validate-manifest.py file at 000 INVALID (manifest, pins), both the same
scripts/repo_shape.py file at 000 INVALID (manifest, pins), both the same
.git/modules/spec at 000 DRIFTED, both the same (finding 1)
spec/CLAUDE.md file at 000 INVALID (leg-shape-files), both COMPLIANT, both (finding 3)
controls (project and holder) COMPLIANT COMPLIANT

Could not verify

  • A real Python 3.14. Only 3.12 is on this host, so 3.14 is covered by the shim and the in-process refused_stat only.

Lane 2's decision: #975 lands as it is, after #976 and #986, gated again over the main of its turn. The SHOULD and the two NITs go to follow-ups, which lane 2 proposes to Brett Heap before filing.

Lane: openreposhape-2 (openRepoShape-2)

brettheap added a commit that referenced this pull request Oct 20, 2026
…ames `--family-root`, so each runs as printed (#927) (#1002)

Closes #927, from lane 3's triage of #459 (U074, L2-70).

## What

Every remedy that tells someone to run `family.py bump` now names `--family-root`, so each one runs as printed. `bump` requires `--family-root` (the usage at `scripts/family.py:7`).

- **`scripts/family.py`:** the `member-already-present` and `member-duplicate-id` remedies name `--family-root <holder>`. The holder is quoted with `shell_quoted(family.root)`, as `add`'s own line is.
- **`scripts/bump-leg.py`:** the `bump-leg-assembly-is-not-a-leg` remedy names `--family-root <holder>`, a placeholder, because bump-leg has no holder to name.
- **Two more in bump-leg,** in their own commit (`7b42c652`): `bump-leg-root-not-a-project` and `bump-leg-wrong-kind`. Each pointed at a bare `scripts/family.py bump`, and now gives the full line.
- **README and the handbook** (`3df537a4`): README's "Windows, natively" sentence and the handbook's Windows panel now name `family.py`'s `bump` line among the lines quoted for the shell they are printed in, because the two remedies above quote the holder for it. `tests/test_readme_windows_quoting_sentence.py` holds both to the code; CI's full suite caught the gap (run 39311987496).
- **The sweep:** `git grep` and a multi-line scan found 36 hits. Every other hit already carries `--family-root`, or is prose or history with no line to type. No pinned copy prints a bare `bump`.

## Tests

All runs used `/usr/bin/python3 -m pytest` under a stub `gh`, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`.

- **`tests/test_bump_remedies_family_root.py`, 21 rows:**
  - **`family.py`:** both codes, in three holder folders (a plain one, `my work` and `Dana's work`). Each checks three things:
    - the printed line, and that `shlex` gives the holder back as one word;
    - that `family.py`'s own parser accepts it;
    - that it runs for real as printed: exit 0, the pin moved, the holder clean.
  - **`bump-leg.py`:** the three remedies, each also accepted by `family.py`'s parser.
- **Against `cf66102f`'s code:** all 21 fail. The 12 `parses` and `runs_as_printed` rows fail on `family.py bump: error: the following arguments are required: --family-root`; the 6 `carries_the_holder_whole` rows and the `bump-leg-assembly-is-not-a-leg` row fail on the printed line, which has no `--family-root`; and the `bump-leg-root-not-a-project` and `bump-leg-wrong-kind` rows find no `bump` line at all, since both remedies named a bare `scripts/family.py bump`.
- **`tests/test_family.py` and `tests/test_family_add_member_id.py`** follow the new words. The latter's `bump` row now runs the printed line exactly.
- **Fifteen family, bump-leg and docs files:** 585 passed and 1 skipped, which needs a case-insensitive disk.
- **The review's fix round** (`1baafc95`): the printed `bump` line is split by the host's own words, and a filled-in holder is quoted, so a holder path with a space is one argument. Under a basetemp with a space, 4 rows failed at `7b42c652` and pass at the head.
- **At `3df537a4`:** `test_readme_windows_quoting_sentence.py` 44, `test_repo_hygiene.py` 100, `test_readme_sections.py` 76, `test_setup_by_hand_commands.py` 5 and `test_bump_remedies_family_root.py` 21 passed.

## Notes

- **Merges:** clean with main `3864b18c`, and with #975, the other open PR that changes README and the handbook, in another section.
- **Follow-up, outside `bump`:** #1005. `member-duplicate-id` also says `` `family.py remove --member <P>` ``, and `bump`'s `member-unknown` says `family.py add`; both verbs require `--family-root` too.
- **Squash message:** the head's commits include two `WIP (#927)` ones, and the landing squash carries its own message.
- **Review:** lane 1's independent reviewer found `3df537a4` READY (#1002 issuecomment-6278129416).

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `3df537a4` is green on Linux and SonarCloud. The branch merged main twice and was never rebased; the last merge is update-branch's, clean, giving c22f161, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 14 test files that run `bump`, `add` or the remedies that name `--family-root`, and read README and the handbook, 722 passed and 1 skipped.

Closes #927

Lane: openreposhape-1 (openRepoShape-1)
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 04:45
@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 20 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit d7ee65a into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/430-c-doctor-unreadable branch October 20, 2026 04:45
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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-20T09:52:10Z) A correction to lane 2's notes on #975 (6278304447 and 6278933012), and to its landing text, d7ee65a. test_a_hand_committed_gitlink_names_both_bump_leg_commands is not order-dependent. It fails whenever LANES_LANE is exported, which is #1042, filed by lane 1.

Lane: openreposhape-2 (openRepoShape-2)

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>
brettheap pushed a commit that referenced this pull request Oct 22, 2026
…eviews and the Known limits continuation, with lanes 2, 4 and 5's items: eleven sites left to #1110, the doctor's 000 and 444 mount rows, the #1082 upgrade warning and the #891 and #1103 limits said as main's code does, #1112 and lane 2's two doctor limits named, and the shape copies measured (#921)

The fix round on RV1155a, RV1155b, RV1155c and RV1155d, the continuations
RV1155d2 and RV1155a2, and the comments lanes openRepoShape-4 and
openRepoShape-5 left on #1155:

- RV1155c's two MUSTs: `spec/` at 000 is `cannot be listed` and at 444
  `spec/.git: cannot be read`; `scripts/` at 444 is
  `INVALID (manifest, pins)` on 3.14 as on 3.12.
- Every SHOULD (a 3, b 2, c 3, d 13) and every NIT but RV1155a's NIT 7,
  RV1155a2's eight among them.
- Where lane 5 and a reviewer word one line, one text covers both:
  #891 (d SHOULD 6, lane 5 SHOULD 7), #1103 (d SHOULD 7, lane 5
  SHOULD 4) and #1090 (c's first NIT, lane 5 NIT 14).
- KNOWN_OPEN in tests/test_path_guard_hygiene.py holds eleven sites, so
  both lines say eleven.
- #1112 is a known limit; lane 2's two doctor limits (#975) and its
  test-only gaps (#975, #1050) are listed, and the "INVALID, not DRIFTED"
  sentence holds for a mount this user cannot list or read.
- The shape-copy measurement fills its placeholder; the "Before tagging"
  pointer is gone.
- The pipx claim carries RV1155d's qualifier everywhere it is made.
- v1.0.0's tab-in-a-pin line is closed (#1015); its bare number or
  boolean in a plan's text field is narrowed (#1016).
- `update-shape.py check` is named with `--at <release-commit>`, and the
  measurement says main `038dafb` was the upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…cannot read gives the gate's advice as its WHY, and each branch #975 added for a mount this user cannot reach is built by a test (#1175) (#1193)

Two things were open after #975. Five of the branches it added to the
`legs` and `members` rows for a mount this user cannot reach were built
by no test, so a one-site mutant of each passed. And the
`leg shape files` note for a leg file this user cannot read gave its
reason (`CLAUDE.md cannot be read (Permission denied)`) but no WHY.

`tests/test_doctor_unreadable_paths.py` now builds each of the five.
`test_a_leg_with_no_gitlink_names_the_mount_it_cannot_reach_too` covers
`check_legs`: a leg with no gitlink recorded over a mount whose listing
is refused. The four in `members_verdict` need a holder with two
members, which no case had, so a checkout cures the unreached one.
`test_beside_a_member_checkout_the_unreached_member_gets_the_advice`
runs for the holder's bootstrap and for the git line, and
`test_a_member_at_mode_000_beside_one_a_checkout_cures` uses a real
`chmod 000`.

The note's WHY is new. `shape_file_state` returns the words and the
`OSError` of a refused `stat` or read, `leg_shape_comparison` returns a
third value, the `OSError` of each leg file this user cannot read, and
`check_leg_shape_files` gathers them. The note's `why` is
`cannot_read_why(refused)`: `PERMISSION_ADVICE` for a permission and
`OTHER_REASON` for any other cause, ending "Then run this doctor again".
Beside a missing file's line, the advice follows that line's own why,
because the copy is still the NEXT for the missing file. NEXT stays None
and the row stays a note, so the verdict does not change: no leg file
gates anything. A whole mount this user cannot reach gets no why here,
since the `legs` row names it and gives the advice.

`tests/test_doctor_unreadable_paths.py` has 43 passed (34 at main), and
488 passed, 3 skipped in one pytest with `tests/test_shape_doctor.py`,
whose session fixtures it imports. All twelve single-site mutants of
`shape-doctor.py` are killed, and the five of #975 each pass main's copy
of the file.

Closes #1175

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 23, 2026
… read is named in the gate's words, `spec: .git/modules/spec: cannot be read (<reason>)`, so the `legs` and `members` rows never count it as drift, `placement` never calls it "not a git repository", and no HEAD is read for it (#1174) (#1200)

Where this user cannot read a leg's or a member's git directory
(`.git/modules/spec`, or a `.git` gitfile or directory at mode 000 or
400), `shape-doctor.py` counted the mount as drift ("populated, but git
cannot read a HEAD in it"), `placement` called it "not a git
repository", and the verdict was DRIFTED. `validate-pins.py` has named
that directory and refused it since #968, and the verdict is INVALID
there.

`mount_read`, the one question the `legs`, `leg shape files`,
`placement` and `members` rows share, now also asks the git directory as
the gate's `_refuse_an_unreadable_git_dir` does. `git_dir_of(dotgit)` is
a copy of the gate's `_leg_git_dir`, and the `stat` of that directory's
`HEAD` is read through `is_a`. A refused read becomes the mount's
`mount_cannot_be_read` line, so `head_of` never runs in the mount. The
line names the path the way the gate does, normalized and relative to
the root, and starts with the mount's path where the directory is
outside the mount:
`spec: .git/modules/spec: cannot be read (Permission denied)`. The three
rows then do what #975 made them do for a mount this user cannot reach.
The NEXT is this doctor again, the WHY is the gate's advice, drift skips
the leg or member, and the verdict falls to the `pins` row's INVALID, or
the `family` row's for a holder. A gitfile that names no directory, or a
git directory that is not there, is left to git, as before.

Two choices the issue left open. The `leg shape files` row still
compares such a leg's files, since it runs no git, so
`leg_shape_comparison` asks `mount_read(..., git_dir=False)`. And the
guard on the holder's bootstrap note (`if MOUNT_UNREAD not in entry`)
now also skips the `.gitmodules` of a member whose git directory cannot
be read, so no keep line is owed for that member's legs.

`tests/test_doctor_unreadable_paths.py` has 68 passed, and 513 passed, 3
skipped in one pytest with `tests/test_shape_doctor.py`, whose session
fixtures it imports. Each of the 19 mutants run is killed by it, and all
but one pass the parent PR's copy of the file. One test pins the second
choice: the member's why is the same with that file and without it.

Closes #1174

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