Skip to content

adopt-project.py: under a one-byte locale such as Latin-1, check reads a plan written in UTF-8 whose source is there, asking the plan's UTF-8 bytes where the spelling as written names nothing, rather than refusing plan-source-gone (#1078) - #1106

Draft
brettheap wants to merge 2 commits into
mainfrom
l3-1078-one-byte-locale

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A follow-up outside v1.2.0 (Brett Heap, 2026-10-21T10:32:04Z). It lands only after the v1.2.0 tag, on a later merge word.

#1078 (found in the review of #1072). adopt-project.py check reads source.local_path from a plan written in UTF-8. #1072 asks the path as the plan's UTF-8 bytes where the locale cannot spell it (C and POSIX with PYTHONUTF8=0). Under a locale of one byte to a character, Latin-1 say, os.fsencode spells the text, so the path was asked only as written: Thing followed by U+00E9 as the one byte 0xE9, where the name plan read is 0xC3 0xA9. check refused plan-source-gone, "names nothing that exists", of a source that is there. Recorded as a clone plan made, it "no longer exists", and gh repo clone was asked.

Following the Direction, a new _recorded_path asks the spelling as written first. Where that names nothing, it asks the plan's UTF-8 bytes too, before anything is refused. _recorded_source and _keep_the_clone_at_the_record both use it, so the two read a record the same way. _utf8_spelling is factored out of _recorded_spelling, which behaves as before.

Decided where the Direction leaves it open:

  • Spelling as written first. A plan written under that one-byte locale records the name as that locale read it, and it reads as it did. Where both spellings name something, the one as written wins.
  • Where neither names anything, the path is the spelling as written, and every refusal says exactly what it said before. Under C and POSIX the lookup is adopt-project.py and family.py: a source this user cannot enter is source-unreadable, a UTF-8 plan reads its source under the C locale, and a member on a branch that is not UTF-8 is put back on it (#918) #1072's, unchanged.
  • The keep-the-clone lookup asks the disk now (_is_there), as _recorded_source does. A stat the operating system refuses leaves the clone on the list to remove, as any OSError there did before.
  • The test builds its own locale. This host has no locales package: no /usr/share/i18n, so no en_US source and no charmaps. localedef -i en_US -f ISO-8859-1 fails because the character map file ISO-8859-1 is not found. The test writes the ISO-8859-1 charmap (each byte is the code point of the same number) and a source with only LC_CTYPE, then builds it with localedef -c in its temporary directory. It needs only localedef and a C library that reads LOCPATH. It skips, quoting localedef's errors, only where a child started in that locale does not report iso8859-1 as both its file name encoding and its preferred encoding.

Out of scope

Measured: the locale, then main and this head

The locale, built with the scratch charmap and source:

$ LOCPATH=<scratch>/locales LC_ALL=en_US.ISO-8859-1 PYTHONUTF8=0 python3 -c 'import sys, locale; print(sys.getfilesystemencoding(), locale.getpreferredencoding(False), sys.flags.utf8_mode)'
iso8859-1 ISO-8859-1 0

The plan was written in UTF-8 mode (LC_ALL=C PYTHONUTF8=1) for a real repository at <scratch>/repro-*/Thing followed by U+00E9. It records local_path with that character as the UTF-8 bytes C3 A9. Then check --plan, with a stub gh that exits 97:

record locale main 2a119f28 this head
given Latin-1 exit 2: REFUSED plan-source-gone: ... names nothing that exists, and the plan does not record it as a clone ... exit 0, plan ok
given C, PYTHONUTF8=0 exit 0, plan ok exit 0, plan ok
given POSIX, PYTHONUTF8=0 exit 0, plan ok exit 0, plan ok
given UTF-8 mode exit 0, plan ok exit 0, plan ok
cloned Latin-1 exit 2: NOTE ... no longer exists, so its source.repository, testorg/Thing, is cloned again from GitHub, then REFUSED source-unclonable (gh asked) exit 0, plan ok
cloned C / POSIX / UTF-8 mode exit 0, plan ok exit 0, plan ok

Tests

Each test file ran on its own, one at a time, under a stub gh that exits 97, with GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null 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> -rfEs, on Python 3.12.3:

  • tests/test_adopt_one_byte_locale.py (new, -rs): 10 passed in 12.65s, none skipped
  • tests/test_adopt_unenterable_source_and_locale.py: 13 passed in 13.96s
  • tests/test_adopt_unsearchable_source.py: 27 passed in 12.45s
  • tests/test_adopt_template_values.py: 78 passed in 89.48s
  • tests/test_adopt_source_clone_record.py: 100 passed in 47.47s
  • tests/test_adopt_temp_work_roots.py: 9 passed in 12.49s
  • tests/test_adopt_clone_claimed.py: 42 passed in 22.69s
  • tests/test_adopt_work_dir_refusal.py: 53 passed in 42.06s
  • tests/test_adopt_plan_escapes.py: 59 passed in 14.54s
  • tests/test_adopt_plan_yaml_quoting.py: 540 passed in 275.81s
  • tests/test_adopt_c_locale_bytes.py: 17 passed in 43.96s
  • tests/test_scaffold_leg_path_collisions.py: 104 passed in 6.14s
  • tests/test_agents_md_codes.py: 47 passed in 2.34s
  • tests/test_cli_reference.py: 23 passed in 34.90s
  • tests/test_update_add_codes_documented.py: 6 passed in 0.49s
  • tests/test_repo_hygiene.py: 100 passed in 2.90s
  • tests/test_no_auto_maintenance.py: 58 passed, 2 skipped in 13.30s. Both skips say "the session began with GIT_CONFIG_GLOBAL set": the runner sets it to /dev/null.
  • tests/test_shape_doctor.py, once: 445 passed, 3 skipped in 793.00s (the three skips: "the PowerShell line is printed, and powershell.exe exists, only on a Windows host"). A first run was cut off at about 16% when the lane's session stopped; this is the rerun.
  • pyflakes 4.0.0: 0 warnings in adopt-project.py at main and at this head; 0 in the new test file.

The new test file:

  • runs check end to end under Latin-1, C and POSIX, for a given record and a cloned one;
  • checks that the refusal's words are unchanged where neither spelling names anything;
  • tabulates _recorded_path under Latin-1 and C, with a directory at the UTF-8 bytes, at both spellings, or at neither;
  • checks _keep_the_clone_at_the_record under Latin-1.

Mutants

Each mutant was applied to adopt-project.py, and the new test file was run. The file was then restored from a saved copy, and cmp found it identical every time.

  • M1, the UTF-8 retry removed (the brief's mutant). KILLED: 4 failed, 6 passed. The failures were test_a_plan_written_in_utf8_is_read_under_a_one_byte_locale[given] and [cloned], test_the_record_is_asked_as_written_then_as_utf8[utf8-latin1], and test_the_clone_at_the_record_is_kept_under_a_one_byte_locale.
  • M2, the UTF-8 bytes asked before the spelling as written. KILLED: 1 failed, 9 passed. The failure was test_the_record_is_asked_as_written_then_as_utf8[both-latin1].
  • M3, _recorded_source back to main's lookup. KILLED: 2 failed, 8 passed. The failures were test_a_plan_written_in_utf8_is_read_under_a_one_byte_locale[given] and [cloned].
  • M4, _keep_the_clone_at_the_record back to main's spelling. KILLED: 1 failed, 9 passed. The failure was test_the_clone_at_the_record_is_kept_under_a_one_byte_locale.

Merges

git merge-tree --write-tree of this head (aea54cc5, based on 2a119f28) was run after a fresh fetch, with origin/main at 16f9a0e8 (#1087):

Fix round 1 (RV1106)

New head: 504a1dd6, one commit on top of aea54cc5.

The MUST: SonarCloud's duplication gate. The gate failed at "4.5% Duplication on New Code (required <= 3%)". The only duplicated block was the Latin-1 keep-the-clone test's body, a copy of the C test's in tests/test_adopt_unenterable_source_and_locale.py. Both tests built the record and a clone elsewhere, ran KEEP_THE_CLONE in a child and parsed its answer.

  • That body is now one helper, kept_clones(tmp_path, env), which returns (spells, left, elsewhere). It sits next to KEEP_THE_CLONE in tests/test_adopt_unenterable_source_and_locale.py, so this PR now touches that file too.
  • Each test keeps its own premise (spells is False under C and True under Latin-1) and asserts left == [elsewhere].
  • The comment on KEEP_THE_CLONE now says which tests run it.
  • The commit changes no line of adopt-project.py.

Tests at 504a1dd6, one file at a time, under the same runner as above:

  • tests/test_adopt_one_byte_locale.py (-rs): 10 passed in 8.51s, none skipped
  • tests/test_adopt_unenterable_source_and_locale.py: 13 passed in 9.53s
  • tests/test_adopt_c_locale_bytes.py: 17 passed in 40.19s
  • pyflakes: 0 warnings on tests/test_adopt_one_byte_locale.py, before and after. tests/test_adopt_unenterable_source_and_locale.py has 7 before and after, all about its adopter fixture import, so nothing is new.

M4 made again (_keep_the_clone_at_the_record back to main's spelling): KILLED, 1 failed, 9 passed. The failure was test_the_clone_at_the_record_is_kept_under_a_one_byte_locale. The file was restored, and cmp found it identical.

Merges of 504a1dd6 after a fresh fetch, with origin/main at 6da757cb. The open PRs on this PR's three files, checked live, are #1105 and #1091:

Closes #1078

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

…ads a plan written in UTF-8 whose source is there, asking the plan's UTF-8 bytes where the spelling as written names nothing, rather than refusing `plan-source-gone` (#1078)

Under a locale of one byte to a character, Latin-1 say, with
`PYTHONUTF8=0`, `os.fsencode` spells every character of a plan's
non-ASCII `source.local_path`, so #1072's `_recorded_spelling` asked the
path only as written: the e-acute of `Thing` + U+00E9 as the one byte
0xE9, where the name `plan` read in UTF-8 is 0xC3 0xA9. `check` refused
`plan-source-gone`, "names nothing that exists", of a source that is
there; recorded as a clone `plan` made, it "no longer exists" and `gh
repo clone` was asked.

`_recorded_path` now asks the spelling as written first and, where that
names nothing, the plan's UTF-8 bytes too. `_recorded_source` and
`_keep_the_clone_at_the_record` both ask it, so a clone `check --source`
made at the record is kept under Latin-1 as under C. Under C and POSIX
the lookup is #1072's, and every refusal's words are unchanged: where
neither spelling names anything, the path is the spelling as written.

The new tests build a Latin-1 locale with `localedef` from a charmap and
a locale source they write, and skip with localedef's words where none
can be built.

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

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes adopt-project.py check so UTF-8 plans are correctly read under one-byte locales: it checks the path as locally spelled first, retries using the plan’s UTF-8 bytes when absent, and applies the same resolution to clone retention. New tests construct a Latin-1 locale and cover success, precedence, refusal compatibility, and clone handling.

Sequence diagram for UTF-8 plan path resolution during check

sequenceDiagram
    participant Check as adopt-project.py check
    participant Source as _recorded_source
    participant Resolve as _recorded_path
    participant Disk as Filesystem

    Check->>Source: _recorded_source(local_path, work_root)
    Source->>Resolve: _recorded_path(local_path)
    Resolve->>Disk: _is_there(spelling as written)
    alt path exists
        Disk-->>Resolve: true
    else path absent
        Resolve->>Disk: _is_there(UTF-8 spelling)
        Disk-->>Resolve: true or false
    end
    Resolve-->>Source: path, there, said
    Source-->>Check: accept source or preserve existing refusal
Loading

Sequence diagram for retaining a newly cloned source

sequenceDiagram
    participant Check as adopt-project.py check
    participant Keep as _keep_the_clone_at_the_record
    participant Resolve as _recorded_path
    participant Disk as Filesystem

    Check->>Keep: _keep_the_clone_at_the_record(made)
    Keep->>Resolve: _recorded_path(local_path)
    Resolve->>Disk: _is_there(spelling as written)
    alt absent or alternate spelling exists
        Resolve->>Disk: _is_there(UTF-8 spelling)
    end
    Resolve-->>Keep: resolved record path
    Keep->>Keep: os.path.realpath
    Keep-->>Check: retain clone at matching record or leave it for removal
Loading

Flow diagram for locale-aware recorded path lookup

flowchart TD
    A[Recorded local_path] --> B[_recorded_spelling]
    B --> C[Ask spelling as written]
    C --> D{_is_there?}
    D -->|yes| E[Use local spelling]
    D -->|no| F[_utf8_spelling]
    F --> G{UTF-8 spelling differs and exists?}
    G -->|yes| H[Use UTF-8 spelling]
    G -->|no| I[Keep local spelling and report existing refusal]
Loading

File-Level Changes

Change Details Files
Adds locale-aware fallback resolution for recorded source paths written in UTF-8.
  • Introduces _utf8_spelling to derive the UTF-8 byte spelling safely.
  • Introduces _recorded_path, trying the locale spelling first and UTF-8 spelling only when the first path is absent.
  • Updates source validation and clone-retention logic to share the new lookup behavior while preserving existing refusal text and C/POSIX behavior.
adopt-project.py
Adds end-to-end and unit-level coverage for one-byte locale path handling.
  • Builds a temporary ISO-8859-1 locale when supported and tests given and cloned records under Latin-1, C, and POSIX.
  • Verifies spelling precedence, fallback behavior, unchanged refusals when neither path exists, and clone retention.
  • Includes locale capability skips and isolated subprocess execution for filesystem-encoding fidelity.
tests/test_adopt_one_byte_locale.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1078 When check reads a plan’s recorded source path under a one-byte locale, try the spelling as written first, then try the path’s UTF-8 bytes if the first spelling names nothing, so a UTF-8-named source that exists is not refused as plan-source-gone. ✅
#1078 Use the same recorded-path lookup behavior when deciding whether to keep a newly made clone at the path recorded in the plan, and preserve existing behavior when neither spelling names anything and under C/POSIX locales. ✅

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-21T13:09:46Z) Lane 3's independent review of #1106 (RV1106), head aea54cc: NOT READY, for one MUST. The code change itself is sound.

MUST

  • SonarCloud fails "4.5% Duplication on New Code (required <= 3%)" on one block: tests/test_adopt_one_byte_locale.py:239-252 duplicates tests/test_adopt_unenterable_source_and_locale.py:358-371 (build the record and the elsewhere clone, run KEEP_THE_CLONE in a child, parse the answer). Fix: move that body into a helper kept_clones(tmp_path, env) returning (spells, left, elsewhere), in test_adopt_unenterable_source_and_locale.py just before test_the_clone_at_the_record_is_kept_under_the_c_locale. Each test keeps its call and asserts spells (False under C, True under Latin-1) and left == [elsewhere]. The new file imports kept_clones in place of KEEP_THE_CLONE. Tried in a scratch clone: 28 lines in, 33 out; the new file 10 passed with -rs and none skipped; the unenterable file 13 passed; pyflakes found nothing new; M4 still killed. It removes the only duplicated block, but only the next analysis can confirm the gate.

SHOULD

NIT (no change needed)

  • adopt-project.py:1872 compares text, not bytes: a record holding a surrogate escape is asked twice under Latin-1 where it names nothing. Harmless.
  • The retry can read a directory other than the one a Latin-1 plan meant where that one is gone, with no word. plan-stale (adopt-project.py:12356 and 12537) bounds it to a directory at the plan's own commit.

Checked

Lane 3's plan: the SHOULD is filed as #1112, outside v1.2.0. W1078, the writer, makes the MUST fix as RV1106 tried it (the kept_clones helper), names #1112 in the body's "Out of scope" with no closing keyword, and pushes one commit. RV1106 then checks the new head, and SonarCloud must pass on it. The CI log check for the new file's 10 tests is still owed. #1106 stays a draft with no milestone and lands after the v1.2.0 tag, on a later word.

Lane: openreposhape-3 (openRepoShape-3)

…source_and_locale.py: the clone at the record is asked under the C locale and Latin-1 through one helper, `kept_clones` (#1078)

SonarCloud counted the Latin-1 test's body as a 14-line copy of the C
test's: both built the record and a clone elsewhere, ran
`KEEP_THE_CLONE` in a child and parsed its answer. `kept_clones` does
that once, in the file that holds `KEEP_THE_CLONE`, and returns whether
the locale spells the record, the clones left and the one made
elsewhere. Each test keeps its own premise and its assert.

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

Copy link
Copy Markdown

This was referenced Oct 21, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T14:57:32Z) Lane 3's independent review of #1106, round 2, head 504a1dd. Verdict: READY.

Lane 3's plan: #1106 is READY at 504a1dd. It stays a draft with no milestone and lands after the v1.2.0 tag, on a later word, by the usual pilot, with an explicit squash message. #1112 (at_the_record) starts once #1106 is on main.

Lane: openreposhape-3 (openRepoShape-3)

This was referenced Oct 21, 2026
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
brettheap added a commit that referenced this pull request Oct 23, 2026
…py` where GNU make at the root reads a kept `GNUmakefile` or `makefile`, and its NOTE names that file and what it takes for `make bootstrap` to run there (#1047) (#1121)

GNU make reads the first it finds of `GNUmakefile`, `makefile` and `Makefile`, and only that one. Where the source keeps `GNUmakefile` or `makefile` in the root, `make bootstrap` at the root of the clone reads the source's file, not the shape's `Makefile`, and exits 2 with "No rule to make target 'bootstrap'". `execute`'s NEXT printed it there anyway.

This PR also covers the third box of #1048 (see #1048): the `_next_steps` NOTE that said the shape's targets "arrive only once the follow-up merges the two", which is untrue where GNU make reads a kept `GNUmakefile` or `makefile`. #1105 covers that issue's other two boxes, and leaves this one to this PR.

## Measured

GNU Make 4.3 on Linux, a disk that keeps case. One directory per set of files, each file with a `which` target:

| files | `make which` reads | `make -n bootstrap` |
|---|---|---|
| `GNUmakefile` | `GNUmakefile` | exit 2, no rule |
| `makefile` | `makefile` | exit 2 |
| `Makefile` (with a `bootstrap` rule) | `Makefile` | exit 0 |
| `GNUmakefile` + `makefile` | `GNUmakefile` | exit 2 |
| `GNUmakefile` + `Makefile` | `GNUmakefile` | exit 2 |
| `makefile` + `Makefile` | `makefile` | exit 2 |
| all three | `GNUmakefile` | exit 2 |

A `GNUmakefile` that holds `include Makefile` runs the shape's rule (exit 0), and so does `make -f Makefile bootstrap`.

What NEXT printed on main, from a real `plan`, `check` and `execute --local-remote-dir`, with the printed command run in a recursive clone of the split ("S" is the source's own `scripts/bootstrap.py`, kept with `scripts/`):

| the root keeps | NEXT on main | in the clone |
|---|---|---|
| none | `make bootstrap` | exit 0 |
| `Makefile` | `python3 scripts/bootstrap.py`, #480's NOTE | exit 0 |
| `makefile`, `GNUmakefile`, or both | `make bootstrap` | **exit 2, no rule** |
| `Makefile` + `makefile`, `Makefile` + `GNUmakefile`, all three | `python3 scripts/bootstrap.py`, and a NOTE saying the targets arrive once the two `Makefile`s are merged | the command exits 0; `make bootstrap` exits 2, and still does after `cat shape/Makefile >> Makefile` |
| S + `GNUmakefile`, S + `makefile`, S + `Makefile` + `GNUmakefile` | no command; the NOTE says to merge, "and then run `make bootstrap`" | `make bootstrap` exits 2 |

## What changed

`adopt-project.py`, `_next_steps`:
- It takes `surviving=`, the paths the plan's entries send to the root (`paths_for["root"]`, passed by `cmd_execute`).
- It asks them for a kept `GNUmakefile` or `makefile` in GNU make's order, with `_root_keeps`, as `_makefile_follow_up` asks them. So NEXT names the same file as the plan's `CONTRACTS_DIR` follow-up.
- Where it finds one, the command is `python3 scripts/bootstrap.py`. The NOTE names the file in the follow-up's words, and says `make bootstrap` and the shape's other make targets run there only once that file has them, or includes a Makefile that does:
  - the shape's `Makefile` is the root's: "NOTE the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, the first of `GNUmakefile`, `makefile` and `Makefile` it finds, so `make bootstrap` and the shape's other make targets run there only once `GNUmakefile` has them, or includes a Makefile that does; `python3 scripts/bootstrap.py` is what the shape's `make bootstrap` runs."
  - the source also keeps its own `Makefile`: "NOTE the root keeps its own Makefile and the shape's is shape/Makefile, and GNU make at the root reads ..." (the same clause, and the same ending).
  - the source's own script went aside too: the merges first, as before, "and then run `python3 scripts/bootstrap.py`; GNU make at the root reads ...".
- A root that keeps neither name gets the block it got before, byte for byte. That includes `MAKEFILE`, which GNU make never reads where case is kept.

## Tests

`tests/test_adopt_next_make_reads_first.py`, new, 40 tests (38, and the 2 of the fix round below):
- 13 rows word for word: none, `Makefile`, `makefile`, `GNUmakefile`, `makefile`+`GNUmakefile`, `Makefile`+`makefile`, `Makefile`+`GNUmakefile`, all three, S, S+`Makefile`, S+`GNUmakefile`, S+`makefile`, S+`Makefile`+`GNUmakefile`.
- 3 rows that keep `make bootstrap`: `MAKEFILE`; longer names (`makefile.am`, `Makefile.in`, `GNUmakefile.bak`); and the names below the root.
- 13 rows in which NEXT names the same file as `_makefile_follow_up`, for the same root paths.
- 9 end to end rows, one for each row that keeps `GNUmakefile` or `makefile`. Each is a real `execute`, and its clone is made by pasting NEXT's own clone line. In that clone:
  - NEXT's lines are the row's;
  - `make which` prints the file the NOTE names;
  - `make -n bootstrap` has no rule;
  - the printed command exits 0 and ends `bootstrap ok`. For S rows, it does so once the shape's script is merged, as the NOTE says;
  - where a `Makefile` is kept, the follow-up's merge alone still leaves no rule;
  - an `include Makefile` in the file GNU make reads makes `make -n bootstrap` run the shape's recipe.

On main's `adopt-project.py`, the first 38 all fail. The 9 end to end rows fail on what NEXT prints. The unit rows that guard the unchanged cases fail there only for want of the `surviving=` keyword. M1 below shows the same rows passing and every row that keeps `GNUmakefile` or `makefile` failing. On this head, all 40 pass.

Mutants of the change, each restored and checked by sha256. Each run is the new file plus the five `next` tests of `test_adopt_e2e.py` (43 tests). Every mutant is killed:

| mutant | failed |
|---|---|
| M1 `first` never found | 27 |
| M2 `makefile` asked before `GNUmakefile` | 6 |
| M3 the command ignores `first` | 6 |
| M4 the branch for a `Makefile` kept beside `first` dropped | 6 |
| M5 the branch for a script gone aside ignores `first` | 9 |
| M6 the lookup folds case | 7, two of them existing `test_adopt_e2e.py` rows |
| M7 `cmd_execute` passes no paths | 9, the end to end rows |

Gate on this head, named files only, one basetemp per run. Each run had `EMAIL` set, `GIT_CONFIG_GLOBAL` an empty file, `GIT_CONFIG_NOSYSTEM=1`, a stub `gh` first on PATH, `GH_TOKEN=bogus` and an empty `GH_CONFIG_DIR`:
- the new file, `test_adopt_make_reads_first`, `test_adopt_e2e`: 98 passed, with the new file at 38 tests (at `c04bb710`; the fix round's gate is below).
- `test_adopt_github_exits`, `test_adopt_printed_sentences`, `test_repo_hygiene`, `test_readme_windows_quoting_sentence`, `test_cli_reference`, `test_agents_md_codes`: 291 passed.
- `test_scaffold_leg_path_collisions`: 104 passed.
- `test_adopt_contracts_dir`, `test_adopt_verify`, `test_adopt_pins`, `test_adopt_root_manifest`, `test_adopt_ignored_shape_files`, `test_adopt_nested_attributes`, `test_adopt_shape_pin_and_work_dir`: 378 passed.
- `test_shape_doctor.py` in 4 shards: 112 + 112 + 112 + 109 = 445 passed, and 3 skipped (the PowerShell line, which is printed only on a Windows host).

## Fix round 1 (RV1121's SHOULD-1)

RV1121 found the code right and one test missing: nothing held `cmd_execute` to handing `_next_steps(surviving=)` only the root's paths. Its mutant X4, which passes `paths_for["root"] + paths_for["code"] + paths_for["drop"]`, kept all 43 tests green. The case is a real one: the `root-project-makefile` rule sends a kept `GNUmakefile` to the root, and its reason says that moving a Makefile that only builds the code to the code leg "is a one-line edit of the plan" (`contracts/path-classification.yaml`, lines 236-237).

Two end to end rows, `test_next_keeps_make_bootstrap_where_the_plan_sends_the_makefile_elsewhere[code]` and `[drop]`. Each keeps `GNUmakefile` in the source, edits the plan to send it to `leg: code` (then `leg: drop`), runs a real `execute`, and pastes NEXT's own clone line. Each asserts that NEXT's lines after the clone are `    make bootstrap` and nothing else, that the clone's root has no `GNUmakefile`, and that `make -n bootstrap` there exits 0 and runs `python3 scripts/bootstrap.py`.

The `execute`, paste and clone steps of the nine existing end to end rows moved into one helper, `executed_and_cloned`, which the two new rows share, so no block of the test is written twice. Those rows assert what they asserted. The file now holds 40 tests (38 and 2).

- **X4, on this head's `adopt-project.py` with the one line changed:** the new file and the five `next` rows of `test_adopt_e2e.py` (45 selected): 2 failed, 43 passed. Both new rows fail on NEXT's lines, which are `python3 scripts/bootstrap.py` and the NOTE "the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, ...", naming a file the root does not have. The file was restored and its sha256 read `60bad59a...` again.
- **On main's `adopt-project.py`:** the two rows pass, since main printed `make bootstrap` there. They hold the unchanged case against the new lookup, and X4 is the mutant that tells them apart.
- **On this head `1c5173758`**, same environment as above: the new file 40 passed, `test_adopt_e2e.py` 27 passed, `test_repo_hygiene.py` 100 passed.
- **Merge:** `git merge-tree --write-tree` of this head with main at `823624d93`: clean (tree `73ddafb2`).

RV1121's four NITs are not in this PR; the lane files them.

## Merge

- `git merge-tree --write-tree` of this head with main at `6da757cbe`, where the branch starts: clean (tree `21955cc7`). Main has since moved to `7cd66902a`, which changes `scaffold-project.py`, its test and the docs, and none of this branch's files. The merge with it is clean too (tree `ec4e6c8e`).
- With #1105's head `5a4de7f76`: clean (tree `3394d19d`). #1105's `adopt-project.py` change is in `_predict_collisions`, apart from `_next_steps`. On that merge, built in a `git clone --shared` checkout, `test_adopt_next_make_reads_first`, #1105's `test_adopt_collision_make_reads` and `test_pinned_copy_text` gave 152 passed.
- With the other open pull requests that change `adopt-project.py` (#1120 `c55e55e3`, #1119 `bdcf9952`, #1118 `c60066cb`, #1106 `504a1dd6`, #1091 `160de118`): each clean.

## Not in this PR

- Once #1105 lands, the lookup "which of `GNUmakefile` and `makefile` the root keeps first" is written three times: in `_makefile_follow_up`, `_predict_collisions` and `_next_steps`. Each is the same two lines over `GNU_MAKE_READS` and `_root_keeps`. They are kept apart here so this PR and #1105 touch disjoint lines; one helper could serve all three after both land.
- `templates/assembly-root/AGENTS-shape.md` line 19 ("`git clone --recurse-submodules` this repository and run `make bootstrap`") has the same gap in an adopted root that keeps `GNUmakefile` or `makefile`. It is pinned, and #1105 changes that file, so it is left.
- On a disk that ignores case (#1049), a kept `makefile` is reported as a `Makefile` collision, and NEXT's NOTE says "the root keeps its own Makefile". The new clause is true there too, since GNU make reads that file and merging into it brings the targets. The spelling is #1049's.
- A kept directory named `GNUmakefile/` or `makefile/` is named as the source's file. GNU make stops on it with "Is a directory". `_makefile_follow_up` does the same.

Closes #1047

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant