Skip to content

adopt-project.py and README.md: the Makefile follow-up names a kept makefile or GNUmakefile GNU make reads first, and where a case-blind disk writes the shape's; README says which Makefile exports CONTRACTS_DIR (#977) - #1046

Merged
brettheap merged 3 commits into
mainfrom
fix/977-lower-case-makefile
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

Two follow-ups to #906 that lane 1's review of #964 (r906) found and
ruled out of that pull request. Neither is a regression: both were as
untrue on main before #906 as after it.

(a) The Makefile follow-up names the root file GNU make reads first.
The line is in plan's follow_ups:, in check's output and in the
split commit's message, which is also the pull request's body. GNU make
reads the first file it finds of GNUmakefile, makefile and
Makefile, and only that one. #906 asked only whether the source keeps
Makefile, comparing names byte for byte (_root_keeps). So a source
that keeps makefile or GNUmakefile got "the root Makefile wires
CONTRACTS_DIR ...". But the clone's root then holds the shape's
Makefile, with the block, and the source's file, which make reads
instead, so make contracts-dir answered No rule to make target 'contracts-dir' (r906's probe, both names).

_makefile_follow_up now writes the line:

The root keeps Before After
none "the root Makefile wires CONTRACTS_DIR ..." unchanged
Makefile #906's line: "the shape's Makefile wires ...; but the source keeps a Makefile of its own at the root, so that block waits in shape/Makefile until the two are merged, and the run adds none of it to the root's Makefile" unchanged
makefile "the root Makefile wires ..." "the shape's Makefile wires ...; it is written as the root's Makefile, or as shape/Makefile once case is ignored, as a macOS or Windows disk does, where the source's makefile is that path, until the two are merged; and GNU make at the root reads the source's makefile, ..."
GNUmakefile "the root Makefile wires ..." "the shape's Makefile wires ...; it is written as the root's Makefile, but GNU make at the root reads the source's GNUmakefile, ..."
makefile and GNUmakefile "the root Makefile wires ..." as for makefile, and GNU make reads the source's GNUmakefile
Makefile and makefile #906's line #906's line, then "; and GNU make at the root reads the source's makefile, ..."
Makefile and GNUmakefile, or all three #906's line #906's line, then "; and GNU make at the root reads the source's GNUmakefile, ..."
MAKEFILE, kept in the root by a --path-policy rule (with the shipped rules plan asks about it, and a person's root answer keeps the line plan wrote: "the root Makefile wires ...") "the root Makefile wires ..." "the shape's Makefile wires ...; it is written as the root's Makefile, or as shape/Makefile once case is ignored, ..., where the source's MAKEFILE is that path, until the two are merged"

The clause that names the file, in full:

GNU make at the root reads the source's `makefile`, the first of
`GNUmakefile`, `makefile` and `Makefile` it finds, so `make` there exports
`CONTRACTS_DIR` only once `makefile` does, or includes a Makefile that does

(b) README.md, section "Adopting an existing repository".

  • Before: "the root Makefile it materializes exports CONTRACTS_DIR,
    pointing at the contracts/ of the spec leg where the plan mounts it
    ($(CURDIR)/spec/contracts by default)."
  • After: "the shape's Makefile exports CONTRACTS_DIR, pointing at the
    contracts/ of the spec leg where the plan mounts it
    ($(CURDIR)/spec/contracts by default); it is the root's Makefile, or,
    where the source keeps a Makefile of its own, shape/Makefile, which
    holds the export until a person merges the two."

This is r906's wording, as its re-check corrected it, with one change:
Makefile is in backticks in "a Makefile of its own". A source that
keeps makefile has the shape's Makefile written as the root's
Makefile on a disk that keeps case, so the plain word would have
claimed shape/Makefile for it.

The section is bound to the handbook's adopt section. The handbook
says nothing of CONTRACTS_DIR, and its sentence that a shape file
whose name the source already holds, Makefile among them, is written
under shape/ stays true. So only the section's marker moves, to the
section's new blob, computed with readme_segments and
readme_fingerprint.

The tail of the paragraph is reflowed, and README.md grows by one line,
to 1479, which is the cap. No tests/readme-cap.d/ file is needed while
no other README pull request lands first.

Measured

On a disk that keeps case (Linux, GNU Make 4.3, git 2.43), each row ran
a real execute --local-remote-dir and made a recursive clone of the
split. Each file the source keeps has its own which target, so make which at the clone's root prints the name of the file GNU make read.

The root keeps make which prints the split message names make contracts-dir at the root the block is in (make -f <it> contracts-dir prints the spec leg's contracts/)
makefile makefile makefile no rule Makefile
GNUmakefile GNUmakefile GNUmakefile no rule Makefile
makefile and GNUmakefile GNUmakefile GNUmakefile no rule Makefile
Makefile Makefile #906's line, the source's own Makefile no rule shape/Makefile
Makefile and makefile makefile makefile no rule shape/Makefile
Makefile and GNUmakefile GNUmakefile GNUmakefile no rule shape/Makefile
all three GNUmakefile GNUmakefile no rule shape/Makefile

The disk that ignores case is not measured: there is none here. What
the line says of it rests on place's Path.exists(), and is tested
against _KeptTree(..., fold=True), check's model of that disk.

Tests

  • tests/test_adopt_make_reads_first.py, new: 33 tests.
    • The line word for word, for every row above, and for makefile
      and GNUmakefile sent to legs.
    • The line against _KeptTree on both disks.
    • plan and check for each row but none and Makefile, which
      test_adopt_contracts_dir and test_adopt_printed_sentences already
      plan and check.
    • A real execute for each row except none, with the make
      measurements above.
    • README's sentence, and the handbook marker matching the section as
      it now stands.
  • Fail on base: on e2f9fc42 (main when the branch was made), 23 fail
    and 10 pass. The 10 are the handbook marker test, which was current
    on base, and rows whose line does not change: none and Makefile
    alone; makefile and GNUmakefile sent to legs; and the
    Makefile-plus-another rows of the disk test, which asks only where
    the copy goes.
  • Gate on this head, named files only, one basetemp, a stub gh first
    on PATH, GH_TOKEN=bogus, GIT_SSH_COMMAND=false,
    GIT_CONFIG_GLOBAL=/dev/null:
    • The new file, test_adopt_contracts_dir, test_adopt_printed_sentences,
      test_adopt_leg_keys, test_adopt_plan, test_adopt_e2e,
      test_repo_hygiene, test_readme_sections, test_agents_md_codes,
      test_cli_reference, test_readme_windows_quoting_sentence and
      test_scaffold_leg_path_collisions: 652 passed, 1 skipped (the
      MedxEHR test, no clone here).
    • Every other adopt test file that names Makefile, makefile or
      CONTRACTS_DIR: attributes_eol_block, verify,
      ignored_shape_files, leg_path_collisions, submodules: 368
      passed. root_manifest, root_symlinks, symlink_target_bytes,
      root_shape_paths: 403 passed.
    • test_shape_doctor.py in 4 shards: 445 passed, 3 skipped (the
      PowerShell line, Windows only).
    • After the last test-only edit (helpers imported from
      test_adopt_printed_sentences): the new file and
      test_adopt_printed_sentences, 87 passed (33 and 54).

Mutants of the change, each restored. Each one is killed:

Mutant Failed
GNU make's order with makefile before GNUmakefile 18 of 33. The end-to-end rows makefile+GNUmakefile and all three fail on make which against the split message itself.
the case-folding check off 9
the joining word always "; and" 3
"the shape's" not chosen when only GNUmakefile or makefile is kept 4
GNU make's first file never found 19
_root_keeps folds case by default 13, #906's the-root-keeps-it row among them
README's old sentence 3, test_the_handbook_follows_the_readmes_outline among them
the handbook marker left stale 2

Merge with main

git merge-tree --write-tree of this head with main at dc7cd49b is
clean (tree ae9abfcb). Of this branch's files, only
adopt-project.py moved on main: #1030 (#900) changed _spelled, and
#1038 (#994) changed a hint and a docstring. _spelled changes only a
backslash, a double quote or a backtick inside a name. The names this
line spells are plain letters, so its words do not change. README.md,
docs/handbook.html and tests/readme-cap.d/ did not move on main, so
the marker stays current and the cap still holds.

On the merge, built in a git clone --shared checkout: the new file,
test_adopt_printed_sentences, test_repo_hygiene and
test_readme_sections, 263 passed.

Not in this PR (follow-ups for lane 1 to file)

  • NEXT's make bootstrap, measured. For a source that keeps
    makefile or GNUmakefile, execute's NEXT block prints make bootstrap. make -n bootstrap in the clone exits 2 with No rule to make target 'bootstrap'. For Makefile and GNUmakefile, its NOTE
    says the shape's make targets "arrive only once the follow-up merges
    the two". But GNU make at the root reads GNUmakefile, not the merged
    Makefile (inferred; before the merge, make -n bootstrap there
    exits 2, as the NOTE says).
  • collision_follow_up's sentence. In shape_materialize.py,
    "The shape's make targets ... arrive only with that merge" is likely
    untrue where the source also keeps GNUmakefile or makefile, which
    GNU make reads before the merged Makefile. Inferred, not measured.
  • AGENTS-shape.md in the pinned template. "Where the source kept a
    Makefile of its own, that block waits in shape/Makefile" has the
    same gap for a kept makefile. It is pinned bytes, so it is left.
  • Disks that ignore case.
    • _predict_collisions stays byte for byte, so on such a disk a plan
      for a kept makefile lists no "merge shape/Makefile into Makefile"
      follow-up. execute's own collision report lists it.
    • A split made on a disk that keeps case commits both Makefile and
      makefile, two names that collide in a clone on a disk that
      ignores case.
    • Spellings such as gnumakefile are not asked about.
    • None of this is measured.

Review round 0, body only (lane 1's reviewer r1046's partial review, issuecomment-6288714370): the MAKEFILE row and the byte-for-byte sentence now say that a --path-policy rule, not a person's answer, is what keeps MAKEFILE in the root. Follow-ups are built from the rules' answers alone, so a MAKEFILE a person answers root keeps "the root Makefile wires ...", which a disk that ignores case makes untrue, as on base.

Review round 1 (c121ed90, test file only): the comment above ANSWERED says a --path-policy rule, not a person's answer, reaches the follow-ups (r1046's SHOULD-1). The new test file is ASCII: its section signs and em dashes are written as escapes, with two lines rewrapped: the assert's string is unchanged, the re pattern matches the same text (its raw string now holds the escape), and one docstring changes only where its lines break (NIT-2). tests/test_adopt_make_reads_first.py 33, tests/test_adopt_printed_sentences.py 54 and tests/test_repo_hygiene.py 100 passed.

Closes #977

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Make adoption follow-ups accurately reflect GNU make's filename precedence and the generated Makefile's location.

Bug Fixes:

  • Clarify adoption follow-ups so they identify the file GNU make actually reads first and accurately describe where the generated Makefile is placed on case-insensitive filesystems.

Enhancements:

  • Update the adoption guidance to explain which Makefile exports CONTRACTS_DIR and when the export remains under shape/ until merged.
  • Add comprehensive coverage for GNU make filename precedence, case-folded paths, planning and execution behavior, and the synchronized handbook section.

Documentation:

  • Revise the README adoption section and handbook marker to document the generated Makefile location and CONTRACTS_DIR export behavior.

Tests:

  • Add end-to-end and unit tests covering Makefile, makefile, and GNUmakefile combinations, filesystem case handling, plan/check output, and real split behavior.

…makefile` or `GNUmakefile` GNU make reads first, and where a case-blind disk writes the shape's; README says which Makefile exports `CONTRACTS_DIR` (#977)

GNU make reads the first it finds of `GNUmakefile`, `makefile` and
`Makefile`, and only that one. #906's line asked only whether the source
keeps `Makefile`, byte for byte (`_root_keeps`), so a source that keeps
`makefile` or `GNUmakefile` got "the root Makefile wires `CONTRACTS_DIR`"
while `make contracts-dir` at the clone's root answered `No rule to make
target` (r906's probe). `_makefile_follow_up` now writes the line: it says
the shape's Makefile is written as the root's `Makefile`, names the
source's file GNU make reads at the root ("the first of `GNUmakefile`,
`makefile` and `Makefile` it finds") and what it takes for `make` there
to export `CONTRACTS_DIR`. Beside a kept `Makefile`, #906's words stand
and the clause follows them. On a disk that ignores case (`_root_keeps`
gains `fold`, as `_KeptTree` asks `check`'s disk), a kept `makefile` is
`Makefile`, `place` writes the shape's as `shape/Makefile`, and the line
says so with `CASE_ASIDE`. A plain `Makefile`, or none, prints the line
it printed before.

README.md § Adopting an existing repository said "the root Makefile it
materializes exports `CONTRACTS_DIR`"; it now says the shape's Makefile
does, and that it is the root's Makefile or, where the source keeps a
`Makefile` of its own, `shape/Makefile` (r906's wording, `Makefile` in
backticks: a kept `makefile` leaves the shape's at the root where case
is kept). The handbook's `adopt` section says nothing of `CONTRACTS_DIR`
and its sentence that a shape file whose name the source holds,
`Makefile` among them, goes under `shape/` stays true, so only its
marker moves, to the segment's new blob. +1 README line, within the cap.

tests/test_adopt_make_reads_first.py: the line word for word for the
source keeping none, `Makefile`, `makefile`, `GNUmakefile` and two or
three at once, and `MAKEFILE`; the line against `_KeptTree` on both
disks; `plan` and `check` per row; a real `execute` per row where each
kept file's `which` target makes `make which` in the clone print the
file GNU make read, which the split message names, `make contracts-dir`
has no rule, and `-f` on the file the line names prints the spec leg's
`contracts/`; README's sentence and the handbook marker. 8 mutants of
the fix, README and marker, each killed.

Committed at a usage checkpoint: the new test file's last edit (shared
helpers imported from test_adopt_printed_sentences, a comment) is not
yet re-run; everything before it is.

Lane: openreposhape-1 (openRepoShape-1)

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

The PR updates adoption follow-ups and documentation to account for GNU make selecting only the first available GNUmakefile, makefile, or Makefile, including case-insensitive path behavior, and adds broad unit, integration, and documentation-consistency tests.

Flow diagram for GNU make follow-up selection

flowchart TD
    A["Root files surviving in the assembled repository"] --> B["_makefile_follow_up(surviving)"]
    B --> C{"_root_keeps(surviving, Makefile)"}
    C -->|yes| D["Shape Makefile is written as shape/Makefile; mention the source Makefile"]
    C -->|no| E{"_root_keeps(surviving, Makefile, fold=True)"}
    E -->|yes| F["Case-insensitive collision: shape Makefile may be written as shape/Makefile"]
    E -->|no| G["Shape Makefile is written as the root Makefile"]
    D --> H["Check GNU_MAKE_READS order"]
    F --> H
    G --> H
    H --> I{"First source file found in GNUmakefile, makefile, Makefile"}
    I -->|source GNUmakefile or makefile| J["Explain that GNU make reads the source file and requires it to export CONTRACTS_DIR or include a Makefile that does"]
    I -->|only Makefile or none| K["Keep the existing follow-up wording"]
Loading

File-Level Changes

Change Details Files
Generate Makefile follow-up text based on the filename GNU make actually selects and on case-folding behavior.
  • Add ordered GNU make filename detection for GNUmakefile, makefile, and Makefile.
  • Extend root-path checks with optional case folding to model case-insensitive disks.
  • Describe where the shape Makefile is written and which source file must export CONTRACTS_DIR.
  • Replace the inline follow-up construction with the new helper.
adopt-project.py
Clarify README documentation about which generated Makefile contains the CONTRACTS_DIR export.
  • Explain that the shape's Makefile owns the export.
  • Document root versus shape/Makefile placement when the source already keeps a Makefile.
  • Reflow the adopting-repository section while retaining the README size limit.
README.md
Refresh the handbook linkage to the updated README section.
  • Update the adopt-section README content fingerprint marker.
docs/handbook.html
Add comprehensive coverage for Makefile selection, materialization, execution, and documentation consistency.
  • Test follow-up wording for all combinations of GNU make filenames and case-folded paths.
  • Exercise plan, check, execute, clone, and make behavior end to end.
  • Verify README wording and handbook marker synchronization.
  • Cover GNU make ordering, case folding, conjunction wording, and legacy behavior with mutation-focused cases.
tests/test_adopt_make_reads_first.py

Assessment against linked issues

Issue Objective Addressed Explanation
#977 Make the adoption follow-up accurately identify which kept root makefile GNU make reads first, explain when the root exports CONTRACTS_DIR, and account for case-insensitive path collisions. ✅
#977 Correct the README’s CONTRACTS_DIR description to distinguish the root Makefile from shape/Makefile when the source keeps its own Makefile, and keep the handbook marker synchronized. ✅

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-1, 2026-10-20T10:17Z)

r1046 PARTIAL review of PR #1046 (closes #977) at 83b3671

Stopped at the 77% usage checkpoint. Nothing is applied and nothing of mine is running. At 10:17Z all three trees were clean: the worktree r1046-make-reads-first (HEAD 83b3671), the scratch clone mut (HEAD 83b3671) and the scratch clone merge.

VERDICT (as of now): NOT READY at 83b3671

There is one MUST, and it is in the PR body only: the fix is a body edit, with no code change. The remaining checks (resume steps below) may add findings. They cannot remove this one.

FINDINGS (most severe first)

MUST-1: the body's MAKEFILE row describes a line a person's answer never produces

Where: PR body line 43, the table row "MAKEFILE (a person's answer; no rule keeps it)", with "After" = "the shape's Makefile wires ...; it is written as the root's Makefile, or as shape/Makefile once case is ignored, ...". Body line 29, "A source that keeps only Makefile, or none of the three names, gets the line it got before, byte for byte", contradicts that row.

Why it is false. plan computes follow_ups: once (adopt-project.py:5991-5994, follow_ups_for(source, implied, ...)). implied holds only the rules' answers and the refused-path asks, never a person's answer. With the shipped rules, MAKEFILE is asked (leg: null). The plan therefore writes "the root Makefile wires ...", and a person's later leg: root answer leaves that line as it is. check and the split message print the stale line.

The row's "After" is reachable only when a --path-policy rule keeps MAKEFILE in the root. In that case the parenthetical "no rule keeps it" is false, and so is line 29's "none of the three names ... byte for byte".

Evidence: the probe _lane1-scratch/r1046/py/test_r1046_probe.py, results in _lane1-scratch/r1046/probe/out/head1.jsonl and head2.jsonl.

  • Case MAKEFILE, shipped rules, answered root after plan:
    • plan_leg_MAKEFILE: null, answered_MAKEFILE: root
    • plan line = check line = message line = "the root Makefile wires CONTRACTS_DIR to the contracts/ of the spec leg, ... beside it" (the "none" line)
    • check_rc 0, execute_rc 0
  • Case MAKEFILE-policy (_lane1-scratch/r1046/policy-MAKEFILE.yaml, which adds "MAKEFILE" to root-project-makefile):
    • plan_leg_MAKEFILE: root
    • line = "the shape's Makefile wires ...; it is written as the root's Makefile, or as shape/Makefile once case is ignored, as a macOS or Windows disk does, where the source's MAKEFILE is that path, until the two are merged"

Fix (body only).

  1. Relabel row 43: "MAKEFILE, kept in the root by a --path-policy rule (with the shipped rules plan asks about it, and a person's root answer keeps the line plan wrote: "the root Makefile wires ...")".
  2. Line 29: "A source that keeps only Makefile, or none of the three names and no other case of Makefile a rule keeps, gets the line it got before, byte for byte."
  3. Optionally add a follow-up: follow-ups are read from the rules' answers only. A MAKEFILE a person answers root therefore keeps "the root Makefile wires", which is untrue on a disk that ignores case, where place writes the shape's to shape/Makefile. This is as untrue on base. It is not a regression.

SHOULD-1: the test comment repeats MUST-1's claim

Where: tests/test_adopt_make_reads_first.py:129-133, the ANSWERED comment: "(path, leg) rows no rule writes, a person's answers: ...".

Why: follow_ups_for never receives a person's answers in plan (see MUST-1). Both rows, MAKEFILE in the root and makefile or GNUmakefile sent to the legs, reach it only through a --path-policy rule.

Fix: "rows no shipped rule writes, which a --path-policy rule can (a person's answer in the plan changes no follow-up): ...". The test bodies are fine.

NIT-1: the body says "Each name is printed with _spelled"

Where: body line 31. In fact only the source's names are passed through _spelled (twins[0], first). These five are hand-backticked constants:

  • adopt-project.py:3599, _and_listed([f"{name}" ...])
  • 3597 and 3593, the root's Makefile
  • 3585, shape/Makefile

Evidence: _spelled(n) == "" + n + "" is True for GNUmakefile, makefile, Makefile and shape/Makefile. The bytes are identical, so the line is correct.

Fix: reword to "each kept name with _spelled, the three names GNU make looks for with _and_listed". Or use _and_listed([_spelled(n) for n in GNU_MAKE_READS]); its output is identical.

NIT-2: non-ASCII in the new test

Where: tests/test_adopt_make_reads_first.py:332 and :340 (the section sign), and :345 and :346 (the em dash).

Note: the repo allows non-ASCII here (116 of 296 test files hold some). The brief's style rule asks for ASCII.

Fix:

  • Spell them \u00a7 and \u2014.
  • re reads \u00a7 inside the rf"..." pattern.
  • The assert at 345-346 needs a plain (non-raw) string.

NIT-3: "plan and check for each row" is broader than the new file

Where: body line 108.

Why: the new file runs plan and check for 6 of the 8 rows (ROWS[2:]). The none and Makefile rows are covered elsewhere: test_adopt_contracts_dir.py:231 (plan) and test_adopt_printed_sentences.py:139-145 (plan and check).

Fix: "for each row but none and Makefile, which test_adopt_contracts_dir and test_adopt_printed_sentences already plan and check".

NIT-4: the body's merge section is stale

Where: body line 154, "main at dc7cd49b ... tree ae9abfcb".

Now: main is b0babe1. The merge is still clean (tree 2565a12a), and the same four files pass on it (263, below). README.md, docs/handbook.html and tests/readme-cap.d did not move on main.

Fix: refresh at landing.

NIT-5: a Sourcery summary sentence is inaccurate

Where: body line 208, "ensuring CONTRACTS_DIR is associated with the file GNU make actually reads".

Why: the PR names the file make reads. It does not associate CONTRACTS_DIR with it.

Fix: delete or correct it, or make sure the squash message is the explicit one, not the body.

NIT-6: README's sentence is true but silent on the read-first file

Where: README.md:810-813.

Why: the sentence is true in every measured case. It does not say that a kept GNUmakefile or makefile is what make at the root reads, so make contracts-dir there has no rule (measured).

Fix: a follow-up issue, not this PR. README is at its cap (1479 = 1422 + 57 from the ledger).

NIT-7: an older docstring keeps the old claim (outside the diff)

Where: adopt-project.py:3464, "CONTRACTS_DIR is the root Makefile's alone".

Why: this is #906-era wording. Where the source keeps a Makefile, the export is in shape/Makefile.

Fix: a follow-up candidate only.

VERIFIED SO FAR

Item 1: real plan, check, execute and clone on a disk that keeps case

Setup: Linux, GNU Make 4.3, git 2.43.0, the default legs spec and code. Each kept file has a which target.

Results are in _lane1-scratch/r1046/probe/out/head1.jsonl and head2.jsonl. "MAKEFILE_LIST" is from make -p -q -r which. "Root rc" is make contracts-dir at the root. "Block in" is where make -f <file> contracts-dir prints <clone>/spec/contracts.

Case MAKEFILE_LIST Root rc Block in Line
none Makefile 0, prints the path Makefile "the root Makefile wires", true
Makefile Makefile 2, no rule shape/Makefile #906's line, true
makefile makefile 2 Makefile names makefile, true
GNUmakefile GNUmakefile 2 Makefile names GNUmakefile (", but"), true
Makefile + makefile makefile 2 shape/Makefile true
Makefile + GNUmakefile GNUmakefile 2 shape/Makefile true
makefile + GNUmakefile GNUmakefile 2 Makefile the folded clause names makefile, and the read clause names GNUmakefile; true
all three GNUmakefile 2 shape/Makefile true
MAKEFILE, shipped rules Makefile 0 Makefile "the root Makefile wires", true here (see MUST-1)
MAKEFILE, policy rule Makefile 0 Makefile the folded line, no GNU make clause; true here
  • The line names the file make reads: it never names a file make does not read.
  • CONTRACTS_DIR and make contracts-dir: what the line says of both is true in every row.
  • Plan, check and message: in every row the plan line, the check line and the line in the split message are identical. Each is one line, printable and ASCII (the message holds " - " + the line exactly).

The PR's follow-ups, measured

Item 2: a disk that ignores case

I could not make one. The kernel has no CONFIG_UNICODE (zcat /proc/config.gz). unshare -rm plus mount -t tmpfs -o casefold is refused ("wrong fs type, bad option"), while a plain tmpfs mounts. There is no fusermount, fuse2fs, ciopfs or Python FUSE module.

From the code, the line's claim is consistent:

  • execute clones the source, mounts the legs, and then materialize_assembly_root runs place. At that point a kept makefile is on disk, so Path("Makefile").exists() is True there, and the copy goes to shape/Makefile.
  • _root_keeps(fold=True) and _KeptTree(fold=True).holds both use casefold.
  • _makefile_follow_up's docstring declines to say what GNU make reads by case there, and the line says nothing about it for MAKEFILE.

Items 3 and 4: printing, README and handbook

  • Printing: check prints follow-ups with _shown, and these lines need no spelling, so they are unchanged.
  • Handbook marker: recomputed with readme_segments and readme_fingerprint; it is 65fb9bf156f22898f6c75833f702861d4cc849e6, matching docs/handbook.html. The handbook says nothing of CONTRACTS_DIR. Its "written under shape/" sentence (handbook.html:1309) stays true.
  • README: 1479 lines, which is the cap. The added lines are at most 80 columns.
  • Old claim: no other README or handbook sentence makes it. README:1113 ("appends a CONTRACTS_DIR block to an adopted Makefile") is true.

Item 5: the 23 that fail on base

Base adopt-project.py, README.md and docs/handbook.html were put in place and then restored with git checkout HEAD --. Result: 23 failed and 10 passed, and the 10 are exactly the ones the body lists.

Item 7: issues and the writer's counts

Item 8: style

  • git diff --check e2f9fc42 83b3671b is clean.
  • No added line is over 79 columns in adopt-project.py or the new test.
  • There are no tabs.
  • There is no assert a and b. The two asserts at :193 and :194 have the form assert X is (A and B), which is not compound.
  • Non-ASCII: NIT-2.

TEST COUNTS

Every run used:

  • EMAIL exported and LANES_LANE unset;
  • the stub gh first on PATH, GH_TOKEN=bogus-r1046 and an empty GH_CONFIG_DIR;
  • GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false;
  • the basetemp _lane1-scratch/r1046/bt.

Results:

  • New file at head: 33 passed.
  • Base (as above): 23 failed, 10 passed.
  • Gate A at head: 515 passed, 0 skipped (logs/gate-A.log). The files:
    • test_adopt_make_reads_first 33
    • test_adopt_contracts_dir 34
    • test_adopt_printed_sentences 54
    • test_repo_hygiene 100
    • test_readme_sections 76
    • test_agents_md_codes 47
    • test_cli_reference 23
    • test_readme_windows_quoting_sentence 44
    • test_scaffold_leg_path_collisions 104
  • test_shape_doctor.py, 4 shards: 118 + 110 + 117 + (100 passed, 3 skipped) = 445 passed and 3 skipped (448 ids).
  • Main merge: the four files gave 263 passed (logs/merge-four.log).

MUTANTS (in the mut clone, each restored; left=[] after each)

Code mutants run the new file, test_adopt_contracts_dir and test_adopt_printed_sentences (121 tests).

Mutant Result
R1, first may be Makefile KILLED, 4: new file [Makefile] line and e2e; printed_sentences the-root-keeps-it and plan-says-where
R2, fold casefolds the path but not the kept set KILLED, 1: only test_a_name_only...[MAKEFILE] (weakly covered)
R3, joint ignores folded KILLED, 6: [makefile] and [makefile+GNUmakefile], in line, plan and e2e
R4-R10, R11-R12 (README), W1 (the writer's M1, to verify "18 of 33") and R13 NOT RUN (checkpoint)

The mutant definitions are in _lane1-scratch/r1046/mutants.py, and the runner is run-mutant.sh.

MERGES

  • git merge-tree --write-tree of 83b3671 with main b0babe1: clean, tree 2565a12a4030c39ff62d9a949c16512ab93575a2.
  • The same with fix/910-check-throwaway-repos abd1b98: clean, tree fa99cd047ca45fd934553901c0c8ffc34e4065c9.
  • Main merge, built in the git clone --shared checkout _lane1-scratch/r1046/merge at the commit-tree 8eaa1f7e (parents b0babe1 and 83b3671): the new file, test_adopt_printed_sentences, test_repo_hygiene and test_readme_sections gave 263 passed.

NOT VERIFIED YET

  • The mutants R4-R13 and W1.
  • A disk that ignores case: impossible here; the result is reasoned from the code.
  • Skip reasons for the 3 skips in the doctor shards (not printed; the writer says PowerShell, Windows only).

RESUME STEPS

  1. Go to the worktree and check its state:
    • cd /workspace/projects/openRepoShape-worktrees/r1046-make-reads-first || exit 1
    • git status --short must be empty, and HEAD must be 83b3671.
    • Check that df -i /workspace has room.
    • Check that git -C /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1046/mut status --short is empty.
  2. Run the code mutants:
    • export EMAIL=<the r1046 EMAIL value the brief gives>
    • S=/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1046
    • CODE=(tests/test_adopt_make_reads_first.py tests/test_adopt_contracts_dir.py tests/test_adopt_printed_sentences.py)
    • Run for m in R4-whose-ignores-folded R5-no-written-as-for-GNUmakefile R6-search-order-reversed R7-aside-folds-case R8-legs-fed-as-root R9-first-folds-case R10-twins-keep-slash R13-listed-order-text; do "${S}/run-mutant.sh" "$m" "${CODE[@]}"; done in batches of 3-4, each call under 9 minutes (about 75 s per mutant).
  3. Run the README mutants: for m in R11-readme-unticked-Makefile R12-readme-whitespace-only; do "${S}/run-mutant.sh" "$m" tests/test_adopt_make_reads_first.py tests/test_repo_hygiene.py tests/test_readme_sections.py; done
  4. Verify the writer's "18 of 33": "${S}/run-mutant.sh" W1-writers-M1-order tests/test_adopt_make_reads_first.py
  5. Check main again. Run git -c gc.auto=0 -c maintenance.auto=false fetch -q --no-write-fetch-head origin main. If it moved past b0babe1, redo the merge-tree and the four-file run in a new --shared clone (the scratch merge clone is at 8eaa1f7e).
  6. Write the final report to _lane1-notes-20261011/v11/c1046-r1046.md, merging in this partial. The verdict stays NOT READY unless the body is fixed (MUST-1); a body fix needs no re-gate of the code.

…ERED comment says a --path-policy rule, not a person's answer, reaches the follow-ups, and the file is ASCII

SHOULD-1 and NIT-2 of r1046's review of #1046.

The comment above ANSWERED said its two rows were "`(path, leg)` rows no
rule writes, a person's answers". `plan` builds its follow-ups only from
what the rules answer, so a person's answer in the plan changes no
follow-up: both rows reach `follow_ups_for` only through a
`--path-policy` rule (`MAKEFILE` kept in the root; `makefile` and
`GNUmakefile` sent to the legs). The comment now says so.

The file held four non-ASCII characters, a section sign on two lines and
an em dash on two lines. Each is now its Python escape: Python turns it
into the character in the plain docstring and the plain assert strings,
and `re` does in the `rf` pattern. The values are unchanged. The two
lines the escapes pushed past 79 columns are rewrapped. No assertion
means anything else than before.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T11:12Z)

r1046: independent review of PR #1046 (closes #977) at c121ed9

VERDICT: NOT READY at c121ed9

One MUST is open, and it is in the PR body only. It is the round-1 note's claim that "every string value [is] unchanged".

  • The fix: one phrase in the body. No code or test change follows, and nothing needs to run again.
  • Everything else holds at c121ed9:
    • The code, README and handbook are true in every case I measured.
    • The tests pass, and the merges are clean.
    • My partial's MUST-1, SHOULD-1, NIT-1, NIT-2, NIT-3 and NIT-5 are resolved.
  • With that phrase fixed, this review is READY at c121ed9.

FINDINGS (most severe first)

MUST-2: the body's "Review round 1" note says "every string value unchanged", and two string constants changed

Where: live PR body, line 199: "The new test file is ASCII: its section signs and em dashes are written as escapes, with two lines rewrapped and every string value unchanged (NIT-2)."

Evidence: I parsed both versions of tests/test_adopt_make_reads_first.py with ast (git show 83b3671b:... and git show c121ed90:...) and compared every str constant. There are 161 in each, and these two differ:

  1. The docstring of test_the_handbook_reads_the_sentence_as_it_now_stands.
    • Before: "...README.md
      Adopting an existing\n repository, and says..."
    • After: "...README.md
      Adopting an\n existing repository, ..."
    • The rewrap changed its value. It is equal only once whitespace is collapsed, which I checked.
  2. The raw rf pattern piece.
    • Before: '<!-- reads README\\.md <section sign> '
    • After: '<!-- reads README\\.md \\u00a7 '
    • The raw string now holds the six characters of the escape. re reads both the same way: I compiled both and both match "README.md
      ...", and test_the_handbook_reads_the_sentence_as_it_now_stands passes.

The assert string at :346-348 is unchanged, because adjacent literals join into one constant and its value is identical.

The commit message of c121ed9 says only "The values are unchanged" of the escapes, and then that two lines are rewrapped. Read that way it is defensible. The body's "every string value unchanged" is not.

Fix (body only): replace "with two lines rewrapped and every string value unchanged" with:

"with two lines rewrapped: the assert's string is unchanged, the re pattern matches the same text (its raw string now holds the escape), and one docstring changes only where its lines break".

NIT-A: the body's merge section is out of date (partial NIT-4)

Where: live body lines 157-168, "main at dc7cd49b ... tree ae9abfcb".

Now: main is 8a05721, and the merge with c121ed9 is clean (tree b8dcb1a9). Since dc7cd49, main has moved only adopt-project.py among this branch's files, as the body says. README.md, docs/handbook.html and tests/readme-cap.d have not moved. On the new merge the four files give 263 passed (below).

Fix: refresh the section at landing.

NIT-B: two follow-ups the body marks "inferred" are now measured

Where: live body lines 172-183.

Evidence: in my all-three clone (_lane1-scratch/r1046/probe/out/head1-all-three/Northwind), after cat shape/Makefile >> Makefile:

  • make -n bootstrap at the root still exits 2: "No rule to make target 'bootstrap'";
  • make contracts-dir at the root says "No rule";
  • make -f Makefile contracts-dir prints the spec leg's contracts/.

So, where GNU make reads a kept GNUmakefile, the NOTE's "arrive only once the follow-up merges the two" is untrue. So is collision_follow_up's "arrive only with that merge" (adopt-project.py:14947 and the split message's collision line).

Fix: optionally, mark both "measured (r1046)" when the follow-ups are filed. Both stay outside #977.

NIT-C: a mutant survives: directory entries named makefile/ are untested

Mutant R10: drop the rstrip("/") in twins (adopt-project.py, _makefile_follow_up). 121 of 121 still pass.

Why it matters: with a kept directory entry such as makefile/, folded is True (through _root_keeps's directory form). twins would then be empty, and twins[0] raises in plan. The code is correct now: _makefile_follow_up({"makefile/"}) returns the line. Only the test is missing.

Note: this case is contrived. For such a directory, GNU make 4.3 stops with "makefile: Is a directory." (measured).

Fix: optionally, add a ("makefile/", "root") row to ANSWERED, or accept the gap.

NIT-D: README's sentence is true but does not say a kept GNUmakefile or makefile is read first (partial NIT-6)

Where: README.md:810-813.

Why: the sentence is true in every measured case. It does not say that make at the root reads a kept GNUmakefile or makefile instead.

Fix: a follow-up issue. README is at its cap of 1479 lines (1422 plus 57 from the ledger).

NIT-E: an older docstring keeps the old claim (outside the diff; partial NIT-7)

Where: adopt-project.py:3464, "CONTRACTS_DIR is the root Makefile's alone".

Why: this predates #977. Where the source keeps a Makefile, the export is in shape/Makefile.

Fix: a follow-up candidate.

RESOLVED SINCE THE PARTIAL (checked in the live body and at c121ed9)

  • MUST-1, resolved.
  • SHOULD-1, resolved. The comment above ANSWERED (:129-134) now says that a --path-policy rule, not a person's answer, reaches the follow-ups. That is true: plan builds follow_ups: from implied only (adopt-project.py:5991-5994).
  • NIT-1, resolved. Line 32: "Each kept name is printed with _spelled, and the three names GNU make looks for with _and_listed". That is true: _spelled(twins[0]) and _spelled(first), and _and_listed for the three names.
  • NIT-2, resolved. LC_ALL=C grep -n -P '[^\x00-\x7F]' on the c121ed9 file finds nothing (exit 1). No line is over 79 columns. git diff --check 83b3671b c121ed90 is clean.
  • NIT-3, resolved. Lines 109-111 say "for each row but none and Makefile", which test_adopt_contracts_dir and test_adopt_printed_sentences cover.
  • NIT-5, resolved. The Sourcery summary is gone from the live body.
  • The delta 83b3671b..c121ed90:
    • It touches one file, tests/test_adopt_make_reads_first.py (+11 -9).
    • The author and committer are the estate identity.
    • The comment text and the escapes are true. The marker test reads the section-sign line through the escape and passes.

VERIFIED

Item 1: a real plan, check, execute and clone, on a disk that keeps case

Setup: Linux, GNU Make 4.3, git 2.43.0, default legs. The probe is _lane1-scratch/r1046/py/test_r1046_probe.py, and its results are in probe/out/head1.jsonl and head2.jsonl.

Columns:

  • MAKEFILE_LIST comes from make -p -q -r which.
  • "Root rc" is the exit code of make contracts-dir at the root.
  • "Block in" is the file whose make -f <file> contracts-dir prints <clone>/spec/contracts.
Case MAKEFILE_LIST Root rc Block in Line
none Makefile 0 Makefile "the root Makefile wires": true
Makefile Makefile 2, no rule shape/Makefile #906's line: true
makefile makefile 2 Makefile names makefile: true
GNUmakefile GNUmakefile 2 Makefile names GNUmakefile: true
Makefile + makefile makefile 2 shape/Makefile true
Makefile + GNUmakefile GNUmakefile 2 shape/Makefile true
makefile + GNUmakefile GNUmakefile 2 Makefile true
all three GNUmakefile 2 shape/Makefile true
MAKEFILE, shipped rules, answered root Makefile 0 Makefile "the root Makefile wires": true on this disk
MAKEFILE, --path-policy rule Makefile 0 Makefile the case clause, no GNU make clause: true on this disk
  • The file the line names: it never names a file that make does not read.
  • CONTRACTS_DIR and make contracts-dir: what the line says of both is true in every row.
  • The same line everywhere: the plan's line, check's line and the split message's line are identical in every row. Each is one printable ASCII line.

Item 2: a disk that ignores case

I could not make one here, and found no other way:

  • The kernel has no CONFIG_UNICODE.
  • unshare -rm with mount -t tmpfs -o casefold is refused.
  • There is no fusermount, fuse2fs or ciopfs, and no Python FUSE module.

From the code, the line's claim is consistent:

  • place (Path.exists()) runs after the clone and the leg mounts, so a kept makefile is on disk at that point. On such a disk Path("Makefile").exists() is then True, and the copy goes to shape/Makefile.
  • _root_keeps(fold=True) and _KeptTree(fold=True).holds both use casefold.

Items 3 and 4: printing, README and the handbook

  • Spelling: the kept names are _spelled. The constants spell the same bytes.
  • Handbook marker: recomputed with the repo's readme_segments and readme_fingerprint, it is 65fb9bf156f22898f6c75833f702861d4cc849e6, the same as in docs/handbook.html.
  • Handbook sentence: handbook.html:1309 stays true.
  • README: 1479 lines, which is the cap. Its added lines are at most 80 columns.
  • The old claim: no other README or handbook sentence still makes it.

Item 5: the tests that fail on base

With e2f9fc4's adopt-project.py, README.md and handbook in place, the file gives 23 failed and 10 passed, exactly as the body lists. I restored the files with git checkout HEAD --.

Item 7: the issue and the body's counts

Item 8: style

  • git diff --check is clean for e2f9fc4..83b3671 and for 83b3671..c121ed9.
  • No added line is over 79 columns.
  • There are no tabs.
  • There is no assert a and b.
  • The test file is ASCII.

MUTANTS (run in my mut clone, each restored; the clone stayed clean after each)

  • Code mutants run against the new file, test_adopt_contracts_dir and test_adopt_printed_sentences (121 tests).
  • README mutants run against the new file, test_repo_hygiene and test_readme_sections (209 tests).
  • W1 runs against the new file only (33 tests).
Mutant Result
R1: first may be Makefile KILLED, 4
R2: folding casefolds the path, not the kept set KILLED, 1 (...[MAKEFILE] only)
R3: the joining word ignores folded KILLED, 6
R4: whose ignores folded KILLED, 1 (...[MAKEFILE] only)
R5: no "written as the root's Makefile" for GNUmakefile KILLED, 3
R6: the search order reversed KILLED, 6
R7: aside folds case KILLED, 9
R8: leg entries fed as root KILLED, 2 (sent-to-legs; printed_sentences it-goes-to-the-code-leg)
R9: first folds case KILLED, 5
R10: twins without rstrip("/") SURVIVES (NIT-C)
R13: the listed order's text KILLED, 18
R11: README Makefile without backticks KILLED, 3 (including hygiene test_the_handbook_follows_the_readmes_outline)
R12: README whitespace only, marker not moved KILLED, 2 (the marker test and the hygiene outline test)
W1: the writer's M1, makefile before GNUmakefile KILLED, 18 of 33

Totals: 13 killed and 1 survives (contrived). R1-R3 ran at 83b3671 and the rest at c121ed9; the code is identical in both.

TEST COUNTS

Environment for every run:

  • EMAIL exported, LANES_LANE unset.
  • The stub gh first on PATH, GH_TOKEN=bogus-r1046, and an empty GH_CONFIG_DIR.
  • GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false.
  • One reused basetemp, _lane1-scratch/r1046/bt.

At c121ed9 (the mut clone): the new file, test_adopt_printed_sentences, test_repo_hygiene and test_readme_sections gave 263 passed. That is 33 + 54 + 100 + 76, and it includes the marker test.

At 83b3671, with code, README and handbook identical to c121ed9:

  • Gate A (logs/gate-A.log): 515 passed, 0 skipped. By file:
    • test_adopt_make_reads_first: 33
    • test_adopt_contracts_dir: 34
    • test_adopt_printed_sentences: 54
    • test_repo_hygiene: 100
    • test_readme_sections: 76
    • test_agents_md_codes: 47
    • test_cli_reference: 23
    • test_readme_windows_quoting_sentence: 44
    • test_scaffold_leg_path_collisions: 104
  • test_shape_doctor.py in 4 shards: 118 + 110 + 117 + 100 = 445 passed, 3 skipped (448 tests).

MERGES

main 8a05721 (fetched with the no-maintenance flags; it was b0babe1 at the partial):

  • git merge-tree --write-tree with c121ed9 is clean, tree b8dcb1a91709a109fabf5718bb4ebfa147c31578.
  • I built it in the git clone --shared checkout _lane1-scratch/r1046/merge, as commit-tree 3ebced2a (parents 8a05721 and c121ed9).
  • The four files (new, printed_sentences, repo_hygiene, readme_sections) gave 263 passed (logs/merge2-four.log).
  • From b0babe1 to 8a05721, main touched none of adopt-project.py, README.md, docs/handbook.html, tests/readme-cap.d, shape_materialize.py or the imported test files.
  • The earlier merge with b0babe1 (tree 2565a12a) also gave 263 passed.

fix/910-check-throwaway-repos b83d9c0 (newest on origin; it was abd1b98 at the partial):

  • merge-tree with c121ed9 is clean, tree 8332cfe4164f07d3077ca43ef190782e5c49dc01.
  • With abd1b98 it was also clean, tree 883c365f.

NOT VERIFIED

  • A disk that ignores case: I could not make one here, so that part rests on the code (item 2).
  • Batches B and C: I did not run the writer's batch B (attributes_eol_block, verify, ignored_shape_files, leg_path_collisions, submodules) or batch C (root_manifest, root_symlinks, symlink_target_bytes, root_shape_paths). They were not in my brief; the writer's logs show 368 and 403 passed.
  • The 3 doctor skips: their reasons were not printed. The writer says they are the PowerShell rows, Windows only.

STATE

  • The worktree r1046-make-reads-first is untouched at 83b3671, and git status --short is clean.
  • My scratch clones are both clean: mut is at c121ed9 and merge is at 3ebced2a.
  • No mutant is applied, and no process of mine is running.
  • Writes outside scratch: the only one was the merge-tree tree objects written into the shared object store.
  • No gh writes, and nothing was posted.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T11:15:20Z)

#1046 is READY at c121ed90, on r1046's own condition (issuecomment-6289602193): "Once that phrase is fixed, the review is READY at c121ed9."

  • The round-1 note now uses the reviewer's exact wording: "with two lines rewrapped: the assert's string is unchanged, the re pattern matches the same text (its raw string now holds the escape), and one docstring changes only where its lines break".
  • Lane 1 lands it once tests on c121ed90 is green, in lane 3's order: first green and gated goes first, one HOLD at a time.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 20, 2026 12:39
@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 6 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit f1946d0 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/977-lower-case-makefile branch October 20, 2026 12:39
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

1 participant