From 9808aec9e4d42a74a5a6f2b67b3a98d6fa6d2335 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 11:44:51 +0000 Subject: [PATCH 1/2] openRepoShape --doctor: the installed form keeps the clone of the standard it ran from and names it on the report's last line, so a NEXT line pasted after the command has exited runs instead of dying with `can't open file`, and it hands `--json` and `--placement-plan ` on to `shape-doctor.py` instead of refusing them (#500) Installed, `openRepoShape --doctor` has no checkout beside it, so it clones the standard into `mktemp -d` and runs `shape-doctor.py` from there. The doctor builds its NEXT lines from that clone: `update-shape.py check --upstream `, `adopt-project.py plan`, `setup-project.py --preflight`, the template validators. An EXIT trap then removed the clone as the command returned, so each of those lines, pasted, died with `python3: can't open file`, exit 2. And `--doctor` refused every argument but the path, so `--json` (AGENTS.md doctor step 7) and `--placement-plan ` (the `placement` row's own NEXT, step 4) could not be asked for at all. Brett Heap's ruling, 2026-10-13: "Keep the run's clone". - The EXIT trap is disarmed once the doctor has RUN, and not before. A refusal on the way (the clone failed, the ref did not check out, the ref carries no `shape-doctor.py`) printed no NEXT line and still leaves nothing behind. So does an interrupted run, because bash runs the EXIT trap on INT, TERM and HUP. Beside a checkout nothing is cloned, and nothing is kept or named. - The clone lives in `${TMPDIR:-/tmp}/openRepoShape-doctor.XXXXXXXX`, so a person can find every directory they have been left, and it is resolved with `pwd -P`, because the doctor prints its standard resolved (macOS's `$TMPDIR` sits under `/var` -> `/private/var`). Every step of making it is checked: `--doctor` runs under `set +e`, and an unchecked `cd -- ""` after a failed `mktemp` would have pointed the trap at the person's own directory. - The report's last line names the directory and says it is safe to delete once the person is done with the NEXT commands. Under `--json` that line goes to stderr. stdout is a JSON object a caller parses whole, one more line makes it a document `json.load` refuses, and the object already names the clone as `standard`. Without `--json` it stays on stdout, so a report saved to a file still says what it left behind. The line hands out no `rm` command: an agent told to "run the command the row names" would delete the clone before the commands it holds had run. - `--json` and `--placement-plan ` (or `--placement-plan=`) are the doctor's when they come AFTER `--doctor`, the way a subcommand's flags follow it. Anywhere else they are the lone flags they always were, passed on to setup.sh, which refuses them, so a scaffold run reads those words as before. The file reaches `shape-doctor.py` as `--placement-plan=`, so a name beginning with `-` is still a file. A missing file, an empty one, or a flag where the file goes is refused by name, exit 2. - The doctor runs in the person's own directory and is never `cd`'d, so a relative `` lands where the command was typed: not in the repository, and not in the clone. - A path may follow the flags (`--doctor --json ../Atlas`). A word before `--doctor`, or a second path, is still a `` and is refused as before. The refusal's wording changes: it named "a path and nothing else", and now gives the full form. - `openRepoShape --help`'s `--doctor` paragraph is the text this changes. It now names the two flags and the kept clone. Its verdict list gains `MISPLACED` and `CANNOT ANSWER`, and `COMPLIANT, SHAPE BEHIND` gets its comma back. Its exit codes now say that 3 means the doctor cannot answer and that this command's own refusals exit 2. The old text said "3 usage", which no refusal here exits. `docs/cli.md` is regenerated. Tests (tests/test_shape_doctor.py, shim section; offline, local bare standard, the refusing `gh`/`curl` stubs, `$TMPDIR` the test's own): - the stdin test now asserts the opposite of what it pinned: the clone outlives the run, is the only entry in `$TMPDIR`, is named on the last line, and carries the `openRepoShape-doctor.` prefix; - the installed file, alone in a directory, over a drifted project: every NEXT that names the kept clone runs after the command has exited, as `pasted()` splits it, and the `shape currency` one reproduces `locally-modified scripts/bootstrap.py`; - `--json` from the installed form parses as one object, the kept line is on stderr, and the rows equal the beside-a-checkout `--json` rows with only the standard's path differing; - `--placement-plan` (relative, path-then-flag and `=`-flag-then-path): the plan lands in the directory the command ran from and nowhere else, and the project's tree is unchanged; - six refusals (a scaffold flag, a second path, `--json` before `--doctor`, a missing, empty or flag-shaped plan file), all exit 2, nothing on stdout, nothing cloned, nothing fetched; - tests/test_openreposhape_command.py: a ref with no `shape-doctor.py` is refused and leaves `$TMPDIR` empty. Mutants: with main's `openRepoShape` put back, 12 of these 15 fail. With only `trap - EXIT` removed, the stdin, NEXT-lines and `--json` tests fail. Docs (Brett Heap, 2026-10-13T11:30Z: each lane-4 fix carries its own doc lines). These are reworded in place, at the line caps: - AGENTS.md: the compliance section's installed-form sentence (#505 said the clone is "deleted on exit" and the form "takes a path and nothing else"), and step 7 (`--json` is one object, alone on stdout); - README.md: the doctor paragraph ("writes nothing into the repository and fetches nothing from it"; installed, it KEEPS the clone and its last line names it), `--json` "on stdout", and `--placement-plan` as all `shape-doctor.py` ever writes; - docs/handbook.html mirrors all three and re-hashes `README.md blob`. Closes #500 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 +- README.md | 14 +- docs/cli.md | 23 ++- docs/handbook.html | 25 +-- openRepoShape | 168 +++++++++++++++++--- tests/test_openreposhape_command.py | 14 +- tests/test_shape_doctor.py | 237 +++++++++++++++++++++++++++- 7 files changed, 434 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b94a0a7..c366990a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,9 +215,9 @@ no `--dry-run` to do first. The checks are a REGISTRY with an empty `fix` slot in each of them, which is where a repair mode will hang; nothing in it writes today. The one exception to "writes nothing" is `--placement-plan `, which writes that file and nothing else — see 4. Installed, `openRepoShape --doctor` -runs from a temporary clone of the standard, deleted on exit, and takes a path -and nothing else: to ACT on a row (`--json`, `--placement-plan`, its next -command) run `./shape-doctor.py --root ` from a checkout. +takes `--json` and `--placement-plan ` as well, and KEEPS its temporary +clone of the standard, because the NEXT lines run from it: its last line names +that directory, which is the human's to delete once those commands have run. 1. **Read the verdict, then read the rows.** `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, `NOT A SHAPE ROOT` — exits 0, 1, @@ -263,7 +263,7 @@ command) run `./shape-doctor.py --root ` from a checkout. never changes the verdict: it says whether THIS workstation can run the fixes the other rows named. Relay a missing prerequisite; `openRepoShape --preflight` offers only the `gh` set-up, and the human types that yes. -7. `./shape-doctor.py --json` is the same report as one object, each row +7. `--json` is the same report as one object, alone on stdout, each row carrying its `id`, for when you have to act on a row rather than quote it. ## Updating a project's shape diff --git a/README.md b/README.md index 1417b9ec..6bf3f899 100644 --- a/README.md +++ b/README.md @@ -1109,11 +1109,11 @@ openRepoShape --doctor # the repository you are standing in ./shape-doctor.py --root ../MedxEHR --json ``` -It **writes nothing and fetches nothing**, and it reimplements nothing: every -row is one of the checks this file already documents. The standard a project's -copies are compared against is the checkout the script is RUN FROM — beside -one it uses that one, installed it clones the standard into a temporary -directory first — which is what makes the verdict offline. +It **writes nothing into the repository and fetches nothing from it**, and it +reimplements nothing: each row is a check this file documents. It compares +against the checkout it is RUN FROM, so the verdict is offline. Installed, it +clones the standard into a temporary directory and KEEPS it, because its NEXT +lines run from there: its last line names it, safe to delete after they run. For an assembly root the rows are **naming** (this standard's own `contracts/repository-naming.yaml`), **manifest** and **pins** (the project's @@ -1168,7 +1168,7 @@ next command. `MISPLACED` sits between the two: a path in the wrong leg is a fact about the shape no validator, pin or manifest here can see. **An unfinished merge outranks all three**, as `INVALID (merge)`: the conflict is what turns the other rows red, and no fix commits until it is finished. -`--json` is the same report as one object, each row carrying its `id`. +`--json` is the same report as one object on stdout, each row carrying its `id`. **`placement` is the row that reads what is *inside* the legs.** Brett Heap, 2026-09-10: *"we have to look for code in spec and spec in code"*. It runs the @@ -1209,7 +1209,7 @@ is already split. `adopt-project.py` refuses this kind by name, and the refusal says which tool consumes one. A later `--fix --plan ` will carry a **resolved** plan out by paired leg pull requests and a pin bump, printing what it will do and asking first; until then the moves are yours. `--placement-plan` -is the only thing this command writes, and it writes only the file you name. +is all `shape-doctor.py` ever writes, and it writes only the file you name. **`machine` is the one row that is not about the repository**, so it is `n/a` by status and can never move the verdict: a workstation with no `gh` is not diff --git a/docs/cli.md b/docs/cli.md index 6d8007c9..c22686c2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -155,12 +155,23 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one -`--doctor []` runs `shape-doctor.py` over (default `.`) and prints -one row per check and ONE verdict: COMPLIANT, COMPLIANT SHAPE BEHIND, DRIFTED, -INVALID or NOT A SHAPE ROOT. It writes nothing, and every finding names the -command that fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 -usage. Run beside a checkout of the standard it uses that one; run from -anywhere else it clones the standard into a temporary directory first. +`--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` +over (default `.`) and prints one row per check and ONE verdict: +`COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, +`NOT A SHAPE ROOT` or `CANNOT ANSWER`. Every finding names the command that +fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot +answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot +write); this command's own refusals exit 2. `--json` is the same report as +one JSON object. It writes nothing into the repository: `--placement-plan +` writes that one file (a relative lands in the directory you +ran this from) and moves nothing. + +Run beside a checkout of the standard, it uses that one. Run from anywhere +else, it clones the standard into a temporary directory first and KEEPS it, +because its NEXT lines name files in it. The report's last line names +that directory, which is safe to delete once you are done with those +commands; under `--json` that line goes to stderr, so stdout stays one JSON +object. `--preflight` is the MACHINE check -- it was spelled `--doctor` until 2026-09-10, and the name moved to the repository diagnosis above, which is diff --git a/docs/handbook.html b/docs/handbook.html index ee0b9cc7..309e643f 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1462,12 +1462,12 @@

Is this repository compliant? openRepoShape --doctor

openRepoShape --doctor ../MedxEHR      # or: ./shape-doctor.py --root ../MedxEHR
 openRepoShape --doctor                 # the repository you are standing in
 ./shape-doctor.py --root ../MedxEHR --json
-

It writes nothing into the repository and fetches nothing, and it - reimplements nothing. One flag writes at all, and it writes one file you named: - --placement-plan, below. The - standard a project’s copies are compared against is the checkout the script is RUN FROM - — beside one it uses that one, installed it clones the standard into a temporary - directory first — which is what makes the verdict offline.

+

It writes nothing into the repository and fetches nothing from it, and + it reimplements nothing. One flag writes at all, and it writes one file you named: + --placement-plan, below. It compares against the checkout it is RUN FROM, so + the verdict is offline. Installed, it clones the standard into a temporary directory and + KEEPS it, because its NEXT lines run from there: its last line names it, safe to delete + after they run.

For an assembly root the rows are naming, manifest and pins (the project’s OWN validators when it carries them — they are the pinned copies, which is the point — else the template copies, and the row says @@ -1524,7 +1524,7 @@

Is this repository compliant? openRepoShape --doctor

a red validator names itself in the table whether or not it also names the verdict line. An unfinished merge outranks all three, as INVALID (merge): the conflict is what turns the other rows red, and no fix commits until it is finished. - --json is the same report as one object, + --json is the same report as one object on stdout, each row carrying its id.

placement is the row that reads what is inside the @@ -1565,8 +1565,8 @@

Is this repository compliant? openRepoShape --doctor

tool consumes one. A later --fix --plan <file> will carry a resolved plan out by paired leg pull requests and a pin bump, printing what it will do and asking first; until then the moves are yours. - --placement-plan is the only thing this command writes, and it writes only the - file you name.

+ --placement-plan is all shape-doctor.py ever writes, and it writes + only the file you name.

machine is the one row that is not about the repository, so it is n/a by status and can never move the verdict: a workstation with no gh is not this repository being non-compliant. It runs @@ -1870,9 +1870,10 @@

For AI assistants

above drift, added (#424); the assistant’s route when its shell has no terminal, added (#476); the one-liner asking at the person’s own terminal, never reading the answer from the pipe, added (#492); the statements the v1.0.0 accuracy pass found false, - corrected here as in the README, added (#483) — a page cannot name the commit - that contains it, and the blob below is the fact a test checks. - README.md blob 1417b9ec0235675da8f73ca023756411bc3fbe8a. Every command, flag, exit code + corrected here as in the README, added (#483); the installed doctor keeping its clone of + the standard, which its NEXT lines run from, added (#500) — a page cannot name the + commit that contains it, and the blob below is the fact a test checks. + README.md blob 6bf3f89984e5a8d69785993e4c772ec1518f4e19. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/openRepoShape b/openRepoShape index 0e91ef65..689b8d5c 100755 --- a/openRepoShape +++ b/openRepoShape @@ -36,6 +36,11 @@ ORG="" MODE="run" PREFLIGHT=0 DOCTOR_PATH="" +DOCTOR_PATH_GIVEN=0 +# `shape-doctor.py`'s own two flags, as `--doctor` hands them on (#500). +DOCTOR_ARGS=() +DOCTOR_JSON=0 +PLAN_FILE="" PASSTHROUGH=() WORKDIR="" STAGED="" @@ -89,12 +94,23 @@ opensoft/openRepoTools', and its own installer places them: $OPENREPOSHAPE_BIN_DIR where --install puts it (default ~/.local/bin) $OPENREPOSHAPE_SETUP_SH run this setup.sh on disk instead of fetching one -`--doctor []` runs `shape-doctor.py` over (default `.`) and prints -one row per check and ONE verdict: COMPLIANT, COMPLIANT SHAPE BEHIND, DRIFTED, -INVALID or NOT A SHAPE ROOT. It writes nothing, and every finding names the -command that fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 -usage. Run beside a checkout of the standard it uses that one; run from -anywhere else it clones the standard into a temporary directory first. +`--doctor [] [--json] [--placement-plan ]` runs `shape-doctor.py` +over (default `.`) and prints one row per check and ONE verdict: +`COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, +`NOT A SHAPE ROOT` or `CANNOT ANSWER`. Every finding names the command that +fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot +answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot +write); this command's own refusals exit 2. `--json` is the same report as +one JSON object. It writes nothing into the repository: `--placement-plan +` writes that one file (a relative lands in the directory you +ran this from) and moves nothing. + +Run beside a checkout of the standard, it uses that one. Run from anywhere +else, it clones the standard into a temporary directory first and KEEPS it, +because its NEXT lines name files in it. The report's last line names +that directory, which is safe to delete once you are done with those +commands; under `--json` that line goes to stderr, so stdout stays one JSON +object. `--preflight` is the MACHINE check -- it was spelled `--doctor` until 2026-09-10, and the name moved to the repository diagnosis above, which is @@ -109,9 +125,33 @@ USAGE # instant the substitution closes, so `rm -rf` ran before a single fetched # byte had landed in the directory, and $WORKDIR out here was still empty. # A trap only ever fires in the shell that set it. Callers read $WORKDIR. +# +# A NAME, WHEN ONE IS GIVEN, IS FOR A DIRECTORY THAT OUTLIVES THE RUN (#500). +# `--doctor` keeps its clone of the standard, because the NEXT lines it +# prints name files in there, so the directory carries the name of what +# made it: `ls "$TMPDIR"/openRepoShape-doctor.*` finds every one a person has +# been left, where `tmp.*` would be anybody's. The template form is the one +# GNU and BSD `mktemp` both read. It is RESOLVED, `pwd -P`, because the +# doctor prints its standard resolved, and on macOS `$TMPDIR` sits under the +# `/var` -> `/private/var` link: the report's first and last lines would +# otherwise name one directory two ways. +# +# EVERY STEP OF THAT IS CHECKED, because `--doctor` runs this under `set +e` +# and the trap below removes whatever $WORKDIR holds. A failed `mktemp` +# followed by an unchecked `cd -- ""` would leave `pwd -P` printing the +# directory the person is standing in, and the trap would remove THAT. workdir() { + local base made if [ -z "$WORKDIR" ]; then - WORKDIR="$(mktemp -d)" + if [ -n "${1:-}" ]; then + base="${TMPDIR:-/tmp}" + made="$(mktemp -d "${base%/}/$1.XXXXXXXX")" && + made="$(cd -- "$made" && pwd -P)" && [ -n "$made" ] || + die "could not make a temporary directory under $base. Set \$TMPDIR to a directory you can write to." + WORKDIR="$made" + else + WORKDIR="$(mktemp -d)" + fi trap 'rm -rf -- "$WORKDIR"' EXIT fi } @@ -332,7 +372,7 @@ shape_checkout() { case "$url" in -* | *[![:print:]]*) die "OPENREPOSHAPE_REPO is not a value this tool will put on a \`git clone\` command line: it starts with '-' or carries a control character. Set it to a URL or a path." ;; esac - workdir + workdir openRepoShape-doctor SHAPE_DIR="$WORKDIR/shape" git clone --quiet -- "$url" "$SHAPE_DIR" || die "could not clone $REPO into a temporary directory, so there is no @@ -377,7 +417,7 @@ checked_ref() { # the doctor compares a project's copies against a standard, so it must have # a standard, and a single file in ~/.local/bin is not one. run_doctor() { - local py selfdir="" shape="" + local py selfdir="" shape="" cloned=0 kept py="$(find_python)" || die "no Python 3.9 or newer on this machine (tried python3, python). \`--doctor\` is a Python program: https://www.python.org/downloads/" if [ -f "$SELF" ]; then selfdir="$(cd -- "$(dirname -- "$SELF")" && pwd)" @@ -389,6 +429,7 @@ run_doctor() { die "git is not installed, and with no checkout of the standard beside this command there is nothing to clone one with. Install it: https://git-scm.com/downloads" shape_checkout shape="$SHAPE_DIR" + cloned=1 fi # NAMED, RATHER THAN LEFT TO THE INTERPRETER. A checkout at a ref older # than the doctor — or a fork that does not carry it — produces a @@ -398,11 +439,53 @@ run_doctor() { die "$shape has no $DOCTOR, so there is nothing to run. That checkout is $REPO at $REF. Set \$OPENREPOSHAPE_REF to a ref that carries it, or run this from a checkout of the standard." # RUN, never `exec`: the EXIT trap that removes a cloned checkout belongs - # to THIS process, and exec would replace the shell that owes it. + # to THIS process, and exec would replace the shell that owes it -- and + # the line below, which names a kept clone, is this shell's to print. + # + # IN THE PERSON'S OWN DIRECTORY, never `cd`'d anywhere: that is what + # resolves a relative `--root` and a relative `--placement-plan ` + # against where they typed the command, exactly as `./shape-doctor.py` + # would from a checkout, and never against the clone (#500). set +e - "$py" "$shape/$DOCTOR" --root "${DOCTOR_PATH:-.}" + "$py" "$shape/$DOCTOR" --root "${DOCTOR_PATH:-.}" \ + ${DOCTOR_ARGS[@]+"${DOCTOR_ARGS[@]}"} STATUS=$? set -e + # THE CLONE IS KEPT, AND SAID SO LAST (#500; Brett Heap, 2026-10-13: + # "Keep the run's clone"). The NEXT lines the doctor prints name files in + # the standard it ran from -- `update-shape.py check --upstream + # `, `adopt-project.py plan`, `setup-project.py --preflight`, + # the template validators -- and the EXIT trap used to remove that + # standard as this command returned, so each one, pasted, died with + # `can't open file`. A fix that names a file nobody has is no fix. + # + # DISARMED HERE, ONCE THE DOCTOR HAS RUN, AND NOT BEFORE. A refusal on + # the way here (the clone failed, the ref did not check out, the ref + # carries no doctor) printed no NEXT line, and a run interrupted mid-way + # (bash runs the EXIT trap on INT, TERM and HUP too) printed no report; + # both leave nothing behind, as before. Beside a checkout nothing was + # cloned, so there is nothing to keep or to name. + # + # ON STDERR UNDER `--json`, on stdout otherwise. The JSON object on + # stdout is a contract a caller parses whole, and one line after it is a + # document `json.load` refuses; that object already names the clone, as + # `standard`, and its rows' `next` commands name files in it. A person at a + # terminal reads stderr as well, which is where this command's REFUSED + # lines already go. Without `--json` the line is the report's own last + # line, so a report saved to a file still says what it left behind. + # + # IT NAMES THE DIRECTORY AND HANDS OUT NO `rm`. Every other line here + # that reads like a command is one to run; an agent told to run what the + # report names would delete the clone before the commands it holds ran. + if [ "$cloned" -eq 1 ]; then + trap - EXIT + kept="the standard this report ran from is kept at $WORKDIR, because NEXT commands name files in it; it is safe to delete once you are done with those commands." + if [ "$DOCTOR_JSON" -eq 1 ]; then + say "$kept" >&2 + else + say "$kept" + fi + fi return "$STATUS" } @@ -420,15 +503,51 @@ while [ $# -gt 0 ]; do # `--doctor []`: the REPOSITORY diagnosis. The optional path is # eaten here rather than passed on, because it is a positional and # would otherwise be taken for the a scaffold run names. A - # word beginning with a dash is NOT the path -- it is somebody's flag, - # and the refusal below tells them this mode takes none. + # word beginning with a dash is NOT the path -- it is a flag, and the + # two below are the only ones this mode takes. --doctor) set_mode doctor; shift if [ $# -gt 0 ]; then case "$1" in -*) ;; - *) DOCTOR_PATH="$1"; shift ;; + *) DOCTOR_PATH="$1"; DOCTOR_PATH_GIVEN=1; shift ;; + esac + fi + ;; + # `shape-doctor.py`'s TWO FLAGS, HANDED ON (#500). AGENTS.md's doctor + # steps use both -- `--json` to act on a row by its id, `--placement-plan + # ` as the `placement` row's own NEXT -- and the installed form + # refused them, so a person who held no checkout could ask for neither. + # + # THEY ARE THE DOCTOR'S ONLY AFTER `--doctor`, the way a subcommand's + # flags follow the subcommand. Typed anywhere else they are what they + # always were, a lone flag passed on to setup.sh, which refuses both as + # unknown: a scaffold run reads these words exactly as it did before. + # + # `--placement-plan=` IS WHAT REACHES THE DOCTOR, whichever way it + # was typed, so a beginning with `-` is still a file to argparse. + # The separate word is refused when it begins with `-`, as argparse + # refuses it, so `--placement-plan --json` is not a plan written to a + # file called `--json`; the `=` form is the way to name such a file. + --json | --placement-plan | --placement-plan=*) + if [ "$MODE" != "doctor" ]; then + PASSTHROUGH+=("$1"); shift + elif [ "$1" = "--json" ]; then + DOCTOR_ARGS+=(--json); DOCTOR_JSON=1; shift + else + case "$1" in + --placement-plan=*) PLAN_FILE="${1#--placement-plan=}"; shift ;; + *) + PLAN_FILE="${2-}"; shift + case "$PLAN_FILE" in + -*) PLAN_FILE="" ;; + *) [ $# -eq 0 ] || shift ;; + esac + ;; esac + [ -n "$PLAN_FILE" ] || + die "--placement-plan needs the file to write the plan to: --placement-plan , or --placement-plan= for a name that begins with '-'." + DOCTOR_ARGS+=("--placement-plan=$PLAN_FILE") fi ;; # NAMED, THOUGH `-*` BELOW WOULD ALREADY PASS IT ON. The case exists @@ -455,19 +574,28 @@ while [ $# -gt 0 ]; do --) shift; PASSTHROUGH+=(-- "$@"); break ;; -*) PASSTHROUGH+=("$1"); shift ;; *) + # AFTER `--doctor` AND ITS FLAGS, THE ONE PATH IT HAS NOT YET HAD: + # `--doctor --json ../Atlas` is the same run as `--doctor ../Atlas + # --json` (#500). A word before `--doctor`, or a second path, is + # still a , and the doctor refuses it below as before. + if [ "$MODE" = "doctor" ] && [ "$DOCTOR_PATH_GIVEN" -eq 0 ]; then + DOCTOR_PATH="$1"; DOCTOR_PATH_GIVEN=1; shift; continue + fi [ -z "$PROJECT" ] || die "two project names, '$PROJECT' and '$1'. One positional , then flags." PROJECT="$1"; shift ;; esac done if [ "$MODE" = "doctor" ]; then - # ONE PATH AND NOTHING ELSE. Every other argument this command knows is - # about a run that CREATES something, and silently ignoring one typed - # alongside `--doctor` would answer a question nobody asked. `--json` - # is `shape-doctor.py`'s and reaches it from a checkout, deliberately - # not plumbed through here while the mode is one path wide. + # ONE PATH AND THE DOCTOR'S TWO FLAGS, NOTHING ELSE. Every other + # argument this command knows is about a run that CREATES something, and + # silently ignoring one typed alongside `--doctor` would answer a + # question nobody asked. `--json` and `--placement-plan` were refused + # here too until #500, while the mode was one path wide; they are + # `shape-doctor.py`'s own, and are handed on above. [ -z "$PROJECT" ] && [ -z "$ORG" ] && [ ${#PASSTHROUGH[@]} -eq 0 ] || - die "--doctor takes a path and nothing else; it reads a repository and writes nothing. + die "--doctor takes a path, --json and --placement-plan , and nothing else; it reads a repository and writes nothing into it. + openRepoShape --doctor [] [--json] [--placement-plan ] To scaffold, drop --doctor. To check this machine, run: openRepoShape --preflight" set +e run_doctor diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index ada65bf1..0c931546 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -624,7 +624,14 @@ def test_a_ref_the_doctor_would_check_out_is_validated(ref, tmp_path): def test_a_checkout_without_the_doctor_refuses_by_name(tmp_path): """A ref older than `shape-doctor.py` produced `can't open file` from python3 and a stack of nothing useful. The ref is the thing to change, - so the refusal says so.""" + so the refusal says so. + + AND THE CLONE IT REFUSED IS GONE. `--doctor` keeps its clone once the + doctor has run, because the NEXT lines name files in it (#500); a + refusal before that printed none, so nothing is left in `$TMPDIR`. + """ + temporary = tmp_path / "tmp" + temporary.mkdir() bare = tmp_path / "old.git" subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(bare)], capture_output=True, check=True) @@ -644,10 +651,13 @@ def test_a_checkout_without_the_doctor_refuses_by_name(tmp_path): capture_output=True, text=True, check=False, input=COMMAND.read_text(encoding="utf-8"), cwd=str(tmp_path), env=command_env(home=tmp_path, setup_sh=None, - env={"OPENREPOSHAPE_REPO": str(bare)})) + env={"OPENREPOSHAPE_REPO": str(bare), + "TMPDIR": str(temporary)})) assert result.returncode == 2, result.stdout + result.stderr assert "has no shape-doctor.py" in result.stderr assert "OPENREPOSHAPE_REF" in result.stderr + assert list(temporary.iterdir()) == [], ( + "a refused clone outlived the run; only a doctor that RAN keeps one") # --- --preflight ------------------------------------------------------------ diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 3b60097e..e6d5c2a1 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -2069,9 +2069,58 @@ def test_the_shim_runs_the_script_beside_it_and_fetches_nothing( assert "COMPLIANT (exit 0)" in result.stdout assert f"standard {standard}" in result.stdout, ( "the checkout beside the file is the standard it must use") + assert KEPT not in result.stdout + result.stderr, ( + "nothing was cloned beside a checkout, so there is nothing to keep " + "or to name (#500)") fetched_nothing(tmp_path, doctor_ran=True) +#: The sentence the installed doctor ends on when it has kept its clone +#: (#500), cut where the directory's name begins and where it ends. +KEPT = "the standard this report ran from is kept at " +KEPT_TAIL = (", because NEXT commands name files in it; it is safe to delete " + "once you are done with those commands.") + + +def kept_by(text: str) -> Path: + """The directory the installed doctor's LAST line says it kept. + + The last non-empty line and no other: Brett Heap's ruling on #500 puts + the sentence there, so a report saved to a file still ends by saying what + it left behind. + """ + last = [line for line in text.splitlines() if line.strip()][-1] + assert last.startswith(KEPT) and last.endswith(KEPT_TAIL), last + return Path(last[len(KEPT):-len(KEPT_TAIL)]) + + +def installed(tmp_path) -> Path: + """`openRepoShape` alone in a directory, as `--install` places it: one + file, no checkout beside it, so `--doctor` has to clone the standard.""" + target = tmp_path / "bin" / "openRepoShape" + target.parent.mkdir(exist_ok=True) + shutil.copy2(REPO / "openRepoShape", target) + return target + + +def run_installed(standard: Path, tmp_path, *args, + cwd: Path | None = None) -> subprocess.CompletedProcess: + """The installed command, cloning `standard` from a local PATH. + + `$TMPDIR` is a directory of the test's own, both because the clone now + OUTLIVES the run (#500) and must not outlive it in the machine's `/tmp`, + and because it is what shows what the run left behind. + """ + temporary = tmp_path / "tmp" + temporary.mkdir(exist_ok=True) + return subprocess.run( + ["bash", str(installed(tmp_path)), *args], + capture_output=True, text=True, check=False, input="", + cwd=str(cwd or tmp_path), + env=shim_env(tmp_path, OPENREPOSHAPE_REPO=str(standard), + TMPDIR=str(temporary))) + + @WINDOWS_SKIP @pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") def test_the_shim_from_stdin_clones_the_standard_first(standard, project, @@ -2083,13 +2132,22 @@ def test_the_shim_from_stdin_clones_the_standard_first(standard, project, uses for self-bootstrap — so the clone is real and the network is not touched. The standard it reports must be the temporary clone and not the directory the project lives in. + + AND THE CLONE OUTLIVES THE RUN (#500). This asserted the opposite until + Brett Heap's ruling, "Keep the run's clone" (2026-10-13): an EXIT trap + removed it, so every NEXT line naming a file in it failed when pasted. It + is kept, it is the one thing left in `$TMPDIR`, and the report's last + line names it. """ + temporary = tmp_path / "tmp" + temporary.mkdir() result = subprocess.run( ["bash", "-s", "--", "--doctor", str(project)], capture_output=True, text=True, check=False, input=(REPO / "openRepoShape").read_text(encoding="utf-8"), cwd=str(tmp_path), - env=shim_env(tmp_path, OPENREPOSHAPE_REPO=str(standard))) + env=shim_env(tmp_path, OPENREPOSHAPE_REPO=str(standard), + TMPDIR=str(temporary))) assert result.returncode == 0, result.stdout + result.stderr assert "COMPLIANT (exit 0)" in result.stdout [reported] = [line.split(None, 1)[1].strip() @@ -2098,9 +2156,179 @@ def test_the_shim_from_stdin_clones_the_standard_first(standard, project, assert reported != str(standard), ( "with no checkout beside it the shim must clone one, not reach for " "the directory the project happens to sit next to") - assert not Path(reported).exists(), ( - f"{reported} outlived the run; the EXIT trap did not fire") + assert (Path(reported) / DOCTOR).is_file(), ( + f"{reported} did not outlive the run, and every NEXT line names a " + "file in it (#500)") + kept = kept_by(result.stdout) + assert Path(reported).parent == kept, (reported, kept) + assert [entry.resolve() for entry in temporary.iterdir()] == \ + [kept.resolve()], "the kept clone is the one thing the run left" + assert kept.name.startswith("openRepoShape-doctor."), ( + "a kept directory carries the name of what made it, so a person can " + f"find every one they have been left: {kept}") + fetched_nothing(tmp_path, doctor_ran=True) + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +def test_the_installed_doctors_next_lines_run_after_it_has_exited( + standard, project, tmp_path): + """#500: every NEXT the installed form printed named a file in a clone + its EXIT trap had already removed, so each one, pasted, died with + `python3: can't open file`, exit 2 -- the `shape currency` row's + `update-shape.py check --upstream ` included. + + The installed form here is the one file `--install` places, with nothing + beside it, run over a project with one edited shape copy. Every NEXT + that names a file in the kept clone is run AFTER the command has exited, + as the reader's shell splits it, and the `shape currency` one must + reproduce the finding it was printed for. + """ + edited = project / DRIFT_TARGET + edited.write_text(edited.read_text(encoding="utf-8") + + "\n# a local edit nobody carried upstream\n", + encoding="utf-8") + result = run_installed(standard, tmp_path, "--doctor", str(project)) + assert result.returncode == 1, result.stdout + result.stderr + assert "DRIFTED (1 locally-modified) (exit 1)" in result.stdout, \ + result.stdout fetched_nothing(tmp_path, doctor_ran=True) + kept = kept_by(result.stdout) + shape = kept / "shape" + nexts = [line.split("NEXT", 1)[1].strip() + for line in result.stdout.splitlines() + if line.lstrip().startswith("NEXT ")] + from_the_clone = [line for line in nexts if str(shape) in line] + assert any("update-shape.py" in line for line in from_the_clone), nexts + for line in from_the_clone: + argv = pasted(line) + named = [Path(arg) for arg in argv if arg.startswith(str(shape))] + for path in named: + assert path.exists(), f"{line}\nnames {path}, which is gone" + proc = subprocess.run(argv, capture_output=True, text=True, + check=False, stdin=subprocess.DEVNULL, + start_new_session=True, cwd=str(tmp_path), + env={**shim_env(tmp_path), + "PYTHONDONTWRITEBYTECODE": "1"}) + assert "can't open file" not in proc.stdout + proc.stderr, ( + line + "\n" + proc.stdout + proc.stderr) + if "update-shape.py" in argv[1]: + assert proc.returncode == 1, line + "\n" + proc.stdout \ + + proc.stderr + assert re.search(rf"locally-modified\s+{re.escape(DRIFT_TARGET)}", + proc.stdout), proc.stdout + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +def test_the_installed_doctor_hands_on_json_and_keeps_stdout_one_object( + standard, project, tmp_path): + """`--json` reaches `shape-doctor.py` from the installed form (#500), + which refused it outright -- AGENTS.md's doctor step 7 is "`--json` ... + for when you have to act on a row". + + STDOUT IS THE JSON AND NOTHING ELSE. The line naming the kept clone + goes to stderr under `--json`, because one line after the object makes + a document `json.load` refuses; the object names the clone already, as + `standard`. And the rows are the rows a checkout's `--json` gives, with + only the standard's path differing. + """ + result = run_installed(standard, tmp_path, "--doctor", str(project), + "--json") + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert payload["verdict"] == "COMPLIANT", payload["verdict"] + kept = kept_by(result.stderr) + assert KEPT not in result.stdout + shape = Path(payload["standard"]) + assert shape.parent == kept and (shape / DOCTOR).is_file(), (shape, kept) + beside = subprocess.run( + ["bash", str(standard / "openRepoShape"), "--doctor", str(project), + "--json"], + capture_output=True, text=True, check=False, input="", + env=shim_env(tmp_path)) + assert beside.returncode == 0, beside.stdout + beside.stderr + assert KEPT not in beside.stdout + beside.stderr + checkout = json.loads(beside.stdout) + assert Path(checkout["standard"]) == standard.resolve() + rows = json.loads(result.stdout.replace(str(shape), checkout["standard"])) + assert rows["rows"] == checkout["rows"] + assert beyond_the_machine_row(network_calls(tmp_path)) == [] + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +@pytest.mark.parametrize("args", [ + pytest.param(("--doctor", "{project}", "--placement-plan", "plan.yaml"), + id="path-then-flag"), + pytest.param(("--doctor", "--placement-plan=plan.yaml", "{project}"), + id="flag-then-path"), +]) +def test_the_installed_doctors_placement_plan_lands_where_you_stand( + args, standard, project, tmp_path): + """`--placement-plan ` reaches `shape-doctor.py` from the installed + form (#500) -- it is the `placement` row's own NEXT, which the installed + form refused -- and a RELATIVE `` lands in the directory the + command was run from: not in the repository, and not in the kept clone. + + The plan is the only thing written, as AGENTS.md's doctor section says; + the project's tree is compared file by file before and after. + """ + here = tmp_path / "here" + here.mkdir() + + def snapshot(): + return sorted( + (path.relative_to(project).as_posix(), path.stat().st_size) + for path in project.rglob("*") if path.is_file()) + + before = snapshot() + result = run_installed( + standard, tmp_path, + *[arg.format(project=project) for arg in args], cwd=here) + assert result.returncode == 0, result.stdout + result.stderr + assert [entry.name for entry in here.iterdir()] == ["plan.yaml"] + assert "kind: placement-plan" in (here / "plan.yaml").read_text( + encoding="utf-8") + assert "placement plan written to plan.yaml" in result.stdout + assert snapshot() == before + assert not (kept_by(result.stdout) / "shape" / "plan.yaml").exists() + + +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +@pytest.mark.parametrize("args", [ + pytest.param(("--doctor", "--org", "Foo"), id="a-scaffold-flag"), + pytest.param(("--doctor", ".", "Atlas"), id="a-second-path"), + pytest.param(("--json", "--doctor", "."), id="json-before-doctor"), + pytest.param(("--doctor", ".", "--placement-plan"), id="plan-no-file"), + pytest.param(("--doctor", ".", "--placement-plan="), id="plan-empty"), + pytest.param(("--doctor", ".", "--placement-plan", "--json"), + id="plan-a-flag"), +]) +def test_the_installed_doctor_refuses_before_it_clones(args, standard, + tmp_path): + """What `--doctor` does not take is still refused by name, exit 2, + before anything is cloned (#500 hands on two flags, and only those). + + A scaffold flag, a second path, or `--json` typed BEFORE `--doctor` -- + the doctor's flags follow it, as a subcommand's do, and anywhere else + they are the lone flags a scaffold run passes on. `--placement-plan` + with no file, an empty one, or a flag where the file goes is refused + rather than handed to argparse or written to a file called `--json`. + Nothing reaches stdout, `$TMPDIR` stays empty, and nothing is fetched. + """ + result = run_installed(standard, tmp_path, *args) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "" + assert "REFUSED: " in result.stderr + if "--placement-plan" in " ".join(args): + assert "--placement-plan needs the file" in result.stderr + else: + assert "--doctor takes a path, --json and --placement-plan , " \ + "and nothing else" in result.stderr + assert list((tmp_path / "tmp").iterdir()) == [] + fetched_nothing(tmp_path, doctor_ran=False) @WINDOWS_SKIP @@ -2111,7 +2339,8 @@ def test_the_shim_doctor_takes_a_path_and_nothing_else(standard, tmp_path): capture_output=True, text=True, check=False, input="", env=shim_env(tmp_path)) assert result.returncode == 2, result.stdout + result.stderr - assert "--doctor takes a path and nothing else" in result.stderr + assert "--doctor takes a path, --json and --placement-plan , and " \ + "nothing else" in result.stderr assert "openRepoShape --preflight" in result.stderr, ( "the refusal must name the machine check, because `--doctor` meant " "that until 2026-09-10") From d8d917e8e0f72bacce3215cfad8ccf42afba2880 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 14:07:50 +0000 Subject: [PATCH 2/2] openRepoShape --doctor: the kept clone's last line no longer claims NEXT commands when the doctor refused and printed none, the `--help` synopsis shows the doctor's full form, and `--help` no longer says it writes nothing into the repository while exempting a file that can be there (#500) The review of 9808aec found one SHOULD and two NITs in what this pull request put in front of a reader. SHOULD-1. The line the installed doctor ends on said "because NEXT commands name files in it". The clone is kept whenever the doctor RAN, Brett Heap's ruling, and the doctor can refuse with exit 3 and no report: a missing ``, a `--placement-plan` it cannot write. Then stdout was that line alone, and it named NEXT commands there were none of. It now reads "the standard this run used is kept at , and any NEXT command above runs from it; it is safe to delete once you are done with them.", true with any number of NEXT lines above it, or none. When the clone is kept does not change, and the line still hands out no `rm`. `KEPT` and `KEPT_TAIL` in tests/test_shape_doctor.py carry the new text, and a new test runs the refusal: a `` that is not there, exit 3, one line on stdout, the clone it names present and the only thing left in `$TMPDIR`. NIT-1. The four-line synopsis at the top of `--help` still read `openRepoShape --doctor []` while the paragraph below it and the refusal give `[] [--json] [--placement-plan ]`. The line shows the full form. It has no description beside it, as the `` and `--help | --version` lines have none, because the form is wider than the column the other descriptions start in and a second line would make "those four lines" five; the paragraph below it says what the doctor does. USAGE_LINES in tests/test_openreposhape_command.py follows. NIT-5. `--help` said "It writes nothing into the repository" and then named `--placement-plan ` as the one file, which a relative path run from inside the repository writes into it. It now says the doctor writes no file but the `--placement-plan ` you name, at the path you give, and that a relative one lands in the directory you ran this from, even inside the repository. The paragraph keeps its three lines. docs/cli.md is re-rendered with scripts/render-cli-reference.py inside this worktree: four lines in, four out. README.md (1422 lines), AGENTS.md (490) and docs/handbook.html are untouched, so the `README.md blob` the footer names still holds. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- docs/cli.md | 8 ++++---- openRepoShape | 18 +++++++++++----- tests/test_openreposhape_command.py | 2 +- tests/test_shape_doctor.py | 32 ++++++++++++++++++++++++++--- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c22686c2..11730eaa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -122,7 +122,7 @@ Anything after `--` is passed straight through to scaffold-project.py. ``` openRepoShape --install install (or update) this command into ~/.local/bin openRepoShape --preflight check this MACHINE and stop; creates nothing -openRepoShape --doctor [] diagnose a REPOSITORY against this standard +openRepoShape --doctor [] [--json] [--placement-plan ] openRepoShape [--org ] [setup-project.py options] [-- ] openRepoShape --help | --version @@ -162,9 +162,9 @@ over (default `.`) and prints one row per check and ONE verdict: fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot write); this command's own refusals exit 2. `--json` is the same report as -one JSON object. It writes nothing into the repository: `--placement-plan -` writes that one file (a relative lands in the directory you -ran this from) and moves nothing. +one JSON object. The doctor writes no file but the `--placement-plan ` +you name, at the path you give (a relative lands in the directory you +ran this from, even inside the repository), and moves nothing. Run beside a checkout of the standard, it uses that one. Run from anywhere else, it clones the standard into a temporary directory first and KEEPS it, diff --git a/openRepoShape b/openRepoShape index 689b8d5c..58bbc3f2 100755 --- a/openRepoShape +++ b/openRepoShape @@ -61,7 +61,7 @@ usage() { cat <<'USAGE' openRepoShape --install install (or update) this command into ~/.local/bin openRepoShape --preflight check this MACHINE and stop; creates nothing -openRepoShape --doctor [] diagnose a REPOSITORY against this standard +openRepoShape --doctor [] [--json] [--placement-plan ] openRepoShape [--org ] [setup-project.py options] [-- ] openRepoShape --help | --version @@ -101,9 +101,9 @@ over (default `.`) and prints one row per check and ONE verdict: fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot write); this command's own refusals exit 2. `--json` is the same report as -one JSON object. It writes nothing into the repository: `--placement-plan -` writes that one file (a relative lands in the directory you -ran this from) and moves nothing. +one JSON object. The doctor writes no file but the `--placement-plan ` +you name, at the path you give (a relative lands in the directory you +ran this from, even inside the repository), and moves nothing. Run beside a checkout of the standard, it uses that one. Run from anywhere else, it clones the standard into a temporary directory first and KEEPS it, @@ -474,12 +474,20 @@ run_doctor() { # lines already go. Without `--json` the line is the report's own last # line, so a report saved to a file still says what it left behind. # + # IT SAYS "ANY NEXT COMMAND ABOVE", NEVER "BECAUSE NEXT COMMANDS NAME + # FILES IN IT". The clone is kept whenever the doctor RAN, and the doctor + # can refuse (exit 3: a missing , a `--placement-plan` it cannot + # write) with no report and no NEXT line at all; the first wording said + # there were NEXT commands where there were none (#500, review of + # 9808aec, SHOULD-1). Whether the clone is kept does not change, only + # what the line claims about it. + # # IT NAMES THE DIRECTORY AND HANDS OUT NO `rm`. Every other line here # that reads like a command is one to run; an agent told to run what the # report names would delete the clone before the commands it holds ran. if [ "$cloned" -eq 1 ]; then trap - EXIT - kept="the standard this report ran from is kept at $WORKDIR, because NEXT commands name files in it; it is safe to delete once you are done with those commands." + kept="the standard this run used is kept at $WORKDIR, and any NEXT command above runs from it; it is safe to delete once you are done with them." if [ "$DOCTOR_JSON" -eq 1 ]; then say "$kept" >&2 else diff --git a/tests/test_openreposhape_command.py b/tests/test_openreposhape_command.py index 0c931546..d6b94f82 100644 --- a/tests/test_openreposhape_command.py +++ b/tests/test_openreposhape_command.py @@ -63,7 +63,7 @@ USAGE_LINES = ( "openRepoShape --install install (or update) this command into ~/.local/bin", "openRepoShape --preflight check this MACHINE and stop; creates nothing", - "openRepoShape --doctor [] diagnose a REPOSITORY against this standard", + "openRepoShape --doctor [] [--json] [--placement-plan ]", "openRepoShape [--org ] [setup-project.py options] [-- ]", "openRepoShape --help | --version", ) diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py index 11fe0093..02506475 100644 --- a/tests/test_shape_doctor.py +++ b/tests/test_shape_doctor.py @@ -2077,9 +2077,9 @@ def test_the_shim_runs_the_script_beside_it_and_fetches_nothing( #: The sentence the installed doctor ends on when it has kept its clone #: (#500), cut where the directory's name begins and where it ends. -KEPT = "the standard this report ran from is kept at " -KEPT_TAIL = (", because NEXT commands name files in it; it is safe to delete " - "once you are done with those commands.") +KEPT = "the standard this run used is kept at " +KEPT_TAIL = (", and any NEXT command above runs from it; it is safe to delete " + "once you are done with them.") def kept_by(text: str) -> Path: @@ -2295,6 +2295,32 @@ def snapshot(): assert not (kept_by(result.stdout) / "shape" / "plan.yaml").exists() +@WINDOWS_SKIP +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +def test_a_refused_doctor_still_names_its_kept_clone_without_next_lines( + standard, tmp_path): + """THE CLONE IS KEPT WHENEVER THE DOCTOR RAN, so the last line cannot say + there are NEXT commands (#500, review of 9808aec, SHOULD-1). + + A `` that is not there is refused by `shape-doctor.py` itself, exit + 3, with no report and so no NEXT line. The clone is kept all the same -- + Brett Heap's ruling keeps it once the doctor has run -- and the one line + on stdout says where, as true of this run as of one that printed a dozen + NEXT lines: "any NEXT command above" is empty here. + """ + result = run_installed(standard, tmp_path, "--doctor", + str(tmp_path / "nonexistent")) + assert result.returncode == 3, result.stdout + result.stderr + assert "shape-doctor-root-missing" in result.stderr + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(lines) == 1, result.stdout + kept = kept_by(result.stdout) + assert (kept / "shape" / DOCTOR).is_file(), ( + f"{kept} is named as kept and is not there") + assert [entry.resolve() for entry in (tmp_path / "tmp").iterdir()] == \ + [kept.resolve()] + + @WINDOWS_SKIP @pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") @pytest.mark.parametrize("args", [