Skip to content

adopt-project.py: plan --project NUL (and Con, com1, Lpt9) is refused as adopt-bad-project-name before anything exists, and check and execute find it in the plan's legs.assembly, by the one question the scaffold and family.py init ask (#1014) - #1041

Merged
brettheap merged 2 commits into
mainfrom
l3-1014-adopt-device-names
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1014.

adopt-project.py plan now refuses a --project that no Windows disk can make a folder of (NUL, Con, com1, Lpt9, in any case). check finds the same name in a plan, and execute refuses it. All three use the one function the scaffold and family.py init already ask: shape_materialize.refuse_an_unholdable_name, from #1010 (e076e3e8). The two NITs from RV1010's review of #1010 are closed here too.

What changed

SHOULD-1, plan. _checked_flag_inputs now asks the question of --project as adopt-bad-project-name. It runs with the flags, so nothing exists yet: no work directory, no git in the source, no gh repo clone, no gh call and no plan file. On main, plan --project NUL wrote a plan (project NUL (nul) in acme, exit 0). Now:

REFUSED adopt-bad-project-name: --project is 'NUL', which names the assembly root and the folder `execute` clones it into, and `NUL` is a Windows device name (with or without an extension), which cannot be created there.
Remediation: no plan was written. Run `plan` again with another --project. It names the assembly root, and so the folder `execute` clones the root into and the folder every clone of it is made in; and a root is cloned on Windows too, where no file or folder can be called a device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`, in any case, with or without an extension), and a name ending in a dot or a space is made under another name. Where --project is the source's own name, it cannot be changed here: an in-place adoption keeps the source's own name, and whether to rename the source is a human's decision, made first on GitHub (`gh repo rename`); then run `plan` again against the renamed repository, with its new name as --project.

SHOULD-1, check and execute. The question is now the first one in _what_cannot_be_held. That is the one list execute refuses the first entry of (_repository_urls) and check reports every entry of (_held_findings, #863). It is asked of the plan's legs.assembly, under the same code, both on GitHub and under --local-remote-dir. The code joins RENAMES_THE_LEGS, so check asks gh nothing about the legs of a name that has to change. On main, check of a plan for NUL printed plan ok, exit 0, after gh repo view acme/NUL-spec and acme/NUL-code. Now it gives one finding, FINDING adopt-bad-project-name: <plan>: legs.assembly is 'NUL', ..., exit 1, and the stub gh logs nothing.

NIT-1. "nul\n" is added to @pytest.mark.parametrize("passed", ...) of test_a_name_no_root_can_have_on_windows_speaks_before_a_passed_id in tests/test_derived_id_refusals.py. The row's asserts are unchanged; its docstring says what the row holds. It kills RV1010's mutant C (see Mutants).

NIT-2. "whatever the id" now reads "whatever id is passed" in _refuse_an_unholdable_project_name's docstring (scaffold-project.py) and in the docstring of test_a_name_the_policy_refuses_too_is_refused_by_the_policy (tests/test_scaffold_device_names.py). These are comments only.

Measured with a stub gh (logs, exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR and GIT_SSH_COMMAND=false, on a one-commit local source:

--project plan at main plan at head check (main's plan) at main check (main's plan) at head
NUL, Con, com1, Lpt9 exit 0, plan written, 0 gh calls exit 2 adopt-bad-project-name, no plan, 0 gh calls exit 0 plan ok, 2 repo view calls exit 1, one FINDING adopt-bad-project-name, 0 gh calls, --work-dir empty
Nullable, Console, Auxo exit 0 exit 0 exit 0 plan ok exit 0 plan ok (2 repo view calls, as at main)

Decisions the Direction left open

  1. The code is adopt-bad-project-name. It is one code for plan, check and execute, as adopt-name-too-long and adopt-bad-org are.
  2. Where plan asks it. It is asked in _checked_flag_inputs, right before a passed --id's question, and only where the naming policy holds the three names --project makes (_names_held, the gate the passed-id question already used).
    • adopt's naming refusal is asked after the source is read, because its remedy names the source. So the gate is how "the naming refusal first" is kept without reading the source.
    • A name the policy refuses too keeps its refusal from main. With an --id, NUL.git and Nul. are naming-unclassified. With no --id, they are adopt-bad-id for the derived id, as the scaffold says scaffold-bad-id.
    • The order is then: the naming refusal, then this one, then a passed --id's sentence (foo_bar, '', and nul with a line break).
    • The leg-path, --pin and collision refusals of _checked_flag_inputs still come before it, as they do in the scaffold.
  3. check and execute ask it of legs.assembly, not project:.
    • execute clones the root into <work-dir>/<legs.assembly>.
    • Every name in _what_cannot_be_held comes from legs:.
    • project: names no folder; only _not_the_source reads it.
    • A plan that plan wrote holds the same value in both, so "a plan whose project: is NUL" is found.
  4. It is first in the list, before the names' lengths. It is asked under --local-remote-dir too, because a root is cloned there as well.
  5. One remedy for all three subcommands. plan starts it with "no plan was written." (PLAN_NOT_WRITTEN); check and execute start it with "no repository was created.", as ORG_REMEDIATION does. plan asks before it reads the source, so it cannot yet tell whether --project is the source's own name. The remedy therefore gives both ways out: another --project, or, in place, the rename first, in _rename_first's words.
  6. Docs. adopt-project.py's --help is unchanged, as the scaffold's --project help was in 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, so docs/cli.md is unchanged. AGENTS.md, the README and the handbook name no adopt-* or scaffold-bad-project-name code, so they are unchanged too. The module docstring gains one sentence.

Left out of scope

Tests

Each test file was run alone under the stub gh (exit 97), with GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and EMAIL=tests@openreposhape.invalid. The command was /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<name> -o tmp_path_retention_policy=failed tests/<file>. They ran at 13fd52e93 (on e2f9fc424). It was then rebased onto 5b41d44fc, which changes only templates/family-root/scripts/validate-family.py and its test; the rows marked "after the rebase" ran at the head.

File Result
tests/test_adopt_device_names.py (new) 44 passed in 13.20s
the same file against origin/main's code (the rows must fail there) 31 failed, 13 passed: every device row fails; the 9 control rows and the 4 naming-first rows pass
tests/test_derived_id_refusals.py 63 passed in 10.51s
tests/test_scaffold_device_names.py 48 passed in 12.85s
tests/test_adopt_plan_flag_refusals_first.py 89 passed in 28.93s
tests/test_github_limits_init_check_org.py 95 passed in 26.32s
tests/test_github_name_limits.py 64 passed in 22.91s
tests/test_adopt_leg_paths.py 351 passed in 64.78s
tests/test_scaffold_leg_path_collisions.py 104 passed in 3.73s
tests/test_agents_md_codes.py 47 passed in 1.96s
tests/test_cli_reference.py 23 passed in 28.97s
tests/test_update_add_codes_documented.py 6 passed in 0.47s
tests/test_repo_hygiene.py 100 passed in 2.49s
tests/test_no_auto_maintenance.py 60 passed in 11.77s
tests/test_shape_doctor.py (after the rebase) 445 passed, 3 skipped in 641.11s (0:10:41)
tests/test_adopt_device_names.py (after the rebase) 44 passed in 14.53s
tests/test_repo_hygiene.py (after the rebase) 100 passed in 2.42s

pyflakes gives the same count on the four changed files as on main (1, which predates this change). No added line is over 79 characters.

Mutants

Each mutant changed one hunk in a separate worktree of the commit. The file was then restored with git checkout and checked by sha256 (OK every time).

Mutant Killed by
M1: plan does not ask the question 19 failed: all 16 test_plan_refuses_a_device_name_before_anything_exists rows and 3 test_plan_says_the_name_before_a_passed_id rows
M2: plan asks it after a passed --id's question 2 failed: test_plan_says_the_name_before_a_passed_id[foo_bar] and []
M3: plan asks it without the _names_held gate 4 failed: test_plan_leaves_a_name_the_policy_refuses_to_the_policy (NUL.git, Nul. with --id foo and foo_bar)
M4: plan asks it after the source is read (after _check_names in _checked_plan_inputs) 19 failed: the same rows as M1 (git ran, the work directory was made)
M5: _what_cannot_be_held does not ask it 12 failed: 4 test_check_finds_a_device_name_and_asks_gh_nothing and 8 test_execute_refuses_it_first_of_the_list_check_reads
M6: the code is not in RENAMES_THE_LEGS 12 failed: the 4 check rows (assert ['repo view t...ameWithOwner'] == []) and the 8 list rows
M7: the list asks it only without --local-remote-dir 4 failed: test_execute_refuses_it_first_of_the_list_check_reads[*-local]
M8: RV1010's mutant C (the scaffold's device question moved after checked_value("--id")) 1 failed: test_a_name_no_root_can_have_on_windows_speaks_before_a_passed_id[nul\n] (REFUSED unsafe-value: --id is 'nul\n')

Merges

git merge-tree --write-tree of the head ce53b0b4f3ecc22f5686b438b523614c8e9243a8:

With Result
origin/main 5b41d44fc clean, tree 535279bb (the head's own tree)
#1031 abd1b9844 (fix/910-check-throwaway-repos, touches adopt-project.py) clean, tree 9e4988f4
#1030 18cc65939 (fix/900-spelled-names, touches adopt-project.py) clean, tree 89b24784

No other open PR (7 open) touches adopt-project.py, scaffold-project.py or the three test files.

Fix round 1 (RV1041)

RV1041 (independent review at ce53b0b4f): READY, no MUST, no SHOULD, one NIT.

  • NIT-1 (decision 4, "first in the list"): its mutant R1, which moves the device question to the end of _what_cannot_be_held, survived both test files, because the test named "first of the list" only asserted [CODE]. c0ccf755f adds test_the_device_question_comes_before_the_organisations in tests/test_adopt_device_names.py: a 40-character org:, not local, and legs.assembly NUL give the codes [adopt-bad-project-name, adopt-org-too-long], in that order. Only that test file changed; adopt-project.py is untouched.
  • R1 now: 1 failed (the new row, ['adopt-org-too-long', 'adopt-bad-project-name'] != [CODE, 'adopt-org-too-long']), 44 passed. adopt-project.py was restored and its sha256 matched (e6cf9639...).
  • Tests at c0ccf755f, each file alone, same stub setup: test_adopt_device_names.py 45 passed, test_adopt_plan_flag_refusals_first.py 89 passed, test_no_auto_maintenance.py 60 passed.

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

Summary by Sourcery

Refuse Windows device names as adoption project roots before any artifacts or repositories are created, and report the same validation consistently during plan, check, and execute.

New Features:

  • Reject Windows device names used as adoption project roots during planning, checking, and execution.

Bug Fixes:

  • Prevent invalid project names from producing plans, repository lookups, or repositories that cannot be created on Windows.
  • Ensure checks identify invalid assembly-root names before querying GitHub or validating dependent leg repositories.

Enhancements:

  • Use a shared project-name refusal across adoption workflows and align it with the existing scaffold and family initialization naming policy.

Tests:

  • Add coverage for device-name refusals, validation ordering, GitHub/local execution paths, and valid prefix-matching names.

… refused as `adopt-bad-project-name` before anything exists, and `check` and `execute` find it in the plan's `legs.assembly`, by the one question the scaffold and `family.py init` ask (#1014)

`plan` asks `shape_materialize.refuse_an_unholdable_name` of `--project`
with its flags, before the work directory, the source, `gh` and the plan
file: after the naming policy (asked only of names it holds), before a
passed `--id`, the order #972 set. `check` and `execute` ask it of
`legs.assembly` first in `_what_cannot_be_held`, under the same code, which
joins `RENAMES_THE_LEGS`, so `check` asks `gh` nothing about the legs of a
name that has to change. `Nullable`, `Console` and `Auxo` plan and check as
before.

RV1010's NITs on #1010: the `"nul\n"` row holds the scaffold's "the name
speaks before a passed id's alphabet" (it kills mutant C), and "whatever the
id" reads "whatever id is passed" in `scaffold-project.py` and the docstring
of `tests/test_scaffold_device_names.py`.

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

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR consistently rejects Windows device-name project roots as adopt-bad-project-name: plan validates --project before any side effects, while check and execute validate legs.assembly through their shared refusal list before GitHub queries or repository creation. It adds comprehensive regression tests for refusal ordering, local and GitHub execution paths, valid near-matches, and scaffold behavior.

Sequence diagram for project-name validation across adopt commands

sequenceDiagram
    actor User
    participant Adopt as adopt-project.py
    participant Policy as Naming policy
    participant Shape as shape_materialize
    participant GitHub as gh
    participant FS as Filesystem

    User->>Adopt: plan --project NUL
    Adopt->>Policy: _names_held(project, pins)
    Policy-->>Adopt: true
    Adopt->>Shape: refuse_an_unholdable_name(adopt-bad-project-name)
    Shape-->>Adopt: refusal
    Adopt-->>User: REFUSED adopt-bad-project-name
    Note over Adopt,FS: No source read, gh call, work directory, or plan file

    User->>Adopt: check plan
    Adopt->>Adopt: _what_cannot_be_held(legs.assembly)
    Adopt->>Shape: refuse_an_unholdable_name(adopt-bad-project-name)
    Shape-->>Adopt: refusal
    Adopt-->>User: FINDING adopt-bad-project-name
    Note over Adopt,GitHub: No gh repo view calls

    User->>Adopt: execute plan
    Adopt->>Adopt: _what_cannot_be_held(legs.assembly)
    Adopt->>Shape: refuse_an_unholdable_name(adopt-bad-project-name)
    Shape-->>Adopt: refusal
    Adopt-->>User: REFUSED adopt-bad-project-name
    Note over Adopt,FS: No repository is created
Loading

Flow diagram for shared assembly-root refusal handling

flowchart TD
    A[Project root enters command] --> B{Command}
    B -->|plan --project| C[_names_held]
    C --> D[refuse_an_unholdable_name]
    D --> E{Windows device name?}
    E -->|yes| F[adopt-bad-project-name]
    F --> G[No plan written]
    E -->|no| H[Continue plan validation]

    B -->|check or execute| I[Read legs.assembly]
    I --> J[_what_cannot_be_held]
    J --> D
    F --> K{Command result}
    K -->|check| L[Finding reported; skip GitHub leg queries]
    K -->|execute| M[Refuse before repository creation]
Loading

File-Level Changes

Change Details Files
Added a shared project-name refusal for Windows device names and wired it into adoption planning before any side effects.
  • Reuse shape_materialize.refuse_an_unholdable_name with the new adopt-bad-project-name code and common remediation text.
  • Validate held --project names in _checked_flag_inputs before source access, work-directory creation, Git, GitHub, or plan writing.
  • Preserve refusal ordering relative to naming-policy and passed-ID validation, including names rejected by the naming policy.
adopt-project.py
Extended plan validation findings so existing plans reject invalid assembly roots consistently during checking and execution.
  • Check legs.assembly as the first item in _what_cannot_be_held, including local-remote execution.
  • Add the new code to RENAMES_THE_LEGS so check avoids GitHub leg lookups when the root must be renamed.
  • Make execute refuse before repository creation while check reports the finding.
adopt-project.py
Added regression coverage for device-name handling and refusal precedence across adoption workflows.
  • Test NUL, Con, com1, and Lpt9 across local and cloned sources, with no side effects or GitHub calls.
  • Test check and execute behavior for GitHub and local remote layouts, plus valid control names.
  • Cover refusal ordering before invalid IDs and update scaffold-related mutation coverage.
tests/test_adopt_device_names.py
tests/test_derived_id_refusals.py
Clarified existing scaffold test and implementation documentation for passed-ID terminology.
  • Update docstrings from “whatever the id” to “whatever id is passed.”
scaffold-project.py
tests/test_scaffold_device_names.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1014 Have adopt-project.py plan refuse Windows-unholdable --project names using the shared refusal function, before creating anything, while preserving naming-policy refusal order. ✅
#1014 Have adopt-project.py check find a Windows-unholdable name in the plan’s project: field. ❌ The PR checks legs.assembly, not project:. Plans produced by plan ordinarily carry the same value in both fields, but a plan with an unholdable project: and a different legs.assembly will not be caught on that basis. The issue explicitly asks check to inspect project:.
#1014 Close the two review nits by adding the nul\n test row and correcting the two docstrings’ wording about passed IDs. ✅

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-20T05:49:06Z) RV1041 (independent opus review, ruling 8) of #1041 at ce53b0b: READY. No MUST, no SHOULD, one NIT.

Probes (stub gh exit 97, GH_TOKEN=bogus, empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false), main 5b41d44 vs head:

  • plan --project NUL, Con, com1, Lpt9 (with and without --id foo): main exit 0 and a plan; head exit 2 adopt-bad-project-name, no plan, no work dir, 0 gh calls.
  • check of main's plans for those four: main exit 0 plan ok (2 repo view calls on GitHub); head exit 1, one FINDING adopt-bad-project-name, 0 gh calls, on GitHub and under --local-remote-dir.
  • NUL.git and Nul.: plan and check are byte-identical to main (adopt-bad-id / naming-unclassified; check finds naming-unclassified only).
  • Nullable, Console, Auxo: plan and check byte-identical to main (exit 0, plan ok).
  • 60 outputs compared: only the 24 device rows differ.
  • execute of main's NUL plan at head: exit 2 adopt-bad-project-name, 0 gh calls, nothing made, both modes. At main under --local-remote-dir it made NUL/, NUL-spec.git and NUL-code.git (exit 0).

Decisions: all agreed. legs.assembly over project: is right: project: is read only by _not_the_source (adopt-project.py:15817). A hand edit with project: NUL and legs.assembly: Zqxj passes check, but nothing named NUL is ever made. The _names_held gate keeps naming first (M3 killed: 4 rows).

Tests at head, one file at a time: test_adopt_device_names 44, test_derived_id_refusals 63, test_adopt_plan_flag_refusals_first 89, test_scaffold_device_names 48, test_scaffold_leg_path_collisions 104, test_agents_md_codes 47, test_cli_reference 23, test_update_add_codes_documented 6, test_repo_hygiene 100, test_no_auto_maintenance 60, all passed. The docs guards need no doc: neither requires a new adopt-* code to be documented.

Mutants: M3 killed (4), M6 killed (12). Mine, R1 (the device question moved last in _what_cannot_be_held), survives: see NIT-1.

NIT-1 (tests/test_adopt_device_names.py:236): decision 4, "first in the list", is held by no test. With org "a"*40 and legs.assembly NUL the list is [adopt-bad-project-name, adopt-org-too-long] at head and the reverse under R1. Fix: one row with a 40-character org asserting that order.

Merges (git merge-tree --write-tree): origin/main 5b41d44 clean (tree 535279bb), #1038 3d52f86 clean, #1031 abd1b98 clean, #1030 18cc659 clean; test_adopt_device_names gives 44 passed on each predicted merge.

Lane: openreposhape-3 (openRepoShape-3)

…t in `_what_cannot_be_held`, and a row holds it, a 40-character `org:` with `legs.assembly` NUL listing `adopt-bad-project-name` before `adopt-org-too-long` (RV1041 NIT-1, #1014)

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-20T05:52:22Z) F1041 (fix round 1 for RV1041, READY with one NIT) of #1041: NIT-1 is closed at c0ccf75.

NIT-1 (decision 4, "first in the list" was held by no test): tests/test_adopt_device_names.py gains test_the_device_question_comes_before_the_organisations. It calls _what_cannot_be_held with org "a"*40, not local, and legs.assembly NUL, and asserts the codes are exactly [adopt-bad-project-name, adopt-org-too-long], in that order. Only that test file changed (10 lines added); adopt-project.py is untouched.

RV1041's mutant R1 (the device question moved to the end of _what_cannot_be_held) now fails: 1 failed (the new row, ['adopt-org-too-long', 'adopt-bad-project-name'] != [adopt-bad-project-name, adopt-org-too-long]), 44 passed. adopt-project.py was restored and its sha256 matched (e6cf9639...).

Tests at c0ccf75, each file alone under the stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and EMAIL set: test_adopt_device_names.py 45 passed, test_adopt_plan_flag_refusals_first.py 89 passed, test_no_auto_maintenance.py 60 passed.

The PR body gains a "Fix round 1 (RV1041)" section; the rest is kept. The PR is still a DRAFT. Lane 3's coordinator checked the 10-line diff against RV1041's suggested row, so no second review round is needed.

Lane: openreposhape-3 (openRepoShape-3)

@sonarqubecloud

Copy link
Copy Markdown

brettheap added a commit that referenced this pull request Oct 20, 2026
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043)

`setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git.

Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes.

**`setup-project.py`** (section 0, just above `self_bootstrap`):
- `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from.
- `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does.
- `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character.
- `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`.
- The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2.

**`tests/test_setup_project_repo_misdrawn.py`**, 22 tests:
- **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package.
- **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character).
  - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl.
  - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out.
  - The git log must hold exactly one call: the checkout probe.
- **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before.
- **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log.
- Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597).
- `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in.

**"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path.

These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1.

| Case | Before (`5b41d44f`) | After |
|---|---|---|
| package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line |
| direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only |
| direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only |
| direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` |
| direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` |
| direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 |

The "after" refusal, from the probe:

```
REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path.
```

The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**.
- Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test.
- Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad.

Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.**

| Mutant | Failed |
|---|---|
| m01 check removed (main's read) | 13 |
| m02a check moved after the `(0) self-bootstrap` line | 13 |
| m02b check moved after the clone line | 13 |
| m03a only the value's first character checked | 15 |
| m03b only the table's first entry checked | 13 |
| m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) |
| m05 refusal exits 1 | 13 |
| m06a table drops `E0001` | 4 |
| m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) |
| m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) |
| m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 |
| m06e parity uses set inclusion + table drops `E0001` | 4 |
| m07 a range read without its last code point | 15 |
| m08 control check asked before the code points | 2 (both tab-then-U+202E tests) |
| m09 characters named in the value's order | 3 |
| m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) |
| m11 refusal quotes the value back | 13 |
| m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) |

All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`.

| Run | git | Files | Result |
|---|---|---|---|
| h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s |
| h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s |
| h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s |
| h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s |
| h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) |

No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass.

- `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`.
- No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`.

- **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout.
  - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it.
  - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own.
  - `src/openreposhape/standard.py` is also open in #1038.
- **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares.

Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #953.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap marked this pull request as ready for review October 20, 2026 11:20
@sourcery-ai

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

@brettheap
brettheap merged commit 833af14 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the l3-1014-adopt-device-names branch October 20, 2026 11:20
brettheap added a commit that referenced this pull request Oct 20, 2026
…`--link-source` reading at the old first-`=` split's corners (a keyed path that begins with `=`, a keyed blank path, a key in capitals) (#1054) (#1058)

Three test rows hold what #980 (#1044) left unchanged in how `--link-source` reads a value (a keyed path that begins with `=`, a keyed blank path, a key typed in capitals), so each of rv980's four surviving mutants of `scripts/validate-repository-naming.py` fails a test. Fix round 1 (rv1058's NIT 1) adds a fifth mutant to what the rows kill.

#1044 said a chain whose links hold no `=` is read exactly as before. That is true (rv980 compared 800 runs on the base and the head), but it rested on that probe: `tests/test_chain_remedy_callers.py` held the #980 rows for the new reading and none for the corners of the old first-`=` split. Of rv980's seven mutants, R1, R5, R10 and R14 survived the five closest test files (404 passed under each).

