Skip to content

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

Merged
brettheap merged 5 commits into
mainfrom
fix/712-validate-pins-contracts
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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 scaffolded Atlas:

case main this branch
contracts/ at 000 or r-- PermissionError traceback, exit 1 REFUSED yaml-unreadable: contracts/spec-pin.yaml: cannot be read (Permission denied), exit 2
contracts/spec-pin.yaml at 000 REFUSED yaml-unreadable: <abs>: [Errno 13] Permission denied: '<abs>' + THE LOCKSTEP RULE's remedy the line above, with its own remedy
project.yaml or the shape pin at 000 the absolute path twice + the lockstep remedy project.yaml: cannot be read (Permission denied) (and the shape pin alike)
a shape copy at 000 (AGENTS-shape.md) PermissionError traceback from file_sha256, exit 1 REFUSED shape-copy-unreadable: AGENTS-shape.md: cannot be read (Permission denied), exit 2
neutral_product_pins: ["\x1b[31mRED"] contracts/<ESC>[31mred-pin.yaml and --pin <ESC>[31mRED@<commit> raw (octal 033) 'contracts/\x1b[31mred-pin.yaml' and --pin '\x1b[31mRED'@<commit>
workflows named with \n, U+202E, U+0085, ESC, \xff raw; the \n name is two lines one line each, repr'd
a leg's checkout spec/ at 000 or r-- (fix round 1, RV827's S1) PermissionError: [Errno 13] Permission denied: '<ROOT>/spec/.git', exit 1 REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied), exit 2
../openXdox at 000 beside a root that declares openXdox (fix round 1, S1) PermissionError: [Errno 13] Permission denied: '<BESIDE>/openXdox/.git', exit 1 FINDING neutral-pin-source-unreadable: contracts/openxdox-pin.yaml: <BESIDE>/openXdox (sibling checkout (<BESIDE>/openXdox)) could not answer for testorg/openXdox @ <commit>: Permission denied, exit 1
scalar neutral_product_pins: openXdox + --pin-source openXdox=../nowhere not 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).

Pinned copies. templates/assembly-root/scripts/validate-pins.py and scripts/repo_shape.py both change. As #634 records, no digest is held in this repository: each root and holder reads the copies it carries as upstream-changed in its contracts/shape-pin.yaml (both in an assembly root, scripts/repo_shape.py in a family holder), which update-shape.py apply re-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, on test_validate_pins_unreadable_workflow.py's harness (validate, mode_of, tree_bytes), test_bump_leg_unopenable.py's refused_stat and test_validate_pins_without_git.py's validator:

  • the issue's modes through the real gate (contracts/ 000 and r--, the leg pin, shape pin, manifest and a shape copy at 000): the whole of stderr, exit 2, nothing written, and pins ok once the mode is back;
  • the five is_file() sites under a refused stat, in-process, so on every Python, as root and on Windows; a NUL in a role or product is still "no file";
  • every file named relative to the root (not a mapping ×4, shape-pin-missing, the three manifest refusals, not-UTF-8, not parsable), and beside an older load_yaml's words;
  • each cause's advice: EIO and ENAMETOOLONG on a stat or a read, for workflows and for the five sites, and one refusal holding both causes;
  • one row for each print (47 print sites) × a newline, U+202E, U+0085, ESC and an undecodable byte: spelled, as many times as printed, never raw; and a printable control for each site;
  • the issue's own inputs: ["\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;
  • fix round 1: a leg checkout at 000 and r-- through the real gate (whole stderr), and under a refused stat (permission and EIO) on every Python; a checkout beside the root under a refused stat (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;
  • round 2: the sibling row at a real 000 stubs tree_digest_from_gh (no_gh_api, shared with every neutral-pin row), so no mutant can reach gh api (RV827's N7).

Edited: four existing tests that held the old absolute path: tests/test_refusal_default_remediation.py (the yaml-unparsable line under the lockstep remedy now names contracts/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 in tests/test_validate_pins_unreadable_workflow.py, and, in the merge with main (round 2, RV827's M2), tests/test_reader_refusals_named.py:199 from #817, whose yaml-unparsable line from the root's validate-pins.py now names contracts/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, no GIT_ALLOW_PROTOCOL): 2231 passed, 3 skipped, 1 xfailed, 0 failed in 24m35s. The skips are tests/test_shape_doctor.py:10429's three Windows-only PowerShell rows; the xfail is tests/test_shape_doctor.py:4154's strict a-file row, waiting on #735. tests/test_reader_refusals_named.py alone: 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_reference and test_agents_md_codes.

Mutants (each reverts one check; each run is the new file at fix round 1, 344 tests; all killed):

mutant what it reverts failed
M1 _is_file asks path.is_file() 11 (the refused-stat rows: on 3.12 is_file() still raises inside the handler, so the 3.14 stand-in is what catches it)
M2 _load is plain load_yaml 22
M3 the workflow advice's old gate 3
M4 the permission advice whatever the cause 15
M5 _named relative but not spelled 62
M6 the neutral remedy's --pin raw 6
M7 _quoted is plain shlex.quote 25
M8 no handler on the shape copy's open 3
M9 PinSources keys by casefold() 3
M10 the L4-143 note not pointing at the finding 1
M11 _is_file raises a NUL's ValueError 2
M12 load_yaml names the path twice 1
M13 a held-back line's leg path raw 5
M14 the shape pin's two commits raw 5
M15 RV827's MG: _named without its fallback 2 (S2's rows)
M16 the leg checkout asked with exists() 2 (the refused-stat rows, as M1)
M17 the sibling asked with exists() 1 (the refused-stat row, as M1)
M18 _is_there raises a NUL's ValueError 1

Round 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

Shared files

Round 2 merged main bad05d21 (merge commit b9778ffb, "Merge main bad05d2 into fix/712-validate-pins-contracts"). It brought #747 and #817:

git merge-tree --write-tree from this head eea10815, main and every head fetched from GitHub, heads read with gh pr list at round 2:

against head result
main bad05d21 clean (tree 7b3ba914)
#826 fix/714-legs-not-a-list (lane 4, W714) 680e26ab CONFLICT, two regions in _load_legs_to_check (as at cf376b7a)
#822 fix/763-holder-docs-agree ea86cb60 CONFLICT, one region in _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 PR
#823 fix/715-yaml-reader-spaces (lane 4) 153bab90 clean (tree 88e91125)
#821 fix/711-git-bash-dialect 7581ffb9 clean (tree 856766c0)
#769 l3-739-agree-note (lane 3) 787d7e1e clean (tree abb32d38)
#824 fix/710-non-utf8-branch-names (lane 4) 7777c7d0 clean (tree 2d65b70e)
#795 fix/686-bump-reachability 17bbbaba clean (tree f99ac5ad)

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.py on the merged tree at cf376b7a (689 passed). At #826's head 680e26ab the same resolution, carrying its new manifest-leg lines, passed test_legs_not_a_list, this PR's new file, test_adopt_plan_yaml_quoting, test_manifest_validator and test_pin_validator on the merged tree (1092 passed, round 2):

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:

  • Refuse unreadable pins, manifests, shape copies, workflows, and checkout metadata with stable named diagnostics instead of tracebacks or misleading missing/uninitialized results.
  • Preserve cause-specific remediation guidance for permission, I/O, encoding, and other read failures.
  • Prevent control characters and non-printable content in repository names, paths, workflow names, and command guidance from being emitted raw.
  • Correct neutral-product pin source matching and clarify unused-source diagnostics for invalid manifest fields.

Enhancements:

  • Standardize file diagnostics as root-relative POSIX paths named once across the validator and YAML loader.
  • Add shared handling for refused filesystem metadata access and YAML loading while preserving compatibility with older repository-shape readers.

Tests:

  • Add comprehensive coverage for unreadable files and directories, filesystem error causes, path naming and escaping, checkout source failures, manifest edge cases, and pin-source folding.

…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>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The 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 normalization

sequenceDiagram
    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
Loading

Flow diagram for root-relative unreadable input handling

flowchart 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
Loading

Flow diagram for spelling validator names and paths

flowchart 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
Loading

File-Level Changes

Change Details Files
Normalize unreadable-file handling across pins, manifests, workflows, and shape copies with stable relative names, cause-specific refusals, and actionable remediation.
  • Added shared helpers for root-relative POSIX naming, escaped display, file checks, YAML loading, and permission-versus-other-cause advice.
  • Converted stat and read failures into exit-2 refusals, including shape-copy read failures, while preserving refusal chaining and compatibility with older repo_shape.py copies.
  • Updated workflow aggregation to retain causes and combine distinct remediation advice.
templates/assembly-root/scripts/validate-pins.py
scripts/repo_shape.py
Make all validator diagnostics safe and deterministic when names or paths contain control characters or platform-specific separators.
  • Escaped non-printable workflow names, manifest values, pin paths, repositories, commits, source paths, and command arguments.
  • Standardized repository-relative POSIX paths and avoided duplicate path rendering in YAML read errors and existing findings.
templates/assembly-root/scripts/validate-pins.py
scripts/repo_shape.py
Align neutral-product source matching and unused-source diagnostics with pin-file key semantics.
  • Keyed PinSources with pin_product_key instead of casefold to prevent collisions such as openß/openss.
  • Added context linking unused-source notes to the manifest-neutral-product-pins finding when the field is not a list.
  • Escaped product and source values in related diagnostics.
templates/assembly-root/scripts/validate-pins.py
Expand regression coverage for refusal behavior, diagnostic spelling, advice selection, and source-key edge cases.
  • Added 335 tests covering refused stat/read operations, all relevant file sites, error causes, relative naming, control characters, command output, malformed manifests, and pin-source folding.
  • Updated existing expectations for relative YAML paths and validator spelling.
tests/test_validate_pins_named_and_spelled.py
tests/test_refusal_default_remediation.py
tests/test_bump_leg_unreadable.py
tests/test_validate_pins_unreadable_workflow.py

Assessment against linked issues

Issue Objective Addressed Explanation
#712 Refuse files the validator cannot reach or read with a named, root-relative path, once, and give advice appropriate to the failure cause; apply this consistently to the five file checks, YAML reads, and shape-copy reads. ✅
#712 Spell every disk-, manifest-, pin-, or command-line-derived name and path printed by the validator, including workflow names and pin-entry paths, so control characters and undecodable bytes cannot reach stderr raw. ✅
#712 Address the riders: key PinSources with pin_product_key and make the unused-source note for a non-list neutral_product_pins field point to its finding. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T16:15:44Z) Independent review of #827 (#712) by lane 3's RV827 (opus), on head 9ecf7bf3: NOT READY. One MUST, which is a one-line fix, plus three SHOULDs and five NITs. Everything else holds:

Neither head moved during the review: #827 was still 9ecf7bf3 and #826 still cf376b7a at 16:14Z.

M1: the SonarCloud quality gate fails on a literal bidi character in a test.

  • tests/test_validate_pins_named_and_spelled.py:110 is pytest.param("rev<U+202E written raw>elif", id="bidi-control"). od -c shows the bytes 342 200 256 inside the string.
  • No other file in the repository holds one (git grep -P '[\x{202A}-\x{202E}\x{2066}-\x{2069}]' HEAD).
  • Sonar raises rule text:S6389, a MAJOR vulnerability. The gate's new_security_rating is 3 against a threshold of 1, so it reads ERROR, and gh pr checks shows SonarCloud Code Analysis fail.
  • Fix: pytest.param("rev\u202eelif", id="bidi-control"). The other UNPRINTABLE rows already use escapes, and the value tested does not change.

S1: a leg or sibling checkout that this user cannot search still ends in a traceback. It neighbours the issue's contracts/ case and happens on main too.

  • The leg checkout: _check_pin_digest's (submodule / ".git").exists() at validate-pins.py:987. With spec/ at 000 or r--:
    • on Python 3.11 to 3.13: PermissionError: [Errno 13] Permission denied: '<ROOT>/spec/.git', exit 1;
    • on 3.14, exists() answers False, so the run would wrongly say pin-submodule-uninitialized.
  • The sibling checkout: resolve_neutral_pin_source's (sibling / ".git").exists() raises PermissionError with ../openXdox at 000 (measured in-process).
  • Fix: ask both through _is_a(path, lambda _mode: True). The first becomes a named refusal and the second a source that cannot answer. Or file both on Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459.

S2: the _named fallback is untested.

  • The mutant that drops _named's try/except ValueError survives: all 335 tests in the new file still pass.
  • What the fallback fixes: a role or product given as an absolute path (/x/y) ended main in a ValueError traceback. The head refuses it by name: REFUSED pin-missing: leg '/nonexistent-RV827/x' declares o/r at 'spec' but /nonexistent-RV827/x-pin.yaml does not exist, and neutral-pin-missing likewise.
  • Fix: add one row for _load_leg_pin and one for _check_neutral_pin_entry_and_load, each with an absolute role or product. Assert the refusal code, and that no ValueError escapes.

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:

  • N1: Sonar S1192 (CRITICAL) at validate-pins.py:245, 341 and 426: "Then run the same command again." now occurs three times. Hoist it into a constant.
  • N2: Sonar S5778 at tests/test_validate_pins_named_and_spelled.py:139. Build validator.Report() and list(values) before the with pytest.raises.
  • N3: a directory, FIFO, dangling symlink or loop where a pin, the shape pin or the manifest should be is refused under the "does not exist" wording and the lockstep remedy. A shape copy in that state reads pinned but absent. Main says the same. This is a candidate line for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459. Nothing hangs.
  • N4: the permission advice is prose (chmod u+r, u+rx), and a refused stat names the file, not the directory that blocked it. On Windows a PermissionError is usually a sharing violation. This matches bump-leg.py, so it is acceptable.
  • N5: --root $'...\x1b x' still puts a raw ESC on stderr (exit 2), as the body says. Put that line on Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459's checklist before landing, as the body promises. The family templates also carry scripts/repo_shape.py, so "each root's" could read "each root and holder".

Held (measured, Python 3.12.3, on a real scaffold, main 24a977c0 against the head):

case main head
contracts/ at 000 or r-- PermissionError traceback, exit 1 REFUSED yaml-unreadable: contracts/spec-pin.yaml: cannot be read (Permission denied) with its own remedy, exit 2
leg pin, shape pin, manifest or neutral pin at 000 <ROOT>/...: [Errno 13] ... under the lockstep remedy a relative path, named once, its own remedy, exit 2
AGENTS-shape.md at 000 traceback, exit 1 REFUSED shape-copy-unreadable: ..., exit 2
a .yml named with byte \xff and an ESC, at 000 raw ESC on stderr '.github/workflows/bad\udcff\x1b[31m.yml'
  • Python 3.11 and 3.13 give the same head rows.
  • contracts/ at -wx passes, and an unreadable .gitmodules passes, because gitlinks come from the index.
  • Pinned copies: scripts/repo_shape.py is pinned by digest, and the body says so. No recorded digest of either file exists anywhere, so roots read them as upstream-changed, which update-shape.py apply fixes.
  • Other callers of load_yaml: only the OSError text changed. link_declarations reads __cause__, and the doctor's naming_script_refusal matches the <path>: prefix. No test asserts [Errno.
  • Windows: it uses stat with _ABSENT_WINERRORS and .as_posix(), never os.access. Every skip states its reason. The refused_stat rows stand in for a refused stat on every platform.
  • Docs: none list these codes, and --help is unchanged.
  • Sonar: no S9073, and 0.0% new duplication.

Runs (stub gh first on PATH, GH_TOKEN=bogus, empty GH_CONFIG_DIR):

tree tests result
head 13 files 1111 passed in 1098.91s
merged with #826 both PRs' 8 files 670 passed in 497.47s
merged with #826 tests/test_shape_doctor.py 436 passed, 3 skipped, 1 xfailed (the skips are Windows-only PowerShell rows)

CI on the head: tests and tests-git-bash pass; SonarCloud fails (M1).

Mutants (each against the new file's 335 tests):

mutant change result
MG _named without its fallback survives (S2)
MF resolve_neutral_pin_source looked up by casefold() killed by test_two_pin_files_one_casefold_each_keep_their_own_source
MO _load takes only PermissionError killed by 3 io-error rows
MR the shape copy's refused stat under yaml-unreadable killed by 3 rows

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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, W712) Fix round 1 for #827 (#712), answering RV827 (issuecomment-6236474296) finding by finding. Head ebb1ee0918a5eb70fa990d3652df4778022128da, one commit on 9ecf7bf3; no merge of main, no force-push. The coordinator's rulings are what each line applies.

M1, TAKEN. tests/test_validate_pins_named_and_spelled.py:110 is pytest.param("rev\u202eelif", id="bidi-control"), written as the escape; the value tested is the same. The Write tool had turned the typed escape into the character, which is how it got there.

$ git grep -P '[\x{202A}-\x{202E}\x{2066}-\x{2069}]' HEAD -- .
(no output, exit 1)

S1, TAKEN. Both checkouts ask _is_there, which is _is_a(path, lambda _mode: True) with a NUL read as nothing there, as exists() answered. Measured on a real scaffold, Python 3.12.3, main 24a977c0 against this head:

spec/ at 000      main: TRACEBACK PermissionError: [Errno 13] Permission denied: '<BESIDE>/Atlas/spec/.git'   (exit 1)
                  head: REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied)    (exit 2)
spec/ at r--      main: the same traceback (exit 1); head: the same refusal (exit 2)
../openXdox 000   main: TRACEBACK PermissionError: [Errno 13] Permission denied: '<BESIDE>/openXdox/.git'  (exit 1)
                  head: FINDING neutral-pin-source-unreadable: contracts/openxdox-pin.yaml: <BESIDE>/openXdox (sibling checkout (<BESIDE>/openXdox)) could not answer for testorg/openXdox @ cccc...: Permission denied   (exit 1)

The leg's refusal is new, pin-submodule-unreadable, beside pin-submodule-uninitialized, with _unreadable_file's cause-aware remedy: "Remediation: this validator reads each leg's checkout, to recompute its pinned tree digest from the leg's own objects, and one it cannot read is a question it cannot answer, never a pass." then the permission or other-cause sentence and "Then run the same command again." The sibling is there, so it is the source; _digest_from_source names why it cannot answer.

New rows: test_a_leg_checkout_the_gate_cannot_search_is_refused_by_name[000, r--] (real modes, whole stderr, nothing written); test_a_leg_checkout_whose_stat_is_refused_is_refused_on_every_python[permission, io-error] (the 3.14 stand-in); test_a_checkout_beside_the_root_whose_stat_is_refused_is_the_source (stand-in) and test_a_checkout_beside_the_root_this_user_cannot_search_cannot_answer (real 000). A NUL in a leg's path is one more test_a_name_the_system_cannot_take_is_still_no_file row. Mutants M16 (the leg back to exists()), M17 (the sibling back to exists()) and M18 (_is_there raising the NUL's ValueError) are killed; M16 and M17 only by the stand-in rows, since on 3.12 exists() raises inside the new handler.

S2, TAKEN. test_a_name_spelled_as_an_absolute_path_is_refused_by_name[leg-role, neutral-product]: _load_leg_pin with the role <tmp>/nonexistent-w712/x refuses pin-missing ending <tmp>/nonexistent-w712/x-pin.yaml does not exist, and _check_neutral_pin_entry_and_load with that product refuses neutral-pin-missing (its lowered path); pytest.raises(validator.Refusal) fails on any ValueError. MG (M15) is killed: 2 failed, 342 passed, the two rows.

S3, recorded, no code change: the body's "What it cannot tell" carries it for #460's checklist, with your measured facts (the legs row's carries no 40-hex commit / DRIFTED, manifest kinds and shape currency could not be run: [Errno 13] ... '<ROOT>/contracts', and the git checkout HEAD -- NEXT for a permission fault).

N1, TAKEN. RUN_AGAIN = "Then run the same command again.", used by _workflow_unreadable, _unreadable_file and _workflows_unlistable; _unreadable_file takes reads (default FILES_READ), so the default remedy is byte for byte what it was.

$ grep -c 'Then run the same command again' templates/assembly-root/scripts/validate-pins.py
1

N2, TAKEN. refusal_of_the_run builds report, given = validator.Report(), list(values) before with pytest.raises(...).

N3, recorded, no code change: in "What it cannot tell", for #459's checklist.

N4, no change (the coordinator's ruling): it is bump-leg.py's rule (#383); recorded in "What it cannot tell".

N5, TAKEN in the wording: the body and the squash body say "each root and holder reads the copies it carries as upstream-changed in its contracts/shape-pin.yaml (both in an assembly root, scripts/repo_shape.py in a family holder)". The coordinator posts the --root ESC line on #459.

One more, found in this round: the digest rows' stand-in for an initialized checkout patched Path.exists, which the code no longer asks, so three rows passed on pin-submodule-uninitialized instead of the refusal they are about. The stand-in answers the stat now, and each digest row asserts its refusal's code.

Tests: one background run on ebb1ee09's bytes: 2187 passed, 3 skipped, 1 xfailed, 0 failed in 21m22s, over the new file, the three edited test files, the files that run validate-pins.py and load_yaml, tests/test_shape_doctor.py, test_repo_hygiene, test_cli_reference and test_agents_md_codes (the skips: test_shape_doctor.py:10429's Windows-only PowerShell rows). The new file alone: 344 passed.

Mutants (the new file, 344 tests each): all 18 killed; M15 to M18 are this round's; M11 was re-aimed at _is_file's ValueError after _is_there moved the text it matched.

Merges from ebb1ee09: main 40e678e6 clean; #826 cf376b7a two regions in _load_legs_to_check (your resolution is in the body, for the merge round after the v1.1.0 tag); #747 ff7c59b8 and #822 ea86cb60 one region in _check_shape_copy, as before; #823, #817, #821 and #769 clean.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T17:35:08Z) RV827's re-check of #827's fix round 1 (head ebb1ee09): NOT READY, and only for the merge with main.

M2, in the merge with main: a test on main expects the old absolute path.

The first review's items, re-measured:

  • M1, closed. Line 110 is pytest.param("rev\u202eelif", id="bidi-control"), and git grep -P finds no bidi control in the tree. SonarCloud's gate on ebb1ee09 is OK, with every rating 1 and 0.0% new duplication; S6389, S5778 and the RUN_AGAIN S1192 are gone. One S1192 for " and " (validate-pins.py:801) is open but does not fail the gate.
  • S1, closed.
    • With spec/ at 000 or r--, Python 3.11, 3.12 and 3.13 each give REFUSED pin-submodule-unreadable: spec/.git: cannot be read (Permission denied), exit 2: named once, relative, with its own remedy.
    • 3.14 does the same by reading: _is_a re-raises a refused stat and never asks exists(), and the stand-in row covers it.
    • The sibling gives FINDING neutral-pin-source-unreadable, exit 1. A finding is consistent with the gate: main already gives that finding for a sibling that is there but cannot answer, and --help says an unreadable source "is a finding".
  • S2, closed: the two absolute-name rows kill MG.
  • N1 and N2, closed.
  • S3, N3, N4 and N5 are recorded in the body as stated.

New NITs, none blocking:

  • N6: a leg's .git gitfile at mode 000 still passes _is_there and ends pin-commit-unresolvable under the lockstep remedy. Main says the same; a candidate line for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459.
  • N7: test_a_checkout_beside_the_root_this_user_cannot_search_cannot_answer does not stub tree_digest_from_gh. Under a mutant it fell through to gh api, which conftest's guard refused. Stub it with no_gh.
  • N8: for a sibling, the remedy could name the keyed --pin-source openXdox=<clone>, which outranks a sibling. The wording predates this PR.

Runs (stub gh first on PATH, GH_TOKEN=bogus, empty GH_CONFIG_DIR):

tree result
head, RV827's 13 files plus 2 1197 passed
main d66a9054 + #827 1 failed, 1255 passed (M2)
main d66a9054 + #827, with the M2 edit 44 passed, then 399 passed
that tree + main 42aed788 480 passed
main + #827 + #826 689 passed

Mutants: all five killed:

  • MG: _named without its fallback;
  • MS1: .exists() again;
  • MS2: a refused sibling stat passed over;
  • MS3: the default remedy;
  • MS4: a NUL's ValueError let out.

Merges:

CI: no Actions run exists for ebb1ee09, and only SonarCloud ran. The merge push starts tests.

Lane: openreposhape-3 (openRepoShape-3)

@brettheap

Copy link
Copy Markdown
Contributor Author

(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.

Brett Heap and others added 2 commits October 17, 2026 18:48
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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-17T19:28:04Z) Round 2 for #827 (#712), answering RV827's re-check (issuecomment-6237751677). Head eea10815: the merge commit b9778ffb ("Merge main bad05d2 into fix/712-validate-pins-contracts", parents ebb1ee09 and bad05d21) and one commit for N7 on it. No rebase, no force-push.

The merge. Main bad05d21 (GitHub main at round 2; #795 had not landed) brought #747 and #817.

M2, TAKEN, in the merge commit. tests/test_reader_refusals_named.py:199 is now:

        f"REFUSED yaml-unparsable: contracts/spec-pin.yaml: line {line}: "
        f"{OPENS} "

The other yaml-unparsable lines in tests that name a path (test_reader_refusals_named.py:97, adopt's check; test_yaml_indicator_openers.py:452, validate-manifest.py) run other tools, which this PR leaves as they were.

N7, TAKEN (commit eea10815): test_a_checkout_beside_the_root_this_user_cannot_search_cannot_answer calls no_gh_api, the stub recompute used, now shared, so tree_digest_from_gh refuses gh-not-found and no mutant reaches gh api.

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 (eea10815's bytes, Python 3.12.3, no GIT_ALLOW_PROTOCOL): 2231 passed, 3 skipped, 1 xfailed, 0 failed in 24m35s, over round 1's set plus tests/test_reader_refusals_named.py (the skips: test_shape_doctor.py:10429's Windows-only PowerShell rows; the xfail: test_shape_doctor.py:4154's strict row for #735). tests/test_reader_refusals_named.py alone: 44 passed.

Mutants on the merged bytes (the new file, 344 tests each), all killed:

  • M15 (MG, _named without its fallback): 2 failed, both test_a_name_spelled_as_an_absolute_path_is_refused_by_name rows;
  • M16 (the leg checkout back to exists()): 2 failed, test_a_leg_checkout_whose_stat_is_refused_is_refused_on_every_python[permission, io-error];
  • M17 (the sibling back to exists()): 1 failed, test_a_checkout_beside_the_root_whose_stat_is_refused_is_the_source;
  • M18 (_is_there lets a NUL's ValueError out): 1 failed, test_a_name_the_system_cannot_take_is_still_no_file[leg-checkout].

Merges from eea10815: main bad05d21 clean; #826, now 680e26ab, two regions in _load_legs_to_check, as at cf376b7a. Your resolution, carrying #826's new not_mappings and manifest-leg lines, passed test_legs_not_a_list, the new file, test_adopt_plan_yaml_quoting, test_manifest_validator and test_pin_validator on that merged tree (1092 passed, in a scratch worktree since removed). It is in the body; lane 3's v1.1.1 order puts #827 first. #822 ea86cb60 one region in _check_shape_copy, from #747's change on its older base; #823 153bab90, #821 7581ffb9, #769 787d7e1e, #824 7777c7d0 and #795 17bbbaba clean.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T19:40:18Z) RV827's re-check of #827's round 2, the merge with main (head eea10815): READY.

The merge:

Runs (stub gh first on PATH, GH_TOKEN=bogus, empty GH_CONFIG_DIR):

tree result
head, round 1's 1256-test set 1256 passed
main fc867207 + #827 + #826, 13 files including the bump-leg files fc867207 changed 1365 passed

SonarCloud: the gate is OK, every rating is 1 and new duplication is 0.0%. The one open S1192 (" and ", validate-pins.py:801) does not fail the gate.

Merges:

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai

sourcery-ai Bot commented Oct 17, 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 3 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 9f235da into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/712-validate-pins-contracts branch October 17, 2026 21:45
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…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>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
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>
brettheap added a commit that referenced this pull request Oct 18, 2026
…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>
brettheap added a commit that referenced this pull request Oct 18, 2026
…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>
brettheap added a commit that referenced this pull request Oct 19, 2026
…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>
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