Skip to content

family.py and tests: Family.rewritten's refusal of two members: lines is asked of the method again, its docstring says no verb reaches that count since #974, and a refusal that leaves a ref behind fails assert_unchanged (#1029) - #1039

Merged
brettheap merged 2 commits into
mainfrom
l3-1029-members-count-and-snapshot
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1029. v1.2.0 work, claimed by lane 3.

The follow-up to #1026 (which closed #1024): lane 4's RV1026 SHOULD 1 and NITs 1 and 2 (#1026 issuecomment-6280756903), and #1024's note on remediation. Two commits: the change, and F1039's fix of RV1039's NITs. scripts/family.py changes in a docstring only; no product code changes.

What changed, by section

1. Family.rewritten's count of two members: lines has a test again (RV1026 SHOULD 1).

2. rewritten's docstring is true again (RV1026 NIT 1).

3. snapshot() records every ref (RV1026 NIT 2).

  • tests/test_family_add_refusals.py's snapshot(), the helper behind assert_unchanged, gains "refs": git for-each-ref --format=%(refname) %(objectname).
  • Decided here: the explicit format. Git's default format reads each object for %(objecttype), so one ref to a missing object fails the whole snapshot. Measured on git 2.43.0: the default exits 128 with fatal: missing object 1111... for refs/heads/broken, and the explicit format exits 0 and lists that ref. snapshot(status=False) exists for holders that git half-refuses, so the snapshot must not fail where a ref is broken.
  • Every test file that imports snapshot or assert_unchanged still passes (below).
  • RV1026's branch mutant now fails assert_unchanged (Mutants, below).

4. yaml-unparsable from family.yaml outside an act: DECIDED, left as it is, with no remediation line.

Why:

Out of scope

Tests

Each file was run alone, one at a time, under a stub gh that exits 97, with GH_TOKEN=bogus, an empty GH_CONFIG_DIR, EMAIL=tests@openreposhape.invalid, and with GIT_ALLOW_PROTOCOL and GIT_SSH_COMMAND unset.

The command was timeout 2400 /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-a-<name> -o tmp_path_retention_policy=failed <file>. It ran on the tree committed as this head, over base e2f9fc42.

File Result
tests/test_family_add_gitlink_and_null_members.py 27 passed in 28.70s
tests/test_family_add_refusals.py 65 passed in 59.89s
tests/test_family_add_excludes.py 23 passed in 31.34s
tests/test_family_add_member_id.py 14 passed in 15.88s
tests/test_family_add_member_variant.py 37 passed in 39.23s
tests/test_family_add_move_back_sections.py 41 passed in 87.17s
tests/test_family_add_spelling_leftovers.py 70 passed in 209.08s
tests/test_family_add_spellings_remedies.py 44 passed in 61.80s
tests/test_family_members_not_a_list.py 156 passed in 87.78s
tests/test_family_dry_run.py 61 passed in 69.33s
tests/test_family_bump_remove_rollback.py 31 passed in 36.26s
tests/test_refusal_default_remediation.py (imports snapshot) 20 passed in 15.16s
tests/test_family_member_path.py (imports snapshot) 43 passed in 36.19s
tests/test_local_remote_dir_urls.py (imports both) 27 passed in 21.86s
tests/test_family_members_case_variants.py (imports snapshot) 60 passed in 45.01s
tests/test_agents_md_codes.py 47 passed in 2.14s
tests/test_cli_reference.py 23 passed in 28.77s
tests/test_update_add_codes_documented.py 6 passed in 0.47s
tests/test_repo_hygiene.py 100 passed in 2.37s
tests/test_no_auto_maintenance.py 60 passed in 8.18s

That is 955 passed and 0 failed over 20 files.

  • test_family_add_excludes.py and test_family_add_member_id.py also import snapshot (and assert_unchanged), and they are in the table.
  • So do the four other test_family_add* files, through their imports from test_family_add_refusals.
  • tests/test_shape_doctor.py was not run: neither shape-doctor.py nor a helper it imports changed.
  • No printed line changed, so AGENTS.md, the docs and the README are untouched.

Mutants

Each mutant was applied to scripts/family.py on this head and then restored with git checkout. The file's sha256 was e8682ef71a6c96bf... before and after, and the tree was clean after each.

Mutant File run Result
Family.rewritten: if seen != 1: -> if seen == 0: (issue item 1) test_family_add_gitlink_and_null_members.py killed: 2 failed, 25 passed. Both rows of test_two_members_lines_are_refused_by_the_rewrite_itself fail with Failed: DID NOT RAISE Refusal. Before this PR it survived 358 tests over 6 files (RV1026).
RV1026's branch mutant: Family.open's refused parse runs git branch rv-mutant in the holder, then raises test_family_add_gitlink_and_null_members.py, with this PR's snapshot() killed: 2 failed, 25 passed. Both rows of test_a_second_members_line_above_the_block_is_refused fail at test_family_add_refusals.py:203 (assert snapshot(...) == before). The only differing item is 'refs', which gains refs/heads/rv-mutant.
The same branch mutant, with main's snapshot() (git checkout origin/main -- tests/test_family_add_refusals.py) the same file survives: 27 passed. So the new refs record is what kills it.

Merges

git merge-tree --write-tree of the first head (5095ca88; F1039's one-line commit 0bcb6f59 touches only the test file):

Closes #1029

Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code

Summary by Sourcery

Strengthen family manifest refusal handling and repository-state invariants around duplicate members: entries.

Bug Fixes:

  • Ensure Family.rewritten refuses manifests containing multiple members: lines without modifying the file.
  • Detect leaked Git references when verifying that refused operations leave repository state unchanged.

Enhancements:

  • Update Family.rewritten documentation to reflect that duplicate-key parsing now fails before rewrite and that the count remains defense in depth.

Tests:

  • Add direct rewrite tests covering duplicate members: null and members: [] lines and unchanged file contents.
  • Extend refusal snapshots to record all Git references, including references to missing objects.

…ines is asked of the method again, its docstring says no verb reaches that count since #974, and a refusal that leaves a ref behind fails `assert_unchanged` (#1029)

- tests/test_family_add_gitlink_and_null_members.py: two direct-API rows
  (`members: null` and `members: []` above `name:`, the real block at the
  end) expect `family-members-unrecognised` with `has 2`, and the file
  untouched. The premise is asserted too: the reader refuses that text
  (`key 'members' is written twice`), which is why no verb reaches the
  count since #974 and the rows must call the method (RV1026 SHOULD 1).
- scripts/family.py: `rewritten`'s docstring says so, in the past tense
  for the reader that took the last of two keys, and that the count stays
  as defence in depth (RV1026 NIT 1). No code changed.
- tests/test_family_add_refusals.py: `snapshot()` records every ref, by
  name and object (`git for-each-ref --format=%(refname) %(objectname)`),
  so RV1026's branch mutant now fails `assert_unchanged` (RV1026 NIT 2).
  The explicit format records a ref whose object is missing instead of
  failing (`fatal: missing object` under the default format, git 2.43).
- #1024's remediation note (item 4) is decided as left: see the PR body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR strengthens refusal and rewrite regression coverage without changing product behavior: Family.rewritten's duplicate-member guard is tested and documented as defense in depth, while refusal snapshots now capture every Git ref so leaked branches or other refs are detected, including broken-object refs.

Sequence diagram for duplicate members refusal and rewrite defense

sequenceDiagram
    participant Caller
    participant FamilyOpen as Family.open
    participant Rewritten as Family.rewritten
    participant Manifest as family.yaml

    Caller->>FamilyOpen: open()
    FamilyOpen->>Manifest: load_yaml()
    alt duplicate members key
        Manifest-->>FamilyOpen: yaml-unparsable refusal
        FamilyOpen-->>Caller: refusal
    else rewrite invoked directly
        Caller->>Rewritten: rewritten([])
        Rewritten->>Manifest: read_text()
        Rewritten-->>Caller: family-members-unrecognised refusal
    end
Loading

Sequence diagram for ref-complete unchanged snapshots

sequenceDiagram
    participant Test
    participant Snapshot as snapshot()
    participant Git

    Test->>Snapshot: snapshot()
    Snapshot->>Git: git for-each-ref --format=%(refname) %(objectname)
    Git-->>Snapshot: all refs, including broken-object refs
    Snapshot-->>Test: state with refs
    Test->>Test: assert_unchanged()
    Test-->>Test: detect leaked refs
Loading

File-Level Changes

Change Details Files
Reassert direct-method protection against manifests containing multiple members: lines and document why the defensive count remains necessary.
  • Add parametrized coverage for members: null and members: [] duplicate-line inputs.
  • Verify the YAML reader refuses the premise, then verify Family.rewritten independently refuses with the full count and leaves content byte-for-byte unchanged.
  • Clarify the Family.rewritten docstring to distinguish reader-level rejection from defensive method-level validation and explain zero-count paths.
scripts/family.py
tests/test_family_add_gitlink_and_null_members.py
Make refusal snapshots detect mutations to any Git ref, including refs pointing at missing objects.
  • Record all refs as name/object pairs using an explicit git for-each-ref format.
  • Avoid Git object-type lookup so snapshots continue working when a ref targets a missing object.
  • Use the expanded snapshot to kill the regression mutant that creates a branch before refusing.
tests/test_family_add_refusals.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1024 Update the two failing test rows to expect the YAML reader’s one-line refusal, computing the reported line number from the file while retaining the other refusal and unchanged-holder checks. ❌ The PR adds direct tests for Family.rewritten and updates the existing failing test’s docstring, but it does not change that test’s refusal expectation or make it compute and expect the reader’s line number. The reported two-row CI failure therefore is not fixed by the shown changes.
#1029 Restore direct-API test coverage for Family.rewritten refusing two members: lines, and update its docstring to explain why normal command paths do not reach that count and why it remains as defense in depth. ✅
#1029 Make refusal snapshots detect changes to Git refs, not only changes to HEAD and other repository state. ✅
#1029 Decide whether an unparsable family.yaml outside an act should receive remediation guidance, and either implement and test that guidance or document a reasoned decision to leave it unchanged. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-20T05:45:42Z) RV1039: lane 3's independent review of #1039 at 5095ca8 (closes #1029). Verdict: READY. No MUST or SHOULD items; two NITs, neither blocking. The tests check was still pending at 2026-10-20T05:44Z, so landing waits for it to go green.

Items 1-3:

  • The two new rows of test_two_members_lines_are_refused_by_the_rewrite_itself pin the count of two. Mutant seen != 1 -> seen == 0: killed, 2 failed and 25 passed, DID NOT RAISE Refusal.
  • The rewritten() docstring is true. Family.open is the only manifest parse. Other ways to hide a second margin members: line from the reader are all refused: --- and ... (key written twice), a multi-line quoted scalar, a multi-line flow collection, and a plain multi-line scalar. The count of none is still reached through a verb: test_family_members_not_a_list.py:368-377 and the comment row.
  • snapshot() records every ref. Checked on git 2.43.0: the default for-each-ref format exits 128 (fatal: missing object) on a ref to a missing object, and the explicit format exits 0 and lists the ref. An existing row needs the explicit format: with the default format, test_a_head_that_names_no_readable_commit_is_unwound_like_any_other[a missing object] fails.
  • No test is weakened or made flaky. The holder gets no remote-tracking ref or refs/stash on any add, bump or remove path: the only fetch runs in the member, and the rollback's update-ref puts the branch back on the same object.

Item 4: AGREE. add (7642 -> 7654), bump (9488 -> 9512) and remove (10339 -> 10362) all reach _checked_manifest_committed after Family.open and before any write. A fixed "commit" step is wrong where HEAD's copy is put back, or where a gitlink is staged. #370 says a wrong remedy is worse than the REFUSED line alone.

NIT 1, tests/test_family_add_gitlink_and_null_members.py:490,495: rewritten() never writes, so the "file unchanged" assertion cannot fail. Call write_members([]) in the raises block, or drop the assertion.
NIT 2, PR body, item 4: not every yaml-unparsable names a line. An unclosed quote gives "quoted scalar without a closing quote: '"x'" with no line number. Say "names the file and the fault, and the line where the reader has one". This only strengthens the decision.

Tests at the head, one file at a time, under a stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and EMAIL set. All exited 0, 339 passed: gitlink_and_null_members 27, add_refusals 65, refusal_default_remediation 20, member_path 43, local_remote_dir_urls 27, members_case_variants 60, add_excludes 23, add_member_id 14, no_auto_maintenance 60.

Mutants:

  • Branch mutant with the new snapshot: killed, 2 failed at test_family_add_refusals.py:203, and only 'refs' differs (it gains refs/heads/rv-mutant).
  • Branch mutant with main's snapshot: survives, 27 passed.
  • Mine, the snapshot's refs in the default format: killed, 1 failed.

Merges (git merge-tree --write-tree): with origin/main 5b41d44, clean, tree 65b2061a. With #1027 a79c957, clean, tree f40908fc. No other open PR touches the 3 files.

Lane: openreposhape-3 (openRepoShape-3)

…`, the method that writes, so its "file unchanged" assertion can fail (#1029, RV1039 NIT 1)

`test_two_members_lines_are_refused_by_the_rewrite_itself` called `rewritten()`,
which never writes, so `assert path.read_text(...) == text` could not fail.
It now calls `write_members([])` inside the `pytest.raises` block: the same
refusal is expected, and the assertion covers the method that writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-20T05:51:16Z) F1039: RV1039's two NITs are fixed at 0bcb6f5 (one commit over 5095ca8, a one-line diff in tests/test_family_add_gitlink_and_null_members.py).

NIT 1: test_two_members_lines_are_refused_by_the_rewrite_itself now calls held.write_members([]) in the pytest.raises block, not held.rewritten([]). The expected refusal and the file-unchanged assertion are as before. Mutant A put a write before the refusal in write_members (it appended a comment line, so the count stayed at two): killed at line 495 (assert path.read_text(...) == text), 2 failed. Mutant B changed if seen != 1 to if seen == 0: killed at line 489, 2 failed, DID NOT RAISE Refusal. scripts/family.py was restored: sha256 e8682ef71a6c96bf84f592f052689da3a3cc78f9c0a2dd23fb9934539e7222e7 before and after, cmp identical, tree clean.

NIT 2: the PR body, item 4, now says the refusal "names the file and the fault, and the line where the reader has one", and the only-true-form sentence says "fix what it names (the file and the fault, and the line where the reader has one), then run the same command again". The line after "So after the ..." now reads "after the fault is fixed". Edited with gh pr edit --body-file from the live body and read back byte-identical.

Tests at the new head, each file alone, under a stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and EMAIL set: test_family_add_gitlink_and_null_members.py 27 passed, test_family_add_refusals.py 65 passed, test_no_auto_maintenance.py 60 passed. All exit 0.

Lane 3's coordinator then refreshed the body's stale lines: "Two commits", the row's write_members([]) call, and the merges labelled as of the first head. It checked the one-line diff against RV1039's suggested fix, so no second review round is needed.

Lane: openreposhape-3 (openRepoShape-3)

brettheap added a commit that referenced this pull request Oct 20, 2026
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043)

`setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git.

Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes.

**`setup-project.py`** (section 0, just above `self_bootstrap`):
- `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from.
- `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does.
- `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character.
- `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`.
- The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2.

**`tests/test_setup_project_repo_misdrawn.py`**, 22 tests:
- **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package.
- **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character).
  - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl.
  - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out.
  - The git log must hold exactly one call: the checkout probe.
- **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before.
- **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log.
- Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597).
- `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in.

**"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path.

These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1.

| Case | Before (`5b41d44f`) | After |
|---|---|---|
| package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line |
| direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only |
| direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only |
| direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` |
| direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` |
| direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 |

The "after" refusal, from the probe:

```
REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path.
```

The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**.
- Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test.
- Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad.

Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.**

| Mutant | Failed |
|---|---|
| m01 check removed (main's read) | 13 |
| m02a check moved after the `(0) self-bootstrap` line | 13 |
| m02b check moved after the clone line | 13 |
| m03a only the value's first character checked | 15 |
| m03b only the table's first entry checked | 13 |
| m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) |
| m05 refusal exits 1 | 13 |
| m06a table drops `E0001` | 4 |
| m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) |
| m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) |
| m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 |
| m06e parity uses set inclusion + table drops `E0001` | 4 |
| m07 a range read without its last code point | 15 |
| m08 control check asked before the code points | 2 (both tab-then-U+202E tests) |
| m09 characters named in the value's order | 3 |
| m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) |
| m11 refusal quotes the value back | 13 |
| m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) |

