Repository navigation
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
Conversation
…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>
Reviewer's GuideUpdates 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 refusalsequenceDiagram
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
Sequence diagram for transactional apply rollback on unreadable readssequenceDiagram
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
Flow diagram for unreadable-path handling in update-shape.pyflowchart 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"]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…430 slice B's fix round 1
… 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>
|
(lane openRepoShape-2, 2026-10-19T21:51:00Z) Independent review of #976 (#430 slice B) at Fixed since
Findings
Checked and true
Could not verify
Lane 2's next step: #976 is green at Lane: openreposhape-2 (openRepoShape-2) |
|
|
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 |
…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>
…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>



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:
ENOENT,ENOTDIR,EBADFandELOOP(and Windows 21,123 and 1921) mean absent.
OSErroris refused by name, as<path relative to the root>: cannot be read (<strerror>), exit 2.validate-pins.py's_unreadable_file:chmod u+r/chmod u+rx, or itsOTHER_REASONfor any cause other than permission._is_a, withvalidate-pins.py:253's errno set.scripts/repo_shape.pyis untouched.One new code,
update-unreadable, made in one place:main.read_pinand
prepareRAISE the refused read rather than refuse it. The doctor tellsthe two apart:
OSErrorof a file in the root (refused_in_root, shape-doctor.py: for a pin or acontracts/this user cannot read, thelegs,manifest kindsandshape currencyrows disagree with validate-pins.py's named refusal, andmanifest kindsnames agit checkout HEAD --that discards work #847);Refusalout ofprepareas CANNOT ANSWER, with agit fetchNEXT.
Per site (lines at
50178305)::599 read_pinupdate-pin-missing; the doctor already catches anOSErrorfromread_pin. A pin at mode 000, whosestatanswers and whose read is refused, raises too (_read_yaml), where it wasyaml-unreadablenaming the absolute path with no remedy:778 _classify_rowcopy-missing. A copy at mode 000, whose read already raised on every Python (a traceback), is named bymaintoo:2310 run_validatorRefusal)cmd_applyrolls back on aRefusalonly; absent staysis absent; SKIPPED:291 root_kindstat, andprepareasks that first:2114 preparestat(a folder above it this user cannot search); then NEW,os.stat(<root>/.), so a root it cannot search is named whole, not as afamily.yamla project has not got:496/:498 open_upstreamowner/reponame" (and a relativea/bwould have been cloned from github.com), and a checkout it cannot search wasupdate-upstream-not-a-repo:719/:721 merged_intoos.path.lexistsanswers False to a refusedlstaton EVERY Python, so ashape/this user cannot search made the rowmerged-away, andapplywould drop its pin row:1893 ReadmeShapeLine._classify(and:1885)statorlstatisunreadable, neverabsent. Prose: no exit code moves:1147/:1174 symlink_on_the_way,not_a_directory_on_the_waylinked_record_row, which runs beforeread_pinand catches noOSError, in "could not be run" for acontracts/at 000 that #847 names todayTwo more in the same file, of the same class:
:1230 find_additionsraises.os.path.lexistsread anupstream-addedpath under a folder this user cannot search as "nothingthere", so
checkoffered--addover a file the root may hold.:3212/:3227 Rollback._removelists a refusedstatas not removed.On 3.14 a refused
statanswered False tois_dir()andexists(), so an--addfile was neither removed nor listed. The rollback then said "thetree is exactly as it was". An
--addfile that something else took awaybefore the rollback (a validator, the operator) is still not listed, as on
50178305: there is nothing left to remove.applymid-run: never a half-written root.preparemakes every read the run judges by:the pin, the manifest's link, every pinned copy and every addition.
run_validatorrefuses an unreadable validator, aRefusal;cmd_applygains anOSErrorarm. It turns any refused read with afile name into
update-unreadableand hands it to_rolled_back, whichputs back every byte and lists what it could not;
OSErroris raised as it was.A refused read closes the upstream, as a
Refusaldoes (review of thisPR).
prepareclosed whatopen_upstreamhad opened on aRefusalalone.A refused read out of
classify,find_additionsormerged_intowent pastupstream.close()on its way tomain. Without--upstream, everyupdate-unreadablefrom there left the bare clone of the pin'sowner/repoin the temporary directory, for
checkandapply. The handler is nowexcept (Refusal, OSError):.No other
except Refusal:holds an upstream or a temporary without afinally:Upstream.resolveand_read_yamlhold nothing;mainonly prints the refusal;cmd_checkandcmd_applyclose their upstream in afinally;open_upstreamremoves its own temporary when the clone fails.What changed
Measured by probes in a scaffolded root (local remotes, a stub
gh), onPython 3.12.3 and with a 3.14-like
sitecustomize(Path.is_file,is_dir,existsandis_symlinkanswer False to anyOSError). Every"after" is the same on both.
Tests
New file
tests/test_update_shape_unreadable.py, 48 tests. On50178305, 39 fail and 9 pass; the 9 are the controls, which pass on both. After the change, all 48 pass.chmod, restored in afinally(mode_of). They skip on Windows and as root (needs_a_mode).refused_stat, or a refusedos.lstat, which answer as 3.14 does on any Python, root included.test_check_names_what_it_cannot_read[scripts-444, contracts-000, workflows-444]checkcells refuseupdate-unreadableby name, exit 2, with nocopy-missing,does not existor tracebacktest_apply_names_what_it_cannot_read_and_writes_nothingscripts/444 underapply --yes: refused before the first write, pin untouchedtest_a_refused_stat_is_named_on_every_python[3 cells x check, apply]test_a_read_refused_in_prepare_closes_the_upstream[check, apply]scripts/refused, with the upstream handed a temporary as theowner/repoclone 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]copy-missing, andapplyupdate-conflicttest_a_pin_that_is_gone_is_still_missingupdate-pin-missing, "does not exist"test_a_readable_root_has_nothing_to_donothing to do, exit 0test_a_pin_this_user_cannot_open_is_named_from_the_rootupdate-unreadable, neveryaml-unreadablewith an absolute pathtest_a_copy_this_user_cannot_open_is_namedtest_a_read_refused_is_named_on_every_python[the-pin, a-copy]test_a_read_refused_for_another_reason_gets_the_gates_other_advice[EIO, ENAMETOOLONG]validate-pins.py'sOTHER_REASONandRUN_AGAIN, read from the gate itself, and nochmod(review of this PR)test_a_root_this_user_cannot_search_is_named_in_fullfamily.yaml; no "inside the root"test_a_root_under_a_folder_this_user_cannot_search_is_not_missingupdate-root-missingtest_a_root_whose_stat_is_refused_is_not_missingtest_root_kind_raises_a_refused_stat[2],test_root_kind_still_reads_what_is_therestatis raised; the two kinds still readtest_an_upstream_this_user_cannot_read_is_named[2]--upstreamunder a folder at 000, or at 000 itself, is named in fulltest_open_upstream_raises_a_refused_stat[3]open_upstream's three questionstest_a_shape_folder_this_user_cannot_search_is_no_mergemerged_intoraises, never reads the row as merged-awaytest_merged_into_raises_a_refused_read[2],test_merged_into_still_reads_what_is_theretest_an_addition_under_a_folder_this_user_cannot_search_is_namedfind_additionsraises, never says "not present"test_an_addition_whose_lstat_is_refused_is_raised,test_an_addition_still_reads_what_is_theretest_a_readme_this_user_cannot_stat_is_unreadable_not_absent[stat, lstat]unreadable, neverabsentand never a raisetest_a_validator_whose_stat_is_refused_is_not_skipped,test_a_validator_that_is_not_there_is_still_skippedupdate-unreadable, neverSKIPPED; an absent validator is still SKIPPEDtest_a_validator_unreadable_after_the_writes_rolls_them_backtest_any_read_refused_after_the_writes_rolls_them_backcmd_apply'sOSErrorarm rolls backtest_a_rollback_that_cannot_stat_what_it_made_says_so--addfile it could not removetest_an_add_file_gone_before_the_rollback_is_not_listed--addfile 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:
read_pin's and_classify_row'sis_file();run_validator's;root_kind's two;prepare'sis_dir();_search;open_upstream's three;merged_into'slexistsandis_file();find_additions'lexists;is_file()andis_symlink();_remove'sexists();cmd_apply'sOSErrorarm;main's;yaml-unreadable;ENOENTfrom the absent set;prepare'sexcept Refusal:, and fails both upstream cases on the temporary left behind;chmodadvice, and fails both advice cases;_remove'selif mode is not None:anelse:, 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'sis_dir(), was retired when_removecame to ask onestat: of the two guards it had, either alone masked a mutant of the other.)Targeted runs, one pytest at a time,
/usr/bin/python33.12.3:501783057086e8ca)59b12500d)tests/test_update_shape_unreadable.pytests/test_update_shape*.py, the first 9 filestests/test_update_shape*.py, the other 9test_shape_doctor.pyand sixtest_doctor_*files that run update-shapetest_repo_hygiene.py,test_cli_reference.py,test_agents_md_codes.py)test_family.py -k update_shapeThe reviewer's runs (rv976), on the merge of
59b12500dwith main3864b18cc: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.pyis slice C's, so it is not edited. These are left for it (lines at50178305):refusals_ahead(:3227,:3236) catches only aRefusalfromread_pinandprepare. A refused read now raises there on 3.14, as it already did on 3.12. It is reached only fromwith_merged_away_rows, which needs the pins gate at exit 1 with only merged-away findings, and the root's ownvalidate-pins.pyrefuses an unreadable path at exit 2 first. The review of this PR found it cannot be reached today.merged_away_findings(:3118) callsmerged_into, andlinked_record_row(:6231) callsroot_kind, with noOSErrorhandler. Reaching either needs the same gate exit, or a root that cannot be searched, which is the root check slice C adds.manifestrow forscripts/444 runs the TEMPLATE validator (validator_for,:2000); on 3.12 thepinsandmanifestrows saycould not be run: [Errno 13].update-shape.py, as BENIGN or not reachable (lines at50178305)::2840write_added'sp.exists(): it isupdate-write-failedon both Pythons, and rolled back.:3035,:3132and:3148: the rollback's deliberateos.pathcalls. 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 ischmod u+x.:3549hard_links: the write re-asks withopen.:4595_extend_the_eol_block'sis_file()on.gitattributes: a root-level file, refused only for a rootpreparehas 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.OSErrorthere (a file gone between thestatand the read, a full disk on a read) still escapesapplyas it did, with nothing put back:cmd_apply's new arm raises it again (:5166-5167at this head). The review of this PR named it, and it goes to a follow-up.check.applyrefuses it later asyaml-unreadable, with the absolute path.update-unreadablenames the FIRST path it cannot read, wherevalidate-pins.py'sworkflow-unreadablelists every one. For a folder, its cure covers them all.scripts/repo_shape.pyis untouched. No--helptext changed, and no document listsupdate-shape.py's codes apart from theupdate-add-*sentence.Part of #430
🤖 Generated with Claude Code
Summary by Sourcery
Make
update-shape.pytreat unreadable paths as explicit refusals rather than absences, while preserving rollback safety and cleanup.New Features:
update-unreadablewith path-specific remediation instead of treating them as missing or emitting tracebacks.applywhen a required read becomes unreadable after writes begin.Bug Fixes:
Enhancements:
Documentation:
update-shape.pyhelp and documentation text.Tests: