diff --git a/README.md b/README.md index f3b378dc..f6171a4c 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,8 @@ the second it refuses, creating nothing, if anything stands at `/Atlas`: ```sh git clone --depth 1 https://github.com/opensoft/openRepoShape.git python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code -python3 scaffold-project.py --org --project Atlas --dry-run +python3 scaffold-project.py --org --project Atlas \ + --visibility private --elected-by 'Your Name' --dry-run python3 scaffold-project.py --org --project Atlas \ --visibility private --elected-by 'Your Name' # or public / internal mkdir -p / # --family only, and skipped when IS @@ -1083,11 +1084,15 @@ file: `apply --at --yes --add AGENTS-shape.md` copies it, chmods it if the materializer would, names it in a root `.gitattributes` block of #471, appends its `sha256` row and moves the pin, all inside the same transaction and rolled back whole if a validator goes red. Without `--add` it says `not added; -pass --add ` and re-pins the rest. `--add` refuses a path that is already -pinned, one the target's lists do not name — and one the root ALREADY has a file -at, because two files with one name is a merge and this command copies bytes. A -project that declines an addition has no way to say so yet: it will be reported -by every `check` until it is taken or a file arrives at that path. +pass --add ` and re-pins the rest. `--add` refuses a path already pinned +(`update-add-already-pinned`), one the target's lists do not name +(`update-add-not-upstream`), one with a symlink at or above it, which is never +written through (`update-add-symlink`), one with a file where a directory above +it goes (`update-add-not-a-directory`) — and one the root ALREADY has a file at +(`update-add-would-overwrite`), because two files with one name is a merge and +this command copies bytes. A project that declines an addition has no way to say +so yet: it will be reported by every `check` until it is taken or a file arrives +at that path. `--upstream` takes a path to a clone (offline, and what the tests use) or an `owner/repo` it bare-clones itself; it defaults to the repository the pin diff --git a/docs/handbook.html b/docs/handbook.html index 383e80e7..79a63b04 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -609,7 +609,7 @@

The one thing to read before electing it

- +

Setup

Start here

@@ -972,7 +972,8 @@

What setup.sh actually runs

it refuses, creating nothing, if anything stands at <into>/Atlas:

git clone --depth 1 https://github.com/opensoft/openRepoShape.git <tmp-dir>
 python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code
-python3 scaffold-project.py --org <your-org> --project Atlas --dry-run
+python3 scaffold-project.py --org <your-org> --project Atlas \
+    --visibility private --elected-by 'Your Name' --dry-run
 python3 scaffold-project.py --org <your-org> --project Atlas \
     --visibility private --elected-by 'Your Name'   # or public / internal
 mkdir -p <into>/<Family>   # --family only, and skipped when <into> IS <Family>
@@ -1021,7 +1022,7 @@ 

What you have afterwards — the same on every platform

- +

Setup

If it fails part-way

@@ -1056,7 +1057,7 @@

If it fails part-way

- + @@ -1114,7 +1115,7 @@

Reading private legs in CI

- + @@ -1350,7 +1351,7 @@

The InkRouter example

- + @@ -1450,7 +1451,7 @@

Bootstrap is COPIED into the project, not fetched

- +

Reference

Keeping a project’s shape current

@@ -1493,8 +1494,12 @@

Keeping a project’s shape current

something to do. apply writes NONE of them unless --add <path> names one, per file, and names each file it adds in the line-ending block of an adopted root’s own .gitattributes when that file carries #471’s block; - --add refuses a path that is already pinned, one the target’s lists do - not name, and one the root ALREADY has a file at. + --add refuses a path that is already pinned + (update-add-already-pinned), one the target’s lists do not name + (update-add-not-upstream), one with a symlink at or above it, which is never + written through (update-add-symlink), one with a file where a directory above + it goes (update-add-not-a-directory), and one the root ALREADY has a file at + (update-add-would-overwrite).

--upstream takes a path to a clone (offline) or an owner/repo it bare-clones itself; it defaults to the repository the pin names. make bootstrap @@ -1508,7 +1513,7 @@

Keeping a project’s shape current

- +

Reference

Is this repository compliant? openRepoShape --doctor

@@ -1642,7 +1647,7 @@

Is this repository compliant? openRepoShape --doctor

- + diff --git a/tests/readme-cap.d/550.txt b/tests/readme-cap.d/550.txt new file mode 100644 index 00000000..57cebe13 --- /dev/null +++ b/tests/readme-cap.d/550.txt @@ -0,0 +1,15 @@ ++5 +2026-10-14 +Five lines for two statements the README had wrong (#550, #429). +One: the by-hand setup commands under "What setup.sh does" dry-ran the scaffold +without the --visibility and --elected-by that setup-project.py's scaffold_args +always passes, so the plan a reader ran could name a different elector than the +real run wrote. The dry run now carries both, which takes a second line, as the +real run's does (+1). +Two: the refusals `update-shape.py apply --add` gives were listed as three, in +prose, and the tool gives five, each with a code of its own. A reader who met +`update-add-symlink` or `update-add-not-a-directory` found no sentence for it, +and the list read as complete. The sentence now names all five with what each +refuses and the code to search for, and it cannot be shorter than that: the five +codes, in backticks and parentheses, are 138 characters on their own, and the +two refusals it lacked add about as many again (+4). diff --git a/tests/test_setup_by_hand_commands.py b/tests/test_setup_by_hand_commands.py new file mode 100644 index 00000000..ec5e42fb --- /dev/null +++ b/tests/test_setup_by_hand_commands.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The by-hand scaffold commands the documents print are the ones setup runs (#550). + +README.md's "What setup.sh does" and the handbook's "What `setup.sh` actually +runs" list "the commands that flow runs", and a reader who runs them by hand +trusts that they are. Two of them are `scaffold-project.py`: the plan, which +`setup-project.py` runs with `--dry-run`, and the real run. Both get the same +arguments from `scaffold_args`, which ALWAYS passes `--visibility` and +`--elected-by`; the documents showed the plan without them, so the plan a +reader ran could name a different elector than the real run wrote (#550). +Nothing tied the printed commands to the flow. + +This is the guard. It does not read the source for the flags: it RUNS +`plan_and_confirm` and `scaffold` in process with `run` replaced by a recorder, +so what it compares is the argument list those two functions hand to the +scaffold, in the order they build it, `--dry-run` last on the plan. The +documents are held to that, after the two things a printed command does that a +list does not: a backslash continues a line, and `# ...` is a comment. + +The values are the documents' own: the project `Atlas`, the visibility +`private`, the elector `Your Name`, and the organisation the documents write as +the placeholder ``, which is stood in for by `ORG` on both sides. A +flag `setup-project.py` passes only when it is given (`--id`, `--name`, +`--local-remote-dir`) is in neither: the printed lines show the default run, and +so do the recorded ones. + +Nothing here starts a process, touches git or GitHub, or reads a terminal, so +it holds on every platform; the printed commands are POSIX and are rendered +with `shlex.quote`, which is what they are written in. +""" + +from __future__ import annotations + +import html +import re +import shlex +import subprocess +from pathlib import Path + +from conftest import REPO + +from test_setup_project_py import entry_point_module + +#: The organisation the documents write as a placeholder, and what stands in +#: for it on both sides of the comparison. +PLACEHOLDER = "" +ORG = "ORG" + +#: Where each document's list of commands starts. The README's is the first +#: fenced block under its heading; the handbook's is the first `
` after
+#: its subheading.
+README_BLOCK = re.compile(
+    r"^### What setup\.sh does$.*?^```sh\n(.*?)^```$", re.S | re.M)
+HANDBOOK_BLOCK = re.compile(
+    r"What setup\.sh actually runs.*?"
+    r"
(.*?)
", re.S) + +SCAFFOLD_COMMAND = "python3 scaffold-project.py" + + +def scaffold_lines(block: str) -> list[str]: + """The `scaffold-project.py` commands in a printed block, each as one line + with its continuations joined, its comment dropped and its whitespace + collapsed, and the organisation placeholder replaced by `ORG`.""" + lines = [] + for line in block.replace("\\\n", " ").splitlines(): + line = " ".join(re.sub(r"\s+#.*$", "", line).split()) + if line.startswith(SCAFFOLD_COMMAND): + lines.append(line.replace(PLACEHOLDER, ORG)) + return lines + + +def what_setup_runs(monkeypatch, tmp_path: Path) -> list[str]: + """The two `scaffold-project.py` commands `setup-project.py` runs for the + documents' own values, as `python3 scaffold-project.py ` lines: + the plan first, then the real run.""" + module = entry_point_module() + ran: list[list[str]] = [] + + def record(argv, *args, **kwargs): + ran.append([str(part) for part in argv]) + return subprocess.CompletedProcess(args=argv, returncode=0, + stdout="", stderr="") + + monkeypatch.setattr(module, "run", record) + opts = module.Options() + opts.project = "Atlas" + opts.visibility = "private" + opts.elected_by = "Your Name" + opts.assume_yes = True + args = module.scaffold_args(opts, ORG) + module.plan_and_confirm(opts, REPO, ORG, args, str(tmp_path / "Atlas")) + module.scaffold(REPO, args) + assert len(ran) == 2, ran + lines = [] + for argv in ran: + assert Path(argv[1]).name == "scaffold-project.py", argv + lines.append(" ".join([SCAFFOLD_COMMAND, + *(shlex.quote(part) for part in argv[2:])])) + return lines + + +def assert_the_same(name: str, printed: list[str], ran: list[str]) -> None: + """The document's `scaffold-project.py` lines are the ones setup runs, in + order, and the failure shows both side by side.""" + assert printed == ran, ( + f"{name} prints these `scaffold-project.py` commands as the ones " + "setup runs (the plan, then the real run):\n " + + "\n ".join(printed) + "\nsetup-project.py runs these:\n " + + "\n ".join(ran) + + "\nThe plan and the real run take the same arguments from " + "`scaffold_args`, `--visibility` and `--elected-by` included (#550).") + + +def test_the_readme_prints_the_commands_setup_runs(monkeypatch, tmp_path): + text = (REPO / "README.md").read_bytes().decode("utf-8") + found = README_BLOCK.search(text) + assert found, "README.md has no fenced block under `### What setup.sh does`" + assert_the_same( + "README.md", scaffold_lines(found.group(1)), + what_setup_runs(monkeypatch, tmp_path)) + + +def test_the_handbook_prints_the_commands_setup_runs(monkeypatch, tmp_path): + text = (REPO / "docs" / "handbook.html").read_bytes().decode("utf-8") + found = HANDBOOK_BLOCK.search(text) + assert found, ("docs/handbook.html has no code block under `What " + "setup.sh actually runs`") + assert_the_same( + "docs/handbook.html", scaffold_lines(html.unescape(found.group(1))), + what_setup_runs(monkeypatch, tmp_path)) + + +def test_setup_runs_the_plan_and_the_real_run_with_the_same_arguments( + monkeypatch, tmp_path): + plan, real = what_setup_runs(monkeypatch, tmp_path) + assert plan == real + " --dry-run" + assert "--visibility private" in real and "--elected-by 'Your Name'" in real + + +def test_a_printed_command_is_read_as_one_line_without_its_comment(): + block = ("git clone x\n" + "python3 scaffold-project.py --org --project Atlas \\\n" + " --visibility private --elected-by 'Your Name' # or public\n" + "mkdir -p y # python3 scaffold-project.py is not here\n") + assert scaffold_lines(block) == [ + "python3 scaffold-project.py --org ORG --project Atlas " + "--visibility private --elected-by 'Your Name'"] + + +def test_a_plan_printed_without_the_flags_the_real_run_has_is_a_different_line(): + printed = scaffold_lines( + "python3 scaffold-project.py --org --project Atlas " + "--dry-run\n") + assert printed == ["python3 scaffold-project.py --org ORG --project Atlas " + "--dry-run"] + assert printed[0] != ("python3 scaffold-project.py --org ORG --project " + "Atlas --visibility private --elected-by 'Your Name'" + " --dry-run") diff --git a/tests/test_update_add_codes_documented.py b/tests/test_update_add_codes_documented.py new file mode 100644 index 00000000..bef8fd43 --- /dev/null +++ b/tests/test_update_add_codes_documented.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The `update-add-*` codes the documents name are the ones `apply --add` gives (#429). + +`update-shape.py apply --add ` refuses in five ways, each with a code of +its own that a person meets on stderr as `REFUSED update-add-...`: +`update-add-already-pinned`, `update-add-not-upstream`, `update-add-symlink` +(#239), `update-add-not-a-directory` (#285) and `update-add-would-overwrite`. +README.md and `docs/handbook.html` list them in one sentence each, and that +sentence named three: #269 and #346 (both landed 2026-10-12) each added a +refusal and left both documents alone, so a reader who met `update-add-symlink` +or `update-add-not-a-directory` found no sentence for it, and the list read as +complete (#429). Nothing tied the sentence to the tool. + +This is the guard, in both directions, and it is the kind this repository +already keeps for AGENTS.md's codes (`tests/test_agents_md_codes.py`): + +- every `update-add-*` code the tool raises is named in README.md, in + backticks, and in `docs/handbook.html`, in ``: a refusal added + tomorrow fails here until both documents say so; +- every `update-add-*` token either document spells is a code the tool raises: + a misspelt or renamed one fails here, naming the token and the document. + +WHICH CODES THE TOOL RAISES. A string `update-shape.py` could print whole, +which is `tests/test_agents_md_codes.py::printed_strings` (found with `ast`, +never a grep, so a comment cannot satisfy it; a docstring and the pieces of an +f-string are left out), kept when its whole text is `update-add-` followed by +lowercase words. Nothing runs: no process, no git, no network, the same on +every platform. `FLOOR` is the five the tool raises today, so a scan that +stopped finding them (a rename, a code built from pieces) fails by name here +and does not turn into a test that checks nothing. + +WHICH TOKENS A DOCUMENT SPELLS. Any `update-add-` followed by lowercase words +joined by hyphens, anywhere in its text. A new document naming a code is held +to the tool without anybody adding it to a list here. +""" + +from __future__ import annotations + +import re + +from conftest import REPO + +from test_agents_md_codes import printed_strings + +#: What an `update-add-*` code looks like: the prefix, then lowercase words +#: joined by hyphens. `fullmatch`ed against a constant, `findall`ed in a +#: document. +CODE = r"update-add-[a-z]+(?:-[a-z]+)*" + +#: The codes the tool raises today. The scan must find at least these. +FLOOR = frozenset({ + "update-add-already-pinned", + "update-add-not-upstream", + "update-add-symlink", + "update-add-not-a-directory", + "update-add-would-overwrite", +}) + +#: The documents that list the refusals, each with how it spells a code: a +#: backticked span in the README, a `` element in the handbook. +DOCUMENTS = { + "README.md": "`{}`", + "docs/handbook.html": "{}", +} + + +def raised_codes() -> frozenset[str]: + """The `update-add-*` codes `update-shape.py` holds as whole constants.""" + return frozenset( + text for text in printed_strings( + (REPO / "update-shape.py").read_bytes()) + if re.fullmatch(CODE, text)) + + +def document_text(name: str) -> str: + """A document's text, read as bytes and decoded as UTF-8, so a Windows + checkout's locale cannot change what is read.""" + return (REPO / name).read_bytes().decode("utf-8") + + +def unnamed(raised: frozenset[str], documents: dict[str, str]) -> list[str]: + """One line per code in `raised` that a document does not spell its way.""" + return [f"{name} does not name {code} (as {DOCUMENTS[name].format(code)})" + for name, text in sorted(documents.items()) + for code in sorted(raised) + if DOCUMENTS[name].format(code) not in text] + + +def not_raised(raised: frozenset[str], documents: dict[str, str]) -> list[str]: + """One line per `update-add-*` token a document spells that is no code in + `raised`.""" + return [f"{name} spells {token}, which update-shape.py does not raise" + for name, text in sorted(documents.items()) + for token in sorted(set(re.findall(CODE, text)) - raised)] + + +def test_the_scan_finds_the_codes_the_tool_raises(): + missing = sorted(FLOOR - raised_codes()) + assert not missing, ( + f"update-shape.py no longer holds {missing} as a whole string " + "constant. If a refusal was renamed, rename it in README.md, in " + "docs/handbook.html and in FLOOR here; a code built from pieces " + "cannot be found, and a refusal nobody can search for is the gap " + "this test exists to close (#429)") + + +def test_every_add_refusal_code_the_tool_raises_is_named_in_both_documents(): + documents = {name: document_text(name) for name in DOCUMENTS} + problems = unnamed(raised_codes(), documents) + assert not problems, ( + "\n".join(problems) + "\n`apply --add` gives each of these refusals; " + "README.md's and the handbook's sentence about them must name every " + "one with what it refuses, so a reader who meets one finds it (#429)") + + +def test_every_add_refusal_code_a_document_spells_is_one_the_tool_raises(): + documents = {name: document_text(name) for name in DOCUMENTS} + problems = not_raised(raised_codes(), documents) + assert not problems, "\n".join(problems) + + +def test_a_code_the_tool_raises_and_a_document_omits_is_named_with_its_document(): + raised = frozenset({"update-add-symlink", "update-add-brand-new"}) + documents = { + "README.md": "`update-add-symlink` and `update-add-brand-new`", + "docs/handbook.html": "update-add-symlink only", + } + assert unnamed(raised, documents) == [ + "docs/handbook.html does not name update-add-brand-new " + "(as update-add-brand-new)"] + + +def test_a_misspelt_code_fails_naming_the_token_and_the_document(): + raised = frozenset({"update-add-symlink"}) + documents = { + "README.md": "`update-add-symlink` and `update-add-symlnk`", + "docs/handbook.html": "update-add-symlink", + } + assert not_raised(raised, documents) == [ + "README.md spells update-add-symlnk, which update-shape.py does not " + "raise"] + + +def test_a_code_that_is_only_a_prefix_of_a_longer_one_does_not_satisfy_it(): + raised = frozenset({"update-add-symlink"}) + documents = {"README.md": "`update-add-symlink-loop`", + "docs/handbook.html": "update-add-symlink-loop"} + assert unnamed(raised, documents) != [] + assert not_raised(raised, documents) != []