All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`.

| Run | git | Files | Result |
|---|---|---|---|
| h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s |
| h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s |
| h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s |
| h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s |
| h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) |

No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass.

- `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`.
- No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`.

- **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout.
  - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it.
  - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own.
  - `src/openreposhape/standard.py` is also open in #1038.
- **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares.

Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #953.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…hain link before an `=` that leaves a path after it, so the offer the gate prints for a link holding `=` verifies that link when typed as printed (#980) (#1044)

For a declared-unverified chain link whose name holds `=`, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered `open=y` the source `--link-source open=y=<path>`, and read every `--link-source` key at the value's first `=`: the key was `open`, which no chain names, so the value was a bare PATH for the first link, `open=y` stayed declared-unverified under the same offer, exit 0.

**The issue's direction (a)**, in `scripts/validate-repository-naming.py`. `LinkSources._read_source` reads a `--link-source` key the way `_read_remedy` reads a `--link-remedy` link since #949: the longest text before an `=` that names a link of a chain this run reads, by `pin_product_key`, and leaves a path after it. `__init__` and `no_path` both take the key and the path from it, so the path refused as empty is the one after the key's own `=`. For a chain whose links hold no `=`, every value is read exactly as before: a text holding `=` can only name a link that holds one.

**What (a) keeps from the documented rules, and how each case was checked** (probes below, and one test row or more each):
- A link holding `=`: `open=y=<tree>` verifies `open=y`, as printed and in capitals (`OPEN=Y=<tree>`, by `pin_product_key` as every key is).
- Two links where one is a prefix of the other (`open` and `open=y` in one chain): `open=y=<tree>` is `open=y`'s and `open=<tree>` is `open`'s, in either order. On main both were `open`'s and the later replaced the earlier, so `open=y` read none.
- A path that itself holds `=`: `open=y=<dir>/a=b/tree` is `open=y`'s with that whole path. A text holding `/` names no link, since a link's name is `repo_basename`'d, so an `=` after a `/` never ends the key. A path that begins like a longer link's name is given after `./`: `open=./y=dir` is `open`'s in a chain naming `open=y` too.
- A key that names a link but leaves an empty path: `open=y=` is `PRODUCT=` for `open=y`, refused as `link-source-no-path`, exit 2, as `openXdox=` is (#442). On main it was a bare PATH asked for `openXdox`, a link the person did not name. Where a shorter link leaves a path (`open=y=` with `open` in the chain too), that link is the key, `open` with the path `y=`, as `--link-remedy openXdox=y=` gives `openXdox` the words `y=` where a chain names `openXdox=y`: the longest link that leaves a path wins, and `open=y` leaves none.
- A value that is a real directory spelled like the whole value: `open=y=x` beside a directory `open=y=x` is still `open=y`'s, decided by the chain and not the disk, with `_also_a_directory`'s line; `./open=y=x` asks for the directory, as a bare PATH for the first link.
- The key is read whole, where a remedy's link is stripped and cut at its last `/`: ` open=y=<tree>` and `opensoft/open=y=<tree>` name no link and stay bare PATHs, as on main.

**One difference from `--link-remedy`, deliberately.** `--link-remedy open=y=` is a NOTE (its link falls back to the first `=`, `open`). `--link-source open=y=` is refused, because the #442 rule refuses `PRODUCT=` for every link, and `--link-remedy`'s own `openXdox=` refusal is made by argparse before the chains are known, which is why it cannot catch `open=y=`. `_read_source`'s docstring says so.

**Not (b).** (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its `--pin-source` as an absolute path, with a separator before every `=`, so no text before one names a link.

**Docs.** The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the `LinkSources`, `_link_remedy` and `_read_remedy` docstrings, the `--link-source` help, and `docs/cli.md` regenerated from it. `setup-project.py`'s `as_the_plan_reads_it` docstring stated the first-`=` rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a `--link-source` key is read (README names `--link-source PRODUCT=<path>` only), so they are unchanged. The class docstring records that `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one (`--pin` names are `[A-Za-z0-9_.-]`), and it is left as its own change.

Measured from a root scaffolded at main `5b41d44f` (local remotes, no network):
- `update-shape.py check --upstream <this checkout> --at c64e85f` lists `upstream-changed scripts/validate-repository-naming.py`, with `upstream-changed 1  unchanged 10`.
- `update-shape.py apply --yes --branch ...` exits 0, commits `contracts/shape-pin.yaml`, `project.yaml` and the copy, and the project's validators print `pins ok` and `manifest ok`. A fresh `check` prints `unchanged 11`, and the root's own naming gate exits 0.
- The new sha256 of `scripts/validate-repository-naming.py`: `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` (78664 bytes). No other pinned copy and no template changes.

Run from empty folders, `<D>` a fresh folder, trees as named (`trees/y` declares `openInk`, `trees/x` declares `open=y`; for the chain with `open`, `trees/x2` declares `open` and `trees/o` declares `open=y`). The gate prints an arrow between links; it is written `->` here.

| Probe | At main `5b41d44f` | At head `c64e85fe` |
|---|---|---|
| P1, the issue's row: `--referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y` | `link-source-unreadable ... could not answer for openXdox`, "It was read as a PATH: `open` is not a link", and `open=y` declared-unverified with the same offer; exit 0 | only `openXdox` declared-unverified (no source named it); no `link-source-unreadable`; exit 0 |
| P2, P1 plus `openXdox=<D>/trees/x`, `--explain` | NOTE: the value not used, read as a PATH; `CHAIN openXdox -> open=y -> openInk [declared-unverified]` | `[verified]`, nothing warned or noted |
| P3, chain `openXdox,open,open=y,openInk`, `open=<o>` then `open=y=<y>` | `link-source-unreadable: y=<D>/trees/y (--link-source open=...)`, NOTE `open=<o>` replaced; 2 links declared-unverified | `[verified]`, nothing warned or noted |
| P4, `open=y=` (chain without `open`) | bare PATH `open=y=` asked for `openXdox`; exit 0 | `REFUSED link-source-no-path: --link-source open=y= names no path for open=y`; exit 2 |
| P5, `open=y=` (chain with `open`) | `y= (--link-source open=...) could not answer for open` | the same |
| P6, `open=y=<D>/trees/a=b/y` | read as a PATH, `[declared-unverified]` | `[verified]` |
| P7, `open=y=x` beside a directory `open=y=x` | bare PATH, read as the directory for `openXdox` | `link-source-unreadable: x (--link-source open=y=...) could not answer for open=y`, then "`open=y=x` is also a directory; `--link-source ./open=y=x` reads it as one." |
| P7b, `./open=y=x` | the directory, for `openXdox` | the same |
| P8, chain with `open`: `open=./y=dir`, `open=y=<y>` | `open=y=<y>` read for `open`, replacing `open=./y=dir` | `[verified]` |

Eleven new rows in `tests/test_chain_remedy_callers.py`, section "#980", reusing its helpers (`naming`, `tree`, `gate_warnings`, `UNREACHED`, `GATE_OFFER`, `EQUALS`), with no assert that joins conditions with `and`:
- `test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]`: reads the offer the gate prints for `open=y`, types it with a tree, and asserts the one WARNING left is `openXdox`'s, with no `link-source-unreadable` and no NOTE.
- `test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]`
- `test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path`
- `test_a_shorter_link_that_leaves_a_path_is_the_key`
- `test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it`
- `test_a_key_is_read_whole[a-blank, an-owner]`

**Failing at main.** The test file copied onto a `git archive` of `5b41d44f`: 8 of the 11 fail, each on the defect (the offer row's `open=y` WARNING and `link-source-unreadable`, the prefix rows' `[declared-unverified]`, `open=y=` exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (`test_a_shorter_link_that_leaves_a_path_is_the_key`, `test_a_key_is_read_whole[a-blank]` and `[an-owner]`), and each kills a mutant below.

**Mutants**, each applied alone to the fixed gate in a scratch repository, then `test_chain_remedy_callers.py`, `test_link_source_chain_defaults.py`, `test_naming_policy.py`, `test_chain_link_fold.py` and `test_chain_pin_sources.py` (400 passed, 4 skipped with no mutant). All 11 killed:

| Mutant | Failed | Killed by (new rows unless named) |
|---|---|---|
| M1 back to `typed.partition("=")` | 8 | the offer rows, the prefix rows, `open=y=`, the directory row |
| M2 the shortest match instead of the longest | 3 | the prefix rows |
| M3 the "leaves a path after it" check dropped | 1 | `test_a_shorter_link_that_leaves_a_path_is_the_key` |
| M4a the `./PATH` rule dropped (`./` stripped from the key) | 3 | the directory row; `test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path`; `test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory` |
| M4b the key cut at its last `/`, as a remedy's link is | 4 | as M4a, and `test_a_key_is_read_whole[an-owner]` |
| M5 the empty-path fallback dropped | 6 | `open=y=`; `test_a_value_that_names_no_path_is_spelled`; 4 rows of `test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused` |
| M6 the fallback at the first `=`, as a remedy's | 1 | `open=y=` |
| M7 `no_path` taken after the first `=` | 1 | `open=y=` |
| M8 the keyed path taken after the first `=` | 7 | the offer rows, the prefix rows, the directory row |
| M10 the key stripped, as a remedy's link is | 1 | `test_a_key_is_read_whole[a-blank]` |
| M12 the key not folded by `pin_product_key` | 56 | `in-capitals` and the prefix rows, and 52 existing rows |

**Runs**, all on head `c64e85fe`, `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp per run, with `EMAIL=service@opensoft.dev`, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`:
- git 2.43.0 (`/usr/bin/git`), 31 files: the 25 that run the naming gate (`grep -l validate-repository-naming tests/`, the pinned-copy files `test_pinned_copy_text.py`, `test_update_shape_kept_helper.py`, `test_update_shape_unreadable.py` and `test_adopt_reencoded_files.py` among them, and `test_repo_hygiene.py`), the files that scan every script's source (`test_git_output_decoding.py`, `test_shape_advisory.py`, `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`), and the trio's other two, `test_agents_md_codes.py` and `test_cli_reference.py`: **2964 passed, 5 skipped** in 1749 s, exit 0.
- git 2.47.1 first on PATH, the 25 naming-gate files: **2680 passed, 3 skipped** in 1181 s, exit 0.

No template changes. `grep` of `tests/` found no fixed digest or `^id:`-style pattern over the gate: the tests that read a root's copy of it (`test_shape_doctor.py`, `test_doctor_validator_verdicts.py`) compare it at run time, and both are in the first run, as are the source scanners above and `test_cli_reference.py`, which holds `docs/cli.md` to the help.

- `git merge-tree --write-tree origin/main HEAD`, origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees): clean, tree `82780dc63d7f`, the head's own tree.
- Open pull requests: none of the 9 open (#954, #1023, #1025, #1027, #1028, #1030, #1031, #1038, #1039) touches `scripts/validate-repository-naming.py`, `tests/test_chain_remedy_callers.py`, `setup-project.py` or `docs/cli.md`, so there is no merge-tree to run against them.

Review: rv980, lane 5's independent reviewer, not the writer (w980): READY at `c64e85fe` (issuecomment-6288391970), with no MUST or SHOULD; the landing head only merges main over it. Its NIT-1 (three test rows for the corners of the old first-`=` split) and NIT-2 (a sentence in `validate-pins.py`) go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 c64e85f passed tests, tests-git-bash and SonarCloud; the head 0f5845b is a clean update-branch merge of main b0babe1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree ac9c882 over main b0babe1: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #980.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap marked this pull request as ready for review October 20, 2026 10:17
@sourcery-ai

sourcery-ai Bot commented Oct 20, 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 4 hours and 1 minute by commenting @sourcery-ai review. Upgrade to get a review now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment