Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(exec): **the gate keeps mxbuild's full output in a per-run log and names it on screen and in the BUILD-LOG row.** exec.sh printed `head -20` of the output and deleted the rest, so the evidence of a failed gate (a CE1613 detail among it) was lost in a field migration project (12 modules). A gate that does not end clean now writes `.mpr-snapshots/mxbuild-logs/<run>.log` (newest 30 kept); the screen shows the top lines plus the path and the row ends with `mxbuild log: <path>`. Test: `tests/wave2/test-exec-gate-log.sh` (Refs #441, #443, #171, #285, #424) — field migration project
- fix(build-plan-status): **every State the build-plan skill lists (built, partial, not built, blocked) and every spelling the phase card already counts (complete, verified, in progress, not needed and the like) is now read the same way by the plan header and the phase badge, so card, header and badge give one count; blocked counts as not built and is named in the header, genuinely unreadable values are still reported as unreadable; the later-stage placeholder FAIL is relabelled PENDING only while that stage is pending in PROJECT.md, with tests that a real FAIL stays FAIL and that FAIL stays when the current stage is unknown** — MendixMau
- fix(release-review): **the v1.0 release review: the multilingual skill loses its em and en dashes (wording only, content unchanged; its routing row keeps the trigger separator the agent stubs cut on), `doctor.sh` warns when a hook copy in `~/.claude/hooks` calls a toolkit script that no longer exists (it names the script and says to re-run `bin/install-claude-hooks.sh --full`; a warning, never a failure), and the `.gitignore` line for `.claude/progress-report-sessions.txt` now also reaches a project checked out as a git worktree (`.git` is a file there) in `doctor.sh` and `sync-project.sh`; `check-ignore --no-index` stops the line being appended again when the file is already tracked, and a `git rm --cached` hint is printed.** Tests: new `test-doctor-stale-hooks.sh`; worktree and tracked-file cases added to `test-progress-report-gitignore.sh`. CHANGELOG tidy (a stray blank line, one credit). — MendixMau
- fix(progress-report): **round 6 of the review: the session cache is machine-local and tied to its project, the first cached run is not slower than a cold one, and the build ledger decides the Stage 4 to 5 switch.** (1) `.claude/progress-report-sessions.txt` holds absolute session-log paths, so `init-project.sh`, `doctor.sh` and `sync-project.sh` now put `/.claude/progress-report-sessions.txt` in the project's `.gitignore`, the way they do for the doctor receipt and `toolkit.env`. (2) The cache header records the project's real path and the attribution rules (25% of tool calls, 20 calls); a copied or moved project, or a changed threshold, reads it as cold, so a copy never lists the original's sessions. (3) Files skipped by the 300-file cap are recorded as `no`, so a cached run never reads more files than a cold one. (4) With rows in `metrics-ledger.tsv` (else BUILD-LOG) the switch comes from the ledger; an earlier exec.sh call or build skill in a session log is ignored, the timed sign-off rule stays, and the footer says how the switch is chosen. Tests: F15 to F17 in `test-progress-report-field-fixes.sh`, new `test-progress-report-gitignore.sh` — MendixMau
Expand Down
25 changes: 24 additions & 1 deletion project-bin/exec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ BUILD_LOG="$PROJECT_ROOT/docs/BUILD-LOG.md"
GATE_STATE="not-run" # not-run | skipped | unverified | pass | fail
GATE_ERRORS="?" # mxbuild error count behind a pass (0 = clean); recorded in the stamp
MXB_WHY="" # mxbuild's errors[] reason when it exited without checking the model
MXB_LOG_NOTE="" # "mxbuild log: <path>" when the gate's full output was saved (BUILD-LOG detail)

# ── Exec approval (auto records itself) ──────────────────────────────────────
# bin/exec-approval.sh decides ask vs auto; the ASKING happens before exec.sh ever runs (in
Expand Down Expand Up @@ -490,7 +491,7 @@ HDR2
# The gate cell is read from GATE_STATE rather than passed in, so no caller
# can omit it — a blank cell in this table reads as "fine", which is the exact
# false-green the gate exists to prevent.
_detail="$2${CHANGE_CLASS:+ · change: $CHANGE_CLASS}${CR_ID:+ · cr: $CR_ID}${SCAN_NOTE:+ · $SCAN_NOTE}${HV_NOTE:+ · $HV_NOTE}$_EXEC_APPROVAL_SUFFIX${STEP_TIMES:+ · steps: $STEP_TIMES}"
_detail="$2${CHANGE_CLASS:+ · change: $CHANGE_CLASS}${CR_ID:+ · cr: $CR_ID}${SCAN_NOTE:+ · $SCAN_NOTE}${MXB_LOG_NOTE:+ · $MXB_LOG_NOTE}${HV_NOTE:+ · $HV_NOTE}$_EXEC_APPROVAL_SUFFIX${STEP_TIMES:+ · steps: $STEP_TIMES}"
printf '| %s | `%s` | %s | %s | %s |\n' \
"$(date '+%Y-%m-%dT%H:%M:%S%z')" "$LOG_NAME" \
"${GATE_STATE:-not-run}" "$1" "$_detail" >> "$BUILD_LOG"
Expand Down Expand Up @@ -1639,12 +1640,32 @@ if [ -x "$MXBUILD" ] && [ -x "$JAVA_EXE" ]; then
MXB_WHY="${MXTK_MXBUILD_WHY:-}"
step_done gate

# Keep the gate's full mxbuild output when the run did not end clean. The screen shows only
# the top lines (the failure detail is often in the tail), and the temp file behind
# mxtk_mxbuild_error_count is gone by now, so without this the evidence of a failed gate was
# lost (a field migration project lost the detail of a CE1613 this way). One file per run,
# under .mpr-snapshots/mxbuild-logs/ (git-ignored, outside the snapshot dirs restore_snapshot
# picks from), the newest 30 kept. Its project-relative path goes on screen and in the row.
MXB_LOG_REL=""
if [ -n "$MXBUILD_OUT" ] && { [ "$MXBUILD_EXIT" -ne 0 ] || [ -s "$ERRORS_FILE" ]; }; then
_MXB_LOG_DIR="$PROJECT_ROOT/.mpr-snapshots/mxbuild-logs"
MXB_LOG_REL=".mpr-snapshots/mxbuild-logs/$(date +%Y%m%d-%H%M%S)-$$.log"
if mkdir -p "$_MXB_LOG_DIR" 2>/dev/null && printf '%s\n' "$MXBUILD_OUT" > "$PROJECT_ROOT/$MXB_LOG_REL" 2>/dev/null; then
MXB_LOG_NOTE="mxbuild log: $MXB_LOG_REL"
ls -t "$_MXB_LOG_DIR"/*.log 2>/dev/null | sed -n '31,$p' | while IFS= read -r _old; do rm -f "$_old"; done
else
MXB_LOG_REL=""
fi
fi
mxb_log_hint() { [ -z "$MXB_LOG_REL" ] || echo " full mxbuild output: $MXB_LOG_REL"; }

if [ -f "$ERRORS_FILE" ] && [ -s "$ERRORS_FILE" ]; then
CE_COUNT=$(err_count "$ERRORS_FILE")
if [ "$CE_COUNT" = "?" ]; then
GATE_STATE="unverified"
echo " ⚠ mxbuild errors file could not be parsed — check manually."
echo "$MXBUILD_OUT" | grep -v "^$\|icon\|Microsoft\|Assembly\|__" | head -10 || true
mxb_log_hint
elif [ "$CE_COUNT" = "0" ] && [ "$MXBUILD_EXIT" -ne 0 ]; then
# Non-empty errors file, 0 Error problems, NON-ZERO exit: mxbuild never checked the
# model. The reason sits in errors[], which err_count does not read, e.g. (verbatim,
Expand All @@ -1658,6 +1679,7 @@ if [ -x "$MXBUILD" ] && [ -x "$JAVA_EXE" ]; then
echo " ✗ mxbuild exited $MXBUILD_EXIT and listed 0 model problems — it did NOT check the model."
echo " ${MXB_WHY:-no reason in errors[]; mxbuild output follows}"
[ -z "$MXB_WHY" ] && { echo "$MXBUILD_OUT" | grep -v "^$\|icon\|Microsoft\|Assembly\|__" | tail -10 || true; }
mxb_log_hint
echo " A version mismatch is fixed by the matching mxbuild: ./mxcli setup mxbuild -p $MPR_BASE"
echo " (find_mxbuild prefers it once cached), or MXBUILD_PATH=<that mxbuild>."
elif [ "$CE_COUNT" = "0" ]; then
Expand Down Expand Up @@ -1797,6 +1819,7 @@ EOF
echo " ✗ mxbuild failed to run (exit $MXBUILD_EXIT) — gate could not verify the model."
[ -n "$MXB_WHY" ] && echo " mxbuild says: $MXB_WHY"
echo "$MXBUILD_OUT" | grep -v "^$\|icon\|Microsoft\|Assembly\|__" | head -20 || true
mxb_log_hint
# mxbuild CRASHED ON THE WRITTEN MODEL (field 2026-10-13, Mendix 11.14.0, mxcli v0.25.0): a
# two-hop data source passed `check`, then mxbuild's loader died with no errors file. This
# branch kept the write, the next exec printed "Baseline NOT measured" and wrote on top of a
Expand Down
114 changes: 114 additions & 0 deletions tests/wave2/test-exec-gate-log.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# test-exec-gate-log.sh - exec.sh keeps the gate's full mxbuild output when the gate does not
# end clean.
#
# usage: bash tests/wave2/test-exec-gate-log.sh [path-to-exec.sh]
#
# Field (a migration project, 12 modules, 2026-10-16/17): exec.sh printed `head -20` of mxbuild's
# output and deleted the rest with its temp file, so the detail of a failed gate (a CE1613 among
# them) was lost. Now the full output goes to .mpr-snapshots/mxbuild-logs/<run>.log; the screen
# shows the top lines plus the log path, and the BUILD-LOG row names the log.
#
# A mxbuild fails with a long output: the log holds the whole output (including the tail the
# screen cut off), the screen names the path, the row names the path [guard: survives rollback]
# B a clean run writes no log and the row has no log note [guard]
# C only the newest 30 logs are kept
#
# Synthetic only: throwaway git repo, stub mxcli, stub mxbuild, stub java. Nothing real runs.
set -uo pipefail

TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SUBJECT="${1:-$TOOLKIT/project-bin/exec.sh}"
case "$SUBJECT" in /*) ;; *) SUBJECT="$PWD/$SUBJECT" ;; esac
WORK="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/exec-gatelog.XXXXXX")" && pwd -P)" || exit 2
trap 'rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo " ok - $1"; }
bad() { FAIL=$((FAIL+1)); echo " FAIL - $1${2:+: $2}"; }
echo "== subject: $SUBJECT"

P="$WORK/proj"
mkdir -p "$P/bin" "$P/mprcontents" "$P/mdlsource" "$WORK/jdk/bin"
printf 'not a real model\n' > "$P/Fixture.mpr"
printf 'bson\n' > "$P/mprcontents/a.mxunit"
printf 'CREATE MODULE "Nope";\n' > "$P/mdlsource/test.mdl"
cp "$TOOLKIT/project-bin/_common.sh" "$TOOLKIT/project-bin/restore-mpr.sh" "$P/bin/"
cp "$SUBJECT" "$P/bin/exec.sh"
cat > "$P/bin/snapshot-mpr.sh" <<'SNAP'
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
D=".mpr-snapshots/$(date +%Y%m%d-%H%M%S)-$$-$RANDOM"
mkdir -p "$D"; cp Fixture.mpr "$D/"; cp -r mprcontents "$D/"
echo "Snapshot saved: $D"
SNAP
# The write: `mxcli exec` appends to the model, as a real script would.
cat > "$P/mxcli" <<'MXCLI'
#!/usr/bin/env bash
D="$(cd "$(dirname "$0")" && pwd)"
case "${1:-}" in
check) exit 0 ;;
exec) echo "written by the script" >> "$D/Fixture.mpr"; echo "x" >> "$D/mprcontents/a.mxunit"; echo "fake mxcli exec"; exit 0 ;;
esac
exit 0
MXCLI
chmod +x "$P/bin/"*.sh "$P/mxcli"
# mxbuild: MXB_MODES per call number, comma list: ok | long (exit 137, 40 lines of output, no errors file)
cat > "$WORK/mxbuild" <<'MXB'
#!/usr/bin/env bash
n=$(( $(cat "$MXB_N" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$MXB_N"
mode="$(printf '%s\n' "$MXB_MODES" | tr ',' '\n' | sed -n "${n}p")"; mode="${mode:-ok}"
err=""; for a in "$@"; do case "$a" in --write-errors=*) err="${a#--write-errors=}" ;; esac; done
case "$mode" in
ok) [ -n "$err" ] && printf '{"problems":[]}' > "$err"; exit 0 ;;
long) i=1; while [ "$i" -le 40 ]; do echo "mxbuild output line $i"; i=$((i+1)); done; echo "TAIL-MARKER the real failure detail"; exit 137 ;;
err) [ -n "$err" ] && printf '{"problems":[{"severity":"Error","code":"CE0001","message":"boom","element":{"document":"M.X"}}]}' > "$err"; exit 1 ;;
esac
MXB
printf '#!/usr/bin/env bash\nexit 0\n' > "$WORK/jdk/bin/java"
chmod +x "$WORK/mxbuild" "$WORK/jdk/bin/java"
( cd "$P" && git init -q . && printf '.mpr-snapshots/\ndocs/\n.claude/\n' > .gitignore && git add -A \
&& git -c user.email=t@t -c user.name=t commit -qm fixture ) >/dev/null 2>&1

LOG="$P/docs/BUILD-LOG.md"
sum() { cat "$P/Fixture.mpr" "$P/mprcontents/a.mxunit" | cksum; }
BEFORE="$(sum)" # the committed model; run() resets to it
# run <label> <modes> - resets the model, runs the default path, prints the exit code
run() {
( cd "$P" && git checkout -q -- Fixture.mpr mprcontents && rm -rf $([ -n "${KEEP_SNAP:-}" ] || echo .mpr-snapshots) docs .claude ) >/dev/null 2>&1
rm -f "$WORK/n"; mkdir -p "$WORK/tmp"
( cd "$P" && TMPDIR="$WORK/tmp" SP_RESTART=0 MXTK_NO_INSTALL=1 MXB_N="$WORK/n" MXB_MODES="$2" \
MXBUILD_PATH="$WORK/mxbuild" JAVA_HOME="$WORK/jdk" ./bin/exec.sh mdlsource/test.mdl ) >"$WORK/out.$1" 2>&1
echo $?
}
lastrow() { tail -1 "$LOG" 2>/dev/null; }
kept() { grep -q 'written by the script' "$P/Fixture.mpr"; }

echo "== A: baseline ok, mxbuild fails with a long output: full log kept =="
RC=$(run A ok,long)
LOGF="$(ls "$P"/.mpr-snapshots/mxbuild-logs/*.log 2>/dev/null | head -1)"
[ -n "$LOGF" ] && ok "a per-run log exists under .mpr-snapshots/mxbuild-logs/" || bad "A: no log" "$(ls -R "$P/.mpr-snapshots" 2>&1 | tail -5)"
grep -q 'TAIL-MARKER' "$LOGF" 2>/dev/null && grep -q 'mxbuild output line 1$' "$LOGF" && ok "log holds the whole output, tail included" || bad "A: log content" "$(tail -3 "$LOGF" 2>/dev/null)"
grep -q 'TAIL-MARKER' "$WORK/out.A" && bad "A: the screen printed the tail (not truncated)" || ok "screen still shows only the top lines"
grep -q "full mxbuild output: .mpr-snapshots/mxbuild-logs/" "$WORK/out.A" && ok "screen names the log path" || bad "A: no path on screen" "$(tail -12 "$WORK/out.A")"
lastrow | grep -q 'mxbuild log: .mpr-snapshots/mxbuild-logs/' && ok "BUILD-LOG row names the log" || bad "A: row" "$(lastrow)"
kept && bad "A: write kept" || ok "rollback still applies and the log survived it"

echo "== B: clean run: no log, no note [guard] =="
RC=$(run B ok,ok)
[ "$RC" -eq 0 ] && kept && ok "exit 0, write kept" || bad "B rc=$RC" "$(tail -8 "$WORK/out.B")"
[ -z "$(ls "$P"/.mpr-snapshots/mxbuild-logs/*.log 2>/dev/null)" ] && ok "no log on a clean gate" || bad "B: a log was written"
lastrow | grep -q 'mxbuild log' && bad "B: row mentions a log" || ok "row has no log note"

echo "== C: only the newest 30 logs are kept =="
( cd "$P" && git checkout -q -- Fixture.mpr mprcontents && rm -rf .mpr-snapshots docs .claude ) >/dev/null 2>&1
mkdir -p "$P/.mpr-snapshots/mxbuild-logs"
i=1; while [ "$i" -le 35 ]; do : > "$P/.mpr-snapshots/mxbuild-logs/20200101-0000$(printf %02d "$i")-$i.log"; i=$((i+1)); done
RC=$(KEEP_SNAP=1 run C ok,long)
N="$(ls "$P"/.mpr-snapshots/mxbuild-logs/*.log 2>/dev/null | wc -l | tr -d ' ')"
[ "$N" -eq 30 ] && ok "35 old + 1 new pruned to 30" || bad "C: $N logs"
[ -f "$P/.mpr-snapshots/mxbuild-logs/20200101-000001-1.log" ] && bad "C: the oldest log survived" || ok "the oldest are the ones removed"

echo ""
echo "PASS: $PASS FAIL: $FAIL"
[ "$FAIL" -eq 0 ]
Loading