Skip to content

scaffold-project.py: the leg mount passes --force to git submodule add, so a leg path an ignore rule matches (__pycache__, .DS_Store, or the default spec under an operator's own excludes) scaffolds instead of dying at the mount after all three repositories exist and both legs are pushed (#229) - #235

Merged
brettheap merged 3 commits into
mainfrom
fix/scaffold-mount-ignored-legs
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

Fixes #229.

What

_build_assembly_root now passes --force to the leg mount's git submodule add, the cure #200 gave adopt for #174. A comment says why. The root is a fresh git init the materializer has just filled, so two sets of rules reach the mount: the shape's own .gitignore and the operator's global excludes. A gitlink, once added, is tracked, and no ignore rule touches it again. --force is git's flag here, not one of this tool's, which still has none.

adopt-project.py pairs its --force with _refuse_an_unmountable_path, which refuses before the add the two things --force would otherwise let through. The scaffold needs no such guard, and each reason was checked rather than assumed:

--help is unchanged, so docs/cli.md is too.

Why (repro on main)

A leg path the shape's .gitignore matches (--code-path __pycache__, legs/__pycache__, .venv, legs.pyc, .DS_Store, or --spec-path __pycache__) planned cleanly under --dry-run. The real run created all three repositories, pushed both legs, then died at the mount with git's "The following paths are ignored by one of your .gitignore files". That left three repositories and no project, and the corrected re-run was refused as scaffold-remote-exists. Worse, an operator whose own global excludes name spec or code could not scaffold with the DEFAULT paths at all, and no spelling of the invocation avoided it. The issue has the table.

Tests

Landing notes

🤖 Generated with Claude Code

Summary by Sourcery

Make scaffolded projects robust to ignored leg paths and inherited Git initialization state.

Bug Fixes:

  • Allow scaffolding to mount legs at paths matched by repository or operator ignore rules instead of failing after creating and pushing the repositories.
  • Prevent operator Git templates from injecting pre-existing submodule stores into newly initialized scaffold roots.

Enhancements:

  • Add end-to-end coverage for ignored mount paths, shape-path collisions, canonical gitlinks, recursive clones, validators, and Git templates.

… add`, so a leg path an ignore rule matches (`__pycache__`, `.DS_Store`, or the default `spec` under an operator's own excludes) scaffolds instead of dying at the mount after all three repositories exist and both legs are pushed (#229)

`_build_assembly_root` passes `--force` to `git submodule add`. The root is a fresh `git init` the materializer has just filled, so two sets of rules reach the mount path: the shape's own `.gitignore` (`__pycache__/`, `*.py[cod]`, `.venv/`, `.DS_Store`), copied into the root, and the operator's own excludes (a global `core.excludesFile`). A leg path either one matches was refused at the mount, which runs after `_create_remotes` and `_seed_legs` have made all three repositories and pushed both legs: the run exited 2 with three repositories and no project, `--dry-run` (which asks no ignore rule anything) had planned it, and a corrected re-run met `scaffold-remote-exists`. The DEFAULT paths failed for an operator whose own excludes name `spec` or `code`, so no spelling of the invocation avoided it. A gitlink once added is tracked, so no rule touches it again. It is #174 (adopt, fixed by #200) on the scaffold side.

`--force` also turns off two other refusals of git's, which adopt's `_refuse_an_unmountable_path` makes itself before the add. The scaffold needs no such guard, and each claim was checked rather than assumed:
- The index holds only the first mount's gitlink (and the `.gitmodules` it wrote) when the second add runs, and `.git/modules` only the first's: the materialized files are staged after both mounts. Logged before each add in a real run, for the default paths and for `legs/spec` with `impl/code`. And `refuse_bad_leg_paths` (#179) lets through no pair that is equal or nested, whatever the case: none of the 21,856 pairs it accepted, out of 24,025 pairs of 155 paths. It is the guard that matters, because with `--force` a second add at the SAME path exits 0 ("Adding existing repo") and the code leg is never mounted.
- Nothing the materializer writes is a symlink, so no mount can lie beyond one: none in `templates/`, none tracked, every file written through `write_lf`, none in the root before the first mount of a real run. A mount inside the other leg is the nesting just refused.
- A path the SHAPE wrote (`contracts`, `project.yaml`) is #197's, and `--force` changes nothing for it. Of 54 leg paths tried on the real pre-mount root with and without `--force`, 49 behave the same both ways (47 are a file or directory the shape writes, a path beneath one, or a case twin of one, and of those 40 exit 128 both ways and 7 mount both ways); only the 5 that an ignore rule matches differ.

tests/test_scaffold_mount_ignored.py runs the real scaffold into `--local-remote-dir`, with a `gh` on PATH that exits 97 if called. Default paths under global excludes naming `code` and `spec`; `--code-path` over `__pycache__`, `legs/__pycache__` (a directory-only rule, which git refuses after the clone) and `legs.pyc` and `.DS_Store` (file rules, refused before it); `--spec-path __pycache__`, the first mount; and `src` as the control. Each case asks git that the rule really matches, then asserts exit 0, a gitlink at each leg path in the pushed root's HEAD at the tip its leg was pushed at (`git ls-tree` on the bare remote, not the exit code), `.gitmodules` naming exactly the two canonical remotes, and a recursive clone that checks out both legs and passes `validate-manifest.py` and `validate-pins.py`. `--code-path README.md`, `scripts` and `contracts` still exit 2 with no gitlink there; the message is not asserted, so the test holds before and after #197.

Dropping `--force` fails 24 of the module's 38 tests: all four checks of each of the six cases that an ignore rule matches, and none of the control's or of the three refusals'. The targeted set, this module and five existing files, passes on this head: 157 passed, 0 failed, 0 skipped (tests/test_scaffold_mount_ignored.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_paths.py, test_scaffold_e2e.py, test_scaffold_pin_and_reuse.py, test_cli_reference.py). `docs/cli.md` is unchanged, since no `--help` text is. `git merge-tree` against fix/scaffold-shape-path-collisions (#197) and fix/decode-git-output-utf8 is clean, and the new module with #197's tests merged into it passes (169 passed).

Closes #229

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

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 4 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes scaffolding failures caused by Git ignore rules matching leg mount paths by passing --force to leg submodule mounts. It adds comprehensive real-scaffold tests covering global excludes, shape ignore patterns, both mount orders, pushed gitlink integrity, recursive consumers, and unchanged refusal of paths already written by the shape.

Sequence diagram for forced leg mounting during scaffolding

sequenceDiagram
    participant Operator
    participant Scaffold as scaffold-project.py
    participant Git
    participant Remote as Git remotes

    Operator->>Scaffold: scaffold project
    Scaffold->>Remote: _create_remotes
    Scaffold->>Remote: _seed_legs
    loop spec and code legs
        Scaffold->>Git: git submodule add --force <leg> <path>
        Git-->>Scaffold: gitlink added despite ignore rules
        Scaffold->>Git: git config -f .gitmodules submodule.<path>.url
    end
    Scaffold->>Git: stage and commit assembly
    Scaffold->>Remote: push project
    Scaffold-->>Operator: successful scaffold
Loading

Flow diagram for ignored and invalid scaffold leg paths

flowchart TD
    A[Build assembly root] --> B{Leg path matches an ignore rule?}
    B -- Yes --> C[git submodule add --force]
    B -- No --> D[git submodule add]
    C --> E[Tracked gitlink at leg path]
    D --> E
    E --> F{Path already written by shape?}
    F -- Yes --> G[Git refuses; existing validation remains]
    F -- No --> H[Both legs mounted and scaffold completes]
Loading

File-Level Changes

Change Details Files
Force leg submodule mounts through Git ignore rules while preserving existing path-safety behavior.
  • Adds Git’s --force flag to both spec and code leg git submodule add operations.
  • Documents why ignored paths are safe to force: the assembly root is freshly initialized, leg paths are distinct and non-nested, and materialized content contains no symlinks.
  • Keeps shape-written paths refused, since --force does not bypass conflicts with existing files or directories.
scaffold-project.py
Adds end-to-end coverage for ignored mount paths and verifies the resulting pushed assembly.
  • Exercises default paths under operator global excludes and custom paths matching directory and file ignore rules, including first- and second-mount cases.
  • Verifies ignore-rule matching, successful scaffolding, absence of gh calls, pushed gitlinks at the leg tips, canonical .gitmodules entries, recursive checkout, and manifest/pin validation.
  • Retains regression checks that mounting over shape-written paths exits 2 without creating a gitlink.
tests/test_scaffold_mount_ignored.py

Assessment against linked issues

Issue Objective Addressed Explanation
#229 Ensure _build_assembly_root can mount spec and code legs when their mount paths are matched by the shape's .gitignore or the operator's global excludes, preventing scaffolding from failing after repositories have already been created and pushed. ✅
#229 Preserve the existing behavior for genuinely unmountable leg paths, such as paths occupied by files or shape-written directories, while allowing ignored-but-otherwise-valid paths. ✅
#229 Add regression coverage for default and custom ignored mount paths, both directory-only and file-pattern rules, spec and code mounts, successful pushed gitlinks, recursive cloning, and unchanged invalid-path behavior. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…te=`, so an operator's `init.templateDir` holding `modules/spec` no longer plants a module store that the leg mount's `--force` reactivates, which pushed a root whose gitlinks were a stranger's commit and whose recursive clone failed (#229)

The review of 5878305 found a third refusal of git's that `--force` on the leg mount turns off and that the commit body did not name: a `.git/modules/<path>` already there is REACTIVATED ("Reactivating local git directory") instead of refused. The assembly root was a fresh `git init`, and `git init` copies the operator's template (`init.templateDir`, or `GIT_TEMPLATE_DIR`) into `.git`, so a template that holds a `modules/spec` store (one made by copying a working Triad root's `.git` does) planted one. Reproduced on 5878305: the scaffold exited 0, the pushed root's `spec` and `code` gitlinks were the planted store's commit and neither leg's tip, and `git clone --recurse-submodules` of it exited 128 (`Fetched in submodule path 'code', but it did not contain ...`). On main, without `--force`, the same input exits 2 at the mount, so 5878305 had turned a loud refusal into a quiet wrong success.

The cure is `git init -q --template= -b <tracking branch>`: an empty template is git's documented way to copy none. The assembly root is a temporary build directory the tool fills and pushes, so no template content belongs in it, and whoever clones the pushed root still gets their own template from `git clone`. Leaving the default template out also leaves `hooks/*.sample`, `info/exclude` and `description` out of the root's `.git`; nothing in the scaffold reads them, and the targeted set below, which clones the root and runs its bootstrap, passes.

The other `git init` calls the scaffold runs never reach a `--force`, so they are left as they were: scaffold-project.py:708 is `git init --bare` for the three local remotes, which are only pushed to and cloned from; `git_init_commit` (scripts/shape_materialize.py:559, called from `_seed_legs` at scaffold-project.py:758) seeds each leg's working tree, which is the SOURCE of the mount and is never mounted into; and the work root is a `mkdir`. The one `--force` is the submodule add in `_build_assembly_root`, and `_refuse_existing_trees` (scaffold-project.py:675) has made the assembly directory empty before its `git init`, so `.git/modules` holds nothing before the first mount and only that mount's store at the second.

The comment in `_build_assembly_root` is corrected where the review found it wrong or stale. It named two refusals `--force` turns off and said neither can arise; it now names three, and says why `--template=` closes the new one. "The index holds only the first mount" was incomplete: when the second mount runs the index holds `.gitmodules` and the first gitlink, and no leg path can be `.gitmodules`, which `refuse_bad_leg_paths` refuses (#201). And "a path the SHAPE wrote ... git refuses it after the clone" was wrong about where git refuses (at the mount, exit 128: `already exists and is not a valid git repo` for `scripts` and `contracts`, `destination path ... already exists and is not an empty directory` for `README.md` and `project.yaml`) and would go stale the day #197 refuses those paths up front. It now says a leg AT a path the shape wrote is a collision of another kind, refused up front by the collision check where there is one and by git at the mount where there is not, and that `--force` changes neither, which holds before and after #197 lands. The same wording is corrected in the test module.

A correction to 5878305's body, which cannot be amended: it said every file the materializer writes goes through `write_lf`. `copy_out` (scripts/shape_materialize.py:1183-1186) writes byte copies with `shutil.copyfile`. The conclusion stands: `copyfile` follows a source link and writes a regular file, `find templates -type l` is empty and no tracked entry has mode 120000, so the materializer makes no symlink for a mount to lie beyond.

tests/test_scaffold_mount_ignored.py gains four tests over ONE more real scaffold, run as an operator whose `init.templateDir` (set through the test's own `GIT_CONFIG_GLOBAL`, as the excludes are) holds a non-bare store at `modules/spec` and at `modules/code`, each with a commit of its own and an `origin` (a store with none dies at `git remote set-url origin`, loudly, which is not the defect). One asks git that a plain `git init` under that configuration plants the store and `--template=` plants none, so the rest cannot quietly stop running the defect. One asserts exit 0 and no "A git directory for". One asserts the pushed root's `spec` and `code` gitlinks EQUAL the tips the legs were pushed at and are not the planted commit. One clones the root recursively with the template config OFF, so the clone itself is clean, and asserts it succeeds and passes the clone's `validate-manifest.py`, `validate-pins.py` and (review N5) `bootstrap.py`. The clone check is now one helper shared with the ignore-rule cases, so `bootstrap.py` runs in all eight clones, about 0.5 s each.

Mutation: with `--template=` removed from the `git init` and nothing else changed, the module fails 2 of its 42 tests, the gitlink test (`{'spec': <planted>, 'code': <planted>} != {'spec': <spec tip>, 'code': <code tip>}`) and the clone test (`Fetched in submodule path 'code', but it did not contain <planted>`), and passes the other 40, the premise test and the exit test among them. Against 5878305 itself the same two fail.

The targeted set, this module and five existing files, passes on this commit: 161 passed, 0 failed, 0 skipped (tests/test_scaffold_mount_ignored.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_paths.py, test_scaffold_e2e.py, test_scaffold_pin_and_reuse.py, test_cli_reference.py). `docs/cli.md` is unchanged, since no `--help` text is.

Closes #229

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-11T19:57:17Z) Review round 1 is in: 9bb8bd0, one commit on 5878305, answering the adversarial review's MUST and its comment nits.

The targeted set (mount-ignored, ignored-files, leg-paths, e2e, pin-and-reuse, cli) gave 161 passed. The same planted-store input also defeats adopt-project.py execute on main through #200's --force mount: adoption verified over gitlinks naming the stranger's commit. That is a separate issue, being filed now.

@brettheap

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-11T20:03:46Z) Filed, re-verified on main 54aa69a: #243. The planted-store input this PR's 9bb8bd0 closes for the scaffold also defeats adopt-project.py execute. Its git clone of the source applies the template, and #200's --force mount reactivates the stores: both gitlinks name the stranger's commit, VERIFICATION still says adoption verified, and the recursive clone exits 128. --template= on adopt's clone prevents it. Not changed by this PR.

…ce` changes in git 2.43 (a module store reactivated, a gitlink accepted, an early dry run skipped and its refusal made later) and names the assembly directory `_refuse_existing_trees` checks, and the test module's comments hold before and after #197 lands (#229)

Comments and docstrings only. The AST of `scaffold-project.py` and of
`tests/test_scaffold_mount_ignored.py`, with docstrings removed, is identical
to 9bb8bd0's, and `tests/test_scaffold_mount_ignored.py`,
`tests/test_scaffold_e2e.py` and `tests/test_cli_reference.py` pass as they
did. This answers the four nits of the re-check of 9bb8bd0:

NIT-2. The comment in `_build_assembly_root` and the test module's docstring
said `--force` "turns off three other refusals". Probed one by one on git
2.43.0 in a scratch repository (`GIT_CONFIG_GLOBAL` pointed at a file of its
own, `GIT_CONFIG_NOSYSTEM=1`), two of the three are not turned off:
- A `.git/modules/<path>` already there: exit 128 "A git directory for 'spec'
  is found locally with remote(s)" without `--force`; with it, exit 0
  "Reactivating local git directory", and the gitlink is the store's commit,
  not the leg's. Turned off, as the comment said.
- An entry at the path in the index: without `--force`, exit 128 "'spec'
  already exists in the index" for a gitlink, a regular file and a tracked
  directory alike. With it, a GITLINK is accepted (the leg is cloned and the
  entry is replaced, or, with a repository already checked out there,
  "Adding existing repo at 'spec' to the index" and the entry keeps that
  repository's commit), and a regular file or a tracked directory is still
  refused, as "already exists in the index and is not a submodule".
- git's early dry run of the path, which refuses a path beyond a symlink
  ("pathspec 'link/sub' is beyond a symbolic link") or inside a submodule
  ("Pathspec 'spec/sub' is in submodule 'spec'") before anything is cloned:
  with `--force` it is skipped, and git still exits 128 on both, later. Beyond
  a symlink it first CLONES the leg into the link's target and fails at the
  last step ("Failed to add submodule"); inside a submodule it refuses to
  create a git directory inside the other's. The refusal is moved, not turned
  off.
The comment now says each of these as probed. The conclusion stands: none of
the three can arise in `_build_assembly_root` (the root is `git init`ed with
`--template=`, the index holds only `.gitmodules` and the first gitlink at the
second mount, and the materializer writes only regular files), and the
paragraph on why `--template=` closes the planted-store case is unchanged.

NIT-3. The comment said "The work directory was empty (`_refuse_existing_trees`)".
That function refuses a `<work-dir>/<assembly>` that exists and is not empty,
and says nothing about the work directory, which may hold anything. The
comment now says that, and draws the same conclusion from it: no `.git` was
inside the assembly directory when the run began.

NIT-1. The `SHAPE_PATHS` comment said git refuses those paths "at the mount
(exit 128)". That is true on main, and false once #197 (PR #234) lands, when
they are refused up front as `scaffold-leg-path-collides`. The comment now
says both ("where #197's collision check is in force ... where it is not"),
without claiming which state it is in, and that the tests assert only exit 2
and no gitlink, so they hold in both. It mirrors the comment in
`_build_assembly_root`.

NIT-5. The clone test's docstring quoted `Fetched in submodule path 'spec'`;
under the mutation (`--template=` removed) git names `'code'`, because the
template plants both stores. The docstring now says the path is a leg's, and
names both.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-11T20:19:09Z) A re-check of 9bb8bd0 found it MERGEABLE WITH NITS. MUST-1 is closed: with the planted store, the gitlink equals the leg's pushed tip, the clean clone, the validators and bootstrap pass, and the doctor says COMPLIANT. On the merge with main, 161 passed.

7779e3d answers its comment nits, comments and docstrings only. The code's AST, with docstrings stripped, is identical.

test_scaffold_mount_ignored.py test_scaffold_e2e.py test_cli_reference.py: 99 passed. (Codex is at its review quota on this account, so no @codex review this round.)

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits. You can see your limits in the Codex usage dashboard.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 39ac59c into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/scaffold-mount-ignored-legs branch October 11, 2026 21:46
brettheap added a commit that referenced this pull request Oct 11, 2026
…) into fix/plan-report-stdout-encoding (#214)

One conflict, resolved by keeping both sides: the `from shape_materialize
import (...)` list in update-shape.py. This branch added
`escape_what_stdout_cannot_encode` and main added `force_stage` (#202) in the
same place. Both names are now in the list, in its existing order
(commit_trailers, escape_what_stdout_cannot_encode, force_stage,
lane_trailer_argument, ...), wrapped to 79 columns like its neighbours, so the
list's tail lines re-flow with it. No other file conflicted, and nothing else
was changed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…ng-paths

main moved to 39ac59c: #197 (b538015, PR #234) moved `_overlap` and
`_first_and_more` into scripts/shape_materialize.py as `path_overlap` and
`first_and_more`, and adopt-project.py imports them back under the old
names; 150c41d and 39ac59c (PRs #233 and #235) touch update-shape.py and
scaffold-project.py. One conflict, in adopt-project.py: this branch had
edited `_first_and_more` to `_spelled` its path, and main removed the
function.

Resolved by taking main's side of that one hunk: `_overlap` and
`_first_and_more` are deleted from adopt-project.py, and main's import of
the shared definitions stays as it is. No line is written by hand in this
merge. It leaves `_kept_collisions` naming a source path through
`first_and_more`, unspelled, so the spelling case of
tests/test_adopt_finding_spelling.py fails on this commit alone; the next
commit gives `first_and_more` an optional `spell` and passes `_spelled`.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…) into fix/adopt-record-submodule-placement

Brings in b538015 (#197), 150c41d (#202) and 39ac59c (#229) on top of
4835fb4. No rebase. They landed while this branch's targeted run was
going, and the files they change overlap this branch's: adopt-project.py
and scripts/shape_materialize.py.

No conflicts. Checked:

- adopt-project.py: main moves `_overlap` and `_first_and_more` into
  shape_materialize.py as `path_overlap` and `first_and_more`; this
  branch's `submodule_record` and its record append are untouched.
- scripts/shape_materialize.py: main adds `shape_path_collisions`,
  `force_stage` and `neutral_pin_path`, and `assembly_root_paths` takes
  `neutral_pins`. This branch's `_spelling_reason` and
  `LEG_SEGMENT_REASONS`, which ask `repo_shape.path_spelling_fault`, are
  untouched, and no line main added names either, nor
  `leg_path_problem` (`git diff 4835fb4 origin/main`).
- The branch's diff against main is still #189 alone: 12 files, +1670
  -156, the same as against 4835fb4.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 11, 2026
…the last that is not a neutral product's name is refused before anything is created, under `--dry-run` alike, so `openXdox,a #b,openDox` no longer creates three repositories and pushes a manifest the project's own `validate-manifest.py` refuses as `yaml-unparsable` (#251) (#271)

`scaffold-project.py --referent-chain` now refuses, as `chain-link-malformed`, any link between
the first and the last whose name the naming policy does not classify as `neutral-product`
(`open<Product>`). The refusal names every such link, says nothing was created, and gives the
spelling to use. It runs before `gh api` or git reads a pin's tree, and under `--dry-run`
alike. (With no `--elected-by` and no `GIT_AUTHOR_NAME`, resolving the elector runs `git config
user.name` first.)

- `scaffold-project.py`: new `_refuse_malformed_chain_links(policy, chain)`, called in
  `_scaffold` right after the policy is loaded and before `_declaration`. That is before
  `gh api` or git reads a pin's tree, before the `--dry-run` return, and before the work
  directory and the remotes exist. A link is judged at its basename, the way `resolve_referent`
  reads it and `naming_block` records it. New `referent_chain_links(raw)` is the one place
  the argument is read, used by the check and by `_declaration`, so the links checked are the
  links recorded. `--help` for `--referent-chain` says the rule. The first and last links are
  unchanged. The refusal quotes the chain and each bad link with `repr`, as `--pin` and `--id`
  do in their refusals, so a link holding a newline, a carriage return or an ESC byte prints
  as `\n`, `\r` or `\x1b` and cannot start a second line that reads as a refusal.
- `docs/cli.md`: regenerated with `scripts/render-cli-reference.py` (one help paragraph).
- `tests/test_scaffold_referent_chain_links.py`: new, 44 tests (30 in the first commit, 14 in
  fix round 1).

**A behaviour change for input that used to work.** A middle link that is not of the form
`open<Product>`, with an uppercase letter after `open` (or after `openx`), is now refused. That
includes a case variant such as `openydox` or `OPENYDOX`, which main accepted:
`openXdox,openydox,openDox` scaffolded on main (the review ran it, against a layer where
`openXdox` declares `openYdox`, and `validate-manifest.py`, `validate-repository-naming.py` and
`validate-pins.py` all exited 0), because a chain link is compared casefolded elsewhere. The
reason is that nothing else anchors a middle link: the run compares it with no pin and no
referent, so only its form can say what it is, and the policy's uppercase rule is what keeps
`opensoft` (the organisation login) from reading as a product. The refusal names the link and the spelling to use. The first and last
links are still compared casefolded, as before, because each is anchored to a name this run
holds.

`naming_block` writes the chain as a plain flow sequence, `referent_chain: [openXdox, openDox]`,
and nothing checked a middle link. `_chain_shape_problem` checks the chain's length, repeats,
the first link and the last. A link whose tree is not on the disk is declared-unverified, which
is only a warning. On 150c41d, scaffolding `codexDox` (pin `openXdox`, `--pin-source` a layer
declaring `openDox`) exited 0 and created and pushed all three repositories for every one of
these chains:

| `--referent-chain` | recorded | read back by `load_yaml` |
|---|---|---|
| `openXdox,a #b,openDox` | `[openXdox, a #b, openDox]` | `REFUSED yaml-unparsable`; `validate-manifest.py` exit 2 |
| `openXdox,it's,openDox` | as typed | `['openXdox', "it's, openDox"]` |
| `openXdox,a]b,openDox` | as typed | `['openXdox', 'a]b, openDox']` |
| `openXdox,"q",openDox` | as typed | `['openXdox', 'q', 'openDox']` |
| `openXdox,null,openDox` | as typed | `['openXdox', None, 'openDox']` |
| `openXdox,007,openDox` | as typed | `['openXdox', 7, 'openDox']` |
| `openXdox,Atlas,openDox` | as typed | as typed, a project-leg name recorded as a product link |

On this branch, each of those exits 2 with `REFUSED chain-link-malformed`, and no remote
directory exists afterwards. The control `openXdox,openDox`, a three-link
`openXdox,openYdox,openDox`, `opensoft/openXdox,openDox`, `OPENXDOX,openDox` and
`openXdox,OPENDOX` still scaffold. The written projects' `validate-manifest.py` and
`validate-repository-naming.py --project` exit 0.

**Why refuse, and not quote (the issue's two options).** The naming policy already defines a
link. Every link after the first is read from the previous link's `neutral_product_pins:`
(`referent.chain.link_declared_by` in `contracts/repository-naming.yaml`). The example chains
there are `[openXdox, openDox]` and `[openXwallet, openWallet]`, and `validate-manifest.py`
says a recorded chain is "expected a list of neutral-product names". A name in the
`neutral-product` form is a single plain YAML word, so the manifest stays plain, and it is
still true that no writer quotes a flow item. Quoting would record `a #b` faithfully: a link
the scaffold's own check calls broken, naming a repository that cannot exist. It would also
make this writer depend on the quoted-flow-item reader, which #249, #250 and #252 are changing
now. Checking only the repository-name alphabet is not enough either: `null`, `007` and `true`
are valid GitHub names, and the reader resolves them as None, 7 and True.

**Why only the middle links.** The first link must casefold to a `--pin` product
(`[A-Za-z0-9_.-]`). The last must casefold to the `open<Product>` that the project's name claims.
Neither can therefore hold a `#`, a quote, a bracket, a comma or a space, and the last cannot be
a YAML bool, null or number. Both comparisons ignore case on purpose: a pin names which
repository, not how it was typed (`_pinned_basenames`). Refusing `OPENXDOX` at an end would add a new
spelling rule that this issue does not ask for. A test keeps the two ends as they were. A middle
link has no such anchor, so it is judged by its form, and therefore by its case (see the
behaviour change above).

**Where else a chain is accepted.** `adopt-project.py` passes no chain to `naming_block`
(:2887-2890). `scripts/family.py` and `update-shape.py` take none. `setup-project.py` passes
everything after `--` to the scaffold, so it gets this check. `validate-repository-naming.py
--referent-chain` takes a chain but writes nothing, so it is unchanged.

- **Fails on 150c41d.** The new file, run in a throwaway worktree at 150c41d: 25 failed,
  5 passed. 23 in-process refusal tests fail with `ProcessStarted: ['gh', 'api',
  'repos/opensoft/openXdox/git/trees/111…']`: the run went past the chain to read the pin's
  tree. That `gh` was intercepted and never ran. The 2 end-to-end issue rows exit 0 with three
  bare remotes. The 5 that pass are the positive and unchanged-ends tests.
- **Mutation probes** (each hunk reverted or bent in a throwaway worktree, new file re-run):
  - call removed: 25 failed;
  - slice widened to every link: 2 failed (the capitalised first and last links);
  - slice narrowed to `[1:-2]`: 25 failed;
  - any classified name accepted: 8 failed (`null`, `Atlas`, `opensoft`, `OPENYDOX`);
  - basename not taken: 1 failed (the qualified middle link);
  - check moved after `_declaration`: 23 failed (every in-process refusal hits `gh api`);
  - plural dropped: 1 failed;
  - help text reverted without regenerating: `test_cli_reference.py` 3 failed;
  - `_declaration` reading the argument inline again: 0 failed, as expected, because that
    change preserves behaviour.
- **Reach.** All 14 tracked `*.yml`/`*.yaml` files load the same on 150c41d and on this head
  (7 ok, and 7 templates refused on their unrendered `{{...}}`). `repo_shape.py` is not
  touched. `naming_block` called from the scaffold is the only writer of `referent_chain`.
- **Fix round 1 (5cb1d3e, on top of 114bec9)**, run at 5cb1d3e with the CI-pinned
  `git-filter-repo` 2.47.0 on PATH:
  - `tests/test_scaffold_referent_chain_links.py tests/test_scaffold_e2e.py
    tests/test_cli_reference.py tests/test_repo_hygiene.py tests/test_naming_policy.py`:
    313 passed.
  - `tests/test_scaffold_pin_and_reuse.py tests/test_scaffold_leg_path_collisions.py
    tests/test_scaffold_leg_paths.py tests/test_scaffold_ignored_shape_files.py`: 166 passed.
  - The new file alone holds 44 tests (30 before). `scripts/render-cli-reference.py --check`
    prints `[ok]`, `git diff --check` is clean, and no added line holds a host or scratch path.
  - Six more mutants, each applied to `scaffold-project.py` alone with the whole new file run
    (no `-x`), all caught: the policy pattern dropped from the remediation (1 failed), the
    `--explain` sentence dropped (1), links not stripped (3), the check reading its own
    unstripped split (3), the chain printed raw (26: the 22 refusal rows that assert the quoted
    chain, and the 4 line-break rows), and a bad link printed raw (7: the two single-quote
    rows, the two-bad-links refusal and the 4 line-break rows).
  - The streams under "Targeted runs at 114bec9" below were NOT re-run at 5cb1d3e, except the
    files named above. Fix round 1 changed three comment or docstring lines and the refusal's
    own message in `scaffold-project.py`, and the new test file.
- **Targeted runs at 114bec9**: all green.
  - `tests/test_scaffold_referent_chain_links.py tests/test_scaffold_e2e.py
    tests/test_scaffold_pin_and_reuse.py tests/test_naming_policy.py
    tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_leg_paths.py
    tests/test_scaffold_ignored_shape_files.py`: 402 passed.
  - `tests/test_yaml_flow_quotes.py tests/test_load_yaml_refusals.py
    tests/test_adopt_plan_escapes.py tests/test_manifest_validator.py tests/test_pin_validator.py
    tests/test_neutral_pin_validator.py tests/test_repo_hygiene.py tests/test_cli_reference.py
    tests/test_adopt_plan.py`: 275 passed, 1 skipped. The skip is `test_adopt_plan.py:393`:
    `SHAPE_MEDXEHR_CLONE` is unset.
  - `tests/test_shape_doctor.py`: 181 passed.
  - In a throwaway merge with main 39ac59c (#229): `tests/test_scaffold_referent_chain_links.py
    tests/test_scaffold_mount_ignored.py tests/test_scaffold_pin_and_reuse.py
    tests/test_cli_reference.py` gave 105 passed.

Re-checked at 5cb1d3e after fix round 1: `git merge-tree --write-tree` against `origin/main`
4b2add8, `fix/yaml-block-key-nul` 9be6287 and `fix/yaml-quote-boundaries` 21fc213 is clean for
each. `fix/yaml-flow-mapping-keys` (#252) has no branch on origin or in the shared repository
now; its earlier tip 2438190 is still in the object store and is clean too.
`tests/test_scaffold_referent_chain_links.py tests/test_cli_reference.py
tests/test_repo_hygiene.py` on the throwaway merge of 5cb1d3e with 4b2add8: 107 passed.

`git merge-tree --write-tree` of 114bec9 against each sibling branch and against main:
all clean, checked right before the first push.
- `fix/yaml-quote-boundaries` (#249, #250) at 150c41d, so no commit yet: clean. It needs a
  fresh check once it has commits.
- `fix/yaml-block-key-nul` (#248) at 9be6287: clean.
- `fix/yaml-flow-mapping-keys` (#252) at 2438190: clean.
- `origin/main` at 39ac59c (#229 via #235, which also edits `scaffold-project.py`, in
  `_build_assembly_root`): clean, and its tests pass on the merged tree (above). This change
  does not touch `scripts/repo_shape.py`, so none of the reader fixes overlap it.

- **Follow-up 1: `--pin` accepts a product name the manifest reader resolves as something other
  than a string.** `--pin null@<sha>`, `007` and `true` each scaffold with exit 0, and the
  project's own `validate-pins.py` then exits 1 with `neutral-pin-entry-malformed: … entry None`
  (or `7`, `True`). It reaches the chain's FIRST link too: `--pin null@<sha> --referent-chain
  null,openDox` scaffolds three repositories, records `referent_chain: [null, openDox]`, reads
  back `[None, 'openDox']`, and `validate-pins.py` exits 1. That writer belongs to `--pin`, not to
  the chain, and the eventual fix should cover the first link. It is a candidate follow-up issue.
- **Follow-up 2: the validators still accept such a chain.** `validate-manifest.py` and
  `validate-repository-naming.py` do not flag a recorded middle link that is not a
  neutral-product name, such as `[openXdox, Atlas, openDox]` written by an older scaffold or by
  hand (all three validators exit 0 on it). Both are shipped, digest-pinned copies, and neither
  writes anything. The policy file also says the chain rule is data, while the middle-link form
  is a constant in `scaffold-project.py` (`CHAIN_LINK_FORM`). It is a candidate follow-up issue.
- `<owner>/` before a link is still dropped without being checked, for every link, as
  `_chain_links` has always done. The manifest records basenames.
- The full suite and the macOS and Windows legs were not run locally. The new tests send git
  nothing on stdin, and write the stand-in repositories' files as bytes.

Reviewed adversarially (APPROVE WITH NITS, 0 blockers); fix round 1 escapes the refused chain and covers the remediation and link-splitting tests. CI is green on Linux, macOS and Windows.

Closes #251

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…e-notice

It merged with no conflict. Main brings six commits since 54aa69a:

- e7d1720, update-shape.py (#208 via #224);
- 404760f, scripts/repo_shape.py's double-quoted flow items (#213 via
  #244), with tests/test_yaml_flow_quotes.py;
- 4835fb4, adopt-project.py's plan writer escapes (#199 via #247), with
  tests/test_adopt_plan_escapes.py;
- b538015, scaffold-project.py's leg paths against the shape's own
  paths (#197 via #234);
- 150c41d, update-shape.py's `apply --add` (#202 via #233);
- 39ac59c, scaffold-project.py's leg mount `--force` (#229 via #235).

Two of them touch adopt-project.py, and no hunk of either meets this
branch's:

- 4835fb4 changes the plan writer (`y`, `_PLAIN_RE`), adds `_shown` to
  the plan and check reports, and writes the `--paths-from-file` listing
  in `_extract_leg` through `write_paths_listing`. This branch changes
  other lines of `_extract_leg`: it reads the tree digest before the
  push and pushes through `_push_the_leg`. The signature is the same on
  both sides, so main's new direct `_extract_leg` test calls it as
  written.
- b538015 moves `_overlap`, `_first_and_more`, `MOVE_THE_LEG` and
  `CASE_ASIDE` into scripts/shape_materialize.py, and imports the first
  three back under the same names. This branch adds no use of any of
  them. A `symtable` check finds no name that the merged adopt-project.py,
  scaffold-project.py or scripts/shape_materialize.py reads and nothing
  binds.

`git diff -U0` of this merge against 39ac59c equals the branch's against
54aa69a, apart from the hunk headers. On the merged tree,
tests/test_adopt_plan_escapes.py, tests/test_yaml_flow_quotes.py,
tests/test_scaffold_leg_path_collisions.py and
tests/test_adopt_leg_path_collisions.py give 275 passed.

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

Labels

None yet

Projects

None yet

1 participant