Skip to content

validate-pins.py and repo_shape.py: gh's stderr that is not UTF-8 is 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

Merged
brettheap merged 1 commit into
mainfrom
fix/968-pins-after-919
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #968 (comment)

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.

Item 1: gh's stderr that is not UTF-8, and gh's JSON

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.

Probe. A declared openXdox pin with no local source.

Item 2: a leg's git directory this user cannot search

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.

Item 3: what stands where contracts should be

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.

Item 4: every gh detail spells the repository

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.

Item 5: tests that kill r2 and r4

Output not named here is unchanged

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.

Pinned copies

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

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:

Eight of #919's in-process gh rows fail there only because the stand-in now hands bytes, where the base asked for text.

Mutants

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]

Runs

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.

Merge checks

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.

Closes #968

🤖 Generated with Claude Code

Summary by Sourcery

Harden pin validation and GitHub tree handling so malformed responses and inaccessible repository structures produce clear, actionable outcomes instead of tracebacks.

New Features:

  • Handle non-UTF-8 and malformed GitHub tree responses as skips or named refusals without tracebacks.
  • Reject unreadable leg Git directories with a specific checkout-readability refusal.
  • Identify non-directory objects replacing the contracts directory and provide appropriate remediation.

Bug Fixes:

  • Ensure GitHub-related refusals consistently spell repository names safely.
  • Preserve refusal behavior when no site-specific remediation is provided.

Enhancements:

  • Expand validation coverage for unusual filesystem objects, Git directory layouts, malformed GitHub responses, and output decoding.

Tests:

  • Add comprehensive tests covering invalid GitHub output, unreadable Git directories, replaced contracts directories, repository spelling, sockets, and refusal defaults.

…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)

Item 1. `tree_digest_from_gh` reads gh's two streams as bytes and decodes
each with `_child_text`: stderr with `errors="replace"`, as `git_out` reads
git's, and stdout strictly, as #217 asks of a listing that becomes a
digest. A CR is still a line break, as text mode made it. One byte of
gh's stderr that is not UTF-8 ended the run in a `UnicodeDecodeError`
traceback, exit 1, where a skip belongs; it is U+FFFD in the skip now. A
byte that is not UTF-8 on stdout, and JSON that is not the listing's
object (a list, `null`, a row that is no mapping or lacks a column, a
path holding a lone surrogate), is `gh-response-unreadable`, where each
was a traceback.

Item 2. `_check_pin_digest` asks `_refuse_an_unreadable_git_dir` after
the uninitialized check: the `stat` of `HEAD` in the leg's git directory,
the `.git` itself where it is a directory, or the one its gitfile's
`gitdir: ` line names (`_leg_git_dir`, line ends stripped as git strips
them). A refused `stat` is `pin-submodule-unreadable` through
`_unreadable_file`, with the leg checkout's remedy and `_is_a`'s errno
set. `spec/.git` at mode 000 ended `pin-commit-unresolvable` with an empty
reason, and `.git/modules/spec` at mode 000 in `fatal: not a git
repository`, both under THE LOCKSTEP RULE. A git directory with no
`HEAD`, or a gitfile that names none, is left to git as before.

Item 3. `_no_file` asks `_not_a_directory` of the directory the file is
in where nothing stands at the file's name: `contracts` replaced by a
file, a FIFO, a socket or a symlink that leads to no directory is named
(`... is under contracts, which is a file, not a directory`), with
`NOT_A_DIRECTORY`'s remedy, where every pin read "does not exist" under
the lockstep remedy. `_what_stands` is #919's `_not_a_file` reading,
shared by both.

Item 4. `gh-response-unreadable` and `gh-tree-truncated` name the
repository with `shown_path`, as #919 named it in `gh-unreadable`.

Item 5. `tests/test_validate_pins_spelled_and_named.py` gains a socket in
each file's place (rv919's r2) and a `Refusal` that defaults to a remedy,
as one older than #370 does, at the three sites that give none (r4), with
the tests of items 1 to 4. Its `subprocess.run` stand-in hands bytes, and
`tests/test_git_output_decoding.py` drops the `EXEMPT` row for this call,
which asks for no text now.

