Skip to content

openRepoShape --doctor: a signal sent to the command's shell alone waits for the doctor, so its clone of the standard is never removed while shape-doctor.py runs from it, and a doctor ended by a signal leaves nothing (#680) - #740

Merged
brettheap merged 1 commit into
mainfrom
l3-680-doctor-signals
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #680 (comment)

Closes #680.

What changed and why

The bash launcher openRepoShape, run_doctor. Installed, --doctor clones the standard into $TMPDIR/openRepoShape-doctor.XXXXXXXX, and workdir arms an EXIT trap that removes it (openRepoShape:201). The trap is disarmed once the doctor has run (#500). A TERM or HUP sent to the shell alone ended the shell at once. The EXIT trap then removed the clone while the doctor, which nobody had signalled, was still running from it.

  • The shell outlives the doctor child (openRepoShape:697-716). INT, TERM and HUP are trapped around the doctor, and each trap only records the signal (caught, a local of run_doctor, :667). bash runs a trap for a foreground command only once that command has ended, so the EXIT trap cannot run while the doctor is alive.
  • How the doctor ended decides what is left:
    • A doctor that ran to its end keeps and names its clone, as before.
    • A doctor ended by a signal (a status over 128) leaves nothing (:766, :792-795). This holds whether or not the shell was sent the signal too.
  • The shell then ends by the signal it was sent (:783-799, kill -"$caught" "$$"). A calling shell reads 128 + the signal's number in $?.
  • An INT counts only where the doctor was ended by one too (:776-782). This is bash's own rule for SIGINT, and what this shell already did before INT was trapped: an INT sent to the shell alone lets the doctor finish, and the shell exits with the doctor's status.
  • The comment the issue names (now :729-738) is rewritten, and the RAN TO ITS END wording carries into the next paragraph (:749).

Design (a), and why

The doctor stays in the foreground, and the trap only records the signal. I did not use design (b), which runs the doctor in the background, forwards the signal and calls wait:

  • A non-interactive bash starts a background job with SIGINT and SIGQUIT ignored. The doctor would then raise no KeyboardInterrupt on Ctrl-C.
  • Every git and gh the doctor runs would inherit that ignored SIGINT. A Ctrl-C reaches the whole foreground process group, and they would ignore it. Forwarding TERM reaches only the doctor, never what it started.
  • Under (a), a Ctrl-C or a terminal hang-up reaches the doctor and everything it started, as it always did.
  • The cost of (a) is that a TERM to the shell alone waits for the doctor to finish. Before this PR that doctor ran to its end anyway, orphaned and over a deleted standard.

The exit: a death by the signal, which a shell reads as 128 + N

The issue says "Exit 128 + the signal number". Every calling shell reads exactly that in $?, measured below. I did this by re-raising the signal (kill -N $$ after trap -), not with exit $((128 + N)). The reason is measured, not argued:

  • A bash loop running a command that traps INT and then does exit 130 goes on to its next turn at a Ctrl-C: after 1 status 130 | after 2 status 0 | after 3 status 0. With the re-raise, the loop stops: outer rc -2.
  • bash's rule is that a command which EXITED 130, rather than being ended by SIGINT, handled the Ctrl-C itself.
  • Main's launcher dies by the signal in the three "stated behaviour" rows (-2/-15/-1). An exit 130 would have been a regression for every for … do openRepoShape --doctor …; done.

The tests pin the death by the signal (returncode == -N). The mutant that uses exit 128+N fails five of them.

Measured: the issue's table, before (main de92614) and after (af75e37)

These measurements use the installed launcher, one file with nothing beside it, over a stub standard whose shape-doctor.py records its pid and blocks for 10 s. Signals go by recorded PID. The first five rows are the issue's; the last four are added.

Before, main de92614:

signal sent to clone 0.6 s later child 0.6 s later bash exit the child at its end "kept at" line $TMPDIR after
TERM bash alone gone running -15 its own file gone no empty
HUP bash alone gone running -1 its own file gone no empty
INT bash and child gone ended -2 killed no empty
TERM bash and child gone ended -15 killed no empty
HUP bash and child gone ended -1 killed no empty
INT bash alone exists running 0 its own file present yes clone kept
INT child alone exists ended 130 killed yes clone kept
TERM child alone exists ended 143 killed yes clone kept
HUP child alone exists ended 129 killed yes clone kept

After, af75e37:

signal sent to clone 0.6 s later child 0.6 s later bash exit the child at its end "kept at" line $TMPDIR after
TERM bash alone exists running -15 its own file present yes clone kept
HUP bash alone exists running -1 its own file present yes clone kept
INT bash and child gone ended -2 killed no empty
TERM bash and child gone ended -15 killed no empty
HUP bash and child gone ended -1 killed no empty
INT bash alone exists running 0 its own file present yes clone kept
INT child alone gone ended 130 killed no empty
TERM child alone gone ended 143 killed no empty
HUP child alone gone ended 129 killed no empty

What a calling sh -c 'bash openRepoShape --doctor …; echo $?' reads after this change: TERM to the shell and the doctor gives 143, INT gives 130, HUP gives 129; TERM to the shell alone gives 143, HUP gives 129.

Tests

All new tests are in tests/test_shape_doctor.py's installed-doctor section. They are POSIX only (WINDOWS_SKIP), like the tests around them.

  • test_a_doctor_ended_by_a_signal_leaves_nothing (:2884) covers INT, TERM and HUP sent to the shell and the doctor, plus TERM sent to the doctor alone. In each case there is no clone in $TMPDIR, no "kept at" line, and the doctor never reached its end. The exit is -N when the shell was signalled, and 128 + N when only the doctor was.
  • test_a_signal_to_the_installed_doctors_shell_alone_waits_for_it (:2917) covers TERM, HUP and INT sent to the shell alone:
    • 0.5 s later the shell is still running and the clone's shape-doctor.py is there;
    • once let go, the doctor finds its own file at its end and its line is printed;
    • the clone is kept and named on the last line;
    • the exit is -15, -1, or the doctor's 0 for INT.
  • The harness for both:
    • HELD_DOCTOR (:2789) records its pid and its parent's. The test asserts that the parent is the shell it started, and signals only those two PIDs.
    • The stub waits for a release file, at most 60 s. The fixture held_run (:2848) lets it go at teardown, so a failed test leaves nothing running and needs no kill.
    • DEFAULT_SIGNALS (:2809) resets INT, TERM and HUP before exec bash, so a pytest run under nohup or as a background job cannot hand the shell an ignored signal it could neither trap nor reset.
  • test_a_plain_repositorys_next_lines_outlive_the_installed_doctor (:2743, L8) runs a git repository with an origin through run_installed:
    • every path a NEXT names under the kept clone exists after the command exits;
    • the adoption line and the machine row's setup-project.py --preflight run as pasted, with no can't open file;
    • the setup.sh --org <your-org> line is checked for its path only.
  • Shared helpers. The drifted-project test (:2697) now uses next_lines, named_in_the_clone and run_once_it_has_exited along with the new test, rather than a second copy of them. run_installed takes its environment from installed_env, which the new tests use too.

Mutation checks. Each mutant of the launcher fails at least one new test:

mutant new tests that fail
main's launcher 3
exit 128+N in place of the re-raise 5
keep the clone whatever the status 4
no rm before the re-raise 3
EXIT trap disarmed before the doctor 1
no INT rule 1
no traps 2
clone never kept (pre-#500) the plain-repository test

Runs. All runs used a stub gh exiting 97 first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR, and every pytest command took /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch> -o tmp_path_retention_policy=failed:

tests bytes run against result
tests/test_shape_doctor.py the final launcher; sha256sum -c OK for both files after the run 413 passed, 3 skipped in 783.57s (0:13:03)
the plain-repository test, re-run after one assertion was added to it (PLAIN_NEXTS_RUN <= ran), with its section via -k "installed or shim or ended_by_a_signal or plain_repositorys" final 26 passed, 390 deselected in 39.94s
tests/test_openreposhape_command.py tests/test_install_sh.py tests/test_repo_hygiene.py tests/test_cli_reference.py the final launcher 243 passed in 104.86s (0:01:44)
tests/test_repo_hygiene.py tests/test_cli_reference.py the final test file 99 passed in 47.90s

Git Bash. tests-git-bash's test_the_doctor_checks_its_clone_out_at_the_ref_in_force reaches run_doctor with a stub doctor and no signal. On that path the traps are set and reset and caught stays empty, so the run is unchanged. It passed here on Linux bash 5.2. It was not run under Git Bash.

Docs

None changed. README.md:1151, AGENTS.md:220 and docs/handbook.html:1568 say that the installed doctor keeps its clone for the NEXT lines. That still holds for every doctor that ran to its end, and none of them states the interrupted case. --help and docs/cli.md are unchanged.

Pinned copies

None touched. The launcher is not a pinned shape copy: it is not in FAMILY_COPIED_VERBATIM and is not under templates/.

Lands after

#656, which is already on main as 35133f2. The branch is cut from de92614. Main has since gained 30676c9 (#634), which touches neither file, and git merge-tree with it is clean.

🤖 Generated with Claude Code

Summary by Sourcery

Correct installed doctor signal handling so the shell waits safely, interrupted runs clean up, and completed runs retain usable clones.

Bug Fixes:

  • Ensure the installed doctor’s temporary clone remains available while the doctor is running, even when the launcher shell receives a signal alone.
  • Remove the temporary clone when the doctor is terminated by a signal, while preserving and reporting clones from doctors that finish normally.
  • Propagate signals by terminating the launcher with the received signal so callers observe the expected signal-based status.

Enhancements:

  • Expand installed-doctor coverage for signal handling, clone lifetime, interrupted runs, and executable NEXT report paths.

Tests:

  • Add POSIX tests covering signals sent to the launcher, the doctor, or both, including cleanup and exit-status behavior.
  • Verify plain repository NEXT commands remain usable after the doctor exits and its clone is kept.

…its for the doctor, so its clone of the standard is never removed while `shape-doctor.py` runs from it, and a doctor ended by a signal leaves nothing (#680)

Installed, `--doctor` clones the standard into $TMPDIR and arms an EXIT
trap that removes it, disarmed once the doctor has run (#500). A TERM or
HUP sent to the shell alone ended it at once: the EXIT trap removed the
clone while the doctor, which nobody had signalled, still ran from it, and
the "kept at" line was never printed.

`run_doctor` now traps INT, TERM and HUP around the doctor, which stays in
the foreground (design (a) of the issue), and each trap only records the
signal. bash runs a trap for a foreground command once that command has
ended, so the EXIT trap cannot run while the doctor is alive. Then:
- a doctor that ran to its end keeps and names its clone, as before;
- a doctor ended by a signal (a status over 128) leaves nothing, whether
  or not the shell was sent the signal too;
- a shell sent TERM or HUP, or an INT the doctor was ended by too, ends
  by that signal once the above is done, so a calling shell reads 128 + its
  number in `$?` and a shell loop running the command stops at a Ctrl-C.
  An INT the doctor outlived is the doctor's business, as bash's own rule
  for SIGINT has it, and the shell exits with the doctor's status.

tests/test_shape_doctor.py: a held stub doctor, started in a session of
its own and signalled by recorded PID. INT, TERM and HUP sent to the shell
and the doctor leave nothing, and so does TERM sent to the doctor alone;
TERM, HUP and INT sent to the shell alone wait for the doctor, whose clone
is there while it runs and is kept and named at its end. A plain
repository's NEXT lines are run from the installed form after it has
exited too, with helpers the drifted project's test now shares.

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

sourcery-ai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes installed --doctor lifecycle and signal handling by deferring shell signal handling until the foreground doctor finishes, preserving completed clones while removing clones from signal-terminated doctors, and re-raising shell-directed signals with the expected status semantics. Comprehensive POSIX tests cover cleanup, signal propagation, exit codes, and post-exit NEXT command usability.

File-Level Changes

Change Details Files
Changed installed doctor signal handling so the launcher remains alive through the foreground doctor run, preventing cleanup of the clone while the doctor is still using it.
  • Trap INT, TERM, and HUP around the doctor and defer handling until the foreground command ends.
  • Preserve normal signal propagation to the doctor and its subprocesses.
  • Re-raise a signal received by the shell after cleanup so callers observe signal-based termination semantics.
openRepoShape
Defined distinct cleanup behavior for completed and signal-terminated doctor runs.
  • Keep and report the clone when the doctor reaches its end, including after a shell-only TERM or HUP.
  • Remove the clone and suppress the kept-clone output when the doctor exits due to INT, TERM, or HUP.
  • Retain the existing SIGINT behavior when only the shell is interrupted and the doctor completes normally.
openRepoShape
Expanded installed-doctor regression coverage for interrupted runs, retained NEXT commands, and process lifecycle behavior.
  • Add POSIX tests for shell-only and doctor-directed INT, TERM, and HUP, including clone state, output, doctor completion, and exit codes.
  • Use a held stub doctor plus PID recording, signal-default reset, isolated sessions, and teardown release handling to make signal tests deterministic.
  • Verify NEXT paths from plain repositories remain available and executable after the launcher exits, and extract shared helpers for these assertions.
tests/test_shape_doctor.py

Assessment against linked issues

Issue Objective Addressed Explanation
#680 Ensure signals sent to the launcher shell alone do not remove the standard clone while the doctor is running, while runs where the doctor is terminated by a signal clean up the clone and propagate the expected signal status. ✅
#680 Add POSIX tests that pin the interrupted-run behavior, including shell-and-doctor signals and signals sent to the shell alone. ✅
#680 Test that NEXT commands from an installed doctor run on a plain repository continue to reference usable files in the retained clone. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-16T23:08:46Z) Lane 3's independent review of #740 at af75e374 (RV-d, ruling 8): READY. There is no MUST and no SHOULD, only three NITs, none of which blocks. Every measured row matches the PR body's tables. CI on the head is green (tests, SonarCloud, package and tests-git-bash).

Measured: the launcher alone in a directory over a held stub doctor, main against the head. Every signal went to a PID RV-d started.

case main head
TERM or HUP to the shell alone clone gone while the doctor runs clone kept and named; exit -15 / -1 (fixed)
INT, TERM or HUP to shell and doctor; Ctrl-C to the group nothing left same
INT to the shell alone clone kept, exit 0 same
INT, TERM or HUP to the doctor alone clone kept nothing left, same exits (the announced change)
Ctrl-C in a calling loop, or in $(...) -2 -2
set -e caller, or sh reading $? after TERM 143 143
a second TERM while held dies at once waits, then -15 with the clone kept
a doctor that lingers 2 s after TERM clone gone while it runs clone removed only when it ends

Re-raising instead of exit 128+N is right. Every calling shell still reads 128 + N, the -N main gave is kept, and a calling loop still stops at Ctrl-C. exit $((128 + caught)) at :798 covers a shell that does not die.

NITs (optional):

  • N1, openRepoShape:714-716: PIPE, USR1 or ALRM sent to the shell alone still remove the clone. That is the same on main, and nothing normally sends these to the shell alone. One sentence in the :697 comment would say that only INT, TERM and HUP are held.
  • N2, :714-717: a Ctrl-C in the microseconds between the traps and the doctor's start is swallowed (measured with a busy-wait put there). A [ -z "$caught" ] || … check before the doctor line would close the gap.
  • N3, :766: a doctor that exits 129-255 on its own is read as signalled. shape-doctor.py exits only 0-3, so this cannot happen in practice.

The harness: it leaks nothing, and nothing was left running. The stub ends itself after 60 s, and teardown waits at most 120 s. RV-d found no load-sensitive assertion and had no flake at load averages of 25-85.

Mutants: 10 of 12 launcher mutants are killed:

  • main's launcher;
  • exit 128+N;
  • keeping the clone at any status;
  • no rm before the re-raise;
  • no INT rule, or the INT rule at any status;
  • no traps, or traps never reset;
  • HUP recorded as TERM;
  • the EXIT trap disarmed early.

The two survivors change nothing a run can see.

Tests on the committed merge with main 66b230ed (stub gh): test_shape_doctor.py and test_openreposhape_command.py give 533 passed, 3 skipped, 1 xfailed (#735).

Merges:

Docs and pinned copies: README:1151, AGENTS.md:220 and the handbook's line 1568 stay true, and the launcher is not a pinned copy.

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_shape_doctor.py" line_range="2915" />
<code_context>
+@pytest.mark.parametrize(("name", "ends_by_it"), [
+    pytest.param("SIGTERM", True, id="TERM"),
+    pytest.param("SIGHUP", True, id="HUP"),
+    pytest.param("SIGINT", False, id="INT"),
+])
+def test_a_signal_to_the_installed_doctors_shell_alone_waits_for_it(
</code_context>
<issue_to_address>
**Normal exit 130 masks shell SIGINT**

When the doctor exits normally with status 130 after only the shell receives SIGINT, `run_doctor` treats `STATUS == 130` as proof that the doctor was ended by SIGINT and clears `caught`; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.

Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.
</issue_to_address>

### Comment 2
<location path="tests/test_shape_doctor.py" line_range="2768" />
<code_context>
+        tools = {path.name for path in named_in_the_clone(line, shape)}
+        named |= tools
+        if placeholder not in line:
+            run_once_it_has_exited(line, tmp_path)
+            ran |= tools
+    assert PLAIN_NEXTS_NAMED <= named, named
</code_context>
<issue_to_address>
**Failed commands count as successful**

When a named NEXT command exits nonzero without printing “can't open file”, `run_once_it_has_exited` checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to `ran`, so the test passes despite the command failing.

Assert the expected return status and result from `run_once_it_has_exited` before adding the tool names to `ran`.

Also at `tests/test_shape_doctor.py:2690-2691`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the signal handling is wrong, the doctor could lose its cloned standard while still using it, or a temporary clone could be left behind after an interrupted run. That clone is bounded and can be removed manually; reverting restores the previous signal behavior but does not clean up a clone already left behind.

Blocking findings: tests/test_shape_doctor.py:2915, tests/test_shape_doctor.py:2768


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@pytest.mark.parametrize(("name", "ends_by_it"), [
pytest.param("SIGTERM", True, id="TERM"),
pytest.param("SIGHUP", True, id="HUP"),
pytest.param("SIGINT", False, id="INT"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Normal exit 130 masks shell SIGINT

When the doctor exits normally with status 130 after only the shell receives SIGINT, run_doctor treats STATUS == 130 as proof that the doctor was ended by SIGINT and clears caught; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.

Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.

Prompt for AI agents
In `tests/test_shape_doctor.py` at line 2915:

**Normal exit 130 masks shell SIGINT**

When the doctor exits normally with status 130 after only the shell receives SIGINT, `run_doctor` treats `STATUS == 130` as proof that the doctor was ended by SIGINT and clears `caught`; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.

Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.

tools = {path.name for path in named_in_the_clone(line, shape)}
named |= tools
if placeholder not in line:
run_once_it_has_exited(line, tmp_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Failed commands count as successful

When a named NEXT command exits nonzero without printing “can't open file”, run_once_it_has_exited checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to ran, so the test passes despite the command failing.

Assert the expected return status and result from run_once_it_has_exited before adding the tool names to ran.

Also at tests/test_shape_doctor.py:2690-2691.

Prompt for AI agents
In `tests/test_shape_doctor.py` at line 2768:

**Failed commands count as successful**

When a named NEXT command exits nonzero without printing “can't open file”, `run_once_it_has_exited` checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to `ran`, so the test passes despite the command failing.

Assert the expected return status and result from `run_once_it_has_exited` before adding the tool names to `ran`.

Also at `tests/test_shape_doctor.py:2690-2691`.

brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained 25 pull requests since de92614: #634, #699, #671, #719,
#701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718,
#744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict:
AGENTS.md, adopt-project.py and docs/cli.md merged as text, and
README.md and docs/handbook.html are unchanged by the merge. README.md
is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is
current; `render-cli-reference.py --check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… verification row's colon, `run_validator`'s "costs no child an import" and the decoy test's `sitecustomize` claim say what main does (#666) (#730)

Four comments and docstrings said something main no longer does. They now say what the code beneath them does. A fix round after review added two more of the same kind (see "Fix round"). Comments and docstrings only: no behaviour, no `--help` text, no pinned copy, so no README, handbook or `docs/cli.md` change.

- **`adopt-project.py`, the module docstring.** It said the verification table reads `code: 0 of N source paths (seeded from template)`. The printed row has no colon (`  {leg:<6} {count:>5} of {N} source paths{note}`). It now says the table's `code` row reads `0 of N source paths (seeded from template)`, the words `AGENTS.md` and `README.md` use since #505 and the docstring of the verification function uses too. `git grep 'code: 0 of'` finds nothing now.
- **`shape-doctor.py`, `submodule_config`.** The last sentence of the docstring gave a reason that is true of the string and not decisive: "It is also what the row PRINTS, and a lone surrogate is a string no UTF-8 stream can write." The table goes through `ascii_text` and `--json` through `json.dumps`, so neither would raise on a lone surrogate. I dropped the sentence, the issue's first direction. The comparison with `origin_url`'s reading of a clone's remote is the reason, and the paragraph already gives it first. **This hunk carries the bytes of lane 3's #700**, which landed as `55c6ba7d`: the two removed lines and the one added line (`    working clone.`) are the same. So the hunk is on main already and drops out of the squash (see "Evidence").
- **`shape-doctor.py`, `run_validator`.** "It costs no child an import" holds for an unedited copy only. A new paragraph says a root whose own copy of a validator gained an `import yaml` read `passes` while `site` was on, and now reads `crashed before reaching a verdict (exit 1): ModuleNotFoundError: No module named 'yaml'`, the crash its own CI gets, because `validate.yml` installs nothing. It calls that row a fair answer and says `-S` does not ask whether a copy was edited. No code change.
- **`tests/test_shape_doctor.py`, the decoy test's two comments** (the block above `DECOY_REPO_SHAPE` and the inline comment before `reached`). `run_validator` starts every child as `[sys.executable, "-S", script, ...]`, so no child imports a `sitecustomize`. The comments now say that, say that replace-not-prepend and the #306 review S1 case are held by `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, and say that the decoy test holds the template row's real verdict under an exported `PYTHONPATH`.

The review of the first two commits approved with nits. Two new commits take S1 and N1: `7f2bb36b` makes both edits, and `426877bf` rewrites the last sentence of the N1 comment, which did not parse ("...a future removal of `-S`, which `test_run_validator_starts_every_child_with_no_site` ... is the test that holds"). It now ends "...a future removal of `-S`; `test_run_validator_starts_every_child_with_no_site` (in `tests/test_doctor_validator_site.py`) is the test that holds `-S`." Both are comments and docstrings only.

- **S1, a third stale sentence.** The docstring of `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` said that for the callers which pass no `pythonpath`, "only a probe like this one, or (b)'s decoy, can see that they have none". That is false under `-S`: a `run_validator` that keeps the variable for those callers passes the decoy test in all three cases and fails the probe test twice (the second row of the table below). It now says only a probe like this one can see that they have none, and that the decoy of (b) could before `-S` (#342). It is the same defect as the issue's, in a place the issue did not name. "Could" holds for the `manifest` and `pins` cases: with `-S` removed, 2 of the 3 decoy cases fail, and the `family` case never caught it.
- **N1, the inline comment overstated the loop.** The comment above `reached` said "What this loop still holds is that no row quotes the decoy or says it exited 86." Under `-S` the loop cannot fire. It now says so, says the loop is kept as a tripwire for a future removal of `-S`, and names `test_run_validator_starts_every_child_with_no_site` in `tests/test_doctor_validator_site.py` as the test that holds `-S`. That test is on main.
- **N2, this body.** The merge checks and the notes on #630 were stale (they cited `de926142` and a conflict that is gone). They are rerun and rewritten below.

**Only comments and docstrings changed.** `git diff 8062810 426877b` has 74 changed lines (44 added, 30 removed) in three files. A script checked each changed line against the file's comment tokens and docstring spans (both sides of the diff): 0 outside. A second check parsed each file at the base and at the head, blanked every docstring, and compared the ASTs: identical in all three files. For the two fix-round commits together, the AST of `tests/test_shape_doctor.py` with docstrings blanked is identical to the one at `a2113cff`, `git diff --check` is clean, and the longest added line is 75 columns (73 in the second commit).

**What the squash holds.** `git merge-tree --write-tree` of this head with origin/main `20c8873b` gives the tree `6ead2ffc`. `git diff 20c8873 6ead2ffc` is 71 changed lines in three files, and `shape-doctor.py` has 8 of them: the new `run_validator` paragraph and nothing else. `git diff` of the two trees has no hunk that mentions `submodule_config`, `surrogate`, `PRINTS` or `working clone`. On main, `submodule_config`'s docstring has no "PRINTS" sentence and ends in `working clone.`. The same two checks on that tree: 0 lines outside comments and docstrings, ASTs identical. On main `2d988200` with the head `426877bf` the squash is the same: 71 changed lines, 8 of them in `shape-doctor.py`, 0 outside comments and docstrings, ASTs identical, and no hunk that mentions `submodule_config`.

**`python3 scripts/render-cli-reference.py --check`**, at `426877bf`: `[ok] docs/cli.md is what the generator produces`. Only the first line of a module docstring is help text, and none of the first lines changed.

**The decoy comments, measured.** I did not take the issue's reading on trust. In a throwaway clone at the base I put each #306 regression into `run_validator` and ran the two tests (`test_an_exported_pythonpath_does_not_reach_a_template_validator`, three cases, and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, six):

| mutant | with `-S` (main) | with `-S` removed |
|---|---|---|
| the standard's `scripts/` put in FRONT of the operator's value | decoy test: 3 of 3 pass. Probe test: 1 of 6 fails | decoy test: 3 of 3 fail |
| the variable kept for the callers that pass none (S1) | decoy test: 3 of 3 pass. Probe test: 2 of 6 fail | decoy test: 2 of 3 fail |
| the operator's directory AHEAD of the standard's | decoy test: 3 of 3 fail. Probe test: 1 of 6 fails | not run |

So on main the decoy test catches neither #306 regression and the probe test catches both, as lane 4's R342 measured on #636's head. The third row is why the comment says the raising `repo_shape.py` "still bites": it kills an operator-first order. The review re-ran these mutants on `a2113cff` and got the same counts.

**The `run_validator` paragraph, measured.** A root whose own `validate-manifest.py` is `import yaml` plus a print, run through `run_validator` from this tree: exit 1 and `ModuleNotFoundError: No module named 'yaml'`. The same call with `-S` taken out of the child's argv: exit 0, `manifest ok`. `yaml` is importable from the machine's site-packages and not without `site`.

With the real `gh` unreachable (a stub first on PATH, a bogus token, an empty `GH_CONFIG_DIR`).

On `a2113cff`, before the fix round:

- `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_scaffold_leg_path_collisions.py`: 250 passed (57.5 s).
- `tests/test_doctor_gitmodules_not_utf8.py`, `tests/test_doctor_validator_rows.py`, `tests/test_doctor_validator_site.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_submodules.py` (the files that drive `run_validator`, `submodule_config` or the seeded-leg row): 334 passed (28 min 18 s).
- `tests/test_shape_doctor.py`, in two halves run side by side (the first 204 test ids, then the last 204): 204 passed in 11 min 31 s, and 201 passed, 3 skipped in 4 min 58 s. That is all 408 tests. The 3 skips are the PowerShell-line tests, which need `powershell.exe` on a Windows host.
- The review ran all of `tests/test_shape_doctor.py` there: 405 passed, 3 skipped (512 s). On the predicted merge with main `2fac9790` it ran `test_shape_doctor.py`, `test_doctor_validator_site.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_verdicts.py`, `test_doctor_gitmodules_not_utf8.py`, `test_repo_hygiene.py`, `test_cli_reference.py` and `test_agents_md_codes.py`: 632 passed, 3 skipped (760 s).

On `426877bf`, after the fix round, which changes comments and docstrings only, so the runs above stand:

- The two tests the fix round touches, `test_an_exported_pythonpath_does_not_reach_a_template_validator` (3 cases) and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` (6 cases), run by node id: 9 passed (11.7 s). The same 9 also passed (16.1 s) at `7f2bb36b`.
- `tests/test_repo_hygiene.py`: 77 passed (1.9 s). It also passed (2.0 s) at `7f2bb36b`.

No test fails on the base, because nothing here changes behaviour. The AST comparison above is the proof in their place.

`git merge-tree --write-tree` of this head against origin/main and against every ref on origin that could meet it. The full run was on 2026-10-16, with the head `7f2bb36b` (the one before the last comment commit) and main `20c8873b`: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. I ran #731's head `ab55c29f` through the same list. After `426877bf` I re-ran main, now `2d988200`, and the six heads that edit `tests/test_shape_doctor.py`, the one file that commit touches (#705, #737, #740, `l3-669-members-row`, `l3-680-doctor-signals` and `l3-697-crash-cure-next`), and #630: all clean.

- **origin/main: clean.** At `20c8873b`, with the head `7f2bb36b`, the predicted tree is `6ead2ffc`. At `2d988200` (three `adopt-project.py` commits later, with their tests and a change in `scripts/shape_materialize.py`), with the head `426877bf`, it is `7ee5caf4`, and the squash is the same three files (43 added, 28 removed). #731's head `ab55c29f` is clean with `2d988200` too. This head with #731's head: clean.
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change.
- **#700 and #702 have landed** (`55c6ba7d` and `20c8873b`), and so has #701, and #699, #717 and #719 for `adopt-project.py`. Their heads still merge clean with this head.
- **Every ref that edits `shape-doctor.py`, `tests/test_shape_doctor.py` or `adopt-project.py` merges clean with this head**, except the two old heads named in the bullet after next. They include the heads of PRs not on main yet (#705, #721, #737 and #742, and lane 3's `l3-680`, `l3-683`, `l3-687`, `l3-691` and `l3-697`, which were not on origin when this body was first drafted). #673 has no branch or PR head on origin.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change.
- **Two landed pre-squash heads conflict with this head only because they are old.** #632 conflicts with main on its own. #636 merges clean with main, but its old head conflicts with this head in `run_validator`'s docstring, next to the text #636 added; main has that text, and this head merges clean with main.
- **Refs that conflict with main on their own are not made worse.** #682, #745, #747, `fix/648`, `fix/681` and `fix/704` conflict with main in `templates/family-root/scripts/validate-family.py`; #742 and `l3-683` in `shape-doctor.py`. Each merges clean with this head.
- **Every other open head and branch: clean.**

Nothing from the issue. Its four lines are done. The decoy test's `sitecustomize` machinery is dead code under `-S` and I left it, because the issue says comment only. The comment above `reached` now says it is kept as a tripwire.

- **The decoy test's `sitecustomize.py` and exit-86 trap never run.** `DECOY_SITECUSTOMIZE`, `os._exit(86)` and the `(exit 86)` and `DECOY` clauses in `reached` can only fire for a child started without `-S`. They are kept as a tripwire and the comment says so. The other choice is to remove them; `test_run_validator_starts_every_child_with_no_site` already holds `-S`. Small, tests only.

Review: rv666, lane 5's independent review, approved a2113cf with nits; 7f2bb36 and 426877b take its S1 and N1 (above), and codex found no major issue at a2113cf. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 426877b passed tests, tests-git-bash and SonarCloud; the head 32722b3 is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 55d5d3b over main dcf62ed: the shape_doctor, scaffold_leg_path_collisions, repo_hygiene, agents_md_codes, cli_reference sets, 686 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #666

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