Skip to content

scaffold-project.py, family.py and shape_materialize.py: the scaffold refuses a --project no Windows disk can make a folder of, by the one question init asks of --family, and the five folder flags' --help say what an empty value and . mean (#989, part of #999) - #1010

Merged
brettheap merged 3 commits into
mainfrom
l3-989-999-scaffold-names-folder-help
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #989.
Part of #999.

Three commits on origin/main 3864b18cc: one for #989, one for #999's first part, and one for lane 4's SHOULD-1 on #972.

What changed, and why

#989: the scaffold asks --project the question init asks of --family

  • One function. scripts/shape_materialize.py gains refuse_an_unholdable_name(refusal, flag, name, *, names, remediation). It is _refuse_an_unholdable_holder_name's question lifted out of family.py: the leg path rule (_unmountable_reason) asked of a name of one segment. The caller gives its own code, its flag, what the name names, and its remediation, as refuse_bad_leg_paths(refusal, ...) takes its code.
  • family.py. _refuse_an_unholdable_holder_name now calls the shared function. family-bad-holder-name keeps its words byte for byte: tests/test_family_init_asks_every_value.py pins the whole text, and it passes.
  • scaffold-project.py. A new refusal, scaffold-bad-project-name. The Direction left the code to the writer. The sentence is --project is 'NUL', which names the assembly root and the folders the scaffold makes for it, and NUL is a Windows device name (with or without an extension), which cannot be created there. The remedy names --project, the root's repository, the folder it is cloned into, its working tree and, under --local-remote-dir, its bare repository <Project>.git. HOLDER_NAME_REMEDIATION stays the holder's own.
  • Where it is asked. In _scaffold, just after _refuse_bad_id. That is before a passed id's checked_value, _refuse_what_github_cannot_hold, _refuse_malformed_chain_links and _declaration, the first gh api a --pin can make. It is also asked inside _refuse_bad_id's passed-id branch, which keeps scaffold-project.py and family.py init: a flag passed empty is refused by name, a passed --id is told what an id may hold once the name passes, and an empty folder this user cannot list is told the permission (#911, #930) #972's order: the naming refusal, then this one, then the id's sentence. The naming half is _refuse_a_name_no_root_can_have, moved unchanged out of _refuse_bad_id.
  • Both routes. Against GitHub, the clone folder NUL is what Windows cannot make. Under --local-remote-dir, the bare NUL.git cannot be made either. So the name is refused in both. The legs' names are not asked: NUL-spec is no device name, because the rule is a whole-name rule.

Decisions the Direction left open:

  1. The naming refusal speaks first for a device-shaped name the policy also refuses, such as NUL.git or Nul., whatever id is passed.
  2. The name speaks before a passed id's alphabet. The question is asked before checked_value("--id"), so --project NUL with a malformed --id is told about the name first. init asks checked_value("--id") before its own question; that order is unchanged here.

#999, first part: the five folder flags' --help

The scaffold's --work-dir and --local-remote-dir, and family.py init's --into, --work-dir and --local-remote-dir, each gain one sentence: "An empty value is refused as <flag>-no-path, and . means the directory the run stands in." docs/cli.md is regenerated with scripts/render-cli-reference.py. After merging origin/main, --check says [ok] docs/cli.md is what the generator produces.

Lane 4's SHOULD-1 on #972 (issuecomment-6269587341)

One row, test_init_asks_no_length_of_a_local_run_before_a_passed_bad_id, in tests/test_github_limits_init_check_org.py. It runs a 101-character --family with --id foo_bar under --local-remote-dir. It asserts REFUSED family-bad-id: --id 'foo_bar' must match, no family-name-too-long, and nothing made.

Left out of scope

Tests

Every run used a stub gh first on PATH (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and EMAIL=tests@openreposhape.invalid. Each file ran alone under timeout 2400: /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-W989 -o tmp_path_retention_policy=failed <file>.

The tree tested was this branch's tree before origin/main 3864b18cc was merged in. That merge changed only docs/cli.md among this branch's files, so tests/test_cli_reference.py ran again after it. The head's tree, 492cbbeb, is that merged tree exactly.

File Result
tests/test_scaffold_device_names.py (new) 48 passed in 13.63s
tests/test_derived_id_refusals.py 62 passed in 11.38s
tests/test_github_limits_init_check_org.py 95 passed in 27.75s
tests/test_scaffold_init_empty_values.py 36 passed in 7.35s
tests/test_family_init_asks_every_value.py 78 passed in 22.18s
tests/test_github_name_limits.py 64 passed in 25.47s
tests/test_scaffold_pin_collisions.py 33 passed in 5.63s
tests/test_scaffold_leg_path_collisions.py 104 passed in 4.14s
tests/test_scaffold_referent_chain_links.py 44 passed in 8.66s
tests/test_family_init_landing.py 19 passed in 7.63s
tests/test_scaffold_e2e.py 46 passed in 18.60s
tests/test_dry_run_parity.py 123 passed in 35.61s
tests/test_cli_reference.py 23 passed in 32.24s; after the merge, 23 passed in 30.04s
tests/test_agents_md_codes.py 47 passed in 1.91s
tests/test_update_add_codes_documented.py 6 passed in 0.44s
tests/test_repo_hygiene.py 100 passed in 2.57s
tests/test_no_auto_maintenance.py 60 passed in 11.43s

tests/test_shape_doctor.py was not run: shape-doctor.py and scripts/repo_shape.py are unchanged.

New rows:

  • tests/test_scaffold_device_names.py:
    • the issue's eight names (NUL, Con, Nul, com1, Lpt9, COM9, Aux, Prn), each in a dry run and a real run. Each must exit 2 with the whole refusal, print no plan and make nothing.
    • NUL against GitHub with a --pin, in a dry run and a real run, with no gh call made.
    • the controls Nullable, Console and Auxo, which still plan.
    • one list of 23 names that BOTH tools must refuse or both take, so they cannot part again.
    • NUL.git and Nul., which the naming policy refuses first, whatever id is passed.
  • tests/test_derived_id_refusals.py: --project NUL with --id foo_bar and with --id '' is refused for the name before the id, beside init's NUL row.
  • tests/test_scaffold_init_empty_values.py: each of the five flags' --help carries the sentence, read with COLUMNS=1000 so argparse breaks no code at its hyphen.
  • tests/test_github_limits_init_check_org.py: the SHOULD-1 row.

Mutants

One hunk was broken at a time. The file was restored after each and checked by sha256.

Mutant Killed by
M1: _scaffold no longer asks the device question test_scaffold_device_names.py: 33 failed, 15 passed
M2: _refuse_bad_id's passed-id branch no longer asks it test_a_name_no_root_can_have_on_windows_speaks_before_a_passed_id: 2 failed (said scaffold-bad-id)
M3: no naming refusal before the device refusal test_a_name_the_policy_refuses_too_is_refused_by_the_policy: 2 failed, 2 passed (the --id foo rows fail; the foo_bar rows were already guarded by _refuse_bad_id)
M4: the device question moved after _declaration test_against_github_the_name_is_refused_before_gh_is_asked: 2 failed (a pin-... refusal after gh api)
M5: the shared function never refuses test_both_tools_refuse_the_same_names and test_a_windows_device_name_is_refused_as_the_holders_name: 22 failed, 8 passed
M6: init no longer asks the shared function test_both_tools_refuse_the_same_names: 15 failed, 8 passed
M7: lane 4's SHOULD-1 mutant, if args.local_remote_dir is not None: return dropped from _refuse_a_holder_name_github_cannot_hold test_init_asks_no_length_of_a_local_run_before_a_passed_bad_id: 1 failed
M8: init --into's help loses the sentence test_each_folder_flag_s_help_says_what_empty_and_a_dot_mean: 1 failed, 4 passed
M9: the scaffold's --work-dir help loses the sentence the same test: 1 failed, 4 passed

Probes

Same environment as the tests: scaffold-project.py --org acme --project <name> --local-remote-dir <d>/lr --work-dir <d>/out --elected-by X, with and without --dry-run.

  • NUL, Con, Nul, com1, Lpt9, COM9, Aux and Prn each exit 2 with scaffold-bad-project-name, in both modes. Nothing was made beside the run and the stub gh was never called.
  • Nullable, Console and Auxo exit 0 in both modes. The real runs made Nullable.git, Nullable-spec.git and Nullable-code.git.

Merges

git merge-tree --write-tree of head 37b0fc438d2516e921a4c5dbbfb6dc8bf629b252:

W993's l3-993-* branch was not on origin when this was opened, so it was not merge-tree'd.

Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code

Brett Heap and others added 3 commits October 19, 2026 21:36
… asks both tools whether a name is one a Windows disk can make a folder of, so the scaffold refuses `--project NUL`, `Con` or `com1` as `scaffold-bad-project-name` before anything exists, under `--dry-run` alike, as `init` refuses the same `--family` (#989)

`shape_materialize.refuse_an_unholdable_name` is `family.py`'s
`_refuse_an_unholdable_holder_name` question, lifted: the leg path rule
(`_unmountable_reason`) asked of a name of one segment, with the caller's
code, flag, words and remediation. `init` asks it as before, in the same
words. The scaffold asks it of `--project` after `_refuse_bad_id` and before
a passed id's alphabet and `_declaration`, and, for a passed id that is no
id, inside `_refuse_bad_id`: the naming refusal, then this one, then the
id's sentence (#972's order). A device-shaped name the naming policy refuses
too (`NUL.git`) is refused as the policy refuses it, whatever the id.
`Nullable`, `Console` and `Auxo` still scaffold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive folder flags says an empty value is refused as `<flag>-no-path` and that `.` means the directory the run stands in, as `--pin-source`'s says of its own (part of #999)

The scaffold's `--work-dir` and `--local-remote-dir`, and `family.py
init`'s `--into`, `--work-dir` and `--local-remote-dir`, each refused an
empty value by name since #972 with no word in `--help`. `docs/cli.md` is
regenerated with `scripts/render-cli-reference.py`. The docstring word in
`templates/assembly-root/scripts/validate-manifest.py`, #999's second part,
waits for the next change to that pinned copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… GitHub length of a `--family` under `--local-remote-dir` before a passed id that is no id, so it says `family-bad-id`, never `family-name-too-long` (#972's review, lane 4's SHOULD-1)

Dropping `if args.local_remote_dir is not None: return` from
`_refuse_a_holder_name_github_cannot_hold` survived every test before this
row; it fails this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR centralizes Windows-unholdable-name validation and applies it to scaffold project roots with deliberate naming and ID refusal precedence, expands folder-option help text for empty values and ., and adds comprehensive regression tests including the local-run validation-order case.

Sequence diagram for scaffold project-name validation precedence

sequenceDiagram
    participant User
    participant Scaffold as scaffold-project.py
    participant Policy as NamingPolicy
    participant Materialize as shape_materialize
    participant GitHub as gh

    User->>Scaffold: _scaffold(--project, --id, --pin)
    Scaffold->>Scaffold: _refuse_bad_id()
    opt passed id is malformed
        Scaffold->>Policy: _refuse_a_name_no_root_can_have()
        Policy-->>Scaffold: naming refusal or accepted root
        Scaffold->>Materialize: refuse_an_unholdable_name()
        Materialize-->>Scaffold: scaffold-bad-project-name or accepted
        Scaffold-->>User: scaffold-bad-id
    end
    Scaffold->>Materialize: refuse_an_unholdable_name()
    Materialize-->>Scaffold: scaffold-bad-project-name or accepted
    opt project name accepted
        Scaffold->>GitHub: gh api / repository operations
    end
Loading

Flow diagram for folder-flag path handling

flowchart TD
    A[Parse folder flag] --> B{Value is empty?}
    B -- Yes --> C[Refuse as <flag>-no-path]
    B -- No --> D{Value is .?}
    D -- Yes --> E[Use directory the run stands in]
    D -- No --> F[Use supplied path]
    C --> G[No folders created]
    E --> H[Continue run]
    F --> H
Loading

File-Level Changes

Change Details Files
Shared the Windows-unholdable single-segment name check between family initialization and project scaffolding, adding project-specific refusal behavior and preserving policy-first ordering.
  • Added refuse_an_unholdable_name around the existing leg-path rule.
  • Refactored family holder-name validation to use the shared helper without changing its refusal text.
  • Added scaffold-bad-project-name validation for device names, trailing dot/space cases, and related unholdable names.
  • Applied validation before ID checks/declaration and before any GitHub API call, including local-remote runs and dry runs.
  • Added tests covering device-name parity, refusal precedence, controls, no side effects, and no gh invocation.
scaffold-project.py
scripts/family.py
scripts/shape_materialize.py
tests/test_scaffold_device_names.py
tests/test_derived_id_refusals.py
Documented empty folder-flag values and the meaning of . consistently in both CLI help and the generated CLI reference.
  • Updated help for scaffold --work-dir and --local-remote-dir.
  • Updated help for family init's --into, --work-dir, and --local-remote-dir.
  • Regenerated docs/cli.md and added coverage for all five options.
scaffold-project.py
scripts/family.py
docs/cli.md
tests/test_scaffold_init_empty_values.py
Added regression coverage for validation ordering when a local family run has both an overlong name and an invalid passed ID.
  • Asserted that family-bad-id is reported before the GitHub name-length refusal and that no local repositories are created.
tests/test_github_limits_init_check_org.py

Assessment against linked issues

Issue Objective Addressed Explanation
#989 Refuse Windows device names supplied as scaffold-project.py --project before creating anything, including in dry runs and before any gh call. ✅
#989 Use the same shared Windows-unholdable-name check for the scaffold’s --project and family.py init’s --family, while preserving the naming-policy refusal order. ✅

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

RV1010, lane 3's independent review of #1010 at head 37b0fc4: READY

The head did not move while I worked. Its tree, 492cbbeb, is its merge with origin/main 3864b18. CI's tests job was still pending at 21:53Z, so wait for green before landing.

Checks, measured under a stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and local remotes only:

  • One answer. The old family._refuse_an_unholdable_holder_name (main), the same function at head, and shape_materialize.refuse_an_unholdable_name called directly gave byte-identical code, detail and remediation over 704 names: CON/PRN/AUX/NUL and COM0-9/LPT0-9 in four cases, each bare and with .txt, ".", a trailing space, .git and others, plus Nullable, Console, Auxo, COM10, LPT10 and look-alikes. 554 refused, 150 passed. family-bad-holder-name keeps its words. The superscript-digit forms were never known (COM1-superscript passes at main and head); neither the naming policy nor checked_value lets a non-ASCII name through.
  • Both tools end to end. 34 names as dry runs: the scaffold and init refuse or take the same names on every row at head, and init at head equals init at main on every row.
  • The scaffold. NUL, Con, com1, Lpt9 and AUX, as --dry-run and as a real run, on --local-remote-dir, on GitHub, and on GitHub with --pin: all 30 runs exit 2 with scaffold-bad-project-name, 0 stub gh calls, and nothing made. At main the same GitHub rows reach the stub (repo view or the pin's api graphql), and the real local runs make NUL.git, NUL-spec.git and NUL-code.git.
  • Order. North_Wind --id foo_bar gives naming-unclassified. NUL with --id foo_bar, '', nul, Foo or a 200-character id gives scaffold-bad-project-name. NUL.git and Nul. with --id foo or foo_bar give naming-unclassified, as init does, with stderr byte-identical to main.
  • Help. --work-dir '' and --local-remote-dir '' (scaffold), and --into '', --work-dir '' and --local-remote-dir '' (init), each give their own <flag>-no-path. "." plans, or builds, in the directory the run stands in. render-cli-reference.py --check: ok at head and on head+shape-doctor.py and shape_advisory.py: a path in the root this user cannot reach is named on every Python, never read as absent or stood in for by the template, a root it cannot search is refused first, and a row that could not ask is never DRIFTED #975.
  • Lane 4's row. Mutant M7 fails exactly 1 of 95 tests in test_github_limits_init_check_org.py: the new row.

MUST: none.

SHOULD-1 (PR body, "Closes #989"; adopt-project.py:4901). #989 also measured adopt-project.py plan taking --project NUL. At head, plan --project NUL and plan --project Con still exit 0. No issue tracks that half, and this PR closes #989. Fix: file the follow-up issue before landing and name it in the body, or say "Part of #989".

NIT-1 (scaffold-project.py:2319 against :2323). Decision 2, "the name speaks before a passed id's alphabet", is held by no test. Moving the device question after checked_value("--id") survives test_scaffold_device_names (48 passed) and test_derived_id_refusals (62 passed). It changes --project NUL --id given "nul" plus a line break from scaffold-bad-project-name to unsafe-value. Fix: add "nul\n" to parametrize("passed", ["foo_bar", ""]) at tests/test_derived_id_refusals.py:276.
NIT-2 (scaffold-project.py:1060-1061; the docstring of tests/test_scaffold_device_names.py:175; the PR body's decision 1). "whatever the id": with no --id, NUL.git is refused as scaffold-bad-id for the derived id (main and head alike; init says family-bad-id). Say "whatever id is passed".
NIT-3 (optional; scaffold-project.py:992-993 and :1070). The passed-id branch asks _refuse_a_name_no_root_can_have twice when the device question refuses. It starts no process and gives the same answer.

Tests at head, one file at a time:
test_scaffold_device_names 48 passed; test_derived_id_refusals 62 passed; test_github_limits_init_check_org 95 passed; test_scaffold_init_empty_values 36 passed; test_family_init_asks_every_value 78 passed; test_dry_run_parity 123 passed; test_readme_windows_quoting_sentence 44 passed (the diff adds no shell_quoted call: 37 in family.py at main and head); test_agents_md_codes 47 passed; test_cli_reference 23 passed; test_repo_hygiene 100 passed; test_update_add_codes_documented 6 passed; test_no_auto_maintenance 60 passed.
Skipped, because the PR body shows them green: test_github_name_limits, test_scaffold_pin_collisions, test_scaffold_leg_path_collisions, test_scaffold_referent_chain_links, test_family_init_landing, test_scaffold_e2e. Also skipped: test_shape_doctor (unchanged tool).

Mutants (one hunk each, restored and checked by sha256):

  • M4 (device question after _declaration): killed by test_against_github_the_name_is_refused_before_gh_is_asked, 2 failed.
  • M3 (no naming refusal before the device refusal): killed by test_a_name_the_policy_refuses_too_is_refused_by_the_policy[foo-*], 2 failed.
  • M7 (lane 4's): killed by the new row, 1 failed.
  • Mine, the shared function matching upper case only: killed, 20 failed in test_scaffold_device_names and 4 in test_family_init_asks_every_value.
  • Mine, the scaffold asking only under --dry-run: killed, 9 failed (the real-run rows); test_dry_run_parity does not catch it.
  • Mine, the device question after checked_value("--id"): survives (NIT-1).

Merges (git merge-tree --write-tree with 37b0fc4): origin/main 3864b18 clean; #1012 (l3-993) d87263e clean; #1002 3df537a clean (the body names 1baafc9); #988 a1a95ff clean; #975 3fd80cb clean. No other open PR touches scaffold-project.py, scripts/family.py, scripts/shape_materialize.py or docs/cli.md.

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai

sourcery-ai Bot commented Oct 19, 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 20 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit e076e3e into main Oct 19, 2026
5 checks passed
@brettheap
brettheap deleted the l3-989-999-scaffold-names-folder-help branch October 19, 2026 23:17
brettheap added a commit that referenced this pull request Oct 20, 2026
… refused as `adopt-bad-project-name` before anything exists, and `check` and `execute` find it in the plan's `legs.assembly` (#1014) (#1041)

`adopt-project.py plan` took a `--project` that no Windows disk can
make a folder of (`NUL`, `Con`, `com1`, `Lpt9`, in any case), and
`check` passed the plan. Both now ask the one question the scaffold
and `family.py init` already ask, `refuse_an_unholdable_name` from
#1010, under the new code `adopt-bad-project-name`.

`plan` asks it after the naming policy and before a passed `--id`, so
a name the policy refuses keeps its refusal. It runs before the work
folder, git, `gh` and the plan file. `check` and `execute` ask it first
of the plan's `legs.assembly`, the folder `execute` makes, on GitHub
and under `--local-remote-dir`. The code renames the legs, so `check`
asks `gh` nothing more about them. `Nullable`, `Console` and `Auxo`
still pass: a device name is the whole name.

RV1010's row (`nul\n` passed as an id) and its two NITs are closed
with it.

Closes #1014

Lane: openreposhape-3 (openRepoShape-3)
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