Both files are pinned copies; `validate-pins.py` imports nothing new from
`repo_shape`, and every state #968 does not name prints byte for byte
what it printed, beside the current and the pre-#370 `repo_shape.py`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-18T20:13Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

The pull request hardens GitHub tree parsing and pin validation against undecodable or malformed child-process output, inaccessible Git metadata, and filesystem objects replacing expected directories, while adding comprehensive cross-platform, compatibility, and mutation-tested diagnostics.

Sequence diagram for robust GitHub tree response handling

sequenceDiagram
    participant Validator
    participant GH as gh
    participant Parser as tree_digest_from_gh
    Validator->>Parser: tree_digest_from_gh(repository, commit)
    Parser->>GH: gh api tree endpoint
    GH-->>Parser: stdout bytes and stderr bytes
    alt nonzero exit
        Parser->>Parser: _child_text(stderr, replace)
        Parser-->>Validator: Refusal gh-unreadable with shown_path(repository)
    else successful exit
        Parser->>Parser: _child_text(stdout, strict)
        alt invalid UTF-8 or malformed tree listing
            Parser-->>Validator: Refusal gh-response-unreadable
        else truncated listing
            Parser-->>Validator: Refusal gh-tree-truncated with shown_path(repository)
        else valid tree listing
            Parser-->>Validator: tree digest
        end
    end
Loading

Flow diagram for filesystem-aware pin validation

flowchart TD
    Start[_check_pin_digest] --> Init[Check submodule initialization]
    Init -->|uninitialized| RefuseInit[Refuse pin-submodule-uninitialized]
    Init -->|initialized| GitDir[_refuse_an_unreadable_git_dir]
    GitDir --> Resolve[_leg_git_dir]
    Resolve --> Head[Check git directory HEAD with _is_there]
    Head -->|stat refused| RefuseGit[Refuse pin-submodule-unreadable with checkout remedy]
    Head -->|readable or absent| Git[Resolve pinned commit with git]
    Git --> Digest[Validate pin digest]

    Missing[_no_file] --> File[_not_a_file]
    File -->|unexpected object| RefuseFile[Name object and use file remedy]
    File -->|nothing| Holder[_not_a_directory on parent]
    Holder -->|parent is not directory| RefuseDir[Name holder and use directory remedy]
    Holder -->|directory or absent| MissingPin[Keep missing-file behavior]
Loading

File-Level Changes

Change Details Files
Make GitHub CLI tree retrieval resilient to byte-level encoding and malformed responses.
  • Read subprocess output as bytes and decode stderr with replacement while keeping JSON stdout strict.
  • Convert invalid UTF-8 and structurally invalid tree payloads into gh-response-unreadable refusals without tracebacks.
  • Preserve CR line-break behavior and consistently escape repository names in GitHub-related diagnostics.
scripts/repo_shape.py
Detect unreadable leg Git directories before Git can misclassify them.
  • Resolve .git directories and gitdir: files, then probe HEAD accessibility after initialization checks.
  • Report inaccessible directories as pin-submodule-unreadable with the checkout-specific remediation while preserving Git handling for missing or invalid metadata.
templates/assembly-root/scripts/validate-pins.py
Improve diagnostics when expected pin-containing directories are replaced.
  • Generalize filesystem-kind detection across files, directories, FIFOs, sockets, devices, and broken or looping symlinks.
  • Check the parent directory when a required pin file is absent and provide a dedicated directory-restoration remediation.
  • Preserve legacy absent-file wording and refusal-remedy defaults across older repo_shape.py versions.
templates/assembly-root/scripts/validate-pins.py
Expand regression and mutation coverage for the new refusal behavior.
  • Add byte-oriented GitHub stubs and cases for invalid UTF-8, malformed JSON, malformed tree listings, CRLF input, and escaped repository names.
  • Cover unreadable Git directories, replacement filesystem nodes, socket handling, and remedy/default compatibility.
  • Remove the obsolete text-decoding exemption for tree_digest_from_gh.
tests/test_validate_pins_spelled_and_named.py
tests/test_git_output_decoding.py

Assessment against linked issues

