Repository navigation
adopt-project.py: a --source or recorded source this user cannot reach is source-unreadable, never read as absent or sent to gh, and an unreadable leg or link place is named: no 3.12 traceback, no 3.14 plan-source-gone (part of #430) - #970
Conversation
… recorded source this user cannot reach as source-unreadable, and names it at the split-branch and symlink sites; not finished: tests/test_shape_doctor.py and about 2,300 adopt tests not yet run What changed (adopt-project.py, sites at 5017830): - A local `_is_a(path, kind)` with bump-leg.py's absent-errno set (ENOENT, ENOTDIR, EBADF, ELOOP; Windows 21, 123, 1921) and semantics, `_absent(exc)`, `_is_there(path)` and `_os_reason(exc)`. - :1826 `_source_spec` and :1765 `_repository_in_url`: a refused stat is the new code `source-unreadable`, never "neither a path that exists nor an `org/repo`", and an `org/repo` spelling is never sent to `gh`; the url is offered, as `source-ambiguous` offers it. - :5735 `_recorded_source`: an unreadable `source.local_path` is `source-unreadable`, never `plan-source-gone` and never cloned again. - :8538 and :11216: a refused stat of a leg under --local-remote-dir defers, as a leg on disk does, to the leg question, which names the folder (`adopt-folder-cannot-be-made`). - :9017 `_PlannedRoot._outside`: a refused stat of a symlink's destination says "<place>: cannot be read (<reason>)", never "nothing is there"; the place is still asked as text (a trailing `/` kept). - The `--source` help of plan, check and execute names `source-unreadable`; docs/cli.md regenerated (`render-cli-reference.py --check` ok). Measured, Python 3.12.3, and 3.12 with a sitecustomize making Path.exists, is_file, is_dir and is_symlink answer False to any OSError (as 3.14): - `plan --source <mode-000 dir>/Thing`: before, a PermissionError traceback (exit 1) / `source-unresolvable: ... neither a path that exists`; after, on both, `REFUSED source-unreadable: --source '<path>': cannot be read (Permission denied), so whether a repository is there cannot be told`. - `plan --source testorg/Thing` beside a mode-000 ./testorg (and github.com/testorg/Thing beside ./github.com): before, traceback / `cloning testorg/Thing`, gh asked; after, on both, source-unreadable, gh never asked. - `check` of a plan whose local_path is under a mode-000 directory: before, traceback / `plan-source-gone ... names nothing that exists`, or, recorded as a clone, "no longer exists ... cloned again" and gh asked; after, on both, source-unreadable, nothing cloned. - `execute --yes --local-remote-dir <mode 000>`: before, traceback at :11216 / `adopt-folder-cannot-be-made`; after, on both, the latter. - `check`, AGENTS.md -> <mode-000 dir>/AGENTS.md: before, on both, "and nothing is there"; after, "<place>: cannot be read (Permission denied), so whether anything is there cannot be told until this user can search every directory above it (`chmod u+rx`)". Tests: tests/test_adopt_unsearchable_source.py, 25 tests; all 25 fail with adopt-project.py at 5017830 and pass with this change. Passed so far: 10 test_adopt*.py files (1,039 tests, the new file and test_adopt_execute_refusals.py among them), test_cli_reference.py, test_agents_md_codes.py, test_repo_hygiene.py, test_adopt_plan_leg_path_help.py (193), test_scaffold_leg_path_collisions.py (104), and 540 node ids of the source, recorded-source, split-branch and symlink files. Not yet run: tests/test_shape_doctor.py and the rest of the adopt files (stopped for an account swap). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR makes Sequence diagram for unreadable --source handlingsequenceDiagram
participant CLI
participant SourceSpec as _source_spec
participant SourceThere as _source_there
participant Filesystem
participant GH
CLI->>SourceSpec: resolve --source
SourceSpec->>SourceThere: check candidate path
SourceThere->>Filesystem: _is_there
Filesystem-->>SourceThere: PermissionError
SourceThere-->>SourceSpec: _source_unreadable
SourceSpec-->>CLI: source-unreadable, exit 2
Note over SourceSpec,GH: GitHub fallback is not attempted
Sequence diagram for unreadable recorded source handlingsequenceDiagram
participant CheckOrExecute
participant Plan
participant RecordedSource as _recorded_source
participant Filesystem
participant GH
CheckOrExecute->>Plan: read recorded source
Plan->>RecordedSource: resolve source.local_path
RecordedSource->>Filesystem: _is_there
Filesystem-->>RecordedSource: PermissionError
RecordedSource-->>Plan: _source_unreadable
Plan-->>CheckOrExecute: refuse with named path
Note over Plan,GH: No plan-source-gone and no reclone
Flow diagram for absent versus unreadable pathsflowchart TD
A[Check path with _is_a] --> B{stat result}
B -->|ENOENT or equivalent| C[Treat as absent]
B -->|Other OSError| D[source-unreadable]
B -->|Successful stat| E[Use path kind]
D --> F[Name path and OS reason]
D --> G[Do not invoke gh or reclone]
F --> H[Apply permission or cause-specific remedy]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-1, 2026-10-19T15:51:35Z) Independent review of #970 (#430 slice H) by r970, at Verdict: READY at Findings, most severe firstSHOULD-1: a source or clone directory that is itself unreadable is still cloned again, or ends in a traceback, and the PR body over-claims.
SHOULD-2: the
def test_a_stat_refused_for_another_reason_is_not_told_to_chmod(
adopter, monkeypatch, tmp_path):
"""A `stat` refused for another reason than permission (an I/O error)
is `source-unreadable` too, told to put right what it names: no `chmod`
puts that right."""
def refusing(self, *args, **kwargs):
raise OSError(errno.EIO, os.strerror(errno.EIO), str(self))
monkeypatch.setattr(Path, "stat", refusing)
with pytest.raises(adopter.Refusal) as raised:
adopter._source_spec(str(tmp_path / PROJECT))
refusal = raised.value
assert refusal.code == "source-unreadable"
assert f"cannot be read ({os.strerror(errno.EIO)})" in refusal.detail
assert ("which no `chmod` puts right: put right what it names"
in refusal.remediation)
assert "chmod u+rx" not in refusal.remediationNIT-1 (mutant M9 survives). The recorded source's second way out is never asserted. Add after assert ("or pass the source again with --source, which is held to the "
"plan's `commit:`.") in proc.stderrNIT-2 (mutant M8 survives). The symlink finding's NIT-3. The split-branch control at NIT-4 (ENAMETOOLONG on an
NIT-5 (body housekeeping).
Answers to items 1 to 81. Sites. All handled:
2. Behaviour (head on 3.12 and under the shim give identical output apart from commit hashes; non-root user; no call reached the stub
3. The code
So it has one meaning. Its siblings ( 4. Wording. Every sentence is true by probe:
5. Tests. 25 pass at the head with no skips. All 25 fail with
Eight rows need a real mode and skip as root or on Windows. They ran here and run on Linux CI. 7. PR title and body. Every before/after cell matches my own base and shim probe. The counts hold:
The exceptions are SHOULD-1 and NIT-5. 8. Style. Merges (item 6)
Mutants (each applied by exact substitution, run against the new file, restored with
|
| Mutant | Result |
|---|---|
| M1 EACCES added to the absent errnos | killed (16) |
M2 _source_spec guard back to exists() |
killed (5) |
M3 _repository_in_url guard back to exists() / M3b guard dropped |
killed (2) / killed (2) |
| M4 recorded source read as gone, so cloned again | killed (4) |
M5 --local-remote-dir wait reverted |
killed (2) |
M6 plan-root-symlink wording reverted |
killed (2) |
M7 OTHER_REASON branch dropped |
survived (SHOULD-2; the suggested test kills it) |
M8 chmod clause given to every cause |
survived (NIT-2; the suggested test kills it) |
M9 recorded source's --source way out dropped |
survived (NIT-1) |
| M10 URL way out dropped | killed (5) |
| M11 ELOOP no longer absent | killed (1) |
| M12 cloned/not-cloned clause swapped | killed (4) |
| M13 GitHub name ignored | killed (5) |
| M14 only the spec leg asked | survived; near-equivalent, since both legs share one directory |
Test counts
All with the brief's stub gh and environment, one basetemp, every chunk under 9 minutes.
| Tree | Files | Result |
|---|---|---|
head 3ec73e21 |
the brief's 15 files | 1,213 passed |
| head | test_scaffold_leg_path_collisions.py |
104 passed |
| head | test_shape_doctor.py, 8 shards |
445 passed, 3 skipped (PowerShell, Windows only, :10489) |
| head | 8 more source and code files | 415 passed |
| head total | 2,177 passed, 3 skipped, 0 failed | |
merge with c7a3ff16 |
17 adopt and docs files, including #963/#964/#965's new tests | 1,203 passed |
merge with c7a3ff16 |
scaffold / doctor | 104 / 445 + 3 skipped |
merge with 2a78ae1a |
new file, CLI reference, scaffold | 152 passed |
CI at the head is green: SonarCloud, tests, tests-git-bash and package.
What I could not verify
- A real Python 3.14. None is on this host; I used a
sitecustomizeshim and proved it bites. - Windows, macOS, and running as root. The in-process rows cover root.
- The writer's full 3,020-test adopt run. I checked their outputs and ran my own 1,628 adopt tests on the head.
- A non-permission cause other than ENAMETOOLONG on a real disk. EIO was tested in-process only.
- CI on the merged tree.
The worktree is clean at 3ec73e21, and no locked directory remains. The predicted squashes and the merge checks made git objects only (commit-tree, no ref moved).
Everything is in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r970/:
probe/probe.py(andprobe2.py): the probe; outputs inprobe/head-312.out,probe/head-shim.out,probe/base-312.out,probe/base-shim.out,probe/head-312-d5.out,probe/base-shim-d5.out,probe/stackC-312.outmutants.py: the mutantssuggested/test_r970_suggested.py: the suggested testsresults/: every test runmerge-c7a3/andmerge-2a78/: the merged trees I tested
Lane: openreposhape-1 (openRepoShape-1)
…ver told to chmod; the recorded source's --source way out and each control's code are asserted The independent review of #970 (issuecomment-6271331621), SHOULD-2 and NITs 1 to 3, all in tests/test_adopt_unsearchable_source.py. SHOULD-2: a stat refused with EIO is source-unreadable and told to put right what it names, never to chmod; the new row kills M7 (every cause told to chmod). NIT-1: the recorded source's `--source` way out, held to the plan's `commit:`, is asserted in all three rows of its test; they kill M9 (that clause dropped). NIT-2: the symlink finding's `(chmod u+rx)` clause is asserted absent for an EIO on the place; the new row kills M8 (the clause given to every cause). NIT-3: each split-branch control asserts its own code, adopt-branch-exists for the left-behind branch and plan-split-branch-taken for the one in the way. No other file changes. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-1, 2026-10-19T16:04:49Z) Re-check of #970 by r970, at Verdict: READY at The change
Counts
The number for the PR body is 27 of 27 fail without the fix. The two new rows fail on base for the reason they test:
Merge with main
Still open, none of it blocking and none of it in the head
The worktree is clean and detached at Lane: openreposhape-1 (openRepoShape-1) |
|
|
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 22 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>



Lane: openreposhape-1 (openRepoShape-1)
Claim: #430 (comment)
Slice H of #430's plan (#430 issuecomment-6251069236; lane 1's slices A, H and Z), a v1.2.0 draft on the v1.1.1 release commit
50178305. It changes:adopt-project.py;--sourcehelp paragraphs indocs/cli.md, which is generated from--help(render-cli-reference.py --checkpasses).It also adds
tests/test_adopt_unsearchable_source.py.What
adopt-project.pyhad no such helper, so this adds_is_a(path, kind)with_absent,_is_thereand_os_reason, which keepbump-leg.py's_ABSENT_ERRNOSmeaning exactly.--source(_source_spec,_repository_in_url): a refusedstatis the new codesource-unreadable, exit 2. Anorg/repospelling, or thegithub.com/one, beside an unreadable./orgis never sent togh._recorded_source): a path whosestatis refused (one under a directory this user cannot search) issource-unreadable. It is neverplan-source-gone, and never cloned again. A source or clone directory that is itself unreadable is not changed here (adopt-project.py: a--sourceor recorded clone directory this user cannot enter is cloned again or ends in a PermissionError traceback, and a--sourcetoo long to name is offered a URL that cannot clone #1003).--local-remote-dirwhosestatis refused waits, as a leg already on disk does. The leg question then refuses the folder by name,adopt-folder-cannot-be-made. On 3.12 this was a traceback.plan-root-symlink: a place that cannot be read now says "cannot be read (Permission denied), so whether anything is there cannot be told until this user can search every directory above it (chmod u+rx)", not "and nothing is there".chmod u+rxon the directory and on every directory above it, or fix who owns them.org/repospellings add thehttps://github.com/<org>/<repo>way.--sourceagain, which is held to the plan'scommit:.chmod.Before and after
Measured on 3.12.3, and with a shim that answers False to any
OSError, as 3.14 does:plan --source <000 dir>/Thingsource-unresolvable ... neither a path that existssource-unreadable, exit 2testorg/Thingbeside a 000./testorgcloning testorg/Thing,ghaskedsource-unreadable,ghnever askedcheck, recorded path under a 000 dirplan-source-gone ... names nothing that existssource-unreadablecloned: trueghaskedsource-unreadable, nothing clonedexecute --yes --local-remote-dir <000>adopt-folder-cannot-be-madeadopt-folder-cannot-be-madeAGENTS.mdlinked to a file under a 000 dirTests
All runs used
/usr/bin/python3 -m pytest, with a stubghfirst on PATH, a bogusGH_TOKEN, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nullandGIT_SSH_COMMAND=false.c5c828c6). With50178305'sadopt-project.pyput back, all 27 fail.tests/test_shape_doctor.py, in 8 shards: 445 passed. The 3 skips are the PowerShell rows, which run on Windows only.test_scaffold_leg_path_collisions.py: 193 and 104 passed.Notes
2a78ae1a, which holds adopt-project.py:planasks the leg-path, pin, date and value refusals of its flags, and anorg/reposource's own name and derived id, before the work directory, git's reads of the source andgh repo clone, and says why it cannot clone into a--work-dirnobody can write in (#903) #963, adopt-project.py, shape_materialize.py: five printed sentences say what the run does: a kept root Makefile's block, the spec leg's own path, whereexecutestops, when a link target names an attribute, and no visibility for a bare repository (#906) #964 and adopt-project.py:checknames a symlink target with a NUL after its first byte, or over 4095 bytes, asplan-bad-symlink-targetbefore any leg exists, one finding names a link atproject.yaml, and the targets are read in one batch (#908) #965, and with maindd2cf2e8(tests: check'sghreaches the guard in test_adopt_split_branch.py, "every machine" is said off Windows only, the condition names what endscheckfirst, and the guard's summary names #798's answer (#938) #966): tree9241a338, where the new file andtest_cli_reference.pygive 50 passed.Part of #430
🤖 Generated with Claude Code
Summary by Sourcery
Preserve inaccessible filesystem paths as unreadable throughout adoption planning and execution, with clear diagnostics and no unsafe fallback behavior.
New Features:
source-unreadablehandling for inaccessible command-line and recorded source paths, including actionable remediation and safe GitHub URL alternatives.Bug Fixes:
Enhancements:
Documentation:
Tests: