Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<root>/` or `<root>/app/`, so in a multi-app folder (`apps/<X>/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/<w>` now resolves to the worktree, not the main project above it. `find_mpr` finds a single `apps/<X>/` model and refuses to guess between several, listing them and naming `MPR_FILE=apps/<X>/<X>.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 <project> --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(tests): **`test-look-ledger-hook-repair.sh` H7 no longer fails under `LC_ALL=C`.** It checked the kept non-ASCII line with `grep '^ "note": "f.r"'`; under the C locale `ü` is two bytes and `.` matches one, so H7 failed with the file intact (seen while scoping #487's fixtures). It now matches the exact bytes of the whole line (`grep -qxF` with an octal-escaped `printf`), with CRs stripped first, so it holds under any locale and CRLF (the settings writer emits CRLF on native Windows Python). Before: 12/13 under `LC_ALL=C`, 13/13 under `C.UTF-8`; after: 13/13 under C, POSIX and C.UTF-8. — MendixMau
- fix(gates): **a decision taken during a stage no longer reads as that stage's approval** (#477). The #207 flag prefixed a FAIL with `APPROVED OVER A FAILING GATE` whenever any CONFIRMED row carried the stage's number, so a Stage-5 build with `| 5 | Sub-agents run on Sonnet | CONFIRMED |` was flagged, and `status.sh` told the lead to re-open an approval nobody gave while the real gap (PENDING obligations) sat underneath. The register has no dedicated exit row, so `gate-check.sh` now counts a stage as approved on either of two pieces of evidence in the Decisions table: an explicit exit row ("Stage N approved", "Stage 4 — approved with conditions", "Approved: Stage N …", "Gate approved", or "<artifact> approved" for a fixed artifact list such as "Build plan approved"), or the stage having CONFIRMED rows of its own plus a CONFIRMED decision for a later stage up to 6 (the #207 field case); an early Stage-7 "no cutover" row or an N/A/skip row is not evidence. The Stage 3/4 gate checks and every verdict and exit code are unchanged. `test-bug03-gates.sh` T17 (16 cases, checked under gawk, mawk and original-awk): 7 FAIL on master, 47/47 with the fix. — MendixMau
- fix(progress-report): **the top bar is one group of section links (Stages, Build, Pages, Tips) instead of stage chips plus links; the page carries one `id=stamp`, and the header drops the build-plan.html note.** The sticky bar loses the `Stage P 0 … 6` chips, which repeated the stage list right below it, and gains a `Stages` link to that list; the `#stage-N` card anchors stay for links from other pages, and the header now states the current stage. Five elements shared `id=stamp` (invalid HTML); the "Last updated" span keeps it and the others get their own ids plus a `data-stamp` marker, which `build-plan-status.sh --refresh` now reads (with `id=stamp`, so pages written before this still compare) to leave an unchanged page alone. The filter now depends on both scripts: a project whose `bin/build-plan-status.sh` is locally edited keeps the old `id=stamp` filter after sync, so the page is rewritten on every refresh; such a project needs `sync-project.sh --only-bin build-plan-status.sh --upgrade-bin build-plan-status.sh` alongside this. "replaces the former build-plan.html" is gone from the header sub-line. Fixes #479 items 1–3 — MendixMau (review 2026-10-20)
Expand Down
18 changes: 9 additions & 9 deletions bin/check-requirements.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<X>/, 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" || { 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
exit 2
fi

Expand Down
19 changes: 19 additions & 0 deletions bin/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +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)"
# 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/<X>/<X>.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
Expand Down Expand Up @@ -1183,6 +1196,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>/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/<X>/<X>.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;"
Expand Down
3 changes: 3 additions & 0 deletions bin/sync-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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>/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
Expand Down
66 changes: 54 additions & 12 deletions project-bin/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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: <project>/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/<X>/) 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
Expand All @@ -26,24 +27,50 @@
# 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: <root>/*.mpr (single tree), <root>/app/*.mpr (two-tree), and
# <root>/apps/<X>/*.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 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" ] && [ -f "$1/PROJECT.md" ] && [ "$1" != "${HOME:-/nonexistent}" ]
}
# mxtk_walk_root <dir> — walk up from <dir>; 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>/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)}"
}
Expand Down Expand Up @@ -135,6 +162,16 @@ find_mpr() {
found="$f"
done
fi
# Multi-app folder: one app per apps/<X>/ (#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
Expand All @@ -143,7 +180,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=<name>.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/<X>/<X>.mpr (relative to $PROJECT_ROOT) to choose." >&2
else
echo "Set MPR_FILE=<name>.mpr to choose." >&2
fi
return 1
fi
echo "$found"
Expand Down
12 changes: 11 additions & 1 deletion project-bin/lint-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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>/X.mpr, #420): one app is linted as it is found; several are not
# guessed between (exit 2, naming MPR=apps/<X>/<X>.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/<X>/<X>.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=<name>.mpr" >&2
echo "lint-gate: no .mpr found in $ROOT, $ROOT/app or $ROOT/apps/<X> — set MPR=<name>.mpr" >&2
exit 2
fi
if [ "$(ls "$ROOT"/*.mpr 2>/dev/null | wc -l | tr -d " ")" -gt 1 ] && [ -z "${MPR_EXPLICIT:-}" ]; then
Expand Down
2 changes: 1 addition & 1 deletion project-bin/metrics-ledger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"; }
Expand Down
Loading
Loading