From f8ef35d6826e984390250207d63a58173374634c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:06:16 +0000 Subject: [PATCH 1/3] shape_materialize.py: the shape half of #172's leg-path collision rule moves out of adopt-project.py to sit beside `assembly_root_paths`, so scaffold-project.py can ask the same question from one definition, and adopt's sentences stay byte for byte what they were (#197) `scaffold-project.py --spec-path contracts` creates all three repositories, pushes both legs and only then dies at `git submodule add`, because the shape has already written `contracts/` into the assembly root (#197). The question that catches it is #172's shape half, which #198 wrote inside `adopt-project.py`, where the scaffold cannot import it. A second copy in the scaffold is how the two tools would start refusing different paths, so this commit moves the code and changes no behaviour. What moved, with #198's docstrings kept: `CASE_ASIDE`, `MOVE_THE_LEG`, `_overlap` (now `path_overlap`), `_first_and_more` (now `first_and_more`), and the half of `_shape_collisions` that reads `assembly_root_paths` (now `shape_path_collisions`). The functions are public because two modules import them. `shape_path_collisions` takes the `collision_dir` the caller hands `materialize_assembly_root`, so the two callers differ here by the argument they differ by there. Adopt passes its `COLLISION_DIR`, and its sentence, which names `execute` and `shape/`, is produced unchanged. What stayed in adopt-project.py: - `_shape_collisions`, which now asks the shared half and then its own question about `shape/`. That half reads #198's `SHAPE_DIR_RESERVED`, built from adopt's `COLLISION_DIR` and also said by `LEG_COLLISION_REMEDIATION`. Only an adoption writes copies there, and moving the sentence would have meant a second spelling of the phrase or moving a constant adopt's remediation reads. So it stays, verbatim. - `leg_path_collisions`, unchanged. - The root-kept half (`_kept_collisions`, `_kept_in_root`, `_leg_collision_findings`), since only an adoption reads a source tree. - `LEG_COLLISION_REMEDIATION`, which speaks of `check` and `shape/`. The moved names come in through one separate import statement, under the private names `_kept_collisions` and `_shape_collisions` already call. The statement sits after `import shape_advisory`, away from the materializer import list that #198 and #179 each edit. Checked: for every path `assembly_root_paths()` lists plus 24 other values (the `shape/` variants among them), asked as both legs, plus seven root-kept cases, the remediation and `SHAPE_DIR_RESERVED`, adopt's output is identical before and after (78 lines, `cmp` against #198's head 163173e, whose tree main's squash 20e13f0 holds byte for byte). tests/test_adopt_leg_path_collisions.py is unchanged and passes, with test_adopt_leg_paths.py and test_shape_doctor.py (622 passed). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 56 +++++++++----------------- scripts/shape_materialize.py | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 38 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 313b4cf3..34b2d0d7 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -102,6 +102,12 @@ materialize_assembly_root, naming_block, run, write_lf, ) import shape_advisory # noqa: E402 +# #172's shape half, shared with the scaffold since #197 (see that section +# below), under the names this file has always called it by. +from shape_materialize import ( # noqa: E402 + MOVE_THE_LEG, first_and_more as _first_and_more, path_overlap as _overlap, + shape_path_collisions, +) #: The naming policy this tool classifies leg names against. One constant, #: because three spellings of the same path is how the second one goes stale. @@ -1201,10 +1207,13 @@ def _plan_leg_paths(plan: Plan) -> tuple[tuple, tuple]: # half for the `leg: root` answers it wrote itself, and refuses nothing for # it: those answers are the person's to change. -#: Said where an overlap holds only once case is ignored. -CASE_ASIDE = " once case is ignored, as a macOS or Windows disk does" -#: The exit every collision has; a root-kept one has a second. -MOVE_THE_LEG = "choose another path for the leg" +# The question about `assembly_root_paths` -- `path_overlap`, +# `first_and_more`, `shape_path_collisions` and the phrases they share -- +# moved to `scripts/shape_materialize.py` for #197, because +# `scaffold-project.py` asks it of its own leg paths. `_shape_collisions` +# below asks it with this tool's `COLLISION_DIR` and adds the `shape/` half, +# and `leg_path_collisions` adds the root-kept half: only an adoption has +# either. #: Why a mount at or under `COLLISION_DIR` is refused where no copy would land #: there (#172's review, P3-3): the directory is the shape's, whatever this @@ -1225,41 +1234,12 @@ def _plan_leg_paths(plan: Plan) -> tuple[tuple, tuple]: ) -def _overlap(leg: str, other: str) -> tuple[str, str] | None: - """How the mount path `leg` stands to the path `other`: `(relation, - aside)`, the relation `is`, `contains` or `is inside`, and the aside - `CASE_ASIDE` when it holds only once case is ignored. None when the two - do not overlap at all.""" - for mine, theirs, aside in ((leg, other, ""), - (leg.casefold(), other.casefold(), CASE_ASIDE)): - if mine == theirs: - return "is", aside - if theirs.startswith(mine + "/"): - return "contains", aside - if mine.startswith(theirs + "/"): - return "is inside", aside - return None - - -def _first_and_more(paths: list[str]) -> str: - """`a` or `a` and 2 more: one path named, the rest counted.""" - more = f" and {len(paths) - 1} more" if len(paths) > 1 else "" - return f"`{paths[0]}`{more}" - - def _shape_collisions(what: str, value: str) -> list[str]: - """Where the mount path `value` overlaps what the shape writes.""" - out: list[str] = [] - files = [path for path in assembly_root_paths() if _overlap(value, path)] - if files: - relation, aside = _overlap(value, files[0]) - them = "them" if len(files) > 1 else "it" - out.append( - f"{what} {value!r} {relation} {_first_and_more(files)}{aside}. " - f"The shape writes {them} into the assembly root, and `execute` " - f"would write {them} into the mounted leg or beside it under " - f"`{COLLISION_DIR}/`, leaving the root without {them}: " - f"{MOVE_THE_LEG}.") + """Where the mount path `value` overlaps what the shape writes: the + paths of `assembly_root_paths`, asked through the definition this tool + shares with `scaffold-project.py` (#197), then this tool's own + `COLLISION_DIR`, which only an adoption writes into.""" + out = shape_path_collisions(what, value, COLLISION_DIR) beside = _overlap(value, COLLISION_DIR) if beside: relation, aside = beside diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 152c1f23..6ea32599 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1088,6 +1088,84 @@ def assembly_root_paths() -> tuple[str, ...]: *(rel for _, rel in COPIED_FROM_SHAPE), SHAPE_PIN) +# --------------------------------------------------------------------------- +# A leg's mount path overlaps nothing the shape writes (#172, #197) +# --------------------------------------------------------------------------- +# +# `adopt-project.py` asked this first (#172). `--spec-path contracts` is +# canonical, so #169 passed it, and `execute` made and pushed both legs, +# mounted one at `contracts`, wrote the shape's `contracts/spec-pin.yaml` +# INTO the mounted leg, and died staging the split. +# +# THE SHAPE HALF OF THAT RULE MOVED HERE, beside the list it reads, because +# `scaffold-project.py` asks the same question of its own two leg paths +# (#197), and a second copy of the relation or of the sentence is how two +# tools start refusing different paths. Two halves are adopt's alone and +# stayed there: the one that reads a SOURCE tree, a path an adoption plan +# keeps in the root, and the one about `shape/`, the directory only an +# adoption writes the shape's copies into. +# +# A mount path is refused when it EQUALS, CONTAINS or LIES INSIDE a path +# `assembly_root_paths` lists, compared WITHOUT REGARD TO CASE: a macOS or +# Windows disk keeps `Scripts` and `scripts` as one directory. + +#: Said where an overlap holds only once case is ignored. +CASE_ASIDE = " once case is ignored, as a macOS or Windows disk does" +#: The exit every collision has; a root-kept one has a second. +MOVE_THE_LEG = "choose another path for the leg" + + +def path_overlap(leg: str, other: str) -> tuple[str, str] | None: + """How the mount path `leg` stands to the path `other`: `(relation, + aside)`, the relation `is`, `contains` or `is inside`, and the aside + `CASE_ASIDE` when it holds only once case is ignored. None when the two + do not overlap at all.""" + for mine, theirs, aside in ((leg, other, ""), + (leg.casefold(), other.casefold(), CASE_ASIDE)): + if mine == theirs: + return "is", aside + if theirs.startswith(mine + "/"): + return "contains", aside + if mine.startswith(theirs + "/"): + return "is inside", aside + return None + + +def first_and_more(paths: list[str]) -> str: + """`a` or `a` and 2 more: one path named, the rest counted.""" + more = f" and {len(paths) - 1} more" if len(paths) > 1 else "" + return f"`{paths[0]}`{more}" + + +def shape_path_collisions(what: str, value: str, + collision_dir: str) -> list[str]: + """Where the mount path `value` overlaps a path of `assembly_root_paths`: + one sentence, naming the first path it overlaps and counting the rest, + or none. `what` names the value as its caller does (`--spec-path`, + `legs.spec_path`). + + `collision_dir` IS THE ONE THE CALLER HANDS `materialize_assembly_root`. + `adopt-project.py execute` passes `shape/`: it mounts the legs FIRST and + materializes over them, so a shape file at a mount goes into the mounted + leg or beside it under that directory, and the sentence names `execute` + because it is the caller that passes one. Whether `value` overlaps + `collision_dir` itself is the caller's question, not this one's. + """ + out: list[str] = [] + files = [path for path in assembly_root_paths() + if path_overlap(value, path)] + if files: + relation, aside = path_overlap(value, files[0]) + them = "them" if len(files) > 1 else "it" + out.append( + f"{what} {value!r} {relation} {first_and_more(files)}{aside}. " + f"The shape writes {them} into the assembly root, and `execute` " + f"would write {them} into the mounted leg or beside it under " + f"`{collision_dir}/`, leaving the root without {them}: " + f"{MOVE_THE_LEG}.") + return out + + def materialize_family_root(shape_root: Path, target: Path, values: dict[str, str]) -> Materialized: """Write the FAMILY-root skeleton into `target`. From aabfae00411d7ac32940c9673d5c108490add63e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:11:24 +0000 Subject: [PATCH 2/3] scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root is refused before anything is created, under `--dry-run` alike, so `--spec-path contracts` no longer creates three repositories and pushes both legs to die at `git submodule add` (#197) `_build_assembly_root` writes the shape into the root FIRST and mounts the legs after it, so a leg path the shape had just written met only `git submodule add`. Measured on #198's head 163173e, whose tree main holds byte for byte since its squash 20e13f0, against a local remote directory: - `--spec-path contracts`, `--code-path project.yaml` and `--code-path Makefile/code` each planned under `--dry-run` with exit 0. - The real run then created all three bare repositories, pushed a commit to each leg, left the assembly root with no commit and exited 2 (`'contracts' already exists and is not a valid git repo`, `destination path ... project.yaml already exists`, `could not create leading directories of ... Makefile/code`). - `--spec-path Contracts` exited 0 with a gitlink beside the shape's `contracts/`, which a macOS or Windows disk keeps as one path. Against GitHub, each left three real repositories for a run that made no project. THE QUESTION IS ADOPT'S (#172), through the one definition the previous commit moved: `shape_path_collisions` over `assembly_root_paths()`, with `path_overlap`'s relation (is, contains, is inside) and `CASE_ASIDE`. A file the shape gains is refused here without anybody editing this tool. The refusal is `scaffold-leg-path-collides`, beside adopt's `adopt-leg-path-collides`. It names each leg's overlap in #198's words, then a remediation that lists the materializer's paths and says nothing was created. `shape_path_collisions` gains `collision_dir=None`, the scaffold's case. The scaffold hands `materialize_assembly_root` no collision directory, so the sentence says what happens here instead of what adopt's `execute` would do: the shape writes the path into the root before the legs are mounted, and Git cannot mount a leg at a path the root already holds, or beyond a file it holds on the way there. Adopt passes `shape`, and its 78 dumped lines are still byte-identical to 163173e's, which are main's. WHY `shape` IS NOT REFUSED HERE. Adopt refuses a mount at or under `shape/` because `execute` writes the shape's copy of a file the source already holds there, and #198 reserves the directory for those copies. The scaffold materializes into a directory it made itself, where a collision raises rather than landing under `shape/`. `update-shape.py` reads `shape/` only for a pin that names a path under it, which only an adoption writes. So nothing the scaffold or its project's tools write lands there, and refusing it would be a rule with no collision behind it. `shape` and `shape/spec` scaffold, and a test holds that. WHERE THE REFUSAL SITS. `_scaffold` calls `_refuse_colliding_leg_paths` straight after the last `checked_value`, before any process can start: - before `_identity`, which may read git's config; - before `_declaration`, which asks `gh api` for a `--pin` with no `--pin-source`; - before `_leg_visibility`, which asks `gh repo view` about a reused root; - before the plan is printed and the `--dry-run` return; - before `mkdtemp` or `--work-dir`, the local remote directory and `_create_remotes`. A dry run therefore refuses with the real run's exit code and its exact stderr. `main()` does nothing but parse arguments before `_scaffold`. tests/test_scaffold_leg_path_collisions.py (89 tests): - Every path `assembly_root_paths()` lists is refused as either leg. So are a directory that holds them (`contracts`, `scripts`, `.github`, `.github/workflows`) and a path beyond a file (`Makefile/code`, `contracts/shape-pin.yaml/spec`). The case-only variants say the aside. - The refused files are exactly what `materialize_assembly_root` writes into an empty directory, and each directory written into is refused too. - Both tools hold `shape_materialize`'s own objects, and a spy shows both call them, adopt with `shape` and the scaffold with none. - `--dry-run` and the real run give the same exit code and the same stderr. - The real run leaves the remote directory's listing unchanged and makes no temporary work directory. - A stub `gh` records no call before the refusal, against a control that reaches it. - A non-colliding pair (`project`, `scripts/code`) still scaffolds. Most cases run `main()` in-process with `subprocess.Popen` refused, so a pass proves no git or gh process started. Out of scope, left: `--pin` also writes `contracts/-pin.yaml`, which `assembly_root_paths()` does not list (`contracts` is refused through the other files under it, a pin's exact path is not). Non-canonical spellings such as `contracts/` and `./contracts` slip past this check until #179 (#201) lands its canonical check before `checked_value`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scaffold-project.py | 59 ++- scripts/shape_materialize.py | 22 +- tests/test_scaffold_leg_path_collisions.py | 407 +++++++++++++++++++++ 3 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 tests/test_scaffold_leg_path_collisions.py diff --git a/scaffold-project.py b/scaffold-project.py index bde77846..50ab7600 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -8,7 +8,9 @@ 1. Validates the three names — ``, `-spec`, `-code` — against `contracts/repository-naming.yaml` BEFORE it creates anything, so a naming mistake costs a message rather than three - repositories and a rename. + repositories and a rename. A leg path that is, holds or lies inside a + path the shape writes into the assembly root (`contracts`, + `project.yaml`) is refused just as early, for the same reason (#197). 2. Creates the three remotes: `gh repo create` normally, or three BARE repositories in a directory with `--local-remote-dir`, which is the test path and touches no network. @@ -60,6 +62,11 @@ stage_written, ) from shape_advisory import scaffold_lines # noqa: E402 +# The shape half of the leg-path collision rule, which `adopt-project.py` +# asks of its own mount paths (#172), imported as one unit (#197). +from shape_materialize import ( # noqa: E402 + assembly_root_paths, shape_path_collisions, +) #: The pin argument: `openGlass@<40 hex>`, optionally organisation-qualified. PIN_ARG_RE = re.compile(r"^(?P[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?)@(?P[0-9a-fA-F]{40})$") @@ -392,6 +399,49 @@ def _exists_remedy(role: str, empty: bool, reuse_flag: bool) -> str: "--force.") +#: What a leg-path collision tells the person to do. The paths are +#: `assembly_root_paths()`, the materializer's own list, never a copy of it. +LEG_COLLISION_REMEDIATION = ( + "Remediation: nothing was created, and nothing here picks a path for " + "you, so pass another one yourself. A leg path neither is, holds nor " + "lies inside, whatever the case, a path the shape writes into the " + "assembly root (" + + ", ".join(f"`{path}`" for path in assembly_root_paths()) + + "). The defaults `spec` and `code` pass." +) + + +def _refuse_colliding_leg_paths(spec_path: str, code_path: str) -> None: + """Neither leg is mounted over a path the shape writes into the assembly + root, or this refuses (#197). + + `_build_assembly_root` writes the shape into the root FIRST and mounts + the legs after it, so a leg path the shape had just written was met only + by `git submodule add`. On main through 20e13f0, `--spec-path contracts` + and `--code-path project.yaml` planned under `--dry-run`, then created + all three repositories, pushed both legs, left the root empty and exited + 2 (`'contracts' already exists and is not a valid git repo`). + `--code-path Makefile/code` died the same way, and `--spec-path + Contracts` exited 0 with a gitlink beside the shape's `contracts/`, + which a macOS or Windows disk keeps as one path. Against GitHub, each + left three repositories for a run that made no project. + + THE QUESTION IS `adopt-project.py`'s (#172), asked through the one + definition both tools import: `shape_path_collisions` over + `assembly_root_paths()`, so a file the shape gains is refused here + without anybody editing this tool. It is asked with no collision + directory, because the scaffold hands the materializer none and writes + nothing under adopt's `shape/`. + """ + collisions = [sentence + for what, value in (("--spec-path", spec_path), + ("--code-path", code_path)) + for sentence in shape_path_collisions(what, value)] + if collisions: + raise Refusal("scaffold-leg-path-collides", " ".join(collisions), + LEG_COLLISION_REMEDIATION) + + def _elector(args) -> str: """(a) Whose act electing this shape was, or a refusal. @@ -861,6 +911,13 @@ def _scaffold(args) -> int: args.code_path = checked_value("--code-path", args.code_path) args.org = checked_value("--org", args.org) args.pin_owner = checked_value("--pin-owner", args.pin_owner) + # THE LEG PATHS AGAINST THE SHAPE'S OWN, before anything else runs + # (#197): `_identity` may read git's config, `_declaration` asks `gh api` + # for a `--pin` with no `--pin-source`, `_leg_visibility` asks `gh repo + # view` about a reused root, and `--dry-run` returns only after all of + # them. Refused here, a dry run refuses exactly as the real run would, + # and no process has been started. + _refuse_colliding_leg_paths(args.spec_path, args.code_path) project_id, display, elected_on, reference, elected_by = _identity( args, project) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 6ea32599..9e02099d 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1138,18 +1138,22 @@ def first_and_more(paths: list[str]) -> str: def shape_path_collisions(what: str, value: str, - collision_dir: str) -> list[str]: + collision_dir: str | None = None) -> list[str]: """Where the mount path `value` overlaps a path of `assembly_root_paths`: one sentence, naming the first path it overlaps and counting the rest, or none. `what` names the value as its caller does (`--spec-path`, `legs.spec_path`). - `collision_dir` IS THE ONE THE CALLER HANDS `materialize_assembly_root`. + `collision_dir` IS THE ONE THE CALLER HANDS `materialize_assembly_root`, + so the two callers differ here by the argument they differ by there. `adopt-project.py execute` passes `shape/`: it mounts the legs FIRST and materializes over them, so a shape file at a mount goes into the mounted leg or beside it under that directory, and the sentence names `execute` because it is the caller that passes one. Whether `value` overlaps `collision_dir` itself is the caller's question, not this one's. + `scaffold-project.py` passes none (#197): it materializes into an empty + directory and mounts the legs AFTER, so `git submodule add` meets a path + the shape already wrote. """ out: list[str] = [] files = [path for path in assembly_root_paths() @@ -1157,11 +1161,19 @@ def shape_path_collisions(what: str, value: str, if files: relation, aside = path_overlap(value, files[0]) them = "them" if len(files) > 1 else "it" + if collision_dir is None: + consequence = ( + " before the legs are mounted, and Git cannot mount a leg at " + "a path the root already holds, or beyond a file it holds on " + "the way there") + else: + consequence = ( + f", and `execute` would write {them} into the mounted leg or " + f"beside it under `{collision_dir}/`, leaving the root " + f"without {them}") out.append( f"{what} {value!r} {relation} {first_and_more(files)}{aside}. " - f"The shape writes {them} into the assembly root, and `execute` " - f"would write {them} into the mounted leg or beside it under " - f"`{collision_dir}/`, leaving the root without {them}: " + f"The shape writes {them} into the assembly root{consequence}: " f"{MOVE_THE_LEG}.") return out diff --git a/tests/test_scaffold_leg_path_collisions.py b/tests/test_scaffold_leg_path_collisions.py new file mode 100644 index 00000000..5eb9042d --- /dev/null +++ b/tests/test_scaffold_leg_path_collisions.py @@ -0,0 +1,407 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A scaffolded leg's path overlaps nothing the shape writes (#197). + +`scaffold-project.py` writes the shape into the assembly root FIRST and mounts +the legs after it. On main through 20e13f0, `--spec-path contracts` and +`--code-path project.yaml` planned under `--dry-run` (exit 0). The real run +then created all three repositories, pushed both legs, left the assembly root +empty and exited 2 at `git submodule add`: `'contracts' already exists and is +not a valid git repo`. `--code-path Makefile/code` died the same way, and +`--spec-path Contracts` exited 0 with a gitlink beside the shape's +`contracts/`, which a macOS or Windows disk keeps as one path. Against GitHub, +each left three real repositories for a run that made no project. + +The rule is #172's shape half, which both tools ask through ONE definition: +`shape_materialize.shape_path_collisions` over `assembly_root_paths()`. A leg +path that EQUALS, CONTAINS or LIES INSIDE a path the shape writes is refused, +whatever the case, before anything is created, and `--dry-run` refuses it +too. The scaffold hands the materializer no collision directory, so adopt's +`shape/` is not on its list. + +Most tests call `main()` in this process with every process start refused, +which shows the refusal comes before git, before `gh` and before the work +directory. The end-to-end tests run the script against a local remote +directory, and one of them puts a recording `gh` on PATH. +""" + +from __future__ import annotations + +import importlib.util +import os +import stat +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from conftest import (ADOPT, ORG, REPO, SCAFFOLD, WINDOWS_SKIP, git, + run_script) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from shape_materialize import ( # noqa: E402 + CASE_ASIDE, PLACEHOLDER_RE, assembly_root_paths, materialize_assembly_root, +) + +PROJECT = "Northwind" +UTF8 = "utf-8" +ROLES = ("spec", "code") +REFUSED = "REFUSED scaffold-leg-path-collides" +#: What every scaffold collision says after naming the overlap. The scaffold +#: builds the root before it mounts the legs; adopt's sentence says what +#: `execute` would do instead, and is +#: `tests/test_adopt_leg_path_collisions.py`'s. +SCAFFOLD_SAYS = ("The shape writes {them} into the assembly root before the " + "legs are mounted, and Git cannot mount a leg at a path the " + "root already holds, or beyond a file it holds on the way " + "there: choose another path for the leg.") +REMEDIATION_ENDS = "The defaults `spec` and `code` pass." +BASE_ARGS = ("--org", ORG, "--project", PROJECT, "--elected-by", "Test Human", + "--elected-on", "2026-09-02") + +#: `(role, value, relation, what it names)`: a directory the shape writes +#: into, at the top and nested, and a path beyond a FILE it writes. A path +#: the shape writes exactly is every row of `assembly_root_paths()` below. +RELATIONS = [ + pytest.param("spec", "contracts", "contains", + "`contracts/spec-pin.yaml` and 3 more", id="contracts"), + pytest.param("code", "scripts", "contains", + "`scripts/validate-pins.py` and 4 more", id="scripts"), + pytest.param("spec", ".github", "contains", + "`.github/workflows/validate.yml`", id="dot-github"), + pytest.param("code", ".github/workflows", "contains", + "`.github/workflows/validate.yml`", id="dot-github-nested"), + pytest.param("code", "Makefile/code", "is inside", "`Makefile`", + id="beyond-a-shape-file"), + pytest.param("spec", "contracts/shape-pin.yaml/spec", "is inside", + "`contracts/shape-pin.yaml`", id="beyond-a-nested-file"), +] + +#: The same overlaps with a capital letter in the way. `Contracts` is the +#: one main scaffolded to exit 0 on a case-sensitive disk. +CASE_ONLY = [ + pytest.param("Contracts", "contains", id="contracts-capital"), + pytest.param("PROJECT.YAML", "is", id="project-yaml-capitals"), + pytest.param("MAKEFILE/code", "is inside", id="beyond-a-file-capitals"), +] + +#: Canonical paths that overlap nothing the scaffold writes, each asked as +#: `--spec-path` beside the default `--code-path code`. `project` and +#: `scripts/spec` share a name's prefix or a directory with the shape, and no +#: path. `shape` and `shape/spec` are refused by adopt, which writes its copy +#: of a file the source already holds there; the scaffold writes nothing +#: there, because it hands the materializer no collision directory. `code` +#: is not here: beside the default it is two legs at one path, which is +#: #179's refusal, not this one's. +CLEAR = ["spec", "legs/spec", "legs/.github", "scripts/spec", + "contracts/spec", ".github/spec", "Scripts/x", "project", "README", + "Makefile.d/code", "shape", "shape/spec"] + +#: The issue's reproductions, and the two more found probing it: each made +#: three repositories on main, and the last of them made a broken project. +REPRODUCED = [ + pytest.param("--spec-path", "contracts", id="contracts"), + pytest.param("--code-path", "project.yaml", id="project-yaml"), + pytest.param("--code-path", "Makefile/code", id="beyond-a-file"), + pytest.param("--spec-path", "Contracts", id="case-only"), +] + + +@pytest.fixture(scope="module") +def scaffold(): + """`scaffold-project.py` loaded as a module, so `main()` runs here.""" + module_spec = importlib.util.spec_from_file_location( + "scaffold_leg_path_collisions", SCAFFOLD) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def adopter(): + """`adopt-project.py` loaded as a module, the other tool asking.""" + module_spec = importlib.util.spec_from_file_location( + "adopt_for_scaffold_collisions", ADOPT) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +class ProcessStarted(AssertionError): + """A process was started where the refusal should already have come.""" + + +@pytest.fixture +def no_process(monkeypatch): + """Every command the scaffold runs, `git` and `gh` alike, starts through + `subprocess.Popen`, which `subprocess.run` calls. While this holds, + starting one fails the test and names the command, so a refusal that + passes came before the first `git` or `gh` and before the work + directory, on every platform.""" + def refuse(args, *_, **__): + raise ProcessStarted(f"a process was started: {args!r}") + monkeypatch.setattr(subprocess, "Popen", refuse) + + +def refusal_of(scaffold, capsys, *legs: str) -> str: + """`main()` under `--dry-run` with `legs`: the refusal it printed.""" + code = scaffold.main([*BASE_ARGS, "--dry-run", *legs]) + out, err = capsys.readouterr() + assert code == 2, out + err + assert out == "", "the refusal comes before the plan is printed" + assert err.startswith(f"{REFUSED}: "), err + assert err.rstrip().endswith(REMEDIATION_ENDS), err + return err + + +def says(them: str) -> str: + return SCAFFOLD_SAYS.format(them=them) + + +def listing(directory: Path) -> list[str]: + return sorted(path.relative_to(directory).as_posix() + for path in directory.rglob("*")) + + +# --- the rule, at the scaffold's own command line --------------------------- + +@pytest.mark.parametrize("role", ROLES) +@pytest.mark.parametrize("path", assembly_root_paths()) +def test_every_path_the_shape_writes_is_refused_as_either_leg( + scaffold, no_process, capsys, role, path): + err = refusal_of(scaffold, capsys, f"--{role}-path", path) + assert err.startswith( + f"{REFUSED}: --{role}-path {path!r} is `{path}`. {says('it')}\n"), err + assert CASE_ASIDE not in err + + +@pytest.mark.parametrize("role, value, relation, named", RELATIONS) +def test_a_path_that_holds_or_lies_beyond_one_is_refused( + scaffold, no_process, capsys, role, value, relation, named): + err = refusal_of(scaffold, capsys, f"--{role}-path", value) + them = "them" if " more" in named else "it" + assert err.startswith( + f"{REFUSED}: --{role}-path {value!r} {relation} {named}. " + f"{says(them)}\n"), err + + +@pytest.mark.parametrize("value, relation", CASE_ONLY) +def test_a_collision_that_holds_only_without_case_says_so( + scaffold, no_process, capsys, value, relation): + err = refusal_of(scaffold, capsys, "--spec-path", value) + assert f"--spec-path {value!r} {relation} `" in err + assert f"{CASE_ASIDE}. The shape writes" in err + + +def test_both_legs_are_asked_and_named_in_order(scaffold, no_process, + capsys): + err = refusal_of(scaffold, capsys, "--code-path", "project.yaml", + "--spec-path", "contracts") + detail = err.split("\n", 1)[0] + assert detail == ( + f"{REFUSED}: --spec-path 'contracts' contains " + f"`contracts/spec-pin.yaml` and 3 more. {says('them')} " + f"--code-path 'project.yaml' is `project.yaml`. {says('it')}") + + +def test_the_remediation_lists_the_materializers_paths(scaffold, no_process, + capsys): + err = refusal_of(scaffold, capsys, "--spec-path", "contracts") + remediation = err.split("\n", 1)[1] + assert remediation.startswith("Remediation: nothing was created") + assert ("(" + ", ".join(f"`{path}`" for path in assembly_root_paths()) + + ")") in remediation + + +@pytest.mark.parametrize("value", CLEAR) +def test_a_path_that_overlaps_nothing_the_scaffold_writes_plans( + scaffold, capsys, value): + code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", value]) + out, err = capsys.readouterr() + assert code == 0, out + err + assert REFUSED not in err + assert f"legs mounted at {value}/ and code/" in out + assert "--dry-run: nothing was created." in out + + +# --- one definition, asked by both tools ------------------------------------- + +def test_both_tools_ask_the_one_definition(adopter, scaffold): + """The names each tool calls ARE `shape_materialize`'s, not copies. + Adopt keeps `_shape_collisions` for the half only it has, `shape/`, and + no relation or phrase of its own.""" + shared = shape_materialize.shape_path_collisions + assert scaffold.shape_path_collisions is shared + assert adopter.shape_path_collisions is shared + listed = shape_materialize.assembly_root_paths + assert scaffold.assembly_root_paths is listed + assert adopter.assembly_root_paths is listed + assert adopter._overlap is shape_materialize.path_overlap + assert adopter._first_and_more is shape_materialize.first_and_more + assert adopter.MOVE_THE_LEG is shape_materialize.MOVE_THE_LEG + assert not hasattr(adopter, "CASE_ASIDE"), "a second CASE_ASIDE in adopt" + + +def test_both_tools_refuse_through_it(adopter, scaffold, no_process, capsys, + monkeypatch): + """And both CALL it, each with the collision directory it hands the + materializer: adopt `shape`, the scaffold none.""" + asked = [] + + def spy(what, value, collision_dir=None): + asked.append((what, value, collision_dir)) + return [f"{what} {value!r} spied."] + + monkeypatch.setattr(adopter, "shape_path_collisions", spy) + monkeypatch.setattr(scaffold, "shape_path_collisions", spy) + assert adopter.leg_path_collisions((("--spec-path", "x"),)) == [ + "--spec-path 'x' spied."] + code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", "y"]) + err = capsys.readouterr().err + assert code == 2 + assert err.startswith(f"{REFUSED}: --spec-path 'y' spied. " + "--code-path 'code' spied.\n") + assert asked == [("--spec-path", "x", adopter.COLLISION_DIR), + ("--spec-path", "y", None), ("--code-path", "code", None)] + + +@pytest.mark.parametrize("path", assembly_root_paths()) +def test_both_tools_name_the_same_overlap(adopter, scaffold, no_process, + capsys, path): + """Which path, which relation and which shape path, word for word; only + what each tool would have done next differs.""" + head = ". The shape writes" + adopt_said, = adopter.leg_path_collisions((("--spec-path", path),)) + err = refusal_of(scaffold, capsys, "--spec-path", path) + detail = err[len(f"{REFUSED}: "):].split("\n", 1)[0] + assert detail.split(head)[0] == adopt_said.split(head)[0] + assert detail == " ".join( + shape_materialize.shape_path_collisions("--spec-path", path)) + + +def test_the_paths_refused_are_the_paths_the_materializer_writes( + scaffold, no_process, capsys, tmp_path): + """NO SECOND LIST. Materialized into an empty directory, as + `_build_assembly_root` does, the root holds exactly + `assembly_root_paths()`: every file is refused as a leg path, and so is + every directory they are written into.""" + template = REPO / "templates" / "assembly-root" + values = {name[2:-2]: "x" for path in template.rglob("*") if path.is_file() + for name in PLACEHOLDER_RE.findall(path.read_text(UTF8))} + root = tmp_path / "root" + root.mkdir() + materialize_assembly_root(REPO, root, values) + files = [path for path in listing(root) if (root / path).is_file()] + directories = [path for path in listing(root) if (root / path).is_dir()] + assert sorted(files) == sorted(assembly_root_paths()) + assert directories, "the shape writes into directories, too" + for path in files: + err = refusal_of(scaffold, capsys, "--spec-path", path) + assert err.startswith(f"{REFUSED}: --spec-path {path!r} is `{path}`.") + for path in directories: + err = refusal_of(scaffold, capsys, "--code-path", path) + assert err.startswith( + f"{REFUSED}: --code-path {path!r} contains `{path}/"), err + + +# --- end to end ------------------------------------------------------------- + +@pytest.mark.parametrize("legs", [ + pytest.param(("--spec-path", "contracts"), id="contracts"), + pytest.param(("--code-path", "project.yaml"), id="project-yaml"), + pytest.param(("--spec-path", "contracts", "--code-path", "project.yaml"), + id="both"), +]) +def test_a_dry_run_refuses_exactly_as_the_real_run(tmp_path, legs): + """On main the dry run said yes and the real run made three + repositories. Now both say the same no, word for word.""" + remotes, work = tmp_path / "remotes", tmp_path / "work" + args = (*BASE_ARGS, *legs, "--local-remote-dir", str(remotes), + "--work-dir", str(work)) + dry = run_script(SCAFFOLD, *args, "--dry-run") + real = run_script(SCAFFOLD, *args) + assert (dry.returncode, real.returncode) == (2, 2), ( + dry.stdout + dry.stderr + real.stdout + real.stderr) + assert dry.stderr == real.stderr + assert dry.stdout == real.stdout == "" + assert real.stderr.startswith(f"{REFUSED}: {legs[0]} {legs[1]!r} ") + assert not remotes.exists() and not work.exists() + + +@pytest.mark.parametrize("flag, value", REPRODUCED) +def test_the_real_run_refuses_before_anything_exists(tmp_path, flag, value): + """The remote directory is listed before and after and has not changed, + and the default work directory, a temporary one, was never made: the + temporary directory this run is given stays empty.""" + remotes = tmp_path / "remotes" + remotes.mkdir() + (remotes / "KEEP").write_text("here before the run\n", encoding=UTF8) + before = listing(remotes) + temporary = tmp_path / "tmp" + temporary.mkdir() + result = run_script(SCAFFOLD, *BASE_ARGS, flag, value, + "--local-remote-dir", str(remotes), + env={"TMPDIR": str(temporary), "TEMP": str(temporary), + "TMP": str(temporary)}) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith(f"{REFUSED}: {flag} {value!r} ") + assert "creating remotes" not in result.stdout + assert listing(remotes) == before == ["KEEP"] + assert list(temporary.iterdir()) == [], "no work directory was made" + + +@WINDOWS_SKIP # the stub `gh` is a `#!` script; Windows executes none +def test_no_gh_command_runs_before_the_refusal(tmp_path): + """Pointed at GitHub, with a `--pin` that `gh api` would read and a + `--reuse-empty-repo` that `gh repo view` would ask about, the refusal + comes first and `gh` is never run. The control is the same run with the + default paths: it reaches `gh`, so the stub would have recorded a call.""" + bin_dir, log = tmp_path / "fakebin", tmp_path / "gh.log" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text(textwrap.dedent("""\ + #!/usr/bin/env python3 + import os, sys + with open(os.environ["GH_STUB_LOG"], "a", encoding="utf-8") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") + print("HTTP 404: Not Found", file=sys.stderr) + sys.exit(1) + """), encoding=UTF8) + gh.chmod(gh.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + env = {"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", + "GH_STUB_LOG": str(log)} + common = (*BASE_ARGS, "--pin", "openGlass@" + "1" * 40, + "--reuse-empty-repo", "--work-dir", str(tmp_path / "work")) + + refused = run_script(SCAFFOLD, *common, "--spec-path", "contracts", + env=env) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.startswith(f"{REFUSED}: --spec-path 'contracts' ") + assert not log.exists(), f"gh was run: {log.read_text(encoding=UTF8)}" + assert not (tmp_path / "work").exists() + + control = run_script(SCAFFOLD, *common, env=env) + assert control.returncode == 2, control.stdout + control.stderr + assert "pin-unreadable" in control.stderr + assert log.read_text(encoding=UTF8).startswith( + "api repos/opensoft/openGlass") + + +def test_a_pair_that_collides_with_nothing_still_scaffolds(tmp_path): + """Beside the shape, not over it: `project` beside `project.yaml`, and + `scripts/code` among the shape's own scripts.""" + remotes = tmp_path / "remotes" + result = run_script(SCAFFOLD, *BASE_ARGS, "--spec-path", "project", + "--code-path", "scripts/code", + "--local-remote-dir", str(remotes), + "--work-dir", str(tmp_path / "work")) + assert result.returncode == 0, result.stdout + result.stderr + tree = git("ls-tree", "-r", "main", + cwd=remotes / f"{PROJECT}.git").stdout.splitlines() + gitlinks = sorted(line.split("\t")[1] for line in tree + if line.startswith("160000")) + assert gitlinks == ["project", "scripts/code"] + paths = {line.split("\t")[1] for line in tree} + assert {"project.yaml", "scripts/bootstrap.py"} <= paths From 87fd7674294bd269f4684e4487629bbfee37346e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:15:27 +0000 Subject: [PATCH 3/3] scaffold-project.py: a leg path at or beyond the `contracts/-pin.yaml` a `--pin` writes is refused before anything is created, from the one `neutral_pin_path` the materializer writes it at, so `--pin openGlass@ --spec-path contracts/openglass-pin.yaml` no longer plans and then makes three repositories (#197) #197's review of aabfae0 (S1) found the one path the materializer writes that the refusal did not know. `_build_assembly_root` hands `materialize_assembly_root` the declared pins and `_materialize` writes each at `contracts/-pin.yaml`, but `assembly_root_paths()` described a call with no pin. On aabfae0, and on main a5b09c3, `--pin openGlass@ --pin-source --spec-path contracts/openglass-pin.yaml` planned under `--dry-run` with exit 0, and the real run created all three repositories, pushed both legs and died at `git submodule add`. `Contracts/OpenGlass-pin.yaml` exited 0 with a gitlink beside the pin, one path on a macOS or Windows disk. ONE DEFINITION. `neutral_pin_path(product)` in scripts/shape_materialize.py is now the tools' only spelling of that path: `_materialize` writes the pin there, the scaffold's plan line and `descendant_note` name it from there, and `assembly_root_paths(neutral_pins=())` lists it between the copies and `contracts/shape-pin.yaml`, where the materializer writes it. `shape_path_collisions` takes the same keyword and asks about that list, and the scaffold passes its `--pin` products. A parameter on the list, rather than the scaffold appending pin paths to it, keeps "every path this call writes, in the order it writes them" in the one function beside the materializer, so the collision set, the remediation's list and the test that holds the list to what was written read one answer. The products come from `declared_pin_products`: `parse_pin` alone, the parser `declared_pin_values` uses, keyed and ordered as its dict is, with no digest and so no process. Adopt hands the materializer no pin and passes none, so its sentences are byte for byte what they were: the review's dump of adopt's collision sentences, re-run on these bytes, has sha256 5eb70cfb684a, the same as aabfae0's, and tests/test_adopt_leg_path_collisions.py is unchanged. The refusal runs where it did: after `checked_value`, before `_identity`, `_declaration`, `_shape_revision`, `_leg_visibility`, the dry-run return, the work directory, the remotes and `git init`. A malformed `--pin` is now refused there, by `parse_pin`'s own `pin-malformed`, rather than in `_declaration`; it was refused before anything was created either way. The remediation lists the pins' files with the rest. With no `--pin`, `contracts/openglass-pin.yaml` lies inside a directory the shape writes into, not a path it writes, and it still plans. Two ordering claims are now tests that run on every OS (review N3). A run without `--elected-by`, with GIT_AUTHOR_NAME unset because CI sets it, refuses with no process started, where its control starts `git config user.name`. With `_declaration` replaced by a recorder, a colliding `--pin` run never calls it, where its control does. Adopt's sentence tail, "`execute` would write it into the mounted leg", is pinned word for word. The `shape_path_collisions` docstring now says adopt passes its `COLLISION_DIR`, `shape` with no slash, and that `plan`, `check` and `execute` all reach it through `leg_path_collisions` (N4). Mutants, each against tests/test_scaffold_leg_path_collisions.py (104 tests): the scaffold passing no pins, 11 failed; `assembly_root_paths` dropping the pins, 10 failed; `neutral_pin_path` not lowercasing, 9 failed; the refusal moved after `_identity`, 1 failed, the new no-elector test alone; moved after `_declaration`, 13 failed, the new recorder test among them. Runs on these bytes: test_scaffold_leg_path_collisions, test_adopt_leg_path_collisions and test_scaffold_pin_and_reuse, 216 passed. On their merge with main a5b09c3, those three plus test_scaffold_e2e, test_scaffold_ignored_shape_files, test_adopt_leg_paths, test_shape_doctor, test_cli_reference, test_commit_trailers, test_adopt_mount_ignored and test_scaffold_leg_paths, 999 passed. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scaffold-project.py | 70 +++++-- scripts/shape_materialize.py | 75 +++++-- tests/test_scaffold_leg_path_collisions.py | 231 +++++++++++++++++++-- 3 files changed, 319 insertions(+), 57 deletions(-) diff --git a/scaffold-project.py b/scaffold-project.py index 50ab7600..67ca6f35 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -10,7 +10,8 @@ creates anything, so a naming mistake costs a message rather than three repositories and a rename. A leg path that is, holds or lies inside a path the shape writes into the assembly root (`contracts`, - `project.yaml`) is refused just as early, for the same reason (#197). + `project.yaml`, a `--pin`'s `contracts/-pin.yaml`) is refused + just as early, for the same reason (#197). 2. Creates the three remotes: `gh repo create` normally, or three BARE repositories in a directory with `--local-remote-dir`, which is the test path and touches no network. @@ -63,9 +64,10 @@ ) from shape_advisory import scaffold_lines # noqa: E402 # The shape half of the leg-path collision rule, which `adopt-project.py` -# asks of its own mount paths (#172), imported as one unit (#197). +# asks of its own mount paths (#172), imported as one unit (#197), with the +# one spelling of the file a `--pin` adds to what the shape writes. from shape_materialize import ( # noqa: E402 - assembly_root_paths, shape_path_collisions, + assembly_root_paths, neutral_pin_path, shape_path_collisions, ) #: The pin argument: `openGlass@<40 hex>`, optionally organisation-qualified. @@ -347,6 +349,20 @@ def declared_pin_values(args) -> dict[str, dict[str, str]]: return pins +def declared_pin_products(args) -> dict[str, None]: + """The products `declared_pin_values` keys its pins by, in its order and + read by the same `parse_pin`, with no digest: so no `git` and no `gh`. + + #197: `_build_assembly_root` hands those pins to the materializer, which + writes `neutral_pin_path(product)` for each, and the leg paths are asked + about that file before `_identity` and `_declaration` run, which is + before any digest is read. A malformed `--pin` is therefore refused + here, by `parse_pin`'s own `pin-malformed`, rather than in + `_declaration`; it was refused before anything was created either way. + """ + return dict.fromkeys(parse_pin(raw, args.pin_owner)[0] for raw in args.pin) + + def _reusable_remotes(names: dict, urls: dict, repositories: dict, local: bool, reuse_flag: bool) -> set[str]: """Which remotes already exist, and whether that is allowed. @@ -399,19 +415,22 @@ def _exists_remedy(role: str, empty: bool, reuse_flag: bool) -> str: "--force.") -#: What a leg-path collision tells the person to do. The paths are -#: `assembly_root_paths()`, the materializer's own list, never a copy of it. -LEG_COLLISION_REMEDIATION = ( - "Remediation: nothing was created, and nothing here picks a path for " - "you, so pass another one yourself. A leg path neither is, holds nor " - "lies inside, whatever the case, a path the shape writes into the " - "assembly root (" - + ", ".join(f"`{path}`" for path in assembly_root_paths()) - + "). The defaults `spec` and `code` pass." -) +def _leg_collision_remediation(neutral_pins=()) -> str: + """What a leg-path collision tells the person to do. The paths are + `assembly_root_paths(neutral_pins)`, the materializer's own list for this + run, never a copy of it: each `--pin` adds the file it writes.""" + return ( + "Remediation: nothing was created, and nothing here picks a path for " + "you, so pass another one yourself. A leg path neither is, holds nor " + "lies inside, whatever the case, a path the shape writes into the " + "assembly root (" + + ", ".join(f"`{path}`" for path in assembly_root_paths(neutral_pins)) + + "). The defaults `spec` and `code` pass." + ) -def _refuse_colliding_leg_paths(spec_path: str, code_path: str) -> None: +def _refuse_colliding_leg_paths(spec_path: str, code_path: str, + neutral_pins=()) -> None: """Neither leg is mounted over a path the shape writes into the assembly root, or this refuses (#197). @@ -432,14 +451,25 @@ def _refuse_colliding_leg_paths(spec_path: str, code_path: str) -> None: without anybody editing this tool. It is asked with no collision directory, because the scaffold hands the materializer none and writes nothing under adopt's `shape/`. + + A `--pin` IS ONE MORE PATH. `_build_assembly_root` hands the materializer + `neutral_pins=pins`, which writes `neutral_pin_path(product)` for each, + so `neutral_pins` is those products (`declared_pin_products`) and they + are asked about with the rest. Without them, `--pin openGlass@ + --spec-path contracts/openglass-pin.yaml` planned and then made three + repositories, and `Contracts/OpenGlass-pin.yaml` exited 0 with a gitlink + beside the pin, one path on a macOS or Windows disk. With no `--pin`, + `contracts/openglass-pin.yaml` lies inside a directory the shape writes + into, not a path it writes, and it mounts. """ collisions = [sentence for what, value in (("--spec-path", spec_path), ("--code-path", code_path)) - for sentence in shape_path_collisions(what, value)] + for sentence in shape_path_collisions( + what, value, neutral_pins=neutral_pins)] if collisions: raise Refusal("scaffold-leg-path-collides", " ".join(collisions), - LEG_COLLISION_REMEDIATION) + _leg_collision_remediation(neutral_pins)) def _elector(args) -> str: @@ -679,7 +709,7 @@ def _print_plan(args, values: dict, names: dict, repositories: dict, print(f"pin {pin_values['PIN_REPOSITORY']} @ " f"{pin_values['PIN_COMMIT'][:12]} tree " f"{pin_values['PIN_TREE_SHA256'][:12]}… -> " - f"contracts/{product.lower()}-pin.yaml ({pin_values['PIN_DIGEST_SOURCE']})") + f"{neutral_pin_path(product)} ({pin_values['PIN_DIGEST_SOURCE']})") _print_declared_classification(policy, names, declared_pins, chain, link_pins, pins) _print_plan_actions(args, values["TOPIC"], local) @@ -916,8 +946,10 @@ def _scaffold(args) -> int: # for a `--pin` with no `--pin-source`, `_leg_visibility` asks `gh repo # view` about a reused root, and `--dry-run` returns only after all of # them. Refused here, a dry run refuses exactly as the real run would, - # and no process has been started. - _refuse_colliding_leg_paths(args.spec_path, args.code_path) + # and no process has been started. Each `--pin`'s file is among the + # shape's paths, its product read off the argument by `parse_pin` alone. + _refuse_colliding_leg_paths(args.spec_path, args.code_path, + declared_pin_products(args)) project_id, display, elected_on, reference, elected_by = _identity( args, project) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 9e02099d..a7203c68 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -206,7 +206,7 @@ def default_reference(elected_on: str) -> str: # copies above after they have been written. ) #: Rendered only when the project DECLARES a neutral-product pin, and written -#: once per declared pin as `contracts/-pin.yaml`. +#: once per declared pin at `neutral_pin_path(product)`. NEUTRAL_PIN_TEMPLATE = "contracts/neutral-product-pin.yaml" EXECUTABLE = ("scripts/validate-pins.py", "scripts/validate-manifest.py", BOOTSTRAP, VALIDATE_NAMING) @@ -215,6 +215,23 @@ def default_reference(elected_on: str) -> str: #: and a second spelling is how that list would stop matching what is written. SHAPE_PIN = "contracts/shape-pin.yaml" + +def neutral_pin_path(product: str) -> str: + """Where a declared neutral-product pin is written: `contracts/-pin.yaml`, `contracts/openglass-pin.yaml` for `openGlass`. + + SPELLED ONCE, for `SHAPE_PIN`'s reason (#197). `_materialize` writes the + pin here, `assembly_root_paths` lists it for a call that declares one, + and `scaffold-project.py` refuses a leg path over it from that list. The + refusal first read a list with no pin in it, so `--pin openGlass@ + --spec-path contracts/openglass-pin.yaml` planned under `--dry-run`, then + created all three repositories and died at `git submodule add`. The + validators a project carries spell it again: they ship into the project + and import nothing from here. + """ + return f"contracts/{product.lower()}-pin.yaml" + + # --------------------------------------------------------------------------- # The FAMILY root's own lists (2026-09-04) # --------------------------------------------------------------------------- @@ -1024,7 +1041,7 @@ def descendant_note(policy: NamingPolicy, name: str, role: str, f"descendant because {found.referent.reason}") return (f"NOTE {name} also matches the descendant form; it is not a " f"descendant because no pin on {referent} is declared — declare " - f"`contracts/{referent.lower()}-pin.yaml` later if it becomes one, " + f"`{neutral_pin_path(referent)}` later if it becomes one, " f"or record the chain that reaches it") @@ -1071,21 +1088,28 @@ def materialize_assembly_root(shape_root: Path, target: Path, collision_dir=collision_dir, append=append, neutral_pins=neutral_pins) -def assembly_root_paths() -> tuple[str, ...]: +def assembly_root_paths(neutral_pins=()) -> tuple[str, ...]: """Every path `materialize_assembly_root` writes when nothing is in the - way and no neutral-product pin is declared, in the order it writes them. - - READ FROM THE LISTS THAT CALL HANDS `_materialize`, plus the pin it - writes last, never kept by hand: `adopt-project.py` refuses a leg mount - path that overlaps one of these (#172), and a second list would let the - shape gain a file the refusal does not know about. `EXECUTABLE` adds no - path (it marks paths the lists already write), and a collision moves a - path under the caller's `collision_dir`, which is the caller's to name. - `tests/test_adopt_leg_path_collisions.py` materializes into an empty - directory and holds this to what was written. + way, in the order it writes them. `neutral_pins` names the products of + the `neutral_pins` that call is handed (iterating that mapping yields + them); with none, no neutral-product pin is declared. + + READ FROM THE LISTS THAT CALL HANDS `_materialize`, plus the file each + declared pin is written to (`neutral_pin_path`) and the pin it writes + last, never kept by hand: `adopt-project.py` (#172) and + `scaffold-project.py` (#197) refuse a leg mount path that overlaps one of + these, and a second list would let the shape gain a file the refusal + does not know about. `EXECUTABLE` adds no path (it marks paths the lists + already write), and a collision moves a path under the caller's + `collision_dir`, which is the caller's to name. + `tests/test_adopt_leg_path_collisions.py` and + `tests/test_scaffold_leg_path_collisions.py` materialize into an empty + directory and hold this to what was written, the second with pins too. """ return (*TEMPLATED, *COPIED_VERBATIM, - *(rel for _, rel in COPIED_FROM_SHAPE), SHAPE_PIN) + *(rel for _, rel in COPIED_FROM_SHAPE), + *(neutral_pin_path(product) for product in neutral_pins), + SHAPE_PIN) # --------------------------------------------------------------------------- @@ -1138,7 +1162,8 @@ def first_and_more(paths: list[str]) -> str: def shape_path_collisions(what: str, value: str, - collision_dir: str | None = None) -> list[str]: + collision_dir: str | None = None, *, + neutral_pins=()) -> list[str]: """Where the mount path `value` overlaps a path of `assembly_root_paths`: one sentence, naming the first path it overlaps and counting the rest, or none. `what` names the value as its caller does (`--spec-path`, @@ -1146,17 +1171,25 @@ def shape_path_collisions(what: str, value: str, `collision_dir` IS THE ONE THE CALLER HANDS `materialize_assembly_root`, so the two callers differ here by the argument they differ by there. - `adopt-project.py execute` passes `shape/`: it mounts the legs FIRST and - materializes over them, so a shape file at a mount goes into the mounted - leg or beside it under that directory, and the sentence names `execute` - because it is the caller that passes one. Whether `value` overlaps + `adopt-project.py` passes its `COLLISION_DIR`, `shape` with no slash, + from `_shape_collisions`, which `plan`, `check` and `execute` all reach + through `leg_path_collisions`. `execute` is the act that mounts the legs + FIRST and materializes over them, so a shape file at a mount goes into + the mounted leg or beside it under that directory, and the sentence + names `execute` whichever of the three asks. Whether `value` overlaps `collision_dir` itself is the caller's question, not this one's. `scaffold-project.py` passes none (#197): it materializes into an empty directory and mounts the legs AFTER, so `git submodule add` meets a path the shape already wrote. + + `neutral_pins` IS THE SAME KIND OF ARGUMENT: the products whose pins the + caller hands the materializer, so the paths asked about are + `assembly_root_paths(neutral_pins)`, each pin's file among them. The + scaffold passes its `--pin` products (#197); adopt hands the materializer + no pin and passes none, so its list and its sentences are unchanged. """ out: list[str] = [] - files = [path for path in assembly_root_paths() + files = [path for path in assembly_root_paths(neutral_pins) if path_overlap(value, path)] if files: relation, aside = path_overlap(value, files[0]) @@ -1250,7 +1283,7 @@ def place(rel: str, write) -> str: result.shape_files.append( place(rel, lambda p, t=text: write_lf(p, t))) for product, pin_values in (neutral_pins or {}).items(): - rel = f"contracts/{product.lower()}-pin.yaml" + rel = neutral_pin_path(product) text = render((template_root / NEUTRAL_PIN_TEMPLATE) .read_text(encoding="utf-8"), {**values, **pin_values}, rel) place(rel, lambda p, t=text: write_lf(p, t)) diff --git a/tests/test_scaffold_leg_path_collisions.py b/tests/test_scaffold_leg_path_collisions.py index 5eb9042d..68ca4d36 100644 --- a/tests/test_scaffold_leg_path_collisions.py +++ b/tests/test_scaffold_leg_path_collisions.py @@ -18,6 +18,13 @@ too. The scaffold hands the materializer no collision directory, so adopt's `shape/` is not on its list. +A `--pin` adds a path: the materializer writes each declared pin at +`neutral_pin_path(product)`, `contracts/openglass-pin.yaml` for `openGlass`. +The refusal first asked about a list with no pin in it, so a leg on that file +planned and then made three repositories (#197's review, S1). The scaffold now +asks about `assembly_root_paths()`, the list the +materializer writes for that run. + Most tests call `main()` in this process with every process start refused, which shows the refusal comes before git, before `gh` and before the work directory. The end-to-end tests run the script against a local remote @@ -26,6 +33,7 @@ from __future__ import annotations +import argparse import importlib.util import os import stat @@ -43,6 +51,7 @@ import shape_materialize # noqa: E402 from shape_materialize import ( # noqa: E402 CASE_ASIDE, PLACEHOLDER_RE, assembly_root_paths, materialize_assembly_root, + neutral_pin_path, ) PROJECT = "Northwind" @@ -60,6 +69,11 @@ REMEDIATION_ENDS = "The defaults `spec` and `code` pass." BASE_ARGS = ("--org", ORG, "--project", PROJECT, "--elected-by", "Test Human", "--elected-on", "2026-09-02") +#: The file `--pin openGlass@` writes beside the manifest, spelled out +#: here so the tests say what `neutral_pin_path` must return. +GLASS_PIN = "contracts/openglass-pin.yaml" +#: A commit for a `--pin` the refusal comes before: nothing reads its digest. +ANY_COMMIT = "1" * 40 #: `(role, value, relation, what it names)`: a directory the shape writes #: into, at the top and nested, and a path beyond a FILE it writes. A path @@ -94,10 +108,24 @@ #: of a file the source already holds there; the scaffold writes nothing #: there, because it hands the materializer no collision directory. `code` #: is not here: beside the default it is two legs at one path, which is -#: #179's refusal, not this one's. +#: #179's refusal, not this one's. `GLASS_PIN` is clear WITHOUT a `--pin`: +#: nothing writes it then, and it lies inside a directory the shape writes +#: into, as `contracts/spec` does. CLEAR = ["spec", "legs/spec", "legs/.github", "scripts/spec", "contracts/spec", ".github/spec", "Scripts/x", "project", "README", - "Makefile.d/code", "shape", "shape/spec"] + "Makefile.d/code", "shape", "shape/spec", GLASS_PIN] + +#: `(value, relation, aside)` with `--pin openGlass@`: the pin's file, +#: a path beyond it, and the file with capitals in the way. The overlap is a +#: question about two strings, never about a disk, so the aside is said on +#: every platform: a case-insensitive disk would meet the pin there, and a +#: case-sensitive one would keep two paths that no other disk can. +PINNED = [ + pytest.param(GLASS_PIN, "is", "", id="the-pin-file"), + pytest.param(f"{GLASS_PIN}/x", "is inside", "", id="beyond-the-pin-file"), + pytest.param("Contracts/OpenGlass-pin.yaml", "is", CASE_ASIDE, + id="the-pin-file-capitals"), +] #: The issue's reproductions, and the two more found probing it: each made #: three repositories on main, and the last of them made a broken project. @@ -129,6 +157,28 @@ def adopter(): return module +@pytest.fixture(scope="module") +def glass(tmp_path_factory): + """A local stand-in for `openGlass` and its commit, for `--pin-source`: + a pinned run that gets past the refusal reads the pin's digest here.""" + path = tmp_path_factory.mktemp("glass") / "openGlass" + path.mkdir() + git("init", "-q", "-b", "main", cwd=path) + (path / "README.md").write_text("# openGlass\n", encoding=UTF8) + git("add", "-A", cwd=path) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "seed", + cwd=path) + return path, git("rev-parse", "HEAD", cwd=path).stdout.strip() + + +def pinned(glass, *products: str) -> tuple[str, ...]: + """`--pin @` for each product, read from `glass`.""" + source, commit = glass + pins = [arg for product in products + for arg in ("--pin", f"{product}@{commit}")] + return (*pins, "--pin-source", str(source)) + + class ProcessStarted(AssertionError): """A process was started where the refusal should already have come.""" @@ -226,6 +276,45 @@ def test_a_path_that_overlaps_nothing_the_scaffold_writes_plans( assert "--dry-run: nothing was created." in out +# --- a `--pin`'s file is a path the shape writes ---------------------------- + +@pytest.mark.parametrize("role", ROLES) +@pytest.mark.parametrize("value, relation, aside", PINNED) +def test_a_pin_file_is_refused_as_either_leg( + scaffold, glass, no_process, capsys, role, value, relation, aside): + """With `--pin openGlass@` the shape writes `GLASS_PIN`, so a leg + there is refused in the words every shape path gets, before the pin's + digest is read from the `--pin-source`, and the remediation lists it.""" + err = refusal_of(scaffold, capsys, f"--{role}-path", value, + *pinned(glass, "openGlass")) + assert err.startswith( + f"{REFUSED}: --{role}-path {value!r} {relation} `{GLASS_PIN}`{aside}. " + f"{says('it')}\n"), err + remediation = err.split("\n", 1)[1] + assert ("(" + ", ".join(f"`{path}`" + for path in assembly_root_paths(["openGlass"])) + + ")") in remediation + + +def test_every_pin_s_file_is_refused_and_counted(scaffold, glass, no_process, + capsys): + """A second `--pin` writes a second file, named by its product alone as + the materializer names it, owner or not, and `contracts` then holds two + more of the shape's paths.""" + pins = pinned(glass, "openGlass", "ExplicitOwner/openPane") + err = refusal_of(scaffold, capsys, "--spec-path", + "contracts/openpane-pin.yaml", "--code-path", GLASS_PIN, + *pins) + assert err.split("\n", 1)[0] == ( + f"{REFUSED}: --spec-path 'contracts/openpane-pin.yaml' is " + f"`contracts/openpane-pin.yaml`. {says('it')} " + f"--code-path {GLASS_PIN!r} is `{GLASS_PIN}`. {says('it')}") + err = refusal_of(scaffold, capsys, "--spec-path", "contracts", *pins) + assert err.startswith( + f"{REFUSED}: --spec-path 'contracts' contains " + f"`contracts/spec-pin.yaml` and 5 more. {says('them')}\n"), err + + # --- one definition, asked by both tools ------------------------------------- def test_both_tools_ask_the_one_definition(adopter, scaffold): @@ -242,29 +331,34 @@ def test_both_tools_ask_the_one_definition(adopter, scaffold): assert adopter._first_and_more is shape_materialize.first_and_more assert adopter.MOVE_THE_LEG is shape_materialize.MOVE_THE_LEG assert not hasattr(adopter, "CASE_ASIDE"), "a second CASE_ASIDE in adopt" + assert scaffold.neutral_pin_path is shape_materialize.neutral_pin_path + assert neutral_pin_path("openGlass") == GLASS_PIN def test_both_tools_refuse_through_it(adopter, scaffold, no_process, capsys, monkeypatch): - """And both CALL it, each with the collision directory it hands the - materializer: adopt `shape`, the scaffold none.""" + """And both CALL it, each with the collision directory and the pins it + hands the materializer: adopt `shape` and none, the scaffold none and + its `--pin` products.""" asked = [] - def spy(what, value, collision_dir=None): - asked.append((what, value, collision_dir)) + def spy(what, value, collision_dir=None, *, neutral_pins=()): + asked.append((what, value, collision_dir, tuple(neutral_pins))) return [f"{what} {value!r} spied."] monkeypatch.setattr(adopter, "shape_path_collisions", spy) monkeypatch.setattr(scaffold, "shape_path_collisions", spy) assert adopter.leg_path_collisions((("--spec-path", "x"),)) == [ "--spec-path 'x' spied."] - code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", "y"]) + code = scaffold.main([*BASE_ARGS, "--dry-run", "--spec-path", "y", + "--pin", f"openGlass@{ANY_COMMIT}"]) err = capsys.readouterr().err assert code == 2 assert err.startswith(f"{REFUSED}: --spec-path 'y' spied. " "--code-path 'code' spied.\n") - assert asked == [("--spec-path", "x", adopter.COLLISION_DIR), - ("--spec-path", "y", None), ("--code-path", "code", None)] + assert asked == [("--spec-path", "x", adopter.COLLISION_DIR, ()), + ("--spec-path", "y", None, ("openGlass",)), + ("--code-path", "code", None, ("openGlass",))] @pytest.mark.parametrize("path", assembly_root_paths()) @@ -281,31 +375,109 @@ def test_both_tools_name_the_same_overlap(adopter, scaffold, no_process, shape_materialize.shape_path_collisions("--spec-path", path)) +def test_adopt_still_says_what_execute_would_write(adopter): + """The tail the shared sentence takes for adopt, which the comparison + above stops short of, word for word (#197's review, N3).""" + said, = adopter.leg_path_collisions((("--spec-path", "project.yaml"),)) + assert said == ( + "--spec-path 'project.yaml' is `project.yaml`. The shape writes it " + "into the assembly root, and `execute` would write it into the " + "mounted leg or beside it under `shape/`, leaving the root without " + "it: choose another path for the leg.") + + +@pytest.mark.parametrize("products", [ + pytest.param((), id="no-pin"), + pytest.param(("openGlass",), id="one-pin"), + pytest.param(("openGlass", "openPane"), id="two-pins"), +]) def test_the_paths_refused_are_the_paths_the_materializer_writes( - scaffold, no_process, capsys, tmp_path): + scaffold, no_process, capsys, tmp_path, products): """NO SECOND LIST. Materialized into an empty directory, as - `_build_assembly_root` does, the root holds exactly - `assembly_root_paths()`: every file is refused as a leg path, and so is - every directory they are written into.""" + `_build_assembly_root` does, with each pin handed over as it hands them, + the root holds exactly `assembly_root_paths(products)`, in the order + written: every file is refused as a leg path once the same `--pin`s are + passed, and so is every directory they are written into.""" template = REPO / "templates" / "assembly-root" values = {name[2:-2]: "x" for path in template.rglob("*") if path.is_file() for name in PLACEHOLDER_RE.findall(path.read_text(UTF8))} root = tmp_path / "root" root.mkdir() - materialize_assembly_root(REPO, root, values) + materialized = materialize_assembly_root( + REPO, root, values, neutral_pins={product: {} for product in products}) files = [path for path in listing(root) if (root / path).is_file()] directories = [path for path in listing(root) if (root / path).is_dir()] - assert sorted(files) == sorted(assembly_root_paths()) + assert tuple(materialized.written) == assembly_root_paths(products) + assert sorted(files) == sorted(assembly_root_paths(products)) + assert {neutral_pin_path(product) for product in products} <= set(files) assert directories, "the shape writes into directories, too" + pins = [arg for product in products + for arg in ("--pin", f"{product}@{ANY_COMMIT}")] for path in files: - err = refusal_of(scaffold, capsys, "--spec-path", path) + err = refusal_of(scaffold, capsys, "--spec-path", path, *pins) assert err.startswith(f"{REFUSED}: --spec-path {path!r} is `{path}`.") for path in directories: - err = refusal_of(scaffold, capsys, "--code-path", path) + err = refusal_of(scaffold, capsys, "--code-path", path, *pins) assert err.startswith( f"{REFUSED}: --code-path {path!r} contains `{path}/"), err +def test_the_products_asked_about_are_the_ones_the_materializer_gets( + scaffold, glass): + """The refusal reads each `--pin`'s product before any digest exists, + and `_build_assembly_root` hands the materializer `declared_pin_values`: + the same products in the same order, an owner and a repeat included.""" + source, commit = glass + args = argparse.Namespace( + pin=[f"openGlass@{commit}", f"ExplicitOwner/openPane@{commit}", + f"openGlass@{commit}"], + pin_owner="opensoft", pin_source=source) + products = scaffold.declared_pin_products(args) + assert list(products) == list(scaffold.declared_pin_values(args)) + assert list(products) == ["openGlass", "openPane"] + + +# --- the order: before `_identity` and before `_declaration` ---------------- + +def test_the_refusal_comes_before_git_is_asked_who_elected( + scaffold, no_process, capsys, monkeypatch): + """Without `--elected-by`, `_identity` asks `git config user.name`; the + control shows it, so the refusal that starts no process came before it + (#197's review, N3). `GIT_AUTHOR_NAME` answers first when it is set, and + CI sets it, so it is unset here.""" + monkeypatch.delenv("GIT_AUTHOR_NAME", raising=False) + unelected = ["--org", ORG, "--project", PROJECT, + "--elected-on", "2026-09-02", "--dry-run"] + with pytest.raises(ProcessStarted, match="user.name"): + scaffold.main(unelected) + code = scaffold.main([*unelected, "--spec-path", "contracts"]) + out, err = capsys.readouterr() + assert (code, out) == (2, ""), err + assert err.startswith(f"{REFUSED}: --spec-path 'contracts' contains "), err + + +def test_the_refusal_comes_before_the_declaration_is_read( + scaffold, no_process, capsys, monkeypatch): + """`_declaration` reads each `--pin`'s digest, with `gh api` when no + `--pin-source` is given. The `gh` stub below shows `gh` never runs, on + POSIX only; this shows on every platform that `_declaration` is never + called (#197's review, N3), and the control that the same command line + reaches it once nothing collides.""" + called = [] + + def declaration(args): + called.append(list(args.pin)) + return {}, set(), (), {} + + monkeypatch.setattr(scaffold, "_declaration", declaration) + asks_gh = ("--pin", f"openGlass@{ANY_COMMIT}", "--reuse-empty-repo") + refusal_of(scaffold, capsys, "--spec-path", "contracts", *asks_gh) + assert called == [] + with pytest.raises(ProcessStarted): + scaffold.main([*BASE_ARGS, "--dry-run", *asks_gh]) + assert called == [[f"openGlass@{ANY_COMMIT}"]] + + # --- end to end ------------------------------------------------------------- @pytest.mark.parametrize("legs", [ @@ -352,6 +524,31 @@ def test_the_real_run_refuses_before_anything_exists(tmp_path, flag, value): assert list(temporary.iterdir()) == [], "no work directory was made" +def test_a_pinned_run_refuses_before_anything_exists(tmp_path, glass): + """On #197's first head this dry run said yes and the real run made + three repositories. The control reads the pin from the local + `--pin-source` and plans its file; with a leg on that file the dry run + and the real run say the same no, and nothing exists afterwards.""" + remotes, work = tmp_path / "remotes", tmp_path / "work" + remotes.mkdir() + (remotes / "KEEP").write_text("here before the run\n", encoding=UTF8) + args = (*BASE_ARGS, *pinned(glass, "openGlass"), + "--local-remote-dir", str(remotes), "--work-dir", str(work)) + control = run_script(SCAFFOLD, *args, "--dry-run") + assert control.returncode == 0, control.stdout + control.stderr + assert f"-> {GLASS_PIN} (local-clone (" in control.stdout + dry = run_script(SCAFFOLD, *args, "--spec-path", GLASS_PIN, "--dry-run") + real = run_script(SCAFFOLD, *args, "--spec-path", GLASS_PIN) + assert (dry.returncode, real.returncode) == (2, 2), ( + dry.stdout + dry.stderr + real.stdout + real.stderr) + assert dry.stderr == real.stderr + assert dry.stdout == real.stdout == "" + assert real.stderr.startswith( + f"{REFUSED}: --spec-path {GLASS_PIN!r} is `{GLASS_PIN}`. ") + assert listing(remotes) == ["KEEP"] + assert not work.exists() + + @WINDOWS_SKIP # the stub `gh` is a `#!` script; Windows executes none def test_no_gh_command_runs_before_the_refusal(tmp_path): """Pointed at GitHub, with a `--pin` that `gh api` would read and a