Tests only. The one file changed is `tests/test_chain_remedy_callers.py` (72 lines added, none removed, over two commits), in the "#980" section after `test_a_key_is_read_whole`, reusing the file's `THREE`, `EQUALS`, `naming`, `tree`, `gate_warnings`, `UNREACHED` and `clear_ambient_pin_sources`. No assert joins conditions with `and` or `or`. No script, template or doc changes, so no pinned copy moves and a root's `update-shape.py check` gains no row.

Each pins bytes the gate already prints on main (the new parameter of row 3 passes on `8a057215`'s script, which is byte-identical to the one here).

1. `test_a_keyed_path_may_begin_with_an_equals`, two parameters: `openXdox==x` with `THREE` (`a-plain-link`), and `open=y==` with `EQUALS` (`a-link-holding-an-equals`, the issue's parenthetical). Exit 0; the WARNING `link-source-unreadable: =x (--link-source openXdox=...) could not answer for openXdox: no such file or directory` (and `= (--link-source open=y=...) ... for open=y`); no "read as a PATH" line. Kills R10.
2. `test_a_keyed_blank_path_is_one_blank_and_is_not_refused`: `'openXdox= '` with `THREE`. Exit 0; no `REFUSED`; the WARNING `link-source-unreadable:   (--link-source openXdox=...) could not answer for openXdox: no such file or directory` (the path is one blank); no "read as a PATH" line. Kills R1 and R14.
3. `test_a_key_typed_in_capitals_is_noted_as_the_chain_spells_it`, two parameters, with the name `brett-wip`, which reads no declaration. Exit 0, and the one `NOTE ` line is compared as a whole line: `NOTE --link-source <typed>: not used - no name classified here reads <link>'s own declaration.` (the em dash of the gate's text, as the file's other NOTE rows spell it).
   - `a-link-holding-an-equals`: `OPEN=Y=<tree>` with `EQUALS`, the NOTE ends `reads open=y's own declaration.`. Kills R5.
   - `a-capital-inside-a-link` (new in fix round 1, rv1058's NIT 1): `OPENXDOX=<tree>` with `THREE`, the NOTE ends `reads openXdox's own declaration.`. The first parameter's chain, `open=y`, is all lowercase, so it could not tell "the link as the chain spells it" from "the link lowercased"; `openXdox` has a capital X. Kills R5 as well, and M2 (`return link.lower(), ...`), which survived the first head.

Bytes against `5b41d44f`, the base of #1044 (checked with `git archive 5b41d44 contracts scripts` and the same values: stdout, stderr and exit code compared):
- Identical to main: `openXdox==x` (row 1), `'openXdox= '` (row 2) and `OPENXDOX=<tree>` (row 3, the `a-capital-inside-a-link` parameter).
- Different, because they are the new reading of a link holding `=`: `open=y==` with `EQUALS` (row 1's second parameter; at `5b41d44f` it is a bare PATH, `link-source-unreadable: open=y== (--link-source) ... for openXdox`, with "It was read as a PATH: `open` is not a link ...") and `OPEN=Y=<tree>` with `EQUALS` (row 3's first parameter; at `5b41d44f` its NOTE says `reads a chain link's own declaration`, with the same "read as a PATH" sentence).

Left alone, as the issue's "Optional" part says it may be: the "It was read as a PATH" line (`LinkSources.read_as_path`, which names only the text before the first `=`). It is a NIT for a follow-up and #1053's neighbour (`validate-pins.py` has the twin), and changing it touches the pinned `scripts/validate-repository-naming.py`.

Each applied alone to `scripts/validate-repository-naming.py` at the line the issue (or rv1058, for M2) names, in the worktree, restored with the saved copy and read back (sha256 `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` after each; `git diff` of the script empty). R10's text is also at `:479` in `_read_remedy`, which is not mutated. The whole file (68 tests) is run with each, on git 2.43, at the fix round's head.

| Mutant | The one-line edit | What it prints for the row's value | Fails | Count |
|---|---|---|---|---|
| R10 `:439` | `typed.rfind("=", 0, at)` to `typed.rfind("=", 0, at - 1)` | `openXdox==x` is a bare PATH: `link-source-unreadable: openXdox==x (--link-source) ...` and "It was read as a PATH: `openXdox` is not a link ..." | row 1, both parameters, on the WARNING assert | 2 failed, 66 passed |
| R1 `:437` | `typed[at + 1:]` to `typed[at + 1:].strip()` in `if link is not None and ...` | `'openXdox= '` is a bare PATH: `openXdox=  (--link-source)`, with the same false line | row 2, on the WARNING assert | 1 failed, 67 passed |
| R14 `:385` | `self._read_source(typed)[1]` to `self._read_source(typed)[1].strip()` in `no_path` | `'openXdox= '` is `REFUSED link-source-no-path`, exit 2 (main: 0) | row 2, on `returncode == 0` | 1 failed, 67 passed |
| R5 `:438` | `return link, typed[at + 1:]` to `return typed[:at], typed[at + 1:]` | the NOTE says `reads OPEN=Y's own declaration` (and `OPENXDOX's`) | row 3, both parameters, on the NOTE assert | 2 failed, 66 passed |
| M2 `:438` (rv1058) | `return link, typed[at + 1:]` to `return link.lower(), typed[at + 1:]` | the NOTE says `reads openxdox's own declaration` | row 3, `a-capital-inside-a-link` only, on the NOTE assert | 1 failed, 67 passed |

Each mutant fails only the row it is named for, and the file passes (68) with the script restored. At the first head (`b775c739`, 67 tests) R10 was 2 failed, R1 1, R14 1 and R5 1, and M2 survived with 67 passed.

`EMAIL=service@opensoft.dev`, a stub `gh` first on PATH (it refuses every call), `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`; through `$Q/v110/run9.sh` (`-q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp each). Never the full suite.

- git 2.43.0 (`/usr/bin/git`), `tests/test_chain_remedy_callers.py` alone: 68 passed (63 before this PR, plus 5: row 1 has two parameters, row 3 has two, row 2 is one).
- git 2.43.0, that file with `tests/test_link_source_chain_defaults.py` and the trio (`tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`): 261 passed.
- git 2.47.1 (exactly `$Q/v115/w764/git247/bin/git`, first on PATH), `tests/test_chain_remedy_callers.py`: 68 passed.
- The five new test ids on `8a057215`'s tree (a scratch copy of its `git archive`, with this file copied in): 5 passed. The whole file there: 66 passed, 2 skipped (the two older-library rows need history the scratch copy lacks).
- `git diff --check` clean; no line over 79 characters.

`git merge-tree --write-tree origin/main HEAD`, origin/main `833af144` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees; main moved from `8a057215` by #1041, which touches `adopt-project.py`, `scaffold-project.py` and device-name tests, none of this PR's files): clean, exit 0, tree `bd706fdacef2`. The branch is two commits ahead of the old base `8a057215`.

Review: rv1058, lane 5's independent reviewer, not the writer (w1054): READY at `b775c739` (issuecomment-6289571980) with no MUST or SHOULD; its two NITs were taken in one new commit, and the re-check is READY at `af390352` (issuecomment-6289945831), with no MUST or SHOULD and one optional docstring NIT. CI (Brett Heap's ruling 27): H0 is the head af39035 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 8a05721; and the local gate passed on its predicted squash tree 60843b9 over main 9e154d8: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 238 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1054.
Closes #1054.

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 20, 2026
…round a loop is refused in words that name the link and the loop, not "(File exists)", and a symlink to a file is named as one (#978) (#1065)

## What

`plan`, `check` and `execute` refused a `--work-dir` that is a symlink
round a loop as "making it a directory failed (File exists)". `os.mkdir`
of such a link fails with EEXIST, the error a file in the way gets, so the
person was told a file is there and found a link. The way out, "Pass a
--work-dir this user can write in", was the one for a folder this user
cannot write in, and said nothing of a link. #963's review (r903) left it
tracked as #978.

`_work_root` now asks a new `_unmade_work_dir` why the directory could not
be made. The code stays `adopt-work-dir-unwritable`, exit 2, and nothing
is made.

- The `--work-dir` is named as given, made absolute and not resolved. On
  base the reason named `os.path.realpath`'s reading of it, which for a
  link into a loop is another link, a path the person never typed.
- A loop is named only where `os.path.realpath` left a symlink in the path
  (it follows every other one) and `os.stat` of that link fails with ELOOP
  (`_the_loop_in`). So a link to a file, or a long chain of links, is
  never called a loop.
- The way out names the link: the `--work-dir` itself where it is a
  symlink, else the link in the loop.
- A file at the `--work-dir`, or above it, keeps the system's words and
  #524's way out, as the issue asks.
- A dangling link is no loop: `realpath` reads it as the path it leads
  to, which is made, as on base (the controls below).
- `_work_dir_refusal` takes the way out as `fix=`, #524's by default.
  Names are printed with `_spelled`. No `shell_quoted` call is added.

Measured with `plan` of a local source, `<A>` the directory that holds the
links, before on `1a47ed07` and after on this head (exit 2 in every row):

| `--work-dir` | Before | After |
| --- | --- | --- |
| `self`, a link to itself | `` --work-dir `<A>/self` cannot be made: making it a directory failed (File exists) `` | `` --work-dir `<A>/self` cannot be made: it is a symbolic link that leads round a loop `` |
| `a`, with `a -> b` and `b -> a` | `` --work-dir `<A>/a` cannot be made: making it a directory failed (File exists) `` | `` --work-dir `<A>/a` cannot be made: it is a symbolic link that leads round a loop `` |
| `into -> a`, into that pair | `` --work-dir `<A>/a` cannot be made: making it a directory failed (File exists) `` | `` --work-dir `<A>/into` cannot be made: it is a symbolic link that leads round a loop, at the symbolic link `<A>/a` `` |
| `through -> loop/sub`, with `loop -> loop` | `` --work-dir `<A>/loop/sub` cannot be made: making it a directory failed (Too many levels of symbolic links) `` | `` --work-dir `<A>/through` cannot be made: it is a symbolic link that leads round a loop, at the symbolic link `<A>/loop` `` |
| `loop/sub`, no link itself | `` --work-dir `<A>/loop/sub` cannot be made: making it a directory failed (Too many levels of symbolic links) `` | `` --work-dir `<A>/loop/sub` cannot be made: the way to it leads round a loop, at the symbolic link `<A>/loop` `` |
| `tofile -> a-file` | `` --work-dir `<A>/a-file` cannot be made: making it a directory failed (File exists) `` | `` --work-dir `<A>/tofile` cannot be made: it is a symbolic link that leads to `<A>/a-file`, which is not a directory `` |
| `a-file`, a file | `(File exists)` | unchanged |
| `a-file/under` | `(Not a directory)` | unchanged |

The way out:

- Before, in every row: "Remediation: Pass a --work-dir this user can
  write in, or none: without one, each run makes a new one."
- After, in the six symlink rows: "Remediation: Remove the symbolic link
  `<link>`, or make it lead to a directory, or pass another --work-dir, or
  none: without one, each run makes a new one." `<link>` is `<A>/self`,
  `<A>/a`, `<A>/into`, `<A>/through`, `<A>/loop` and `<A>/tofile`.
- `execute` still says "no leg repository was created and nothing was
  pushed." first. The two file rows keep the way out they had.

**Wider than the issue.** The issue asks for the EEXIST case, where the
`--work-dir` is itself the link. A path that runs through a loop fails
`os.mkdir` with ELOOP instead, and said only the system's words, with the
same way out for a folder this user cannot write in. It is named the same
way now: `--work-dir through` and `--work-dir loop/sub` reach the same
place. So the words `tests/test_adopt_clone_claimed.py` held for a loop
above the work directory change. That row's point, #650's code
`adopt-work-dir-unwritable` and not `adopt-work-dir-shared`, is unchanged.

### The comment wording in #963's code

r903's round-2 NITs a-c (#963 issuecomment-6253937080). Comments and
docstrings only.

- **(a)** `_ask_git_branch` passes an empty temporary directory as
  `--git-dir`, which is no repository.
  - `adopt-project.py`, `_checked_flag_inputs`: "That question is asked of
    a throwaway repository in the system temp folder, which it removes"
    is now "That question is asked with an empty temporary directory as
    its `--git-dir`, which `plan` removes".
  - `adopt-project.py`, `cmd_plan`: "runs `git check-ref-format` in a
    throwaway repository first" is now "runs `git check-ref-format` first,
    with an empty temporary directory as its `--git-dir`, which `plan`
    removes".
  - `tests/test_adopt_plan_flag_refusals_first.py`, the module docstring:
    "in a throwaway repository" is now "with an empty temporary directory
    as its `--git-dir`, which `plan` removes".
  - The same file's
    `test_the_branch_a_tracking_branch_names_is_asked_before_the_source`:
    "is asked of a throwaway repository in the temp folder ... and it
    removed what it made" is now "is asked of git with an empty temporary
    directory as its `--git-dir` ... and the directory was removed".
  - "which it removes" in r903's words is "which `plan` removes" here, so
    that it does not read as git removing it.
- **(b)** The refusals still made after the source are listed "among
  them", in `_checked_flag_inputs`' docstring and the test's module
  docstring: a `--project` of 251 to 255 characters is refused
  `unsafe-value` after git's reads.
- **(c)** The naming refusals come "with a remedy about the source where
  it names a repository".

## Tests

- `tests/test_adopt_work_dir_refusal.py`, 53 tests:
  - `test_a_work_dir_that_is_a_symlink_loop_is_refused` held "(File
    exists)" through `mkdir_says`. It now holds the new words, for a link
    to itself and for a pair, through `plan`, `check` and `execute`: 6.
  - New: a link into a pair and a link through a loop (6), a path under a
    loop (3), and a link to a file (3), each through the three commands;
    each asserts exit 2, the whole of stderr, no plan written, no leg, and
    nothing made or changed under the test's directory.
  - New: `_the_loop_in` asked directly, which calls a link to a file or to
    a directory no loop (1).
  - New controls: a dangling link to `target-a` and to
    `missing-dir/target-b`, through `plan` and `check`, exit 0 and the
    target made (4).
- `tests/test_adopt_clone_claimed.py`, 42 tests:
  `test_a_loop_or_a_file_above_the_work_dir_keeps_its_650_refusal`'s three
  `a-symlink-loop` rows hold the new words; its file rows keep the
  system's.
- `tests/test_adopt_plan_flag_refusals_first.py`: docstrings only.
- **Fail on base.** With `1a47ed07`'s `adopt-project.py` in this head's
  tree, the two changed files: 22 failed, 73 passed. The 22 are exactly
  the #978 rows: the 19 in `test_adopt_work_dir_refusal.py` and the 3
  `a-symlink-loop` rows. The 4 dangling-link controls pass on both. On
  this head: 95 passed.
- **The gate**, on this head, one basetemp, `EMAIL` exported, `LANES_LANE`
  unset, a stub `gh` first on PATH, `GH_TOKEN=bogus-w978`, an empty
  `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and
  `GIT_SSH_COMMAND=false`, in 17 chunks and 4 shards, each under 9
  minutes:
  - 46 files: the 34 that name `adopt-work-dir-unwritable`, `_work_root`
    or `work_dir`; `test_adopt_plan_flag_refusals_first`,
    `test_repo_hygiene`, `test_agents_md_codes`, `test_cli_reference`,
    `test_readme_windows_quoting_sentence` and
    `test_scaffold_leg_path_collisions`; and the 7 that read
    `adopt-project.py`'s source (`test_git_output_decoding`,
    `test_no_auto_maintenance`, `test_non_utf8_git_failures`,
    `test_shape_advisory`, `test_pinned_copy_text`,
    `test_openreposhape_package`, `test_template_values_single_pass`):
    3444 passed, 2 skipped, in 17 chunks.
  - `test_shape_doctor.py`, all 448 tests in 4 shards: 445 passed and 3
    skipped.

**Mutants**, each applied alone to `adopt-project.py`, then
`tests/test_adopt_work_dir_refusal.py` run, then restored by sha256. All
12 killed. They ran on a file that differs from this head's only in two
comment lines ("which `plan` removes").

| Mutant | Failed | Killed by |
| --- | --- | --- |
| M1 ELOOP dropped from the errno check | 6 | through-a-loop, under-a-loop |
| M2 the same-link branch always taken | 6 | into-a-pair, through-a-loop |
| M3 the "is a link" test dropped from the link-to-a-file branch | 3 | the file AT the `--work-dir` (keeps `File exists`) |
| M4 the way out under a loop names no link | 3 | under-a-loop |
| M5 the `--work-dir` named as `realpath` reads it | 9 | into-a-pair, through-a-loop, link-to-a-file |
| M6 only the last part of the path asked for a link | 6 | through-a-loop, under-a-loop |
| M7 no loop ever found | 15 | every loop row |
| M8 the link named where its target goes | 3 | link-to-a-file |
| M9 the old way out kept | 18 | every symlink row |
| M10 `_same_entry` always false | 6 | to-itself, a-pair |
| M11 `realpath`'s reading passed for the path as typed | 9 | into-a-pair, through-a-loop, link-to-a-file |
| M12 any link left in the path called a loop, with no ELOOP | 1 | `test_only_a_symlink_that_loops_is_called_a_loop` |

M12 survives every command-level row: on Linux `realpath` leaves no link
in the path but one that loops. The direct test of `_the_loop_in` holds
the guard.

## Merge with main

`git merge-tree --write-tree` of this head with `main` at `eee089ca` is
clean (tree `02c1717d`). Of this branch's files, only `adopt-project.py`
moved on main. #1046 changed other functions. #1041 added the
`adopt-bad-project-name` refusal inside `_checked_flag_inputs`, below the
docstring and the comment this branch rewords. A `--project` the naming
policy refuses as well (`NUL.git`) is still refused only after the
source, and the docstring's list of what comes after the source, read
"among them", stays true. The branch was not merged, and the gate ran on
this head.

## Not in this PR

- A link to a path under a file (`w -> a-file/sub`) still names the path
  `realpath` reads, `<A>/a-file/sub`, with "(Not a directory)". True, but
  it does not name the link.
- Not measured on Windows, where a loop is named only if `os.stat` of the
  link reports ELOOP.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `a4e179dd` is green on Linux and SonarCloud. The branch merged main once and was never rebased; the last merge is update-branch's, clean, giving 228531a, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 47 test files that run `--work-dir` refusals and #650's claimed clones, read `adopt-project.py`'s source, and the doctor's, 3,898 passed and 3 skipped.

Closes #978

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

Development

Successfully merging this pull request may close these issues.

adopt-project.py: plan and check take a Windows device name (--project NUL, Con) that the scaffold and family.py init now refuse

1 participant