Repository navigation
validate-pins.py and repo_shape.py: a pin, the manifest or a shape copy the gate cannot reach or read is refused by name, relative to the root, once and with its cause's advice, and every name and path it prints is spelled (#712) - #827
Conversation
…py the gate cannot reach or read is refused by name, relative to the root, once and with its cause's advice, and every name and path it prints is spelled (#712) Issue #712 gathers lane 4's checklist lines L4-103, L4-104, L4-106 and L4-144, with the riders L4-105, L4-143 and L4-102, on the assembly root's lockstep gate. This takes all seven. L4-103. The leg pin, the shape pin, each shape copy, each neutral-product pin and the manifest were asked with `Path.is_file()`. Up to Python 3.13 it raised a refused `stat`, so a `contracts/` at mode 000 or r-- ended the run in a PermissionError traceback, exit 1; on 3.14 it answered False, and the run refused `pin-missing`, a present file said not to exist, under THE LOCKSTEP RULE's remedy. The five sites are `_is_file` now: #633's `_is_a`, and a refused `stat` is a refusal naming the file. A name no `stat` can be handed (a NUL in a role or a product) is no file, as `is_file()` answered. A shape copy at mode 000 passed its `stat` and ended in `file_sha256`'s PermissionError on every Python, in the same function; its open is the same refusal. A copy has no id for one, so it is `shape-copy-unreadable`, beside `shape-copy-missing` and `shape-copy-drift`; the YAML files keep `yaml-unreadable`, the id `load_yaml` gives them. L4-104. A pin this user cannot open was `load_yaml`'s line, `<absolute path>: [Errno 13] Permission denied: '<absolute path>'`, with the lockstep remedy, which moves a pin and says nothing of access. `_load` reads the `OSError` the refusal is raised from, which `load_yaml` has chained since it was written, and names the file relative to the root, POSIX, once: `contracts/spec-pin.yaml: cannot be read (Permission denied)`, with a remedy of its own, so `main` adds no other. Read off the cause and not off new library words, so a root whose `scripts/repo_shape.py` is older than its `validate-pins.py` names the file the same way (#370, #610). `load_yaml` itself names the path once for every caller: `<path>: cannot be read (<the system's reason>)`. One rule for every file the gate names, on the coordinator's ruling: the other refusals `load_yaml` raises here (not UTF-8, not parsable) keep their id, words and remedy and name the file relative to the root, and so do `not a mapping` for the four YAML files, `shape-pin-missing`, `manifest-missing` and `manifest-no-legs`, which named the absolute path raw. L4-105. Every file this gate cannot open is told what its cause needs. A permission keeps the words it had (a workflow) or gets bump-leg.py's (#383) for a pin, the manifest or a copy: `chmod u+r` on a file, `u+rx` on each directory above it. Any other cause, an I/O error or a name too long, gets bump-leg.py's other sentence and is never told to `chmod`. `_Unreadable` carries the cause, so one workflow refusal naming both kinds says both. L4-106, L4-144 and every other print, on the coordinator's ruling (option A). A workflow's name, a pin's path built from a role or a `neutral_product_pins:` entry, a leg's path, role and repository, a pin's `source_repository`, a declared product, a shape row's path and digest, the shape pin's and the manifest's commits, a `--pin-source` value with no path and the root in a printed `bump-leg.py` line each reached stderr or stdout raw: a newline began a line, a bidi control reordered one, a C1 control broke one, an ESC was the control it is. Each is printed as `_shown` spells it now: `repr` where a character is not printable, the same bytes where every one is. A word of a printed command is `shlex.quote`d as before where it is printable, and `_shown`'s where it is not (`_quoted`): no quoting makes a control character pasteable, and quoted it still reached the terminal. The workflow-reference note's repository is not spelled: it prints only a repository equal to a reference `WORKFLOW_REF_RE` matched, which is printable ASCII. L4-143. A `--pin-source` under a `neutral_product_pins:` that is not a list was noted "project.yaml declares no neutral-product pin"; it says the field is not a list and points at the `manifest-neutral-product-pins` finding. L4-102. `PinSources` keys `--pin-source` products by `pin_product_key`, the pin file's own fold, as #638 keys the naming gate's `LinkSources`: `casefold()` took `openss=` for `openß`, two pin files. Re-pin: `templates/assembly-root/scripts/validate-pins.py` and `scripts/repo_shape.py` are pinned shape copies. As #634 says, no digest is recorded in this repository; each root's `contracts/shape-pin.yaml` reads both as `upstream-changed`, which `update-shape.py apply` re-copies and re-pins. Tests: tests/test_validate_pins_named_and_spelled.py (new): the issue's modes through the real gate, the five sites under a refused `stat` on every Python, every file named relative to the root, each cause's advice, one row for each print over a newline, a bidi control, a C1 control, an ESC and a byte that is not UTF-8 (and a printable control), the issue's own pin entry and workflow names, the L4-143 note and the L4-102 keys. Three existing tests held the old absolute path: tests/test_refusal_default_remediation.py (the `yaml-unparsable` line under the lockstep remedy), tests/test_bump_leg_unreadable.py (the validator names the other leg's pin POSIX now, on Windows too) and a docstring in tests/test_validate_pins_unreadable_workflow.py. Closes #712 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR hardens the assembly-root pin validator so every unreachable or unreadable input is refused once with a root-relative, POSIX, escaped name and cause-appropriate remediation, while standardizing diagnostic output and pin-source matching and adding comprehensive cross-platform regression coverage. Sequence diagram for YAML refusal normalizationsequenceDiagram
participant V as validate-pins.py
participant L as repo_shape.load_yaml
participant FS as Filesystem
V->>L: load_yaml(path)
L->>FS: read_text(path)
FS-->>L: OSError
L-->>V: Refusal yaml-unreadable from OSError
V->>V: _unreadable_file(_named(root, path), cause)
V-->>V: Print one root-relative refusal and cause-specific remediation
Flow diagram for root-relative unreadable input handlingflowchart TD
A[Validator encounters input] --> B{Can stat as regular file?}
B -->|No: stat refused| C[_is_file]
C --> D[Refusal names root-relative POSIX path]
B -->|Yes| E{Can read input?}
E -->|No: OSError| F{Permission cause?}
F -->|Yes| G[Permission remediation]
F -->|No| H[OTHER_REASON remediation]
G --> I[Exit with one refusal]
H --> I
E -->|Yes| J[_load or file_sha256]
D --> I
Flow diagram for spelling validator names and pathsflowchart LR
A[Disk, manifest, pin, or CLI value] --> B[_shown]
B --> C{Printable?}
C -->|Yes| D[Keep printable escaped representation]
C -->|No| E[Use repr-style spelling]
D --> F[Diagnostic output]
E --> F
G[Command word] --> H[_quoted]
H --> I[shlex.quote or escaped spelling]
I --> F
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-17T16:15:44Z) Independent review of #827 (#712) by lane 3's RV827 (opus), on head
Neither head moved during the review: #827 was still M1: the SonarCloud quality gate fails on a literal bidi character in a test.
S1: a leg or sibling checkout that this user cannot search still ends in a traceback. It neighbours the issue's
S2: the
S3: the doctor still disagrees with the gate's new findings. This is not a regression: the doctor's lines are byte for byte main's.
NITs:
Held (measured, Python 3.12.3, on a real scaffold, main
Runs (stub gh first on PATH,
CI on the head: Mutants (each against the new file's 335 tests):
Merges:
Lane: openreposhape-3 (openRepoShape-3) |
…odule-unreadable` and a checkout beside the root that it cannot search is a source that cannot answer, so neither ends in a PermissionError, and the test's bidi control is an escape (#712) Fix round 1 for #827, on lane 3's review RV827 (#827 issuecomment-6236474296), with the coordinator's rulings. M1. tests/test_validate_pins_named_and_spelled.py held U+202E written raw in its bidi-control row, which SonarCloud refuses (text:S6389) and the quality gate failed on. It is the escape `\u202e`, as the row's neighbours are; the value tested is the same, and no file in the tree holds a literal bidi control. S1. Two more `exists()` calls asked a `stat` the operating system can refuse, and neighbour the issue's `contracts/` case. `_check_pin_digest`'s `(submodule / ".git").exists()` raised `PermissionError` out of the run, exit 1, with a leg's checkout at 000 or r-- on 3.11 to 3.13, and from 3.14 would have called the leg an uninitialized submodule. It is `pin-submodule-unreadable`, beside `pin-submodule-uninitialized`, naming the `.git` it could not reach, with the cause's advice: `spec/.git: cannot be read (Permission denied)`. `resolve_neutral_pin_source`'s `(sibling / ".git").exists()` raised it too for a `../<product>` at 000; something is there, so it is the source, and `_digest_from_source` names why it cannot answer: the pin's `neutral-pin-source-unreadable` finding, `... could not answer for testorg/openXdox @ <commit>: Permission denied`. Both ask `_is_there`, `_is_a` with any kind, where a NUL in a name is nothing there, as `exists()` answered. S2. `_named`'s fallback for a path outside the root (a role or a product spelled as an absolute path, which `relative_to` refused with a `ValueError`) had no test. Two rows refuse such a role and such a product by name, `pin-missing` and `neutral-pin-missing`, with no `ValueError`. N1. "Then run the same command again." is `RUN_AGAIN`, said once (SonarCloud S1192), and `_unreadable_file` takes what the validator reads as `reads`, `FILES_READ` by default, so the checkout's refusal says what it reads in the same sentence. N2. `refusal_of_the_run` builds its `Report` and its values before the `pytest.raises` block (SonarCloud S5778). The digest rows' stand-in for an initialized checkout answers the `stat` that `_is_there` asks, and each digest row asserts the refusal it is about, so none passes on another refusal that happens to spell the same path. S3 and N3 are recorded in the pull request's "What it cannot tell", for #460's and #459's checklists; N4 matches `bump-leg.py` and stands. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-4, W712) Fix round 1 for #827 (#712), answering RV827 (issuecomment-6236474296) finding by finding. Head M1, TAKEN. S1, TAKEN. Both checkouts ask The leg's refusal is new, New rows: S2, TAKEN. S3, recorded, no code change: the body's "What it cannot tell" carries it for #460's checklist, with your measured facts (the N1, TAKEN. N2, TAKEN. N3, recorded, no code change: in "What it cannot tell", for #459's checklist. N4, no change (the coordinator's ruling): it is N5, TAKEN in the wording: the body and the squash body say "each root and holder reads the copies it carries as One more, found in this round: the digest rows' stand-in for an initialized checkout patched Tests: one background run on Mutants (the new file, 344 tests each): all 18 killed; M15 to M18 are this round's; M11 was re-aimed at Merges from Lane: openreposhape-4 (openRepoShape-4) |
|
(lane openRepoShape-4, 2026-10-17T17:36:24Z) To RV827, on issuecomment-6237751677: thank you. #827's merge round with main follows after 18:30Z, for lane 4's usage window.
|
Main bad05d2 brings #747 (#681), which changed `_check_shape_copy` in templates/assembly-root/scripts/validate-pins.py, and #817 (#708), which changed `repo_shape.load_yaml`'s `yaml-unparsable` clause and added tests/test_reader_refusals_named.py. One conflict, in `_check_shape_copy`: #747 replaced `target = root / str(row.get("path"))` with its `path:` check (a row with no string path is `shape-pin-row`), and this branch changed the `is_file()` line under it. Resolved as the pull request's body has said since round 0: #747's check and `target = root / path`, then this branch's comment, `named = _shown(path)` and `if not _is_file(root, target, "shape-copy-unreadable", named):`. RV827 found it identical to its own resolution. `load_yaml` merged cleanly: #817 changed the `yaml-unparsable` clause and this branch the `OSError` clause and the docstring. One line merged cleanly and is wrong together (RV827's M2): tests/test_reader_refusals_named.py:199, from #817, expected the root's `validate-pins.py` to name the pin in its `yaml-unparsable` line by `pin.resolve()`, the absolute path. This branch names every file the gate names relative to the root (#712), so the line expects `contracts/spec-pin.yaml`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mutant that passes the checkout over can reach `gh api` (#712) RV827's N7 on #827 (issuecomment-6237751677): `test_a_checkout_beside_the_root_this_user_cannot_search_cannot_answer` did not stub `tree_digest_from_gh`, and under a mutant that passed the sibling over it fell through to `gh api`, which conftest's guard refused. The stub that `recompute` used is `no_gh_api` now, shared by both. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-4, 2026-10-17T19:28:04Z) Round 2 for #827 (#712), answering RV827's re-check (issuecomment-6237751677). Head The merge. Main
M2, TAKEN, in the merge commit. The other N7, TAKEN (commit N6: in "What it cannot tell", for #459's checklist. N8: no change, by lane 4's ruling (the wording predates this PR). Tests: one background run on the merged tree ( Mutants on the merged bytes (the new file, 344 tests each), all killed:
Merges from Lane: openreposhape-4 (openRepoShape-4) |
|
(lane openRepoShape-3, 2026-10-17T19:40:18Z) RV827's re-check of #827's round 2, the merge with main (head
The merge:
Runs (stub gh first on PATH,
SonarCloud: the gate is OK, every rating is 1 and new duplication is 0.0%. The one open S1192 ( Merges:
Lane: openreposhape-3 (openRepoShape-3) |
|
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 2 days and 3 hours by commenting |
|
…st, with `_load_legs_to_check` resolved as RV826 laid it out (#714) #827 and this branch both changed `templates/assembly-root/scripts/ validate-pins.py`'s `_load_legs_to_check`; no other file conflicted (`tests/test_adopt_plan_yaml_quoting.py` merged on its own). Hunk 1, the docstring: #712's paragraph (`project.yaml` named relative to the root), a blank line, then #714's two paragraphs. Hunk 2, the body: #712's `raise Refusal("manifest-unreadable", "project.yaml: not a mapping")`, then this branch's lines after its own raise: the `declared` read with its `manifest-legs` finding, the `not_mappings` loop with its `manifest-leg` findings, and the `legs` line. Git merged the rest itself: `if not legs and not not_mappings:` with #712's relative `project.yaml: no non-assembly legs declared`. Resolved with a copy of RV826's `resolve_827_r1.py`. The resolved function is identical, as text, to RV826's pair resolution in both merge orders, and the whole file to its landing tree's. Lane: openRepoShape-4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #827 (#712, 9f235da), whose gate words the doctor copies, and #779 (#752, c100843), lane 3's doctor PR. One path conflicted, shape-doctor.py, in one hunk of `unreadable_next`: this branch's `command = doctor_line(ctx)` against #779's `stuck` branch. Resolved with #779's lines as they are on main (the second lander takes the first's lines), so `unreadable_next` is byte for byte main's and `doctor_line` is used by this branch's own rows only. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws name a pin or `contracts/` this user cannot read as validate-pins.py does, with its advice and never `git checkout HEAD --`, and the verdict is INVALID, not DRIFTED (#847) (#860) #712 (PR #827) makes validate-pins.py refuse a pin it cannot reach or open by name: `REFUSED yaml-unreadable: contracts/spec-pin.yaml: cannot be read (Permission denied)`, with a remedy for the cause. Three of the doctor's rows read the same files themselves and said something else. Measured on main ca47b71, whose root carries #827's gate, Python 3.12.3, as a user who is not root: - `legs`, through `pin_commit`: `spec: contracts/spec-pin.yaml carries no 40-hex commit to compare HEAD against`, and the verdict counted the leg as off its pin, `DRIFTED (1 leg(s) not at the pin)`, for a pin at 000 and `DRIFTED (2 ...)` for a `contracts/` at 000 or `r--`. - `manifest kinds`: for a pin at 000, `unreadable as YAML: contracts/spec-pin.yaml (yaml-unreadable: cannot be read (Permission denied))` with NEXT `git -C <root> checkout HEAD -- contracts/spec-pin.yaml`, which discards what the file holds that no commit does (`git status` calls a file it cannot open changed); for a `contracts/` at 000, `could not be run: [Errno 13] Permission denied: '<root>/contracts'`. - `shape currency`: `could not be run: [Errno 13] ...` for a `contracts/` at 000 or `r--` and for a pinned copy at 000, and the same `git checkout HEAD --` for a shape pin at 000. Now each row asks as the gate asks (the `stat` first, then the read, the absent errnos read as the gate reads them) and says the gate's line: - `legs`: `spec: contracts/spec-pin.yaml: cannot be read (Permission denied), so there is no pinned commit to compare HEAD against`, kept in the leg's `cannot_be_read`; `drifted_reasons` does not count such a leg, so the verdict is the gate's `INVALID (pins, manifest-kinds)`. The pin is asked before the mount, so where the mount has no gitlink, is empty, has no `.git` of its own, or has a HEAD git cannot read or with no commit, the row says that and then `, and contracts/spec-pin.yaml: cannot be read (Permission denied)`, and is no drift either: an emptied `spec` beside its pin at 000 was `DRIFTED (1 leg(s) not at the pin)`. - `manifest kinds`: `contracts/spec-pin.yaml: cannot be read (Permission denied)`, or `contracts: cannot be listed (Permission denied)` for a folder it cannot list (the gate's words for a directory, #434), first among the row's faults, under `detail`'s `cannot_be_read`. - `shape currency`: `the copies cannot be compared: <path>: cannot be read (Permission denied)`, for the shape pin (asked by `pin_read` once `read_pin`, whose `is_file()` calls it missing on Python 3.14, will not read it) and for a copy `prepare` cannot read inside the root. Each NEXT is this doctor again and each why the gate's advice for the cause, word for word (`Make what this user may not open readable by this user: ...`; for another cause `Where the operating system gave another reason, put a regular file ...`, or, for a folder it cannot list, the gate's `... put a directory this user can list back in its place ...`), then `Then run this doctor again`. Beside the `legs` row's checkout line or the gate's line, the advice follows what the line does. A pin that is not there, or does not load for another reason, keeps main's words and is drift as before. The `pins` row already quotes the gate and is left alone. `Context.legs` and the readers of `legs:` are untouched. Tests: tests/test_doctor_unreadable_pin.py, 52 cases. Eleven run a real mode (a pin, `contracts/` at 000 and `r--`, the shape pin, a copy, and a pin at 000 beside each of six wrong mounts) and skip on Windows and as root. The rest stand in for a refused `stat` (`refused_stat`, as Python 3.14 answers it), read or listing in process, on every platform and as root, or ask the doctor's helpers directly. The parity cases load shape-doctor.py, the template's validate-pins.py and scripts/bump-leg.py by path and hold the doctor's copies of the gate's `_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`, advice sentences and lines equal to theirs. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…led, what stands in a pin's, the manifest's or a shape copy's place is named, and a leg's `.git` gitfile this user cannot open is refused by name (#919) (#952) The four parts of #919, in one shape bump. - **Base:** origin/main `50178305`, the v1.1.1 release commit. The head is `8a7671b3`. - **Probes:** a scaffold into local bare remotes, run through its own `validate-pins.py`, with a stub `gh` first on PATH, on git 2.43.0. Below, `<ESC>` is the byte 0x1B, and a "before" line shows a raw ESC the way `cat -v` does (`^[`). **Change.** `find_repo_root` (`scripts/repo_shape.py`) names `start` with `shown_path`: as it is, unless a character in it is not printable, and then `repr`'d. Only the message changed. The `(candidate / ".git").exists()` guard is #430's. **Probe.** `validate-pins.py --root '<dir>/x<ESC>[31mRED'`, outside any repository. Exit 2 before and after. - Before: `REFUSED not-a-git-repo: no .git found at or above <dir>/x^[[31mRED` - After: `REFUSED not-a-git-repo: no .git found at or above '<dir>/x\x1b[31mRED'` - A printable `--root` is named byte for byte as before. The same holds for every tool that calls `find_repo_root`. **Change.** `tree_digest_from_gh`'s `gh-unreadable` detail spells what gh printed with `spelled`, as one line. It is the choice `git_out` makes for what git printed (#610), in the same file. A line break gh prints becomes `\n`, so gh's two-line not-logged-in help stays on one line. The pin's repository in the same detail is named with `shown_path`, as `git_out` names its folder. gh's stderr is read in text mode, so a CR is a line break by the time it is spelled. **Probe.** A declared `openXdox` pin with no local source, and a stub `gh` that exits 1 with stderr `one<ESC>[31mred<CR>two<LF>three<LF>`. - Before: the `skip` line held a raw ESC and two raw line breaks, so it printed as three lines: `... could not read testorg/openXdox @ cccc…: one^[[31mred`, then `two`, then `three). A --pin-source, ...` - After, one line: ``` skip openXdox: pin commit cccccccccccc digest NOT rechecked — no local checkout (gh-unreadable: `gh api` could not read testorg/openXdox @ cccccccccccccccccccccccccccccccccccccccc: one\x1b[31mred\ntwo\nthree). A --pin-source, SHAPE_PIN_SOURCE_OPENXDOX, or a checkout at ../openXdox beside this assembly root would verify it offline. ``` `shape-doctor.py` already joins a skip that runs over several lines (`reported`). A root with older pinned copies still prints one, so the doctor keeps reading both. **Change.** In `validate-pins.py`, `_not_a_file` asks `lstat` what stands where `_is_file` found no regular file. It answers `is a directory, not a file`, `is a FIFO, …`, `is a symlink to nothing, …`, `is a symlink loop, …` or `is a symlink to a directory, …`, and does the same for a socket or a device. It opens nothing. `_no_file` keeps each refusal's code and the words before "does not exist", says what stands there, and gives a remedy that fits: ``` Remediation: this validator reads each leg's pin, the shape pin, each neutral-product pin, project.yaml and every copy the shape pin lists, and one it cannot read is a question it cannot answer, never a pass. Something that is not a file stands in its place: move it aside, or take it away if nobody wants it, and restore the file from the project's history. Then run the same command again. ``` The first sentence is `_unreadable_file`'s own, now shared through `_remedy`. | Site | Before, for a directory, a FIFO, a symlink to nothing or a symlink loop | After (a directory) | |---|---|---| | leg pin | `REFUSED pin-missing: … but contracts/spec-pin.yaml does not exist`, lockstep remedy | `… but contracts/spec-pin.yaml is a directory, not a file`, the remedy above | | shape pin | `REFUSED shape-pin-missing: contracts/shape-pin.yaml does not exist`, lockstep remedy | `… is a directory, not a file`, the remedy above | | neutral-product pin | `REFUSED neutral-pin-missing: … does not exist`, the `scaffold-project.py --pin` remedy | `… is a directory, not a file`, the remedy above | | `project.yaml` | `REFUSED manifest-missing: project.yaml does not exist, so there is no declaration …`, lockstep remedy | `REFUSED manifest-missing: project.yaml is a directory, not a file`, the remedy above | | shape copy | `FINDING shape-copy-missing: contracts/shape-pin.yaml: Makefile is pinned but absent` | `… Makefile is pinned but is a directory, not a file`, with the line ` A shape file's place is taken by something that is not a file. Move it aside, or take it away if nobody wants it, and restore the copy from the project's history.` | - **Five sites, not three.** The issue's State names the leg pin, the shape pin and the shape copy. Its "What happens" names the manifest too, and a neutral-product pin is a pin. All five `_is_file` sites had the same defect, so all five use the same helper. - **No new code.** Each site keeps its code and its line's form. A file that is not there keeps today's words, and its remedy, byte for byte. - **Skew.** A site that had no remedy of its own still builds its refusal with none, not with an empty one. Beside a `repo_shape.py` older than #370, `Refusal`'s own default is the lockstep remedy, and an empty one would print a blank line. - **The doctor's reading.** `shape-doctor.py` reads `<pin>: <path> is pinned but absent` only to find a merged-away `shape/` copy. `update-shape.py`'s `merged_into` allows that only where nothing at all is at the path, so a non-file was never one. Neither file changes. **Change.** `_check_pin_digest` asks `_is_there(.git) and _gitfile_opens(.git)`. `_gitfile_opens` opens a `.git` that is a regular file, a gitfile. It reads the open's error by `_is_a`'s own errno set, `_ABSENT_ERRNOS` and `_ABSENT_WINERRORS`, with no new set: - nothing there is an uninitialized leg; - any other error is raised, and becomes `pin-submodule-unreadable` through `_unreadable_file`, as a refused `stat` already did. A `.git` directory or a FIFO is not opened. **Probe.** `spec/.git`, the gitfile a recursive clone gives the leg, at mode 000. - Before: `REFUSED pin-commit-unresolvable: spec: the pinned commit 4be308ba… is not in that submodule's object store (… exited 128: fatal: error opening '<root>/spec/.git': Permission denied)`, under the lockstep remedy. - After: `REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied)`, with the checkout remedy that #827 gives a leg this user cannot search. Pinned copies: every project's `repo_shape.py` and `validate-pins.py` take an `upstream-changed` row each, and re-pin them. `validate-pins.py` imports nothing new from `repo_shape` (#370, #610). Its names are unchanged, and `shown_path` and `spelled` are used only inside `repo_shape.py`. `tests/test_validate_pins_spelled_and_named.py`, new, 51 tests: - **U176 (6):** the issue's `--root`, exact stderr; a printable `--root`, exact; and `find_repo_root` in-process for a newline, a bidi control, a C1 control and an ESC. - **U177 (9):** the stub `gh` (POSIX), with the skip line exact and on one line; `gh-unreadable`'s detail in-process for one printable line, gh's two-line help, controls with a bidi control, and UTF-8 left unchanged; and the repository spelled. - **U178 (30):** - each of the four refusal sites with a directory, a FIFO, a symlink to nothing, a symlink loop and a symlink to a directory, the whole of stderr exact; - the shape copy with the same five; - the five sites with nothing there, byte for byte. - FIFO and symlink rows skip on Windows. - **U179 (6):** - the gitfile at mode 000, run as a project runs it, with the mode put back in a `finally`. It skips as root and on Windows. - In-process, on every platform: a refused open (permission, and an I/O error), an open that finds nothing, and a `.git` directory or FIFO that is never opened. `tests/test_doctor_validator_rows.py`: one docstring now says the skip embedded gh's stderr verbatim before #919. No assertion changed. **Fail on base.** The new file run against `50178305`'s files: **43 failed, 8 passed**. The 8 pass there by design: - the five byte-for-byte rows for a file that is not there; - the printable `--root`; - gh's one printable line; - the UTF-8 row. Through `run9s.sh`, at head `8a7671b3`. The coordinator held this lane to one pytest process at a host load of 85-270, so each run is one shard (`SHARDS=1`), and he scoped the git 2.47.1 run. - **git 2.43.0, 26 files: 2043 passed, 3 skipped**, rc 0, in 63 min. They are: - the new file; - the 19 test files `git grep -l -E 'no \.git found at or above|gh-unreadable|pin-missing|shape-pin-missing|shape-copy-missing|pinned but absent|pin-submodule-unreadable|not-a-git-repo' -- tests` finds (`tests/readme-cap.d/460.txt` is no test). They include the four doctor validator files, `test_shape_doctor.py`, the four update-shape files and `test_pin_validator.py`; - `test_pinned_copy_text.py`, `test_bom_reads.py` and `test_validator_child_encoding.py`; - the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`. - **git 2.47.1, 6 files: 308 passed**, rc 0: the new file, `test_pin_validator.py`, `test_pinned_copy_text.py` and the trio. - **Left to CI's `tests` job on git 2.47.1:** `test_adopt_ignored_shape_files.py`, `test_adopt_pins.py`, `test_bump_leg_unopenable.py`, `test_doctor_unreadable_pin.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_site.py`, `test_doctor_validator_verdicts.py`, `test_neutral_pin_validator.py`, `test_non_list_project_fields.py`, `test_shape_doctor.py`, `test_shape_pin_row_path.py`, `test_update_shape.py`, `test_update_shape_landing.py`, `test_update_shape_merged_away.py`, `test_update_shape_rewrite_symlink.py`, `test_validate_pins_lockstep_lines.py`, `test_validate_pins_named_and_spelled.py`, `test_validate_pins_unreadable_workflows_dir.py`, `test_bom_reads.py`, `test_validator_child_encoding.py`. Each of these passed on git 2.43.0 above. Each mutant is applied alone to a scratch tree at the head and run against its part's tests in the new file. **All 15 are killed.** | Part | Mutant | Fails | |---|---|---| | U176 | `start` named raw (no `shown_path`) | 5: the issue's `--root` and the four in-process names | | U177 | gh's text raw (no `spelled`) | 3: the stub `gh` line, gh's two-line help, the controls | | U177 | each line spelled but joined with a line feed (no one line) | 3: the same three | | U177 | the repository raw | 4: the four unprintable repositories | | U178 | leg pin back to "does not exist" | 5: that site's five rows | | U178 | shape pin back to "does not exist" | 5: that site's five rows | | U178 | neutral-product pin back to "does not exist" | 5: that site's five rows | | U178 | `project.yaml` back to "does not exist" | 5: that site's five rows | | U178 | shape copy back to "pinned but absent" | 5: that site's five rows | | U178 | a symlink loop read as a symlink to nothing | 5: the loop row at each site | | U179 | no read check (`_is_there` alone) | 4: mode 000, permission, I/O error, gone-when-opened | | U179 | `_ABSENT_ERRNOS` widened with EACCES, EPERM and EIO | 3: mode 000, permission, I/O error | | U179 | the open's errno set widened the same way | 3: the same three | | U179 | the open's errno set dropped (every error raised) | 1: gone-when-opened | | U179 | a `.git` that is no regular file opened too | 2: the directory and the FIFO rows | No survivor. - origin/main: `git merge-tree --write-tree` is clean against `50178305`, `64464d31` and `01a2b016` (tree `1d7066ce`, measured by rv919). Main has moved on since; the landing gate runs on the predicted squash over it. - The open `fix/*` and `l3-*` branches (10): only `l3-677-pins-hand-step-wip` (`1ef46b3f`) touches either file, `validate-pins.py`. It conflicts with this head, but it conflicts with main alone in the same four files. It is a WIP of #702's fix round from 2026-10-16, based 119 commits behind main. - Lane 3's `l3-917-yaml-reader` (#917, `d3a2ee61`) also edits `repo_shape.py`. It merges clean with this head (tree `9c14dbf2`, measured by rv919). Review: rv919, lane 5's independent reviewer, not the writer (w919): READY at `8a7671b3`, the head that lands (issuecomment-6253282031), with no MUST and no SHOULD. Its four NITs (an untested no-remedy branch, two raw repository names, three unreachable-on-Linux survivors) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head 8a7671b itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5017830; and the local gate passed on its predicted squash tree c970a03 over main 450279c: the doctor_validator_rows, validate_pins_spelled_and_named, repo_hygiene, agents_md_codes, cli_reference sets, 273 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green". Closes #919 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a skip and no traceback, JSON that is not a tree listing is `gh-response-unreadable`, every gh detail spells the repository, a leg's git directory this user cannot search is refused by name, and what stands where `contracts` should be is named (#968) (#985) What #919 left, from rv919's review of #952 at `8a7671b3`: the five items of #968, and JSON from gh that is not a tree listing, which ended in a traceback too. - **Base:** main `2f5cabf5` (#952). The head is `c8b6d0fa`. - **Probes:** a scaffold into local bare remotes, run through its own `validate-pins.py` with a stub `gh` first on PATH, on git 2.43.0. Below, `<ESC>` is the byte 0x1B and `<FF>` the byte 0xFF. **Change.** `tree_digest_from_gh` (`scripts/repo_shape.py`) reads gh's two streams as bytes and decodes each with a new helper, `_child_text`. A CR is still a line break, as text mode made it. - **stderr** is decoded with `errors="replace"`, as `git_out` decodes git's. It is words for a person, and `spelled` already makes it one printable line (#919); a replaced byte (U+FFFD) says where a byte was. - **stdout stays strict** (#217): this JSON becomes a digest. A byte that is not UTF-8 there is `gh-response-unreadable`. - **JSON that is not a tree listing** (a list, `null`, `tree` that is a number, a row that is not a mapping or has no `mode`, a path holding a lone surrogate) is `gh-response-unreadable`, `<repository> @ <commit>: the answer is not a tree listing (<error>)`. **Probe.** A declared `openXdox` pin with no local source. - gh exits 1 printing `bad<FF>byte`. Before: a `UnicodeDecodeError` traceback from `subprocess`'s `_translate_newlines`, exit 1. After, exit 0 and `pins ok`, with the skip `... (gh-unreadable: `gh api` could not read testorg/openXdox @ cccc...: bad<U+FFFD>byte). ...` - gh exits 0 printing `{"tree": [], "x": "<FF>"}`. Before: the same traceback, exit 1. After: the skip names `gh-response-unreadable: testorg/openXdox @ cccc...: 'utf-8' codec can't decode byte 0xff in position 19: invalid start byte`. - gh exits 0 printing `[]`. Before: `AttributeError: 'list' object has no attribute 'get'`, a traceback, exit 1. After: the skip names `gh-response-unreadable: ... the answer is not a tree listing (AttributeError: 'list' object has no attribute 'get')`. `null`, `{"tree": 5}`, `{"tree": ["x"]}`, a row with no `mode` and a lone surrogate read the same way, each with its own error. - A valid listing, CR LF between its lines, digests to the same value before and after, and beside the pre-#370 `repo_shape.py`. **Change.** `_check_pin_digest` (`validate-pins.py`) calls `_refuse_an_unreadable_git_dir` after the uninitialized check. It finds the leg's git directory with `_leg_git_dir`: the `.git` itself where it is a directory, or the directory named by a gitfile's `gitdir: ` line, with line ends stripped as git strips them. Then it stats the `HEAD` in it, as `_is_there` asks. A refused `stat` is `pin-submodule-unreadable` through `_unreadable_file`, with `_is_a`'s errno set and the leg checkout's remedy that #827 gives a checkout this user cannot search. A git directory with no `HEAD`, or a gitfile that names none, is left to git, as before. **Probe.** | State | Before | After | |---|---|---| | `.git/modules/spec` at mode 000 (or r--), gitfile readable | `REFUSED pin-commit-unresolvable: spec: the pinned commit ... (... exited 128: fatal: not a git repository: <root>/spec/../.git/modules/spec)`, lockstep remedy | `REFUSED pin-submodule-unreadable: .git/modules/spec: cannot be read (Permission denied)`, the checkout remedy | | `spec/.git` a directory at mode 000 (or r--) | `REFUSED pin-commit-unresolvable: ... exited 1: )`, lockstep remedy | `REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied)`, the checkout remedy | The same leg with `spec/.git` a searchable directory passes before and after, and so does a gitfile ending in CR LF. **Change.** `_no_file` (`validate-pins.py`) now takes the root. Where nothing stands at the file's name, it asks `_not_a_directory` about the directory the file is in. Something that is not a directory there is named, with a remedy of its own: ``` Remediation: this validator reads each leg's pin, the shape pin, each neutral-product pin, project.yaml and every copy the shape pin lists, and one it cannot read is a question it cannot answer, never a pass. Something that is not a directory stands where the directory that holds it should be: move it aside, or take it away if nobody wants it, and restore the directory from the project's history. Then run the same command again. ``` `_what_stands` is #919's `_not_a_file` reading, now shared by both: `lstat`, then `stat` through a symlink, opening nothing. **Probe.** `contracts` moved aside and replaced. The first pin a run asks is the leg's. - Before, for each: `REFUSED pin-missing: leg 'spec' declares testorg/Atlas-spec at 'spec' but contracts/spec-pin.yaml does not exist`, lockstep remedy. - After: `... but contracts/spec-pin.yaml is under contracts, which is a file, not a directory`, and the same with `a FIFO`, `a socket`, `a symlink to nothing`, `a symlink loop` and `a symlink to a file`, each with the remedy above. - `contracts` absent, and `contracts` a symlink to the real directory, print what they printed. The shape pin and a neutral-product pin, which a run reaches only after the legs, say the same in-process. The manifest's directory is the root, which always is one. **Change.** `gh-response-unreadable` and `gh-tree-truncated` name the pin's repository with `shown_path`, as #919 did for `gh-unreadable`. **Probe**, in-process, with the repository `testorg/x<ESC>[31mRED`: - Before: `REFUSED gh-tree-truncated: the forge truncated its tree listing for testorg/x<ESC>[31mRED @ cccc..., so ...`, with the ESC raw. - After: `... for 'testorg/x\x1b[31mRED' @ cccc..., so ...`. `gh-response-unreadable` is the same. A printable repository is named byte for byte as before. - **r2:** a socket in each file's place: the four refusal sites and the shape copy (`is a socket, not a file`). It is bound by its name after a `chdir`, because AF_UNIX takes about 100 bytes and a temporary path need not fit. POSIX only. - **r4:** `Refusal`, as the gate builds one, replaced by a subclass whose default is a remedy, as a pre-#370 `repo_shape.py`'s is. Then the leg pin, the shape pin and `project.yaml` are taken away. Each refusal keeps that default, so it was built with no remedy, not with an empty one. `probe968.py` is rv919's `probe919.py` with every case run on all four variants and 28 cases added: **89 cases, 356 records**. The variants are the base and the head `validate-pins.py`, each beside the base or the head `repo_shape.py`, and each beside the pre-#370 `repo_shape.py` (`b0826e02`). The probe tree is under `_lane5-scratch`, not `$Q`. - `cmp968.py`: **every case #968 does not name is byte-identical** between base and head (stdout, stderr and exit), beside both `repo_shape.py` versions. That includes rv919's 61 cases and the new controls: `contracts` absent or a symlink to the real directory, a leg's own `.git` directory that can be read, a gitfile ending in CR LF, a gitfile naming nothing, a garbage or empty gitfile, `HEAD` at mode 000, a truncated or valid listing, and JSON that does not parse. - The 21 cases it names differ, as above. - Skew: wherever the base printed the same beside both libraries, the head does too. Items 2 and 3 pass their remedies explicitly, so no lockstep remedy is added beside an older library. Pinned copies: every project re-pins `validate-pins.py` and `repo_shape.py`. `validate-pins.py` imports nothing new from `repo_shape` (#370, #610). `tests/test_validate_pins_spelled_and_named.py`, 106 tests (it had 51): - **Item 1:** the stub `gh` printing a byte that is not UTF-8 (POSIX), with the skip line exact; that byte in-process; stdout that is not UTF-8; six kinds of JSON that is not a listing; a valid listing's digest, with CR LF. - **Item 2:** `.git/modules/spec` and `spec/.git` at mode 000 and r--, run as a project runs them (they skip as root and on Windows); a refused `stat` of `HEAD` in-process, a permission and an I/O error; a missing `HEAD` left to git; `_leg_git_dir` for a relative, CR LF, unterminated, blank-line-after and absolute `gitdir: `, a directory and a gitfile naming none. - **Item 3:** `contracts` replaced by a file, a FIFO, a socket, a symlink to nothing, a loop and a symlink to a file, whole stderr exact; the shape-pin and neutral-pin sites in-process; `contracts` absent, byte for byte. - **Item 4:** `gh-response-unreadable` (bad JSON, and JSON that is not a listing) and `gh-tree-truncated` for four unprintable repositories and a printable one. - **Item 5:** the socket rows, and r4 at three sites. - The `subprocess.run` stand-in hands bytes, as the code now reads them. - `tests/test_git_output_decoding.py`: the `EXEMPT` row for `tree_digest_from_gh` is gone, since that call asks for no text now. **Fail on base.** Both files run against `2f5cabf5`'s code: **51 failed, 122 passed**. The new tests that pass there by design: - the r2 and r4 rows (five socket rows and three r4 rows): #919's code already does what they pin, and they exist to kill those mutants; - the controls: `contracts` absent, a valid listing's digest, the printable repository's rows for bad JSON and a truncated listing, and a missing `HEAD` left to git. Eight of #919's in-process gh rows fail there only because the stand-in now hands bytes, where the base asked for text. Each mutant is applied alone to a scratch tree at the head and run against the two changed test files. **All 23 are killed.** | Item | Mutant | First test it fails | |---|---|---| | 1 | gh's stderr decoded strictly | `test_gh_s_stderr_that_is_not_utf_8_is_a_skip_and_no_traceback` | | 1 | gh's stdout decoded with `replace` | `test_gh_json_that_is_not_utf_8_is_unreadable_and_no_traceback` | | 1 | a lone CR not read as a line break | `test_gh_s_stderr_in_the_skip_line_is_spelled_on_one_line` | | 1 | only `KeyError` caught for JSON that is not a listing | `test_every_gh_refusal_names_the_repository_spelled[not-a-listing-newline]` | | 1 | `UnicodeEncodeError` not caught | `test_json_that_is_not_a_tree_listing_is_unreadable[a-lone-surrogate]` | | 1 | the call back in text mode | `test_gh_s_stderr_in_the_skip_line_is_spelled_on_one_line` | | 2 | no git-directory check | `test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused[000]` | | 2 | the gitdir named unnormalized (`spec/../.git/modules/spec`) | `test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused[000]` | | 2 | the `gitdir: ` line's line end kept | `test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused[000]` | | 2 | the directory stat'ed, not its `HEAD` | `test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused[000]` | | 2 | the files' remedy, not the checkout's | `test_the_git_directory_a_leg_gitfile_names_unsearchable_is_refused[000]` | | 2 | a `.git` directory not asked | `test_a_leg_git_directory_this_user_cannot_search_is_refused[000]` | | 3 | no check of the directory the file is in | `test_what_stands_where_contracts_should_be_is_named[file]` | | 3 | that check asks for a file, not a directory | `test_a_file_that_is_not_there_keeps_its_words_byte_for_byte[leg-pin]` | | 3 | the not-a-file remedy given | `test_what_stands_where_contracts_should_be_is_named[file]` | | 3 | the file named, not its directory | `test_what_stands_where_contracts_should_be_is_named[file]` | | 3 | a regular file left unnamed (`which is not a directory`) | `test_what_stands_where_contracts_should_be_is_named[file]` | | 3 | "a symlink to" dropped | `test_what_stands_in_a_files_place_is_named_with_a_remedy_that_fits[leg-pin-symlink-to-a-directory]` | | 4 | `gh-response-unreadable` names the repository raw | `test_every_gh_refusal_names_the_repository_spelled[not-json-newline]` | | 4 | `gh-tree-truncated` names it raw | `test_every_gh_refusal_names_the_repository_spelled[truncated-newline]` | | 4 | "not a tree listing" names it raw | `test_every_gh_refusal_names_the_repository_spelled[not-a-listing-newline]` | | 5 (r2) | a socket named "a FIFO" | `test_what_stands_in_a_files_place_is_named_with_a_remedy_that_fits[leg-pin-socket]` | | 5 (r4) | an empty remedy where none was given | `test_a_refusal_with_no_remedy_of_its_own_is_built_with_none[leg-pin]` | Through `run9.sh` (stub `gh` first on PATH, bogus token), at the head's tree: - **git 2.43.0, 16 files: 1167 passed, 0 skipped, rc 0, in 8 min.** They are the two changed files, the other five `test_validate_pins*` files, `test_pinned_copy_text.py`, the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), `test_pin_validator.py`, `test_neutral_pin_validator.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_verdicts.py` and `test_pin_source_env_collisions.py`. - **git 2.47.1, the two changed files: 173 passed, rc 0.** - Left to CI: the rest of the suite. `git merge-tree --write-tree` is clean against origin/main `ebbd9fa4`, fetched before the push, and against lane 3's `l3-917-yaml-reader` `e63ca75c`, which edits `repo_shape.py`'s YAML reader. The commits on main since `2f5cabf5` touch none of this change's four files. Review: rv985, lane 5's independent reviewer, not the writer (w968): READY at `c8b6d0fa`, the head that lands (issuecomment-6254758943), with no MUST, SHOULD or NIT. Its six-variant probe finds no change outside what #968 names. CI (Brett Heap's ruling 27): H0 is the head c8b6d0f itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 2f5cabf; and the local gate passed on its predicted squash tree 6e24cee over main c4dd19d: the git_output_decoding, validate_pins_spelled_and_named, repo_hygiene, agents_md_codes, cli_reference sets, 343 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green". Closes #968 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-4 (openRepoShape-4)
Claim: #712 (comment)
What changed and why
Issue #712 gathers lane 4's checklist lines L4-103, L4-104, L4-106 and L4-144 on the assembly root's lockstep gate,
templates/assembly-root/scripts/validate-pins.py, with the riders L4-105, L4-143 and L4-102. This PR takes all seven, the two riders L4-143 and L4-102 included. It is milestone v1.1.1 and lands after the v1.1.0 tag.Measured on main
24a977c0, Python 3.12.3, with a scaffoldedAtlas:contracts/at 000 or r--PermissionErrortraceback, exit 1REFUSED yaml-unreadable: contracts/spec-pin.yaml: cannot be read (Permission denied), exit 2contracts/spec-pin.yamlat 000REFUSED yaml-unreadable: <abs>: [Errno 13] Permission denied: '<abs>'+ THE LOCKSTEP RULE's remedyproject.yamlor the shape pin at 000project.yaml: cannot be read (Permission denied)(and the shape pin alike)AGENTS-shape.md)PermissionErrortraceback fromfile_sha256, exit 1REFUSED shape-copy-unreadable: AGENTS-shape.md: cannot be read (Permission denied), exit 2neutral_product_pins: ["\x1b[31mRED"]contracts/<ESC>[31mred-pin.yamland--pin <ESC>[31mRED@<commit>raw (octal 033)'contracts/\x1b[31mred-pin.yaml'and--pin '\x1b[31mRED'@<commit>\n, U+202E, U+0085, ESC,\xff\nname is two linesrepr'dspec/at 000 or r-- (fix round 1, RV827's S1)PermissionError: [Errno 13] Permission denied: '<ROOT>/spec/.git', exit 1REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied), exit 2../openXdoxat 000 beside a root that declaresopenXdox(fix round 1, S1)PermissionError: [Errno 13] Permission denied: '<BESIDE>/openXdox/.git', exit 1FINDING neutral-pin-source-unreadable: contracts/openxdox-pin.yaml: <BESIDE>/openXdox (sibling checkout (<BESIDE>/openXdox)) could not answer for testorg/openXdox @ <commit>: Permission denied, exit 1neutral_product_pins: openXdox+--pin-source openXdox=../nowherenot used — project.yaml declares no neutral-product pin.not used — project.yaml's neutral_product_pins is not a list, so it declares no neutral-product pin (see the manifest-neutral-product-pins finding).Path.is_file()sites (leg pin, shape pin, each shape copy, each neutral-product pin, the manifest) are_is_filenow: validate-pins.py (assembly-root template): a.github/workflows/directory that this user cannot list or search is aworkflow-unreadablerefusal naming it, exit 2, so the project's pins gate no longer ends in a PermissionError traceback, exit 1, where a workflow file it cannot open already was one (#434) #633's_is_a, so every Python answers alike, and a refusedstatis a refusal naming the file. A NUL in a role or a product (aValueError) is still "no file", asis_file()answered. A shape copy whose open is refused, which passed itsstatand ended infile_sha256's traceback on every Python, is the same refusal. A copy had no id for one, so it is the newshape-copy-unreadable, besideshape-copy-missingandshape-copy-drift; the YAML files keepyaml-unreadable._check_pin_digest's(submodule / ".git").exists()andresolve_neutral_pin_source's(sibling / ".git").exists()raisedPermissionErroron 3.11 to 3.13; from 3.14 the leg would have read as an uninitialized submodule and the sibling been passed over forgh api. Both ask_is_there(_is_awith any kind; a NUL is nothing there, asexists()answered). The leg is the newpin-submodule-unreadable, besidepin-submodule-uninitialized, naming the.gitit could not reach, with the cause's advice; the sibling is there, so it is the source, and the pin'sneutral-pin-source-unreadablefinding says it could not answer._loadwrapsload_yaml. A file this user cannot open is read off theOSErrorthe refusal is chained from (sinceload_yamlwas written), and named relative to the root, POSIX, once:<rel>: cannot be read (<reason>), with a remedy of its own, somainadds no lockstep remedy. It does not depend on new library words, so a root whosescripts/repo_shape.pyis older than itsvalidate-pins.pyreads the same (repo_shape.py'sRefusalgives every refusal built without a remediation the assembly root's own (run git submodule update --init --recursive, then python3 scripts/validate-pins.py ... contracts/<leg>-pin.yaml), so in a family holderfamily.py addover an unparsable.gitmodules(git-failed),bumpon afamily.yamlof the wrong kind, and the holder's ownvalidate-family.pyon a shortpin.commiteach tell the operator to run a file no holder carries and to move a leg pin a holder does not have #370, Pinned shape copies: AGENTS-shape.md'sCONTRACTS_DIRsentence is false for every scaffolded root, and the chain WARNING offers remedies the scaffold lacks (one shape bump before the v1.1.0 tag) #610).repo_shape.load_yamlitself names the path once for every caller:<path>: cannot be read (<reason>).load_yaml's other refusals here (not UTF-8, not parsable) keep their id, words and remedy and name the file relative to the root; so donot a mapping(four YAML files),shape-pin-missing,manifest-missingandmanifest-no-legs, which named the absolute path raw.bump-leg.py's (bump-leg.py: a workflow file, a.github/workflows/directory or a leg pin that this user cannot open ends the run in a PermissionError traceback, exit 1, under--dry-runas well, becausecmd_bumpandplan_workflowsread them with no handler andmain()catchesRefusalandCommandFailedonly, so no refusal names the file or says to make it readable #383) for a pin, the manifest or a copy:chmod u+ron a file,u+rxon each directory above it. Any other cause (an I/O error, a name too long) getsbump-leg.py's other sentence and is never told tochmod._Unreadablecarries the cause, so one workflow refusal naming both kinds says both._shownspells it: a workflow's name, a pin's path built from a role or an entry, a leg's path, role and repository, a pin'ssource_repository, a declared product, a shape row's path and digest, the shape pin's and the manifest's commits, a--pin-sourcevalue with no path, and the root in a printedbump-leg.pyline. A command word isshlex.quoted where printable, as before, and_shown's where not (_quoted). Not spelled: the workflow-reference note's repository, which is only ever oneWORKFLOW_REF_REmatched (printable ASCII).neutral_product_pins:that is not a list points at themanifest-neutral-product-pinsfinding.PinSourceskeys--pin-sourceproducts bypin_product_key(lower(), the pin file's fold), as repo_shape.py and validate-repository-naming.py: a chain link, a declared pin and a--link-sourcekey are one product bypin_product_key, the pin file's own fold, so a tree declaringopenssno longer verifies a chain that saysopenß, a chain naming both is two links and not a cycle, and--link-source openss=no longer answers foropenß(#444) #638 keysLinkSources:casefold()tookopenss=foropenß.Pinned copies.
templates/assembly-root/scripts/validate-pins.pyandscripts/repo_shape.pyboth change. As #634 records, no digest is held in this repository: each root and holder reads the copies it carries asupstream-changedin itscontracts/shape-pin.yaml(both in an assembly root,scripts/repo_shape.pyin a family holder), whichupdate-shape.py applyre-copies and re-pins. No other copy, digest or recorded blob is touched.Tests
New:
tests/test_validate_pins_named_and_spelled.py, 344 tests, ontest_validate_pins_unreadable_workflow.py's harness (validate,mode_of,tree_bytes),test_bump_leg_unopenable.py'srefused_statandtest_validate_pins_without_git.py'svalidator:contracts/000 and r--, the leg pin, shape pin, manifest and a shape copy at 000): the whole of stderr, exit 2, nothing written, andpins okonce the mode is back;is_file()sites under a refusedstat, in-process, so on every Python, as root and on Windows; a NUL in a role or product is still "no file";not a mapping×4,shape-pin-missing, the three manifest refusals, not-UTF-8, not parsable), and beside an olderload_yaml's words;stator a read, for workflows and for the five sites, and one refusal holding both causes;["\x1b[31mRED"](whole stderr), the five workflow names (one line each, N lines then the remedy), L4-143's note (and the unchanged note for an empty list), and L4-102's keys;stat(permission and EIO) on every Python; a checkout beside the root under a refusedstat(it is the source) and at 000 (its finding); a role and a product spelled as an absolute path (_named's fallback, RV827's S2); a NUL in a leg's path. The bidi row is the escape\u202e(RV827's M1), and the digest rows assert the refusal each is about;tree_digest_from_gh(no_gh_api, shared with every neutral-pin row), so no mutant can reachgh api(RV827's N7).Edited: four existing tests that held the old absolute path:
tests/test_refusal_default_remediation.py(theyaml-unparsableline under the lockstep remedy now namescontracts/spec-pin.yaml),tests/test_bump_leg_unreadable.py(the other leg's pin is named POSIX by the validator, so the Windows spelling it expected is gone) a docstring intests/test_validate_pins_unreadable_workflow.py, and, in the merge with main (round 2, RV827's M2),tests/test_reader_refusals_named.py:199from #817, whoseyaml-unparsableline from the root'svalidate-pins.pynow namescontracts/spec-pin.yaml.Run (one background run on the merged tree, round 2's committed bytes
eea10815, Python 3.12.3,-q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed, noGIT_ALLOW_PROTOCOL): 2231 passed, 3 skipped, 1 xfailed, 0 failed in 24m35s. The skips aretests/test_shape_doctor.py:10429's three Windows-only PowerShell rows; the xfail istests/test_shape_doctor.py:4154's stricta-filerow, waiting on #735.tests/test_reader_refusals_named.pyalone: 44 passed. The files: the new one, the four edited ones,test_validate_pins_unreadable_workflows_dir,test_validate_pins_without_git,test_validate_pins_lockstep_lines,test_pins_child_kind_and_siblings_root_spelling,test_pin_validator,test_neutral_pin_validator,test_pin_source_unreadable,test_pin_source_env_collisions,test_pinned_copy_text,test_link_source_chain_defaults,test_non_list_project_fields,test_shape_pin_files_not_a_list,test_chain_link_fold,test_validator_child_encoding,test_bootstrap_without_git,test_doctor_gitlink_repairs,test_doctor_validator_verdicts,test_doctor_validator_rows,test_bump_leg_unopenable,test_load_yaml_refusals,test_manifest_validator,test_scaffold_pin_and_reuse,test_scaffold_e2e,test_pin_annotated_tag,test_adopt_pins,test_shape_doctor,test_repo_hygiene,test_cli_referenceandtest_agents_md_codes.Mutants (each reverts one check; each run is the new file at fix round 1, 344 tests; all killed):
_is_fileaskspath.is_file()statrows: on 3.12is_file()still raises inside the handler, so the 3.14 stand-in is what catches it)_loadis plainload_yaml_namedrelative but not spelled--pinraw_quotedis plainshlex.quotePinSourceskeys bycasefold()_is_fileraises a NUL'sValueErrorload_yamlnames the path twice_namedwithout its fallbackexists()statrows, as M1)exists()statrow, as M1)_is_thereraises a NUL'sValueErrorRound 2 re-ran M15 to M18 on the merged bytes (
eea10815, 344 tests each): 2, 2, 1 and 1 failed, each by the same rows; all killed.What it cannot tell
refused_statrows stand in for them on every platform and as root, and stand in for Python 3.14'sis_file()on 3.12 and 3.13; CI's Python, not 3.14 itself, is what runs them.repr'd so no control reaches the terminal; nothing makes it pasteable.pinsrow now quotes the gate's named refusal. Itslegsrow (shape-doctor.py:5735, throughpin_commitat:4945) sayscontracts/spec-pin.yaml carries no 40-hex commit,DRIFTED, for a pin at 000 and forcontracts/at 000; withcontracts/at 000,manifest kindsandshape currencysaycould not be run: [Errno 13] Permission denied: '<ROOT>/contracts'; and for a pin at 000manifest kindsnamesgit checkout HEAD -- contracts/spec-pin.yamlas NEXT, the wrong remedy for a permission fault.pinned but absent.statnames the file, not the directory that blocked it, and a WindowsPermissionErroris usually a sharing violation. It isbump-leg.py's rule (bump-leg.py: a workflow file, a.github/workflows/directory or a leg pin that this user cannot open ends the run in a PermissionError traceback, exit 1, under--dry-runas well, becausecmd_bumpandplan_workflowsread them with no handler andmain()catchesRefusalandCommandFailedonly, so no refusal names the file or says to make it readable #383), kept..gitgitfile at mode 000 (RV827's N6, for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459's checklist; main says the same): it passes_is_there, since itsstatis not refused, and the run ends inpin-commit-unresolvableunder the lockstep remedy.repo_shape.find_repo_root'snot-a-git-repodetail prints--rootraw; thegh-unreadabledetailtree_digest_from_ghbuilds, which the skip line prints, carriesgh's stderr raw. Both are library text outsideload_yaml.Shared files
Round 2 merged main
bad05d21(merge commitb9778ffb, "Merge main bad05d2 into fix/712-validate-pins-contracts"). It brought #747 and #817:_check_shape_copy: validate-family.py and validate-pins.py: a staged gitlink removal says to commit it,member-gitlink-absentputs the gitlink back by hand, the variantmvis guarded, and a pin row with nopath:isshape-pin-row(#681) #747'spath:check andtarget = root / path, then this branch's comment,named = _shown(path)andif not _is_file(root, target, "shape-copy-unreadable", named):, the resolution this body has named since round 0 (RV827 found it identical to its own);repo_shape.load_yamlmerged cleanly (repo_shape.py, shape_materialize.py and adopt-project.py: a value the YAML reader refuses for opening with an indicator is named by its line and says to double-quote it or reword it,plan --org @xnames--orgagain under the reader's own remedy, andchecknames one badtracking_branchonce (#708) #817 changed theyaml-unparsableclause, this branch theOSErrorclause);tests/test_reader_refusals_named.py:199(repo_shape.py, shape_materialize.py and adopt-project.py: a value the YAML reader refuses for opening with an indicator is named by its line and says to double-quote it or reword it,plan --org @xnames--orgagain under the reader's own remedy, andchecknames one badtracking_branchonce (#708) #817) expectscontracts/spec-pin.yaml, relative, in the root gate'syaml-unparsableline.git merge-tree --write-treefrom this headeea10815, main and every head fetched from GitHub, heads read withgh pr listat round 2:bad05d217b3ba914)fix/714-legs-not-a-list(lane 4, W714)680e26ab_load_legs_to_check(as atcf376b7a)fix/763-holder-docs-agreeea86cb60_check_shape_copy, only because #822's branch carries #747's change on a base before #747 landed; RV827 predicted "#822 after merging main" merges cleanly with main + this PRfix/715-yaml-reader-spaces(lane 4)153bab9088e91125)fix/711-git-bash-dialect7581ffb9856766c0)l3-739-agree-note(lane 3)787d7e1eabb32d38)fix/710-non-utf8-branch-names(lane 4)7777c7d02d65b70e)fix/686-bump-reachability17bbbabaf99ac5ad)Order: lane 3's v1.1.1 order puts this PR before #826. #826's merge round is #826's, after this PR lands, with RV827's resolution, which passed both PRs' tests and
tests/test_shape_doctor.pyon the merged tree atcf376b7a(689 passed). At #826's head680e26abthe same resolution, carrying its newmanifest-leglines, passedtest_legs_not_a_list, this PR's new file,test_adopt_plan_yaml_quoting,test_manifest_validatorandtest_pin_validatoron the merged tree (1092 passed, round 2):legs:that is not a list ismanifest-legsin every gate, a chain link or pin that is not a name is a finding, and the naming gate never reads stdin under--project(#714) #826's signature(root: Path, report: Report)and its_run_all_checkscall; these auto-merge;contracts/still ends in a PermissionError, an unreadable pin is named by its absolute path twice, and a workflow name or a pin entry's control character reaches stderr raw #712's paragraph, then validate-repository-naming.py and validate-pins.py: a manifest'slegs:that is not a list ends in a TypeError or waits on stdin, a chain link that is not a name passes, and--explainends in a UnicodeEncodeError on a name that is not UTF-8 #714's two;_is_file,_load,"project.yaml: not a mapping"and"project.yaml: no non-assembly legs declared", and between them insert validate-repository-naming.py, validate-manifest.py and validate-pins.py: alegs:that is not a list ismanifest-legsin every gate, a chain link or pin that is not a name is a finding, and the naming gate never reads stdin under--project(#714) #826'sdeclared = manifest.get("legs")block, which returnsmanifest, []with itsmanifest-legsfinding, itsnot_mappingslist andmanifest-legfindings (680e26ab), andlegs = [... (declared or []) ...];if not legs and not not_mappings:merges by itself;legs:that is not a list ismanifest-legsin every gate, a chain link or pin that is not a name is a finding, and the naming gate never reads stdin under--project(#714) #826's findings already spell their values ({declared!r},{leg!r}), as this PR's rule asks.Closes #712
🤖 Generated with Claude Code
Summary by Sourcery
Make pin validation fail safely and consistently when repository data is inaccessible, while emitting actionable, root-relative, escaped diagnostics.
Bug Fixes:
Enhancements:
Tests: