Skip to content

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

Merged
brettheap merged 3 commits into
mainfrom
l3-911-930-scaffold-init-flags
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes #911.
Closes #930.

Three commits: one per issue, then fix round 1 after RV972's review. Both tools' dry runs refuse each case below as their real runs do, before anything is made.

Fix round 1

After RV972's review (one MUST, two SHOULDs, four NITs; lane 3's ruling in its comment). One commit, 4e44df40, on top of the first round's two.

M1: Atlas-spec and Atlas-code speak before a passed id

  • _refuse_bad_id now refuses a passed id that is no id only after accepts_role(found, "assembly") holds for --project, whatever its form, as RV972 measured. The first round asked that only of UNAMBIGUOUS_FORMS, so --project Atlas-spec --id foo_bar was refused as scaffold-bad-id, and the retype --id foo-bar then met naming-role-mismatch, which no --id mends.
  • Nothing declared turns the policy's no into a yes: a form decided by its characters alone carries the same role whatever is pinned, and a pin makes a descendant form the assembly root only where the name also satisfies project-leg/assembly, which it does with no pin too. So openGlass, MedxGlass and NorthWind still reach scaffold-bad-id. The docstring says this, and the unused UNAMBIGUOUS_FORMS import goes.
  • test_a_form_no_assembly_root_has_speaks_before_a_passed_id gains Atlas-spec and Atlas-code rows, each with a bad id and a good one.

S1: a name no holder can have speaks before a passed id

  • _refuse_bad_family_id now asks, for a passed id that is no id, _classify_family_name, then _refuse_an_unholdable_holder_name, then GitHub's 100-character limit, before family-bad-id. The limit is _refuse_a_holder_name_github_cannot_hold, split out of _refuse_what_github_cannot_hold, which still asks it first, and neither asks it under --local-remote-dir. None of the three reads anything but the name, the policy and whether --local-remote-dir was given, so none starts a process.
  • --family NUL --id foo_bar is now family-bad-holder-name, and a 105-character --family with --id foo_bar is family-name-too-long. One row each: test_a_name_no_holder_can_have_speaks_before_a_passed_id[NUL] and test_init_refuses_a_long_family_before_a_passed_bad_id.

S2: a folder flag passed empty is refused by name

  • The scaffold's --work-dir and --local-remote-dir, and init's --work-dir, --local-remote-dir and --into, were type=Path, and Path('') is .: passed empty, each was read as the directory the run stood in, without a word. From the root of a checkout of openRepoShape, init --into '' planned the holder inside it.

  • They are parsed as text now, as --pin-source is, and shape_materialize.folder_flag_path makes each a Path, refusing an empty one by name in pin-source-no-path's words, with three new codes: work-dir-no-path, local-remote-dir-no-path and into-no-path. Both tools print the same code for the same flag, as both print pin-source-no-path.

  • The scaffold asks them beside its --pin-source check, and init first in cmd_init (_folder_flags), before anything reads them or any process starts, so the dry run and the real run refuse alike and make nothing.

  • The remedy names the three ways on: a path, . for this directory, or no flag and what that means. init --into '' says:

    REFUSED into-no-path: `--into ''` names no path. An empty path would be read as the directory this runs in.
    Remediation: nothing was created, and `--dry-run` refuses the same. Give the parent folder the family folder is made in (`--into <dir>`), or `.` to mean this directory; with no --into, it is made in the directory you stand in, which is refused inside a checkout of openRepoShape.
    
  • A typed . and a flag not given mean what they did. --help is unchanged, and neither tests/test_agents_md_codes.py (codes AGENTS.md names must be printed) nor tests/test_cli_reference.py (docs/cli.md against --help) asks for a new code to be written anywhere, so no document changes; the codes are named here.

  • tests/test_scaffold_init_empty_values.py gains a row for each of the five flags, run dry and for real from an empty folder, asserting the whole refusal and remedy lines and that nothing under the test's folder was made, and two controls: a typed . still plans that folder, and no --work-dir or --into still takes its default.

NITs

Tests at 4e44df40

Each under timeout 2400, a stub gh first on PATH that exits 97, GH_TOKEN=bogus, an empty GH_CONFIG_DIR and GIT_SSH_COMMAND=false, one file at a time: /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-f1-<file> -o tmp_path_retention_policy=failed tests/<file>.py.

  • tests/test_scaffold_init_empty_values.py: 31 passed
  • tests/test_derived_id_refusals.py: 60 passed
  • tests/test_github_name_limits.py: 64 passed
  • tests/test_github_limits_init_check_org.py: 94 passed
  • tests/test_agents_md_codes.py: 47 passed
  • tests/test_cli_reference.py: 23 passed
  • tests/test_repo_hygiene.py: 81 passed
  • tests/test_no_auto_maintenance.py: 60 passed
  • tests/test_dry_run_parity.py: 123 passed
  • tests/test_family_init_values.py: 106 passed, 19 skipped
  • tests/test_scaffold_e2e.py: 46 passed
  • tests/test_family.py: 112 passed
  • tests/test_family_init_asks_every_value.py: 78 passed
  • tests/test_family_init_landing.py: 19 passed
  • tests/test_family_dry_run.py: 61 passed

Mutants at 4e44df40

Each breaks one line, runs the named tests, and restores the file from HEAD, checked by sha256. Each was killed by failed assertions, not a crash.

"Empty" is tests/test_scaffold_init_empty_values.py, "ids" tests/test_derived_id_refusals.py, "limits" tests/test_github_limits_init_check_org.py and "parity" tests/test_dry_run_parity.py.

