diff --git a/CHANGELOG.md b/CHANGELOG.md index c7eacbd4..b1ae32ae 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(exec, pre-commit hook): **a write no longer leaves units that changed only in XPath whitespace for the reviewer to read.** mxcli v0.27.0's `alter entity … rename attribute` rewrote 135 units in 20 unrelated modules that way (mendixlabs/mxcli#1596). `exec.sh` now runs `model-scope.py restore` after the write (MDL and `--patch`) and before the mxbuild gate, so the gate checks the final model; it prints the real changes per module and puts a `scope:` note in the BUILD-LOG row. It never blocks: a failure is a warning, `MXTK_NO_SCOPE=1` skips it, and it is skipped when `FORCE_EXEC` let the write run over uncommitted model changes, where the diff against `HEAD` is not this write's alone. The pre-commit hook (marker v2) runs `model-scope.py check --staged` and refuses staged noise, naming `restore` as the remedy; `MODEL_SCOPE_OK=1` lets one commit through; no `model-scope.py` skips the check, and no Python means a warning only. `install-project-hooks.sh --check` reports a v1 hook as stale and a re-run rewrites it in place. Fixture `tests/wave2/test-model-scope.sh` sections E and F (21 checks; 13 fail against master's `exec.sh` and hook) — saingge - process(design-artifacts, conversion-runbook): **Stage 3 runs in parallel by default, from one brief and a mechanical task list**: the lead fixes modules, canonical names and design tokens, writes `docs/progress/stage3-brief.md`, and takes the tasks from the stage-3 lines of `bin/lib/artifact-check.sh` (so no artifact is forgotten); one subagent per 1–2 screens or artifact group, a shared opening line so the start is cached, mechanical tasks on the cheap tier, 5-line replies, the lead the only writer of `PROJECT.md`. The runbook's Stage 3 fan-out row points there, and its Stage 2 row names `facts-lock.sh resolve`. Measured on one project (single runs, Sonnet, 250k auto-compact): serial 13 min / $3.60, parallel 10 min / $4.44, parallel with a brief 6 min / $4.32; 0 name mismatches in the parallel runs (serial dropped 3 entities); both parallel runs forgot the process map and swimlanes, which the task list now covers. Baseline words 64,004 before and after — Stage 3 experiment 2026-10-28 - fix(facts-lock, gate-check): **parallel BRDs keep one spelling per identifier**: `bin/facts-lock.sh resolve` freezes each casing conflict (`WIPStatus`/`WipStatus`) whose majority spelling is used by strictly more BRD sources than any other and records the count in the lock (`resolvedBy`); a tie stays contested, and `build` still never picks on its own. Stage 2 of `gate-check.sh` now reads the lock once there are 2+ BRDs: a BRD that contradicts a frozen identifier FAILs and is quoted; no lock yet is a note, never a FAIL. Measured cause: 20 parallel BRDs on PROJECT-C carried 10 casing conflicts into Stage 3 as open questions, and facts-lock.sh, documented only as advice, reached 1 of 6 projects. Fixtures: `test-facts-lock.sh` resolve cases (4), `test-bug03-gates.sh` T19 (2 of 4 fail on the previous gate-check) — speed/parallel experiments 2026-10-28 - fix(row-groups): **a build plan whose number and Kind cells are formatted as code (`` `BUILD` ``, `` `12` ``) is read like a plain one**: before, it read 0 BUILD rows, so with `Row groups: on` grouping silently did nothing (the fast-lane agent in the 2026-10-28 speed experiment had to strip the backticks by hand). Fixture: `test-row-groups.sh` G5 (both cases fail on the previous row-groups.sh) — speed experiment 2026-10-28 @@ -80,7 +81,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m - new(canvas): **`project-bin/mf-canvas-facts.py` prints how a microflow or nanoflow lies on the Studio Pro canvas: labels on top of each other, lines through objects, note lines leaving the side that faces away, flows that run right-to-left or curl, captions wider than their shape, and decision captions that ask the opposite of their true branch (SENSE).** It prints facts, one line each, and no verdicts; read-only (it reads units through `mxpatch.py`), exit 2 when a flow could not be read. `verify-module.sh` runs it per module as an info rung (1c, log `14-canvas-facts.log`; a missing script, Python or `.mpr`, an exit 2 or a timeout is an INFO row reading NOT MEASURED, never a gate). Fixture `tests/wave2/test-mf-canvas-facts.sh` runs on a model mxcli v0.27.0 wrote (units and `.mpr` captured); `test-verify-module-446.sh` gains V8 for the rung. Calibration is from Studio Pro 10.24 screenshots, not verified on Studio Pro 11. Field evidence, measured 2026-10-21 on one Mendix 10.24 app: 1,588 flows read in one pass; on the 36 flows an mxcli-built branch added or changed in one module, OVERLAP 19, LINE 27, NOTE-SIDE 17, LEFTWARD 3, TURN 4, SENSE 9 before repair — saingge - learn(preflight, teamserver-alignment): **a flow's return-type change and its callers' update go in one script (STOP row 33), and Studio Pro 10.24.18's "Merge feature branch" merges `origin/`, not the local branch.** On a copy of a Mendix 10.24.18 app with mxcli v0.27.0, the first script alone left `mx check` with CE0112 at a caller stored the way Studio Pro stores a call, while `check` and `exec` stayed silent; Studio Pro's own git log shows the merge of the remote branch. Text only; baseline-routed, +101 words. — saingge - fix(doctor, preflight, platform-link): **a clean `fix property-sets --dry-run` on Mendix below 10.24.24 is no longer reported as "every unit has the property names Studio Pro writes".** mxcli's property table starts at 10.24.24, so on 10.24.18 (mxcli v0.27.0) the dry-run printed "Every unit already has the property names Studio Pro writes" while Mendix's `mx diff` exited 129 on a unit mxcli had written (mendixlabs/mxcli#1595). `doctor.sh --property-sets`, `--if-stale` and `--quick` now WARN "NOT PROOF" and name `mx diff .mpr .mpr out.json` (exit 0) as the proof; the receipt records `unproven`; exit stays 0. `learned-detection-gaps.md` and `platform-link.md` said v0.26.0 closed the gap. Fixture P2b (11 cases). Baseline-routed, +18 words. — saingge -- new(model-scope): **a write's whitespace-only units can now be found and put back.** mxcli v0.27.0's `alter entity … rename attribute` rewrote 135 units in 20 modules of a Mendix 10.24.18 app that never referenced the attribute; the only change in each was the line breaks between bracketed XPath conditions, and `mxcli diff-local` showed nothing (mendixlabs/mxcli#1596). New `project-bin/model-scope.py`: `report` sorts each changed unit into real or noise (every differing field is an XPath constraint that is equal once whitespace outside string literals is removed) and prints the real changes per module; `restore` checks the noise units out of `HEAD` and updates their ContentsHash rows through `mxpatch.py`; `check --staged` exits 1 when noise is staged (`MODEL_SCOPE_OK=1` lets one commit through). Not wired into `exec.sh` or the pre-commit hook yet. Fixture `tests/wave2/test-model-scope.sh`, with a golden before/after capture of a real v0.27.0 rename on a public model (`fixtures/model-scope/golden-v0.27.0/`) — saingge +- new(model-scope): **a write's whitespace-only units can now be found and put back.** mxcli v0.27.0's `alter entity … rename attribute` rewrote 135 units in 20 modules of a Mendix 10.24.18 app that never referenced the attribute; the only change in each was the line breaks between bracketed XPath conditions, and `mxcli diff-local` showed nothing (mendixlabs/mxcli#1596). New `project-bin/model-scope.py`: `report` sorts each changed unit into real or noise (every differing field is an XPath constraint that is equal once whitespace outside string literals is removed) and prints the real changes per module; `restore` checks the noise units out of `HEAD` and updates their ContentsHash rows through `mxpatch.py`; `check --staged` exits 1 when noise is staged (`MODEL_SCOPE_OK=1` lets one commit through). `exec.sh` and the pre-commit hook call it (see the `fix(exec, pre-commit hook)` entry above). Fixture `tests/wave2/test-model-scope.sh`, with a golden before/after capture of a real v0.27.0 rename on a public model (`fixtures/model-scope/golden-v0.27.0/`) — saingge - fix(graph-sweep, lint): **the orphan list now says a flow used only by a pluggable widget's datasource or action property may have no catalog edge, and prints the one `mxcli search` that finds the page (it needs a `REFRESH CATALOG FULL SOURCE` catalog; this sweep reads a FULL one, so the note says to rebuild).** The caveat named only layout snippet calls, so a live flow read as "strong evidence of deadness" (`graph-sweep.sh` comment, `review-report.js` orphan detail, and the `lint-that-actually-runs.md` QUAL004 row, which only said the catalog "likely misses" the ref). Observed on a Mendix 10.24 app with mxcli v0.27.0: 5 live flows used only as a Gallery's or a third-party widget's microflow datasource had 0 `refs` rows, `show references to` said "no references found", `describe page` printed each, and QUAL004 flagged each. A Gallery with a microflow datasource and no microflow `onClick` had its ref (15 of 15); with both, only the `onClick` ref (2 of 2); the two Data grid 2 microflow datasources had theirs. The QUAL004 row now states that and links [mendixlabs/mxcli#1513](https://github.com/mendixlabs/mxcli/issues/1513) (open; reported for Data grid 2 on v0.26.0); the ledger entry and the pending-issue draft now say they are filed. Pinned by `test-catalog-edge-guard.sh` case 1 (fails on 393d253). — saingge - fix(bug-lookup): **`bin/bug-lookup.sh` also searches the project's own `bug-logs/**.md` drafts, marked `(project draft: :)`, after the ledger's output.** It searched only the toolkit ledger, so a bug a session had already written down in the project was invisible to the lookup run before the next experiment on the same error; the ledger's output and ranking are unchanged (drafts are ranked in a separate pass), `--full` and `--index` stay ledger-only, and `MXTK_PROJECT_DIR=` turns drafts off. Field: a Mendix 10.24 app with mxcli v0.27.0, 43 files in `bug-logs/`; the query "cannot be spliced" listed 0 project drafts at 393d253 and lists 3 (3 distinct files) now, each pointing at a `## ` line; the project was not written. Pinned by group P of `tests/wave2/test-bug-lookup.sh` (3 of its checks fail on 393d253) on synthetic drafts; the run under /bin/bash 3.2.57 passes, other awks not run. — saingge - fix(harvest-learnings): **section 1 drafted only the first `## ` section of each bug-log file.** The awk splitter's `print buf "\x01"` put a newline after each delimiter, so every later section began with an empty line, read an empty heading and was skipped. It is `printf "%s\x01", buf` now. Field run on a Mendix 10.24 app with mxcli v0.27.0 (43 bug-log files, 54 `## ` sections): `harvest-learnings.sh` at 393d253 drafted 43, the fix drafts 54. Pinned by `tests/wave2/test-harvest-learnings.sh` T5, on a two-file fixture that keeps a captured log's structure with its text replaced (fixed: 7 of 7 sections, 20 of 20 checks; 393d253: 2 of 7, T5 fails). The splitter's delimiter bytes were checked under macOS awk 20200816 only. — saingge diff --git a/project-bin/exec.sh b/project-bin/exec.sh index fba7cfc8..99d8f6ab 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -2,6 +2,7 @@ # exec.sh — the guard chain around a model write. # # STOP-row scan → concurrent-writer guard → module-brief advisory → mxcli check → snapshot → baseline → exec +# → model-scope restore (units changed only in whitespace go back to HEAD; model-scope.py) # → mxbuild delta gate → auto-restore only on a NEW error → lint ratchet → SP reopen # # The mxbuild gate is a DELTA gate, not an absolute "0 errors" one: a write is kept @@ -514,7 +515,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}${ROWS_NOTE:+ · $ROWS_NOTE}${RESTATE_NOTE:+ · $RESTATE_NOTE}${GATE_NOTE:+ · $GATE_NOTE}${MXB_LOG_NOTE:+ · $MXB_LOG_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}${ROWS_NOTE:+ · $ROWS_NOTE}${RESTATE_NOTE:+ · $RESTATE_NOTE}${GATE_NOTE:+ · $GATE_NOTE}${MXB_LOG_NOTE:+ · $MXB_LOG_NOTE}${HV_NOTE:+ · $HV_NOTE}${SCOPE_NOTE:+ · $SCOPE_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" @@ -569,6 +570,7 @@ take_score() { # check and the mxbuild gate still stand behind it. SCAN_NOTE=""; HV_NOTE="" # HV_NOTE: the ContentsHash bracket's cell text, set below RESTATE_NOTE="" # the restatement warnings' cell text (#269, #261), set after the check +SCOPE_NOTE="" # the model-scope restore's cell text, set after the write if [ "$PATCH_MODE" != "1" ]; then _SCAN="" for _s in "${MXTK_ROOT:-}/bin/mdl-scan.sh" \ @@ -1834,6 +1836,37 @@ if [ "$EXEC_STATUS" -ne 0 ]; then echo " Running the gate anyway." fi +# ── Model scope: put back units that changed only in whitespace ────────────── +# mxcli v0.27.0's `alter entity ... rename attribute` rewrote 135 units in 20 modules that never +# referenced the attribute, removing the line breaks in their XPath constraints (mxcli#1596); +# `mxcli diff-local` showed nothing. model-scope.py restore puts such units back from HEAD and +# updates their ContentsHash rows. It runs after the write (MDL and --patch alike) and before +# the mxbuild gate, so the gate checks the final model. The model was committed when this exec +# started (the refusal above), so the diff against HEAD is this write's change; when FORCE_EXEC let +# the write run over uncommitted model changes it is not, and the step is skipped. Never blocks: a +# failure is a warning, and model-scope.py says what it left (a part-way restore keeps the index +# matching). MXTK_NO_SCOPE=1 skips it. +_ms="$PROJECT_ROOT/bin/model-scope.py" +if [ "${MXTK_NO_SCOPE:-0}" != "1" ] && [ -z "$MPR_DIRTY" ] && [ -f "$_ms" ] && [ -n "${PY:-}" ] \ + && [ -d "$MODEL_DIR/mprcontents" ]; then + echo "" + echo "→ Model scope: units this write changed only in whitespace go back to HEAD..." + _ms_rc=0 + _ms_out="$("$PY" -I "$(native_path "$_ms")" restore --mpr "$(native_path "$MPR")" 2>&1)" || _ms_rc=$? + printf '%s\n' "$_ms_out" | sed 's/^/ /' + if [ "$_ms_rc" -ne 0 ]; then + echo " ⚠ model-scope exited $_ms_rc (its message is above); the write is kept, with any noise not" + echo " restored still in it. To see what is left, then retry:" + echo " python3 -I bin/model-scope.py report -v" # portability-ok: command text shown to the user, not run here + echo " python3 -I bin/model-scope.py restore" # portability-ok: command text shown to the user, not run here + SCOPE_NOTE="scope: incomplete (exit $_ms_rc)" + else + # First line: "model scope vs HEAD: N unit file(s) changed, R real, K noise". + SCOPE_NOTE="$(printf '%s\n' "$_ms_out" | sed -n '1s/^model scope vs [^:]*: /scope: /p')" + case "$_ms_out" in *"restored "*) SCOPE_NOTE="$SCOPE_NOTE restored" ;; esac + fi +fi + step_done exec echo "" echo "→ Running mxbuild model check (catches BSON corruption before SP opens)..." diff --git a/project-bin/install-project-hooks.sh b/project-bin/install-project-hooks.sh index f4cc60ce..fbef05d4 100755 --- a/project-bin/install-project-hooks.sh +++ b/project-bin/install-project-hooks.sh @@ -37,6 +37,16 @@ # holds a PASS stamp for exactly that staged content. Commits that touch no model file # are untouched. It runs on every machine, cloud or laptop, with no toolkit clone needed. # +# v2 (model-scope): the hook also runs model-scope.py check --staged and refuses a commit that +# stages units whose bytes changed but whose meaning did not (XPath whitespace only; see +# model-scope.py). One mxcli rename rewrote 135 units in 20 unrelated modules that way, and a +# reviewer had to read all of them. The remedy is `python3 -I bin/model-scope.py restore` (not a +# commit, so rule 7 holds); MODEL_SCOPE_OK=1 lets one commit through, out loud. A project whose +# bin/ has no model-scope.py skips the check; a machine with no Python gets one warning; the commit stands. +# Version marker: "# mxtk-model-stamp hook v". Any version is OURS (never chained as a foreign +# hook); only the current one counts as installed, so --check reports an older hook as stale and +# a re-run rewrites it in place. +# # Guard rules (toolkit CLAUDE.md, "Shipping an instrument" rules 6/7): # * rule 7 — it never blocks the remedy: ./bin/verify-model.sh is not itself a commit, so # there is always a way to earn the stamp without going through this hook. @@ -57,7 +67,8 @@ cd "$PROJECT_ROOT" git rev-parse --show-toplevel >/dev/null 2>&1 || { echo "install-project-hooks.sh: $PROJECT_ROOT is not in a git repository"; exit 2; } HOOKS="$(git rev-parse --git-path hooks)" HOOK="$HOOKS/pre-commit" -MARK="# mxtk-model-stamp hook v1" +MARK_PREFIX="# mxtk-model-stamp hook v" +MARK="${MARK_PREFIX}2" # bin/ relative to the git top level, so the hook works when the project root is a subdir. BIN_REL="$(git rev-parse --show-prefix)bin" @@ -86,6 +97,9 @@ leftover() { # ours | foreign | none if [ -n "$(git config --get core.hooksPath 2>/dev/null)" ]; then echo "⚠ core.hooksPath is set ($(git config --get core.hooksPath)) — add this to that pre-commit yourself:" echo " \"\$(git rev-parse --show-toplevel)/$BIN_REL/model-stamp.sh\" check --staged || exit 1" + echo " and, before it, the scope check (refuses units that changed only in whitespace; exit 1 refuses, other codes only warn):" # portability-ok: advice text + echo " b=\"\$(git rev-parse --show-toplevel)/$BIN_REL\"; py=\"\$(PROJECT_ROOT=\"\$b/..\" bash -c '. \"\$1/_common.sh\" >/dev/null 2>&1 && resolve_py' _ \"\$b\")\"" + echo " [ -z \"\$py\" ] || { \"\$py\" -I \"\$b/model-scope.py\" check --staged --project \"\$b/..\"; [ \$? != 1 ] || exit 1; }" echo " and this to its post-commit (revision notes for Studio Pro History; python via resolve_py, never the shebang):" # portability-ok: advice text echo " b=\"\$(git rev-parse --show-toplevel)/$BIN_REL\"; py=\"\$(PROJECT_ROOT=\"\$b/..\" bash -c '. \"\$1/_common.sh\" >/dev/null 2>&1 && resolve_py' _ \"\$b\")\"" echo " [ -n \"\$py\" ] && MXTK_HOOK=1 \"\$py\" \"\$b/mx-metadata-note.py\" write HEAD || true" @@ -98,6 +112,7 @@ fi if [ "${1:-}" = "--check" ]; then rc=0 if [ -f "$HOOK" ] && grep -q "^$MARK" "$HOOK"; then echo " ✓ pre-commit hook installed ($HOOK)" + elif [ -f "$HOOK" ] && grep -q "^$MARK_PREFIX" "$HOOK"; then echo " ✗ pre-commit hook is an older version (no model-scope check) — run ./bin/install-project-hooks.sh"; rc=1 else echo " ✗ pre-commit model-verification hook missing — run ./bin/install-project-hooks.sh"; rc=1; fi for h in post-commit post-rewrite; do if [ -f "$HOOKS/$h" ] && grep -q "^$NOTE_MARK" "$HOOKS/$h"; then echo " ✓ $h revision-note hook installed" @@ -117,7 +132,7 @@ if [ "${1:-}" = "--check" ]; then fi mkdir -p "$HOOKS" -if [ -f "$HOOK" ] && ! grep -q "^$MARK" "$HOOK"; then +if [ -f "$HOOK" ] && ! grep -q "^$MARK_PREFIX" "$HOOK"; then mv "$HOOK" "$HOOK.pre-mxtk" echo " existing pre-commit hook kept as $(basename "$HOOK").pre-mxtk and chained first" fi @@ -126,6 +141,20 @@ cat > "$HOOK" </dev/null 2>&1 && resolve_py' _ "\$proj/bin" 2>/dev/null)" + if [ -z "\$py" ]; then + echo "⚠ no python found — the model-scope check (units changed only in whitespace) was skipped for this commit" >&2 # portability-ok: resolve_py above is the probe + else + "\$py" -I "\$scope" check --staged --project "\$proj"; rc=\$? + [ "\$rc" = 1 ] && exit 1 + [ "\$rc" = 0 ] || echo "⚠ the model-scope check could not run (exit \$rc) — skipped for this commit" >&2 + fi +fi stamp="\$top/$BIN_REL/model-stamp.sh" if [ ! -x "\$stamp" ]; then echo "⚠ $BIN_REL/model-stamp.sh is missing — the model-verification hook cannot check this commit (re-run the toolkit's sync-project.sh)" >&2 @@ -162,6 +191,7 @@ HOOKEOF chmod +x "$HOOK" echo " ✓ pre-commit hook installed -> $HOOK" echo " (refuses to commit .mpr/mprcontents changes that have no mxbuild-gate PASS stamp; ./bin/verify-model.sh writes one)" +echo " (and units that changed only in whitespace: python3 -I bin/model-scope.py restore puts them back)" # portability-ok: command text shown to the user, not run here # --- revision notes for Studio Pro History (post-commit + post-rewrite) ------------------------- # Both hooks find Python through bin/_common.sh's resolve_py (executes each candidate, skips the diff --git a/project-bin/model-scope.py b/project-bin/model-scope.py index 8c2e5728..46614275 100755 --- a/project-bin/model-scope.py +++ b/project-bin/model-scope.py @@ -25,7 +25,8 @@ every unit. restore report, then put each noise unit back from REF (`git checkout REF -- `) and set its ContentsHash row in the .mpr from the restored file (mxpatch.py's rehash), so - the index Studio Pro reads stays in step. Run it by hand; nothing calls it yet. + the index Studio Pro reads stays in step. bin/exec.sh runs this after each write to a + committed model. check --staged: the staged index against HEAD, for the pre-commit hook. Exit 1 when a noise unit is staged, printing the restore command. MODEL_SCOPE_OK=1 lets that one commit through with a warning. diff --git a/tests/wave2/test-model-scope.sh b/tests/wave2/test-model-scope.sh index 174e8f53..9f4260d9 100755 --- a/tests/wave2/test-model-scope.sh +++ b/tests/wave2/test-model-scope.sh @@ -17,6 +17,10 @@ # C restore: second run writes nothing, --against REF, a model under app/, clean tree, # a checkout that stops part-way leaves the index matching [guard] # D check --staged: exit codes, the restore command, MODEL_SCOPE_OK=1, no model yet +# E the pre-commit hook (install-project-hooks.sh): refuses staged noise [guard], override, +# a v1 hook is reported stale and upgraded in place, no model-scope.py = no block +# F exec.sh: the noise is restored before the mxbuild gate, which sees the final model [guard]; +# MXTK_NO_SCOPE=1 control; --patch; no model-scope.py = exec.sh as before # # B-D run on SYNTHETIC models (fixtures/model-scope/mkmodel.py), shaped like the capture. # The ContentsHash check is the fixtures' own code (mkmodel.py hashes), never the subject's. @@ -174,6 +178,119 @@ mkm edit "$R" MfB1 ws; mkm edit "$R" MfA1 ws; mkm edit "$R" PgA ws; ( cd "$R" && out="$(ms check --staged 2>&1)" echo "$out" | grep -q 'refused: 3 staged unit file' && ok "counts every staged noise unit" || bad "count" "$out" +echo "== E: the installed pre-commit hook" +SD="$(dirname "$SUBJECT")" +mk e1 +mkdir -p "$R/bin" +cp "$TK/project-bin/_common.sh" "$TK/project-bin/model-stamp.sh" "$TK/project-bin/install-project-hooks.sh" "$R/bin/" +cp "$SUBJECT" "$SD/mxpatch.py" "$SD/mxunit_bson.py" "$R/bin/" +( cd "$R" && printf 'bin/\n' > .gitignore && gitq add .gitignore && gitq commit -qm ignore ) +( cd "$R" && ./bin/install-project-hooks.sh >/dev/null 2>&1 ) +grep -q 'model-scope.py' "$R/.git/hooks/pre-commit" && ok "hook calls model-scope.py" || bad "hook text" +( cd "$R" && ./bin/install-project-hooks.sh --check >/dev/null 2>&1 ) && ok "--check: current hook accepted" || bad "--check rejected the current hook" +mkm edit "$R" MfB1 ws; ( cd "$R" && git add -A ) +out="$(cd "$R" && MODEL_UNVERIFIED_OK=1 gitq commit -qm noise 2>&1)"; rc=$? +[ "$rc" -ne 0 ] && echo "$out" | grep -q 'commit refused: 1 staged unit file' && ok "commit with noise refused by the hook [guard]" || bad "noise commit went through rc=$rc" "$out" +[ "$(cd "$R" && git rev-list --count HEAD)" = 2 ] && ok "no commit was made" || bad "a commit was made" +out="$(cd "$R" && MODEL_SCOPE_OK=1 MODEL_UNVERIFIED_OK=1 gitq commit -qm noise 2>&1)"; rc=$? +[ "$rc" -eq 0 ] && ok "MODEL_SCOPE_OK=1 lets one commit through" || bad "override failed rc=$rc" "$out" +mkm edit "$R" MfB2 field; ( cd "$R" && git add -A ) +out="$(cd "$R" && MODEL_UNVERIFIED_OK=1 gitq commit -qm real 2>&1)"; rc=$? +[ "$rc" -eq 0 ] && ok "a commit with only real changes is untouched" || bad "real commit refused rc=$rc" "$out" +# An older hook (v1) is OURS: --check reports it stale, a re-run rewrites it in place, never chains it. +sed -i.bak 's/hook v2/hook v1/' "$R/.git/hooks/pre-commit"; rm -f "$R/.git/hooks/pre-commit.bak" +out="$(cd "$R" && ./bin/install-project-hooks.sh --check 2>&1)"; rc=$? +[ "$rc" -eq 1 ] && echo "$out" | grep -q 'older version' && ok "--check reports a v1 hook as stale" || bad "stale not reported rc=$rc" "$out" +( cd "$R" && ./bin/install-project-hooks.sh >/dev/null 2>&1 ) +[ ! -e "$R/.git/hooks/pre-commit.pre-mxtk" ] && grep -q 'hook v2' "$R/.git/hooks/pre-commit" && ok "re-install upgrades v1 in place, no chaining" || bad "v1 chained or not upgraded" +mv "$R/bin/model-scope.py" "$R/bin/model-scope.py.off" +mkm edit "$R" MfA1 ws; ( cd "$R" && git add -A ) +out="$(cd "$R" && MODEL_UNVERIFIED_OK=1 gitq commit -qm "no scope script" 2>&1)"; rc=$? +[ "$rc" -eq 0 ] && ok "no model-scope.py in the project: the hook does not block" || bad "blocked without the script rc=$rc" "$out" + +echo "== F: exec.sh" +# A project with a synthetic model and a stub mxcli whose `exec` rewrites units the way the +# field rename did: 3 XPath-whitespace units and 1 real change. The mxbuild stub records how many +# unit files differ from HEAD each time it runs, so the test sees WHAT THE GATE SAW. +fproj() { # + P="$WORK/$1"; mkm init "$P" + mkdir -p "$P/bin" "$P/mdlsource" "$WORK/jdk/bin" + ( cd "$P" && git config user.email t@t && git config user.name t ) + cp "$TK/project-bin/_common.sh" "$TK/project-bin/restore-mpr.sh" "$TK/project-bin/exec.sh" "$P/bin/" + cp "$SUBJECT" "$SD/mxpatch.py" "$SD/mxunit_bson.py" "$P/bin/" + printf 'CREATE MODULE "Nope";\n' > "$P/mdlsource/test.mdl" + 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 App.mpr "$D/"; cp -r mprcontents "$D/" +echo "Snapshot saved: $D" +SNAP + cat > "$P/mxcli" < "$P/patch.py" < "$WORK/mxbuild" <<'MXB' +#!/usr/bin/env bash +n=$(( $(cat "$MXB_N" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$MXB_N" +echo "$n $(git -C "$MXB_PROJ" status --porcelain -uall -- mprcontents | grep -c mxunit)" >> "$MXB_SEEN" +err=""; for a in "$@"; do case "$a" in --write-errors=*) err="${a#--write-errors=}" ;; esac; done +[ -n "$err" ] && printf '{"problems":[]}' > "$err" +exit 0 +MXB + printf '#!/usr/bin/env bash\nexit 0\n' > "$WORK/jdk/bin/java" + chmod +x "$P/bin/"*.sh "$P/mxcli" "$WORK/mxbuild" "$WORK/jdk/bin/java" + printf '.mpr-snapshots/\ndocs/\n.claude/\nbin/\n/mxcli\n/patch.py\nmdlsource/\n' > "$P/.gitignore" + ( cd "$P" && git add .gitignore && git commit -qm ignore ) +} +frun() { # : runs exec.sh, prints its exit code + local p="$1"; shift + rm -f "$WORK/n" "$WORK/seen"; mkdir -p "$WORK/tmp" + ( cd "$p" && env TMPDIR="$WORK/tmp" SP_RESTART=0 MXTK_NO_INSTALL=1 MXB_N="$WORK/n" MXB_SEEN="$WORK/seen" MXB_PROJ="$p" \ + MXBUILD_PATH="$WORK/mxbuild" JAVA_HOME="$WORK/jdk" MXTK_NO_SCORE=1 "$@" ) > "$WORK/out" 2>&1 + echo $? +} +fchg() { ( cd "$P" && git status --porcelain -uall -- mprcontents | grep -c mxunit ); } + +fproj f1 +RC=$(frun "$P" ./bin/exec.sh mdlsource/test.mdl) +[ "$RC" -eq 0 ] && ok "exec.sh succeeds" || bad "exec.sh rc=$RC" "$(tail -15 "$WORK/out")" +grep -q '4 unit file(s) changed, 1 real, 3 noise' "$WORK/out" && grep -q 'restored 3 noise unit(s) from HEAD' "$WORK/out" \ + && ok "summary printed in exec.sh output" || bad "no summary" "$(grep -n -i 'scope\|restor' "$WORK/out")" +grep -q 'real changes by module: ModuleB 1' "$WORK/out" && ok "real change attributed to its module" || bad "no per-module line" +[ "$(fchg)" -eq 1 ] && ok "only the real change is left in the working tree" || bad "tree" "$(cd "$P" && git status --short)" +[ "$(mkm hashes "$P")" = clean ] && ok "ContentsHash index matches the files" || bad "index not clean" +# The gate is mxbuild call 2 (call 1 is the baseline, before the write): it must see the FINAL model. +[ "$(sed -n 2p "$WORK/seen" | awk '{print $2}')" = 1 ] && ok "the mxbuild gate saw the restored model (1 changed unit) [guard]" || bad "gate saw: $(cat "$WORK/seen")" +grep -q 'scope: 4 unit file(s) changed, 1 real, 3 noise restored' "$P/docs/BUILD-LOG.md" && ok "BUILD-LOG row carries the scope note" || bad "no BUILD-LOG note" "$(tail -2 "$P/docs/BUILD-LOG.md")" + +fproj f2 +RC=$(frun "$P" MXTK_NO_SCOPE=1 ./bin/exec.sh mdlsource/test.mdl) +[ "$(fchg)" -eq 4 ] && ! grep -q 'Model scope' "$WORK/out" \ + && ok "MXTK_NO_SCOPE=1: the noise stays (control: the restore is what removes it)" || bad "control" "$(cd "$P" && git status --short)" + +fproj f3 +RC=$(frun "$P" ./bin/exec.sh --patch patch.py) +[ "$RC" -eq 0 ] && ok "--patch run succeeds" || bad "--patch rc=$RC" "$(tail -15 "$WORK/out")" +[ "$(fchg)" -eq 1 ] && grep -q 'restored 2 noise unit(s)' "$WORK/out" \ + && ok "--patch: noise restored too" || bad "--patch tree" "$(grep -n -i 'scope\|restor' "$WORK/out"; cd "$P" && git status --short)" +[ "$(sed -n 2p "$WORK/seen" | awk '{print $2}')" = 1 ] && ok "--patch: the gate saw the restored model" || bad "gate saw: $(cat "$WORK/seen")" + +fproj f4 +rm "$P/bin/model-scope.py" +RC=$(frun "$P" ./bin/exec.sh mdlsource/test.mdl) +[ "$RC" -eq 0 ] && ! grep -q 'Model scope' "$WORK/out" && [ "$(fchg)" -eq 4 ] \ + && ok "no model-scope.py in bin/: exec.sh runs as before" || bad "missing script changed exec.sh rc=$RC" "$(tail -8 "$WORK/out")" + echo "" echo "PASS: $PASS FAIL: $FAIL" [ "$FAIL" -eq 0 ]