diff --git a/AGENTS.md b/AGENTS.md index 207bdd26..ee82ff3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,8 +292,7 @@ git -C push -u origin shape/update- # then open a pull request `upstream-removed`, `unmapped` or `copy-missing`; `apply` refuses the last three. A file with no row is not a shape copy and is none of your business. 2. **Show the human the verdicts and let them say yes.** Re-pinning records - which openRepoShape this project is a copy of. Do not pass `--yes` on your - own initiative. + which openRepoShape this project is a copy of. Never pass `--yes` yourself. 3. **Never pass `--accept-local` to make a refusal go away.** It re-pins a locally edited file FROM THE PROJECT'S OWN BYTES, which makes the drift `validate-pins.py` reports today invisible. Whether the project keeps its @@ -368,8 +367,7 @@ git -C push -u origin # then open a pull request A FAMILY is a holder that pins other projects' ASSEMBLY ROOTS as submodules under `members/`. It is not a project — no legs — and membership confers -nothing. Use one when the parts DEPLOY SEPARATELY; one project is what parts -that ship together already are. +nothing. Use one when the parts DEPLOY SEPARATELY. ```sh python3 scripts/family.py init --org --family --into .. [--reuse-empty-repo] @@ -393,13 +391,15 @@ python3 scripts/family.py siblings --family-root deliberate and reads like a mistake until somebody explains it. 2. **A family pins ASSEMBLY ROOTS, never legs.** `--member /`, never `-spec`: a leg has no `project.yaml`, and is rightly refused. + A holder keeps `members/` in ONE spelling on every disk: `add` refuses + `family-members-dir-variant` (`member-path-taken` for a variant of the + member's own path) until a human renames what sits under another spelling. 3. **Each of `add`, `bump` and `remove` writes ONE commit** moving the gitlink and the pin together, on a branch you make first (see above): the tracking branch is refused (`family-on-tracking-branch`). Push the branch as its `NEXT` line says and land it as a pull request; never suggest a push to the default branch. Never hand-edit `family.yaml`'s `members:` - block — the tool rewrites it wholesale. `bump` takes the same repeatable - `--trailer ": "` as `update-shape.py apply`. + block — the tool rewrites it wholesale. 4. **The member must be scaffolded or adopted FIRST.** A family cannot make a project out of a repository; it can only pin one that already is. 5. **`make siblings` in the holder — or `family.py siblings --family-root diff --git a/README.md b/README.md index f0761220..f87d91a8 100644 --- a/README.md +++ b/README.md @@ -883,10 +883,9 @@ is reviewed identically to one in this family. | what the holder owns | the legs | nothing but pins and utilities | **Use a family when the parts deploy separately.** Eight services that are -eight APIs are eight projects: folding them into one assembly root would give -them one gate, one release and one pin, which is the opposite of what "deploy -separately" means. **Use one project when the parts ship together** — that is -what the spec and code legs already are. +eight APIs are eight projects: one assembly root would give them one gate, one +release and one pin, the opposite of "deploy separately". **Use one project +when the parts ship together**: that is what the spec and code legs are. ### The workstation layout: a plain folder, and two copies of every member @@ -904,13 +903,13 @@ InkRouter/ a PLAIN FOLDER, not a git repository ``` `scripts/family.py init` lands the holder there: `--into ` is the PARENT -directory (the directory you are standing in, by default), the folder +directory (the directory you are standing in, by default; refused inside a +checkout of this standard, `family-landing-in-standard`), the folder `/` is created in it, and the holder is cloned INSIDE that at -`//` — the same "lands where you were standing" rule -projects have. Standing in the family folder already, it does not nest a -second one. `--work-dir` remains the override that lands `/` -and makes no folder. The doubled name is kept on purpose: the holder keeps its -own repository name and `family.yaml` is what tells the two apart. +`//`. Standing in the family folder already, it does not +nest a second one. `--work-dir` is the override that lands +`/` and makes no folder. The doubled name is on purpose: the +holder keeps its own repository name, and `family.yaml` tells the two apart. `make siblings` in the holder — or `python3 scripts/family.py siblings --family-root `, the same file through the other entry point — clones @@ -923,17 +922,17 @@ inside the holder is pinned and DETACHED, and is what `bootstrap` and **fetched and nothing else** — no checkout, no reset, no pull — and one that is a different repository is reported and skipped. It is idempotent. -**Nothing ever moves a checkout.** When the holder's parent folder is not -named after the family, `make siblings` WARNS and prints the exact `mv` for a -human to run. A tool that relocated the directory would do it out from under -the shell, the editor and every agent lane standing in it, and would break -linked worktrees, whose `.git` files carry absolute paths — an act by -surprise, which is the one thing every refusal here exists to avoid. +**Nothing ever moves a checkout**: a linked worktree's `.git` file carries an +absolute path. The holder's parent folder must be named EXACTLY after its +repository, case included, on every disk. When it is not, and no link of that +name beside it leads to it, `make siblings` WARNS, prints the exact `mv` for a +human to run and clones nothing until it is run, so the next run clones each +member once. For a holder straight in a folder (`~/projects/InkRouter`) the +move makes the family folder `~/InkRouter`, beside `~/projects`, and the holder +`~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`. ### The InkRouter example -Eight services, each its own project, one holder: - ``` InkRouter/ the family FOLDER (see the layout above) InkRouter/ the family holder — family.yaml, members/, utilities @@ -957,13 +956,14 @@ branch made first (the tracking branch is refused) and print its push: the gitlink and `members[].pin` move together, the lockstep rule an assembly root applies to its legs, which `scripts/validate-family.py` enforces. Each row records the member's `project.yaml` `id`, so the validator can check -`members/` holds the project the row claims, not merely a repository -at the right commit. **A family pins assembly roots, never legs**: -`-spec` is refused; a leg has no `project.yaml`. - -`make bootstrap` in the holder fetches every member and its legs — resolving -the GitHub App or `SHAPE_LEGS_TOKEN` credential the workflow does, as a member -is a private assembly root mounting two private legs — then runs each member's +`members/` holds the project the row claims. **A family pins assembly +roots, never legs**: `-spec` is refused; a leg has no `project.yaml`. +A holder keeps `members/` in ONE spelling on every disk: `add` refuses +`family-members-dir-variant` (`member-path-taken` for a variant of the member's +own path) until a human renames what sits under another spelling. + +`make bootstrap` in the holder fetches every member and its legs, with the +workflow's GitHub App or `SHAPE_LEGS_TOKEN` credential, then runs each member's own `make bootstrap`; `make validate` runs the family validator, then each member's. `make bootstrap` reports and skips one it lacks a credential for. @@ -972,12 +972,11 @@ The holder carries the same COPY pin an assembly root does, so instead of `project.yaml` and must leave `validate-family.py` green. The name is the `family` form in `contracts/repository-naming.yaml`: one -CamelCase token, exactly an assembly root's rule, with **precedence below** -it, and DECLARED-ONLY. `InkRouter` is spelled like an assembly root and +CamelCase token, exactly an assembly root's rule, with **precedence below** it, +and DECLARED-ONLY. `InkRouter` is spelled like an assembly root and `family.yaml` is the only thing that tells them apart, so the classifier reports the holder form only when it is asked for (`--role family`). That is -what let a fifth family be added to a policy two live projects already carry a -copy of without changing any existing name's answer. +what let a fifth family be added without changing any existing name's answer. ## The double pin, and the lockstep invariant diff --git a/docs/handbook.html b/docs/handbook.html index fadb789d..76b5ad83 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1325,7 +1325,7 @@

Adopting an existing repository

- +

Reference

Families: a holder for projects that ship separately

@@ -1362,7 +1362,8 @@

The workstation layout: a plain folder, and two copies of every member

IRRS-worktrees/… whatever the member's own work needs session-handoff-*.md the estate's own files live in the folder

scripts/family.py init lands the holder there: --into <dir> - is the PARENT directory (the directory you are standing in, by default), the folder + is the PARENT directory (the directory you are standing in, by default; refused inside a + checkout of this standard, family-landing-in-standard), the folder <Family>/ is created in it, and the holder is cloned INSIDE that at <into>/<Family>/<Family> — the same “lands where you were standing” rule projects have. Standing in the family folder already, it does not @@ -1381,12 +1382,18 @@

The workstation layout: a plain folder, and two copies of every member

follows it. An existing clone is fetched and nothing else — no checkout, no reset, no pull — and one that is a different repository is reported and skipped. It is idempotent.

-

Refuses Nothing ever moves a checkout: when - the holder’s parent folder is not named after the family, make siblings - WARNS and prints the exact mv for a human to run. A tool that relocated the - directory would do it out from under the shell, the editor and every agent lane standing in - it, and would break linked worktrees, whose .git files carry absolute paths - — an act by surprise, which is the one thing every refusal here exists to avoid.

