Repository navigation
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
Conversation
…ser cannot reach is named on every Python, a root it cannot search is refused first, and a row that could not ask is never DRIFTED (#430) Part of #430, slice C (lane 1's plan, issuecomment-6251069236), a v1.2.0 draft. Checkpointed at a usage window: the code is written and probed, the new tests are written and NOT YET RUN. What changed, measured by a probe on Python 3.12.3 and a 3.14-like shim (S/w430c/cells-before.txt, cells-after.txt): - validator_for asks the own copy with is_a and raises a refused stat; the manifest, pins and family rows name the copy (`scripts/validate-manifest.py: cannot be read (Permission denied), so the project's own gate was not run, ...`) and never run the standard's template in its place. gate_line names no line for it. - mount_read asks a leg's or member's mount (stat, listing, .git stat) for the legs, leg shape files, placement and members rows: a refused one is the gate's line (`spec: cannot be listed (...)`, `spec/.git: cannot be read (...)`, `members/IRRS: cannot be read (...)`), kept as mount_cannot_be_read, routed to no line. - drifted_reasons counts drift only from a row's own readable entries, so a row that could not be run, or a mount that cannot be read, gives INVALID, never DRIFTED. `spec/` 000: DRIFTED (1 leg(s) not at the pin) -> INVALID (pins) on both; holder `members/` 000: DRIFTED -> INVALID (family) on both. - run_checks names a refused read of a path in the root in the gate's words; any other OSError keeps "could not be run". - main refuses a root this user cannot search (or whose stat is refused): REFUSED shape-doctor-root-unreadable: --root <root>: cannot be read (Permission denied), exit 3, before any row. - own_bootstrap answers False for a refused stat on every Python; kept_gitdir, keeps_modules and checkout_reach ask with is_a. - shape_advisory.py: Tree.kind asks a local _is_a with the rule's errno set, so a record it cannot stat silences nothing, on every Python. Not yet done: run tests/test_doctor_unreadable_paths.py; the targeted batches after the change; the 29 mutants (S/w430c/tools/mutants.py). Baseline on 5017830: test_shape_doctor.py 445 passed, 3 skipped; the first nine doctor files 478 passed, 6 skipped; the second nine running. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…annot reach is named on every Python, never read as absent or stood in for by the template, a root it cannot search is refused first, and a row that could not ask is never DRIFTED (#430) Up to Python 3.13 `Path.is_file()`, `is_dir()` and `exists()` raise a `stat` the operating system refuses; from 3.14 they answer False to any `OSError`. The doctor asked them of the root's own paths, so one tree got two wrong answers. A `scripts/` this user cannot search made the manifest and pins rows `could not be run` on 3.12, and on 3.14 the standard's template ran in the project's place and "passed". A leg mount at mode 000 made three rows `could not be run` and the verdict DRIFTED (1 leg(s) not at the pin), on both; a holder's `members/` at 000 was DRIFTED with the member `could not be run`, or "unbootstrapped" on 3.14; and a root this user cannot search was a traceback, or NOT A SHAPE ROOT. Each path is now asked as validate-pins.py asks it (the doctor's `is_a`; a local `_is_a` with the same errno set in shape_advisory.py) and named in its words, `<path>: cannot be read (<reason>)`, or `cannot be listed` for a folder, with this doctor again as the NEXT and the gate's advice for the cause as the WHY: - validator_for raises a refused stat of the root's own copy: the manifest, pins and family rows name it and run nothing, never the template; gate_line names no line for it. - mount_read asks a leg's or a member's mount, its listing and its `.git` for the legs, leg shape files, placement and members rows; an unread mount is kept as `mount_cannot_be_read` and routed to no line. A leg file whose stat or read is refused is `cannot be read`, not `absent`. - drifted_reasons counts drift only from a row's own readable entries, so a row that could not be run, or a mount or member that cannot be read, gives INVALID naming the row, never DRIFTED. - run_checks names a refused read of a path inside the root out of any check; any other OSError keeps "could not be run". - main refuses a root whose stat, or whose search, is refused: `REFUSED shape-doctor-root-unreadable: --root <root>: cannot be read (Permission denied)`, exit 3, before any row. Only search is asked: a root at mode 111 stays COMPLIANT. - own_bootstrap answers "cannot run" for a refused stat on every Python; kept_gitdir, keeps_modules and checkout_reach ask with is_a. - shape_advisory.py's Tree.kind raises a refused stat, so a record it cannot reach silences nothing on every Python. Measured on 3.12.3 and a 3.14-like shim: `spec/` at 000 or 444 is INVALID (pins) on both; a holder's `members/` at 000 is INVALID (family); the root at 000 or 444, or under a parent at 000, is refused by name, exit 3. shape_advisory.py is not a shape copy, so no root gets an upstream-changed row. The `copy-missing` rows 3.14 gives for an unreadable `scripts/` or `.github/workflows/` come from update-shape.py and are slice B's. Tests: tests/test_doctor_unreadable_paths.py, 34 tests, real modes and the in-process refused_stat stand-in; each of 29 one-line mutants that puts a bare guard back fails one of them. This commit, on the WIP checkpoint 5e7a044: the members stand-in test holds the member's own sentence, which comes first, because the stand-in refuses the sibling ../IRRS by name too and the row then says what stands beside the holder. The code is 5e7a044's. Part of #430 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR introduces consistent, errno-aware unreadable-path semantics across shape-doctor.py and shape_advisory.py: roots that cannot be searched are refused up front, paths inside reachable roots are named with gate-aligned diagnostics, templates are never substituted for inaccessible project gates, and unreadable rows are INVALID or notes rather than false DRIFTED/absent results. A broad regression suite validates behavior across Python versions and both real permission modes and simulated OS refusals. Sequence diagram for unreadable root handlingsequenceDiagram
participant User
participant Doctor as shape-doctor.py
participant OS
User->>Doctor: main(--root)
Doctor->>OS: is_a(root, S_ISDIR)
OS-->>Doctor: directory or refused OSError
alt root stat refused
Doctor-->>User: REFUSED shape-doctor-root-unreadable
else root is searchable but not listable
Doctor->>OS: stat(root/.)
OS-->>Doctor: success
Doctor-->>User: run checks
else root search refused
Doctor->>OS: stat(root/.)
OS-->>Doctor: refused OSError
Doctor-->>User: REFUSED shape-doctor-root-unreadable
end
Sequence diagram for unreadable project gatessequenceDiagram
participant Doctor as shape-doctor.py
participant Validator
participant OS
Doctor->>Validator: validator_for(ctx, own, template)
Validator->>OS: is_a(own_path, S_ISREG)
alt own gate readable
OS-->>Validator: true
Validator-->>Doctor: project gate
Doctor->>Validator: run gate
else own gate absent
OS-->>Validator: false
Validator-->>Doctor: standard template
Doctor->>Validator: run template
else own gate stat refused
OS-->>Validator: refused OSError
Validator-->>Doctor: raise OSError
Doctor-->>Doctor: unreadable_gate_row()
Doctor-->>Doctor: no template substitution
end
Entity relationship diagram for unreadable check detailserDiagram
CHECK_ROW ||--o{ LEG_ENTRY : contains
CHECK_ROW ||--o{ MEMBER_ENTRY : contains
LEG_ENTRY }o--|| MOUNT_READ_RESULT : records
MEMBER_ENTRY }o--|| MOUNT_READ_RESULT : records
CHECK_ROW {
string status
string next_command
string why
}
LEG_ENTRY {
string cannot_be_read
string mount_cannot_be_read
string head
string pin
}
MEMBER_ENTRY {
string mount_cannot_be_read
string head
string pin
}
MOUNT_READ_RESULT {
string gate_line
string reason
boolean listing_refused
}
Flow diagram for unreadable mount verdictsflowchart TD
A[Read leg or member mount] --> B{mount_read succeeds?}
B -->|No| C[Record mount_cannot_be_read]
C --> D[Name gate-aligned cannot-be-read line]
D --> E[No checkout or lockstep command]
E --> F[Run doctor again]
B -->|Yes| G[Read mount listing and .git]
G --> H{Readable mount data has a problem?}
H -->|Yes| I[Evaluate pin or member drift]
H -->|No| J[Continue normal checks]
F --> K[INVALID, never DRIFTED]
I --> L[DRIFTED only for readable entries]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…ADME.md and the handbook: `member_state` is back under Sonar's 15 (16 -> 14), and every exit-3 list names a root this user cannot search (#975 review, fix round 1) (#430) SHOULD 1 (S3776): `member_state`'s `refused` was optional, and the nested `if refused is not None` took it from 13 to 16. Its one caller, `check_members`, always passes its list, so the parameter is required and the append unconditional: 14 by the review's own counter, and the `--json` entry and the row's advice are the same bytes. NIT 2: a root this user cannot search exits 3 since 5e7a044, and the doctor's EXIT CODES said so; the four other lists of exit-3 causes did not. Both launchers hand the path to `--root` unchecked (openRepoShape:1069, cli.py:436), so the cause is theirs to name too: "a <path> this user cannot search" in the `--doctor` help of `openRepoShape` and `src/openreposhape/cli.py` (the paragraph re-wrapped at its own 77 columns) and in `docs/cli.md` as the generator renders it, "a path this user cannot search" in README.md's exit-3 sentence and the handbook's copy of it. README.md keeps its line count (one line under its cap), and the handbook's two records of the README section it reads move to the section's new blob. Part of #430 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-19T21:58:40Z) Independent review of #975 (#430 slice C) at Fixed since
Checked and true
Could not verify
Lane 2's note: #975 is green at Lane: openreposhape-2 (openRepoShape-2) |
|
(lane openRepoShape-2, 2026-10-19T22:39:47Z) Addendum to the independent review of #975 (#430 slice C) at This closes the items my READY left under "Could not verify". It was run on the merge of Findings
Checked and true
Could not verify
Lane 2's decision: #975 lands as it is, after #976 and #986, gated again over the main of its turn. The SHOULD and the two NITs go to follow-ups, which lane 2 proposes to Brett Heap before filing. Lane: openreposhape-2 (openRepoShape-2) |
…ames `--family-root`, so each runs as printed (#927) (#1002) Closes #927, from lane 3's triage of #459 (U074, L2-70). ## What Every remedy that tells someone to run `family.py bump` now names `--family-root`, so each one runs as printed. `bump` requires `--family-root` (the usage at `scripts/family.py:7`). - **`scripts/family.py`:** the `member-already-present` and `member-duplicate-id` remedies name `--family-root <holder>`. The holder is quoted with `shell_quoted(family.root)`, as `add`'s own line is. - **`scripts/bump-leg.py`:** the `bump-leg-assembly-is-not-a-leg` remedy names `--family-root <holder>`, a placeholder, because bump-leg has no holder to name. - **Two more in bump-leg,** in their own commit (`7b42c652`): `bump-leg-root-not-a-project` and `bump-leg-wrong-kind`. Each pointed at a bare `scripts/family.py bump`, and now gives the full line. - **README and the handbook** (`3df537a4`): README's "Windows, natively" sentence and the handbook's Windows panel now name `family.py`'s `bump` line among the lines quoted for the shell they are printed in, because the two remedies above quote the holder for it. `tests/test_readme_windows_quoting_sentence.py` holds both to the code; CI's full suite caught the gap (run 39311987496). - **The sweep:** `git grep` and a multi-line scan found 36 hits. Every other hit already carries `--family-root`, or is prose or history with no line to type. No pinned copy prints a bare `bump`. ## Tests All runs used `/usr/bin/python3 -m pytest` under a stub `gh`, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`. - **`tests/test_bump_remedies_family_root.py`, 21 rows:** - **`family.py`:** both codes, in three holder folders (a plain one, `my work` and `Dana's work`). Each checks three things: - the printed line, and that `shlex` gives the holder back as one word; - that `family.py`'s own parser accepts it; - that it runs for real as printed: exit 0, the pin moved, the holder clean. - **`bump-leg.py`:** the three remedies, each also accepted by `family.py`'s parser. - **Against `cf66102f`'s code:** all 21 fail. The 12 `parses` and `runs_as_printed` rows fail on `family.py bump: error: the following arguments are required: --family-root`; the 6 `carries_the_holder_whole` rows and the `bump-leg-assembly-is-not-a-leg` row fail on the printed line, which has no `--family-root`; and the `bump-leg-root-not-a-project` and `bump-leg-wrong-kind` rows find no `bump` line at all, since both remedies named a bare `scripts/family.py bump`. - **`tests/test_family.py` and `tests/test_family_add_member_id.py`** follow the new words. The latter's `bump` row now runs the printed line exactly. - **Fifteen family, bump-leg and docs files:** 585 passed and 1 skipped, which needs a case-insensitive disk. - **The review's fix round** (`1baafc95`): the printed `bump` line is split by the host's own words, and a filled-in holder is quoted, so a holder path with a space is one argument. Under a basetemp with a space, 4 rows failed at `7b42c652` and pass at the head. - **At `3df537a4`:** `test_readme_windows_quoting_sentence.py` 44, `test_repo_hygiene.py` 100, `test_readme_sections.py` 76, `test_setup_by_hand_commands.py` 5 and `test_bump_remedies_family_root.py` 21 passed. ## Notes - **Merges:** clean with main `3864b18c`, and with #975, the other open PR that changes README and the handbook, in another section. - **Follow-up, outside `bump`:** #1005. `member-duplicate-id` also says `` `family.py remove --member <P>` ``, and `bump`'s `member-unknown` says `family.py add`; both verbs require `--family-root` too. - **Squash message:** the head's commits include two `WIP (#927)` ones, and the landing squash carries its own message. - **Review:** lane 1's independent reviewer found `3df537a4` READY (#1002 issuecomment-6278129416). It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `3df537a4` is green on Linux and SonarCloud. The branch merged main twice and was never rebased; the last merge is update-branch's, clean, giving c22f161, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 14 test files that run `bump`, `add` or the remedies that name `--family-root`, and read README and the handbook, 722 passed and 1 skipped. Closes #927 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 3 days and 20 hours by commenting |
…lict, so #1025 is tested against main as it stands, with #983's change to validate-family.py and #1026's fix of main's two red rows (#936) Main took twelve squashes since e076e3e: #1026, #958, #986, #1002, #1016, #1011, #1021, #1022, #973, #975, #1015 and #983. Only #983 touches validate-family.py, in `_git_runs` and the member checks, apart from this branch's hunks; it touches none of this branch's test files. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-20T09:52:10Z) A correction to lane 2's notes on #975 (6278304447 and 6278933012), and to its landing text,
Lane: openreposhape-2 (openRepoShape-2) |
…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>
…eviews and the Known limits continuation, with lanes 2, 4 and 5's items: eleven sites left to #1110, the doctor's 000 and 444 mount rows, the #1082 upgrade warning and the #891 and #1103 limits said as main's code does, #1112 and lane 2's two doctor limits named, and the shape copies measured (#921) The fix round on RV1155a, RV1155b, RV1155c and RV1155d, the continuations RV1155d2 and RV1155a2, and the comments lanes openRepoShape-4 and openRepoShape-5 left on #1155: - RV1155c's two MUSTs: `spec/` at 000 is `cannot be listed` and at 444 `spec/.git: cannot be read`; `scripts/` at 444 is `INVALID (manifest, pins)` on 3.14 as on 3.12. - Every SHOULD (a 3, b 2, c 3, d 13) and every NIT but RV1155a's NIT 7, RV1155a2's eight among them. - Where lane 5 and a reviewer word one line, one text covers both: #891 (d SHOULD 6, lane 5 SHOULD 7), #1103 (d SHOULD 7, lane 5 SHOULD 4) and #1090 (c's first NIT, lane 5 NIT 14). - KNOWN_OPEN in tests/test_path_guard_hygiene.py holds eleven sites, so both lines say eleven. - #1112 is a known limit; lane 2's two doctor limits (#975) and its test-only gaps (#975, #1050) are listed, and the "INVALID, not DRIFTED" sentence holds for a mount this user cannot list or read. - The shape-copy measurement fills its placeholder; the "Before tagging" pointer is gone. - The pipx claim carries RV1155d's qualifier everywhere it is made. - v1.0.0's tab-in-a-pin line is closed (#1015); its bare number or boolean in a plan's text field is narrowed (#1016). - `update-shape.py check` is named with `--at <release-commit>`, and the measurement says main `038dafb` was the upstream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cannot read gives the gate's advice as its WHY, and each branch #975 added for a mount this user cannot reach is built by a test (#1175) (#1193) Two things were open after #975. Five of the branches it added to the `legs` and `members` rows for a mount this user cannot reach were built by no test, so a one-site mutant of each passed. And the `leg shape files` note for a leg file this user cannot read gave its reason (`CLAUDE.md cannot be read (Permission denied)`) but no WHY. `tests/test_doctor_unreadable_paths.py` now builds each of the five. `test_a_leg_with_no_gitlink_names_the_mount_it_cannot_reach_too` covers `check_legs`: a leg with no gitlink recorded over a mount whose listing is refused. The four in `members_verdict` need a holder with two members, which no case had, so a checkout cures the unreached one. `test_beside_a_member_checkout_the_unreached_member_gets_the_advice` runs for the holder's bootstrap and for the git line, and `test_a_member_at_mode_000_beside_one_a_checkout_cures` uses a real `chmod 000`. The note's WHY is new. `shape_file_state` returns the words and the `OSError` of a refused `stat` or read, `leg_shape_comparison` returns a third value, the `OSError` of each leg file this user cannot read, and `check_leg_shape_files` gathers them. The note's `why` is `cannot_read_why(refused)`: `PERMISSION_ADVICE` for a permission and `OTHER_REASON` for any other cause, ending "Then run this doctor again". Beside a missing file's line, the advice follows that line's own why, because the copy is still the NEXT for the missing file. NEXT stays None and the row stays a note, so the verdict does not change: no leg file gates anything. A whole mount this user cannot reach gets no why here, since the `legs` row names it and gives the advice. `tests/test_doctor_unreadable_paths.py` has 43 passed (34 at main), and 488 passed, 3 skipped in one pytest with `tests/test_shape_doctor.py`, whose session fixtures it imports. All twelve single-site mutants of `shape-doctor.py` are killed, and the five of #975 each pass main's copy of the file. Closes #1175 Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read is named in the gate's words, `spec: .git/modules/spec: cannot be read (<reason>)`, so the `legs` and `members` rows never count it as drift, `placement` never calls it "not a git repository", and no HEAD is read for it (#1174) (#1200) Where this user cannot read a leg's or a member's git directory (`.git/modules/spec`, or a `.git` gitfile or directory at mode 000 or 400), `shape-doctor.py` counted the mount as drift ("populated, but git cannot read a HEAD in it"), `placement` called it "not a git repository", and the verdict was DRIFTED. `validate-pins.py` has named that directory and refused it since #968, and the verdict is INVALID there. `mount_read`, the one question the `legs`, `leg shape files`, `placement` and `members` rows share, now also asks the git directory as the gate's `_refuse_an_unreadable_git_dir` does. `git_dir_of(dotgit)` is a copy of the gate's `_leg_git_dir`, and the `stat` of that directory's `HEAD` is read through `is_a`. A refused read becomes the mount's `mount_cannot_be_read` line, so `head_of` never runs in the mount. The line names the path the way the gate does, normalized and relative to the root, and starts with the mount's path where the directory is outside the mount: `spec: .git/modules/spec: cannot be read (Permission denied)`. The three rows then do what #975 made them do for a mount this user cannot reach. The NEXT is this doctor again, the WHY is the gate's advice, drift skips the leg or member, and the verdict falls to the `pins` row's INVALID, or the `family` row's for a holder. A gitfile that names no directory, or a git directory that is not there, is left to git, as before. Two choices the issue left open. The `leg shape files` row still compares such a leg's files, since it runs no git, so `leg_shape_comparison` asks `mount_read(..., git_dir=False)`. And the guard on the holder's bootstrap note (`if MOUNT_UNREAD not in entry`) now also skips the `.gitmodules` of a member whose git directory cannot be read, so no keep line is owed for that member's legs. `tests/test_doctor_unreadable_paths.py` has 68 passed, and 513 passed, 3 skipped in one pytest with `tests/test_shape_doctor.py`, whose session fixtures it imports. Each of the 19 mutants run is killed by it, and all but one pass the parent PR's copy of the file. One test pins the second choice: the member's why is the same with that file and without it. Closes #1174 Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-2 (openRepoShape-2)
Claim: #430 (comment)
A v1.2.0 DRAFT, slice C of lane 1's plan for #430 (issuecomment-6251069236):
shape-doctor.pyandscripts/shape_advisory.py. Branchfix/430-c-doctor-unreadable, from main50178305, two commits:5e7a0440(aWIP:usage-window checkpoint holding all the code) and76ebca2e(one test's assertion; the code is5e7a0440's). Nothing is merged into it.run_checks(decision 10) also names a refused read out of any other row in the gate's words, so the rule reaches a site this PR did not list. A leg file the row cannot read iscannot be read (<reason>)inleg shape files. Slice B (lane 2's other writer,update-shape.py) is a separate branch, and no test here depends on it.Decisions
The plan's rule, held for every site: stat the path;
ENOENT,ENOTDIR,EBADFandELOOP(and Windows 21, 123 and 1921) are absent; any otherOSErroris named invalidate-pins.py's words,<path relative to the root>: cannot be read (<reason>)(orcannot be listedfor a folder, #434), with the gate's advice for the cause. The doctor asks with its ownis_a(#847);scripts/shape_advisory.pygets a local_is_awith the same errno set (a parity test holds it to the gate's).scripts/repo_shape.pyis untouched.scripts/shape_advisory.pyis not a shape copy. It is in none ofshape_materialize.py's copy lists, and the pins of a scaffolded project (11 rows) and a holder (10 rows) name neither it norshape-doctor.py. No project or holder gets anupstream-changedrow from this PR.Each site (lines at
50178305):main(:11768, and one new check): REFUSES. A root whosestatis refused (a folder above it this user cannot search), or that this user cannot SEARCH (statof<root>/.), isREFUSED shape-doctor-root-unreadable, exit 3, before any row. The code is the onemainalready gives a rootresolve()refuses, so no new code. Exit 3, not the rule's 2: the doctor's exit 2 isNOT A SHAPE ROOT, a verdict about the tree, and 3 is its documented "usage or environment". Only search is asked: a root at mode 111 (searchable, not listable) is COMPLIANT on both Pythons, measured, and is not refused.validator_for(:2000): REFUSES for its row. An own copy whosestatis refused raises; themanifest,pinsandfamilyrows name it (unreadable_gate_row) and run nothing, never the standard's template in its place.unloaded_manifest_rownames it beside the manifest's refusal;gate_linegives no line, solockstep_nextand theagent-filesconfirm clause name no gate that cannot run.check_legs(:7247/:7248): REFUSES for the leg. One helper,mount_read, asks the mount'sstat, its listing and its.git'sstat, for the four rows that read a mount. A refused one is the gate's line, kept in the leg'smount_cannot_be_read, routed to no checkout and no lockstep line; the NEXT is this doctor again where no other leg has a line, and the advice is the WHY, or follows the line's, as shape-doctor.py: for a pin or 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 does for a pin. Only a missing gitlink is said before it.member_state(:9792/:9793): REFUSES for the member, through the same helper, key and words;members_verdictroutes no such member and gives the advice.leg_shape_comparison(:7514) andshape_file_state(:7486): the leg's state is the mount's line, and a leg file whosestator read is refused iscannot be read (<reason>), neverabsentwith acpover it. The row is anote, neverokand never a FINDING (thelegsrow asserts the mount).audit_leg(:8454): the leg's state is the mount's line, an unread leg, soplacementis anote(n/awhere no leg could be read), as for an unpopulated one; never git'sit is not a git repositoryfor a mount it could not enter.own_bootstrap(:6277): BENIGN, by decision: a refusedstatanswers "cannot run" (False) on every Python. It chooses only which line a row prints, and themanifestandpinsrows name an unreadablescripts/.kept_gitdir(:6715),keeps_modules(:6803) andcheckout_reach'sgit_dir/modules(:6872) ask withis_a: a refusedstatraises on every Python and its row names it (10), where 3.14 called a kept repository gone. BENIGN:checkout_reach:6859/:6870,detached:9368andorigin_url:9446(reached only after the same mount's, or sibling's, listing and.gitstatsucceeded);root / ".git"at:10355,:10386,:10854,:10977,git_dir_holds:11135,Context:1327,check_agent_files:9306andcheck_what_is_here:10473-:10478(a name directly in the root, or in a git directory git has just read, isstatable by anyone who can search the root, whichmainnow requires).:404/:11759,:1492,:2199/:2370/:6301(a copyvalidator_forjuststated, or the template),:7490,:7516,:7657,:8533,:10600;:1591(repo_shape.pybeside a copy juststated);:6393(os.path.lexists, root level),:9608(a remote's path),:9715-:9752(unclonable/sibling_slot, shape-doctor.pymembersrow: its green note saysmake siblingsplaces every member with no working clone, a directorymake siblingsrefuses included, so one row says both that it places IRRS and that it leaves IRRS exactly as it is #669'sunlistable).run_checks: anOSErrorout of any check that is a refused read of a path inside 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) is that row's FINDING in the gate's words, NEXT this doctor again, WHY the advice, detailcannot_be_readandpath. Any otherOSErrorkeepscould not be run: <exc>.scripts/shape_advisory.pyTree.kind(:355/:357): asks_is_a, so a refusedstatraises and each reader's net reads the file as there and unreadable (the record:it could not be read (PermissionError), which silences nothing) on every Python, where 3.14 called it absent.Which verdict a "could not be run" row gives. INVALID, naming it; never DRIFTED and never CANNOT ANSWER.
drifted_reasonscounts drift only from a row's own readable entries: alegsrow with none (an exception out ofcheck_legs) and a leg whose mount or pin cannot be read are no leg off its pin, and amembersrow is drift only where a member this user CAN reach has a problem. Such a row falls through toinvalid_verdict(a red gate) or the catch-all, which names it. Not CANNOT ANSWER: exit 3 says nothing is a statement about the tree, and an unreadable path in the root is one (#430's "What it should do" 2, and #847's ruling for an unreadable pin). The ladder's order is unchanged.What changed
Measured by a probe on Python 3.12.3 and a 3.14-like
sitecustomizeshim (is_file,is_dir,exists,is_symlinkanswer False to anyOSError), in a project and a holder scaffolded into local bare remotes from a committed copy of the branch, with a stubgh,GH_TOKEN=bogusand an emptyGH_CONFIG_DIR. Controls (nothing chmod'd) are COMPLIANT before and after, on both.scripts/444INVALID (manifest, pins), both rowscould not be run: [Errno 13] ...DRIFTED (5 copy-missing),ok manifest the standard's templates/assembly-root/scripts/validate-manifest.py passesINVALID (manifest, pins); 3.14-like stillDRIFTED (5 copy-missing), slice B's.github/workflows/444INVALID (pins)DRIFTED (1 copy-missing)spec/(a leg mount) 000DRIFTED (1 leg(s) not at the pin); legs, leg shape files and placementcould not be runINVALID (pins); legs names the mount, the other twonotes name itspec/444DRIFTED (...); legs and leg shape filescould not be runDRIFTED (...);spec: populated, but with no .git of its ownwith agit submodule updateNEXT, and every leg fileabsentwith acpNEXTINVALID (pins);spec/.git: cannot be readin all three rowsmembers/000DRIFTED (a member is not at its pin), memberscould not be runmembers/IRRS: not populated -- the pinned copy is an unbootstrapped submoduleINVALID (family); members names the mountmembers/IRRS000DRIFTED (...),could not be runINVALID (family);members/IRRS: cannot be listedContextNOT A SHAPE ROOT, exit 2root.is_dir()REFUSED shape-doctor-root-missing: ... is not a directory on this machineThe new lines:
For a cause other than permission, each says the gate's other advice (
OTHER_REASON, orOTHER_REASON_FOLDERfor a mount it cannot list, and for the root). The--jsonentry of a leg or member carriesmount_cannot_be_read.shape_advisory.py's record reader saysit could not be read (PermissionError)for a record whosestatis refused, on every Python.Tests
tests/test_doctor_unreadable_paths.py, 34 tests. MODE cases chmod a real path (skipped on Windows and as root, restored in afinallybymode_of) and run the doctor as a person does; STAND-IN cases userefused_stat(#383, which answersis_file()as 3.14 does) and #847'srefusing, in process, on every platform. It imports its helpers from the files that own them (conftest,test_shape_doctor,test_doctor_unreadable_pin,test_doctor_legs_members_next,test_bump_leg_unopenable, and the twotest_validate_pins_unreadable_*files): nothing is copied.test_an_own_gate_this_user_cannot_reach_is_named_never_the_templatescripts/444own_copy,cannot_be_read; nopasses; verdict not asserted (slice B)test_a_leg_mount_this_user_cannot_reach_is_named_and_no_drift[spec-000, spec-r--]mount_cannot_be_read; leg shape files and placement arenotes naming it; verdictINVALID (pins)test_a_member_this_user_cannot_reach_is_named_and_no_drift[members-000, member-000, member-r--]INVALID (family)test_a_root_this_user_cannot_search_is_refused_by_name[root-000, root-r--, above-000]chmod u+rxremediationtest_a_root_this_user_can_search_and_not_list_is_not_refusedtest_validator_for_raises_a_refused_stat_and_names_the_own_copyvalidator_forraises; the manifest row names the copy, runs nothing (exitNone)test_another_reason_for_the_own_copy_gets_the_other_adviceOTHER_REASONtest_an_own_copy_that_is_not_there_still_falls_back_to_the_templatetest_no_lockstep_line_is_named_for_a_gate_this_user_cannot_reachgate_linegives no command;confirm_digestsays nothingtest_an_unloaded_manifest_names_an_own_gate_it_cannot_reachtest_a_leg_mount_refused_is_named_on_every_python[mount-stat, mount-listing, listing-io-error]OTHER_REASON_FOLDERfor EIO), no drifttest_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git.gitrefusednote) and placement (n/a) all say<leg>/.git: cannot be readtest_beside_a_checkout_line_the_unread_mount_gets_the_advicetest_a_member_mount_refused_is_named_on_every_pythontest_a_leg_file_this_user_cannot_read_is_said_not_called_absentstatand a refused read arecannot be read; the row is anotewith no copytest_a_bootstrap_this_user_cannot_reach_cannot_run_on_every_pythonown_bootstrapis False for a refusedstat, True otherwisetest_a_git_directory_this_user_cannot_reach_is_raised[keeps_modules, kept_gitdir, checkout_reach]stattest_a_root_whose_stat_is_refused_is_refused_on_every_pythonmainin processstatis the same refusal, neverroot-missingtest_a_refused_read_out_of_a_check_is_named_by_run_checkscould not be runtest_a_row_that_could_not_be_run_is_invalid_never_driftlegsormembersrow with no entry isINVALID (<row>); a leg off its pin is still DRIFTEDtest_a_member_this_user_cannot_reach_is_no_drift_beside_one_that_istest_a_record_this_user_cannot_reach_silences_nothing_on_every_pythonTree.kindraises; the record silences nothing,it could not be read (PermissionError)test_a_record_in_a_folder_this_user_cannot_searchr--foldertest_a_record_that_is_not_there_is_still_absentfile,dirtest_the_advisory_calls_nothing_there_what_the_gate_calls_so_ABSENT_ERRNOSand_ABSENT_WINERRORSequal the gate'sRuns (
/usr/bin/python3 -m pytest, Python 3.12.3, one process at a time):50178305(before)76ebca2e(after)tests/test_shape_doctor.pytests/test_doctor_*.py, files 1-9 (gitlink_repairs..partial_clone)tests/test_doctor_*.py, files 10-18 (paste_through_git..validator_verdicts)test_stdout_encoding.py,test_scaffold_leg_path_collisions.py, and the 8 files naming a changed function (test_adopt_finding_spelling,test_adopt_single_repository_record,test_bom_reads,test_bootstrap_non_utf8_branch,test_refusal_default_remediation,test_shape_advisory,test_validate_family_duplicates,test_validate_family_unnamed_mounts)tests/test_doctor_unreadable_paths.pyrender-cli-reference.py --check:[ok](no help text changed). The before-runs of the doctor files ran from a clean clone of50178305; the firsttest_shape_doctor.pyrun from the worktree before any edit.Mutants (each one line, applied alone to the fixed file, only its killer run with
-k, the fixed bytes restored and checked by sha256):validator_for:is_a(own_path, S_ISREG)->own_path.is_file()test_validator_for_raises_a_refused_stat_and_names_the_own_copymain: the searchos.stat(<root>/.)->passtest_a_root_this_user_cannot_search_is_refused_by_name[root-000, root-r--]main:is_a(root, S_ISDIR)->root.is_dir()test_a_root_whose_stat_is_refused_is_refused_on_every_pythonmount_read:is_a(mount, S_ISDIR)->mount.is_dir()test_a_leg_mount_refused_is_named_on_every_python[mount-stat]mount_read: a refused listing read as emptytest_a_leg_mount_refused_is_named_on_every_python[mount-listing, listing-io-error]mount_read:.gitasked with.exists()test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_gitcheck_legs:mount_read-> the two bare guards of50178305test_a_leg_mount_refused_is_named_on_every_python(all three)member_state: the sametest_a_member_mount_refused_is_named_on_every_pythonleg_shape_comparison: the bare guard backtest_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_gitaudit_leg: the bare guard backshape_file_state:here.is_file()test_a_leg_file_this_user_cannot_read_is_said_not_called_absentcheck_leg_shape_files:okfor an unread leg or fileown_bootstrap: the refusedstatraisedtest_a_bootstrap_this_user_cannot_reach_cannot_run_on_every_pythonkept_gitdir:path.is_dir()test_a_git_directory_this_user_cannot_reach_is_raised[kept_gitdir]keeps_modules:.is_dir()checkout_reach:(git_dir / "modules").is_dir()run_checks:named = Nonetest_a_refused_read_out_of_a_check_is_named_by_run_checksdrifted_reasons: drift from alegsrow with no entrytest_a_row_that_could_not_be_run_is_invalid_never_driftdrifted_reasons: a leg whose mount cannot be read countedtest_a_leg_mount_refused_is_named_on_every_python(all three)drifted_reasons: a member that cannot be read countedtest_a_member_this_user_cannot_reach_is_no_drift_beside_one_that_ismembers_verdict: an unread member routed to a linetest_a_member_mount_refused_is_named_on_every_pythonmember_problem: the unread mount not said firstgate_line: the refusedstatraisedtest_no_lockstep_line_is_named_for_a_gate_this_user_cannot_reachvalidator_row: the refusedstatraisedtest_validator_for_raises_a_refused_stat_and_names_the_own_copyunloaded_manifest_row: the refusedstatraisedtest_an_unloaded_manifest_names_an_own_gate_it_cannot_reachcheck_legs: the unread-mount branch droppedtest_a_leg_mount_refused_is_named_on_every_python[mount-listing, listing-io-error]Tree.kind:path.exists()test_a_record_this_user_cannot_reach_silences_nothing_on_every_pythonshape_advisory._ABSENT_ERRNOS+EACCEStest_the_advisory_calls_nothing_there_what_the_gate_calls_somember_state:detached()asked of an unread mounttest_a_member_this_user_cannot_reach_is_named_and_no_drift[member-r--] (a real mode: 3.12)All 29 KILLED (
S/w430c/mutants.log); the fixed bytes restored after each and checked by sha256.Not changed
shape currencyrow'scopy-missingunder 3.14 for ascripts/or a.github/workflows/this user cannot search, and so the verdictsDRIFTED (5 copy-missing)andDRIFTED (1 copy-missing): those rows come fromupdate-shape.py'sprepare, which the doctor imports. Slice B (lane 2's w430b) fixes them inupdate-shape.py; the doctor already renders a raisedOSErrorby name (unreadable_copy_row, 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). This PR does not touchupdate-shape.py, and its tests assert no verdict of those two cells.familyrow's crash under a holdermembers/this user cannot search (validate-family.pyraisesPermissionError): slice E'stemplates/family-root/scripts/validate-family.py. It keeps the verdictINVALID (family)either way.main's othershape-doctor-root-unreadable(resolve()raising) keeps its words,--root <arg>: <exc>.scripts/shape_advisory.py:438and:714((root / ".git").exists()): names directly in the root, which the doctor now refuses up front; adopt-project's source is slice H's.README.md's exit-3 sentence ("no such path, a refused--placement-plan, ...") does not name a root this user cannot search; the module docstring's EXIT CODES does. README is outside this slice's files..gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914'sshape-doctor.pyhalf is left alone. No branch on origin names adopt-project.py and shape-doctor.py: a.gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914, and main01a2b016has not touchedshape-doctor.pyorshape_advisory.pysince50178305, so its lines could not be compared here; a reviewer holding adopt-project.py and shape-doctor.py: a.gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914 should check them against this diff.01a2b016(4 commits, none in these files); not merged, as asked.Part of #430
🤖 Generated with Claude Code
Summary by Sourcery
Make unreadable paths explicit and consistent across shape validation, refusing inaccessible roots early and preventing inaccessible data from being mistaken for absence or drift.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: