From 06dffdb294bafddd23cd2b5363d4e4d26b36106c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:31:02 +0000 Subject: [PATCH 1/5] fix(root): stop the _common.sh root search climbing out of a multi-app project (#420) Tier 2 and tier 3 accepted a model only at / or /app/. A multi-app folder (apps//X.mpr) matched neither, so tier 3 walked up from $PWD and took the first ancestor holding any .mpr (or fell back to the toolkit), and look-ledger rows landed in another folder's ledger. - project-bin/_common.sh: _mxtk_has_model probes apps/*/ as a third layout; mxtk_walk_root stops at the first folder that holds a model or is a project folder (.claude/ other than $HOME's, or an installed bin/_common.sh). find_mpr finds a single apps// model and refuses to guess between several, naming MPR_FILE=apps//.mpr. - bin/check-requirements.sh: its private root-only copy of the walk is replaced by mxtk_walk_root, so --fix no longer installs tests/e2e into a stray ancestor. - tests/wave2/test-common-root-multiapp.sh: 20 cases over the three layouts with a stray ancestor .mpr, nested multi-app folders, a model-less project and check-requirements; 12 fail on master. Single-tree and two-tree resolution are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + bin/check-requirements.sh | 18 ++-- project-bin/_common.sh | 63 ++++++++++--- tests/wave2/test-common-root-multiapp.sh | 113 +++++++++++++++++++++++ 4 files changed, 174 insertions(+), 21 deletions(-) create mode 100755 tests/wave2/test-common-root-multiapp.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index edafc87e..bd687569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding `.claude/` (never `$HOME`'s) or an installed `bin/_common.sh`. `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, a `$PWD` inside one app's folder still resolves to that app, and `$HOME`'s `.claude/` is not a stop. New `tests/wave2/test-common-root-multiapp.sh`: 20 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project and `check-requirements.sh`; 12 fail on master. — MendixMau ## v2026.10.19 - new(progress-report): **the progress report gains a Project pages section: a compact folder tree of the HTML pages found in the project's output folders (gate dashboard, analysis reports, blueprint, design system, process map, wireframes, UI reviews, test reports), each a relative link from `architecture/` with its modified date.** Pages are found by scanning the project's files on every render, never from a stored list; only the top-level folders in `PAGE_DIRS` are scanned (each named with its producer; a symlinked one is skipped), `node_modules`, `.mpr-snapshots`, `deployment`, `mprcontents`, `theme`, `.git`, vendor and dot folders are never entered, a folder with more than 8 pages folds behind a count, every folder keeps its row and lists at most 100 links (`+K more`), and the report never lists itself. Dates are shown as days, so the section is unchanged on the same day unless a listed page changed and a `--refresh` leaves the page alone. Test: `tests/wave2/test-progress-report-pages.sh` (single and two-tree layouts, Windows hrefs) — MendixMau (request 2026-10-19) diff --git a/bin/check-requirements.sh b/bin/check-requirements.sh index 1d806e30..2e3f2449 100755 --- a/bin/check-requirements.sh +++ b/bin/check-requirements.sh @@ -6,7 +6,7 @@ # # WHY MOST THINGS DO NOT NEED INSTALLING # Skills are read in place from $MXTK_ROOT and never copied. Shell helpers resolve through -# _common.sh tier 3 (walk up from $PWD for the .mpr) plus verify-module.sh's _tool search +# _common.sh tier 3 (walk up from $PWD for the .mpr, stopping at the project folder) plus verify-module.sh's _tool search # (project bin -> script dir -> toolkit), so the shared copy works from inside any wired # project. That leaves exactly one thing that must physically exist in the repo: the # tests/e2e engine, because node resolves require() paths relative to the file. @@ -26,14 +26,14 @@ for a in "$@"; do case "$a" in esac; done # --- where are we, and where is the toolkit? ------------------------------------------ -ROOT="" -d=$(pwd) -while [ "$d" != "/" ]; do - if ls "$d"/*.mpr >/dev/null 2>&1; then ROOT="$d"; break; fi - d=$(dirname "$d") -done -if [ -z "$ROOT" ]; then - echo "✗ no .mpr found walking up from $(pwd) — not inside a Mendix project" >&2 +# The same walk _common.sh tier 3 makes (mxtk_walk_root): a model at the root, under app/ or +# under apps//, and never past the project's own folder. This loop used to be a private copy +# that probed the root only, so on a two-tree or multi-app project it climbed to the first +# ancestor holding any .mpr, and --fix installed tests/e2e into that folder (#420). +. "$(cd "$(dirname "$0")/.." && pwd)/project-bin/_common.sh" +ROOT="$(mxtk_walk_root "$(pwd)")" || ROOT="" +if [ -z "$ROOT" ] || ! _mxtk_has_model "$ROOT"; then + echo "✗ no .mpr found walking up from $(pwd)${ROOT:+ (stopped at the project folder $ROOT)} — not inside a Mendix project" >&2 exit 2 fi diff --git a/project-bin/_common.sh b/project-bin/_common.sh index 1fbf4991..da341f7d 100755 --- a/project-bin/_common.sh +++ b/project-bin/_common.sh @@ -16,7 +16,8 @@ # 1. $PROJECT_ROOT, if the caller set it — always wins. # 2. The sourcing script's location (bin/ -> ..), when that parent IS a project. # This is the installed-copy case: /bin/verify-module.sh. -# 3. The current directory's project root, found by walking up for an .mpr. +# 3. The current directory's project root, found by walking up for an .mpr (at the root, +# under app/, or under apps//) and never past the first project folder (#420). # # Tier 3 is why this is not a one-liner any more. The toolkit's own copy of these # scripts used to resolve tier 2 to the TOOLKIT, so running @@ -26,24 +27,47 @@ # obvious thing, that it was aimed at the wrong directory. Copying the script into # every project was the workaround. Tier 3 removes the need for it: the shared # toolkit copy now works from inside any wired project, no env var, no install. +# +# Where a model may sit under a project root — the three layouts, probed one level each, the +# same probe find_mpr makes: /*.mpr (single tree), /app/*.mpr (two-tree), and +# /apps//*.mpr (multi-app folder, one app per subfolder). +_mxtk_has_model() { + ls "$1"/*.mpr >/dev/null 2>&1 || ls "$1"/app/*.mpr >/dev/null 2>&1 || ls "$1"/apps/*/*.mpr >/dev/null 2>&1 +} +# A folder that is a project whether or not a model was found in it: it holds the project's +# .claude/ (not $HOME's — that is Claude Code's user config, not a project) or an installed +# bin/_common.sh. The toolkit's own copy lives in project-bin/, so the toolkit is not one. +_mxtk_is_project_dir() { + [ -f "$1/bin/_common.sh" ] && return 0 + [ -d "$1/.claude" ] && [ "$1" != "${HOME:-/nonexistent}" ] +} +# mxtk_walk_root — walk up from ; print the first folder that holds a model (any of +# the three layouts) or is a project folder, whichever comes first. rc 1 when neither is found +# below /. The marker stop is what keeps the walk inside the project (#420): a multi-app folder +# (apps//X.mpr) failed the root/app probe, the walk climbed on, and the first ancestor with +# ANY .mpr became PROJECT_ROOT, so look-ledger rows landed in another folder's ledger. +mxtk_walk_root() { + local d="$1" + while [ -n "$d" ] && [ "$d" != "/" ]; do + if _mxtk_has_model "$d" || _mxtk_is_project_dir "$d"; then printf '%s\n' "$d"; return 0; fi + d=$(dirname "$d") + done + return 1 +} _mxtk_resolve_root() { - local self_parent d + local self_parent self_parent=$(cd "$(dirname "${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}}")/.." 2>/dev/null && pwd) - # tier 2: the script sits in a real project's bin/ (model at the root, or under app/ on a - # two-tree checkout — the same one-level probe find_mpr makes) - if [ -n "$self_parent" ] && { ls "$self_parent"/*.mpr >/dev/null 2>&1 || ls "$self_parent"/app/*.mpr >/dev/null 2>&1; }; then + # tier 2: the script sits in a real project's bin/ (a model in any of the three layouts) + if [ -n "$self_parent" ] && _mxtk_has_model "$self_parent"; then printf '%s\n' "$self_parent"; return 0 fi - # tier 3: walk up from $PWD looking for the .mpr (root or app/). + # tier 3: walk up from $PWD (mxtk_walk_root). # WHY app/ (2026-09-26, card-disbursement requirements-driven build): on a two-tree checkout # with no root .mpr, tiers 2 and 3 used to find nothing AT the project and kept climbing — and # the first stray .mpr in any ancestor directory became PROJECT_ROOT. A scratch copy under a # folder holding one unrelated .mpr pointed model-stamp.sh at that file and its hook passed. - d=$(pwd) - while [ "$d" != "/" ]; do - if ls "$d"/*.mpr >/dev/null 2>&1 || ls "$d"/app/*.mpr >/dev/null 2>&1; then printf '%s\n' "$d"; return 0; fi - d=$(dirname "$d") - done + # WHY apps/*/ and the project-folder stop (#420): the same climb, for a multi-app folder. + mxtk_walk_root "$(pwd)" && return 0 # nothing found: keep the old behaviour so the caller's own error is the one seen printf '%s\n' "${self_parent:-$(pwd)}" } @@ -135,6 +159,16 @@ find_mpr() { found="$f" done fi + # Multi-app folder: one app per apps// (#420). The root resolver accepts this layout, so + # the model lookup must too; with more than one app it refuses to guess, like any other pair. + local multi=0 + if [ "$count" -eq 0 ] && [ -d "$PROJECT_ROOT/apps" ]; then + for f in "$PROJECT_ROOT"/apps/*/*.mpr; do + [ -e "$f" ] || continue + count=$((count + 1)); multi=1 + found="$f" + done + fi if [ "$count" -eq 0 ]; then echo "ERROR: no .mpr found in $PROJECT_ROOT" >&2 @@ -143,7 +177,12 @@ find_mpr() { if [ "$count" -gt 1 ]; then echo "ERROR: $count .mpr files in $PROJECT_ROOT — refusing to guess which model to write." >&2 for f in "$PROJECT_ROOT"/*.mpr; do [ -e "$f" ] && echo " $(basename "$f")" >&2; done - echo "Set MPR_FILE=.mpr to choose." >&2 + if [ "$multi" = 1 ]; then + for f in "$PROJECT_ROOT"/apps/*/*.mpr; do [ -e "$f" ] && echo " ${f#"$PROJECT_ROOT"/}" >&2; done + echo "Set MPR_FILE=apps//.mpr (relative to $PROJECT_ROOT) to choose." >&2 + else + echo "Set MPR_FILE=.mpr to choose." >&2 + fi return 1 fi echo "$found" diff --git a/tests/wave2/test-common-root-multiapp.sh b/tests/wave2/test-common-root-multiapp.sh new file mode 100755 index 00000000..00ef3f0a --- /dev/null +++ b/tests/wave2/test-common-root-multiapp.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# test-common-root-multiapp.sh — _common.sh's root search stays inside the project (#420). +# +# usage: bash tests/wave2/test-common-root-multiapp.sh project-bin/_common.sh +# +# The defect: tier 2 and tier 3 of the root search accepted a model only at / or +# /app/. A multi-app project (apps//X.mpr) matched neither, so tier 3 kept walking up +# from $PWD and the first ancestor holding ANY .mpr became PROJECT_ROOT; with none above, it fell +# back to the toolkit. look-ledger `seen` rows then landed in that other folder's ledger. +# The fix probes apps/*/ as a third layout and stops the walk at the first project folder (one +# holding .claude/ — not $HOME's — or an installed bin/_common.sh). +# +# Every case runs in its own `env -i` shell with PROJECT_ROOT unset, from a chosen $PWD, through +# a probe script that sources _common.sh the way the instruments do — either an installed copy +# (/bin/) or the shared toolkit copy (/project-bin/). A stray .mpr sits in an +# ancestor of every project, so a walk that climbs past the project is caught, not lucky. +# +# R1 multi-app under a stray .mpr: installed copy, toolkit copy from the root and from docs/ +# R2 multi-app, nothing above: the project, not the toolkit fallback +# R3 single tree and R4 two-tree (app/): unchanged — the root, from a subfolder too +# R5 nested multi-app folders: the inner project from inside it, the outer one from between +# R6 a project folder with no model yet: the walk stops there; find_mpr fails naming it +# R7 $HOME's .claude/ is not a project folder: the walk goes on past it (old behaviour) +# R8 PWD inside one app's folder: that app's folder (state follows the model, as before) +# R9 find_mpr: one app under apps/ found; two refused with both listed; MPR_FILE picks one +# R10 bin/check-requirements.sh (same walk): multi-app root named; a model-less project exits 2 +set -u + +COMMON="${1:?usage: $0 project-bin/_common.sh}" +COMMON="$(cd "$(dirname "$COMMON")" && pwd)/$(basename "$COMMON")" +TK_SRC="$(cd "$(dirname "$COMMON")/.." && pwd)" +T="$(mktemp -d "${TMPDIR:-/tmp}/mxtk-root-multiapp.XXXXXX")" +T="$(cd "$T" && pwd)" +trap 'rm -rf "$T"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS + 1)); echo " ok $*"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL $*"; } +eq() { if [ "$2" = "$3" ]; then ok "$1"; else fail "$1 (want ${3#$T/}, got ${2#$T/})"; fi; } + +PROBE='. "$(dirname "$0")/_common.sh"; printf "%s\n" "$PROJECT_ROOT"' +# The shared toolkit copy, in a folder of its own beside the projects. +mkdir -p "$T/tk/project-bin"; cp "$COMMON" "$T/tk/project-bin/_common.sh" +printf '#!/usr/bin/env bash\n%s\n' "$PROBE" > "$T/tk/project-bin/probe.sh" +# install — an installed copy, as init-project.sh lays it out +install() { mkdir -p "$1/bin" "$1/.claude"; cp "$COMMON" "$1/bin/_common.sh"; cp "$T/tk/project-bin/probe.sh" "$1/bin/probe.sh"; } +# root [HOME] — the PROJECT_ROOT that script resolves from that directory +root() { (cd "$1" && env -i PATH="$PATH" HOME="${3:-$T/nohome}" bash "$2"); } +TKP="$T/tk/project-bin/probe.sh" + +echo "== R1 multi-app under a stray .mpr ==" +S="$T/r1/stray"; P="$S/proj"; mkdir -p "$P/apps/A" "$P/docs"; : > "$S/stray.mpr"; : > "$P/apps/A/A.mpr"; install "$P" +eq "R1a installed copy, PWD=project" "$(root "$P" "$P/bin/probe.sh")" "$P" +eq "R1b toolkit copy, PWD=project" "$(root "$P" "$TKP")" "$P" +eq "R1c toolkit copy, PWD=project/docs" "$(root "$P/docs" "$TKP")" "$P" + +echo "== R2 multi-app, nothing above ==" +P="$T/r2/proj"; mkdir -p "$P/apps/A" "$P/docs"; : > "$P/apps/A/A.mpr"; mkdir -p "$P/.claude" +eq "R2 toolkit copy, PWD=project/docs: the project, not the toolkit" "$(root "$P/docs" "$TKP")" "$P" + +echo "== R3 single tree / R4 two-tree, unchanged ==" +S="$T/r3/stray"; P="$S/proj"; mkdir -p "$P/mdlsource"; : > "$S/stray.mpr"; : > "$P/App.mpr"; install "$P" +eq "R3a single tree, installed copy" "$(root "$P" "$P/bin/probe.sh")" "$P" +eq "R3b single tree, toolkit copy from mdlsource/" "$(root "$P/mdlsource" "$TKP")" "$P" +S="$T/r4/stray"; P="$S/proj"; mkdir -p "$P/app" "$P/docs"; : > "$S/stray.mpr"; : > "$P/app/App.mpr"; install "$P" +eq "R4a two-tree, installed copy" "$(root "$P" "$P/bin/probe.sh")" "$P" +eq "R4b two-tree, toolkit copy from docs/" "$(root "$P/docs" "$TKP")" "$P" +rm -rf "$P/bin" "$P/.claude" +eq "R4c two-tree, unwired (no markers): found by the app/ probe" "$(root "$P/docs" "$TKP")" "$P" + +echo "== R5 nested multi-app folders ==" +O="$T/r5/outer"; I="$O/work/inner" +mkdir -p "$O/apps/X" "$I/apps/A" "$I/docs" "$O/work/notes"; : > "$O/apps/X/X.mpr"; : > "$I/apps/A/A.mpr" +mkdir -p "$O/.claude" "$I/.claude" +eq "R5a from inside the inner project" "$(root "$I/docs" "$TKP")" "$I" +eq "R5b from between the two: the outer one" "$(root "$O/work/notes" "$TKP")" "$O" + +echo "== R6 project folder, no model yet ==" +S="$T/r6/stray"; P="$S/proj"; mkdir -p "$P/docs"; : > "$S/stray.mpr"; install "$P" +eq "R6a walk stops at the project folder" "$(root "$P/docs" "$TKP")" "$P" +out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash -c ". '$T/tk/project-bin/_common.sh'; find_mpr" 2>&1)"; rc=$? +case "$rc:$out" in 1:*"no .mpr found in $P"*) ok "R6b find_mpr fails naming the project, not the stray" ;; *) fail "R6b find_mpr rc=$rc: $out" ;; esac + +echo "== R7 \$HOME's .claude/ is not a project folder ==" +H="$T/r7/home"; mkdir -p "$H/.claude" "$H/scratch/sub"; : > "$T/r7/Above.mpr" +eq "R7 walk passes \$HOME and finds the model above it" "$(root "$H/scratch/sub" "$TKP" "$H")" "$T/r7" + +echo "== R8 PWD inside one app's folder ==" +P="$T/r8/proj"; mkdir -p "$P/apps/A/javasource" "$P/.claude"; : > "$P/apps/A/A.mpr" +eq "R8 apps/A/javasource -> apps/A (the model's own folder)" "$(root "$P/apps/A/javasource" "$TKP")" "$P/apps/A" + +echo "== R9 find_mpr in a multi-app folder ==" +P="$T/r9/proj"; mkdir -p "$P/apps/A" "$P/.claude"; : > "$P/apps/A/A.mpr" +fm() { (cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" ${1:+MPR_FILE="$1"} bash -c ". '$T/tk/project-bin/_common.sh'; find_mpr; d=\$(find_model_dir) && echo \"dir=\$d\"" 2>&1); } +eq "R9a one app: found, model dir is apps/A" "$(fm)" "$P/apps/A/A.mpr +dir=$P/apps/A" +mkdir -p "$P/apps/B"; : > "$P/apps/B/B.mpr" +out="$(fm)" +case "$out" in *"2 .mpr files"*"apps/A/A.mpr"*"apps/B/B.mpr"*"MPR_FILE=apps/"*) ok "R9b two apps: refused, both listed, MPR_FILE named" ;; *) fail "R9b: $out" ;; esac +eq "R9c MPR_FILE=apps/B/B.mpr picks B (echoed as given, as before)" "$(fm apps/B/B.mpr | head -1)" "apps/B/B.mpr" + +echo "== R10 bin/check-requirements.sh uses the same walk ==" +CR="$TK_SRC/bin/check-requirements.sh" +S="$T/r10/stray"; P="$S/proj"; mkdir -p "$P/apps/A"; : > "$S/stray.mpr"; : > "$P/apps/A/A.mpr"; mkdir -p "$P/.claude" +out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash "$CR" 2>&1)" +line="$(printf '%s\n' "$out" | grep 'project root' | sed 's/.*project root *//; s/ *$//')" +eq "R10a multi-app root reported, not the stray" "$line" "$P" +rm -f "$P/apps/A/A.mpr" +out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash "$CR" 2>&1)"; rc=$? +case "$rc:$out" in 2:*"stopped at the project folder $P"*) ok "R10b model-less project: exit 2, names where the walk stopped" ;; *) fail "R10b rc=$rc: $out" ;; esac + +echo +echo "root-multiapp: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From 189f76fa546b758e4b44f84f0422af4195a3f46e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:54:58 +0000 Subject: [PATCH 2/5] fix(root): a bare .claude/ is not a project folder (#420) Claude Code creates .claude/ in whatever folder it runs in; a stray /tmp/.claude on the test machine stopped the walk there and broke test-coverage-check-shape.sh's self-parent fallback. A project folder is now an installed bin/_common.sh, or .claude/ beside PROJECT.md (the pair init-project.sh writes). Fixture gains R11 (bare .claude/ above an unmarked project: the old fallback and the model above still apply). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 +- project-bin/_common.sh | 11 ++++++--- tests/wave2/test-common-root-multiapp.sh | 31 ++++++++++++++++-------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd687569..ab313996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding `.claude/` (never `$HOME`'s) or an installed `bin/_common.sh`. `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, a `$PWD` inside one app's folder still resolves to that app, and `$HOME`'s `.claude/` is not a stop. New `tests/wave2/test-common-root-multiapp.sh`: 20 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project and `check-requirements.sh`; 12 fail on master. — MendixMau +- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding an installed `bin/_common.sh`, or a `.claude/` beside a `PROJECT.md` (a bare `.claude/`, which Claude Code creates wherever it runs, does not count, and `$HOME` never does). `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, and a `$PWD` inside one app's folder still resolves to that app. New `tests/wave2/test-common-root-multiapp.sh`: 22 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project, a bare `.claude/` above an unmarked project, and `check-requirements.sh`; 12 fail on master. — MendixMau ## v2026.10.19 - new(progress-report): **the progress report gains a Project pages section: a compact folder tree of the HTML pages found in the project's output folders (gate dashboard, analysis reports, blueprint, design system, process map, wireframes, UI reviews, test reports), each a relative link from `architecture/` with its modified date.** Pages are found by scanning the project's files on every render, never from a stored list; only the top-level folders in `PAGE_DIRS` are scanned (each named with its producer; a symlinked one is skipped), `node_modules`, `.mpr-snapshots`, `deployment`, `mprcontents`, `theme`, `.git`, vendor and dot folders are never entered, a folder with more than 8 pages folds behind a count, every folder keeps its row and lists at most 100 links (`+K more`), and the report never lists itself. Dates are shown as days, so the section is unchanged on the same day unless a listed page changed and a `--refresh` leaves the page alone. Test: `tests/wave2/test-progress-report-pages.sh` (single and two-tree layouts, Windows hrefs) — MendixMau (request 2026-10-19) diff --git a/project-bin/_common.sh b/project-bin/_common.sh index da341f7d..10f65f54 100755 --- a/project-bin/_common.sh +++ b/project-bin/_common.sh @@ -34,12 +34,15 @@ _mxtk_has_model() { ls "$1"/*.mpr >/dev/null 2>&1 || ls "$1"/app/*.mpr >/dev/null 2>&1 || ls "$1"/apps/*/*.mpr >/dev/null 2>&1 } -# A folder that is a project whether or not a model was found in it: it holds the project's -# .claude/ (not $HOME's — that is Claude Code's user config, not a project) or an installed -# bin/_common.sh. The toolkit's own copy lives in project-bin/, so the toolkit is not one. +# A folder that is a project whether or not a model was found in it: it holds an installed +# bin/_common.sh (init-project.sh installs one into every project), or a .claude/ beside a +# PROJECT.md (the wired-project pair init-project.sh writes). A bare .claude/ is NOT enough: +# Claude Code creates one in whatever folder it runs in (a stray /tmp/.claude stopped the walk +# in testing), and $HOME's is the user config. The toolkit's own copy lives in project-bin/ +# and the toolkit has no PROJECT.md, so the toolkit is not a project folder. _mxtk_is_project_dir() { [ -f "$1/bin/_common.sh" ] && return 0 - [ -d "$1/.claude" ] && [ "$1" != "${HOME:-/nonexistent}" ] + [ -d "$1/.claude" ] && [ -f "$1/PROJECT.md" ] && [ "$1" != "${HOME:-/nonexistent}" ] } # mxtk_walk_root — walk up from ; print the first folder that holds a model (any of # the three layouts) or is a project folder, whichever comes first. rc 1 when neither is found diff --git a/tests/wave2/test-common-root-multiapp.sh b/tests/wave2/test-common-root-multiapp.sh index 00ef3f0a..8e1370d9 100755 --- a/tests/wave2/test-common-root-multiapp.sh +++ b/tests/wave2/test-common-root-multiapp.sh @@ -7,8 +7,8 @@ # /app/. A multi-app project (apps//X.mpr) matched neither, so tier 3 kept walking up # from $PWD and the first ancestor holding ANY .mpr became PROJECT_ROOT; with none above, it fell # back to the toolkit. look-ledger `seen` rows then landed in that other folder's ledger. -# The fix probes apps/*/ as a third layout and stops the walk at the first project folder (one -# holding .claude/ — not $HOME's — or an installed bin/_common.sh). +# The fix probes apps/*/ as a third layout and stops the walk at the first project folder (an +# installed bin/_common.sh, or .claude/ beside PROJECT.md — not in $HOME). # # Every case runs in its own `env -i` shell with PROJECT_ROOT unset, from a chosen $PWD, through # a probe script that sources _common.sh the way the instruments do — either an installed copy @@ -24,6 +24,8 @@ # R8 PWD inside one app's folder: that app's folder (state follows the model, as before) # R9 find_mpr: one app under apps/ found; two refused with both listed; MPR_FILE picks one # R10 bin/check-requirements.sh (same walk): multi-app root named; a model-less project exits 2 +# R11 a bare .claude/ (no PROJECT.md) is not a project folder: a stray /tmp/.claude above an +# unmarked project must not capture the walk; the old self-parent fallback still applies set -u COMMON="${1:?usage: $0 project-bin/_common.sh}" @@ -41,8 +43,10 @@ PROBE='. "$(dirname "$0")/_common.sh"; printf "%s\n" "$PROJECT_ROOT"' # The shared toolkit copy, in a folder of its own beside the projects. mkdir -p "$T/tk/project-bin"; cp "$COMMON" "$T/tk/project-bin/_common.sh" printf '#!/usr/bin/env bash\n%s\n' "$PROBE" > "$T/tk/project-bin/probe.sh" +# wire — the .claude/ + PROJECT.md pair init-project.sh writes +wire() { mkdir -p "$1/.claude"; : > "$1/PROJECT.md"; } # install — an installed copy, as init-project.sh lays it out -install() { mkdir -p "$1/bin" "$1/.claude"; cp "$COMMON" "$1/bin/_common.sh"; cp "$T/tk/project-bin/probe.sh" "$1/bin/probe.sh"; } +install() { mkdir -p "$1/bin"; wire "$1"; cp "$COMMON" "$1/bin/_common.sh"; cp "$T/tk/project-bin/probe.sh" "$1/bin/probe.sh"; } # root [HOME] — the PROJECT_ROOT that script resolves from that directory root() { (cd "$1" && env -i PATH="$PATH" HOME="${3:-$T/nohome}" bash "$2"); } TKP="$T/tk/project-bin/probe.sh" @@ -54,7 +58,7 @@ eq "R1b toolkit copy, PWD=project" "$(root "$P" "$TKP")" "$P" eq "R1c toolkit copy, PWD=project/docs" "$(root "$P/docs" "$TKP")" "$P" echo "== R2 multi-app, nothing above ==" -P="$T/r2/proj"; mkdir -p "$P/apps/A" "$P/docs"; : > "$P/apps/A/A.mpr"; mkdir -p "$P/.claude" +P="$T/r2/proj"; mkdir -p "$P/apps/A" "$P/docs"; : > "$P/apps/A/A.mpr"; wire "$P" eq "R2 toolkit copy, PWD=project/docs: the project, not the toolkit" "$(root "$P/docs" "$TKP")" "$P" echo "== R3 single tree / R4 two-tree, unchanged ==" @@ -64,13 +68,13 @@ eq "R3b single tree, toolkit copy from mdlsource/" "$(root "$P/mdlsource" "$TKP" S="$T/r4/stray"; P="$S/proj"; mkdir -p "$P/app" "$P/docs"; : > "$S/stray.mpr"; : > "$P/app/App.mpr"; install "$P" eq "R4a two-tree, installed copy" "$(root "$P" "$P/bin/probe.sh")" "$P" eq "R4b two-tree, toolkit copy from docs/" "$(root "$P/docs" "$TKP")" "$P" -rm -rf "$P/bin" "$P/.claude" +rm -rf "$P/bin" "$P/.claude" "$P/PROJECT.md" eq "R4c two-tree, unwired (no markers): found by the app/ probe" "$(root "$P/docs" "$TKP")" "$P" echo "== R5 nested multi-app folders ==" O="$T/r5/outer"; I="$O/work/inner" mkdir -p "$O/apps/X" "$I/apps/A" "$I/docs" "$O/work/notes"; : > "$O/apps/X/X.mpr"; : > "$I/apps/A/A.mpr" -mkdir -p "$O/.claude" "$I/.claude" +wire "$O"; wire "$I" eq "R5a from inside the inner project" "$(root "$I/docs" "$TKP")" "$I" eq "R5b from between the two: the outer one" "$(root "$O/work/notes" "$TKP")" "$O" @@ -81,15 +85,15 @@ out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash -c ". '$T/tk/project case "$rc:$out" in 1:*"no .mpr found in $P"*) ok "R6b find_mpr fails naming the project, not the stray" ;; *) fail "R6b find_mpr rc=$rc: $out" ;; esac echo "== R7 \$HOME's .claude/ is not a project folder ==" -H="$T/r7/home"; mkdir -p "$H/.claude" "$H/scratch/sub"; : > "$T/r7/Above.mpr" +H="$T/r7/home"; mkdir -p "$H/scratch/sub"; wire "$H"; : > "$T/r7/Above.mpr" eq "R7 walk passes \$HOME and finds the model above it" "$(root "$H/scratch/sub" "$TKP" "$H")" "$T/r7" echo "== R8 PWD inside one app's folder ==" -P="$T/r8/proj"; mkdir -p "$P/apps/A/javasource" "$P/.claude"; : > "$P/apps/A/A.mpr" +P="$T/r8/proj"; mkdir -p "$P/apps/A/javasource"; wire "$P"; : > "$P/apps/A/A.mpr" eq "R8 apps/A/javasource -> apps/A (the model's own folder)" "$(root "$P/apps/A/javasource" "$TKP")" "$P/apps/A" echo "== R9 find_mpr in a multi-app folder ==" -P="$T/r9/proj"; mkdir -p "$P/apps/A" "$P/.claude"; : > "$P/apps/A/A.mpr" +P="$T/r9/proj"; mkdir -p "$P/apps/A"; wire "$P"; : > "$P/apps/A/A.mpr" fm() { (cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" ${1:+MPR_FILE="$1"} bash -c ". '$T/tk/project-bin/_common.sh'; find_mpr; d=\$(find_model_dir) && echo \"dir=\$d\"" 2>&1); } eq "R9a one app: found, model dir is apps/A" "$(fm)" "$P/apps/A/A.mpr dir=$P/apps/A" @@ -100,7 +104,7 @@ eq "R9c MPR_FILE=apps/B/B.mpr picks B (echoed as given, as before)" "$(fm apps/B echo "== R10 bin/check-requirements.sh uses the same walk ==" CR="$TK_SRC/bin/check-requirements.sh" -S="$T/r10/stray"; P="$S/proj"; mkdir -p "$P/apps/A"; : > "$S/stray.mpr"; : > "$P/apps/A/A.mpr"; mkdir -p "$P/.claude" +S="$T/r10/stray"; P="$S/proj"; mkdir -p "$P/apps/A"; : > "$S/stray.mpr"; : > "$P/apps/A/A.mpr"; wire "$P" out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash "$CR" 2>&1)" line="$(printf '%s\n' "$out" | grep 'project root' | sed 's/.*project root *//; s/ *$//')" eq "R10a multi-app root reported, not the stray" "$line" "$P" @@ -108,6 +112,13 @@ rm -f "$P/apps/A/A.mpr" out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash "$CR" 2>&1)"; rc=$? case "$rc:$out" in 2:*"stopped at the project folder $P"*) ok "R10b model-less project: exit 2, names where the walk stopped" ;; *) fail "R10b rc=$rc: $out" ;; esac +echo "== R11 a bare .claude/ is not a project folder ==" +B="$T/r11/tmpish"; P="$B/work/proj"; mkdir -p "$B/.claude" "$P/project-bin" +cp "$COMMON" "$P/project-bin/_common.sh"; cp "$TKP" "$P/project-bin/probe.sh" +eq "R11a unmarked project under a bare .claude/: self-parent fallback, as before" "$(root "$P" "$P/project-bin/probe.sh")" "$P" +: > "$T/r11/Up.mpr" +eq "R11b ... and a model above the bare .claude/ is still found, as before" "$(root "$P" "$P/project-bin/probe.sh")" "$T/r11" + echo echo "root-multiapp: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] From ef8a7845a4713cf1f740deeb5dd7aae7757c7d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:16:16 +0000 Subject: [PATCH 3/5] fix(root): teach the model probes in model-stamp, lint-gate, doctor and metrics-ledger the apps/ layout (#420) find_mpr accepted apps// but four consumers still probed root and app/ only: model-stamp.sh check refused a staged model with "no .mpr resolves", lint-gate.sh exited 2 (exec.sh logged lint as "could not run"), doctor.sh reported no model, and metrics-ledger.sh's fallback found none. - model-stamp.sh: _mxtk_has_model (type-guarded, inline fallback for a held-back _common.sh). With several apps and nothing model-related staged `check` still passes, so a multi-app folder does not block every commit; staged files under one apps// are checked against that app; staged across several refuse and name MPR_FILE. - lint-gate.sh: one app linted; several refused naming MPR=apps//.mpr. - doctor.sh: both probes; several apps are listed with the MPR_FILE hint. - metrics-ledger.sh: the no-_common.sh fallback probes apps/*/. - sync-project.sh: known_fix_note for a held-back _common.sh names #420 and the --only-bin _common.sh --upgrade-bin _common.sh remedy. - check-requirements.sh: fail with exit 2 if _common.sh cannot be loaded. - test-common-root-multiapp.sh: R12-R16 (model-stamp, lint-gate, doctor, a model-less worktree under .claude/worktrees/); 33/33, 22 fail on master. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 3 +- bin/check-requirements.sh | 2 +- bin/doctor.sh | 7 +++ bin/sync-project.sh | 3 ++ project-bin/lint-gate.sh | 12 ++++- project-bin/metrics-ledger.sh | 2 +- project-bin/model-stamp.sh | 51 ++++++++++++++++++--- tests/wave2/test-common-root-multiapp.sh | 58 ++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab313996..4f16f669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding an installed `bin/_common.sh`, or a `.claude/` beside a `PROJECT.md` (a bare `.claude/`, which Claude Code creates wherever it runs, does not count, and `$HOME` never does). `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, and a `$PWD` inside one app's folder still resolves to that app. New `tests/wave2/test-common-root-multiapp.sh`: 22 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project, a bare `.claude/` above an unmarked project, and `check-requirements.sh`; 12 fail on master. — MendixMau +- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding an installed `bin/_common.sh`, or a `.claude/` beside a `PROJECT.md` (a bare `.claude/`, which Claude Code creates wherever it runs, does not count, and `$HOME` never does). One visible consequence: a model-less git worktree under `.claude/worktrees/` now resolves to the worktree, not the main project above it. `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. The consumers that probe for the model themselves now know the layout too: `model-stamp.sh check` (a staged model no longer reads as "no .mpr resolves"; with several apps, nothing staged still passes, so a multi-app folder does not block every commit, and a commit touching one app is checked against that app), `lint-gate.sh` (was exit 2, so `exec.sh` logged lint as "could not run"), `doctor.sh` and `metrics-ledger.sh`'s fallback. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, and a `$PWD` inside one app's folder still resolves to that app. **A project whose `bin/_common.sh` is hand-edited does not get this from `sync-project.sh`**, which holds that copy back (and with it every older script that depends on it); `sync-project.sh --only-bin _common.sh --upgrade-bin _common.sh` takes it (the old copy is backed up), and sync's drift warning now names #420. New `tests/wave2/test-common-root-multiapp.sh`: 33 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project, a bare `.claude/`, a worktree, `check-requirements.sh`, `model-stamp.sh`, `lint-gate.sh` and `doctor.sh`; 22 fail on master. — MendixMau + ## v2026.10.19 - new(progress-report): **the progress report gains a Project pages section: a compact folder tree of the HTML pages found in the project's output folders (gate dashboard, analysis reports, blueprint, design system, process map, wireframes, UI reviews, test reports), each a relative link from `architecture/` with its modified date.** Pages are found by scanning the project's files on every render, never from a stored list; only the top-level folders in `PAGE_DIRS` are scanned (each named with its producer; a symlinked one is skipped), `node_modules`, `.mpr-snapshots`, `deployment`, `mprcontents`, `theme`, `.git`, vendor and dot folders are never entered, a folder with more than 8 pages folds behind a count, every folder keeps its row and lists at most 100 links (`+K more`), and the report never lists itself. Dates are shown as days, so the section is unchanged on the same day unless a listed page changed and a `--refresh` leaves the page alone. Test: `tests/wave2/test-progress-report-pages.sh` (single and two-tree layouts, Windows hrefs) — MendixMau (request 2026-10-19) diff --git a/bin/check-requirements.sh b/bin/check-requirements.sh index 2e3f2449..84a86de0 100755 --- a/bin/check-requirements.sh +++ b/bin/check-requirements.sh @@ -30,7 +30,7 @@ esac; done # under apps//, and never past the project's own folder. This loop used to be a private copy # that probed the root only, so on a two-tree or multi-app project it climbed to the first # ancestor holding any .mpr, and --fix installed tests/e2e into that folder (#420). -. "$(cd "$(dirname "$0")/.." && pwd)/project-bin/_common.sh" +. "$(cd "$(dirname "$0")/.." && pwd)/project-bin/_common.sh" || { echo "cannot load _common.sh" >&2; exit 2; } ROOT="$(mxtk_walk_root "$(pwd)")" || ROOT="" if [ -z "$ROOT" ] || ! _mxtk_has_model "$ROOT"; then echo "✗ no .mpr found walking up from $(pwd)${ROOT:+ (stopped at the project folder $ROOT)} — not inside a Mendix project" >&2 diff --git a/bin/doctor.sh b/bin/doctor.sh index f97feb8c..4696bf3f 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -347,6 +347,7 @@ if [ -n "$PS_MODE" ]; then . "$TOOLKIT_ROOT/project-bin/_common.sh" MPR="$(ls "$PROJECT_DIR"/*.mpr 2>/dev/null | head -1)" [ -n "$MPR" ] || MPR="$(ls "$PROJECT_DIR"/app/*.mpr 2>/dev/null | head -1)" + [ -n "$MPR" ] || MPR="$(ls "$PROJECT_DIR"/apps/*/*.mpr 2>/dev/null | head -1)" # multi-app folder (#420) PMXCLI_PROBE="$(find_project_mxcli 2>/dev/null || true)" head_ "Property sets (Studio Pro merge safety): $PROJECT_DIR" if [ "$PS_IF_STALE" = 1 ] && [ "$PS_MODE" = check ] && [ -n "$PMXCLI_PROBE" ]; then @@ -1183,6 +1184,12 @@ if [ -n "$PROJECT_DIR" ]; then # Two-tree checkout (cloud-dev-environment.md: `mxcli new --output-dir ./app`) — the app # lives under app/. Reported "no .mpr found" on a healthy pilot, 2026-09-04. [ -n "$MPR" ] || MPR="$(ls "$PROJECT_DIR"/app/*.mpr 2>/dev/null | head -1)" + # Multi-app folder (apps//X.mpr, #420): report every app, not "no .mpr found". + if [ -z "$MPR" ] && ls "$PROJECT_DIR"/apps/*/*.mpr >/dev/null 2>&1; then + MPR="$(ls "$PROJECT_DIR"/apps/*/*.mpr | head -1)" + _apps="$(cd "$PROJECT_DIR" && ls apps/*/*.mpr | paste -s -d, - | sed 's/,/, /g')" + [ "$(ls "$PROJECT_DIR"/apps/*/*.mpr | wc -l | tr -d ' ')" -gt 1 ] && note "multi-app folder: $_apps — instruments need MPR_FILE=apps//.mpr to pick one" + fi [ -n "$MPR" ] && ok "model: ${MPR#$PROJECT_DIR/}" || { warn "no .mpr found in the project root" note "If the model lives elsewhere, point doctor at the folder that contains it;" diff --git a/bin/sync-project.sh b/bin/sync-project.sh index ba56ae24..a1bbf504 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1145,6 +1145,9 @@ known_fix_note() { page-scope.sh) echo "bin/page-scope.sh predates the HEADER_WORDS fix (F-042, 2026-08-28): SHOW PAGES header columns Excluded/Folder/Params were parsed as page rows, inflating the page denominator (measured: 6 real pages counted as 13), so every consumer of page-scope.json graded against furniture. One-line fix — recommended upgrade." ;; _common.sh) + if ! grep -q '_mxtk_has_model' "$PROJECT_DIR/bin/_common.sh" 2>/dev/null; then + echo "bin/_common.sh predates the MULTI-APP ROOT fix (#420, toolkit, 2026-10-20): in a multi-app folder (apps//X.mpr) its root search climbed to the first ancestor holding any .mpr, so ledgers, stamps and lint landed in another folder. A hand-edited copy also holds back every older script that depends on it. Take just this file (your copy is backed up): $0 $PROJECT_DIR --only-bin _common.sh --upgrade-bin _common.sh" + fi if grep -q 'mxtk_ensure_mxbuild' "$PROJECT_DIR/bin/_common.sh" 2>/dev/null \ && ! grep -q 'mxtk_model_version' "$PROJECT_DIR/bin/_common.sh" 2>/dev/null; then echo "bin/_common.sh predates the VERSION-MISMATCH FALSE GREEN fix (toolkit, 2026-09-27, field project): find_mxbuild picked the NEWEST mxbuild, which refuses a model of another Mendix version (exit 3, reason in errors[], problems[] empty), and the gate counted that as 0 errors — 29 of 29 execs logged 'mxbuild clean' on one machine and a CE0066 shipped. Now: the mxbuild matching the model's _ProductVersion is preferred, and a non-zero exit with 0 problems is unverified, never clean. Upgrade BOTH: --upgrade-bin _common.sh --upgrade-bin exec.sh (and verify-model.sh)."; return diff --git a/project-bin/lint-gate.sh b/project-bin/lint-gate.sh index 0aace996..49d305db 100755 --- a/project-bin/lint-gate.sh +++ b/project-bin/lint-gate.sh @@ -108,9 +108,19 @@ if [ -z "${MPR:-}" ]; then if [ -z "$MPR" ] && ls "$ROOT"/app/*.mpr >/dev/null 2>&1; then MPR="app/$(basename "$(ls "$ROOT"/app/*.mpr | head -1)")" fi + # Multi-app folder (apps//X.mpr, #420): one app is linted as it is found; several are not + # guessed between (exit 2, naming MPR=apps//.mpr), the same refusal as find_mpr. + if [ -z "$MPR" ] && ls "$ROOT"/apps/*/*.mpr >/dev/null 2>&1; then + if [ "$(ls "$ROOT"/apps/*/*.mpr | wc -l | tr -d ' ')" -gt 1 ]; then + echo "lint-gate: several apps under $ROOT/apps — set MPR=apps//.mpr to choose:" >&2 + ls "$ROOT"/apps/*/*.mpr | sed "s#^$ROOT/# #" >&2 + exit 2 + fi + MPR="$(cd "$ROOT" && ls apps/*/*.mpr | head -1)" + fi fi if [ -z "$MPR" ] || [ ! -f "$ROOT/$MPR" ]; then - echo "lint-gate: no .mpr found in $ROOT or $ROOT/app — set MPR=.mpr" >&2 + echo "lint-gate: no .mpr found in $ROOT, $ROOT/app or $ROOT/apps/ — set MPR=.mpr" >&2 exit 2 fi if [ "$(ls "$ROOT"/*.mpr 2>/dev/null | wc -l | tr -d " ")" -gt 1 ] && [ -z "${MPR_EXPLICIT:-}" ]; then diff --git a/project-bin/metrics-ledger.sh b/project-bin/metrics-ledger.sh index f84b5e95..19c63ab6 100755 --- a/project-bin/metrics-ledger.sh +++ b/project-bin/metrics-ledger.sh @@ -65,7 +65,7 @@ if ! type require_py >/dev/null 2>&1; then } fi if ! type find_mpr >/dev/null 2>&1; then - find_mpr() { ls "$PROJECT_ROOT"/*.mpr "$PROJECT_ROOT"/app/*.mpr 2>/dev/null | head -1; } + find_mpr() { ls "$PROJECT_ROOT"/*.mpr "$PROJECT_ROOT"/app/*.mpr "$PROJECT_ROOT"/apps/*/*.mpr 2>/dev/null | head -1; } fi if ! type find_project_mxcli >/dev/null 2>&1; then find_project_mxcli() { [ -x "$PROJECT_ROOT/mxcli" ] && echo "$PROJECT_ROOT/mxcli"; } diff --git a/project-bin/model-stamp.sh b/project-bin/model-stamp.sh index aaf6d693..c1989f56 100755 --- a/project-bin/model-stamp.sh +++ b/project-bin/model-stamp.sh @@ -75,25 +75,62 @@ fi # WHY (2026-09-25, same build). `find_mpr` failing used to end every command here, `check` # included — so the pre-commit hook refused the Stage P intake commit with "no .mpr found" # although nothing model-related was staged, and each pre-model commit needed the override. -# `check` now passes when no .mpr exists at the root or under app/ AND no model path +# `check` now passes when no .mpr exists at the root, under app/ or under apps// AND no model path # (*.mpr, mprcontents/) is staged; a staged model that no .mpr resolves still refuses, and # two .mpr files still refuse (find_mpr's "refusing to guess"). Every other command still # needs a model: a stamp or fingerprint over no model would be a constant. -_mxtk_any_mpr() { local f; for f in "$PROJECT_ROOT"/*.mpr "$PROJECT_ROOT"/app/*.mpr; do [ -e "$f" ] && return 0; done; return 1; } -if [ "${1:-}" = "check" ] && [ -z "${MPR_FILE:-}" ] && ! _mxtk_any_mpr; then +# The same three-layout probe as the root search (_common.sh _mxtk_has_model, #420): without +# apps// a staged model in a multi-app folder exited 1 "no .mpr resolves". The inline +# fallback is for an older hand-edited _common.sh that sync held back. +_mxtk_any_mpr() { + if type _mxtk_has_model >/dev/null 2>&1; then _mxtk_has_model "$PROJECT_ROOT"; return; fi + local f; for f in "$PROJECT_ROOT"/*.mpr "$PROJECT_ROOT"/app/*.mpr "$PROJECT_ROOT"/apps/*/*.mpr; do [ -e "$f" ] && return 0; done; return 1 +} +# A multi-app folder with several apps (apps/A/A.mpr, apps/B/B.mpr) has no single model either: +# find_mpr refuses to guess, and that refusal must not block every commit (#420). So `check` +# treats it like the no-model case: nothing model-related staged passes; staged files that all +# sit under ONE apps// are checked against that app's .mpr; staged files across several apps +# refuse and name MPR_FILE. +_mxtk_several_apps() { + ls "$PROJECT_ROOT"/*.mpr >/dev/null 2>&1 && return 1 + ls "$PROJECT_ROOT"/app/*.mpr >/dev/null 2>&1 && return 1 + [ "$(ls "$PROJECT_ROOT"/apps/*/*.mpr 2>/dev/null | wc -l | tr -d ' ')" -gt 1 ] +} +_several=0 +if [ "${1:-}" = "check" ] && [ -z "${MPR_FILE:-}" ] && _mxtk_several_apps; then _several=1; fi +if [ "${1:-}" = "check" ] && [ -z "${MPR_FILE:-}" ] && { [ "$_several" = 1 ] || ! _mxtk_any_mpr; }; then _q=0; for a in "$@"; do case "$a" in -q|--quiet) _q=1 ;; esac; done _staged="" if git -C "$PROJECT_ROOT" rev-parse --git-dir >/dev/null 2>&1; then _staged="$(git -C "$PROJECT_ROOT" diff --cached --name-only --diff-filter=d 2>/dev/null \ | grep -E '(^|/)[^/]+\.mpr$|(^|/)mprcontents/' || true)" + # the same list relative to the project folder, to tell which apps// it touches + [ "$_several" = 1 ] && _staged="$(git -C "$PROJECT_ROOT" diff --cached --name-only --relative --diff-filter=d 2>/dev/null \ + | grep -E '(^|/)[^/]+\.mpr$|(^|/)mprcontents/' || true)" fi if [ -z "$_staged" ]; then - [ "$_q" = 1 ] || echo " no model in this project yet (no .mpr at the root or under app/) — nothing to verify" + if [ "$_several" = 1 ]; then + [ "$_q" = 1 ] || echo " several apps under apps/ and no model files staged — nothing to verify" + else + [ "$_q" = 1 ] || echo " no model in this project yet (no .mpr at the root, under app/ or under apps//) — nothing to verify" + fi exit 0 fi - echo " ✗ model files are staged, but no .mpr resolves under $PROJECT_ROOT (root or app/):" >&2 - printf '%s\n' "$_staged" | head -5 | sed 's/^/ /' >&2 - exit 1 + if [ "$_several" = 1 ]; then + _apps="$(printf '%s\n' "$_staged" | sed -n 's#^\(apps/[^/]*\)/.*#\1#p' | sort -u)" + if [ "$(printf '%s\n' "$_staged" | grep -vc '^apps/[^/]*/')" -eq 0 ] && [ "$(printf '%s\n' "$_apps" | grep -c .)" -eq 1 ] \ + && [ "$(ls "$PROJECT_ROOT/$_apps"/*.mpr 2>/dev/null | wc -l | tr -d ' ')" -eq 1 ]; then + MPR_FILE="$_apps/$(basename "$(ls "$PROJECT_ROOT/$_apps"/*.mpr)")"; export MPR_FILE + else + echo " ✗ model files are staged across several apps, or outside apps// — set MPR_FILE=apps//.mpr to choose:" >&2 + printf '%s\n' "$_staged" | head -5 | sed 's/^/ /' >&2 + exit 1 + fi + else + echo " ✗ model files are staged, but no .mpr resolves under $PROJECT_ROOT (root, app/ or apps//):" >&2 + printf '%s\n' "$_staged" | head -5 | sed 's/^/ /' >&2 + exit 1 + fi fi MPR="$(find_mpr)" || exit 1 diff --git a/tests/wave2/test-common-root-multiapp.sh b/tests/wave2/test-common-root-multiapp.sh index 8e1370d9..54b1654f 100755 --- a/tests/wave2/test-common-root-multiapp.sh +++ b/tests/wave2/test-common-root-multiapp.sh @@ -26,6 +26,15 @@ # R10 bin/check-requirements.sh (same walk): multi-app root named; a model-less project exits 2 # R11 a bare .claude/ (no PROJECT.md) is not a project folder: a stray /tmp/.claude above an # unmarked project must not capture the walk; the old self-parent fallback still applies +# The consumers that probe for the model themselves, in the apps// layout: +# R12 model-stamp.sh check, one app: a staged model reaches the stamp check (not "no .mpr +# resolves"); nothing staged still passes `check --staged` +# R13 model-stamp.sh check, two apps: nothing staged passes (a multi-app folder must not block +# every commit); staged under one app is checked against that app; across two refuses +# R14 lint-gate.sh: one app gets past model discovery; two apps refuse naming MPR=apps/ +# R15 doctor.sh : reports the app's model, and names MPR_FILE when there are two +# R16 a model-less git worktree under /.claude/worktrees/ (it carries its own +# bin/_common.sh) resolves to the worktree, no longer to the main project above it set -u COMMON="${1:?usage: $0 project-bin/_common.sh}" @@ -119,6 +128,55 @@ eq "R11a unmarked project under a bare .claude/: self-parent fallback, as before : > "$T/r11/Up.mpr" eq "R11b ... and a model above the bare .claude/ is still found, as before" "$(root "$P" "$P/project-bin/probe.sh")" "$T/r11" +echo "== R12/R13 model-stamp.sh check in a multi-app folder ==" +TKB="$TK_SRC/project-bin" +mkms() { # mkms ... — a git project with an installed model-stamp.sh and the apps + local d="$1" a; shift; mkdir -p "$d/bin"; wire "$d" + cp "$COMMON" "$d/bin/_common.sh"; cp "$TKB/model-stamp.sh" "$d/bin/" + for a in "$@"; do mkdir -p "$d/apps/$a/mprcontents"; echo m > "$d/apps/$a/$a.mpr"; echo u > "$d/apps/$a/mprcontents/u.mxunit"; done + ( cd "$d" && git init -q . && git -c user.email=t@example.com -c user.name=t commit -q --allow-empty -m init ) +} +ms() { (cd "$1" && shift && env -i PATH="$PATH" HOME="$T/nohome" bash bin/model-stamp.sh "$@" 2>&1); } +P="$T/r12/proj"; mkms "$P" A +out="$(ms "$P" check --staged)"; rc=$? +case "$rc:$out" in 0:*"no model files staged"*) ok "R12a one app, nothing staged: check --staged passes" ;; *) fail "R12a rc=$rc: $out" ;; esac +(cd "$P" && git add apps) +out="$(ms "$P" check --staged)"; rc=$? +case "$out" in *"no .mpr resolves"*) fail "R12b staged model still reads as no model: $out" ;; + *UNVERIFIED*) ok "R12b one app, model staged: reaches the stamp check (UNVERIFIED, rc $rc)" ;; *) fail "R12b rc=$rc: $out" ;; esac +P="$T/r13/proj"; mkms "$P" A B +out="$(ms "$P" check --staged)"; rc=$? +case "$rc:$out" in 0:*) ok "R13a two apps, nothing staged: check --staged passes" ;; *) fail "R13a rc=$rc: $out" ;; esac +out="$(ms "$P" check)"; rc=$? +case "$rc:$out" in 0:*"several apps"*) ok "R13b two apps, nothing staged: plain check passes and says why" ;; *) fail "R13b rc=$rc: $out" ;; esac +(cd "$P" && git add apps/A) +out="$(ms "$P" check --staged)"; rc=$? +case "$out" in *UNVERIFIED*) ok "R13c staged under apps/A only: checked against apps/A (UNVERIFIED, rc $rc)" ;; *) fail "R13c rc=$rc: $out" ;; esac +(cd "$P" && git add apps/B) +out="$(ms "$P" check --staged)"; rc=$? +case "$rc:$out" in 1:*"MPR_FILE=apps/"*) ok "R13d staged across two apps: refused, MPR_FILE named" ;; *) fail "R13d rc=$rc: $out" ;; esac + +echo "== R14 lint-gate.sh model discovery in a multi-app folder ==" +P="$T/r14/proj"; mkdir -p "$P/bin" "$P/apps/A"; wire "$P"; : > "$P/apps/A/A.mpr" +cp "$COMMON" "$P/bin/_common.sh"; cp "$TKB/lint-gate.sh" "$P/bin/" +out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash bin/lint-gate.sh 2>&1)" +case "$out" in *"no .mpr found"*) fail "R14a one app: lint-gate found no model: $out" ;; *) ok "R14a one app: lint-gate gets past model discovery" ;; esac +mkdir -p "$P/apps/B"; : > "$P/apps/B/B.mpr" +out="$(cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" bash bin/lint-gate.sh 2>&1)"; rc=$? +case "$rc:$out" in 2:*"MPR=apps/"*"apps/A/A.mpr"*"apps/B/B.mpr"*) ok "R14b two apps: refused (rc 2), both listed" ;; *) fail "R14b rc=$rc: $out" ;; esac + +echo "== R15 doctor.sh in a multi-app folder ==" +P="$T/r15/proj"; mkdir -p "$P/apps/A" "$P/apps/B"; : > "$P/apps/A/A.mpr" +out="$(env -i PATH="$PATH" HOME="$T/nohome" bash "$TK_SRC/bin/doctor.sh" "$P" 2>&1)" +case "$out" in *"model: apps/A/A.mpr"*) ok "R15a one app: model reported" ;; *) fail "R15a: $(printf '%s' "$out" | grep -i 'mpr' | head -3)" ;; esac +: > "$P/apps/B/B.mpr" +out="$(env -i PATH="$PATH" HOME="$T/nohome" bash "$TK_SRC/bin/doctor.sh" "$P" 2>&1)" +case "$out" in *"multi-app folder: apps/A/A.mpr, apps/B/B.mpr"*MPR_FILE*) ok "R15b two apps: both named, MPR_FILE pointed at" ;; *) fail "R15b: $(printf '%s' "$out" | grep -i 'mpr' | head -3)" ;; esac + +echo "== R16 a model-less worktree under .claude/worktrees/ ==" +P="$T/r16/proj"; W="$P/.claude/worktrees/w"; install "$P"; : > "$P/App.mpr"; install "$W"; mkdir -p "$W/docs" +eq "R16 toolkit copy from the worktree's docs/: the worktree" "$(root "$W/docs" "$TKP")" "$W" + echo echo "root-multiapp: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] From d1148c3382e7940409c24df27c1d41f6bd09e424 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:40:15 +0000 Subject: [PATCH 4/5] fix(root): refuse a multi-app commit the stamp cannot cover; doctor names the checked app (#420) Review: the several-apps refusal suggested MPR_FILE=apps//.mpr, and doing that verified A while B's staged changes went through unverified (the stamp covers one model's paths). - model-stamp.sh: the refusal now says commit one app at a time (git reset apps/, MPR_FILE=... ./bin/verify-model.sh, commit). In a multi-app folder with MPR_FILE set, `check` refuses when any staged model path is outside dirname(MPR_FILE) and names those paths. - doctor.sh --property-sets: honours MPR_FILE; otherwise says which app it checked and names the others. - test-common-root-multiapp.sh: R13e (A+B staged, MPR_FILE=A: refused; passed unverified at the previous head), R13f (only A staged and stamped: passes), R15c. 36/36. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- bin/doctor.sh | 14 +++++++++++++- project-bin/model-stamp.sh | 23 ++++++++++++++++++++++- tests/wave2/test-common-root-multiapp.sh | 13 ++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/bin/doctor.sh b/bin/doctor.sh index 4696bf3f..11a16bd0 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -347,9 +347,21 @@ if [ -n "$PS_MODE" ]; then . "$TOOLKIT_ROOT/project-bin/_common.sh" MPR="$(ls "$PROJECT_DIR"/*.mpr 2>/dev/null | head -1)" [ -n "$MPR" ] || MPR="$(ls "$PROJECT_DIR"/app/*.mpr 2>/dev/null | head -1)" - [ -n "$MPR" ] || MPR="$(ls "$PROJECT_DIR"/apps/*/*.mpr 2>/dev/null | head -1)" # multi-app folder (#420) + # Multi-app folder (#420): MPR_FILE picks the app; otherwise the first is checked and the + # screen says so, naming the others, rather than silently checking one of several. + _ps_apps="" + if [ -z "$MPR" ] && ls "$PROJECT_DIR"/apps/*/*.mpr >/dev/null 2>&1; then + if [ -n "${MPR_FILE:-}" ] && [ -f "$PROJECT_DIR/${MPR_FILE#"$PROJECT_DIR"/}" ]; then + MPR="$PROJECT_DIR/${MPR_FILE#"$PROJECT_DIR"/}" + else + MPR="$(ls "$PROJECT_DIR"/apps/*/*.mpr | head -1)" + [ "$(ls "$PROJECT_DIR"/apps/*/*.mpr | wc -l | tr -d ' ')" -gt 1 ] \ + && _ps_apps="$(cd "$PROJECT_DIR" && ls apps/*/*.mpr | paste -s -d, - | sed 's/,/, /g')" + fi + fi PMXCLI_PROBE="$(find_project_mxcli 2>/dev/null || true)" head_ "Property sets (Studio Pro merge safety): $PROJECT_DIR" + [ -n "$_ps_apps" ] && note "multi-app folder ($_ps_apps): checking ${MPR#"$PROJECT_DIR"/} only — rerun with MPR_FILE=apps//.mpr for each other app" if [ "$PS_IF_STALE" = 1 ] && [ "$PS_MODE" = check ] && [ -n "$PMXCLI_PROBE" ]; then PS_VER="$(ps_version "$PMXCLI_PROBE")" if [ -n "$PS_VER" ] && ! ps_stale; then diff --git a/project-bin/model-stamp.sh b/project-bin/model-stamp.sh index c1989f56..ac5a08b7 100755 --- a/project-bin/model-stamp.sh +++ b/project-bin/model-stamp.sh @@ -122,8 +122,12 @@ if [ "${1:-}" = "check" ] && [ -z "${MPR_FILE:-}" ] && { [ "$_several" = 1 ] || && [ "$(ls "$PROJECT_ROOT/$_apps"/*.mpr 2>/dev/null | wc -l | tr -d ' ')" -eq 1 ]; then MPR_FILE="$_apps/$(basename "$(ls "$PROJECT_ROOT/$_apps"/*.mpr)")"; export MPR_FILE else - echo " ✗ model files are staged across several apps, or outside apps// — set MPR_FILE=apps//.mpr to choose:" >&2 + # Not "set MPR_FILE": that verifies one app and would let the others' staged changes + # through unverified (the stamp covers one model's paths). One app per commit. + echo " ✗ model files are staged across several apps (or outside apps//) — commit one app at a time:" >&2 printf '%s\n' "$_staged" | head -5 | sed 's/^/ /' >&2 + echo " git reset apps/ (unstage the other apps), then" >&2 + echo " MPR_FILE=apps//.mpr ./bin/verify-model.sh then commit; repeat per app" >&2 exit 1 fi else @@ -133,6 +137,23 @@ if [ "${1:-}" = "check" ] && [ -z "${MPR_FILE:-}" ] && { [ "$_several" = 1 ] || fi fi +# Several apps and MPR_FILE set (by the caller, or derived above): the stamp covers that one +# app's paths only, so a staged model path in ANY other folder would pass unverified. Refuse it +# and name the paths (#420 review: MPR_FILE=apps/A/A.mpr with A and B staged let B through). +if [ "${1:-}" = "check" ] && [ -n "${MPR_FILE:-}" ] && [ -d "$PROJECT_ROOT/apps" ] \ + && [ "$(ls "$PROJECT_ROOT"/apps/*/*.mpr 2>/dev/null | wc -l | tr -d ' ')" -gt 1 ] \ + && git -C "$PROJECT_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + _mdir="$(dirname "${MPR_FILE#"$PROJECT_ROOT"/}")" + _outside="$(git -C "$PROJECT_ROOT" diff --cached --name-only --relative --diff-filter=d 2>/dev/null \ + | grep -E '(^|/)[^/]+\.mpr$|(^|/)mprcontents/' | grep -v "^$_mdir/" || true)" + if [ -n "$_outside" ]; then + echo " ✗ MPR_FILE=$MPR_FILE covers $_mdir/ only, but model files outside it are staged:" >&2 + printf '%s\n' "$_outside" | head -5 | sed 's/^/ /' >&2 + echo " commit one app at a time: git reset those paths, verify and commit $_mdir/, then the next app" >&2 + exit 1 + fi +fi + MPR="$(find_mpr)" || exit 1 MPR="$(_real "$MPR")" MODEL_DIR="$(dirname "$MPR")" diff --git a/tests/wave2/test-common-root-multiapp.sh b/tests/wave2/test-common-root-multiapp.sh index 54b1654f..dbf4e339 100755 --- a/tests/wave2/test-common-root-multiapp.sh +++ b/tests/wave2/test-common-root-multiapp.sh @@ -31,6 +31,8 @@ # resolves"); nothing staged still passes `check --staged` # R13 model-stamp.sh check, two apps: nothing staged passes (a multi-app folder must not block # every commit); staged under one app is checked against that app; across two refuses +# with "one app at a time"; MPR_FILE=apps/A with A and B staged refuses naming apps/B (the +# stamp covers A only); MPR_FILE=apps/A with only A staged and A stamped passes # R14 lint-gate.sh: one app gets past model discovery; two apps refuse naming MPR=apps/ # R15 doctor.sh : reports the app's model, and names MPR_FILE when there are two # R16 a model-less git worktree under /.claude/worktrees/ (it carries its own @@ -154,7 +156,14 @@ out="$(ms "$P" check --staged)"; rc=$? case "$out" in *UNVERIFIED*) ok "R13c staged under apps/A only: checked against apps/A (UNVERIFIED, rc $rc)" ;; *) fail "R13c rc=$rc: $out" ;; esac (cd "$P" && git add apps/B) out="$(ms "$P" check --staged)"; rc=$? -case "$rc:$out" in 1:*"MPR_FILE=apps/"*) ok "R13d staged across two apps: refused, MPR_FILE named" ;; *) fail "R13d rc=$rc: $out" ;; esac +case "$rc:$out" in 1:*"one app at a time"*"git reset apps/"*) ok "R13d staged across two apps: refused, one app at a time" ;; *) fail "R13d rc=$rc: $out" ;; esac +msA() { (cd "$P" && env -i PATH="$PATH" HOME="$T/nohome" MPR_FILE=apps/A/A.mpr bash bin/model-stamp.sh "$@" 2>&1); } +msA write pass fixture >/dev/null +out="$(msA check --staged)"; rc=$? +case "$rc:$out" in 1:*"covers apps/A/ only"*"apps/B/"*) ok "R13e MPR_FILE=apps/A, A and B staged: refused, apps/B paths named" ;; *) fail "R13e rc=$rc: $out" ;; esac +(cd "$P" && git reset -q apps/B) +out="$(msA check --staged)"; rc=$? +case "$rc" in 0) ok "R13f MPR_FILE=apps/A, only A staged (and stamped): passes" ;; *) fail "R13f rc=$rc: $out" ;; esac echo "== R14 lint-gate.sh model discovery in a multi-app folder ==" P="$T/r14/proj"; mkdir -p "$P/bin" "$P/apps/A"; wire "$P"; : > "$P/apps/A/A.mpr" @@ -172,6 +181,8 @@ case "$out" in *"model: apps/A/A.mpr"*) ok "R15a one app: model reported" ;; *) : > "$P/apps/B/B.mpr" out="$(env -i PATH="$PATH" HOME="$T/nohome" bash "$TK_SRC/bin/doctor.sh" "$P" 2>&1)" case "$out" in *"multi-app folder: apps/A/A.mpr, apps/B/B.mpr"*MPR_FILE*) ok "R15b two apps: both named, MPR_FILE pointed at" ;; *) fail "R15b: $(printf '%s' "$out" | grep -i 'mpr' | head -3)" ;; esac +out="$(env -i PATH="$PATH" HOME="$T/nohome" bash "$TK_SRC/bin/doctor.sh" --property-sets "$P" 2>&1)" +case "$out" in *"checking apps/A/A.mpr only"*"MPR_FILE="*) ok "R15c --property-sets, two apps: says which app it checked" ;; *) fail "R15c: $(printf '%s' "$out" | head -5)" ;; esac echo "== R16 a model-less worktree under .claude/worktrees/ ==" P="$T/r16/proj"; W="$P/.claude/worktrees/w"; install "$P"; : > "$P/App.mpr"; install "$W"; mkdir -p "$W/docs" From 706af71d3e7f704722c6e1a9683f1972ccfbaaec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:40:28 +0000 Subject: [PATCH 5/5] docs(changelog): describe the multi-app commit refusal and doctor note (#420) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c37287c5..6f22883d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding an installed `bin/_common.sh`, or a `.claude/` beside a `PROJECT.md` (a bare `.claude/`, which Claude Code creates wherever it runs, does not count, and `$HOME` never does). One visible consequence: a model-less git worktree under `.claude/worktrees/` now resolves to the worktree, not the main project above it. `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. The consumers that probe for the model themselves now know the layout too: `model-stamp.sh check` (a staged model no longer reads as "no .mpr resolves"; with several apps, nothing staged still passes, so a multi-app folder does not block every commit, and a commit touching one app is checked against that app), `lint-gate.sh` (was exit 2, so `exec.sh` logged lint as "could not run"), `doctor.sh` and `metrics-ledger.sh`'s fallback. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, and a `$PWD` inside one app's folder still resolves to that app. **A project whose `bin/_common.sh` is hand-edited does not get this from `sync-project.sh`**, which holds that copy back (and with it every older script that depends on it); `sync-project.sh --only-bin _common.sh --upgrade-bin _common.sh` takes it (the old copy is backed up), and sync's drift warning now names #420. New `tests/wave2/test-common-root-multiapp.sh`: 33 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project, a bare `.claude/`, a worktree, `check-requirements.sh`, `model-stamp.sh`, `lint-gate.sh` and `doctor.sh`; 22 fail on master. — MendixMau +- fix(root): **the project-root search no longer climbs out of a multi-app project (#420).** `_common.sh` accepted a model only at `/` or `/app/`, so in a multi-app folder (`apps//X.mpr`) tier 3 walked up from `$PWD` and the first ancestor holding any `.mpr` became `PROJECT_ROOT` (with none above, the toolkit itself), and look-ledger `seen` rows landed in that folder's ledger. The search now probes `apps/*/` as a third layout and stops at the first project folder: one holding an installed `bin/_common.sh`, or a `.claude/` beside a `PROJECT.md` (a bare `.claude/`, which Claude Code creates wherever it runs, does not count, and `$HOME` never does). One visible consequence: a model-less git worktree under `.claude/worktrees/` now resolves to the worktree, not the main project above it. `find_mpr` finds a single `apps//` model and refuses to guess between several, listing them and naming `MPR_FILE=apps//.mpr`. The consumers that probe for the model themselves now know the layout too: `model-stamp.sh check` (a staged model no longer reads as "no .mpr resolves"; with several apps, nothing staged still passes, so a multi-app folder does not block every commit, and a commit touching one app is checked against that app; a commit staged across several apps is refused with "one app at a time", and with `MPR_FILE` set it is refused when any staged model path lies outside that app's folder, since the stamp covers one app only), `lint-gate.sh` (was exit 2, so `exec.sh` logged lint as "could not run"), `doctor.sh` (its `--property-sets` check says which app it checked and how to check the others) and `metrics-ledger.sh`'s fallback. `bin/check-requirements.sh` kept a private copy of the old walk that probed the root only, so `--fix` could install `tests/e2e` into a stray ancestor; it now uses the shared walk (`mxtk_walk_root`). Single-tree and two-tree resolution are unchanged, and a `$PWD` inside one app's folder still resolves to that app. **A project whose `bin/_common.sh` is hand-edited does not get this from `sync-project.sh`**, which holds that copy back (and with it every older script that depends on it); `sync-project.sh --only-bin _common.sh --upgrade-bin _common.sh` takes it (the old copy is backed up), and sync's drift warning now names #420. New `tests/wave2/test-common-root-multiapp.sh`: 36 cases over all three layouts with a stray `.mpr` above each project, nested multi-app folders, a model-less project, a bare `.claude/`, a worktree, `check-requirements.sh`, `model-stamp.sh`, `lint-gate.sh` and `doctor.sh`; 24 fail on master. — MendixMau - fix(progress-report): **the build plan is shown once: the phase cards are the plan.** Expanding a card now shows its full rows (#, Kind, Step, Produces/Proves, Depends on, State, Scripts) and Claims, which only the duplicate "Full plan tables" block carried; that block is gone and views A (script folders) and B (module proof) are their own section. The `now` phase renders open with a selected style, opening another phase closes the previous one (accordion; `#phase-N` links still open their card), and a viewer's saved open/closed state survives the 30 s reload and wins over the default (#474) — field feedback from a requirements-driven project at Stage 5 — MendixMau (field feedback 2026-10-20) ## v2026.10.19