Skip to content

shape-doctor.py and shape_advisory.py: a .git or an agent file linked into a folder this user cannot search is said once in what is here or agent files, <name>: cannot be read (<reason>), never absent; a .git offers no way in (#1110) - #1144

Merged
brettheap merged 5 commits into
mainfrom
fix/1110-doctor-advisory-unsearchable-git
Nov 8, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1110. This is slice A: the eight sites in shape-doctor.py and scripts/shape_advisory.py. It is outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0"), and it lands after the tag, on a later word.

What

Eight sites asked exists() or is_file() of a name directly in the root. Both calls follow a link. Each site now asks the file's own stat helper, which applies #430's rule: stat the path; ENOENT, ENOTDIR, EBADF and ELOOP (and Windows' 21, 123 and 1921) mean nothing is there; any other OSError is raised so the caller can name it. The helper is is_a in shape-doctor.py and _is_a in scripts/shape_advisory.py. In shape-doctor.py the four .git sites share a new carries_git(root), which is is_a(root / ".git", <any mode>).

Measured over a folder Atlas whose .git links to the git directory of a checkout of acme/Orbit in a folder at mode 000, and over an AGENTS.md linked the same way. "Shim" means a 3.14-like sitecustomize in which is_file, is_dir, exists and is_symlink answer False to any OSError.

Site Base, Python 3.12.3 Base, shim Head, 3.12 and shim
check_agent_files raises; run_checks makes it FINDING agent files AGENTS.md: cannot be read (Permission denied), and the verdict gains agent-files note absent: AGENTS.md, NEXT scaffold-project.py --help note AGENTS.md: cannot be read (Permission denied), NEXT this doctor again, WHY the chmod advice; the verdict is the project's own. The pinned AGENTS-shape.md is a FINDING
check_what_is_here raises; FINDING what is here <name>: cannot be read (...) no git repository, no manifest, no contracts/ -- an ordinary directory n/a <name>: cannot be read (Permission denied), then what it did find; NEXT this doctor again, WHY the chmod advice
check_the_way_in raises; FINDING the way in .git: cannot be read (...) there is no repository here to adopt: a new project is SCAFFOLDED, NEXT setup.sh --org <your-org> --project Atlas n/a its .git cannot be read, so it is not known whether this is a git repository to adopt, and neither way in is offered until it can be: `what is here` says how; no NEXT; git_repository: null
check_the_other_way_in raises; FINDING the other way in .git: cannot be read (...) row left out, as for a folder with no repository no row: no way in is offered
origin_org raises PermissionError None, the answer for a folder with no repository (acme readable) raises the refused stat; the way in asks .git first, so the doctor never reaches it
repo_local_origin_name raises; FINDING naming .git: cannot be read (...) None, so naming classifies Atlas and drops Orbit raises; naming catches it: Atlas: classifies under the naming policy; origin's name is not read while .git cannot be read (see `what is here`)
shape_advisory._read raises into read()'s net: not-answered, why ... (PermissionError) ... not-a-repository not-answered, why .git: cannot be read (Permission denied), so whether this is a repository is not known here, and the advisory says nothing
shape_advisory.repository_names raises ['Atlas'] (['Atlas', 'Orbit'] readable) raises the refused stat

The whole doctor over the .git fixture (--root Atlas; text report; the machine row omitted):

  • Base, 3.12. naming, what is here, the way in and the other way in are each FINDING .git: cannot be read (Permission denied), with the same NEXT and the same chmod WHY, four times. There is no Triad row. NOT A SHAPE ROOT, exit 2.
  • Base, shim. naming reads ok Atlas. what is here reads n/a ... an ordinary directory. the way in reads n/a there is no repository here to adopt, with NEXT setup.sh --org <your-org> --project Atlas. Exit 2.
  • Head, 3.12 and shim. The two reports are byte-identical (cmp):
  ok   naming        Atlas: classifies under the naming policy; origin's name is not read while .git cannot be read (see `what is here`)
  n/a  what is here  .git: cannot be read (Permission denied)
       NEXT          python3 <standard>/shape-doctor.py --root <root>
       WHY           Make what this user may not open readable by this user: `chmod u+r` on a file, `chmod u+rx` on a directory (and on every directory above it inside the root), or fix who owns it. Then run this doctor again
  n/a  the way in    its .git cannot be read, so it is not known whether this is a git repository to adopt, and neither way in is offered until it can be: `what is here` says how
NOT A SHAPE ROOT   (exit 2)

The eight entries leave KNOWN_OPEN in tests/test_path_guard_hygiene.py; its stale-entry check fails until they do. Measured: putting the check_the_way_in entry back fails it with 0 found, 1 listed.

Why: the design choices

  1. One row carries the line and the cure: what is here. It is the one row that reads every name in a directory that is no shape root, so a .git and an agent file follow one rule there, and the cure is said once whatever combination is unreadable. In a project the agent files row says the same line for the three agent files (2). The rows that depend on .git do not repeat it:

    • the way in points at what is here;
    • naming adds one clause;
    • the other way in and the Triad row are silent.

    What that covers for an agent file, and what it does not. An agent file the doctor cannot read is said once, <name>: cannot be read (<reason>), and never read as absent, in what is here and in agent files. It is not the only reader of an AGENTS.md, and over an unreadable one a way in is offered as on main. _leg_by_agents_md in scripts/shape_advisory.py (except Exception: return None, "an unreadable file says nothing") is called by shape_advisory.read, whose readers are leg_clone (which the way in and the other way in ask), the Triad row and adopt-project.py's triad lines. An AGENTS.md it cannot read is still read as absent there, so those rows still offer adoption, the scaffold and the Triad advice over it, as on main (Known limits). It is not one of family.py, the family root's siblings.py, shape-doctor.py and shape_advisory.py: a name in the root that is a link into a folder this user cannot search is still read as absent at twelve sites (follow-up of #430 slice Z) #1110's eleven sites, and it is listed there now as a twelfth. It is not fixed here: _leg_by_agents_md is unchanged. For a .git no way in is offered (3).

    This is the doctor's own precedent for a mount (Every Path.is_file() / is_dir() / exists() guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow @<sha> check, update-shape.py says copy-missing and update-pin-missing ("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430's slice C). There the legs row names the mount, with the NEXT and the advice, and leg shape files and placement say the line without them. Rejected: making the way in the owner. It asks about the .git, and reads an AGENTS.md only through the advisory (above), so it has no line of its own for an agent file, and with two owners the cure would be said twice when both a .git and an agent file are unreadable.

  2. Status follows the README's rule that a FINDING means something else asserts it.

    • In a directory with no manifest, the rows stay n/a. Nothing asserts a name there, and that verdict reads no row (NOT A SHAPE ROOT, exit 2, as before).
    • In agent files, an unreadable file is a note when it is rendered and nothing pins it (AGENTS.md, CLAUDE.md). It is a FINDING when it is the pinned AGENTS-shape.md, which the project's own validate-pins.py refuses by name. This is the rule the row already applied to an absent file.
    • So base 3.12's INVALID (..., agent-files) for an unreadable AGENTS.md is gone.
  3. the way in offers neither command over a .git it cannot read. Whether the folder is a repository is not known. The scaffold is the defect, and an adoption over a .git git cannot read would fail at plan. The row has no NEXT, and detail.git_repository is null (unknown) in this one case.

  4. origin_org, repo_local_origin_name and repository_names raise the refused stat instead of answering None or dropping a name. This is is_a's contract ("for the caller to name"). Each caller handles it:

    • the way in and the other way in ask .git first;
    • naming catches it;
    • _read asks .git first.
  5. shape_advisory.read() gets no new state. NOT_ANSWERED is by its own definition "the advisory could not be read here, and says nothing", and every reader already keeps it silent:

    • the doctor's Triad row and leg_clone;
    • adopt-project.py's triad lines.

    A new state would be a second name for the same answer, which every reader would have to treat identically. What changes is its why, which now names .git: cannot be read (<reason>) in validate-pins.py's words. adopt-project.py is not touched. The constant's comment says so.

  6. carries_git accepts any mode. A gitdir file (--separate-git-dir, a linked worktree, a submodule's checkout), a directory, or a link to either is a repository, as exists() said wherever the stat succeeds. mount_read asks a mount's .git the same way.

  7. detail keeps true for a name that is there and adds cannot_be_read. That is a list in what is here and agent files, and a string in the way in and naming. A --json reader keeps its booleans, and no false says "absent" for a path that IS there.

  8. Beside an absent agent file, that file's command stays the NEXT and the advice is added to its why. This is how the legs row keeps its checkout line beside a pin it cannot read.

  9. naming keeps the policy's own status while origin's name is not read. It classified the folder's own name, so a name that classifies stays ok, with the clause saying origin's was not read; a name the policy does not classify, or a naming script that refuses, is the policy's FINDING with the same clause. That is a half-asked question, said as one, and it is pinned for each exit (0, 1 and 2) in a row of its own.

The change stays inside the eight sites, carries_git, and the callers that must handle the raise (check_not_a_root_naming). Other lanes' DRAFTs change shape-doctor.py too, #1109 among them; this keeps a later merge small.

Tests

New rows: tests/test_doctor_unsearchable_links.py, 25 rows. 17 came with the first two commits, and 8 were added in the fix round after the review (the third commit). The fourth commit splits two composite asserts, one per condition (SonarCloud's python:S9073), and changes no result.

  • Mode cases (a real link into a real folder at 000, the doctor run as a person runs it; skipped on Windows and as root):
    • a .git, with a readable control first;
    • an AGENTS.md in a plain directory;
    • a project's AGENTS.md and its CLAUDE.md.
  • Stand-in cases (in process, refused_stat and refusing; on every platform and as root):
    • the three .git questions, with controls for a gitdir file, no .git, and a name under a file;
    • the rows of a directory with no manifest, for a permission and for an I/O error;
    • what is here beside a readable Makefile;
    • the advisory, for a permission and for an I/O error, with a --separate-git-dir control (a gitdir file is a repository to _read and to repository_names);
    • the doctor's two readers of the advisory;
    • agent files: rendered, I/O error, pinned, and beside an absent file.
  • Added after the review (in process, on every platform and as root; the four link rows skip where no symlink can be made):
    • test_a_git_link_that_leads_nowhere_is_still_no_repository[loop, dangling]: controls, .git -> .git (ELOOP) and .git -> ../nowhere/.git (ENOENT). the way in is the scaffold, what is here an ordinary directory, no cure and no cannot be read anywhere, no origin asked, the advisory not-a-repository;
    • test_carries_git_says_no_to_a_link_that_leads_nowhere[loop, dangling]: carries_git is False for the same two links;
    • test_an_unreadable_pinned_agent_file_beside_an_absent_one_is_a_finding: refused_stat on AGENTS-shape.md with CLAUDE.md unlinked is a FINDING, the absent file's command still the NEXT, and verdict_for says INVALID (agent-files);
    • test_naming_says_origin_was_not_read_whatever_the_name_comes_to[classifies, does-not-classify, script-refuses]: the naming row with the .git refused, for exit 0 (Atlas, status ok), exit 1 (my-service, FINDING) and exit 2 (a stand-in naming script that refuses, FINDING): the clause that origin's name was not read is on every branch.
New rows Python 3.12.3 3.14-like shim
base 038dafba (a --shared scratch clone, with the new file laid over it) 2 passed, 23 failed 2 passed, 23 failed
head 89486f69 25 passed 25 passed

The two that pass on base are the two controls. Every other base failure is on the row's own assertion: a status, a reason, the naming clause, DID NOT RAISE, or a why. Two groups of rows fail differently:

  • test_a_git_this_user_cannot_reach_is_named_once_on_every_python[io-error] and test_an_agent_file_this_user_cannot_reach_is_named_on_every_python[io-error] fail on base 3.12 (and only there) with OSError: [Errno 5] Input/output error escaping exists() (in repo_local_origin_name, called by naming) and is_file() (in check_agent_files). Under the shim the same two fail on a reason (an ordinary directory, absent: AGENTS.md).
  • [carries_git] and the two test_carries_git_says_no_to_a_link_that_leads_nowhere rows fail on base as missing, AttributeError: the helper is new. Their siblings [origin_org] and [repo_local_origin_name] carry the proof (DID NOT RAISE).

Each added row, on base and on head:

Row Base Head Kills
..._is_still_no_repository[loop], [dangling] passes: a control passes R01, R29 (and R19)
test_carries_git_says_no_to_a_link_that_leads_nowhere[loop], [dangling] fails: AttributeError, a new helper passes R01, R29
..._pinned_agent_file_beside_an_absent_one_is_a_finding fails on the reason: absent: AGENTS-shape.md, CLAUDE.md (the FINDING already holds there, so it pins this change's words) passes R07
test_naming_says_origin_was_not_read...[classifies] fails on the clause (status ok, exit and names hold) passes R13
...[does-not-classify] fails on the clause passes R15
...[script-refuses] fails on the clause passes R14
the --separate-git-dir lines of test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing the control lines pass on base; the rows still fail after them (DID NOT RAISE for a permission, a why for an I/O error) pass R27, R28

Mutants: 47 of 47 killed. The writer's 18 (D1 to D14 and A1 to A4) and the review's 29 (R01 to R29), each applied alone to one of four --shared scratch clones of the head (run again after the fourth commit, with the same result), with the 25 rows run and the file put back from its bytes. After each one its sha256 matched its HEAD blob: shape-doctor.py 4841bc5b... (39 mutants) and scripts/shape_advisory.py d48d565d... (8). The worktree was never changed.

  • The review's 29, against the first 17 rows and a broad set of 298 tests: 22 killed. R01, R07, R13, R14, R15, R27 and R28 survived them, and R29 was killed only by the hygiene scan's ban on a bare os.path.islink. With the 8 added rows all 29 are killed by the rows alone:
    • R01, carries_git reads an ELOOP .git as a repository: [loop] of both link rows;
    • R29, a dangling .git read as a repository: [dangling] of both, so the hygiene scan no longer stands alone;
    • R07, an unreadable PINNED copy beside an absent file is only a note: the pinned-beside-absent row;
    • R13, naming a FINDING while .git cannot be read: [classifies];
    • R14, the clause dropped where the script refuses (exit 2): [script-refuses];
    • R15, the clause dropped where the name does not classify (exit 1): [does-not-classify];
    • R27, _read takes a gitdir file for no repository, and R28, repository_names drops its origin: both rows of the advisory test.
  • The writer's 18: round 1, 16 killed. Two survived:
    • D8: carries_git asking for a directory only, so a gitdir file is no repository;
    • A2: _read catching only PermissionError.
      The second commit adds the gitdir-file control and the advisory's I/O-error case.
  • The writer's 18: round 2, 18 killed.
    • In agent files: D1 a refused file counted absent; D2 a pinned one a note; D3 no NEXT; D4 the advice dropped beside an absent file.
    • In what is here: D5 false in detail; D6 no advice; D7 3.14's absent answer.
    • D8 is above. D9: the way in falls through to the scaffold. D10: the other way in offers it.
    • D11: naming drops origin silently. D14: naming raises to run_checks, a second telling.
    • D12 and D13: origin_org and repo_local_origin_name swallow the refusal as None.
    • In the advisory: A1 not-a-repository; A2 is above; A3 repository_names swallows it; A4 the exception's name in place of the system's reason.

Gate on head 89486f69. Python 3.12.3 and git 2.43.0, with a stub gh first on PATH, GH_TOKEN bogus, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false. At most 4 calls in parallel, each under 9 minutes (the longest took 3 min 24 s). origin/main is still 038dafba, the branch's base, so no merge was made.

Call Result
tests/test_doctor_unsearchable_links.py 25 passed
the same, under the shim 25 passed
tests/test_path_guard_hygiene.py 49 passed
tests/test_shape_doctor.py, 448 tests in 4 shards by node id 111 passed and 1 skipped; 112 passed; 111 passed and 1 skipped; 111 passed and 1 skipped. Total 445 passed, 3 skipped
tests/test_scaffold_leg_path_collisions.py 104 passed
tests/test_doctor_unreadable_paths.py and tests/test_doctor_unreadable_pin.py 86 passed
every other file that imports or runs shape_advisory: test_shape_advisory, test_bom_reads, test_adopt_single_repository_record, test_doctor_gitmodules_not_utf8, test_doctor_validator_verdicts, test_bootstrap_non_utf8_branch 203 passed
the doc-pinning trio: test_agents_md_codes, test_cli_reference, test_repo_hygiene 170 passed
tests/test_doctor_unreadable_paths.py, under the shim 34 passed

Known limits

  • Python 3.14 itself was not run. Every 3.14 statement here comes from the shim and from refused_stat.
  • Windows was not measured. The mode rows skip there and as root; the stand-in rows run everywhere.
  • An unreadable leg AGENTS.md is still read as absent where _leg_by_agents_md reads it (scripts/shape_advisory.py, except Exception: return None), so a way in is still offered over it. Its readers are leg_clone (which the way in and the other way in ask), the Triad row and adopt-project.py's triad lines. Measured over a clone with origin acme/Orbit whose AGENTS.md is a spec leg's, linked into a folder at mode 000: main and this head both say the way in ... it is ADOPTED in place with adopt-project.py plan ... --project Orbit, the other way in ... a NEW project is SCAFFOLDED with setup.sh --org <your-org> --project Orbit, and the advisory Triad row. Readable, the same folder is NAMED OR WRITTEN AS A LEG with no NEXT, and the Triad row says it is a clone of the spec leg. This head adds what is here AGENTS.md: cannot be read (Permission denied); a git repository, with the cure. The site is not one of family.py, the family root's siblings.py, shape-doctor.py and shape_advisory.py: a name in the root that is a link into a folder this user cannot search is still read as absent at twelve sites (follow-up of #430 slice Z) #1110's eleven, and it is listed there now as a twelfth. It is not fixed here: _leg_by_agents_md is unchanged.
  • A .git that is a gitdir file pointing into a folder this user cannot search is a name the doctor can read, so carries_git says True. git then fails to read origin, and the adoption line ends --org <your-org>, as before. Nothing in this slice's guards touches that case.
  • The same class: a root .git that is a DIRECTORY at mode 000. carries_git asks the .git itself, which this user can stat, so it is still called a repository. Main and this head both offer adoption, adopt-project.py plan --source <root> --project Atlas --org <your-org>, and the scaffold, and print the Triad advice. Measured over a real repository with origin acme/Orbit, its .git at 000.
  • shape_advisory.NOT_ANSWERED's new why, .git: cannot be read (<reason>), so whether this is a repository is not known here, and the advisory says nothing, is printed by nothing. _triad_advisory_row returns no row for that state and adopt-project.py prints nothing for it, so the text is true but reachable only in process, where the rows hold it. The constant's comment says --json, and no --json report carries it.
  • the way in's detail.git_repository is null in this one case, where it was always a boolean.
  • AGENTS.md's "Report the verdict and both commands" (the NOT A SHAPE ROOT step) is not amended for the case in which the way in offers none. The row's own words say why. A one-clause amendment would close it; AGENTS.md is not changed in this PR.
  • No docs changed. There is no new doctor code and no --help change (so docs/cli.md is unchanged), and the README lists the rows, not their texts. The doc-pinning trio passes.

Not in this PR

Slice B's three sites stay on KNOWN_OPEN under #1110:

  • scripts/family.py _gitmodules_names;
  • scripts/family.py _sections_giving;
  • templates/family-root/scripts/siblings.py submodule_urls.

They wait for other lanes' open DRAFTs on family.py's remove (#1117 and #1098). #1110 stays open for them.

A shape root whose own .git is a link into a folder this user cannot search still reads DRIFTED, and both of its pinned validators crash. That was r1144's observation outside this PR. Base and head behave alike, and it is filed as #1146.

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Make shape discovery and advisory checks distinguish unreadable linked .git and agent files from absent paths, report the access problem with appropriate guidance, and avoid unsafe repository recommendations.

New Features:

  • Report unreadable .git and agent-file links explicitly instead of treating them as absent, including actionable recovery guidance.
  • Preserve repository uncertainty across doctor and advisory results when .git cannot be read, avoiding adoption or scaffolding recommendations.

Bug Fixes:

  • Prevent unreadable linked paths from being misclassified as missing repositories, missing agent files, or missing origin names across the affected doctor and advisory checks.

Enhancements:

  • Centralize repository detection through stat-based checks that distinguish absent paths from paths whose access is refused.
  • Ensure unreadable paths are reported once in the appropriate doctor row while retaining accurate status, details, and follow-up commands.

Tests:

  • Add comprehensive coverage for inaccessible, dangling, looping, and gitdir-file links, including permission and I/O errors across doctor and advisory behavior.
  • Update path-guard hygiene expectations as the affected direct existence checks are replaced.

Brett Heap and others added 2 commits October 21, 2026 21:31
…he root that is a link into a folder this user cannot search is said once, `<name>: cannot be read (<reason>)` with the chmod cure, never absent, and no way in is offered over it (#1110)

Slice A of #1110: the eight sites in `shape-doctor.py` and
`scripts/shape_advisory.py` that asked `exists()` or `is_file()` of a name
directly in the root now ask `is_a` / `_is_a`, which raise a refused `stat`.

- `what is here` names each name it cannot read in validate-pins.py's
  words, with this doctor again as the NEXT and the gate's advice as the
  WHY: the one row that carries the cure.
- `the way in` offers neither command while the `.git` cannot be read, and
  points at `what is here`; `the other way in` and the Triad row are silent.
- `naming` classifies the folder's own name and says origin's was not read.
- `origin_org`, `repo_local_origin_name` and `repository_names` raise the
  refused `stat` (`carries_git`, `_is_a`) where they answered as for a folder
  with no repository.
- `shape_advisory.read()` answers `not-answered` (silent) with the `.git`'s
  line in `why`, where 3.14 said `not-a-repository`; no new state.
- `agent files` says the line with the cure: a `note` for a rendered file
  nothing pins, a FINDING for the pinned `AGENTS-shape.md`.

The eight entries leave `KNOWN_OPEN`; the three family sites stay, slice B.
New rows: tests/test_doctor_unsearchable_links.py.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le is still a repository to the doctor's `.git` questions, and the advisory names a `.git` refused for another reason than permission (#1110)

Two rows the first round of mutants showed missing: `carries_git` asked
for a directory only (D8) and `_read` catching only `PermissionError` (A2)
both passed the new rows. The `.git` questions now also answer for a
`--separate-git-dir` checkout, whose `.git` is a file, and the advisory row
runs for an I/O error as well as a permission.

Part of #1110

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

sourcery-ai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Make unreadable .git entries distinguishable from absent repositories in the advisory, naming, and way-in checks, while keeping the other-way-in row silent.

  • scripts/shape_advisory.py L140-146, L499-508, L792-803
  • shape-doctor.py L11212-11229, L11304-11316, L11795-11818 (+5 small edits)

Report unreadable agent files as present, preserving pinned-file findings and the appropriate repair command and advice.

  • shape-doctor.py L10102-10134, L10139-10147, L10185-10191

Have “what is here” name unreadable root entries once, with the doctor command and the cause-specific advice.

  • shape-doctor.py L11367-11389, L11404-11410, L11423-11428

Tests

Add regression coverage for unreadable links, absent-path controls, naming and advisory behavior, and pinned versus rendered agent files.

  • tests/test_doctor_unsearchable_links.py L1-600

Remove obsolete open-path exceptions now covered by the new guarded reads.

  • tests/test_path_guard_hygiene.py L658 (removed lines)

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-1, 2026-10-21T22:41:03Z) r1144, lane 1's independent review of #1144 at eb3f098c: READY on condition, with no MUST, two SHOULDs and six NITs. It is not the writer w1110a.

The full review

r1144: independent review of #1144 at eb3f098c

Verdict: READY on condition

No MUST findings. The code does what the body says for the .git case, and every caller of the three helpers that now raise handles the raise. No traceback escaped in any probe. Every gate count in the body is reproduced.

The two conditions:

  • S1: the title and one design claim say more than the code does for agent files. Correct the wording and record the follow-up.
  • S2: seven of my mutants survive every test, one of them on a branch that moves the verdict. Add the rows.

Findings

MUST

None.

SHOULD

S1. The title and design point 1 overclaim for agent files. An unreadable leg AGENTS.md is still read as absent, and a way in is offered over it.

S2. Seven mutants of the new code survive every test I ran. Each survived the 17 new rows and a broad set of 298 tests: the new rows, test_shape_advisory, the unreadable pair, the hygiene test, and the 99 test_shape_doctor tests matching naming, way-in, agent and .git terms.

  • R07, shape-doctor.py:9978. The status line becomes FINDING if detail["pinned"] else NOTE. A pinned AGENTS-shape.md that cannot be read, beside an absent CLAUDE.md, then drops from FINDING to note, and the verdict changes. No row covers this branch.
    • Add a stand-in row: refused_stat on AGENTS-shape.md, unlink CLAUDE.md, and assert FINDING.
  • R01, carries_git (:11005-11020). An ELOOP .git is read as a repository. No row has a looping .git.
    • My R29 (a dangling .git link read as a repository) is killed only by the hygiene scan, which bans its bare os.path.islink. No behaviour row kills it.
    • Add controls for .git -> .git and .git -> ../nowhere/.git. Expect carries_git False and the way in the scaffold. I measured both as absent on base and head (sc/nLoop, sc/nDangle).
  • R13, :11155. naming goes FINDING while .git cannot be read. No test asserts the naming status in this case.
  • R14 and R15, :11148-11154. The "origin's name is not read" clause is dropped from the exit-2 and exit-1 FINDING branches. Every row uses Atlas, which classifies.
    • Add a name that fails the policy, beside an unreadable .git.
  • R27 and R28, scripts/shape_advisory.py:793 and :508. Asked with S_ISDIR, a gitdir-file .git becomes not-a-repository and drops origin's name. The gitdir control exists only for the doctor's three questions.
    • Add the --separate-git-dir control to test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing.

NIT

  • N1. The body says "every base failure is the intended one: a status, a reason, DID NOT RAISE, or a why". On 3.12 that is not quite true of two rows:

    • test_a_git_this_user_cannot_reach_is_named_once_on_every_python[io-error];
    • test_an_agent_file_this_user_cannot_reach_is_named_on_every_python[io-error].

    Both fail with OSError: [Errno 5] Input/output error escaping base's exists() or is_file() in process.

  • N2. NOT_ANSWERED's new why is printed nowhere.

    • _triad_advisory_row returns no row for that state.
    • adopt-project.py:5955-5967 prints nothing for it.
    • The constant's comment (shape_advisory.py:134-146, "for --json") implies a reader. The text is true but reachable only in process.
  • N3. The known limits omit a root .git that is a directory at mode 000 (sc/nGit000). Base and head both call it a repository and offer adoption with --project Atlas --org <your-org>, the scaffold, and the Triad advice. It is the same class as the gitdir-file limit the body does list.

  • N4. naming stays ok while origin's name is unread. That is defensible, but it is a half-asked question; whichever status you keep, pin it in a test (see R13).

  • N5. tests/test_doctor_unsearchable_links.py:98 is 80 characters long.

  • N6. AGENTS.md's "Report the verdict and both commands" is not amended for the case where no command is offered. The body discloses this; a one-clause amendment would close it.

Observation, outside this PR

A shape root whose own .git is a link into an unsearchable folder (sc/pG) reads DRIFTED (2 leg(s) not at the pin). The legs row says "no gitlink recorded", and both validators crash with PermissionError.

  • This is identical on base and head and on both Pythons.
  • It contradicts the rule written in drifted_reasons' docstring: a path in the root this user cannot reach is INVALID, never DRIFTED.
  • It is a candidate follow-up issue.

Body claims

Claim Result
Eight sites (six doctor, two advisory); carries_git is is_a(root/.git, any mode) true (diff)
Change limited to the eight sites, carries_git and check_not_a_root_naming true
check_agent_files: base 3.12 FINDING plus agent-files in the verdict; base shim note absent; head note … cannot be read, NEXT the doctor, chmod WHY, the verdict the project's own; pinned file a FINDING true (sc/pA: base INVALID (agent-files), head COMPLIANT; sc/pS FINDING)
check_what_is_here, the way in, the other way in: base 3.12, base shim and head columns true
origin_org and repo_local_origin_name: 3.12 raise, shim None, head raises true (in process)
_read: base 3.12 not-answered (PermissionError), base shim not-a-repository, head not-answered with the .git line true
repository_names: 3.12 raises, shim ['Atlas'], head raises true
Whole-doctor reports: base 3.12 (four FINDINGs, no Triad row, exit 2) and base shim true
Head 3.12 and shim byte-identical, and equal to the quoted block true (cmp; the block matches byte for byte)
Stale-entry check: 0 found, 1 listed true
The cure said once in what is here true where nothing has a manifest (sc/nBoth: .git and AGENTS.md both locked, advice once)
"An AGENTS.md … has no other reader there"; title "no way in is offered over it" false for agent files (S1)
INVALID (agent-files) gone; a note under README's rule true, and sound: README makes FINDING "something else asserts this", and leg shape files already makes a leg file it cannot read a note (#430)
git_repository: null only in this case; nothing reads it as a boolean true (only readable-case tests assert is False)
Helpers raise; every caller handles it true (grep: the only callers are naming (catches it), way_in_project and origin_org (after carries_git), and _read; no template or pinned copy calls them)
Gitdir file into a locked folder behaves as before true (sc/nGitdir, base equals head)
17 rows; base 0/17 on 3.12 and the shim; head 17/17 true
Every base failure is the intended one mostly; two exceptions (N1)
Mutants 18 of 18 killed true (I re-ran the writer's 18 with my own driver; all killed)
Shas 4841bc5b… and d48d565d… true
Gate counts: 445 passed / 3 skipped, 66, 104, 86, 203, 170, 51 under the shim true (my shards are 112, 112, 112, and 109 + 3 skipped)
No docs changed; docs/cli.md unchanged; nothing in the docs now false true (README and the handbook list rows, not their texts)
Slice B's three sites stay on KNOWN_OPEN true
"Part of #1110", no closing keyword true (closingIssuesReferences: []; no keyword in either commit message)

Base against head, measured

In the tables, "the probes" means the doctor run over the fixture with .git linked into a folder at mode 000.

3.12 my shim
Base doctor, the probes 4 × FINDING .git: cannot be read, exit 2 ordinary directory, NEXT setup.sh … --project Atlas, exit 2
Head doctor, the probes the quoted block, exit 2 byte-identical to 3.12 (text and --json)
Base, new rows 0 passed, 17 failed 0 passed, 17 failed
Head, new rows 17 passed 17 passed
  • Dangling .git link and .git loop: base and head identical, both absent.
  • Shape root with an unreadable .git: base and head JSON identical.

My mutants

  • 29 written: R01-R29 in mutants.py.
  • Against the 17 new rows (R01-R28): 21 of 28 killed.
  • Survivors: R01, R07, R13, R14, R15, R27 and R28; they survive the 298-test broad set as well (S2).
  • R29 ran only against the broad set, where only the hygiene scan killed it.
  • Total: 22 of 29 killed.
  • Restores: after every mutant the file went back to the HEAD blob, proved by sha256 (4841bc5b… and d48d565d…). The final git status of all four mutant clones is empty.

Merge with main

  • origin/main is still 038dafba, fetched at the start and again at the end. The head is therefore the merge result, and I tested the head.
  • Counts on the head, in the probe environment:
Run Result
test_path_guard_hygiene + the new rows 66 passed
test_shape_doctor, 4 shards 445 passed, 3 skipped (PowerShell, Windows only)
test_scaffold_leg_path_collisions 104 passed
The unreadable pair 86 passed
The six shape_advisory importers 203 passed
test_doctor_next_is_a_command 61 passed
test_doctor_validator_rows + test_release_install_check_children 101 passed
The doc trio 170 passed
New rows + test_doctor_unreadable_paths, under the shim 51 passed

A process slip on my side

My first shard launch used mapfile in zsh, which does not have it. The id lists came out empty, so four pytest runs started the whole suite in parallel.

  • timeout stopped each one at 535 s, about 2% in.
  • No process of mine was left, my worktree is clean at eb3f098c, and no folder in scratch is left at mode 000.

I made no GitHub write, commit or push.

Files

Everything is under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1144/:

  • mutants.py (my 29, plus the writer's 18 parsed into wmutants.py with ast.literal_eval, never executed) and the driver mutate.py;
  • the shim, shim/sitecustomize.py;
  • the probe scripts and outputs: probes/scen.sh, probes/show.py, probes/helpers.py, probes/p1/, probes/fx/sc/*/;
  • the logs, in logs/.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 22, 2026 02:18
…he review found alive: a `.git` that loops or dangles is no repository, a pinned agent file beside an absent one is a FINDING, `naming` says origin was not read on every exit, and the advisory reads a gitdir file as a repository (#1110)

The review of #1144 (r1144, READY on condition) ran 29 mutants of the new
code against the 17 rows; R01, R07, R13, R14, R15, R27 and R28 survived.
Eight rows and one control kill them, and all 47 mutants (the writer's 18
and the review's 29) are now killed.

- `.git -> .git` (ELOOP) and `.git -> ../nowhere/.git` (ENOENT): controls
  that hold on main (`the way in` is the scaffold, `what is here` an
  ordinary directory, no `origin` asked, the advisory `not-a-repository`),
  and `carries_git` False (R01, and the hygiene scan's R29 as well).
- A pinned `AGENTS-shape.md` it cannot read beside an absent `CLAUDE.md`:
  a FINDING, the verdict `INVALID (agent-files)` (R07).
- `naming` with the `.git` refused, for a name that classifies, one the
  policy does not classify (exit 1) and a naming script that refuses
  (exit 2): the status is the policy's, `ok` for the first, and the clause
  that origin's name was not read is on every branch (R13, R14, R15).
- The advisory row gets the `--separate-git-dir` control: `_read` and
  `repository_names` take a gitdir file for a repository (R27, R28).
- Line 98 is cut to the file's 79 columns.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e split, one per condition, so SonarCloud's python:S9073 has nothing to say of the new rows (#1110)

One is a new row's `not row["why"] or PERMISSION_WHY not in row["why"]`, now
`PERMISSION_WHY not in (row["why"] or "")`; the other is the first
round's `"AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned`, now
two asserts. No behaviour changes: the 25 rows and the 47 mutants give the
same results.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title shape-doctor.py and shape_advisory.py: a .git or an agent file in the root linked into a folder this user cannot search is said once, <name>: cannot be read (<reason>) with the chmod cure, never absent, and no way in is offered over it (#1110) shape-doctor.py and shape_advisory.py: a .git or an agent file linked into a folder this user cannot search is said once in what is here or agent files, <name>: cannot be read (<reason>), never absent; a .git offers no way in (#1110) Oct 22, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-22T02:50:40Z) r1144b, lane 1's independent re-check of the fix round at 89486f69: READY, with no MUST or SHOULD. It is not the writer f1144.

The full re-check

Verdict: READY. No MUST and no SHOULD. One NIT, which needs no action.

Head is 89486f699f9a0b451ddbf1d4cfe41d5969391be3. It equals the PR's headRefOid, and origin/main was not moved by my reads.

Checks

1. The diff. git diff --name-only eb3f098c 89486f69 prints only tests/test_doctor_unsearchable_links.py (156 insertions, 8 deletions). shape-doctor.py and scripts/shape_advisory.py have the same sha256 as at the first review: 4841bc5b… and d48d565d….

2. S2, with the rows that kill each mutant. I ran each of the seven alone against the whole file on 3.12.

  • R01 is killed by test_a_git_link_that_leads_nowhere_is_still_no_repository[loop] and test_carries_git_says_no_to_a_link_that_leads_nowhere[loop].
  • R07 is killed by test_an_unreadable_pinned_agent_file_beside_an_absent_one_is_a_finding.
  • R13 is killed by test_naming_says_origin_was_not_read_whatever_the_name_comes_to[classifies].
  • R14 is killed by [script-refuses] of that test, and R15 by [does-not-classify].
  • R27 and R28 are each killed by both rows of test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing.
  • R29 is killed by the [dangling] rows of both link tests, so the hygiene scan no longer stands alone.
  • All 29 of R01–R29 are killed by the file alone. I also ran the writer's 18, so 47 of 47 are killed, each as a real test failure (between 1 and 13 failures per mutant).
  • Restores: after every mutant the file matched its HEAD blob, 39 times shape-doctor.py 4841bc5b1ba95d63 and 8 times shape_advisory.py d48d565d6413f63f. All five clones show an empty git status afterwards.
  • Round-1 claim: I ran D8 and A2 against the first-round test file (e21c8c05's). Both survive (16 passed), as the body says.

3. The new rows.

  • Head: 25 passed on 3.12 and 25 passed under my copy of the shim.
  • Base (038dafba's two tools in head's tree): 23 failed and 2 passed, on 3.12 and under the shim.
  • The two that pass are test_a_git_link_that_leads_nowhere_is_still_no_repository[dangling] and [loop]. They are controls, as the report says.
  • The failure kinds match the report's table.
    • AttributeError … no attribute 'carries_git' at lines 334 and 398.
    • DID NOT RAISE at 350 and 509.
    • A why or reason mismatch at 424, 513, 525, 554, 573 and 595.
    • The naming clause at line 462, three times.
  • N1: on base 3.12 the two io-error rows fail with OSError: [Errno 5] escaping exists() (in repo_local_origin_name, from check_not_a_root_naming) and is_file() (in check_agent_files). Under the shim they fail on a reason, an ordinary directory and absent: AGENTS.md.

4. S1.

5. N1–N6.

  • N1 and N2: the body wording is as the report says.
  • N3: I made a root .git directory at mode 000 with origin acme/Orbit. Head and base both print the way in … ADOPTED in place with adopt-project.py plan --source <root> --project Atlas --org <your-org>, the scaffold, and the Triad advice. That matches the Known limits entry. The folder is back at 755.
  • N4: [classifies] covers it, and design point 9 states it.
  • N5: the file has 0 lines over 79. At eb3f098c line 98 was 80.
  • N6: it is in Known limits.

6. The body.

7. Runs on head.

  • tests/test_path_guard_hygiene.py: 49 passed.
  • tests/test_doctor_unreadable_paths.py plus tests/test_doctor_unreadable_pin.py: 86 passed.

8. CI at 02:49:52Z.

  • tests: pending.
  • tests-git-bash: pass (3m46s).
  • SonarCloud Code Analysis: pass.
  • package: pass.

Findings

MUST: none.

SHOULD: none.

NIT

  • f1144's report says the advisory test's control lines are at "498–500". In tests/test_doctor_unsearchable_links.py at 89486f69 they are lines 500–504, and the base failure follows at line 509. The PR body cites no line numbers, so nothing needs changing.

Not run: Python 3.14, Windows, and the test_shape_doctor.py shards. The shards are not needed because no tool code changed.

State left behind: I made no commit, push or GitHub write. My worktree is clean at 89486f69. No folder under my scratch is at mode 000, and none of my processes is running.

Paths

  • Scratch: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1144/r2/
  • Mutant logs: logs/mut_c1.log to logs/mut_c5.log
  • Head runs: logs/head312.log and logs/headshim.log
  • Base runs: logs/base312.log, logs/baseshim.log, logs/base312v.log, logs/baseshimv.log and logs/base312s.log
  • Hygiene and unreadable-pair runs: logs/hyg.log and logs/unr.log
  • Fixtures: fx/leg and fx/g000
  • PR and issue captures: pr-body.md and i1110.json

Lane: openreposhape-1 (openRepoShape-1)

brettheap pushed a commit that referenced this pull request Oct 22, 2026
…ere` to reading through a regular `AGENTS.md` alone: a directory or a named pipe of that name is listed and not read, the pipe's doctor run under a timeout, and a refused `Makefile` is listed like any other name (#1110)

The review of #1159 (r1) found two mutants of `shape-doctor.py` alive
against all 45 rows:

- M3, `read_through` reading any kind of file (`is_a(path, lambda _m:
  True)`): over a repository whose `AGENTS.md` is a directory, `what is
  here` said `AGENTS.md: cannot be read (Is a directory)`, and over one
  whose `AGENTS.md` is a named pipe the doctor never returned (SHOULD-2);
- M14, `what is here` reading through every name it lists (NIT-4).

New rows, each a control that holds on #1144's head as here:

- `test_an_agents_md_that_is_no_regular_file_is_listed_and_not_read`
  [directory, named-pipe]: the doctor run as a person runs it, given
  `DOCTOR_SECONDS` (120) by `doctor_within`, so a run that opens the pipe
  fails the row instead of hanging the suite. `what is here` says `a git
  repository, an AGENTS.md`, nothing says `cannot be read`, and the clone
  is offered both ways in. The pipe row skips where `os.mkfifo` is absent,
  and nothing opens the pipe in the test's own process.
- `test_what_is_here_reads_through_agents_md_alone`: a leg's clone whose
  `Makefile` read is refused, in process: `what is here` says `a git
  repository, an AGENTS.md, a Makefile`, and no row says `cannot be read`.

`offered_both_ways_in` now reads its rows through `offers_both_ways_in`,
which a whole run's rows (with `machine`) can be given too.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 22, 2026
…lder named as a leg is still that leg, as it was before #1159 and as it is where the disk's file is refused, so `adopt-project.py plan` and `check` print the leg's note over it again (#1110)

The review of #1159 (r1, NIT-2) measured that a `Refusal` raised while
reading a commit's `AGENTS.md` (`Tree.text`, `cat-file blob`) no longer
fell back to the leg's name. #1144's head read it as no leg's file and
then asked the name, so a folder named `<P>-spec` was `leg-clone`; #1159's
head sent the `Refusal` straight to `read()`'s net, `not-answered`, and
`adopt-project.py` stopped printing the leg's note there.

`_read` now catches a `Refusal` from `_leg_by_agents_md` as it catches a
refused `OSError`: the folder's name answers where it is a leg's, and with
none the `Refusal` goes on to `read()`'s net, `not-answered`, as on #1159's
head. This is the review's mutant M17. `refused` stays None: no `OSError`
was refused.

New row, a control that holds on #1144's head as here:
`test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md`, a
committed clone named `Orbit-spec` whose `cat-file blob` of `AGENTS.md`
raises `Refusal`: `leg-clone`, by name, the spec leg. On #1159's head
`0f9aff05` it fails with `not-answered`.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 22, 2026
…ts name where git cannot give its `AGENTS.md` no longer asks `Reading.refused`, which #1144's `Reading` does not have, so it is the control on #1144's head its docstring says it is (#1110)

Measured with the file laid over #1144's head `89486f69`, the row's state,
`by` and role checks pass there, and only `assert reading.refused is
None` failed, with `'Reading' object has no attribute 'refused'`.
`refused` is set only together with `not-answered`, so the line asked
nothing of a `leg-clone` reading. Without it the row passes on
`89486f69` and on this head, and still fails on `0f9aff05`, whose
`Refusal` skips the name (`not-answered`, at the state check).

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 13:43
@sourcery-ai

sourcery-ai Bot commented Nov 8, 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 6 days and 5 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit b1f45fa into main Nov 8, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1110-doctor-advisory-unsearchable-git branch November 8, 2026 13:45
brettheap pushed a commit that referenced this pull request Nov 8, 2026
#1144's final head is update-branch's merge of main bd3e9c1 into
89486f6, the tree that squash-landed as b1f45fa. This is the first of
the three merges that restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
…carries #1144's change to shape-doctor.py and scripts/shape_advisory.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…py: an `id:`, a date or a `repository:` ending in a line feed is refused by name, and a pin's `source_repository:` that is no path or `owner/repo` name is named as the pin's (#1139) (#1166)

The value patterns the issue names refuse a value that ends in a line feed, by name, as #1126 (#1017) makes `COMMIT_RE`, `SHA256_RE` and `COMMIT_40` do:
- `DATE_RE` and `QUALIFIED_RE` in `validate-manifest.py` and `validate-family.py`, `MEMBER_RE` in `scripts/family.py`, `DATE_RE` in `scripts/shape_advisory.py` and `REPOSITORY_RE` in `update-shape.py`;
- `PROJECT_ID_RE`, read with `fullmatch` where the two validators and `family.py add` read an id from a file.

A shape pin's `source_repository:` that is neither a path on this machine nor an `owner/repo` name is refused in the pin's name, not `--upstream`'s. Values without a line feed keep their outcome, and no refusal code is added or renamed. Two inputs do move from one existing code to another (see "Behaviour changed", below).

**What this PR does not cover, and the title does not claim:**
- the naming-policy patterns that `scripts/repo_shape.py` reads from `contracts/repository-naming.yaml`, which `validate-repository-naming.py` still reads with `.match`;
- `update-upstream-not-a-repo`, which still says `--upstream` when a pin names a folder that exists and is not a repository.

Both are follow-ups, with the other findings of the independent review, under "Follow-ups (not in this PR)".

This does not need #1126 and does not touch its files or its test file.

Measured on main `038dafba` (Python 3.12.3, git 2.43.0) by a probe of each tool, before any change. Each value is written in the file as a YAML double-quoted `"...\n"`, which the reader takes as the value and a line feed.

| Read from | Value | On main |
|---|---|---|
| `project.yaml` (`validate-manifest.py`) | `id: "atlas\n"` | only `FINDING manifest-topic: topic is 'xf-project-atlas', expected 'xf-project-atlas\n' derived from id 'atlas\n'`, exit 1 |
| `project.yaml` | `elected_on: "2026-09-02\n"` | `manifest ok`, exit 0 |
| `project.yaml` | a leg's `repository: "testorg/Atlas-spec\n"` | `manifest ok`, exit 0 |
| `family.yaml` (`validate-family.py`) | `id: "inkrouter\n"` | `family ok: InkRouter (inkrouter` and a line break, exit 0 |
| `family.yaml` | `created_on: "2026-09-04\n"` | `family ok`, exit 0 |
| `family.yaml` | a member's `repository: "InkRouter/IRRS\n"` | `FINDING member-repository-name: ... whose name is 'IRRS\n'`, exit 1 |
| a member's `project.yaml`, read by `family.py add` | `id: "irqs\n"` | mounted and committed, exit 0, with the row `id: "irqs\n"`; the holder's gate then said `family ok` |
| `contracts/shape-pin.yaml`, `update-shape.py check` with no `--upstream` | `source_repository: "opensoft/openRepoShape\n"` | `REFUSED unsafe-value: --upstream is 'opensoft/openRepoShape\n'`, exit 2 |
| the same | `source_repository: "opensoft/open Repo"` | `REFUSED update-upstream-unresolvable: --upstream 'opensoft/open Repo' is neither ...`, exit 2 |
| the same | `source_repository: "-opensoft/openRepoShape"` | `REFUSED unsafe-value: --upstream is '-opensoft/openRepoShape'`, exit 2 |
| an adoption plan (`adopt-project.py check`) | `id: "northwind\n"` | `FINDING plan-manifest-invalid: id is 'northwind\n', which the project's own validate-manifest.py refuses: manifest-topic: ...`, exit 1; `execute` refused it, exit 2, before any leg |

In-process on the same main: `repo_shape.PROJECT_ID_RE.match('atlas\n')`, `shape_advisory.DATE_RE.match('2026-09-02\n')` and `REPOSITORY_RE.match('a/b\n')` each matched, and `date.fromisoformat('2026-09-02\n')` raised `ValueError`.

- **`DATE_RE` and `QUALIFIED_RE`** in `templates/assembly-root/scripts/validate-manifest.py` and `templates/family-root/scripts/validate-family.py` end in `\Z`. So do `MEMBER_RE` in `scripts/family.py` (the third spelling `tests/test_family_member_name.py` holds equal to the two `QUALIFIED_RE`), `DATE_RE` in `scripts/shape_advisory.py` and `REPOSITORY_RE` in `update-shape.py`.
- **`PROJECT_ID_RE` keeps its `$`.** Every id refusal prints its text (`must match ^[a-z0-9][a-z0-9-]*$`, pinned in six test files), the validators run against each project's own copy of `repo_shape.py`, and its definition sits inside #1126's hunk. The three callers that read an id from a file ask it with `fullmatch` instead: `validate-manifest.py` (`manifest-id`), `validate-family.py` (`family-id`) and `family.py`'s reading of a member's `project.yaml` (`member-bad-id`, which `add` now says before anything is mounted).
- **`update-shape.py`.** `open_upstream(spec, pin_path=None)` takes the pin the value was read from, which `prepare` passes when no `--upstream` was given. Then `update-upstream-unresolvable` says ``<root>/contracts/shape-pin.yaml: `source_repository:` is '...', which is neither a path on this machine nor an `owner/repo` name``, with a remedy to write the pin's field or pass `--upstream`, and `checked_value` names the same field. **Code kept:** `update-upstream-unresolvable`, the existing code for a value that is not an `owner/repo` name. A line feed now reaches it, since the pattern no longer takes one. Before, `checked_value` refused it as `unsafe-value`.
- **One existing test line:** `tests/test_family_init_values.py` finds validate-family's `DATE_RE` line by its text, which now ends `\Z`.
- **One existing stand-in:** the `temporaries` fixture in `tests/test_update_shape_unreadable.py` stands in for `open_upstream` with `def opening(spec)`, which the new two-argument call refused with `TypeError: ... takes 1 positional argument but 2 were given`. It now takes `pin_path=None` and hands it on to the real `open_upstream`, so `test_a_read_refused_in_prepare_closes_the_upstream[check|apply]` asks what it asked before.
- **The rows** are in a new file of their own, `tests/test_value_pattern_line_break.py`.
- **A second guard, in a second commit (`5102b4294`, a test only, no production file).** The first guard holds the four pattern names this PR ends in `\Z`, and nothing held `PROJECT_ID_RE`, which keeps its `$`. `test_every_reader_of_the_id_pattern_is_in_the_table` reads every tracked `.py` outside `tests/` with `ast` (it runs none) and holds each call of a method of `PROJECT_ID_RE` (`PROJECT_ID_RE.x(...)` or `repo_shape.PROJECT_ID_RE.x(...)`) to the table `ID_READERS`:
  - the three `fullmatch` readers of an id from a file (`validate-manifest.py`, `validate-family.py`, `scripts/family.py`);
  - the modules that still `match` it, each with its reason: argv `--id` in `scaffold-project.py` and `scripts/family.py`, where `checked_value` refuses a line feed after the pattern; argv `--id` in `adopt-project.py`, which the project's own `validate-manifest.py` refuses (`adopt-manifest-invalid`, below; `ID_READERS` still gives it the `checked_value` reason); and `adopt-project.py`'s two `plan-bad-id` readers, of the plan's `id:` and of the id derived from `legs.assembly`, which a line feed never passes (below).

  A new reader of `PROJECT_ID_RE`, `fullmatch` included, fails it by file and method until it is listed in `ID_READERS` with its reason. The table is exact both ways, so a reader taken away fails it too.

**Left as they are, and why:**
- the argv readers of `PROJECT_ID_RE` (`--id` in `scaffold-project.py`, `family.py init` and `adopt-project.py`). The first two hold the id to `checked_value` after the pattern, which refuses a line feed as `unsafe-value` naming `--id`, and `tests/test_derived_id_refusals.py` pins that answer for them. `adopt-project.py` never calls `checked_value` on `--id`: `plan` with `--id` set to `northwind` and a line feed exits 2 with `adopt-manifest-invalid`, in the words of the project's own `validate-manifest.py` (`manifest-id`), and writes no plan (lane 5's re-check, issuecomment-6371408915, NIT 2; measured on `plan` only);
- `adopt-project.py`'s reading of a plan's `id:`: `check` and `execute` hold it to the project's own `validate-manifest.py`, which refused it on main (above) and now names it in `manifest-id`'s words. It is the same value, refused under the same code, `plan-manifest-invalid`, before any leg exists. The id derived from `legs.assembly` is read with `.match` too, and a line feed in `legs.assembly` is `unsafe-value` before that id is asked (the review measured both, on main and here alike). This keeps the open PRs on `adopt-project.py` (24 at 12:02Z) clear;
- `PIN_ARG_RE`, and `NAME_RE`/`REF_RE`/`PATH_RE` in `setup-project.py`, as the issue says;
- `update-upstream-not-a-repo` still says `--upstream`. A pin's value reaches it only when it names a path that exists, not through the pattern.

**Behaviour changed, stated plainly, and what the issue's Verify means by "No refusal code changes":** the issue's Verify line reads as no code added or renamed, and none is. Two inputs do move from one existing code to another, exit 2 both before and after:
- `update-shape.py --upstream "opensoft/openRepoShape<LF>"` was `unsafe-value` and is now `update-upstream-unresolvable`, still naming `--upstream`;
- a pin's `source_repository:` that ends in a line feed, with no `--upstream`, was `unsafe-value` naming `--upstream` and is now `update-upstream-unresolvable` naming the pin's field.

No test pinned either old answer: every gate below passed. `family.py add --member` is stripped before `MEMBER_RE`, so `add` answers as before.

**Not changed, seen while measuring:** with `--upstream` passed, `update-shape.py` does not read the pin's `source_repository:` through `REPOSITORY_RE` at all, and it keeps the value as the upstream's repository name. `validate-family.py` does not hold a member row's `id:` to `PROJECT_ID_RE`; it compares the row with the member's manifest. Neither is a `$` question. Both are under "Follow-ups (not in this PR)".

All in `tests/test_value_pattern_line_break.py`, run against main's production code: the five production files swapped in place from `origin/main` (`961640c68`), then put back with their sha256 matching (`dirty=5` in the run's START line is those five files). On the head `5102b4294` (30 rows) the result is **20 failed, 10 passed in 23.12s, git 2.43.0**. The 10 that pass are baselines: the seven `test_a_pattern_takes_its_value` rows, the spelling-table guard and the two double-quoted-without-a-line-feed rows.

The first 19 are the first commit's rows (19 failed, 10 passed in 22.12s on its 29 rows at `038dafba`). The independent review reproduced them from main's bytes of the same five files (19 failed, 10 passed in 26.78s, the same 19 ids). The 20th, `test_every_reader_of_the_id_pattern_is_in_the_table`, is the second commit's: it fails on main because the three readers of an id from a file are `.match` there, not `.fullmatch`.

- `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-QUALIFIED_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family-QUALIFIED_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family.py-MEMBER_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[shape_advisory-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[update-shape-REPOSITORY_RE]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[id]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[elected_on]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[leg-repository]`
- `test_adopt_check_names_a_plan_id_that_ends_in_a_line_break_as_an_id`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[id]`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[created_on]`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[member-repository]`
- `test_add_refuses_a_member_whose_id_ends_in_a_line_break`
- `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]`
- `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[space]`
- `test_a_pins_source_checked_value_refuses_is_named_as_the_pins`
- `test_an_upstream_passed_with_a_line_feed_is_named_as_passed`
- `test_every_reader_of_the_id_pattern_is_in_the_table`

**13 of 13 killed.** Each mutant was applied alone to one file of the branch's production bytes (the same bytes as the first commit and as the head, which adds a test only), the rows were run, and the file was put back with its sha256 checked (`restored=yes` for all 13). Each run is `tests/test_value_pattern_line_break.py` plus `tests/test_family_member_name.py::test_every_spelling_of_the_member_name_rule_is_the_same_pattern`, git 2.43.0.

| Mutant | File | Killed by |
|---|---|---|
| M1 `DATE_RE` back to `$` | `validate-manifest.py` | 2: `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-DATE_RE]`, `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[elected_on]` |
| M2 `QUALIFIED_RE` back to `$` | `validate-manifest.py` | 3: `...[manifest-QUALIFIED_RE]`, `...[leg-repository]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M3 the id read with `.match` | `validate-manifest.py` | 2: `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[id]`, `test_adopt_check_names_a_plan_id_that_ends_in_a_line_break_as_an_id` |
| M4 `DATE_RE` back to `$` | `validate-family.py` | 2: `...[family-DATE_RE]`, `test_a_family_value_that_ends_in_a_line_break_is_a_finding[created_on]` |
| M5 `QUALIFIED_RE` back to `$` | `validate-family.py` | 3: `...[family-QUALIFIED_RE]`, `...[member-repository]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M6 the id read with `.match` | `validate-family.py` | 1: `test_a_family_value_that_ends_in_a_line_break_is_a_finding[id]` |
| M7 `MEMBER_RE` back to `$` | `scripts/family.py` | 2: `...[family.py-MEMBER_RE]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M8 a member's id read with `.match` | `scripts/family.py` | 1: `test_add_refuses_a_member_whose_id_ends_in_a_line_break` |
| M9 `DATE_RE` back to `$` | `scripts/shape_advisory.py` | 1: `...[shape_advisory-DATE_RE]` (its pattern row alone: `date.fromisoformat` refuses the line feed after the pattern, so no tool row sees it) |
| M10 `REPOSITORY_RE` back to `$` | `update-shape.py` | 3: `...[update-shape-REPOSITORY_RE]`, `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]`, `test_an_upstream_passed_with_a_line_feed_is_named_as_passed` |
| M11 `prepare` passes no pin | `update-shape.py` | 3: `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]` and `[space]`, `test_a_pins_source_checked_value_refuses_is_named_as_the_pins` |
| M12 `checked_value` names `--upstream` again | `update-shape.py` | 1: `test_a_pins_source_checked_value_refuses_is_named_as_the_pins` |
| M13 the pin's refusal sentence removed | `update-shape.py` | 2: `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]` and `[space]` |

No mutant is added for the stand-in in `tests/test_update_shape_unreadable.py`: it is a test change.

The independent review re-ran M3, M8, M11 and M12, and one mutant of its own (X1: `prepare` names the pin even when `--upstream` is passed). All five were killed, and each file was put back with its sha256 checked. The other nine of the 13 are this writer's runs alone.

**The second commit's guard: 6 of 6 probes caught.** Each was applied alone to one tracked file at `5102b4294`, `test_every_reader_of_the_id_pattern_is_in_the_table` was run, and the file was put back with its sha256 checked (`restored=yes` for all six). The first commit's guard, `test_every_spelling_of_a_value_pattern_is_in_the_table`, was run with each plant too: it passes every one of them, which is the gap the second guard closes.

| Probe | File | The new guard | The first guard |
|---|---|---|---|
| P1 a new `PROJECT_ID_RE.match(value)` reader appended | `update-shape.py` | failed: `('update-shape.py', 'match'): 1` is more than the table holds | passed |
| P2 a new `repo_shape.PROJECT_ID_RE.search(value)` reader appended | `scripts/shape_advisory.py` | failed | passed |
| P3 `fullmatch` back to `match` | `templates/assembly-root/scripts/validate-manifest.py` | failed | passed |
| P4 `fullmatch` back to `match` | `templates/family-root/scripts/validate-family.py` | failed | passed |
| P5 `fullmatch` back to `match` | `scripts/family.py` | failed | passed |
| P6 main's bytes of those three files together (the 20th row above) | the three | failed | not run |

**The second commit `5102b4294` (dirty=0)**, Python 3.12.3, with the same stub `gh`, bogus token and no global git config:

| Run | Git | Result |
|---|---|---|
| `tests/test_value_pattern_line_break.py` | 2.43.0 | **30 passed** in 23.00s |
| `tests/test_value_pattern_line_break.py` | 2.47.1 | **30 passed** in 23.30s |
| `tests/test_pinned_copy_text.py`, `tests/test_path_guard_hygiene.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **306 passed** in 61.54s |

That commit changes only the test file (62 lines added, no assert that joins conditions with `and` or `or`, ASCII), so the first commit's runs below are not repeated: they hold the production files and every other test file, and those are unchanged. CI on `5102b4294`, and an independent look at the second commit, were not done when this was written.

**The independent review of the first commit** (rv1166c, READY, read-only) reports these gates on `2593952c9`, all passing: the stand-in gate's 6 files (298 passed), the new file on 2.47.1 (29 passed), the five hygiene files (306 passed), 15 more test files that name a changed function, pattern or code (1611 passed, 19 skipped) and `tests/test_shape_doctor.py` (445 passed, 3 skipped). It also read CI on that head at about 20:04Z: `tests` (the full suite on 3.14), `tests-git-bash`, `package` and SonarCloud all passed.

**The first commit's gates ran on `2593952c` (dirty=0)**, Python 3.12.3, with the real `gh` unreachable (a stub first on `PATH`, a bogus token) and no global git config:

| Run | Git | Result |
|---|---|---|
| the files that held every failure and setup error of the shards below: `tests/test_family.py`, `tests/test_update_shape_pr_base.py`, `tests/test_update_shape_unreadable.py`, `tests/test_update_shape_header_comments.py`, `tests/test_update_shape_messages.py` and the new `tests/test_value_pattern_line_break.py` | 2.43.0 | **298 passed** in 343.60s |
| `tests/test_value_pattern_line_break.py` | 2.47.1 | **29 passed** in 21.31s |
| `tests/test_pinned_copy_text.py`, `tests/test_path_guard_hygiene.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **306 passed** in 69.12s |

**The two shards, on the uncommitted tree** holding the same production bytes as the first commit (the 7 files' sha256 checked before and after), git 2.43.0: the validators' test files, the `REPOSITORY_RE`/`source_repository` files and the `update-shape.py`, `shape_advisory.py` and `family.py` files, 125 in all.

- shard 1 (62 files): 4 failed, 5290 passed, 13 skipped in 56 min;
- shard 2 (63 files): 3 failed, 4 setup errors, 3761 passed, 29 skipped in 50 min.

Every failure and setup error is in the five existing files of the first row of the table above, which all pass on the first commit:
- 2 were real: `test_a_read_refused_in_prepare_closes_the_upstream[check|apply]`, the `opening(spec)` stand-in under the two-argument call. The stand-in is fixed in the first commit.
- The other 5 failures and the 4 setup errors were `update-conflict` on `scripts/validate-family.py` ("not a verbatim copy ... at the pinned commit"). This is an artifact of an uncommitted tree: `tests/test_family.py`'s `upstream_clone` commits the working tree into its clone, while `family.py init` pins the holder at the real `HEAD`, so an uncommitted `templates/family-root/` file is never verbatim at its pin. On the first commit they pass.

Every other file of the two shards passed on these production bytes, so the shards were not re-run.

`git merge-tree --write-tree <other> 5102b42` (git 2.43.0) at 2026-10-23T12:08Z, after re-fetching main (still `961640c6`; read again at 12:10Z, just before the push) and re-listing the open PRs at 12:02Z (55 open). Main has moved from the `038dafba` that this body measured to `961640c6` by one file, `docs/release-notes/v1.2.0.md`. Each open PR that touches one of this PR's 8 paths, at its current head, plus #1126:

| Against | Head | Shares | Result |
|---|---|---|---|
| origin/main | `961640c6` | | clean, tree `4613729516ff` |
| #1098 | `dad7961a` | `scripts/family.py` | clean, tree `4d1ff9517588` |
| #1117 | `10dc011c` | `scripts/family.py` | `scripts/family.py` merges clean; conflict in `docs/handbook.html` only, which this PR does not touch (see below) |
| #1124 | `8dfa32bd` | `validate-family.py` | clean, tree `cb2ec1073646` |
| #1126 | `4c1f80cf` | none (its `scripts/repo_shape.py` and tests) | clean, tree `e82c8945ac37` |
| #1133 | `a7bfee1b` | `update-shape.py` | clean, tree `a710870d1ffc` |
| #1144 | `89486f69` | `scripts/shape_advisory.py` | clean, tree `2d3e3e1ef43d` |
| #1147 | `b75babee` | `validate-manifest.py` | clean, tree `a82ac3b47a3f` |
| #1159 | `66e68f94` | `scripts/shape_advisory.py` | clean, tree `4abeb7b38665` |
| #1164 | `74096eb4` | `scripts/family.py` | clean, tree `ee285e1cb8cf` |
| #1172 | `5aa0f116` | `validate-family.py` | clean, tree `aa32d41e6e2c` |
| #1187 | `cc8a87ee` | `update-shape.py`, `tests/test_update_shape_unreadable.py` | clean, tree `25711eb1cac4` |
| #1201 | `eac177af` | `scripts/family.py` | `scripts/family.py` merges clean; conflict in `docs/handbook.html` only (see below) |

- #1117 and #1201 conflict in `docs/handbook.html` alone. #1201 is stacked on #1117 (their merge-base is #1117's head `10dc011c`). Each of them against origin/main alone gives the same conflict in the same file, so it is theirs: #1117's, from its older base `6da757cb`, which #1201 inherits.
- #1187 and #1201 are new since the first merge-trees. #1187 shares `tests/test_update_shape_unreadable.py` with this PR; its diff adds or removes no line that names `open_upstream`, `def opening` or `REPOSITORY_RE`, so it brings no one-argument stand-in for the two-argument call. That was read for all 55 open heads at 12:12Z; only this PR's own lines name them.
- No other open PR adds or removes a call of a method of `PROJECT_ID_RE`, which the second guard counts: the lines each of the 55 open heads adds or removes outside `tests/`, `docs/` and `*.md` were read at 12:04Z. Only this PR's own name it, and #1201's one new `MEMBER_RE.fullmatch` is another pattern. So a sibling landing first does not move the guard's table.

Each is a candidate for its own issue. They come from the independent review (rv1166c, READY at `2593952c9`, issuecomment-6352173084), which measured each of them on main and at the head unless the item says it did not. The review's finding numbers are in brackets.

- **The naming-policy patterns [1].** `scripts/repo_shape.py` reads the families, the roles and the topic pattern of `contracts/repository-naming.yaml` with `.match` (`family["_re"].match(name)`, `family["_split_re"].match(name)`, `role["_re"].match(name)`, and `topic_pattern`, which defaults to `^xf-project-[a-z0-9-]+$`). `validate-repository-naming.py --project project.yaml` runs in the root template's `Makefile` and `validate.yml`. At the head, and on main, it exits 0 on a leg `repository: "testorg/Atlas\n"` and prints the line feed inside its table; `-spec` and `-code` the same. The project's gate as a whole still fails, because `validate-manifest.py` now refuses that manifest.
- **`update-upstream-not-a-repo` with a pin [3].** With no `--upstream`, a pin whose `source_repository:` names a folder that exists and is no repository (the review measured `contracts` and `scripts/repo_shape.py`) is `REFUSED update-upstream-not-a-repo: --upstream contracts exists but is not a git repository`, exit 2, on main and here. `pin_path` is in scope at `update-shape.py:686-687`, so that refusal can name the pin as `update-upstream-unresolvable` now does.
- **A pin's line feed when `--upstream` is passed [9].** `update-shape.py check --upstream <clone>` over a pin whose `source_repository:` ends in a line feed exits 0, `nothing to do`. The value is kept as `upstream.repository`, which reaches the README shape line's check, `apply`'s commit subject and the pull request words (`update-shape.py:2525`, `3017` and `5121`). The review measured `check`, not `apply`. It belongs with the previous item.
- **A member row's `id:` [4].** On main, `add` of a member whose `project.yaml` reads `id: "irqs\n"` commits the row `id: "irqs\n"`, and the holder's `validate-family.py` then says `family ok`. Under mutant M8 (this branch with main's `.match` in `add`) the same row is written, and the gate still says `family ok`. By code reading, not measured end to end: `validate-family.py` compares a row's id with the member manifest's by `str` equality (`member-id-mismatch`, `validate-family.py:1608`), and `_check_member_ids_unique` does the same, so neither asks `PROJECT_ID_RE` of a row. This PR makes `add` refuse such a member, so no new holder gets such a row.
- **Values that no pattern reads [8].** The review's sweep found these `manifest ok` or `family ok`, exit 0, with a trailing line feed, at the head and on main alike:
  - in `project.yaml`: `name`, a spec or code leg's `path`, `shape.repository` and `shape.digests.digest_algorithm`;
  - in `family.yaml`: `org`, the family's own `repository`, `tracking_branch`, and the shape's and each member pin's `digest_algorithm`.

  No `^...$` pattern reads them, so they are not this PR's. A leg's `path` is checked only by `_leg_path_findings`, which asks no pattern, and the family's `tracking_branch` is refused in `project.yaml` (`manifest-tracking-branch`, U+000A) but not in `family.yaml`. `shape.commit` and `tree_sha256` with a line feed are also `manifest ok`; that is #1126's.

The first commit's message says "the three readers of an id from a file ask it with `fullmatch` (`manifest-id`, `family-id`, `member-bad-id`)". That is true of those three, but it reads as if there were no others [2]. What the review measured instead:
- `adopt-project.py` still reads the id derived from `legs.assembly` (`:6780`) and the plan's `id:` (`:6791`), both `plan-bad-id`, with `.match`;
- neither lets a line feed through. A plan `id: "northwind\n"` is `plan-manifest-invalid`, now in `manifest-id`'s words. A plan with no `id:` and `legs.assembly: "NorthWind\n"` is `REFUSED unsafe-value: legs.assembly is 'NorthWind\n'`, exit 2, before the derived id is asked, on main and here alike.

So it is the wording alone that overstates. The second commit's `ID_READERS` lists both readers with that reason.

The title's pin clause is the narrower one (the pin's name only where the value is no path and no `owner/repo` name), and two inputs change from `unsafe-value` to `update-upstream-unresolvable` (see "Behaviour changed", above).

Review: rv1166 and rv1166c, lane 5's independent opus reviewers, at `2593952c` (issuecomment-6352173084); then rc1166b, lane 5's independent sonnet re-checker, which wrote none of #1166: READY at `5102b429`, H0 of this landing (issuecomment-6371408915), with no MUST or SHOULD; its NITs 1 and 2 are corrected in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 5102b42 passed tests, tests-git-bash and SonarCloud; the head 5c8afef is a clean update-branch merge of main 93d2931, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 3c89065 over main 11560eb: the family_init_values, update_shape_unreadable, value_pattern_line_break, repo_hygiene, agents_md_codes, cli_reference sets, 365 passed, 19 skipped. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1139

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
b1f45fa is #1144's squash on main. Its own files (scripts/shape_advisory.py,
shape-doctor.py, tests/test_doctor_unsearchable_links.py and
tests/test_path_guard_hygiene.py) take the branch's side: S's tree is #1144's
final head 5b38c3a's tree, and merge 1 already brought that in. The merged
tree equals HEAD's tree, c5d0bc7. This is the second of the three merges that
restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Main 0d5704b is five commits past #1144's squash b1f45fa (#1169, #1223,
#1166, #1133, #1104). This is the third of the three merges that restack
#1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…cannot read leaves whether it is a leg unknown, so `the way in` offers neither command and points at `what is here`, and `the other way in` and the Triad row are silent (#1110) (#1159)

On #1144's head, `shape_advisory.py`'s `_leg_by_agents_md` read any exception as "no leg's file". When a leg's `AGENTS.md` was one this user cannot read, as a link into a folder at mode 000 or a file at mode 000, whether the folder is a leg was unknown. The doctor still offered `adopt-project.py plan ... --project Orbit` in `the way in` and `setup.sh` in `the other way in`, and its Triad advisory said "This is a single repository". That held on 3.12 and on a 3.14-like shim, where `Path.is_file` and the rest answer False to any `OSError`.

- `_leg_by_agents_md` answers None only for an `OSError` whose errno says nothing is there (ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and 1921), as #430's rule has it. Any other `OSError` is raised. Bytes that are not UTF-8 still declare no leg, by #385's rule. A `Refusal` ends in `read()`'s net, `not-answered`.
- A leg's name still answers. On a refused `AGENTS.md`, or a `Refusal` from `cat-file blob` in `adopt-project.py`'s reading of a commit, `_read` turns to `_leg_by_name` first, so a folder named as a leg is still `leg-clone` and `plan` and `check` print its note. With no such name, the answer is `not-answered`, and the refused `OSError` is kept in the new `Reading.refused`, which `as_dict` does not carry.
- The doctor raises what the advisory could not read. `leg_clone` raises `reading.refused`. `check_the_way_in` asks it right after `carries_git`, and on an `OSError` gives `way_in_unread_row("AGENTS.md", ...)`, shared with the `.git` row, whose words are unchanged. The row says its `AGENTS.md` cannot be read, so it is not known whether this is a clone of a Triad's leg, which is not adopted or scaffolded on its own, and neither way in is offered until it can be. In `--json` it keeps `origin_org`. `check_the_other_way_in` is silent.
- `what is here` reads a regular `AGENTS.md` through (`read_through`), so a file at mode 000, whose `stat` succeeds, is named there with the cure. A directory or a named pipe called `AGENTS.md` is listed and not read, since the open of a pipe nobody writes to blocks the doctor for ever. Only `AGENTS.md` is read through.
- `tests/test_doctor_unsearchable_links.py` gains 24 rows, 49 in all. Two of #1144's rows change: one expects `PermissionError` from `leg_clone` over a refused `.git`, and one also checks the type of `reading.refused`.
- Measured with `shape-doctor.py --root <fixture>/Atlas`, base #1144's head `89486f69` against `0ca33611`, 32 runs, on 3.12 and on the shim:
  - Over a locked `AGENTS.md` of the spec or the code leg, as a link or at mode 000, base gave NEXT `adopt-project.py plan` in `the way in`, NEXT `setup.sh` in `the other way in` and a Triad row. Head gives the reason above and no NEXT, with no `the other way in` row and no Triad row. Exit 2 at both, and `what is here` names the file with the cure.
  - The readable fixtures are byte-identical to base (8 of 8). Every report on 3.12 equals its report under the shim (32 of 32).
  - In process, `read()` over a locked file went from `advise` to `not-answered`, and `leg_clone` from False to raising `PermissionError`.
  - `adopt-project.py plan` and `check` give equal output on base and head over 64 runs, and the tool is not changed. Where git cannot give a commit's `AGENTS.md` and no name is a leg's, `plan` and `check` now print no advisory where base printed the advice. No fixture meets this.
  - The new file at base fails 17 rows and passes 32, on 3.12 and on the shim alike (the same 17), each on the row's own assertion. At head 49 pass on both.
  - Mutants: 31, of which 28 are killed. The 3 survivors are equivalent: `_leg_by_agents_md` also catching `ValueError` (`UnicodeDecodeError` is the only one `text()` can raise), `check_the_other_way_in` asking `leg_clone` first (`leg_clone` raises a `.git` it cannot read as `carries_git` does), and `_nothing_there` dropping the Windows arm (CI is Linux only).
- Known limits:
  - A leg's file that is not UTF-8 is still "no leg's file", by #385's rule against salvaging text that is not UTF-8. Over such a file the doctor still offers adoption and the Triad advisory, on base and head alike. Two rows hold this.
  - `leg_clone` now raises over a refused `.git`, where #1144's row said False. Both callers ask `carries_git` first, so no report changes.
  - A committed symlink `AGENTS.md` is read by `adopt-project.py`'s commit reader as its target's path, so `plan` and `check` say "This is a single repository" over a link to a leg's file, while the doctor, which reads the disk, calls the checkout a leg. It is the same on base and head, and filed as #1163.

Review: lane 1's independent reviewers reviewed each round. r1159 found `0f9aff05` NOT READY, for two SHOULDs and five NITs with no MUST (#1159 issuecomment-6344638023). The fix round `0ca33611` answered all seven, and r1159b found it READY, with no MUST or SHOULD and two optional NITs (issuecomment-6350397420). NIT-A is applied to the body. NIT-B, a docstring row name broken across a line, is applied in `66e68f94`, the head, whose one commit after `0ca33611` changes that docstring in `tests/test_doctor_unsearchable_links.py` and nothing else (4 lines added, 3 removed). d1159's delta check carried READY to `66e68f94` (issuecomment-6385555505). After #1144 landed as `b1f45fa7`, the branch was restacked onto main by three merges, with no rebase, and its changed lines equal the reviewed delta (sha256 `0a7cfc86`). Lane 1's delta check carries READY to `5370b018` (issuecomment-6723286228).

Tests, under a stub `gh` with a bogus token: the PR body's gate ran at `0ca33611`, and the one commit after it is that docstring. At `0ca33611` the gate gave 49 passed for `tests/test_doctor_unsearchable_links.py` on 3.12 and 49 on the shim, 96 passed for `tests/test_shape_advisory.py` and `tests/test_path_guard_hygiene.py`, `tests/test_shape_doctor.py` in 4 shards (112, 112, 112 passed; 109 passed, 3 skipped), 61 passed for `tests/test_doctor_next_is_a_command.py`, and 290 passed for the 7 other files that name `shape_advisory`. At the restacked head `5370b018`, `tests/test_doctor_unsearchable_links.py` with `tests/test_shape_advisory.py`, the two scans with `tests/test_scaffold_leg_path_collisions.py`, and the doc trio gave 550 passed and 2 skipped over 3 calls.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `5370b018` is green on Linux and SonarCloud. The branch was never rebased. After H0, the only commit is update-branch's clean merge of main, giving 9e4aab8, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 9 pytest calls over `tests/test_doctor_unsearchable_links.py`, `tests/test_shape_advisory.py`, `tests/test_doctor_next_is_a_command.py`, `tests/test_doctor_unreadable_paths.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_adopt_single_repository_record.py`, `tests/test_bom_reads.py`, `tests/test_bootstrap_non_utf8_branch.py`, `tests/test_doctor_gitmodules_not_utf8.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py` and `tests/test_shape_doctor.py` in 4 shards: 1,281 passed, 5 skipped.

Part of #1110

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…ose own `.git` this user cannot read is named `.git: cannot be read (<reason>)`, so the doctor never reads it as DRIFTED, and both pinned validators refuse it, exit 2 (#1146) (#1147)

#1146's case, found by r1144: an assembly root whose own `.git` is a link into a folder at mode 000, so this user cannot search it. On base `038dafba` the doctor read it as `DRIFTED (2 leg(s) not at the pin)`, with `no gitlink recorded` for each leg, and its `manifest` and `pins` rows said each validator `crashed before reaching a verdict (exit 1): PermissionError`. Run directly, `validate-pins.py` and `validate-manifest.py` each ended in a `PermissionError` traceback, exit 1, on Python 3.12, and in `REFUSED not-a-git-repo`, exit 2, under a 3.14-like shim in which `Path.is_file`, `is_dir`, `exists` and `is_symlink` answer False to any `OSError`. Both raised inside `find_repo_root` of `scripts/repo_shape.py`, at `(candidate / ".git").exists()`.

- The validators each call `_refuse_an_unreadable_root(start)` first in `main`. With the file's own `_is_a` (`scripts/repo_shape.py` is a pinned copy too, and a pinned validator must not rely on a newer one, #370 and #610), it asks each `.git` that `find_repo_root` would ask, in the same order, from the real path of the `--root` given, and stops at the first one that is there, a directory or a gitfile. One that answers absent (ENOENT, ENOTDIR, EBADF, ELOOP, and Windows 21, 123 and 1921) is passed over, as `exists()` passes it. A refused `stat` ends in the new code `git-repo-unreadable`, the sibling of `not-a-git-repo`: `REFUSED git-repo-unreadable: .git: cannot be read (Permission denied)`, exit 2, with the cause's advice (the `chmod` cure for a permission). A `.git` above the `--root` given is named whole. `find_repo_root` is not changed and answers as before after it.
- `validate-pins.py` also asks the git directory of that `.git`, as #968 asks of a leg's: a gitfile it cannot open, a git directory whose `HEAD` it cannot reach. On base git said of each that the root records no gitlink, and the gate refused `pin-gitlink-absent`. `validate-manifest.py` runs no git and asks only the `stat`, so over a readable `.git` and an unreadable git directory it passes, a true answer about `project.yaml`.
- The doctor's `root_git_read` asks what `validate-pins.py` asks and names the git directory as the gate does (relative to the root inside it, whole outside it, a gitfile ending in LF or CR LF). The `legs` row says the gate's line once, as a FINDING, with the doctor itself as NEXT and the gate's advice as WHY. Each leg's entry has `gitlink: null`, the line in `gitlink_cannot_be_read`, and `pin`, `populated`, `head`, `own_git` and `unborn` null, so a reader of `--json` gets no `KeyError`. `drifted_reasons` counts no such leg, and still counts a readable leg beside it that is off its pin.
- `placement` audits every leg git can read, and gives the root's line to a leg whose `.git` is there and leads to a repository git names no top level for; its row says the root's line once, `n/a`, only where it is every leg's. The verdict follows the ladder (an unfinished merge, DRIFTED, MISPLACED, INVALID, SHAPE BEHIND): no leg is compared with its pin while the root's `.git` cannot be read, so none is drift, and a path in the wrong leg of a leg git reads is `MISPLACED (1 path)`.
- Over #1200 (`a2fc1da8`): #1200's `mount_read` refused a leg's git directory before the root's line was reached, so `placement` named `<leg>: .git/modules/<leg>: cannot be read` once per leg instead of the root's line once. `audit_leg` now gives the root's line first where the leg's git directory is inside the root's own, under its `.git` or under the directory its gitfile names (where git keeps a submodule's for a root that is a linked worktree or a submodule itself, measured with git 2.43.0). Every other refusal keeps `mount_read`'s line: a mount, a `.git` or a gitfile, and a leg's own `.git` directory. With the root readable, #1200's line stands.
- The S1 round (`4d299dd3`): `audit_leg` asks `populated`, which is True only where `mount_read` got as far as the mount's `.git`, before `read_through_the_root`. A spec mount this user can search and not list (mode 0100 or 0300) had been given the root's line, because `spec/.git` could still be read through the root, against the text above. It now keeps `spec: cannot be listed (Permission denied)`. The verdict stays `INVALID (pins)`, so only the text of the `placement` row changes.
- Both validators are in `COPIED_VERBATIM`, and no file is added to or taken from a pinned list. Against a project one commit past its pin, `update-shape.py check` says `upstream-changed` for the two and `unchanged` for the other 9; a project that holds the old copies keeps `crashed ... PermissionError` in the validator rows until `update-shape.py apply`. No `--help` text changes, so `docs/cli.md` is unchanged, and no refusal code is added to `AGENTS.md`. Not touched: #1144's eight sites, the doctor's gitmodules-on-disk code (#1109), and `scripts/repo_shape.py`.
- Measured in a scaffold's recursive clone, on Python 3.12.3 and under the shim, with base `038dafba` against `b75babee`: the issue's case went from the `DRIFTED` above to `INVALID (manifest, pins)`, the `legs` row `.git: cannot be read (Permission denied), so no gitlink is read and no leg is compared with its pin`, and each validator to `REFUSED git-repo-unreadable`, exit 2, no traceback. A `.git` that is a directory at 000 or 444, or a gitfile at 000, went from `DRIFTED` to `INVALID (pins)` (`validate-manifest.py` passes). A spec leg with a repository of its own and a misplaced path, beside a root `.git` at 000, went from `DRIFTED (1 leg(s) not at the pin)` to `MISPLACED (1 path)`. The controls (a readable `.git`, a link to nothing, a link to itself) give the base's reports byte for byte, with paths and commit SHAs normalized, on both Pythons.
- `tests/test_root_git_unreadable.py` is new and holds 51 rows (47 at `b75babee`, 50 at `a2fc1da8`). Each round was run base against head, with the new file laid over base. At `b75babee`, on 3.12.3 and under the shim, base failed 37 and passed 10 (controls, and the walk rows that stop at a gitfile), and the head passed 47. For the order round, over 54 ids with #1200's 4 rows, base failed 8 and passed 46, and the head passed 54. For the S1 row, `a2fc1da8`'s `shape-doctor.py` failed it and the head passes it. Mutants: 49 of 50 killed at `b75babee` (the survivor, r1147's Da, differs only in a race between the `stat` and the read), 7 of 7 at `a2fc1da8`, and 5 of 7 at `4d299dd3`, where M6 and rv1147c's K4 are equivalent on a real filesystem.
- Known limits:
  - Four other pinned callers of `find_repo_root` keep the defect, a `PermissionError` traceback on 3.12 and `REFUSED not-a-git-repo` under the shim: the assembly root's `bootstrap.py`, and the family root's `validate-family.py`, `bootstrap.py` and `siblings.py`. They need the same local walk in four more pinned copies, filed as #1148.
  - The `chmod` cure says "every directory above it inside the root", but in the issue's case the locked folder is outside the root and the line names `.git`, a link, so `chmod u+rx .git` is refused. These are the gate's words, unchanged, as r1111's N5 left them.
  - A root with no `.git` at all (a link to nothing or to itself, which #430's rule calls absent, or none under a parent whose `.git` cannot be read) still reads `DRIFTED (2 leg(s) not at the pin)`; under such a parent `validate-pins.py` walks up and refuses, and the doctor asks only the root's own `.git`.
  - While the root's `.git` cannot be read, the `legs` row says that alone, so the verdict can read `MISPLACED (1 path)` where the run after it is mended reads `DRIFTED (1 leg(s) not at the pin)`.
  - `placement` also gives the root's line to a leg whose gitfile names a folder that does not exist and to a leg whose `.git` folder has no `HEAD`; the run after the root is mended gives git's answer for both.
  - A root whose `.git` is a link, with legs whose gitfiles name the link's target and not `.git/modules/<leg>`, keeps `mount_read`'s line for each leg in `placement`, since `read_through_the_root` follows no link. The `legs` row still says the root's line once.
  - A family holder whose own `.git` cannot be read, and the `members` row, are not changed (#1109's code is avoided); #1148 asks for them to be measured.
  - Only what #968 asks of a leg is asked: the `stat` of `.git`, the open of a gitfile, the `stat` of the git directory's `HEAD`.

Review: lane 1's independent reviewers reviewed the first two rounds, lane 3 noted the clash with #1200, and lane 5 re-reviewed the order round. r1147 found `e86fb8f4` READY on condition, with no MUST, two SHOULDs and eight NITs (#1147 issuecomment-6332417236): S1, no rows for a relative `--root` or a walk that stops at a gitfile, and S2, `placement` turning down legs it could still read. It killed 13 of its 21 mutants; the survivors were the rows S1 and the NITs asked for, and Da, which is equivalent. The fix round `b75babee` answered both SHOULDs and the NITs, and r1147b found it READY, with no MUST or SHOULD and three NITs (issuecomment-6333323566): each named mutant was killed by its named row, 20 of r1147's 21 are killed (Da is equivalent), N1 and N2 are answered in the text above, and N2's docstring and N3 are optional and left. Lane 3's note (issuecomment-6362488848), from its review of #1200 (issuecomment-6362479557), found that on a merge of #1200 with `b75babee` 5 of the 47 rows failed, all on `placement`'s reason, and named the order that `a2fc1da8` gives. Lane 5's re-review of `a2fc1da8`, by rv1147c (rv1147 and rv1147b hung before they measured anything), found it READY, with no MUST, one SHOULD and four NITs (issuecomment-6723841826): the SHOULD, S1, was a mount this user can search and not list that got the root's line; the NITs were K2, K4 and K7 surviving, rv1248's note, which the text does not contradict, and the draft, to be marked ready. rv1147c killed 8 of its 11 mutants, and found the tree of the merge `569ea056` to be exactly that of `git merge-tree --write-tree b75babe 50869f3`, with no hand edit. Lane 1 took the code for S1, not a narrower text, as `4d299dd3` (issuecomment-6724251407). Lane 5's delta check of `4d299dd3`, by rv1147d, found it READY, with no MUST or SHOULD and three NITs: M6 has no row (an optional stand-in row), the PR was a draft, and CI's `tests` was still running (issuecomment-6724684319).

Tests, under a stub `gh` with a bogus token: at `a2fc1da8`, lane 1's gate, on tree `50a97fd7`, gave 1,018 passed and 5 skipped over 8 calls: `tests/test_root_git_unreadable.py` 50 passed, `tests/test_doctor_unreadable_paths.py` 68 passed, `tests/test_path_guard_hygiene.py` and `tests/test_no_auto_maintenance.py` with `tests/test_scaffold_leg_path_collisions.py` 283 passed and 2 skipped, the doc trio (`test_agents_md_codes`, `test_cli_reference`, `test_repo_hygiene`) 171 passed, and `tests/test_shape_doctor.py` in 4 shards 446 passed and 3 skipped (the PowerShell line that is printed only on a Windows host). Lane 5's rv1147c ran the first two files with `tests/test_shape_doctor.py` in one pytest at `a2fc1da8`, on git 2.43.0 and on 2.47.1: 564 passed and 3 skipped each time. The wider gate at `b75babee`, with the 37 other test files that name `placement` and the validator files, gave 6,740 ids: 6,737 passed and 3 skipped; the validators have not changed since `e86fb8f4`. At `4d299dd3`, w1147s's gate (Python 3.12.3, git 2.43.0) gave `tests/test_root_git_unreadable.py` with `tests/test_doctor_unreadable_paths.py` 119 passed; `tests/test_shape_doctor.py`, 4 shards, 446 passed and 3 skipped; the scans with `tests/test_scaffold_leg_path_collisions.py` 283 passed and 2 skipped; the doc trio 171 passed. That is 1,024 ids: 1,019 passed and 5 skipped.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `4d299dd3` is green on Linux and SonarCloud. The branch was never rebased. After H0, the only commit is update-branch's clean merge of main, giving 95ea564, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 8 pytest calls over `tests/test_root_git_unreadable.py`, `tests/test_doctor_unreadable_paths.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py` and `tests/test_shape_doctor.py` in 4 shards: 1,019 passed, 5 skipped.

Closes #1146

Lane: openreposhape-1 (openRepoShape-1)
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

Development

Successfully merging this pull request may close these issues.

1 participant