+

Refuses Nothing ever moves a checkout: the + holder’s parent folder must be named EXACTLY after its repository, case included, on + every disk. When it is not, and no link of that name beside it leads to it, + make siblings WARNS, prints the exact mv for a human to run and + clones nothing until it is run, so the next run clones each member once. For a holder + straight in a folder (~/projects/InkRouter) the move makes the family folder + ~/InkRouter, beside ~/projects, and the holder + ~/InkRouter/InkRouter, not ~/projects/InkRouter/InkRouter. A tool + that relocated the directory would do it out from under the shell, the editor and every + agent lane standing in it, and would break linked worktrees, whose .git files + carry absolute paths — an act by surprise, which is the one thing every refusal here + exists to avoid.

Ruled by Brett Heap on 2026-09-09 (issue #76): the plain folder, and the holder inside it at <Family>/<Family>.

@@ -1414,6 +1421,10 @@

The InkRouter example

Refuses A family pins assembly roots, never legs: adding <Project>-spec is refused, because a leg has no project.yaml and belongs to its own root.

+

Refuses A holder keeps members/ in ONE spelling on + every disk: add refuses family-members-dir-variant + (member-path-taken for a variant of the member’s own path) until a human + renames what sits under another spelling.

make bootstrap in the holder fetches every member and its legs — resolving the same GitHub App or SHAPE_LEGS_TOKEN credential the workflow does — and then runs each member’s own make bootstrap. diff --git a/templates/family-root/AGENTS-shape.md b/templates/family-root/AGENTS-shape.md index a9e740e4..fbc984d9 100644 --- a/templates/family-root/AGENTS-shape.md +++ b/templates/family-root/AGENTS-shape.md @@ -40,7 +40,10 @@ commit as a pull request: each refuses this holder's tracking branch (`family-on-tracking-branch`) and names the `git switch -c` that makes a branch, as the block's first line does, and ends by printing that branch's `git push`. The member must already be scaffolded or adopted, because a family -cannot make a project out of a repository. +cannot make a project out of a repository. A holder keeps `members/` in ONE +spelling on every disk: `add` refuses `family-members-dir-variant` +(`member-path-taken` for a variant of the member's own path) until a human +renames what sits under another spelling. `bump` takes a repeatable `--trailer ": "`, appended to the commit it writes in the order given. It composes that message itself, so whatever diff --git a/templates/family-root/README.md b/templates/family-root/README.md index e0150475..9bfa6aa9 100644 --- a/templates/family-root/README.md +++ b/templates/family-root/README.md @@ -127,7 +127,10 @@ python3 scripts/family.py remove --family-root --member Each writes ONE commit, with explicit pathspecs, moving the gitlink and the pin together, on the branch the holder stands on: it refuses the tracking branch, so make a branch first, as the block's first line does. Push it as its -`NEXT` line says and land it as a pull request. +`NEXT` line says and land it as a pull request. A holder keeps `members/` in +ONE spelling on every disk: `add` refuses `family-members-dir-variant` +(`member-path-taken` for a variant of the member's own path) until a human +renames what sits under another spelling. ## Private members in CI diff --git a/tests/test_family_docs_one_spelling.py b/tests/test_family_docs_one_spelling.py new file mode 100644 index 00000000..eced3460 --- /dev/null +++ b/tests/test_family_docs_one_spelling.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family docs say what `init`, `make siblings` and `members/` do (#695). + +Three sentences had fallen behind the tools, and one rule had no sentence: + +- `init` lands where you stand, and refuses that default inside a checkout of + this standard (`family-landing-in-standard`, #488). README and the + handbook gave the default and not the refusal. +- `make siblings` stops before it clones anything, and a family folder + reached through a link of the family's name passes (#578). README and the + handbook still said it WARNS and prints the `mv`. They now also say the + folder is named EXACTLY after the repository on every disk, and where the + printed move puts the family folder for a holder straight in a folder. +- A holder keeps `members/` in ONE spelling on every disk, and `add` + refuses `family-members-dir-variant` (`member-path-taken` for a variant of + the member's own path) until a human renames the entries (#574). No doc + said so. + +THE SAME WORDS IN FIVE PLACES. The one-spelling sentence is the same text in +AGENTS.md, README.md, the handbook, the holder's README and the holder's +pinned `AGENTS-shape.md`, so a reader meets one rule however they came. The +handbook spells it in HTML, so it is read as the text a browser shows, with +`` as backticks. + +The printed move itself is pinned by `test_family_siblings_placement.py` +(`~/projects/InkRouter` becomes `~/InkRouter/InkRouter`): this file holds the +sentences that describe it. +""" + +from __future__ import annotations + +import ast +import html +import re + +import pytest + +from conftest import REPO + +ONE_SPELLING = ( + "A holder keeps `members/` in ONE spelling on every disk: `add` refuses " + "`family-members-dir-variant` (`member-path-taken` for a variant of the " + "member's own path) until a human renames what sits under another " + "spelling.") + +INTO_REFUSED = ( + "(the directory you are standing in, by default; refused inside a " + "checkout of this standard, `family-landing-in-standard`)") + +STOPS_BEFORE_CLONING = ( + "When it is not, and no link of that name beside it leads to it, " + "`make siblings` WARNS, prints the exact `mv` for a human to run and " + "clones nothing until it is run, so the next run clones each member " + "once.") + +NAMED_EXACTLY = ( + "named EXACTLY after its repository, case included, on every disk") + +STRAIGHT_IN_A_FOLDER = ( + "For a holder straight in a folder (`~/projects/InkRouter`) the move " + "makes the family folder `~/InkRouter`, beside `~/projects`, and the " + "holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.") + +#: What README and the handbook said before #578, which `make siblings` no +#: longer does. +BEFORE_578 = "`make siblings` WARNS and prints the exact `mv`" + +HANDBOOK = "docs/handbook.html" +README = "README.md" +THE_FIVE = [ + "AGENTS.md", README, HANDBOOK, "templates/family-root/README.md", + "templates/family-root/AGENTS-shape.md"] +README_AND_HANDBOOK = [README, HANDBOOK] + + +def shown(path: str) -> str: + """The text a reader sees in `path`, on one line. + + A page is read as a browser shows it: `` is a backtick, any other + tag is nothing, entities are their characters and a curly apostrophe is + a straight one. Every other file is its own text. Runs of white space, + and so every wrap, are one space.""" + text = (REPO / path).read_text(encoding="utf-8") + if path == HANDBOOK: + text = re.sub(r"", "`", text) + text = html.unescape(re.sub(r"<[^>]+>", "", text)) + text = text.replace("’", "'") + return " ".join(text.split()) + + +@pytest.mark.parametrize("path", THE_FIVE) +def test_each_doc_names_the_one_spelling_rule_in_the_same_words(path): + assert ONE_SPELLING in shown(path) + + +@pytest.mark.parametrize("code", ["family-members-dir-variant", + "member-path-taken"]) +def test_the_codes_the_rule_names_are_codes_family_py_prints(code): + source = (REPO / "scripts" / "family.py").read_text(encoding="utf-8") + held = {node.value for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.Constant) and isinstance(node.value, str)} + assert code in held + + +@pytest.mark.parametrize("path", README_AND_HANDBOOK) +def test_the_into_sentence_gives_the_refusal_with_the_default(path): + assert INTO_REFUSED in shown(path) + + +@pytest.mark.parametrize("path", README_AND_HANDBOOK) +def test_make_siblings_is_described_as_it_stops_before_cloning(path): + assert STOPS_BEFORE_CLONING in shown(path) + + +@pytest.mark.parametrize("path", README_AND_HANDBOOK) +def test_the_stale_warns_and_prints_sentence_is_gone(path): + assert BEFORE_578 not in shown(path) + + +@pytest.mark.parametrize("path", README_AND_HANDBOOK) +def test_the_folder_is_named_exactly_after_the_repository(path): + assert NAMED_EXACTLY in shown(path) + + +@pytest.mark.parametrize("path", README_AND_HANDBOOK) +def test_the_move_for_a_holder_straight_in_a_folder_is_one_level_up(path): + assert STRAIGHT_IN_A_FOLDER in shown(path)