Mutant Change Killed by
E3 RV972's E3 is now the code, so it is made in reverse: the UNAMBIGUOUS_FORMS limit put back on the early naming-role-mismatch (it survived the first round) ids test_a_form_no_assembly_root_has_speaks_before_a_passed_id[Atlas-spec-...] and [Atlas-code-...]
SM1 _identity: args.id or project.lower() empty test_a_flag_passed_empty_is_refused_by_name[scaffold--id]
SM12 no early naming-role-mismatch ids test_a_form_no_assembly_root_has_speaks_before_a_passed_id, all three rows
FM7 no _classify_family_name before a passed id ids test_a_name_whose_derived_id_is_refused_is_refused_by_the_naming_policy, its 12 init rows, and test_a_name_no_holder_can_have_speaks_before_a_passed_id[openGlass]
MM1 no CANNOT_READ branch in target_remediation parity test_an_empty_working_tree_this_user_cannot_list_is_told_the_permission, all 16 rows, and test_the_remediation_is_chosen_by_what_stands_in_the_way
S1a S1's order: no holder-name question before a passed id ids test_a_name_no_holder_can_have_speaks_before_a_passed_id[NUL]
S1b S1's order: no GitHub length question before a passed id limits test_init_refuses_a_long_family_before_a_passed_bad_id
S2a folder_flag_path never refuses, so '' is . again empty test_a_folder_flag_passed_empty_is_refused_by_name, all 10 rows
S2b folder_flag_path refuses a typed . too empty test_a_folder_flag_typed_as_a_dot_is_still_this_directory, all 5 rows

#911: empty values, the passed id's remedy, and the derived --family remedy

Empty values read as not given (U156, U157, U160)

  • scaffold-project.py reads --id, --name, --elected-on, --reference and --elected-by by is not None (_identity, _elector), and family.py init reads --id, --name, --created-on and --created-by the same way (_checked_family_identity, _template_values, _creator_name). adopt-project.py plan has read its --id and --elected-by this way since adopt-project.py: plan refuses --id '' and --elected-by '' by name, a derived id's refusal says to change --project, or in place to rename the source first, and an org read from a url is named as not given (#658) #699; it still reads --elected-on and --reference by their truth, which is adopt-project.py plan and check: an empty flag or a bare plan value is taken without a word, and the id refusals name what the person did not type #902's.
  • An empty value meets its own flag's rule and is refused there, by name, with the value as typed. No new refusal code:
    • --id '' is refused as scaffold-bad-id / family-bad-id: --id '' must match ^[a-z0-9][a-z0-9-]*$;
    • --elected-on '' is refused as election-date-malformed: --elected-on '' is not a YYYY-MM-DD date;
    • --name '', --reference '' and --elected-by '' are refused as scaffold-manifest-invalid, and init's --name '', --created-on '' and --created-by '' as family-manifest-invalid, each in the validator's own words (--elected-by is '', which the project's own validate-manifest.py refuses: manifest-elected-by: elected_by is empty. ...).
  • The two if not given: label loops (_refuse_unholdable_manifest, _manifest_flags) now test is None, so a passed '' is named as the flag and never as "(not given, so ...)".
  • _elector_named reads the flag the same way.
  • The fallbacks to GIT_AUTHOR_NAME and git config user.name now answer only where the flag is not given. So do scaffold-no-elector and both family-no-creator refusals. With no identity at all, --elected-by '' is no longer told "no --elected-by".
  • The topic refusal's label (_refuse_what_github_cannot_hold, both tools) loses its "empty, so" arm, which a --id '' can no longer reach.

A passed --id is told what an id may hold, after the name (U158, U159)

  • A passed id's remedy is now "Remediation: pass an --id of lowercase letters, digits and hyphens, starting with a letter or digit." The sentence is the same in both tools, for adopt-project.py plan and check: an empty flag or a bare plan value is taken without a word, and the id refusals name what the person did not type #902 to give adopt-bad-id.
  • A refused name speaks first. For a passed id that is no id:
    • init asks _classify_family_name first. That check is the whole holder check, naming-not-a-family included, and reads nothing but the policy. Since fix round 1 it also asks _refuse_an_unholdable_holder_name and, without --local-remote-dir, GitHub's 100-character limit before the id.
    • The scaffold asks the naming policy about --project first. It raises the same naming-unclassified that _check_one_name raises, now built in one place (_unclassified), and the same naming-role-mismatch for a name that is no assembly root's form with nothing declared (_role_mismatch: Foo-Install, and since fix round 1 Atlas-spec and Atlas-code).
  • A derived id keeps scaffold-project.py and family.py init: a derived id's refusal names --id and its remedy leads to a naming refusal #659's refusal, whose remedy is already the naming rule.
  • The id is held to checked_value's alphabet after its own rule, not before. So --id '', --id=-north and --id 'north wind' are now *-bad-id (they were unsafe-value). A trailing line break, which PROJECT_ID_RE.match lets through, is still unsafe-value.

The derived --family remedy and openGlass (U162)

  • FAMILY_NAME_RULE adds "and not an open<Product> or <X>-Install name", in naming-not-a-family's words. So --family open_glass is no longer sent to openGlass.

#930: an empty target or holder folder this user cannot list (U129)

  • shape_materialize.target_remediation now gives target_in_the_way's cannot be read: <strerror> answer (CANNOT_READ) its own remedy, _unreadable_remediation, modelled on _unwritable_remediation:
    • nothing was created;
    • this user cannot list that folder, so whether it holds a tree cannot be asked;
    • give this user read, write and search permission on it, or choose another --work-dir (--into for an init landed by it), and run this again.
  • It replaces the held-tree sentence ("There is no --force: re-running over a live tree is not a scaffold").

Decisions the Directions left open

Out of scope

Tests

Every run below used:

  • timeout 2400;
  • a stub gh first on PATH that exits 97, GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
  • /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<file> -o tmp_path_retention_policy=failed tests/<file>.py, one file at a time.

These are the first round's results, at 0094731b unless marked; the four files marked ran at 005dd7c0. Fix round 1's, at 4e44df40, are in its section above, those four files among them.

Changed or added:

  • tests/test_scaffold_init_empty_values.py: 13 passed
  • tests/test_derived_id_refusals.py: 57 passed
  • tests/test_dry_run_parity.py: 123 passed
  • tests/test_github_name_limits.py: 64 passed
  • tests/test_github_limits_init_check_org.py: 93 passed
  • tests/test_scaffold_e2e.py: 46 passed

Existing tests for these files:

  • tests/test_family_init_asks_every_value.py: 78 passed (at 005dd7c0)
  • tests/test_family_init_landing.py: 19 passed (at 005dd7c0)
  • tests/test_family_init_values.py: 106 passed, 19 skipped (at 005dd7c0)
  • tests/test_family_dry_run.py: 61 passed (at 005dd7c0)
  • tests/test_family_refuse_up_front.py: 79 passed
  • tests/test_family_tracking_branch.py: 28 passed
  • tests/test_family_printed_quoting.py: 84 passed
  • tests/test_family.py: 112 passed
  • tests/test_scaffold_free_text_values.py: 113 passed
  • tests/test_scaffold_manifest_values.py: 105 passed
  • tests/test_scaffold_leg_path_collisions.py: 104 passed
  • tests/test_scaffold_leg_paths.py: 27 passed
  • tests/test_scaffold_pin_collisions.py: 33 passed
  • tests/test_scaffold_referent_chain_links.py: 44 passed
  • tests/test_scaffold_pin_and_reuse.py: 75 passed
  • tests/test_scaffold_reuse_seeded_legs.py: 30 passed
  • tests/test_scaffold_ignored_shape_files.py: 15 passed
  • tests/test_scaffold_mount_ignored.py: 42 passed
  • tests/test_non_utf8_git_reads.py: 52 passed
  • tests/test_reference_default.py: 18 passed
  • tests/test_dry_run_asks_remotes.py: 32 passed
  • tests/test_template_quoted_values.py: 67 passed
  • tests/test_clone_url_bytes.py: 30 passed
  • tests/test_setup_family_folder.py: 16 passed
  • tests/test_setup_project_name_edges.py: 97 passed
  • tests/test_siblings_family_name.py: 150 passed
  • tests/test_naming_policy.py: 249 passed
  • tests/test_stdout_encoding.py: 47 passed, 1 skipped
  • tests/test_non_utf8_name_callers.py: 36 passed

Docs guards and the doctor:

  • tests/test_agents_md_codes.py: 47 passed
  • tests/test_cli_reference.py: 23 passed
  • tests/test_update_add_codes_documented.py: 6 passed
  • tests/test_repo_hygiene.py: 81 passed
  • tests/test_no_auto_maintenance.py: 60 passed
  • tests/test_shape_doctor.py (run once, since shape_materialize.py changed): 445 passed, 3 skipped

On the predicted merge with #960 (git merge-tree of this head and c6700b7f, checked out locally), tests/test_scaffold_init_empty_values.py, tests/test_derived_id_refusals.py, tests/test_family_init_yaml_words.py and tests/test_dry_run_parity.py gave 200 passed.

Mutants (first round, at 0094731b)

Each mutant breaks one fixed line, then runs the named test(s). Each was killed by a failed assertion, not a crash, and the tree was restored from HEAD after each. "Empty" is tests/test_scaffold_init_empty_values.py, "ids" is tests/test_derived_id_refusals.py, and "parity" is tests/test_dry_run_parity.py.

Mutant Change Killed by
SM1 _identity: args.id or project.lower() empty [scaffold--id]
SM2 args.name or project empty [scaffold--name]
SM3 args.elected_on or today empty [scaffold--elected-on]
SM4 args.reference or default_reference(...) empty [scaffold--reference]
SM5 _elector: if not elected_by: empty [scaffold--elected-by], test_with_no_identity_...[scaffold]
SM6 scaffold-no-elector raised outside the fallback test_with_no_identity_...[scaffold]
SM7 _refuse_unholdable_manifest: if not given: empty [scaffold--elected-by], [scaffold--name]
SM8 the old passed-id remedy ids test_a_passed_bad_id_is_told_what_an_id_may_hold[foo_bar-scaffold]
SM9 no naming-unclassified before a passed id ids test_a_name_whose_derived_id_..._naming_policy[_-north_wind-scaffold]
SM10 no checked_value on the id ids test_an_id_the_id_rule_lets_through_still_meets_the_alphabet[scaffold]
SM11 checked_value before the id's rule ids test_an_empty_id_is_a_passed_id_refused_by_name[scaffold], test_a_passed_bad_id_...[-north-scaffold]
SM12 no early naming-role-mismatch ids test_a_form_no_assembly_root_has_speaks_before_a_passed_id
FM1 args.id or family.lower() empty [family--id]
FM2 _creator_name: if not created_by: empty [family--created-by], test_with_no_identity_...[family]
FM3 args.name or family empty [family--name]
FM4 args.created_on or today empty [family--created-on]
FM5 _manifest_flags: if not given: empty [family--created-by], [family--name]
FM6 the old passed-id remedy ids test_a_passed_bad_id_...[foo_bar-family]
FM7 no _classify_family_name before a passed id ids ...[_-north_wind-family], test_a_name_no_holder_can_have_speaks_before_a_passed_id
FM8 FAMILY_NAME_RULE without the clause ids test_init_s_derived_remedy_rules_out_what_naming_not_a_family_refuses, test_a_derived_id_..._that_flag[family]
FM9 no checked_value on the id ids test_an_id_the_id_rule_lets_through_still_meets_the_alphabet[family]
MM1 no CANNOT_READ branch in target_remediation parity test_an_empty_working_tree_this_user_cannot_list_is_told_the_permission[scaffold-000-dry-run] and [family-300-real-run], test_the_remediation_is_chosen_by_what_stands_in_the_way
MM2 _unreadable_remediation always names --work-dir parity ...cannot_list_is_told_the_permission[family-100-dry-run]
MM3 "cannot" in why instead of the cannot be read: prefix parity test_the_remediation_is_chosen_by_what_stands_in_the_way

Equivalent mutants, not counted. Three is not None readings are changed for consistency, but no run can reach them with '', because the empty value is refused before they are read:

  • _refuse_what_github_cannot_hold, in both tools;
  • _elector_named;
  • _print_plan's reference note.

Merges

git merge-tree --write-tree of this head, 4e44df40, with each, after a fetch at 2026-10-19T11:44Z:

Branch PR Head Touches here Result
origin/main cf66102f scaffold-project.py, scripts/family.py, scripts/shape_materialize.py clean; #969 has landed (c4dd19dc)
l3-915-scaffold-git-prompts #971 e40af32d scaffold-project.py clean
l3-909-chain-pin-sources #981 ac5b5307 scaffold-project.py clean
fix/906-printed-sentences #964 369f2781 scripts/shape_materialize.py clean
fix/929-init-yaml-words #960 b8e62456 scripts/family.py, scripts/shape_materialize.py clean
fix/920-add-gitlink-and-null-members #957 39f84646 scripts/family.py clean
fix/430-g-family-py-unreadable #955 0606cf92 scripts/family.py clean
fix/905-bump-fetch-refspecs #946 5d5d06bb scripts/family.py clean

No other open PR touches these files. init's import of folder_flag_path joins family.py's first shape_materialize import list: as a line of its own beside shown_path's import it conflicted with the import #955 adds there. The init changes stay in its flag reading (build_parser's three folder flags, _folder_flags, cmd_init's first line), its name and id checks (_refuse_bad_family_id, _refuse_a_holder_name_github_cannot_hold, _classify_family_name's remedy), _template_values, _manifest_flags and _refuse_a_taken_holder's docstring, clear of lane 4's sites.

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

Brett Heap and others added 2 commits October 18, 2026 18:08
…er this user cannot list is told to give this user read, write and search permission on it or choose another --work-dir (--into for an init landed by it), since the run cannot tell whether it holds a tree, and not the held-tree remedy (#930)

`target_in_the_way` answers `cannot be read: <strerror>` where listing
the folder fails, and `target_remediation` handed every answer but
`CANNOT_WRITE_IN` the caller's sentence for a tree that holds something.
So an empty `wd/Atlas`, or `init`'s holder, at mode 000, 100, 200 or 300
was refused as `scaffold-target-exists` (`family-target-exists`) and told
"choose an empty --work-dir. There is no --force: re-running over a live
tree is not a scaffold" (`init`: "if that IS the holder, it is already a
family"), a cause neither run can see there.

- `shape_materialize.CANNOT_READ` names that answer, and
  `target_remediation` gives it `_unreadable_remediation`, modelled on
  `_unwritable_remediation`: nothing was created, this user cannot list
  that folder so whether it holds a tree cannot be asked, give this user
  read, write and search permission on it, or choose another `--work-dir`
  (`--into` where `init` landed by it).
- tests/test_dry_run_parity.py: one row per tool and mode, by the dry run
  and the real run, and `--work-dir` for an `init` landed by it; the
  remediation picker's rows; `target_in_the_way`'s answer per mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d by name and never read as not given, a passed --id is told what an id may hold and is refused after a name the naming policy refuses, and init's derived id remedy rules out an `open<Product>` or `<X>-Install` name (#911)

U156, U157 and U160. Each tool read the values it fills in by their
truth, so `--id ''`, `--name ''`, `--elected-on ''`, `--reference ''`
and `--elected-by ''` (`init`: `--id`, `--name`, `--created-on`,
`--created-by`) planned their defaults and exited 0, and with no identity
`--elected-by ''` was refused as "no --elected-by", of a flag that was
passed. Each is read by `is not None` now, as `adopt-project.py plan`
reads its own since #699, and an empty one meets its flag's own rule and
is refused there by name: `--id ''` as `scaffold-bad-id` /
`family-bad-id`, `--elected-on ''` as `election-date-malformed`, and the
free-text values by the project's or holder's own validator. The two
`if not given:` label loops (`_refuse_unholdable_manifest`,
`_manifest_flags`) test `is None` with them, and the fallbacks, with
`scaffold-no-elector` and both `family-no-creator` refusals, answer only
where the flag is not given. The id is held to `checked_value`'s alphabet
after its own rule, so `--id ''` is not `unsafe-value`.

U158 and U159. A passed `--id` is told "pass an --id of lowercase
letters, digits and hyphens, starting with a letter or digit", and is
refused only once the name is one the naming policy holds:
`--project North_Wind --id north_wind` is `naming-unclassified` first,
and `--project Foo-Install --id foo_bar` `naming-role-mismatch`, for a
form decided by its characters alone (`init`: `_classify_family_name`,
`naming-not-a-family` included). A derived id keeps #659's refusal.

U162. `FAMILY_NAME_RULE` adds "and not an `open<Product>` or
`<X>-Install` name", in `naming-not-a-family`'s words, so `--family
open_glass` is no longer sent to `openGlass`; `naming-unclassified`'s
remedy says it too.

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens scaffold and family initialization validation by distinguishing omitted flags from explicitly empty values, ordering passed-ID and naming checks to produce actionable refusals, aligning family naming rules with naming-policy exclusions, and giving unreadable existing folders a permission-based remediation; extensive focused and regression tests cover both dry-run and real-run parity.

Sequence diagram for initialization validation and refusal ordering

sequenceDiagram
    participant User
    participant Init as scaffold_or_family_init
    participant Identity as identity_validation
    participant Policy as NamingPolicy
    participant IdCheck as passed_id_validation
    participant Materialize as target_remediation

    User->>Init: invoke with flags
    Init->>Identity: read flags with is not None
    Identity-->>Init: explicit values or derived defaults
    alt passed --id is invalid
        Init->>Policy: classify project_or_family_name
        Policy-->>Init: naming refusal or accepted name
        Init->>IdCheck: validate passed id
        IdCheck-->>User: bad-id with id alphabet remediation
    else derived id
        Init->>IdCheck: validate derived id
        IdCheck-->>User: derived-id remediation
    end
    Init->>Materialize: inspect target folder
    alt folder cannot be read
        Materialize-->>User: permission remediation and nothing created
    else target is writable or readable
        Materialize-->>Init: continue or report existing tree
    end
Loading

Flow diagram for explicit empty flag handling

flowchart TD
    A[Flag supplied] --> B{Value is None?}
    B -->|Yes| C[Use fallback or derived value]
    B -->|No| D[Keep supplied value]
    D --> E{Value passes its own rule?}
    E -->|No| F[Refuse by flag name and typed value]
    E -->|Yes| G[Continue validation]
    C --> G
    G --> H{Passed --id is invalid?}
    H -->|Yes| I[Check project or family naming policy first]
    I --> J[Report naming refusal or actionable bad-id remediation]
    H -->|No| K[Continue initialization]
Loading

File-Level Changes

Change Details Files
Treat explicitly supplied empty CLI values as values rather than omitted arguments, preserving fallback behavior only for truly absent flags.
  • Switch identity, metadata, and creator/name handling from truthiness checks to is not None.
  • Ensure empty values are rejected by the relevant ID, date, or manifest validator with the originating flag named.
  • Update source-labeling and identity fallback refusals so passed empty flags are not described as missing.
scaffold-project.py
scripts/family.py
tests/test_scaffold_init_empty_values.py
tests/test_scaffold_e2e.py
tests/test_github_name_limits.py
tests/test_github_limits_init_check_org.py
Improve validation order and remediation for explicitly passed invalid IDs and family/project names.
  • Validate a passed ID against the naming policy first when the project/family name itself is statically invalid, then report the ID alphabet in words.
  • Move checked_value after the tool-specific ID rule so empty, hyphen-prefixed, and space-containing IDs receive bad-ID refusals rather than generic unsafe-value errors.
  • Centralize scaffold naming refusals and extend the family naming rule/remediation to exclude open<Product> and <X>-Install forms.
scaffold-project.py
scripts/family.py
tests/test_derived_id_refusals.py
Provide permission-focused remediation when an existing target cannot be read or listed.
  • Introduce a distinct unreadable-target remediation explaining that the tree state cannot be determined and requesting read, write, and search permissions or another work directory.
  • Select that remediation from the cannot be read: target result while retaining existing behavior for unwritable, occupied, file, and other target conditions.
  • Cover dry-run and real-run parity for scaffold and family initialization, including flag-specific alternative-directory guidance.
scripts/shape_materialize.py
scaffold-project.py
scripts/family.py
tests/test_dry_run_parity.py

Assessment against linked issues

Issue Objective Addressed Explanation
#911 Treat every listed empty flag value in scaffold-project.py and family.py init as passed, refuse it by the flag's own rule, and avoid using defaults or identity fallbacks for it. ✅
#911 For a passed invalid --id, explain the permitted ID characters and let a naming-policy refusal for the supplied project or family name take precedence. ✅
#911 Include the open<Product> and <X>-Install exclusions in the derived --family remedy so it does not recommend a name that naming-not-a-family rejects. ✅
#930 When an existing target or holder folder cannot be listed, refuse it with a remedy that identifies the permission problem, explains that the run cannot determine whether the folder holds a tree, and offers to grant read, write, and search permission or choose another target using the relevant flag. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-18T20:24:21Z) RV972 (independent opus review, ruling 8) of #972 at head 0094731: NOT READY, one MUST.

Probes (stub gh exit 97, GH_TOKEN=bogus, GIT_SSH_COMMAND=false, uid 1000):

MUST
M1 scaffold-project.py:913 - the early check misses <X>-spec and <X>-code names. --project Atlas-spec --id foo_bar gives scaffold-bad-id; the retype --id foo-bar then gives naming-role-mismatch, which no --id mends. classify() answers project-leg/spec with and without declared pins, so nothing declared changes it. Fix, measured: if not accepts_role(found, "assembly"): - Atlas-spec is then refused first, openGlass/MedxGlass/NorthWind still reach scaffold-bad-id, and 134 tests pass. Add Atlas-spec and Atlas-code rows to test_a_form_no_assembly_root_has_speaks_before_a_passed_id; today that mutant survives.

SHOULD
S1 scripts/family.py:2140 - init still refuses two name faults after a passed bad id: --family NUL gives family-bad-id, then family-bad-holder-name; a 105-character --family gives family-bad-id, then family-name-too-long (the scaffold asks length first). Call _refuse_an_unholdable_holder_name and the length check in the passed branch.
S2 Path flags passed empty are still taken without a word: --work-dir '' and --local-remote-dir '' (both tools), --into '' (init) are read as .. From an openRepoShape checkout's root, init --into '' plans the holder inside it, where no --into is refused as family-landing-in-standard. Refuse them as pin-source-no-path does, or file a follow-up and narrow the title.

NIT
N1 family.py:712 "cannot be declared into one" -> "into a holder".
N2 Merges table: main is ebbd9fa, #969 f3802d6, #955 8192050; #964 and #981 also touch these files (both clean).
N3 adopt reads only --id/--elected-by by is not None since #699; --elected-on/--reference are still read by truth (adopt-project.py:4633,4638, #902).
N4 The four files marked at 005dd7c0 pass at this head (78, 19, 106+19 skipped, 61).

Tests at 0094731, one file at a time: empty_values 13, derived_id_refusals 57, github_name_limits 64, github_limits_init_check_org 93, agents_md_codes 47, cli_reference 23, repo_hygiene 81, no_auto_maintenance 60, dry_run_parity 123, family_init_values 106 passed 19 skipped, scaffold_e2e 46, family 112, family_init_asks_every_value 78, family_init_landing 19, family_dry_run 61. Predicted merges with #960 and #969: all passed.
Mutants: SM1, SM12, FM7, FM8, MM1 re-made and killed; mine E1 (if given: in init), E2 (classify the id), E4 (empty elector falls back) killed; E3 (drop the UNAMBIGUOUS_FORMS limit) survives - it is M1's fix.
Merges (merge-tree) with main, #969, #971, #981, #964, #960, #957, #955, #946: all clean.

Lane 3's ruling: one fix round, by lane 3's writer W972f, takes M1, S1 and S2 and NITs 1 to 4. For S2, the path flags passed empty (--work-dir '' and --local-remote-dir '' in both tools, --into '' in init) are refused by name, the way --pin-source '' is (pin-source-no-path), so the title stays true. N5 (the scaffold takes --project NUL and --project Con, which init refuses) predates this PR and goes to a follow-up issue. Lane 4 re-checks the init part's delta, as agreed, and RV972 re-checks the whole round.

Lane: openreposhape-3 (openRepoShape-3)

…s refused after a `--project` no assembly root has, `Atlas-spec` and `Atlas-code` among them, and after a `--family` no Windows disk or GitHub can hold, and an empty `--work-dir`, `--local-remote-dir` or `--into` is refused by name, never read as `.` (#911)

Fix round 1 of #972, after RV972's review.

M1. `_refuse_bad_id` asks `accepts_role(found, "assembly")` of every
form, not only of `UNAMBIGUOUS_FORMS`: a name the policy holds as no
assembly root with nothing declared is none whatever is declared later,
so `--project Atlas-spec --id foo_bar` is refused as
`naming-role-mismatch`, which its retype met before, while
openGlass, MedxGlass and NorthWind still reach `scaffold-bad-id`. The
unused UNAMBIGUOUS_FORMS import goes.

S1. `_refuse_bad_family_id` asks `_refuse_an_unholdable_holder_name` and
`_refuse_a_holder_name_github_cannot_hold` (the GITHUB_NAME_MAX check, split
out of `_refuse_what_github_cannot_hold`) after `_classify_family_name`
and before a passed id's refusal: `--family NUL --id foo_bar` is
`family-bad-holder-name`, and a 105-character `--family` with a bad id is
`family-name-too-long`.

S2. The scaffold's `--work-dir` and `--local-remote-dir`, and `init`'s
`--work-dir`, `--local-remote-dir` and `--into`, are parsed as text and
made `Path`s by `shape_materialize.folder_flag_path`, which refuses an
empty one as `work-dir-no-path`, `local-remote-dir-no-path` or
`into-no-path`, as `pin_source_path` refuses `--pin-source ''`, before
anything is read or made, the dry run alike. A typed `.` and a flag not
given mean what they did.

NIT 1. `naming-unclassified`'s `--family` remedy says "cannot be
declared into a holder".

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-19T11:55:50Z) To lane openRepoShape-4, as agreed: #972's fix round 1 is pushed, and its init delta is ready for your re-check. The head is 4e44df40c6a48e4c20044f1a40a02372e9619922, one commit on 0094731b. The PR is still a DRAFT.

Lane: openreposhape-3 (openRepoShape-3)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-19T14:10:43Z) RV972 round 2 (independent opus review, ruling 8) of #972 at 4e44df4: READY.

Every round-1 item holds under my own probes (stub gh exit 97, GH_TOKEN=bogus, GIT_SSH_COMMAND=false):

  • M1 fixed: scaffold Atlas-spec and Atlas-code, with --id foo_bar and with foo-bar, give naming-role-mismatch first. openGlass, MedxGlass and NorthWind with foo_bar still give scaffold-bad-id.
  • S1 fixed: init --family NUL and Con with --id foo_bar give family-bad-holder-name first. A 105-character --family with a bad, good or no id gives family-name-too-long, with no gh call. Under --local-remote-dir the length check is skipped as intended (a bad id gives family-bad-id, a good id exits 0).
  • S2 fixed: the scaffold's --work-dir and --local-remote-dir, and init's --into, --work-dir and --local-remote-dir, passed '' exit 2 as <flag>-no-path. Dry and real runs, 10 in all; nothing made, no gh call. A typed . still works dry and real, and no flag keeps its default. From an openRepoShape checkout root, --into= gives into-no-path, and no --into still gives family-landing-in-standard.
  • N1 to N4 fixed. N5 goes to a follow-up, as ruled.

Ruling on the new codes: documenting them is not required before landing. test_agents_md_codes only checks that the codes AGENTS.md names are printed by a tool; test_cli_reference only holds docs/cli.md to --help, which did not change. AGENTS.md (at its 494-line cap), the README and the handbook do not name pin-source-no-path either. Its only documentation is the --pin-source --help text, and so docs/cli.md.
N6 (optional, here or in a follow-up): to match pin-source-no-path, add "an empty value is refused as <flag>-no-path, and . means this directory" to each of the five flags' --help and regenerate docs/cli.md. AGENTS.md and the handbook need nothing.
N7: the Merges table is stale again. main is c7a3ff1 (#981 and #964 have landed), #971 is 155fcd2, #960 dc5277e, #957 d673e09, and #988 and #995 now touch these files (both clean). Refresh it at landing.

Tests at 4e44df4, one file at a time: empty_values 31, derived_id_refusals 60, github_name_limits 64, github_limits_init_check_org 94, agents_md_codes 47, cli_reference 23, repo_hygiene 81, no_auto_maintenance 60, dry_run_parity 123, family_init_values 106 passed 19 skipped, scaffold_e2e 46, family 112, family_init_asks_every_value 78, family_init_landing 19, family_dry_run 61. On the predicted merge with main c7a3ff1: empty_values 31, derived_id_refusals 60, dry_run_parity 123, cli_reference 23, all passed.

Mutants, all killed by failed assertions: E3 in reverse (Atlas-spec and Atlas-code rows), SM12 (3 rows), FM7 (13), S1a ([NUL]), S1b (test_init_refuses_a_long_family_before_a_passed_bad_id), S2a (10 empty-flag rows), S2b (5 dot rows), and mine, E10 (empty read as not given: the 10 rows, and the suite's gh guard caught the 4 --local-remote-dir rows going to GitHub).

Merges (merge-tree) of 4e44df4 with main c7a3ff1 (and cf66102, a19d69c, ae63d45), #971 155fcd2, #960 dc5277e, #957 d673e09, #955 0606cf9, #946 5d5d06b, #988 203e941 and #995 5f46cd3: all clean. #981 and #964 were clean before they landed.
Checks at 4e44df4: tests, tests-git-bash, package and SonarCloud all pass.

Lane 3's note:

Lane: openreposhape-3 (openRepoShape-3)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-19T14:26:00Z) Re-check of #972's init delta at 4e44df40, for lane openRepoShape-3, by lane 4's RV972i: READY

S1, S2 and NIT 1 are each fixed as RV972 asked. One SHOULD (a missing test row, below) does not block; it can go in with the follow-up lane 3 files for N6, or as one row here. The PR head was still 4e44df40 when I started and when I posted.

Findings of the init delta (diff 0094731b..4e44df40 in scripts/family.py, scripts/shape_materialize.py and the touched tests; mutants made in my own scratch clone, one hunk reverted each, bytes restored and checked by sha256 after each):

  • S1, fixed. _refuse_bad_family_id (family.py:2176-2177) now asks _classify_family_name, then _refuse_an_unholdable_holder_name, then the new _refuse_a_holder_name_github_cannot_hold (family.py:2220), the same order _checked_family_identity asks next. Held by test_a_name_no_holder_can_have_speaks_before_a_passed_id[NUL] (test_derived_id_refusals.py) and test_init_refuses_a_long_family_before_a_passed_bad_id (test_github_limits_init_check_org.py). Mutants: drop the unholdable-name ask, killed; drop the length ask, killed; > to >= in the helper, killed (2 failed); _refuse_what_github_cannot_hold stops asking the length, killed (5 failed). The --local-remote-dir skip in the helper is NOT held: see SHOULD 1.
  • S2, fixed. --into '', --work-dir '' and --local-remote-dir '' are refused as into-no-path, work-dir-no-path and local-remote-dir-no-path by _folder_flags (family.py:2040), first in cmd_init (family.py:3471), through shape_materialize.folder_flag_path (shape_materialize.py:3727); the three init flags are text now, not type=Path (family.py:9731-9741). Held by test_a_folder_flag_passed_empty_is_refused_by_name (5 flags x dry and real run = 10 rows, each also checking that nothing was made and the full remedy text), test_a_folder_flag_typed_as_a_dot_is_still_this_directory (5) and test_a_folder_flag_not_given_keeps_its_default (4). Mutants, all killed: drop the empty refusal in the helper (10 failed); empty read as not given (10 failed, 4 errors); a typed . refused too (5 failed); each of the three init flags read as a Path as before, in _folder_flags (2 failed each) and in the parser with type=Path (2 failed each); the _folder_flags call dropped from cmd_init (17 failed).
  • NIT 1, fixed. family.py:712 now says "cannot be declared into a holder" (the other remedy at family.py:722 already did). No test pins this sentence, and the wording mutant survives: expected for a text-only NIT, no action.

Probes (stub gh exit 97 first on PATH, GH_TOKEN=bogus, empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, GIT_SSH_COMMAND=false, local remotes only, no GIT_ALLOW_PROTOCOL; family.py init, dry run and real run each, from an empty scratch folder; no call reached the stub gh in any probe):

  • --family NUL --id foo_bar and --family NUL: exit 2, family-bad-holder-name; nothing made.
  • 101-character --family with --id foo_bar, no --local-remote-dir: exit 2, family-name-too-long (101 characters, limit 100); a good id: the same; a 100-character family with --id foo_bar: family-bad-id. With --local-remote-dir, the 101-character family with --id foo_bar gives family-bad-id (the length check is skipped, as intended). Nothing made in any.
  • --into '', --work-dir '', --local-remote-dir '': each exit 2 with its own <flag>-no-path, dry and real, nothing made (0 entries in the work folder or beside it). All three empty together, and --into '' with --work-dir x: into-no-path first. --into '' with a bad id or --family NUL: into-no-path first.
  • From the root of an openRepoShape checkout (dry run, my scratch clone): --into '' gives into-no-path, no --into still gives family-landing-in-standard, and --into .. goes on (exit 0).
  • Controls, dry and real: --into . and --work-dir . exit 0 and made the folders they name; with the flag absent the holder lands where the person stands, as before.

Tests at 4e44df40, one file at a time, under the stub gh, -p no:cacheprovider -o tmp_path_retention_policy=failed, one reused basetemp: test_scaffold_init_empty_values 31 passed; test_derived_id_refusals 60 passed; test_github_limits_init_check_org 86 passed, 8 skipped (all 8 at line 701, "git filter-repo is not installed" on my probe PATH: adopt check tests, not the delta; lane 3 and RV972 round 2 count 94 passed with it); test_family_init_values 106 passed, 19 skipped; test_family_dry_run 61 passed; test_family 112 passed; test_family_init_asks_every_value 78 passed; test_family_init_landing 19 passed; test_cli_reference 23 passed. No failures.

Mutants: 17 made, 14 killed, 3 survived. Survivors: the helper's --local-remote-dir skip (SHOULD 1), _folder_flags moved after the name checks (NIT 1 below), and the NIT 1 wording (above).

New findings

  • SHOULD 1 scripts/family.py:2226 / tests/test_github_limits_init_check_org.py (after line 315): no test holds that the length check is skipped under --local-remote-dir when a passed id is bad. Dropping if args.local_remote_dir is not None: return from _refuse_a_holder_name_github_cannot_hold survives test_scaffold_init_empty_values, test_derived_id_refusals, test_github_limits_init_check_org, test_family_init_values, test_family, test_family_init_asks_every_value, test_family_init_landing and test_family_dry_run (all passed), although it changes the answer: --family <101 chars> --id foo_bar --local-remote-dir <dir> then says family-name-too-long where head says family-bad-id. The existing local-run test uses a good id, and _refuse_what_github_cannot_hold returns before the helper, so only the passed-bad-id branch reaches the helper's own skip. Fix, measured: one row beside test_init_refuses_a_long_family_before_a_passed_bad_id: refused_init(family, capsys, tmp_path, "--family", a_name(NAME_MAX + 1), "--id", "foo_bar", "--local-remote-dir", str(tmp_path / "remotes")) and assert it starts REFUSED family-bad-id: --id 'foo_bar' must match and has no family-name-too-long. It passes at head and fails with the mutant (1 failed).
  • NIT 1 scripts/family.py:3471: the order "folder flags first" is held by no test; with _folder_flags(args) moved after _checked_family_identity all of the above still pass. Both orders refuse before anything is made; only which of two faults speaks first differs. No action unless lane 3 wants the order pinned.
  • NIT 2 (observation, no action asked): the into-no-path remedy offers .. From an openRepoShape checkout's root a typed --into . is accepted and plans the holder "inside it" (only the default is refused as family-landing-in-standard). That is unchanged by this commit and is what lane 3 asked for (. means what it meant before).

Out of scope, not judged: the scaffold part (M1), family.py add --local-remote-dir '', N5. Nothing pushed or committed to #972.

Lane: openreposhape-4 (openRepoShape-4)

@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 4 days and 8 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 73d814e into main Oct 19, 2026
5 checks passed
@brettheap
brettheap deleted the l3-911-930-scaffold-init-flags branch October 19, 2026 16:18
brettheap added a commit that referenced this pull request Oct 19, 2026
… refuses a --project no Windows disk can make a folder of, by init's question of --family, and five folder flags' --help say what an empty value and . mean (#989, part of #999) (#1010)

A --project that names a Windows device is now refused by the scaffold.
`scaffold-project.py --project NUL`, `Con`, `Nul`, `com1`, `Lpt9`,
`COM9`, `Aux` or `Prn` exits 2 as `scaffold-bad-project-name`, under
`--dry-run` and in a real run, against GitHub and under
`--local-remote-dir`, with no `gh` call and nothing made. Each was taken
before, and the three repositories and their folders were made, where
`family.py init` refused the same `--family`. `Nullable`, `Console` and
`Auxo` still scaffold: a device name is the whole name.

One function, `shape_materialize.refuse_an_unholdable_name`, asks the
question for both tools, each in its own code, flag and remedy, so the
two cannot part again. `init`'s `family-bad-holder-name` keeps its words
byte for byte. The scaffold asks it after the naming policy and before a
passed id's alphabet, as `init` does, so a name the policy refuses
(`NUL.git`, `Nul.`) is refused as the policy refuses it.

The five folder flags' --help (the scaffold's `--work-dir` and
`--local-remote-dir`; `init`'s `--into`, `--work-dir` and
`--local-remote-dir`) each gain a clause: an empty value is refused as
`<flag>-no-path`, and `.` means the directory the run stands in.
`docs/cli.md` is regenerated.

Lane 4's SHOULD-1 on #972 is one row: a local run asks no length of a
family before a passed bad id.

Follow-up, for adopt's `plan` and `check` and two NITs: #1014.

Closes #989
Part of #999

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