Repository navigation
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
Conversation
…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>
Reviewer's GuideThis PR strengthens refusal and rewrite regression coverage without changing product behavior: Sequence diagram for duplicate members refusal and rewrite defensesequenceDiagram
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
Sequence diagram for ref-complete unchanged snapshotssequenceDiagram
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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-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 Items 1-3:
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. 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:
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>
|
|
(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) |
… 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>
…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>
|
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 |



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.pychanges in a docstring only; no product code changes.What changed, by section
1.
Family.rewritten's count of twomembers:lines has a test again (RV1026 SHOULD 1).tests/test_family_add_gitlink_and_null_members.py::test_two_members_lines_are_refused_by_the_rewrite_itself, two rows (members: nullandmembers: []), beside the other direct-API rows.kind: family-manifest\n<line>\nname: x\nmembers: []\nand callsFamily(tmp_path, {"members": []}).write_members([]), the method that writes (RV1039 NIT 1, F1039: it called.rewritten([])at5095ca88, which never writes, so the file-unchanged check could not fail).family-members-unrecognisedwith the whole detail,<path> has 2 `members:` line(s) where exactly one was expected, so this tool will not rewrite it, and the file unchanged byte for byte.load_yamlrefuses that text asyaml-unparsable,line 4: key 'members' is written twice. That is why no verb reaches the count since scripts/repo_shape.py: the YAML reader refuses a key written twice in one mapping, block or flow, by the line of the second, and a block scalar line indented less than its first by its line, where it kept the last key and cut the line's first characters; such a line opening#ends the block as a comment (#917) #974, and why the row calls the method.2.
rewritten's docstring is true again (RV1026 NIT 1).#ends the block as a comment (#917) #974: the reader refuses the doubled key, soFamily.openstops the manifest asyaml-unparsablefirst.family.yamlchanged between the open and the rewrite, a caller of the method, or a reader relaxed later, and it names the test above.3.
snapshot()records every ref (RV1026 NIT 2).tests/test_family_add_refusals.py'ssnapshot(), the helper behindassert_unchanged, gains"refs":git for-each-ref --format=%(refname) %(objectname).%(objecttype), so one ref to a missing object fails the whole snapshot. Measured on git 2.43.0: the default exits 128 withfatal: 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.snapshotorassert_unchangedstill passes (below).assert_unchanged(Mutants, below).4.
yaml-unparsablefromfamily.yamloutside an act: DECIDED, left as it is, with no remediation line.Why:
members/,git commit -- family.yamlis the step_checked_manifest_committedwarns against: it would commit the row without the gitlink, and that refusal's remedy there isgit checkout HEAD -- family.yamlinstead._checked_manifest_committedasks on the next run. That duplicates that check and is not a small change.Family.open(add,bumpandremove, real runs and--dry-runalike) runs_checked_manifest_committedafter it, before any write.family-manifest-uncommitted. Its remedy picks the right exit: commit the change on its own first (git commit -- family.yaml), or put back HEAD's copy (git checkout HEAD -- family.yaml).line 88: key 'members' is written twice. That follows repo_shape.py'sRefusalgives every refusal built without a remediation the assembly root's own (run git submodule update --init --recursive, then python3 scripts/validate-pins.py ... contracts/<leg>-pin.yaml), so in a family holderfamily.py addover an unparsable.gitmodules(git-failed),bumpon afamily.yamlof the wrong kind, and the holder's ownvalidate-family.pyon a shortpin.commiteach tell the operator to run a file no holder carries and to move a leg pin a holder does not have #370's rule that a REFUSED line with no remedy of its own says what failed and where.add/bump/remove: amembersfile deleted unstaged, agit bisect, an empty sparse-checkout file, an emptyrebase-merge/and a merge conflicted infamily.yamlmeet a refusal or remedy that does not fit (#626's gaps) #684's control pins, in a file outside this task.tests/test_family_holder_states.py::test_an_unparsable_family_yaml_with_no_act_in_progress_is_as_beforeassertsran.stderr == unparsable_line(holder, at) + "\n"foraddandadd --dry-run: "one line and no remediation, as before family.pyadd/bump/remove: amembersfile deleted unstaged, agit bisect, an empty sparse-checkout file, an emptyrebase-merge/and a merge conflicted infamily.yamlmeet a refusal or remedy that does not fit (#626's gaps) #684".test_a_second_members_line_above_the_block_is_refusedin this PR's file pins the same one line._unparsable_in_an_act) is unchanged.Out of scope
tests/test_family_holder_states.py(family.pyadd/bump/remove: amembersfile deleted unstaged, agit bisect, an empty sparse-checkout file, an emptyrebase-merge/and a merge conflicted infamily.yamlmeet a refusal or remedy that does not fit (#626's gaps) #684's control) in that task's files.test_a_family_yaml_outside_any_repository_keeps_its_refusalone line: there, git cannot answer.Tests
Each file was run alone, one at a time, under a stub
ghthat exits 97, withGH_TOKEN=bogus, an emptyGH_CONFIG_DIR,EMAIL=tests@openreposhape.invalid, and withGIT_ALLOW_PROTOCOLandGIT_SSH_COMMANDunset.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 basee2f9fc42.tests/test_family_add_gitlink_and_null_members.pytests/test_family_add_refusals.pytests/test_family_add_excludes.pytests/test_family_add_member_id.pytests/test_family_add_member_variant.pytests/test_family_add_move_back_sections.pytests/test_family_add_spelling_leftovers.pytests/test_family_add_spellings_remedies.pytests/test_family_members_not_a_list.pytests/test_family_dry_run.pytests/test_family_bump_remove_rollback.pytests/test_refusal_default_remediation.py(importssnapshot)tests/test_family_member_path.py(importssnapshot)tests/test_local_remote_dir_urls.py(imports both)tests/test_family_members_case_variants.py(importssnapshot)tests/test_agents_md_codes.pytests/test_cli_reference.pytests/test_update_add_codes_documented.pytests/test_repo_hygiene.pytests/test_no_auto_maintenance.pyThat is 955 passed and 0 failed over 20 files.
test_family_add_excludes.pyandtest_family_add_member_id.pyalso importsnapshot(andassert_unchanged), and they are in the table.test_family_add*files, through their imports fromtest_family_add_refusals.tests/test_shape_doctor.pywas not run: neithershape-doctor.pynor a helper it imports changed.AGENTS.md, the docs and the README are untouched.Mutants
Each mutant was applied to
scripts/family.pyon this head and then restored withgit checkout. The file's sha256 wase8682ef71a6c96bf...before and after, and the tree was clean after each.Family.rewritten:if seen != 1:->if seen == 0:(issue item 1)test_family_add_gitlink_and_null_members.pytest_two_members_lines_are_refused_by_the_rewrite_itselffail withFailed: DID NOT RAISE Refusal. Before this PR it survived 358 tests over 6 files (RV1026).Family.open's refused parse runsgit branch rv-mutantin the holder, then raisestest_family_add_gitlink_and_null_members.py, with this PR'ssnapshot()test_a_second_members_line_above_the_block_is_refusedfail attest_family_add_refusals.py:203(assert snapshot(...) == before). The only differing item is'refs', which gainsrefs/heads/rv-mutant.snapshot()(git checkout origin/main -- tests/test_family_add_refusals.py)refsrecord is what kills it.Merges
git merge-tree --write-treeof the first head (5095ca88; F1039's one-line commit0bcb6f59touches only the test file):5b41d44f: clean (tree65b2061a). Main moved by one commit since this branch was cut, validate-family.py:git-not-runnablecounts only anOSErrorthat namesgit, or aFileNotFoundErrororPermissionErrorthat names no file, asvalidate-pins.pycounts it, and only around the git calls, so a member's checkout git could not enter, or a member check's own step, is never told to put git on PATH (#928) #983, which touchesvalidate-family.pyand none of this PR's files.bump's fetch leaves out every refspec of the mount's whose source can name a tag, a pattern such asrefs/*and a short name too, so a mirror in a folder,+refs/*:refs/remotes/origin/all/*, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009) #1027 (lane 4,scripts/family.py) ata79c957b: clean (treef40908fc). Its hunks are near_origin_fetch_refspecs, and this PR's one hunk isFamily.rewritten's docstring.scripts/family.py,tests/test_family_add_gitlink_and_null_members.pyortests/test_family_add_refusals.py.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:
Family.rewrittenrefuses manifests containing multiplemembers:lines without modifying the file.Enhancements:
Family.rewrittendocumentation to reflect that duplicate-key parsing now fails before rewrite and that the count remains defense in depth.Tests:
members: nullandmembers: []lines and unchanged file contents.