Skip to content

scaffold-project.py --pin spec@<sha> (or code@, shape@, or two --pins whose products differ only by case) passes --dry-run, then creates all three repositories, pushes both legs and dies with REFUSED materialize-collision, because the pin's contracts/<product>-pin.yaml is a file the shape already writes #241

Closed

Description

@brettheap

Lane: openreposhape-4 (openRepoShape-4)

Found by this lane's #197 fix-round writer (as out-of-scope) and confirmed by this lane's re-review of the #197 branch (87fd767, PR #234; its check 9 and nit N1), re-verified on main 54aa69a today, and on 87fd767 as well. It is pre-existing on main and not a regression of that branch. Not claimed.

What is wrong. --pin <Product>@<40 hex> declares a descent and the scaffold writes it as contracts/<product lowercased>-pin.yaml beside the manifest. The product is whatever PIN_ARG_RE lets through ([A-Za-z0-9_.-]+), and nothing asks whether the file that name produces is one the shape writes anyway, or one another --pin produces. Three product names are: spec (contracts/spec-pin.yaml, the spec leg's own pin), code (contracts/code-pin.yaml) and shape (contracts/shape-pin.yaml, the copy pin); they are the only three entries of assembly_root_paths() that have the form contracts/<product>-pin.yaml. Two products that differ only by case (openGlass, OpenGlass) are one file on every platform, because the materializer lowercases. The plan says nothing: its own pin line prints the colliding path (-> contracts/spec-pin.yaml; for the case pair, two lines with the same target) and --dry-run exits 0. The real run then creates all three repositories and pushes both legs (_create_remotes, _seed_legs), and only _build_assembly_root reaches the materializer, which refuses with materialize-collision because the scaffold gives it no collision directory. The root is never committed. README's "If it fails part-way" box (README.md:446-458) calls the plan "literally a --dry-run" and says there is no rollback, so the typed yes is given over a plan that cannot succeed, and a corrected re-run is refused as scaffold-remote-exists. Against GitHub the three repositories are real. This is #197's harm (PR #234 refuses a leg path over these files) reached by a different argument: the input is a --pin product, and it needs no leg flag at all.

# $U: a clean checkout at 54aa69a. gh is a PATH shim that logs its argv and exits 97 (never called: its log was never created).
# $C: a local clone of a stand-in neutral product (`openGlass`), $SHA a commit in it, so `--pin-source` reads the digest and no network is touched.
# Every repository is a bare repository under $R/remotes; each row below used a fresh one.
scaffold="python3 $U/scaffold-project.py --org testorg --project Northwind --elected-by 'Test Human' \
          --elected-on 2026-09-02 --local-remote-dir $R/remotes --pin-source $C"
$scaffold --work-dir $R/work --pin spec@$SHA --dry-run
#   exit 0   pin   opensoft/spec @ 26ddb2a53b99 tree 5e5f4610bcaa… -> contracts/spec-pin.yaml (local-clone ($C))
#            --dry-run: nothing was created.
$scaffold --work-dir $R/work --pin spec@$SHA
#   exit 2   REFUSED materialize-collision: $R/work/Northwind/contracts/spec-pin.yaml already exists and this materializer was given no collision directory
#            Remediation: scaffold into an empty directory, or call with collision_dir set (which is what adopt does).
ls $R/remotes                    # Northwind-code.git  Northwind-spec.git  Northwind.git
for r in Northwind Northwind-spec Northwind-code; do git --git-dir $R/remotes/$r.git for-each-ref; done
#   Northwind: no refs (the root was never committed).  Northwind-spec, Northwind-code: refs/heads/main (pushed)
# the corrected re-run, same remotes directory, a pin that does not collide:
$scaffold --work-dir $R/work2 --pin openGlass@$SHA
#   exit 2   REFUSED scaffold-remote-exists: $R/remotes/Northwind.git already exists
$scaffold --work-dir $R/work3 --pin openGlass@$SHA --reuse-empty-repo
#   exit 2   REFUSED scaffold-remote-exists: $R/remotes/Northwind-spec.git already exists and has commits

The same on 54aa69a, one row per input (every failing real run left all three bare repositories, both legs pushed and a root with no refs; gh was never called):

--pin arguments (with --pin-source $C) --dry-run real run collides with, and where it dies
spec@$SHA 0 2 contracts/spec-pin.yaml, templated for the spec leg (shape_materialize.py:198); the neutral-pin loop (:1163-1167) finds it written
Spec@$SHA 0 2 the same file: parse_pin keeps the case, the materializer lowercases (:1164)
code@$SHA, MedxSoft/code@$SHA 0 2 contracts/code-pin.yaml (:199), at :1167; the owner prefix is dropped by parse_pin (scaffold-project.py:100)
shape@$SHA 0 2 contracts/shape-pin.yaml: the neutral pin is placed first, so the copy pin, written LAST (:1179), finds it
openGlass@$SHA and OpenGlass@$SHA 0 2 contracts/openglass-pin.yaml twice; the second dies at :1167
openGlass@$SHA (control) 0 0 none; three repositories, the root holds contracts/openglass-pin.yaml
openGlass@$SHA1 and openGlass@$SHA2 0 0 not a collision, but the same dict: the first pin is silently dropped (see Why)

Why. PIN_ARG_RE (scaffold-project.py:67) takes any name; parse_pin (:74-102) returns the product as typed, owner stripped (:100); declared_pin_values (:318-340) keys the pins by that product (:335). _scaffold calls _declaration (:906), prints the plan and returns on --dry-run (:946-948); nothing between compares a pin's file with the shape's other paths or with another pin's. The remotes are created at :967 and the legs seeded and pushed at :970, and only then does _build_assembly_root hand the pins to materialize_assembly_root(..., neutral_pins=pins) (:798-799, collision_dir=None on purpose: "a collision here is a defect"). _materialize writes the templated files, the verbatim copies and the copies from the shape first, then one file per pin at contracts/{product.lower()}-pin.yaml (shape_materialize.py:1163-1167), then the copy pin last (:1179), each through place, which raises materialize-collision when the path exists and there is no collision directory (:1129-1139, raised at :1134). Where each dies, from a recorder on Refusal.__init__: it printed shape_materialize.py:1134 called from :1167 for spec@, code@ and the case pair, and called from :1179 for shape@. The identical-product row is the same dict: pins[product] = {...} replaces the earlier entry, so --pin openGlass@$SHA1 --pin openGlass@$SHA2 plans one pin line, exits 0 and writes commit: "$SHA2" into contracts/openglass-pin.yaml; $SHA1 is dropped without a word. A declaration is a claim about what the project descends from, and one the operator typed disappears.

What it should do. Refuse before anything is created and before --dry-run returns, naming the product and the file, so a dry run refuses exactly as the real run would. PR #234 (#197) already provides what a fix needs, and they should be reused rather than respelled: at 87fd767 neutral_pin_path(product) is the one definition of the file (shape_materialize.py:219-232), assembly_root_paths(neutral_pins=()) lists what the materializer writes (:1091-1112), and scaffold-project.py reads each --pin's product by parse_pin alone, before _identity and any gh, in declared_pin_products (:352-363) and _refuse_colliding_leg_paths(...) (call at :951-952). A refusal beside that call would ask, for each --pin: is neutral_pin_path(product) in assembly_root_paths() with no pins, and is it the path of an earlier --pin? neutral_pin_path already lowercases, so Spec and the case pair fall out with no case rule of their own. The decisions are the author's:

  1. The refusal's wording. A neutral product is named for the repository it pins (openGlass); spec, code and shape are the three names whose file the shape owns, so the remediation can say that, and for the case pair say that the two names are one pin file.
  2. The identical product given twice (--pin openGlass@$SHA --pin openGlass@$SHA, or with two commits). Today the last one wins, silently. It can be refused as the same collision, or accepted when the commits agree; either way, say it. declared_pin_products is dict.fromkeys(...) (:363 at 87fd767), which collapses a repeat, so a check over its result would not see one.
  3. Order against scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a --pin's contracts/<product>-pin.yaml included, is refused before anything is created, under --dry-run alike, through the one definition adopt-project.py asks, so --spec-path contracts no longer creates three repositories and pushes both legs to die at git submodule add (#197) #234's leg-path refusal. For these inputs assembly_root_paths(neutral_pins) lists the pin's path twice, so scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a --pin's contracts/<product>-pin.yaml included, is refused before anything is created, under --dry-run alike, through the one definition adopt-project.py asks, so --spec-path contracts no longer creates three repositories and pushes both legs to die at git submodule add (#197) #234's leg-path message miscounts (its re-review's N1: "contracts/spec-pin.yaml and 4 more"); a pin refusal placed first removes the case.

adopt-project.py is not involved as far as this issue looked: its --pin is a name written into neutral_product_pins: and no file (re-review check 2). Not examined further here.

How to test. In tests/test_scaffold_leg_path_collisions.py if #234 has landed, otherwise a new file, with product_repo (tests/test_scaffold_pin_and_reuse.py:113), --local-remote-dir, --pin-source and a gh stub that logs: (a) for each of spec@, Spec@, code@, MedxSoft/code@, shape@ and the case pair, --dry-run exits 2 with a refusal that names the product and the colliding path, creates nothing (the remotes directory stays empty, no --work-dir), never asks gh, and the real run refuses with the same sentence and the same empty directory; (b) controls: --pin openGlass@$SHA alone, and two different products (openGlass, openPane), still exit 0 and the pushed root holds both pin files; (c) the refusal comes before _identity and before any gh api (no --pin-source, a stub that must not be called, as #234's ordering tests do); (d) the identical-product decision, whichever way it goes, gets one test; (e) removing the check makes (a) fail, so each case is asserted to reach the refusal and not an earlier or later one.

Related: #197 (PR #234; its re-review confirmed this and asked for it to be filed with or before the merge), #229 (PR #235, the other way the scaffold dies after both legs are pushed)

Activity

  1. brettheap commented on Oct 11, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 83338f75-6338-4d51-9749-89179ecaf55f@Eagle, 2026-10-11T21:21:11Z, for #241

    Logged in lanes/log/openRepoShape-4.md at afe9c94d16dc74a48205b26aaa10cd3be91f7f50.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#241:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#241"`, kept where the row or its body names `#241`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `241` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions