Skip to content

family.py: bump and remove refuse member-path when a row's path: is not members/<project> or its project: is not a directory name, and hand git every path literally, so a hand-edited path: members/* or project: . no longer makes remove unmount every member in one commit while dropping one row (#299) - #315

Merged
brettheap merged 4 commits into
mainfrom
fix/family-member-path-literal
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #299 (comment)

What and why

Four commits on one branch, none rewritten: the refusal and the literal pathspecs (1e42241), the ./.. project clause (58aa4e3), the first review's fix round (2f04085), and the second review's fix (8f8eb92).

family.py bump and remove took the member's path from the manifest row's path: and handed it to git as it stood: a pathspec, with its globs, and no --literal-pathspecs. The tool writes that field itself, as members/<project> and nothing else, so a different value is a hand edit; but nothing asked.

  • path: members/*: remove --member Atlas exited 0 with ONE commit that deleted every member's gitlink while dropping only Atlas's row.
  • path: .: it git rm -r -f'd the holder's own files, then died on a FileNotFoundError reading the family.yaml it had just removed.
  • bump with a directory literally named members/* holding a .git: exit 0, the clone checked out at --to and committed as a NEW gitlink at members/*.

validate-family.py already has a rule for it, member-path, but it runs after a commit, never in front of these two verbs.

The rulings

  1. Refuse, before any git call and any write. One function, _member_path(project, row=None), is the one definition of the path a member has. _member_row (what add writes) and cmd_add call it with no row; bump and remove call it with the row, after their any-case lookup (family.py: add refuses a case-variant of a member the family already pins as member-already-present naming the row that pins it, bump and remove find a member in any case, and validate-family.py refuses two rows with one id, so --member inkrouter/irrs beside InkRouter/IRRS no longer mounts one project twice past both gates and bump --member irss no longer recommends that add (#206) #221, family.py add compares --member with the existing rows case-sensitively, so --member inkrouter/irrs on a family that already pins InkRouter/IRRS mounts the same repository again as members/irrs, both gates pass a family that pins one project twice, and bump --member irss's refusal recommends exactly that add #206), so project is the row's own spelling. A row whose path: is not exactly members/<project> raises Refusal("member-path", ...), naming the row's value and the expected one and saying the tool writes that field itself: put the path back, or git checkout -- family.yaml if the edit is not committed. A row with no path: keeps defaulting. The comparison is exact, as the validator's is.
  2. Literal pathspecs on every git call in these two verbs that takes the member's path. _nothing_to_record's git diff and remove's git submodule deinit and git rm carry the global --literal-pathspecs; git submodule is a command of its own and honours it (it reaches it as GIT_LITERAL_PATHSPECS). The stage is force_stage's, already literal. The commit is spelled differently, below. Proved on git 2.43.0 with members/* in a scratch holder: each call acts on the neighbours without the spelling and does nothing with it. With ruling 1 the path is always members/<project>, so this is defence in depth that does not depend on the row being right.
  3. Scope: bump and remove. siblings and the validator only read the row; add's path is checked, and now also comes from _member_path.

The second spelling

templates/family-root/scripts/validate-family.py keeps its own spelling of the rule (_check_member_repository_and_path, member-path). It and scripts/repo_shape.py ship into every holder, so sharing the rule would have moved every holder's pin for the refusal of a hand edit; neither file is changed. family.py has one definition, and the validator has the other.

A project that is not a directory name (58aa4e3)

A row hand-edited in BOTH fields, project: . with path: members/., or project: .. with path: members/.., is equal to members/<project>, so it passed the comparison. remove --member . then exited 0 with ONE commit deleting both members (members/. is the members directory itself; the literal flag does not help, a real directory is the same path literally), and --member .. staged the holder's own files for deletion and died on the same FileNotFoundError. bump was stopped only by accident.

_member_path(project, row) also refuses, under the same member-path code, a row whose project is not a single directory name directly under members/ (., .., the empty name, one with a /), saying why. Only a row is checked, so what add writes behaves as before.

Fix round on the review of 58aa4e3 (2f04085)

The review found no MUST and two SHOULDs; four changes, each ruled in.

  1. The commit's paths are :(literal), not the global flag. git --literal-pathspecs works by putting GIT_LITERAL_PATHSPECS=1 in git's environment, and every child of git commit inherits it, including the holder's commit hooks. On git 2.43 a pre-commit hook that picks staged files with a glob (git diff --cached --name-only -- '*.yaml') saw family.yaml on a plain commit and nothing under the flag, so a linter behind it checked nothing on every add, bump and remove commit. _commit_one now passes -- :(literal)<path> ... and no global option. Proved on 2.43.0 on the commit's own form: :(literal)members/* is refused while a neighbour's gitlink is staged, a path git rm took out of the index and the tree still commits, and the hook sees the variable unset. deinit, rm and diff run no commit hooks and keep the global option. (git rm, like force_stage's git add, writes the index, so a post-index-change hook still sees the variable after them; that predates this PR.)
  2. The ./.. remediation can now work. "Run the same command again" cannot succeed after either old remedy, and "remove the row by hand" leaves the member's gitlink with no row, which the validator and the doctor both pass. It says: restore family.yaml from git (git checkout -- family.yaml if the edit is not committed), or put the row's project: and path: back as the tool wrote them, then run the command again naming that member.
  3. One definition. cmd_add spelled members/<project> itself; it calls _member_path(project) now (no row: the same string, the same behaviour).
  4. add refuses a . or .. segment as member-malformed. MEMBER_RE's class admits the dot (for .github), so add --member <org>/.. matched, and its mount path members/.. is the holder's root. On a fresh holder, with no members/, member-path-taken found the root's own files "in the index" under it and printed the remediation git rm -r --cached -- members/.., which stages the deletion of every file the holder has (15 deletions on a scratch copy). _checked_member now refuses an owner or project segment of . or .. as member-malformed, add's existing argument refusal, before the member is read or anything is mounted, and says why. This replaces the earlier claim here and in a docstring that add refuses these as member-path-taken: that held only once members/ existed. MEMBER_RE lives in scripts/family.py:104, is read only there, and its character class is unchanged.

One expectation moves: tests/test_family_add_refusals.py pins the exact git commit line an unwound add shows, now git commit -q -F - -- :(literal).gitmodules :(literal)members/IRQS :(literal)family.yaml.

Fix round on the re-check of 2f04085 (8f8eb92)

:(literal) is pathspec magic, and GIT_LITERAL_PATHSPECS=1 turns magic off. With it exported in the caller's environment (a safety default, or family.py run from a hook or alias of a git --literal-pathspecs command, which exports it to its children), git read :(literal)family.yaml as a file of that name and found none, so every add, bump and remove commit failed with error: pathspec ':(literal)family.yaml' did not match: add unwound, bump and remove exited 2 with the pin or the unmount left staged and no rollback. Main and 58aa4e3 exited 0 in that environment, so this was a regression against both. _commit_one now drops that one variable from its own commit's environment (env.pop("GIT_LITERAL_PATHSPECS", None) on the environment it already builds), and only there: the commit's paths are literal by their magic and need nothing from the environment, and the other pathspec variables leave magic on and are untouched.

The _commit_one docstring now says the other calls "run no commit hooks" and names the post-index-change hook that rm and force_stage's add still feed the variable to. The unused global_options test helper is removed.

Not in this PR: no check reports a gitlink under members/ that no row names (a validator follow-up, next to #300).

Tests

tests/test_family_member_path.py (43 tests), in test_family.py's family and test_family_add_refusals.py's offline environment (members from local bare remotes, a gh shim that logs and exits 97, an isolated git configuration):

  • remove and bump with the row hand-edited, uncommitted, to members/*, ., the neighbour's path, the member's own path with a trailing slash, and the member's path in another case: exit 2, the refusal text, git ls-files -s -z and git status --porcelain=v1 -z byte-identical, HEAD unchanged, the neighbour's gitlink, row and checkout still there, no gh call.
  • remove and bump against project: . with path: members/. and project: .. with path: members/..: the same refusal and the same unchanged holder, with the new remediation text pinned.
  • The same refusal with subprocess.run forbidden: made before any git call, not merely before any write.
  • bump with a literal members/* checkout holding a .git refuses and leaves that clone's HEAD where it was (skipped on Windows, which refuses * in a file name, with the reason written at the skip; every other row needs no such directory).
  • The controls, the row the tool writes and a row with no path:, still exit 0 in ONE commit of exactly the paths each verb is for, and validate.
  • The hook test: a holder with a pre-commit hook that writes GIT_LITERAL_PATHSPECS and the staged files a '*.yaml' glob finds. Through a real add, bump and remove it sees the variable unset and family.yaml. The hook is sh, which git runs on every OS (the same as the hook in test_a_commit_that_fails_unwinds_the_mount_and_the_pin).
  • The caller's environment: with GIT_LITERAL_PATHSPECS=1 exported to the run, add, bump and remove each exit 0 in ONE commit of exactly the paths each is for, leave nothing staged behind, and the holder validates.
  • add --member of <org>/.., <org>/., ../<Project> and ./<Project> is refused member-malformed on a fresh holder (no members/) and on one with members: exit 2, nothing on stdout, no clone, no gh call, the holder byte for byte as it was; .github and a.b still pass _checked_member.
  • Each of the four git calls, with the glob members/*, reaches the neighbour in its plain spelling (the premise) and does not in the tool's (global flag; :(literal) for the commit): it runs on each CI OS's own git.
  • Every call that names the path in a real bump and a real remove is checked, the set exact: the global flag on deinit, rm, diff and the stages, and :(literal) on each of the commit's paths with the global option absent from it.

Run on Linux, git 2.43.0, on head 8f8eb92: tests/test_family.py tests/test_family_add_refusals.py tests/test_family_add_excludes.py tests/test_family_member_path.py tests/test_cli_reference.py tests/test_repo_hygiene.py, 265 passed, none skipped. docs/cli.md does not move (no --help text changes).

Mutants, each on a scratch copy of the tree:

  • Refusal dropped, flags kept: the refusal rows, the no-git-call rows, the literal-directory row and the one-definition row fail. remove with members/* still stops at deinit (exit 2, nothing written), but path: . still dies on the FileNotFoundError with the whole tree staged for deletion and bump with the literal members/* clone still exits 0 with the wrong commit: the flags are no substitute for the refusal.
  • Refusal and every flag dropped (what main does): remove with members/* exits 0 with one commit whose git show --stat names members/IRRS and members/IRSS (18 deletions) and drops one row.
  • Refusal dropped and git rm's flag dropped alone: deinit stops the run first, so that flag is not reachable end to end. With deinit stubbed out to reach it, git rm without its flag stages the deletion of both members; with it, exit 2 and the index is clean.
  • Each flag dropped alone (deinit, rm, diff), refusal kept: the every-call test fails, and only it.
  • The ./.. clause dropped: the four dot rows and the definition's test fail, and the wrong outcomes come back (remove --member . exits 0 with one commit deleting both members, 18 deletions; remove --member .. dies on the FileNotFoundError).
  • The global flag back on the commit: the hook test fails for all three verbs (the hook logs literal=1, and its glob finds nothing), both every-call tests fail, and the pinned commit line in test_family_add_refusals.py fails. With the fix all pass.
  • The env.pop dropped: the three caller-environment rows fail with error: pathspec ':(literal)family.yaml' did not match any file(s) known to git on the commit, exit 1; the other 40 of the file pass. With the pop, all 43 pass.
  • The add dot-segment refusal dropped: the eight member-malformed rows fail, and the fresh holder's add --member InkRouter/.. prints the git rm -r --cached -- members/.. remediation again.

Closes #299

🤖 Generated with Claude Code

Summary by Sourcery

Protect family member operations from unsafe manifest paths and directory references while ensuring all affected git commands treat member paths literally.

Bug Fixes:

  • Prevent bump and remove from acting on hand-edited member paths or project names that could target multiple members or the holder itself.
  • Ensure all member-related git operations use literal paths, including commits, without breaking commit hooks or callers that set literal-pathspec environments.
  • Reject . and .. member segments during add before any repository access or filesystem changes.

Enhancements:

  • Centralize member path generation and validation for paths written by add and consumed by bump and remove.

Tests:

  • Add comprehensive coverage for malformed member paths, directory-reference project names, literal pathspec handling, hook behavior, caller environments, and unchanged state on refusal.

brettheap and others added 2 commits October 12, 2026 01:52
…h:` is not `members/<project>` and take every path they hand git literally, so a hand-edited `path: members/*` no longer makes `remove --member Atlas` exit 0 with one commit that unmounts every member while dropping one row, and `path: .` no longer `git rm -r -f`s the holder's own files before dying on the missing manifest (#299)

THE DEFECT. `cmd_bump` and `cmd_remove` read the member's path from the
manifest row, `str(row.get("path") or f"{MEMBERS_DIR}/{project}")`, and
handed it to git as it stood. Git reads a path as a pathspec, and nothing
checked the field or passed `--literal-pathspecs`:

- `path: members/*` made `remove --member Atlas` exit 0 with ONE commit
  that deleted every member's gitlink, `members/Atlas` and `members/Borg`
  alike, while dropping only Atlas's row. Borg was then in the manifest and
  not in the tree.
- `path: .` made it `git rm -r -f` the holder's own files, then die on a
  `FileNotFoundError` reading the `family.yaml` it had just removed.
- `bump` needed a second accident first: `member-uninitialized` wants a
  checkout with a `.git` at the literal path, so it took a directory named
  `members/*` as well as the hand edit. With one it exited 0: it fetched and
  checked `--to` out inside that clone, then staged and committed the clone
  as a NEW gitlink at `members/*` beside the rewritten row.

The tool writes `path:` itself, as `members/<project>` and nothing else
(`_member_row`), and `validate-family.py` has a rule for exactly that,
`member-path`. But the validator runs after a commit, in CI and in the
doctor, never in front of these two verbs, so they trusted the field it would
refuse.

THE THREE RULINGS (the issue's decisions, as the coordinator made them).

1. REFUSE, before any git call and any write. One new function,
   `_member_path(project, row=None)`, is the one definition of the path a
   member has. `_member_row` (the row `add` writes) calls it with no row, so
   `add` is unchanged; `bump` and `remove` call it with the row after their
   any-case lookup (#221, #206), so `project` is the row's own spelling. A
   row whose `path:` is not exactly `members/<project>` raises
   `Refusal("member-path", ...)`, the code the validator's finding uses for
   the same rule. The refusal names the row's value and the expected one,
   and its remediation says the tool writes that field itself: put the path
   back in `family.yaml`, or `git checkout -- family.yaml` if the edit is not
   committed. A row with no `path:` keeps defaulting; the comparison is
   exact, as the validator's is. `validate-family.py` and `repo_shape.py` are
   NOT changed: both ship into every holder, so a change there moves every
   pin. The validator keeps its own spelling of the rule, the second one.
2. LITERAL PATHSPECS, in the place git takes global options, on every git
   call in these two verbs that takes the member's path: `_nothing_to_record`'s
   `git diff`, `_commit_one`'s `git commit` (its stage was already literal
   through `force_stage`), and `remove`'s `git submodule deinit` and `git rm`.
   Proved on git 2.43.0 with `members/*` in a scratch holder: each of the four
   acts on the neighbours without the flag and does nothing with it, and
   `git submodule`, a command of its own, honours the global flag (it reaches
   it as `GIT_LITERAL_PATHSPECS`), so no per-pathspec `:(literal)` magic was
   needed. With ruling 1 the path is always `members/<project>`, so this is
   defence in depth that does not depend on the row being right.
3. SCOPE: `bump` and `remove` only. `add` builds its path from checked
   values; `siblings` and the validator only read the row.

TESTS (`tests/test_family_member_path.py`, in `test_family.py`'s family and
the offline environment of `test_family_add_refusals.py`: members from local
bare remotes, a `gh` shim that logs and exits 97, an isolated git
configuration).

- `remove` and `bump` with Atlas's row hand-edited, uncommitted, to
  `members/*`, `.`, the neighbour's path, the member's own path with a
  trailing slash, and the member's path in another case: exit 2, the
  refusal text, `git ls-files -s -z` and `git status --porcelain=v1 -z`
  byte-identical, HEAD unchanged, the neighbour's gitlink, row and checkout
  still there, no `gh` call.
- The same refusal with `subprocess.run` forbidden, so it is made before any
  git call and not merely before any write.
- `bump` with a literal `members/*` checkout holding a `.git` refuses and
  leaves that clone's HEAD where it was (skipped on Windows, which refuses
  `*` in a file name; every other row needs no such directory).
- One expectation moves in `tests/test_family_add_refusals.py`: the failed
  `git commit` an unwound `add` shows is now `git --literal-pathspecs commit
  ...`, because `_commit_one` is shared and `add`'s paths are literals either
  way.
- The controls, the row the tool writes and a row with no `path:`, still exit
  0 in ONE commit of exactly the paths each verb is for, and validate.
- Each of the four git calls, with the glob `members/*`, reaches the
  neighbour without `--literal-pathspecs` (the premise) and does not with it.
- Every call that names the path in a real `bump` and a real `remove` carries
  the flag among git's global options, the set of calls checked exactly.

MUTANTS, each run on a scratch copy of the tree.

- Refusal dropped, flags kept: the 10 refusal rows, the no-git-call rows, the
  literal-directory row and the one-definition row fail. `remove` with
  `members/*` still stops, at `deinit`, exit 2, nothing written; but `path: .`
  still dies on the `FileNotFoundError` with the whole tree staged for
  deletion, and `bump` with the literal `members/*` clone still exits 0 with
  the wrong commit, so the flags are no substitute for the refusal.
- Refusal and every flag dropped (what main did): `remove` with `members/*`
  exits 0 with one commit whose `git show --stat` names `members/IRRS` and
  `members/IRSS`, 18 deletions, one row dropped.
- Refusal dropped and `git rm`'s flag dropped alone: `deinit` stops the run
  first, so that flag is unreachable end to end. With `deinit` stubbed out to
  reach it, `git rm` without its flag stages the deletion of BOTH members
  (the literal `commit` then stops the commit); with it, exit 2 and the index
  is clean.
- Each flag dropped alone (`deinit`, `rm`, `diff`, `commit`), refusal kept:
  the every-call test fails, and only it.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ose `project:` is `.` or `..`, because its `path:` is then equal to `members/<project>` and passed the comparison, so a hand-edited `project: .` with `path: members/.` no longer makes `remove` unmount every member in one commit and `project: ..` no longer stages the holder's own files for deletion (#299)

The first commit of #299 refuses a row whose `path:` is not exactly
`members/<project>`. A row hand-edited in BOTH fields, `project: .` and
`path: members/.`, or `project: ..` and `path: members/..`, is equal to the
path the tool computes for it, so it passed, and `remove` acted on a path
that is not a member:

- `project: .`: `remove --member .` exited 0 with ONE commit that deleted
  both members' gitlinks, `.gitmodules` and the manifest (18 deletions),
  because `git rm -r -f -- members/.` is the members directory itself. The
  literal-pathspecs flag does not help: a path that is a real directory is
  the same path literally.
- `project: ..`: `git rm -r -f -- members/..` removed the holder's own files
  from the index, then `write_members` died on the `FileNotFoundError` of the
  `family.yaml` it had just deleted, exit 1, the tree staged for deletion.
- `bump` was stopped by accident, not by design: `member-uninitialized` for
  `members/.`, and a `fatal: reference is not a tree` from the `git checkout`
  it ran in the holder's own root for `members/..`.

`add` cannot write either row: it refuses the mount path as
`member-path-taken` (probed: `add --member InkRouter/..` and `/.`), and
`cmd_add` does not call `_member_path`, so `add`'s path and its refusal code
are as they were.

`_member_path(project, row)` now also refuses, under the same code, a row
whose project is not a single directory name directly under `members/`:
`.`, `..`, the empty name, or one with a `/`. The refusal says why, that the
path would be `members/.` (the members directory itself) or `members/..` (the
holder's root), not a member, and that no git command was run and nothing was
written; its remediation says the tool never writes such a row, so restore
`family.yaml` from git or remove the row by hand. The clause is made only when
a row is given, so `_member_row`, which is what `add` writes, behaves as it
did. `bump` and `remove` reach it through the call the first commit made;
nothing in either verb changes.

`MEMBER_RE` is NOT changed. It lives in `scripts/family.py` (line 104) and is
read once, by `_checked_member`, which only `cmd_add` calls; `bump` and
`remove` take `--member` through `checked_value` and `repo_basename` and never
meet it, so tightening it would not have reached them. The validator's own
`QUALIFIED_RE` (`templates/family-root/scripts/validate-family.py`, copied
into every holder) already requires each segment to start with a letter or a
digit.

TESTS (`tests/test_family_member_path.py`): `remove` and `bump` against
`project: .` with `path: members/.` and `project: ..` with `path:
members/..` exit 2 with the refusal text, `git ls-files -s -z` and `git
status --porcelain=v1 -z` byte-identical, HEAD unchanged, both members'
gitlinks, rows and checkouts intact, and no `gh` call; the definition's own
test covers `.`, `..`, the empty name and `a/b`, with and without a `path:`,
and that no row means no refusal.

MUTANT, the new clause dropped on a scratch copy: the four new rows and the
definition's test fail, and the probe's wrong outcomes come back. `remove
--member .` exits 0 with one commit deleting `members/IRRS` and
`members/IRSS`, `.gitmodules` and the manifest's rows (4 files, 18
deletions); `remove --member ..` dies on the `FileNotFoundError` with the
holder's files staged for deletion; `bump` falls through to the two
accidents above.

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

sourcery-ai Bot commented Oct 12, 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 21 hours and 32 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

Bump and remove now reject hand-edited or unsafe member paths before any Git or filesystem action, and pass member paths to every relevant Git command literally; extensive tests verify safety, unchanged valid behavior, and protection against glob and directory traversal effects.

Sequence diagram for safe member path validation before bump or remove

sequenceDiagram
    participant User
    participant Family as family.py
    participant Git
    participant Filesystem
    User->>Family: bump or remove
    Family->>Family: _member_path(project, row)
    alt path or project is unsafe
        Family-->>User: Refusal(member-path)
        Note over Family,Git: No Git command or write
    else path is members/<project>
        alt bump
            Family->>Filesystem: checkout member
            Family->>Git: git --literal-pathspecs diff
            Family->>Git: git --literal-pathspecs commit
        else remove
            Family->>Git: git --literal-pathspecs submodule deinit
            Family->>Git: git --literal-pathspecs rm
            Family->>Filesystem: remove manifest row
            Family->>Git: git --literal-pathspecs commit
        end
    end
Loading

Flow diagram for literal member path handling

flowchart TD
    A[Read selected member row] --> B["_member_path(project, row)"]
    B --> C{Exact members/<project> path and safe project name?}
    C -->|No| D[Refuse with member-path]
    C -->|Yes| E[Use members/<project>]
    E --> F[Pass path to Git with --literal-pathspecs]
    F --> G[Only selected member is checked, changed, or committed]
Loading

File-Level Changes

Change Details Files
Validate member paths before bump/remove can perform any Git or filesystem mutation.
  • Added a single _member_path helper for the canonical members/<project> path.
  • Rejects mismatched, missing-safe-default exceptions, and unsafe project names such as ., .., empty names, or names containing / with member-path refusals.
  • Applied validation after member lookup in both verbs while preserving add behavior and defaulting rows without path:.
scripts/family.py
Make all Git operations that consume member paths use literal pathspec semantics.
  • Added --literal-pathspecs to bump's diff and commit calls.
  • Added the flag to remove's submodule deinit and rm calls.
  • Updated the shared commit-command expectation for add-related refusal tests.
scripts/family.py
tests/test_family_add_refusals.py
Add comprehensive regression coverage for unsafe manifest edits and pathspec handling.
  • Tests verify refusals occur before subprocess execution and leave HEAD, index, worktree, rows, and checkouts unchanged.
  • Covers glob, dot, dot-dot, neighbour, trailing-slash, case-variant, and missing-path scenarios for bump and remove.
  • Proves literal pathspec behavior for each affected Git call and confirms valid controls still commit the intended paths.
  • Adds Windows-aware coverage for a literal members/* checkout and verifies the shared path definition.
tests/test_family_member_path.py

Assessment against linked issues

Issue Objective Addressed Explanation
#299 Make bump and remove refuse rows whose path: does not exactly equal members/<project>, before any Git command or filesystem mutation, consistent with the existing member-path validation rule. ✅
#299 Prevent unsafe project names such as ., .., empty names, or names containing / from being used to derive paths that could target the members directory or holder root. ✅
#299 Pass member paths to every relevant Git operation as literal pathspecs, including staging/diffing, submodule deinitialization, removal, and committing, and add regression coverage for the dangerous glob and traversal cases. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

…ad of the global `--literal-pathspecs` so the holder's commit hooks no longer inherit `GIT_LITERAL_PATHSPECS`, the `.`/`..` refusal names a remedy that can work, `cmd_add` takes its path from `_member_path`, and `add` refuses a `.` or `..` segment as `member-malformed` so `add --member <org>/..` on a fresh holder no longer tells the human to stage the deletion of every file (#299)

The review of 58aa4e3 found no MUST and two SHOULDs, and the coordinator ruled
those, the one nit that matters and an out-of-scope finding in.

1. THE COMMIT'S PATHS ARE `:(literal)`, NOT GLOBAL. `git --literal-pathspecs`
   works by putting `GIT_LITERAL_PATHSPECS=1` in git's environment, and every
   child of `git commit` inherits it: the holder's `pre-commit`, `commit-msg`
   and `post-commit` hooks, its own or the operator's `core.hooksPath`. On git
   2.43 a hook that picks the staged files with a glob
   (`git diff --cached --name-only -- '*.yaml'`) saw `family.yaml` on a plain
   commit and NOTHING under the flag, so a linter behind it checked nothing on
   every `add`, `bump` and `remove` commit. `_commit_one` now passes
   `-- :(literal)<path> ...` on the commit, and no global option. Proved on
   2.43.0 on the form `git commit -q -F - -- <paths>`: `:(literal)members/*` is
   refused ("did not match any file(s)") while a neighbour's gitlink is staged,
   a path `git rm` has taken out of the index and the tree still commits (HEAD
   has it), and the hook sees the variable unset. `deinit`, `rm` and `diff`
   run no hooks and keep the global option; `force_stage`'s `add`, which is
   shared, is untouched. The failed commit an unwound `add` shows is now
   `git commit -q -F - -- :(literal).gitmodules :(literal)members/IRQS
   :(literal)family.yaml`, and `test_family_add_refusals.py` pins that.
2. THE `.`/`..` REFUSAL'S REMEDIATION. "Run the same command again" cannot
   succeed after either remedy (`--member .` then names no row), and "remove
   the row by hand" leaves the member's gitlink with no row, which the
   validator and the doctor both pass. It now says: restore `family.yaml` from
   git (`git checkout -- family.yaml` if the edit is not committed), or put the
   row's `project:` and `path:` back as the tool wrote them, then run the
   command again naming that member.
3. ONE DEFINITION. `cmd_add` spelled `members/<project>` itself, so the
   definition had two spellings in one file. It calls `_member_path(project)`
   now, with no row: the same string, the same behaviour.
4. `add --member <org>/..` ON A FRESH HOLDER. `MEMBER_RE`'s class admits the
   dot (for `.github`), so `<org>/..` matched, and its mount path `members/..`
   is the holder's root. With no `members/` directory, `_checked_mount_path`
   found the root's own files "in the index" under it and printed the
   remediation `git rm -r --cached -- members/..`, which stages the deletion
   of EVERY file the holder has (the reviewer pasted it on a scratch copy: 15
   deletions). `_checked_member` now refuses a segment of `.` or `..`, owner or
   project, as `member-malformed`, add's existing argument refusal, before the
   member is read or anything is mounted, and its message says a directory
   reference is not a name and where the project would mount. `MEMBER_RE` (in
   `scripts/family.py`, read only there) is left as it was: `.github` stays
   legal. The `_member_path` docstring no longer says `add` refuses these as
   `member-path-taken`; that held only once `members/` existed.

TESTS (`tests/test_family_member_path.py`, 28 -> 40 in the file).

- A holder with a `pre-commit` hook that writes `GIT_LITERAL_PATHSPECS` and
  the staged files a `'*.yaml'` glob finds: through a real `add`, `bump` and
  `remove` it sees the variable unset and `family.yaml`.
- The every-call recorder test now asserts `:(literal)` on each of the
  commit's paths, and the global option's absence, for the commit, and the
  global option on the other calls; the glob-reaches-a-neighbour test runs the
  commit in its magic form.
- `add --member` of `<org>/..`, `<org>/.`, `../<Project>` and `./<Project>` is
  refused `member-malformed` on a fresh holder (no `members/`) and on one with
  members: exit 2, nothing on stdout, no clone, no `gh` call, the holder byte
  for byte as it was; names with a dot inside (`.github`, `a.b`) still pass.
- The dot refusal's remediation text is pinned.

MUTANTS, each on a scratch copy.

- The global flag back on the commit: the hook test fails for all three verbs
  (the hook logs `literal=1` and its glob finds nothing), both every-call
  tests fail, and the pinned commit line in `test_family_add_refusals.py`
  fails; unmutated, all pass.
- The `add` dot-segment refusal dropped: the eight `member-malformed` rows
  fail, and the fresh holder's `add --member InkRouter/..` prints the
  `git rm -r --cached -- members/..` remediation again.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rom its commit's environment, because that variable turns off the pathspec magic `:(literal)` is, so a caller who exports it no longer fails every `add`, `bump` and `remove` commit with `pathspec ':(literal)family.yaml' did not match` (#299)

The re-check of 2f04085 found that `:(literal)` works only while pathspec magic
is on. `GIT_LITERAL_PATHSPECS=1` turns magic off, so with it in the caller's
environment git read `:(literal)family.yaml` as a file of that name, found
none, and exited 1. `_commit_one` copies `os.environ` into the commit's
environment, so the caller's value reached git:

- `add` exited 2 with the failed commit shown, and unwound the mount;
- `bump` and `remove` exited 2 with no rollback: the rewritten pin staged and
  the member checked out at `--to`, or the unmount and the dropped row staged
  and no commit;
- main and 58aa4e3, whose commit used the global option, exited 0 in that
  environment, so this was a regression against both.

Who has the variable set: a person or script that exports it as a safety
default, or any `family.py` run from a hook or alias of a `git
--literal-pathspecs` command, which exports it to its children.

The fix is one line, `env.pop("GIT_LITERAL_PATHSPECS", None)`, on the
environment `_commit_one` already builds for its commit, and only there. The
commit's paths are literal by their `:(literal)` magic and need nothing from
the environment; the hooks it runs then see the variable unset, as the hook
test of the previous commit pins. The other pathspec variables
(`GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS`, `GIT_ICASE_PATHSPECS`) leave
magic on and are not touched; the reviewer found each harmless on 2.43.

Wording: the `_commit_one` docstring said `deinit`, `rm` and `diff` "run no
hooks". `git rm`, like `force_stage`'s `git add`, writes the index and so runs a
`post-index-change` hook, which still sees `GIT_LITERAL_PATHSPECS=1`; it now
says "no COMMIT hooks" and names that hook. The unused `global_options` helper
in `tests/test_family_member_path.py` is removed.

TEST (`tests/test_family_member_path.py`, 40 -> 43): with
`GIT_LITERAL_PATHSPECS=1` exported to the run, `add`, `bump` and `remove` each
exit 0 in ONE commit of exactly the paths each is for, leave nothing staged
behind (`git status --porcelain=v1 -z` empty), and the holder validates.

MUTANT, the `env.pop` dropped on a scratch copy: the three new rows fail with
`error: pathspec ':(literal)family.yaml' did not match any file(s) known to
git` on `git commit -q -F - -- :(literal)...`, exit 1; the other 40 pass. With
the pop, all 43 pass.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit c7c02fa into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/family-member-path-literal branch October 12, 2026 11:01
brettheap added a commit that referenced this pull request Oct 12, 2026
…s `_entry_findings` order sentence composed with #255's

#313 (71b58a1, #262 and #263) makes `check` find a kept root path where
the shape writes a directory or its `shape/` copy, through
`_root_shape_findings` called from `_leg_findings`, and makes
`shape_materialize` refuse a copy whose own path is taken. This branch
appends each value `execute` writes into a file that no file can hold
to `_entry_findings`. The one conflict is that function's docstring, the
sentence naming the order of its findings: it keeps #313's words (each
entry's `leg:`, the two mount paths and what the root keeps where the
shape writes, then the source's own submodules) and appends this PR's
clause, ", then each value `execute` writes into a file that no file can
hold (#255)". Nothing else in the file conflicts: the return still reads
`(findings + submodule_findings + unwritable_findings,
  unsafe_remediation + remediation + unwritable_remediation)`, and
`_leg_findings` carries #313's findings inside `findings`.

Main's c0ed615 (#308, family.py) comes with it and touches none of this
branch's files. Main has since moved on to c7c02fa (#315, family.py);
this merge stops at 71b58a1.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…mily-add-ghost-section, keeping both appended test blocks

Both reviews tested this union: main's #302 block in full, then #317's #300 block after it at the end of tests/test_family_add_refusals.py.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
brettheap added a commit that referenced this pull request Oct 12, 2026
Main brings two commits since 71b58a1:

- c7c02fa (#299 via #315): family.py's `bump` and `remove` refuse
  `member-path`;
- 346079d (#216 via #228): `plan`'s follow-up `git grep` takes each name
  literally, and `_stage_the_split` adds with `git --literal-pathspecs`.

It merged with no conflict. adopt-project.py and
tests/test_adopt_ignored_shape_files.py changed on both sides. In each,
the merge's diff against this branch is main's own diff (72 and 4
lines), and its diff against main is this branch's own diff (784 and 16
lines). Every other file is one side's.

Main's hunks in adopt-project.py, by where they land:

1-7. `cross_leg_references`: names are literal (#216); the spec
     directory and each hit are spelled; `git --literal-pathspecs grep`;
8. after it, the new `_search_by_hand`;
9-10. `_stage_the_split`: its docstring, and `git --literal-pathspecs
      add -f`.

Main's hunk in tests/test_adopt_ignored_shape_files.py is in
`test_the_forced_paths_are_the_written_ones_in_posix_spelling`, which
this branch does not change: the forced add is
`git --literal-pathspecs add -f`.

This branch changes none of these definitions. `_stage_the_split` runs
inside `_commit_the_split`, after both legs are pushed, where this branch
already reports any failure with the notice. No top-level name is bound
twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, scripts/family.py or tests/conftest.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement

Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1,
merged by sha. No rebase. Among the files this branch changes:
346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260)
changes scripts/shape_materialize.py; 87373a6 (#306) changes
shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes
AGENTS.md and tests/test_repo_hygiene.py.

Two conflicts, each resolved keeping both sides:

- adopt-project.py, the module docstring's `execute` paragraph. #243 ends
  "the root's only submodules are the two leg mounts, each at the commit
  pushed to its leg."; this branch follows "two leg mounts." with "The
  split's `project.yaml` records the leg each source submodule kept in a
  leg was answered into, under `submodules:`, for `shape-doctor.py` to
  check it against." The merge has #243's sentence, then this branch's,
  re-wrapped to the paragraph's width. The module docstring is not in
  `--help` (docs/cli.md holds neither sentence).
- tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`.
  Each side moved the AGENTS.md cap from 464 and wrote a paragraph
  saying why: #288 to 477, for thirteen lines, and this branch to 472,
  for eight. AGENTS.md itself merged cleanly and holds both: 485 lines,
  main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph
  word for word, then this branch's, renumbered "2026-10-12: 477 -> 485"
  because it now follows #288's, and asserts `<= 485`.

`git merge-tree --write-tree` of the two parents gives the same tree in
every file but these two.

Checked:

- adopt-project.py: `_commit_the_split` was changed by both sides, in
  hunks that do not touch. #243 clones the assembly with `--template=`;
  this branch builds `append` with the `submodules:` record. The merged
  function holds both, and each side's text is unchanged. Every other
  definition one side changed is that side's text.
- shape-doctor.py: #306 changes `validator_row` and `run_validator`.
  This branch's placement functions, F1's
  `gitmodules_the_record_answers` among them, are this branch's text.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py,
  scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py,
  update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged
  module's names are the base's plus what either side added.
- AGENTS.md is main's plus exactly this branch's changed lines.
- The branch's diff against main is #189 alone: 12 files, +1694 -157. That
  is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line
  the docstring re-wrap adds.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…rints the `old -> new` row and its trailers without checking the member out or rewriting `family.yaml`, and `remove --dry-run` prints the row, gitlink, `.gitmodules` section and directory it would remove and changes nothing, so the module's `EXIT CODES` line is true of both (#326) (#345)

`bump`, `add` and `remove` took no `--dry-run`, though the module's exit-code
line said "1 nothing (a dry run prints and exits 0)", which was true of `init`
and `siblings` only, and `bump-leg.py` has one. Seeing what a bump would pin
meant running it: fetch, `git checkout` in the member, rewrite `family.yaml`,
stage. Even #210's `nothing to do` cost the fetch and the checkout.

The coordinator's rulings, as implemented:

- `bump --dry-run` leaves after the fetch and before the checkout, the real
  run's first write, so every refusal made before that point is the same
  refusal, exit 2: the row, `--to`'s form, #315's `member-path` and
  `member-uninitialized`. It fetches, as the real run does. A fetch writes
  into the member's object store, which nothing the holder records, and the
  help and the docstring say so. After a failed fetch the closing line says
  `--to` was resolved from what the object store already held. `--to` is
  resolved as `git checkout` takes it (`^{commit}`), and a commit the member
  lacks is exit 2. The questions the real run asks after its checkout are
  also asked, of the member's objects: `project.yaml` through the same
  `_read_member_project` (`_member_id_at`), and `family.rewritten`. The
  digest is `tree_digest`, which reads objects, so it is the real run's. It
  prints `commit <old> -> <new>` and `tree`, `bump-leg.py`'s layout, then the
  gitlink `old -> new` and the whole row as `family.yaml` would carry it. The
  row comes from `_bumped_rows`, which the real run also calls. Exit 1
  follows the real run's #210 rule, asked without the write: HEAD's gitlink
  is `--to`, and the rewritten manifest hashes (`git hash-object --path`, no
  `-w`) to HEAD's blob. A symlinked `project.yaml` is left to the real run,
  and the dry run says so. A member's local changes that `git checkout` would
  refuse are not asked, and the docstring says so.
- `remove --dry-run` leaves after `member-path` and before `git submodule
  deinit`. `ls-files --error-unmatch` over the same literal pathspec fails
  where `deinit` fails, on git's same line. It is printed as a failed git
  command is printed, exit 2, never with `repo_shape`'s leg remediation; the
  dry runs ask git through `_git_bytes` for that reason. It asks
  `family-members-unrecognised` and prints the row, the gitlink, the
  `.gitmodules` section and the `.git/config` registration, and the
  directory. The section is named by git's own lookup over its work-tree
  reading of the file: a name keeps its last `path`; a name moving away
  takes the old path out of the path map; suspicious names are ignored.
  Every section of the name found is dropped. Where none is found it says
  none is removed, and it names the sections left that still give the path.
  The registration is printed only where the holder has one under that name.
  An unmerged `.gitmodules`, which only `git rm` refuses, is not asked.
- `--trailer` with `--dry-run` prints the trailers after the row, worded as
  `bump-leg.py` words them, and never for `nothing to do`.
- The `EXIT CODES` paragraph says which subcommands have a dry run, and new
  paragraphs say what each asks and what neither asks. `bump`'s and
  `remove`'s `--help` gained `--dry-run`, and `docs/cli.md` is regenerated.

`add` gets none here. Its dry run follows the three changes to `cmd_add` in
flight (#317, #318, lane 3's #330), and neither `cmd_add` nor any function
only it calls is touched. AGENTS.md is unchanged; a line in its family
section is a follow-up.

tests/test_family_dry_run.py, over `test_family.py`'s local-bare-remote
family, checks "changes nothing" as bytes: the index, `status`,
`family.yaml`, `.gitmodules`, `.git/config`, the member's HEAD, the work tree
and the holder's `.git/objects`. Then the real run writes exactly what the
dry run printed. Covered:
- a bump previewed and then made;
- `--to` the pin;
- both #210 repairs;
- trailers printed and then committed;
- six refusals compared stderr for stderr with the real run;
- a commit the member lacks;
- a failed fetch;
- `member-not-a-project` at `--to`;
- a linked manifest;
- a remove previewed and then made, registration included;
- a gitlink the index does not hold;
- four `.gitmodules` hand edits, each compared with a real remove;
- the unrecognised manifest;
- in process, no git command that writes (`hash-object -w` included).

Mutants, each killed by the test that must catch it:
- the dry-run return moved after the checkout (`member HEAD changed`);
- `write_members` before the return (`family.yaml changed`);
- remove's return after `git rm` (`index changed`);
- no `--error-unmatch` (exit 0 where 2);
- `hash-object -w`;
- a name keeping its first path;
- a suspicious name read like any other.

Part of #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py

origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the
coordinator asked for e43fa95 or later:
- c7c02fa (#299 via #315), scripts/family.py;
- 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up
  `git grep`;
- 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render`
  substitutes in one pass;
- 86da808 (#253 via #264), scripts/bump-leg.py;
- 6f4ce47 (#302 via #318), scripts/family.py;
- 7491530 (#288 via #309), AGENTS.md, README, docs and
  scaffold-project.py's --help;
- 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default
  identity as `-c` config;
- 87373a6 (#306 via #311), shape-doctor.py;
- 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py;
- c1b6732 (#239 via #269), update-shape.py;
- e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned
  with `--template=`, and the verification refuses a mount whose gitlink
  is not the commit pushed to its leg (`adopt-mount-commit`);
- a1b3a4b (#321 via #335), tests/test_git_output_decoding.py;
- 8cdca5e (#295 via #312), the template's scripts/validate-pins.py.

`git merge-tree` and this merge give the same tree, 07e6b49.
adopt-project.py, scripts/shape_materialize.py and
tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these
main's 346079d and this branch's 86a5e30 change different cases.

Checked on the merged tree:
- AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Every sentence 86a5e30 changed is still there: in `_through`, in
  `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's
  `adopt-mount-commit` leaves as it was), and in the three test files.
- `_commit_the_split` now clones the assembly with `--template=`. The
  next commit, F3, reads what that clone holds in its own `.git`, which
  is why F3 comes after this merge.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
… begin with a letter or a digit as `member-malformed` before the member is read, so `testorg/_Zed`, `testorg/.Zed`, `testorg/-Zed`, `_org/Zed` and `.org/Zed` no longer exit 0 with a commit that the holder's own `validate-family.py` fails as `member-repository` (#366) (#399)

`MEMBER_RE` and the validator's `QUALIFIED_RE` shared one character class;
the validator also requires each segment's first character to be a letter or
a digit. `add` writes `--member` verbatim as the row's `repository:`, so a
name the looser pattern admitted was mounted, pinned and committed with
exit 0, and the gate the holder ships then failed the commit `add` had just
reported.

`MEMBER_RE` now takes `QUALIFIED_RE`'s shape, refused as `member-malformed`
before the member is read. `_checked_member`'s `.`/`..` clause is kept for its
more specific sentence and read first, so it is still reachable. The rule is
pinned rather than moved: `family.py` spells it a third time, because
`repo_shape.py` is a copied file and a constant moved there would reach an
existing holder only through `update-shape.py`, and
`tests/test_family_member_name.py` reads every spelling and fails when they
differ. The validator's rule is unchanged. The general sentence states the
rule, and `--member=-org/Zed`, once refused later as `unsafe-value`, is
`member-malformed` now.

This narrows what #299 (PR #315) deliberately left alone: its comment kept
`MEMBER_RE`'s class loose 'for `.github`', and its writer named this
tightening as the follow-up. `testorg/.github`, and an owner of `.github`,
are refused now; the validator refused both already, so no holder could keep
such a row.

Closes #366

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