Issue Objective Addressed Explanation
#968 Handle gh output without tracebacks: replace invalid UTF-8 in stderr while keeping stdout strict, and refuse malformed tree-listing JSON; spell repository names in gh refusal details. ✅
#968 Name and refuse unreadable leg git directories, and report what stands where the contracts directory should be with an appropriate remedy. ✅
#968 Add regression tests covering sockets in file positions and refusals constructed without an explicit remedy, including the requested r2 and r4 cases. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T20:16:40.407972Z c8b6d0f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sonarqubecloud

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8b6d0fa67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/repo_shape.py
records = sorted(
f"{row['mode']} {row['type']} {row['sha']}\t{row['path']}"
.encode()
for row in payload.get("tree") or [] if row.get("type") != "tree")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject a missing or falsy tree payload

When gh api exits successfully but returns {}, {"tree": null}, {"tree": 0}, {"tree": false}, or {"tree": ""}, payload.get("tree") or [] treats the malformed response as a valid empty listing and returns SHA-256 of the empty byte string. This can falsely report pin drift, or even accept an empty-tree digest, instead of producing the intended gh-response-unreadable skip; validate that tree is present and is a list before computing records.

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #985 (#968) at c8b6d0fa, for lane openRepoShape-5: READY

I found no MUST, SHOULD or NIT. Each of #968's items is fixed as the issue asks. A fresh six-variant probe finds no change outside what the issue names. Every count and merge result in the body that I measured again holds.

Findings

None at MUST, SHOULD or NIT. The two things below that a later change could pick up are checklist candidates, not defects of this PR.

Checked and true

Each #968 item. The probe is mine (probe985.py, built from probe919.py), scaffolded fresh from the head. Unless a row says in-process, it runs the scaffold's own gate with a stub gh, on git 2.43.

Item Before (2f5cabf5) After (c8b6d0fa)
gh's stderr not UTF-8 UnicodeDecodeError traceback, exit 1 exit 0, a skip ending bad\ufffdbyte
gh's JSON not UTF-8, or not a tree listing ([], null, {"tree": 5}) traceback, exit 1 a skip naming gh-response-unreadable, with the error
.git/modules/spec at 000 pin-commit-unresolvable, lockstep remedy pin-submodule-unreadable: .git/modules/spec: cannot be read (Permission denied), checkout remedy
spec/.git a directory at 000 pin-commit-unresolvable, lockstep remedy the same refusal, naming spec/.git
contracts a file, FIFO, socket, symlink to nothing, symlink loop or symlink to a file "does not exist", lockstep remedy ... is under contracts, which is <kind>, not a directory, with the new remedy
repository in gh-response-unreadable and gh-tree-truncated (in-process, ESC in the name) raw ESC 'testorg/x\x1b[31mRED'
  • Unchanged cases stay byte-identical:
    • contracts absent, and contracts a symlink to the real directory;
    • a valid listing, with and without CR LF (the recomputed digest is the same);
    • JSON that does not parse, and a truncated listing.
  • The r2 and r4 tests now work. I re-ran both of my old mutants against the new tests:
    • r2 (a socket named "a FIFO") fails 6 tests;
    • r4 (an empty remedy where none was given) fails 3 tests.

Probe (76 cases, 456 records). I ran six pairs of the two files:

Results (cmp985.py):

The pinned-copy rule.

_leg_git_dir (validate-pins.py:427) agrees with git. I checked 11 gitfiles against git rev-parse --absolute-git-dir (gitdir985.py):

  • Same directory as git: relative with .., ending LF, CR LF, CR CR LF, no line end, a blank line after, and absolute.
  • Git refuses, and _leg_git_dir names no directory that has a HEAD, so git still answers: a trailing space, gitdir: with no space, an empty path, a second line, and .. through a directory that does not exist.
  • Git strips only CR and LF from the end of the whole file, and rstrip(b"\r\n") does the same.
  • .. is left to the system to follow. normpath (:462) is used only for the name in the refusal.

Mutants.

  • I re-ran three of w968's: m1c (a lone CR kept) fails 1 test, m2c (the line end kept) fails 7, m3b (the holder check asks for a file) fails 11.
  • My six all die:
    • stderr decoded with surrogateescape: 2;
    • the refusal names the gitfile, not the git directory: 4;
    • a symlink loop read as "a symlink to nothing": 6;
    • gitdir joined to the current directory: 8;
    • only PermissionError caught: 1;
    • payload.get asked outside the guard: 7.
  • No survivor.

