Skip to content

update-shape.py: a path in the root or the upstream this user cannot read is update-unreadable on every Python, so a scripts/ it can list but not search is no longer five copy-missing and a validator it cannot stat is never SKIPPED - #976

Merged
brettheap merged 6 commits into
mainfrom
fix/430-b-update-shape-unreadable
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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

Slice B of #430's plan (lane 1, issuecomment-6251069236): update-shape.py.
A v1.2.0 DRAFT. It lands only on a v1.2.0 merge word.

Decisions

The plan's rule, as it stands:

  • Stat the path. ENOENT, ENOTDIR, EBADF and ELOOP (and Windows 21,
    123 and 1921) mean absent.
  • Any other OSError is refused by name, as
    <path relative to the root>: cannot be read (<strerror>), exit 2.
  • The advice is validate-pins.py's _unreadable_file: chmod u+r /
    chmod u+rx, or its OTHER_REASON for any cause other than permission.
  • A local _is_a, with validate-pins.py:253's errno set.
  • scripts/repo_shape.py is untouched.

One new code, update-unreadable, made in one place: main. read_pin
and prepare RAISE the refused read rather than refuse it. The doctor tells
the two apart:

Per site (lines at 50178305):

Site Decision Why
:599 read_pin raises absent stays update-pin-missing; the doctor already catches an OSError from read_pin. A pin at mode 000, whose stat answers and whose read is refused, raises too (_read_yaml), where it was yaml-unreadable naming the absolute path with no remedy
:778 _classify_row raises absent, or not a regular file, stays copy-missing. A copy at mode 000, whose read already raised on every Python (a traceback), is named by main too
:2310 run_validator refuses (a Refusal) it runs after the writes, and cmd_apply rolls back on a Refusal only; absent stays is absent; SKIPPED
:291 root_kind raises only a root this user cannot search refuses either stat, and prepare asks that first
:2114 prepare raises the root's own stat (a folder above it this user cannot search); then NEW, os.stat(<root>/.), so a root it cannot search is named whole, not as a family.yaml a project has not got
:496/:498 open_upstream raises on 3.14, a path under a folder this user cannot search was "neither a path ... nor an owner/repo name" (and a relative a/b would have been cloned from github.com), and a checkout it cannot search was update-upstream-not-a-repo
:719/:721 merged_into raises os.path.lexists answers False to a refused lstat on EVERY Python, so a shape/ this user cannot search made the row merged-away, and apply would drop its pin row
:1893 ReadmeShapeLine._classify (and :1885) a reading, never a raise that class promises not to raise: a refused stat or lstat is unreadable, never absent. Prose: no exit code moves
:1147/:1174 symlink_on_the_way, not_a_directory_on_the_way kept: "no link" (BENIGN) every caller asks the path itself next, which now raises or refuses by name; and raising there would end the doctor's linked_record_row, which runs before read_pin and catches no OSError, in "could not be run" for a contracts/ at 000 that #847 names today

Two more in the same file, of the same class:

  • :1230 find_additions raises. os.path.lexists read an
    upstream-added path under a folder this user cannot search as "nothing
    there", so check offered --add over a file the root may hold.
  • :3212/:3227 Rollback._remove lists a refused stat as not removed.
    On 3.14 a refused stat answered False to is_dir() and exists(), so an
    --add file was neither removed nor listed. The rollback then said "the
    tree is exactly as it was". An --add file that something else took away
    before the rollback (a validator, the operator) is still not listed, as on
    50178305: there is nothing left to remove.

apply mid-run: never a half-written root.

  • Before the first write: prepare makes every read the run judges by:
    the pin, the manifest's link, every pinned copy and every addition.
  • After it:
    • run_validator refuses an unreadable validator, a Refusal;
    • cmd_apply gains an OSError arm. It turns any refused read with a
      file name into update-unreadable and hands it to _rolled_back, which
      puts back every byte and lists what it could not;
    • any other OSError is raised as it was.

A refused read closes the upstream, as a Refusal does (review of this
PR). prepare closed what open_upstream had opened on a Refusal alone.
A refused read out of classify, find_additions or merged_into went past
upstream.close() on its way to main. Without --upstream, every
update-unreadable from there left the bare clone of the pin's owner/repo
in the temporary directory, for check and apply. The handler is now
except (Refusal, OSError):.

No other except Refusal: holds an upstream or a temporary without a
finally:

  • Upstream.resolve and _read_yaml hold nothing;
  • main only prints the refusal;
  • cmd_check and cmd_apply close their upstream in a finally;
  • open_upstream removes its own temporary when the clone fails.

What changed

Measured by probes in a scaffolded root (local remotes, a stub gh), on
Python 3.12.3 and with a 3.14-like sitecustomize (Path.is_file,
is_dir, exists and is_symlink answer False to any OSError). Every
"after" is the same on both.

`scripts/` 444 (listable, not searchable): check, and apply --yes
  before, 3.12   Traceback ... in _classify_row ... PermissionError: [Errno 13]
                 Permission denied: '<root>/scripts/validate-pins.py'   (exit 1)
  before, 3.14   check: copy-missing scripts/validate-pins.py, and four more (exit 1)
                 apply: REFUSED update-conflict: ... are pinned and gone from the root (exit 2)
  after          REFUSED update-unreadable: scripts/validate-pins.py: cannot be read (Permission denied)
                 Remediation: this command reads the shape pin, the manifest, every copy the
                 shape pin lists and the --upstream checkout, and one it cannot read is a
                 question it cannot answer, never an absence. 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 the
                 same command again.                                     (exit 2)

`contracts/` 000: check
  before, 3.12   Traceback ... in read_pin ... PermissionError ... contracts/shape-pin.yaml (exit 1)
  before, 3.14   REFUSED update-pin-missing: <root>/contracts/shape-pin.yaml does not exist, ...
                 A project without a shape pin is adopted with `adopt-project.py`, not updated.
  after          REFUSED update-unreadable: contracts/shape-pin.yaml: cannot be read (Permission denied)

`.github/workflows/` 444: check
  before, 3.12   Traceback ... in _classify_row ... .github/workflows/validate.yml (exit 1)
  before, 3.14   copy-missing .github/workflows/validate.yml               (exit 1)
  after          REFUSED update-unreadable: .github/workflows/validate.yml: cannot be read (Permission denied)

the pin at 000, in a readable contracts/: check
  before, both   REFUSED yaml-unreadable: <root>/contracts/shape-pin.yaml: cannot be read (Permission denied)
                 (no remedy)
  after          REFUSED update-unreadable: contracts/shape-pin.yaml: cannot be read (Permission denied)

a copy at 000, in a readable scripts/: check
  before, both   Traceback ... in _classify_row ... read_bytes ... PermissionError (exit 1)
  after          REFUSED update-unreadable: scripts/bootstrap.py: cannot be read (Permission denied)

the root at 600: check
  before, 3.12   Traceback ... in root_kind ... '<root>/family.yaml'            (exit 1)
  before, 3.14   REFUSED update-pin-missing: <root>/contracts/shape-pin.yaml does not exist, ...
  after          REFUSED update-unreadable: <root>: cannot be read (Permission denied)
                 ... `chmod u+rx` on a directory (and on every directory above it), ...

--upstream under a folder at 000 / --upstream at 000
  before, 3.12   Traceback ... in open_upstream ... exists ...                  (exit 1)
  before, 3.14   REFUSED update-upstream-unresolvable: --upstream '<path>' is neither a path
                   on this machine nor an `owner/repo` name
                 REFUSED update-upstream-not-a-repo: --upstream <path> exists but is not a git
                   repository
  after          REFUSED update-unreadable: <path>: cannot be read (Permission denied)
                 REFUSED update-unreadable: <path>/.git: cannot be read (Permission denied)

in process (stand-ins), before -> after, on both:
  merged_into(root, "shape/Makefile"), shape/ at 000   'Makefile' (merged-away) -> PermissionError
  find_additions, docs/ at 000 holding docs/new.md    present False -> PermissionError
  README.md whose stat is refused                     3.12 raised / 3.14 `absent` ->
                                                      readme-shape-line: unreadable (README.md is not readable — Permission denied)
  Rollback.undo, an --add file whose stat is refused  3.14: "ROLLED BACK: ... exactly as it was" ->
                                                      "NOT put back ... docs/new.md, written by --add, was not removed (Permission denied): rm -- <path>"
  `scripts/` refused, the upstream a temporary clone   3.14: a false copy-missing, clone removed;
                                                      round 0 of this PR: update-unreadable, clone LEFT ->
                                                      update-unreadable, clone removed (check and apply)
  a read refused with EIO or ENAMETOOLONG             validate-pins.py's OTHER_REASON, never `chmod`

the doctor, under the 3.14-like shim (shape-doctor.py not edited)
  `scripts/` 444            DRIFTED (5 copy-missing) -> INVALID (pins)
  `.github/workflows/` 444  DRIFTED (1 copy-missing) -> INVALID (pins)

controls, unchanged on both: a root with nothing changed `nothing to do` (exit 0); a pinned
copy that is gone `copy-missing` and `apply`'s `update-conflict ... pinned and gone from the
root`; a pin that is gone `update-pin-missing`.

Tests

New file tests/test_update_shape_unreadable.py, 48 tests. On 50178305, 39 fail and 9 pass; the 9 are the controls, which pass on both. After the change, all 48 pass.

  • The MODE cases use a real chmod, restored in a finally (mode_of). They skip on Windows and as root (needs_a_mode).
  • The STAND-IN cases run in process with refused_stat, or a refused os.lstat, which answer as 3.14 does on any Python, root included.
Test Kind What it pins
test_check_names_what_it_cannot_read [scripts-444, contracts-000, workflows-444] mode #430's three measured check cells refuse update-unreadable by name, exit 2, with no copy-missing, does not exist or traceback
test_apply_names_what_it_cannot_read_and_writes_nothing mode scripts/ 444 under apply --yes: refused before the first write, pin untouched
test_a_refused_stat_is_named_on_every_python [3 cells x check, apply] stand-in the same six as 3.14 answers them
test_a_read_refused_in_prepare_closes_the_upstream [check, apply] stand-in scripts/ refused, with the upstream handed a temporary as the owner/repo clone is: update-unreadable, and the temporary removed (review of this PR)
test_a_copy_that_is_gone_is_still_copy_missing [this-python, as-3.14] control a copy that is NOT there is copy-missing, and apply update-conflict
test_a_pin_that_is_gone_is_still_missing control update-pin-missing, "does not exist"
test_a_readable_root_has_nothing_to_do control nothing to do, exit 0
test_a_pin_this_user_cannot_open_is_named_from_the_root mode a pin at 000: update-unreadable, never yaml-unreadable with an absolute path
test_a_copy_this_user_cannot_open_is_named mode a copy at 000: named, never a traceback
test_a_read_refused_is_named_on_every_python [the-pin, a-copy] stand-in the two above, with the read refused
test_a_read_refused_for_another_reason_gets_the_gates_other_advice [EIO, ENAMETOOLONG] unit a cause other than permission gets validate-pins.py's OTHER_REASON and RUN_AGAIN, read from the gate itself, and no chmod (review of this PR)
test_a_root_this_user_cannot_search_is_named_in_full mode a root at 600 is named whole, never as family.yaml; no "inside the root"
test_a_root_under_a_folder_this_user_cannot_search_is_not_missing mode never update-root-missing
test_a_root_whose_stat_is_refused_is_not_missing stand-in the same
test_root_kind_raises_a_refused_stat [2], test_root_kind_still_reads_what_is_there stand-in, control each manifest's refused stat is raised; the two kinds still read
test_an_upstream_this_user_cannot_read_is_named [2] mode --upstream under a folder at 000, or at 000 itself, is named in full
test_open_upstream_raises_a_refused_stat [3] stand-in each of open_upstream's three questions
test_a_shape_folder_this_user_cannot_search_is_no_merge mode merged_into raises, never reads the row as merged-away
test_merged_into_raises_a_refused_read [2], test_merged_into_still_reads_what_is_there stand-in, control both of its guards; a real merge still reads
test_an_addition_under_a_folder_this_user_cannot_search_is_named mode find_additions raises, never says "not present"
test_an_addition_whose_lstat_is_refused_is_raised, test_an_addition_still_reads_what_is_there stand-in, control the same
test_a_readme_this_user_cannot_stat_is_unreadable_not_absent [stat, lstat] stand-in the README is unreadable, never absent and never a raise
test_a_validator_whose_stat_is_refused_is_not_skipped, test_a_validator_that_is_not_there_is_still_skipped stand-in, control update-unreadable, never SKIPPED; an absent validator is still SKIPPED
test_a_validator_unreadable_after_the_writes_rolls_them_back stand-in mid-run: the refusal, and every byte put back
test_any_read_refused_after_the_writes_rolls_them_back stand-in cmd_apply's OSError arm rolls back
test_a_rollback_that_cannot_stat_what_it_made_says_so stand-in the rollback lists the --add file it could not remove
test_an_add_file_gone_before_the_rollback_is_not_listed control an --add file already gone is not listed as left, and its folder is removed (review of this PR)

Mutants: 24 one-line mutants, run on the final file, each killed:

  • M1 and M2 put back read_pin's and _classify_row's is_file();
  • M3 run_validator's;
  • M4 and M5 root_kind's two;
  • M6 prepare's is_dir();
  • M7 drops _search;
  • M8 to M10 put back open_upstream's three;
  • M11 and M12 merged_into's lexists and is_file();
  • M13 find_additions' lexists;
  • M14 and M15 the README's is_file() and is_symlink();
  • M16 _remove's exists();
  • M18 drops cmd_apply's OSError arm;
  • M19 drops main's;
  • M20 keeps yaml-unreadable;
  • M21 drops ENOENT from the absent set;
  • M22 always says "inside the root";
  • X16o puts back prepare's except Refusal:, and fails both upstream cases on the temporary left behind;
  • X8 gives every cause the chmod advice, and fails both advice cases;
  • X15 makes _remove's elif mode is not None: an else:, and fails the rollback case, which then says "ROLLED BACK IN PART ... docs/new.md, written by --add, was not removed (No such file or directory)".

(M17, _remove's is_dir(), was retired when _remove came to ask one stat: of the two guards it had, either alone masked a mutant of the other.)

Targeted runs, one pytest at a time, /usr/bin/python3 3.12.3:

Set On 50178305 Round 0 (7086e8ca) This head (59b12500d)
tests/test_update_shape_unreadable.py 39 failed, 9 passed 43 passed (of 43) 48 passed
tests/test_update_shape*.py, the first 9 files 335 passed, 3 skipped 335 passed, 3 skipped 335 passed, 3 skipped
tests/test_update_shape*.py, the other 9 418 passed 418 passed 434 passed (main's merge added tests)
test_shape_doctor.py and six test_doctor_* files that run update-shape 238 run, none failed (stopped, host load 300) 831 passed, 9 skipped 836 passed, 9 skipped
the trio (test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py) not run 148 passed 170 passed
the trio and the other files that name what changed (14 files) not run 1142 passed, 3 skipped not run again
test_family.py -k update_shape not run 4 passed, 108 deselected not run again
the tests that scan the shipped tools' source (7 files) not run 460 passed not run again

The reviewer's runs (rv976), on the merge of 59b12500d with main 3864b18cc:

  • the doctor batch, 6 files: 650 passed, 3 skipped;
  • the hygiene and source-scanning files: 528 passed, 1 skipped;
  • test_family.py -k update_shape: 4 passed.

Nothing is red after the change, so no test needed a base run to tell whether it was red before. scripts/render-cli-reference.py --check: ok.

Not changed

  • shape-doctor.py is slice C's, so it is not edited. These are left for it (lines at 50178305):
    • refusals_ahead (:3227, :3236) catches only a Refusal from read_pin and prepare. A refused read now raises there on 3.14, as it already did on 3.12. It is reached only from with_merged_away_rows, which needs the pins gate at exit 1 with only merged-away findings, and the root's own validate-pins.py refuses an unreadable path at exit 2 first. The review of this PR found it cannot be reached today.
    • merged_away_findings (:3118) calls merged_into, and linked_record_row (:6231) calls root_kind, with no OSError handler. Reaching either needs the same gate exit, or a root that cannot be searched, which is the root check slice C adds.
    • Under the shim the doctor's manifest row for scripts/ 444 runs the TEMPLATE validator (validator_for, :2000); on 3.12 the pins and manifest rows say could not be run: [Errno 13].
  • Not changed in update-shape.py, as BENIGN or not reachable (lines at 50178305):
    • :2840 write_added's p.exists(): it is update-write-failed on both Pythons, and rolled back.
    • :3035, :3132 and :3148: the rollback's deliberate os.path calls. They choose a by-hand line's words, never a write. One wording gap: a folder this user cannot SEARCH is told "make writable", where its cure is chmod u+x.
    • :3549 hard_links: the write re-asks with open.
    • :4595 _extend_the_eol_block's is_file() on .gitattributes: a root-level file, refused only for a root prepare has refused. Slice Z's hygiene test may list it.
    • :5258 _differs_from_head.
    • _imports_on_disk (:4071) and _names_defined (:4398): "what it cannot tell, it does not refuse".
    • Upstream.close (:369): its own temporary directory.
  • Other limits:
    • Only a REFUSED read after the first write is turned into a rolled-back refusal. Any other OSError there (a file gone between the stat and the read, a full disk on a read) still escapes apply as it did, with nothing put back: cmd_apply's new arm raises it again (:5166-5167 at this head). The review of this PR named it, and it goes to a follow-up.
    • A manifest at mode 000 is not read by check. apply refuses it later as yaml-unreadable, with the absolute path.
    • update-unreadable names the FIRST path it cannot read, where validate-pins.py's workflow-unreadable lists every one. For a folder, its cure covers them all.
  • scripts/repo_shape.py is untouched. No --help text changed, and no document lists update-shape.py's codes apart from the update-add-* sentence.

Part of #430

🤖 Generated with Claude Code

Summary by Sourcery

Make update-shape.py treat unreadable paths as explicit refusals rather than absences, while preserving rollback safety and cleanup.

New Features:

  • Report unreadable paths consistently as update-unreadable with path-specific remediation instead of treating them as missing or emitting tracebacks.
  • Rollback changes made by apply when a required read becomes unreadable after writes begin.

Bug Fixes:

  • Distinguish unreadable files, directories, roots, upstreams, additions, merged paths, and validators from genuinely absent paths across supported Python versions.
  • Ensure unreadable upstreams and temporary clones are closed and cleaned up on failure.
  • Preserve readable README classification and distinguish absent validators from validators that cannot be read.

Enhancements:

  • Centralize filesystem checks so only absence-related errors are treated as missing while other operating-system errors propagate for user-facing handling.
  • Improve rollback reporting when files cannot be inspected or have already disappeared.

Documentation:

  • Document unreadable-path behavior and its exit-code semantics in update-shape.py help and documentation text.

Tests:

  • Add comprehensive coverage for unreadable roots, pins, copies, upstreams, additions, validators, README files, and rollback behavior across Python filesystem semantics.

Brett Heap and others added 2 commits October 18, 2026 16:37
…nnot read is `update-unreadable`, named from the root, on every Python, never `copy-missing`, `update-pin-missing` or a SKIPPED validator (#430)

Slice B of #430's plan (issuecomment-6251069236), at a checkpoint. The
code and its new tests are complete; the targeted test set is not yet
all run against it.

On Python 3.14 `Path.is_file()`, `is_dir()`, `exists()` and
`is_symlink()` answer False to any OSError, and `os.path.lexists` always
has; up to 3.13 they raise a refused stat. So on 3.14 a `scripts/` this
user can list but not search was five `copy-missing` (and `apply`
refused `update-conflict` over them), and a `contracts/` at 000 was
`update-pin-missing`; on 3.12 each was a traceback.

Every guard in update-shape.py that must tell "absent" from "unreadable"
now stats the path itself, through a local `_is_a`, `_lexists` and
`_is_link` with validate-pins.py's errno set. `read_pin` and `prepare`
RAISE a refused read, for the doctor (#847); `main` refuses it as
`update-unreadable`, `<path from the root>: cannot be read (<reason>)`,
exit 2, with validate-pins.py's `_unreadable_file` advice.

The guards changed: `read_pin` (and a pin at mode 000), `_classify_row`
(and a copy at mode 000), `root_kind`, `prepare` (and a new search
check of `<root>/.`), `open_upstream`, `merged_into`, `find_additions`,
the README reader (`unreadable`, never `absent`, never a raise),
`run_validator` (refused, never SKIPPED) and `Rollback._remove`.
`cmd_apply` turns a refused read after the first write into
`update-unreadable` and rolls back.

Tested so far against this change:
- tests/test_update_shape_unreadable.py (new): 43 passed (35 of them
  fail on 5017830; the 8 that pass there are the controls);
- 21 one-line mutants, each killed by a new test;
- tests/test_update_shape*.py, batch 1 of 2: 335 passed, 3 skipped,
  as on 5017830;
- render-cli-reference.py --check: ok.

Not yet run against it, so not known green: test_update_shape*.py batch
2 (running at this commit), the doctor files, the trio and the other
files that name what changed. Nothing is known red.

Part of #430

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read is `update-unreadable`, named from the root, on every Python, so a `scripts/` it can list but not search is no longer five `copy-missing`, a `contracts/` at 000 no longer `update-pin-missing`, a `shape/` it cannot search no longer drops a pin row as merged-away, and a validator it cannot stat is refused and rolled back, never SKIPPED (#430)

This commit ends the WIP at 37989a6: the targeted set is now all run
against the change, and green. Its own change is in the new test file
only: the two in-process read-refused tests are one parametrized test,
so no twelve-line body is in the file twice.

On Python 3.14 `Path.is_file()`, `is_dir()`, `exists()` and `is_symlink()`
answer False to any OSError, and `os.path.lexists` always has; up to 3.13
they raise a refused stat. So, measured on 5017830:

- in a root whose `scripts/` this user can list but not search, `check`
  called five copies `copy-missing`, and `apply` refused `update-conflict`,
  asking for them to be put back from history;
- a `contracts/` at 000 was `update-pin-missing`, whose remedy adopts the
  project;
- on 3.12 each of those was a traceback.

Every guard in update-shape.py that must tell "absent" from "unreadable"
now stats the path itself, through a local `_is_a`, `_lexists` and
`_is_link` with validate-pins.py's errno set. ENOENT, ENOTDIR, EBADF and
ELOOP are absent; any other OSError is raised.

`read_pin` and `prepare` raise it rather than refuse it, because the doctor
tells the two apart: it names a raised OSError of a file in the root
(#847), and reads a Refusal out of `prepare` as CANNOT ANSWER. `main`
refuses it as `update-unreadable`: `<path from the root>: cannot be read
(<reason>)`, exit 2, with validate-pins.py's `_unreadable_file` advice.

The guards:

- `read_pin`, and a pin at mode 000, which was `yaml-unreadable` with an
  absolute path and no remedy;
- `_classify_row`, and a copy at mode 000, a traceback on every Python;
- `root_kind`;
- `prepare`: the root's own stat, then a new search check of `<root>/.`, so
  a root at 600 is named whole and not as a `family.yaml` a project has not
  got;
- `open_upstream`'s three questions;
- `merged_into`, where `lexists` read a `shape/` this user cannot search as
  a merged-away row, whose pin row `apply` would drop;
- `find_additions`, which offered `--add` over a file the root may hold;
- the README reader, which now reads a refused stat as `unreadable`, never
  `absent`, and still never raises.

`apply` is never left half-written:

- `run_validator` refuses an unreadable validator, where 3.14 SKIPPED it;
- `cmd_apply` turns any refused read after the first write into
  `update-unreadable` and rolls back;
- the rollback lists an `--add` file whose stat is refused, where 3.14
  claimed "exactly as it was".

The symlink walk keeps "no link" on an OSError: every caller asks the path
itself next.

Under 3.14 the doctor's false `DRIFTED (5 copy-missing)` and `DRIFTED (1
copy-missing)` are now `INVALID (pins)`, with no change to the doctor.

Tests:
- tests/test_update_shape_unreadable.py, new, 43 tests. On 5017830, 35
  fail and the 8 controls pass; after, all 43 pass. 21 one-line mutants
  of the guards, each killed by one of them.
- tests/test_update_shape*.py: 335 passed and 3 skipped, and 418 passed,
  as on 5017830.
- test_shape_doctor.py and six test_doctor_* files: 831 passed, 9 skipped.
- The trio and the other files that name what changed: 1142 passed, 3
  skipped.
- test_family.py -k update_shape: 4 passed.
- The source-scanning tests: 460 passed.

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

Updates update-shape.py to consistently distinguish unreadable paths from missing paths across Python versions, report them as named update-unreadable refusals with actionable remediation, and preserve transactional apply behavior through rollback; adds 43 regression tests covering filesystem permissions, refused stat/lstat operations, controls, and mid-run failures.

Sequence diagram for preflight unreadable-path refusal

sequenceDiagram
    participant User
    participant Main as main
    participant Prepare as prepare
    participant PathChecks as _is_a/_mode_of
    participant Refusal as unreadable_refusal

    User->>Main: check or apply
    Main->>Prepare: prepare(args)
    Prepare->>PathChecks: stat root and required paths
    PathChecks-->>Prepare: OSError for refused read
    Prepare-->>Main: raised OSError
    Main->>Refusal: unreadable_refusal(root, exc)
    Refusal-->>Main: update-unreadable Refusal
    Main-->>User: named path, remediation, exit 2
Loading

Sequence diagram for transactional apply rollback on unreadable reads

sequenceDiagram
    participant User
    participant Apply as cmd_apply
    participant Prepare as prepare
    participant Ledger
    participant Validator as run_validator
    participant Rollback as _rolled_back

    User->>Apply: apply --yes
    Apply->>Prepare: prepare(args)
    Prepare-->>Apply: validated plan
    Apply->>Ledger: perform writes
    Apply->>Validator: run_validator(root, script)
    Validator-->>Apply: Refusal(update-unreadable)
    Apply->>Rollback: _rolled_back(refusal, ledger)
    Rollback-->>Apply: restore written bytes and report failures
    Apply-->>User: update-unreadable, rolled back
Loading

Flow diagram for unreadable-path handling in update-shape.py

flowchart TD
    A["update-shape.py inspects a path"] --> B{_mode_of: stat or lstat}
    B -->|ENOENT, ENOTDIR, EBADF, ELOOP, or Windows absent error| C["Treat as absent"]
    B -->|Other OSError| D["Raise refused read"]
    C --> E["Existing missing-path behavior"]
    D --> F["main calls unreadable_refusal"]
    F --> G["REFUSED update-unreadable with named path and remediation"]
Loading

File-Level Changes

Change Details Files
Introduce cross-version detection that distinguishes unreadable paths from genuinely absent paths.
  • Add errno-aware stat and lstat helpers that preserve refused OSErrors while treating only the established absence errors as missing.
  • Replace path predicates across root, pin, copy, upstream, merge, addition, validator, and rollback handling.
  • Ensure unreadable paths are named relative to the root, or fully for the root and upstream paths, with permission-specific or fallback remediation.
update-shape.py
Route unreadable-read failures through consistent refusal and rollback behavior.
  • Add the update-unreadable refusal code and centralize formatting of path, cause, and remediation.
  • Keep read_pin and prepare exceptions raisable for doctor integration while converting top-level failures into named refusals.
  • Convert validator failures and post-write OSErrors into rollback-triggering refusals; report rollback stat failures instead of silently claiming success.
update-shape.py
Preserve non-raising README classification while correcting unreadable-state reporting.
  • Classify refused README stat or lstat operations as unreadable rather than absent.
  • Leave intentional benign path-walk behavior unchanged where callers subsequently inspect the target path.
update-shape.py
Add comprehensive regression coverage for permission and refused-stat behavior.
  • Test real permission modes and Python-3.14-like stat/lstat shims across check and apply.
  • Verify absent controls remain copy-missing, pin-missing, or validator SKIPPED.
  • Verify pre-write refusal prevents writes, post-write refusal restores all bytes, and rollback reports files it cannot inspect or remove.
tests/test_update_shape_unreadable.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

… so an `update-unreadable` from `classify`, `find_additions` or `merged_into` no longer leaves the bare clone of the pin's `owner/repo` behind (#430)

Fix round 1 of PR #976, from rv976's review:

- MUST 1: `prepare` closed the upstream it had opened on `except Refusal:`
  alone. The refused reads this PR raises out of `classify`,
  `find_additions` and `merged_into` went past it to `main`. Without
  `--upstream`, every `update-unreadable` from there left the bare clone
  in the temporary directory, for `check` and `apply`. The handler is now
  `except (Refusal, OSError):`. No other `except Refusal:` holds an
  upstream or a temporary without a `finally`.
- SHOULD 2: the advice split in `unreadable_refusal` is tested. A read
  refused with EIO or ENAMETOOLONG gets validate-pins.py's OTHER_REASON,
  word for word, and no `chmod`.
- SHOULD 3: `Rollback._remove`'s `elif mode is not None:` is tested. An
  `--add` file that something else took away before the rollback is not
  listed as left, as on 5017830.

Tests: tests/test_update_shape_unreadable.py, 48 passed (5 new).
- The old `except Refusal:` fails both new upstream cases.
- Giving every cause the `chmod` advice fails both new advice cases.
- `else:` in place of `elif mode is not None:` fails the new rollback case.

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:51:00Z) Independent review of #976 (#430 slice B) at 59b12500d, for lane openRepoShape-2: READY

Fixed since 7086e8ca (all three re-checked at 59b12500d, on the merge with main 04923f10d):

  • Was a MUST: update-shape.py:2373 (prepare). prepare closed the upstream only on except Refusal:. A refused read raised out of classify, find_additions or merged_into therefore skipped upstream.close(). With no --upstream, the default clones the pin's owner/repo into a temporary folder, so every update-unreadable from that block left that clone behind.
    • It is now except (Refusal, OSError):.
    • test_a_read_refused_in_prepare_closes_the_upstream[check, apply] covers it. With the fix reverted, both tests fail.
    • My own probe passes all 7 cases.
  • Was a SHOULD: update-shape.py:431 (unreadable_refusal). No test held which advice a refusal gets, the chmod advice or OTHER_REASON.
    • test_a_read_refused_for_another_reason_gets_the_gates_other_advice[EIO, ENAMETOOLONG] now checks it against validate-pins.py's own OTHER_REASON, word for word.
    • The mutant that gives every cause the chmod advice now fails 2 tests.
  • Was a NIT: update-shape.py:3504 (Rollback._remove). Changing elif mode is not None: to else: survived every test.
    • test_an_add_file_gone_before_the_rollback_is_not_listed now catches it (1 failed, 69 passed).

Findings

  1. NIT (a follow-up, not this PR): update-shape.py:5160 (cmd_apply's new OSError arm).
  2. NIT (for slice C or slice Z): shape-doctor.py:3228 and :3237 at main (refusals_ahead).

Checked and true

  • The rule at every changed guard.
    • _is_a, _lexists and _is_link treat ENOENT, ENOTDIR, EBADF, ELOOP and Windows 21, 123 and 1921 as absent, and raise any other OSError.
    • scripts/repo_shape.py is untouched; the diff has two files.
    • The sites it covers: read_pin, _classify_row, root_kind, prepare and its new _search, open_upstream's three questions, merged_into, find_additions, the README reader, run_validator and Rollback._remove.
  • The sites the writer called benign. I agree with each. Line numbers are at 7086e8ca:
    • write_added's parent exists() (:3103) runs inside ledger.writing, so a failure is update-write-failed and rolled back;
    • the rollback's os.path calls (:3298, :3395, :3411) only choose the words of a by-hand line;
    • hard_links;
    • the .gitattributes is_file();
    • _differs_from_head, _imports_on_disk and _names_defined, whose paths prepare has already read;
    • Upstream.close.
  • The link walk keeps "no link" on purpose, and a test holds it. Making symlink_on_the_way raise fails 2 tests.
  • The tests fail on the old code. The new file at 7086e8ca, run against main's update-shape.py, gave 35 failed and 8 passed, and the 8 are the controls.
  • Raising instead of refusing is right for the doctor.
    • check_shape_currency names an OSError from read_pin (pin_refusal_row) and from prepare (refused_in_root), and reads a Refusal from prepare as CANNOT ANSWER.
    • The doctor is the only caller of read_pin or prepare outside update-shape.py.
  • apply is never left half-written in the cases I tried:
    • a read refused after the first write is rolled back;
    • a validator refused partway through is rolled back;
    • an --add path under a folder this user cannot search is refused in prepare, before any write;
    • an --add file whose stat is refused at rollback time is listed as not removed;
    • a root at mode 600 is named in full.
  • The admitted limit that only the first unreadable path is named: I accept it. The chmod u+rx cure for a folder covers every file under it.
  • The docs.
    • No document lists update-shape.py's refusal codes apart from the update-add-* sentence.
    • tests/test_agents_md_codes.py checks only from the document to the tools.
    • tests/test_cli_reference.py passes.
  • Sonar, by my own estimate.
    • No changed function goes over 15 for cognitive complexity. The highest are ReadmeShapeLine._classify at 14, Rollback._remove at 14, and cmd_apply and main at 12.
    • The test file has no composite asserts.
    • The only 4-line repeat shared with another test file is an import block.
  • Merges. 59b12500d merges clean with main 3864b18cc, and with 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 at both 76ebca2e and 3fd80cbe.
  • Tests on the merge with 3864b18cc:
    • tests/test_shape_doctor.py and five tests/test_doctor_*.py files: 650 passed, 3 skipped;
    • 11 hygiene and source-scanning files: 528 passed, 1 skipped;
    • tests/test_family.py -k update_shape: 4 passed.
  • Tests on the merge with 04923f10d: the new file, 48 passed.
  • Tests on the merge with c7a3ff16 (the 18 tests/test_update_shape*.py files have not changed since): the first 9 gave 335 passed and 3 skipped, the other 9 gave 434 passed.

Could not verify

Lane 2's next step: #976 is green at 59b12500d (tests and SonarCloud), so lane 2 gates it on the predicted main and asks for the turn on #473. The two NITs go to follow-ups.

Lane: openreposhape-2 (openRepoShape-2)

@sonarqubecloud

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit 36e39a0 into main Oct 19, 2026
4 of 5 checks passed
@brettheap
brettheap deleted the fix/430-b-update-shape-unreadable branch October 19, 2026 23:12
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 added a commit that referenced this pull request Oct 23, 2026
…er its first write, says what it put back on stderr, and raises the error as it was, so a file gone between its `stat` and its read, a broken pipe or an `EIO` no longer leaves the tree half-written behind a traceback (#1173) (#1187)

`update-shape.py apply` rolled back an `OSError` after its first write
only where the system had refused a read (#976). Any other one was
raised as it was, with every copy, the pin and the manifest left
written: a file gone between its `stat` and its read, an interpreter or
a working directory gone when a validator starts, or an error that names
no path (a `BrokenPipeError` from a flush into a pipe nobody reads any
more, an `EIO`). The traceback sat over a half-written tree and said
nothing of what was written.

`cmd_apply`'s `except OSError` arm, where `unreadable_refusal` answers
None, now calls the new `_roll_back_before_raising(ledger)` and then
re-raises with a bare `raise`. The helper asks `ledger.undo()` and, when
it returns None (no write had been made, or the commit landed), says
nothing. Otherwise it flushes stdout, letting an `OSError` from the
flush go because the error being answered may be stdout's own, and
prints what was put back on stderr, in `_rolled_back`'s own words. A
`Refusal` or a `CommandFailed` still goes to `_rolled_back`, and a
refused read is still made `update-unreadable`, with #976's rows and
messages as they were. The error is raised, not made a refusal, so the
tree is as it was under the traceback. Three docstrings that said
`apply` rolls back only on a `Refusal` are put in the past tense.

`tests/test_update_shape_unreadable.py` has 54 passed, 6 rows added.
`test_any_oserror_after_the_writes_rolls_them_back_and_is_raised` runs a
file gone, a broken pipe and an `EIO` naming no path: each leaves the
tree byte for byte as it was, says on stderr only what was put back, and
raises the same error object.
`test_a_stdout_nobody_reads_is_rolled_back_under_its_broken_pipe` runs
the tool as a subprocess with its stdout a pipe whose reader is gone, as
`apply --yes | head -1` leaves it. Two more rows hold the ends where
there is nothing to put back: the error is raised as it always was and
stderr is empty. Each of the seven mutants run against the file is
killed.

Closes #1173

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