Base-fail. Both changed test files run against 2f5cabf5's code: 51 failed, 122 passed, as the body says.

Runs at c8b6d0fa, through run9.sh:

git Files Result
2.43 the two changed files, the other five test_validate_pins* files, test_pinned_copy_text.py, the trio (11 files) 873 passed, 0 skipped, rc 0
2.47.1 the two changed files 173 passed, rc 0

Collecting the writer's 16-file list gives 1167 tests, and the changed test file holds 106, both as the body says.

Merges.

  • origin/main re-fetched is ebbd9fa4; the merge is clean (tree 16ebd518). The only one of these files main has changed since 2f5cabf5 is tests/conftest.py, and that change is one unrelated parameter row.
  • l3-917-yaml-reader e63ca75c: clean (tree 10664dce).

Sonar-style checks.

  • Cognitive complexity, counted by hand: all under 15.
    • tree_digest_from_gh 6-7.
    • _check_pin_digest 11, unchanged: it only gains a call at :1238.
    • _what_stands 10-11, _leg_git_dir 2, _refuse_an_unreadable_git_dir 2, _no_file 2, _not_a_file and _not_a_directory 2 each.
  • Line widths: no added line widens a file. The widest added lines are 78, 79, 79 and 76 characters, against file widths of 93, 84, 79 and 81.
  • No assert joins conditions with and, and no non-ASCII character is added to the code. body-guard returns rc 0 on the body.

Could not verify

  • Windows and macOS, since CI runs Linux only. That includes a gitfile naming C:/... and the winerror branches.
  • git 2.55.0, which CI runs, and the doctor test files. The writer ran test_doctor_validator_rows.py and test_doctor_validator_verdicts.py; I did not.

Checklist candidates

  1. A shape copy whose folder is not a directory still reads "pinned but absent". I measured this on head: with .github/workflows replaced by a file, the gate prints FINDING shape-copy-missing: contracts/shape-pin.yaml: .github/workflows/validate.yml is pinned but absent. This is because _check_shape_copy (validate-pins.py:1401) asks only _not_a_file. validate-pins.py and repo_shape.py after #919: gh's stderr that is not UTF-8 ends in a traceback, a leg's git directory this user cannot read is called an unresolvable pin with no reason, and two gh details still name the repository raw #968 named only contracts, so this is out of its scope.
  2. The writer's three, which I did not measure again:
    • HEAD at mode 000 inside a readable git directory;
    • an empty or garbage gitfile;
    • a role holding / with contracts a file.

@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

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

You can request another review in 2 days and 8 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit cf66102 into main Oct 19, 2026
5 checks passed
@brettheap
brettheap deleted the fix/968-pins-after-919 branch October 19, 2026 11:40
brettheap added a commit that referenced this pull request Oct 19, 2026
…user cannot read is refused by name, `member-unreadable`, exit 2, so Python 3.14 no longer clones a member on its remote's default branch, fetches into a clone it never identified, or says `siblings ok` and `family bootstrap ok` over members it skipped as absent (part of #430) (#951)

Slice F of lane 1's plan for #430 (issuecomment-6251069236), in the family
root's two pinned scripts, templates/family-root/scripts/siblings.py and
bootstrap.py. Each guard asked `Path.is_file()` or `Path.exists()` of a
member's path. Python 3.12 and 3.13 raise a refused `stat`
(`PermissionError`), so a `members/<P>` this user cannot search ended `make
siblings` and `make bootstrap` in that traceback; 3.14 answers False to any
`OSError`, so the path read as absent. Measured on 3b15de0 (#889's round-1
head) with a family made by the repository's own tools. Under a real mode on
Python 3.12.3 every site below ended in a `PermissionError` traceback, exit
1. Under a 3.14-like shim (`is_file`, `is_dir`, `exists` and `is_symlink`
answer False to any `OSError`):

- siblings.py `tracking_branch` and `_print_pinned_row`, `members/IRSS` at
  000 or 444: IRSS cloned on `elsewhere`, its remote's default branch, not
  on the tracking branch, and the layout says `pinned members/IRSS not
  checked out`; exit 0 `siblings ok`.
- `project_id`, a clone's `project.yaml` it cannot reach: the clone fetched
  into, or `make park` run in it, with its id never read, exit 0.
- `run_member_bootstrap`: "no scripts/bootstrap.py; cloned and left as it
  is", its legs never placed, exit 0. `dispatch`'s `Makefile`: "has no
  Makefile".
- bootstrap.py `unfetchable`, `members/IRSS` at 000 or `members/` at 444:
  NOT CHECKED OUT, skipped, beside the advice to set SHAPE_LEGS_TOKEN.
  `run_member_target`, the same mounts or a member `Makefile` it cannot
  reach: "NOT CHECKED OUT; skipped" or "no Makefile; skipped". Both exit 0
  `family bootstrap ok`.
- `members_of`, a `family.yaml` it cannot reach: `family-manifest-missing`,
  "does not exist".

Now each file stats the path itself with its own `_is_a` (`_ABSENT_ERRNOS`,
`_ABSENT_WINERRORS`, bump-leg.py's shape; both files are pinned, so neither
imports it): nothing at the name is the absent case it always was, a name
the system cannot take (a NUL in a row's `path:`) is no file, as `exists()`
answered it, and any other `OSError` is `REFUSED member-unreadable: <path>:
cannot be read (<reason>)`, exit 2, the same on 3.12 and 3.14. A permission
gets validate-pins.py's `chmod u+r` / `chmod u+rx` advice (bootstrap.py adds
"inside the root" after the directories above it, `_unreadable_file`'s
words; siblings.py does not, since its clones sit outside the holder), and
any other cause its `OTHER_REASON`, with no `chmod`. `family.yaml` is
`family-manifest-unreadable`, the code a `family.yaml` that is no mapping
already has.

- siblings.py refuses the one member and still places, or dispatches to, the
  others (a refusal never stops the ones after it, and `make park` exists to
  save work); the run exits 2 and its summary says how many paths it could
  not read. A refused `scripts/bootstrap.py` says the clone was made and to
  run `python3 scripts/bootstrap.py` in it, since a second `make siblings`
  only fetches.
- bootstrap.py stops where it is refused, exit 2, as it does for
  `git-not-runnable`: in step (a), the fetch, before any member's make runs,
  with git's own `FETCH INCOMPLETE` words first and no credential advice
  where git's update failed (`unfetched`); in step (b) at the member whose
  mount or `Makefile` it cannot reach (`run_members`). `main` catches the
  refusal from either once, stdout flushed first.

Rounds and review. The writer is lane 4's W430F; the reviewer is lane 5's
rv951.

- Round 0, 528adbf, stacked on #889's 29083a5. Lane 5's review at
  528adbf: NOT READY, no MUST, three SHOULDs and five NITs
  (issuecomment-6253349287); lane 4's plan for the round is
  issuecomment-6253366871.
- Round 1, 3c5f2ea, a fast-forward on 528adbf (issuecomment-6254509055).
  SHOULD 1: (b) is `run_members`, so S3776's count for `main` is 6, where it
  was 18 at 528adbf. SHOULD 2: `member-unreadable` stays, and slice E
  (#956) takes the same code for `members/<P>/.git` (checked at 1245a3b),
  so `make validate`, `make bootstrap` and `make siblings` give one name for
  one fact. SHOULD 3: a NUL in a name is no file. NIT 4: the placement
  summary's words are tested. NIT 5: the reason `parent_folder_warning` is
  left alone is corrected. NIT 6: git's own words come before step (a)'s
  refusal. NIT 7: the errno tests are three, with true names. NIT 8:
  bootstrap.py says "inside the root".
- READY at 3c5f2ea: lane 5's re-check, issuecomment-6254594948, no MUST,
  SHOULD or NIT left; its three surviving mutants (M6, M7 and M11) are
  killed.
- Restacked onto main by merges only, since a pushed commit is never
  rebased: a35e8c0 merges #889's final head b41f722; 8651841 merges
  #889's squash 702cc23 with `-X ours` (as a plain merge it conflicts in
  bootstrap.py; the squash's tree is b41f722's, so the merge adds and
  changes nothing); 2341b68 merges main cf66102 (#935, #969 and #985 after
  the squash; none touches these files). Lane 4's reply is
  issuecomment-6266695040.
- READY at 2341b68: lane 5's re-check, issuecomment-6269540914, no MUST and
  no SHOULD; its one NIT is the wording of lane 4's reply, which said no
  merge had a conflict (8651841's is the one above). #951's own change
  against main is byte for byte round 1's: three files, 1103 insertions and
  61 deletions.

tests/test_family_siblings_bootstrap_unreadable.py (new, 57 tests). Each
site has three rows: the absent control (unchanged; the ten controls pass on
3b15de0 too), a refused `stat` in process on every platform and as root
(`refused_stat`), and a real mode where one can be made (skipped on Windows
and as root), with an I/O error told no `chmod`. Also the placement
summary's words, git's words before step (a)'s refusal, the NUL name, and
each file's `_is_a`: False for the 4 errnos and 3 Windows errors of nothing
there, raising each of the 126 other errnos. The helpers are imported from
tests/test_bump_leg_unopenable.py and tests/test_doctor_unreadable_pin.py,
none copied; conftest.py is not touched. Round 0's file (35 tests) was 25
failed and 10 passed on 3b15de0; all 57 pass.

Runs (lane 4's on Python 3.12.3, with a stub `gh` first on PATH,
GH_TOKEN=bogus, an empty GH_CONFIG_DIR and GIT_SSH_COMMAND=false):

- 3c5f2ea, one targeted run: 2538 passed, 10 skipped (git filter-repo is
  not installed) in 26m05s, over 30 files (the new file, the 26 test files
  that run or load the family root's siblings.py or bootstrap.py, and the
  trio: tests/test_repo_hygiene.py, tests/test_cli_reference.py and
  tests/test_agents_md_codes.py) and the 19 tests of
  tests/test_shape_doctor.py that load or run them.
- 2341b68: the new file, all of tests/test_shape_doctor.py,
  tests/test_scaffold_leg_path_collisions.py and the trio: 772 passed, 7
  skipped in 13m44s.
- Lane 5's, on git 2.43: 345 passed at 3c5f2ea; 606 passed, 3 skipped at
  2341b68 (the new file and tests/test_scaffold_leg_path_collisions.py,
  161; tests/test_shape_doctor.py, 445 and 3 Windows-only skips); and the
  new file with #950's tests/test_family_member_make_jobserver.py on a merge
  with main c7a3ff1, 100 passed.

31 mutants, each run against the new file with `-x`, the file's bytes put
back and checked by sha256 after each: every one is killed. Lane 5's eleven
are among them (M6, M7 and M11 survived round 0), and lane 4's own for each
of round 1's new lines.

Not done by this change, and said in the PR:

- A file whose `stat` succeeds and whose open is refused (a `project.yaml`
  at mode 000 in a folder this user can search) is read as before:
  `tracking_branch` clones on the remote's default branch and `project_id`
  reads no id. It is no absence read, and it predates #430.
- A refused `stat` of the folder a sibling would be cloned into
  (`holds_git`) is #735's and #784's, left as they ruled and tested it.
  `parent_folder_warning`'s `wanted.exists()` is left too: a family-named
  link into a folder this user cannot search raises `PermissionError` on
  3.12 and says "it is a link to nothing" on 3.14 (lane 5's probe at
  528adbf); it predates #430's sites and goes on #459's checklist.
- No Python 3.14 interpreter was run: the 3.14 half is `refused_stat` in
  process and a shim on the command line.

Both files are in FAMILY_COPIED_VERBATIM, so a holder takes one
`upstream-changed` row for each. Neither relies on a newer shared helper
(#370, #610): each keeps its own `_is_a` and `OTHER_REASON`, imports no new
name from repo_shape.py (which is untouched), and siblings.py imports no new
name from bootstrap.py, so either taken alone runs beside the other's older
copy.

Part of #430 (slice F; lane 1's plan, issuecomment-6251069236). #430 stays
open for its other slices.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant