diff --git a/CHANGELOG.md b/CHANGELOG.md index f248f154..af9972de 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): **re-running an older `mdlsource/` script over a newer owner's live work now WARNs instead of passing silently** (#269). Three field apps had 99/579, 130/357 and 38/159 documents created by more than one script, and a replay of an older one reverted the newer with exit 0 and every gate green. New `project-bin/script-owners.sh` maps each microflow, nanoflow, page and snippet to the scripts that create or restate it, in run order (digit runs compared as numbers, so `2-core/` runs before `11-fixes/`; `done-` ignored), and reads `-- supersedes:` / `-- superseded-by:` headers. `exec.sh` records every kept write in a machine-local last-writer ledger (`.claude/loop/script-owners/`, self-gitignored, compacted per write); `restore-mpr.sh` marks it on a manual restore. A script counts as run when it has a ledger row, a `done-` name, or an applied `docs/BUILD-LOG.md` row, so a fresh clone or cloud session (empty ledger) still warns on a replay over committed newer work; a script nobody ran yet (draft-ahead) stays quiet. The ledger cannot see Studio Pro edits or a `git pull`/`checkout` of the `.mpr`. A replay WARNs only when the newer owner's write is live: ledger first, else one DESCRIBE per document, at most 5 per exec (the rest print `CONFLICT (not checked)`). A new owner without a supersedes header gets one `NOTE:` line on its first run, kept out of BUILD-LOG. Nothing is refused and the exit codes do not change. With no co-owner, `exec.sh` runs one grep over `mdlsource/` and no mxcli call. `gate-check.sh` prints the co-owned count when it is above 0. Part of the rebuilt draft #311 (with the #261 PR). **Behaviour change to check in your project:** `exec.sh` output can carry `WARN: restatement: …` and `NOTE: new owner of …` lines; the BUILD-LOG detail cell can carry `WARN reverts: `. Set `MXTK_RESTATE_GUARD=off` to skip the check. — MendixMau (#269) - fix(root): **the installed and toolkit copies of `_common.sh` now resolve the same project root from inside an app folder** (#489). With the cwd in `app/`, `app/javasource` or `apps//`, the installed copy (`/bin/_common.sh`, tier 2) answered the project while the toolkit copy (tier 3) stopped at the folder holding the `.mpr`, so the same script wrote its look ledger, metrics ledger and BUILD-LOG to two roots depending on where it was run from. `mxtk_walk_root` now returns the project folder when the folder it stops at is `

/app` or `

/apps/` and `

` is a marked project folder (an installed `bin/_common.sh`, or `.claude/` beside `PROJECT.md`); an unmarked app folder is still returned as itself, and so is an app folder that is a project of its own (its own install, or `.claude/` beside `PROJECT.md`); the folder names match in any letter case, and single-tree resolution is unchanged. Builtin tests only, no extra process. `find_mpr` now always prints an absolute path (a cwd-relative `MPR_FILE` broke once a caller did `cd "$PROJECT_ROOT"`), and with several apps and no `MPR_FILE`, sourcing `_common.sh` from inside one app's folder exports `MPR_FILE` as that app's model, so the pick survives exec.sh's `cd "$PROJECT_ROOT"` and reaches `snapshot-mpr.sh` (outside any one app nothing is picked and `find_mpr` still refuses; an explicit `MPR_FILE` or a root/`app/` model always wins; the pick is marked `MXTK_MPR_PICKED`, so a child script whose cwd is inside a different `apps//` re-derives instead of inheriting it, and `model-stamp.sh` says "(picked from your cwd)" when it refuses one). `lint-gate.sh` now honours `MPR_FILE` when `MPR` is unset and it resolves under the project root, so exec.sh run from inside one app no longer reaches its "several apps" refusal; an `MPR_FILE` that does not resolve there (cwd-relative, or absolute through a symlinked path) falls back to discovery as before, never to exit 2. An inherited pick from another project is dropped. **Behaviour change to check in your project:** a toolkit-copy script run from inside `app/` or `apps//` of a wired project now reads and writes state at the project root, as the installed copy already did; if `app/` (or `apps//`) holds `.mpr-snapshots/`, `docs/BUILD-LOG.md` or `.claude/loop/` from a toolkit-copy run, merge them into the project root. `find_mpr` with `MPR_FILE` set now prints an absolute path where it echoed a relative one. A project whose `bin/_common.sh` is hand-edited needs `sync-project.sh --only-bin _common.sh --upgrade-bin _common.sh`, as for #420. `test-common-root-multiapp.sh` R8a, R9c-f, R13g, R17a-j and R18a-h (cwd in `app/`, `app/javasource`, `apps/A`, `apps/A/javasource`, a nested or wired app folder, `App/`, both copies; the cwd's app surviving a `cd` to the root and a child `snapshot-mpr.sh`; a child in another app re-deriving, R18h a child in another project): 67/67 with the fix; `test-lint-gate-accept.sh` T-MA (lint-gate with `MPR_FILE` in a multi-app folder) and T-MF (an unresolvable `MPR_FILE` falls back to discovery). — MendixMau (review 2026-10-20) - fix(portability): **`bug-lookup.sh --index` and `test-routing-tier.sh` no longer depend on sed expanding `\t`, and `check-portability.sh` flags `\t` in a sed pattern or replacement outside a bracket** (#488). BSD sed (macOS) reads `\t` there as the letter `t`, so `sed -E 's/^([0-9]+):## /\1\t/'` fed `awk -F'\t'` no tab and `--index` printed 222 empty lines; the line-count check passed anyway. The index is now one awk (same 222 lines, byte-identical on GNU), the fixture builds its sed script with a real tab (`TAB=$'\t'`), and `test-bug-lookup.sh` runs `--index` under a sed shim that reads `\t` as `t` (fails on master: 0 non-empty lines). The lint also had a blind spot: its comment filter dropped any line holding `:#`, so the bug-lookup line was never checked; it is now anchored on the line number. New rule over the current tree: the two sites on master, 0 elsewhere. `test-sed-tab-bracket.sh` T4. Simulated on Linux only; one macOS run is still owed. — MendixMau (review 2026-10-20) - fix(look hook, verify-module, coverage): **the look hook records from any cwd, and `verify-module.sh` loses four instrument faults** (#446). (a) The PostToolUse(Read) hook was `bash bin/look-ledger.sh seen || true`: from any cwd but the project root (a subagent in a private copy, a shell in `app/javasource`) it exited 127 and `|| true` hid it (field: 30 pages owed, 1 seen). `install-claude-permissions.sh` now writes it to `cd "$CLAUDE_PROJECT_DIR"` first; a missing script or failed run goes to stderr, and is logged once to `.claude/loop/look/hook.log` when that can be written. The installer (and `sync-project.sh`'s hook repair) rewrites the old form in place and removes duplicate look hooks, so one remains; `doctor.sh` flags any look hook not in the root-resolving form. (b) A module with 0 pages in the page scope reports journeys N/A instead of a FAULT (`page-scope.sh` now runs before the runtime rungs; same mxcli calls) — jopterhorst (comment on #446); a missing journey JSON names any `tests/*.test.sh` that exist in another shape; missing e2e dependencies (playwright, axe-core, probed from `JOURNEY_RUNNER`/`MONKEY_JS`/`DESIGN_AUDIT_JS`'s folders) are one FAULT row with the one fix (`npm install playwright axe-core && npx playwright install chromium`), journeys and monkey SKIPPED as dependents of playwright, the design audit static-only; the coverage rung's budget is 1200 s (was 300); `summary.tsv` ends with a `verdict` row (CLEAN / FINDINGS / INCOMPLETE), absent when a run was cut short (`report-normalize.js` skips it); and `coverage-preflight.sh`'s PHANTOM CLAIM pass gives each BRD only the claims it can resolve (12-BRD bench: 16.1 s to 4.2 s), with identical verdicts except that false PHANTOMs for BRDs in another source folder or named only by `.id` are gone (exit can drop 1→0). Found while proving it: every rung's watchdog `sleep` kept the script's stdout open, so `verify-module.sh | tee` waited up to 30 minutes after the verdict; the watchdogs now drop fds 3/4 and take their sleep with them. **Behaviour change to check in your project:** a page-less module's `summary.tsv` has `journeys SKIPPED (N/A: ...)` and the run can exit 0; missing e2e dependencies add an `e2e harness dependency` FAULT row and SKIPPED journeys/monkey rows; `summary.tsv` gains a last `verdict` row; `coverage-preflight.sh` can exit 0 where it exited 1 (the false PHANTOMs above); `.claude/settings.json`'s look hook command changes. Fixtures: `test-look-hook-cwd.sh`, `test-verify-module-446.sh`, `test-coverage-phantom-scope.sh`. — MendixMau (field 2026-10-17), jopterhorst (page-less modules) diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 40159c2a..ab1dc61c 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -2827,6 +2827,18 @@ else fi printf "Report disposition (findings filed?): %s — %s\n" "$DISP_STATUS" "$DISP_NOTE" +# Script ownership (#269): INFORMATION, never a verdict and never a block. A document created by +# more than one mdlsource/ script reverts whenever an older script is replayed; field apps carried +# 99/579, 130/357 and 38/159. exec.sh warns per write; this prints the project-wide count so a +# slice close can see whether it is falling. One awk over mdlsource/, no mxcli; silent at 0. +if [ -f "$TOOLKIT_DIR/project-bin/script-owners.sh" ]; then + OWN_CNT="$(bash "$TOOLKIT_DIR/project-bin/script-owners.sh" count "$PROJECT_DIR" 2>/dev/null || true)" + case "$OWN_CNT" in + 0\ *|"") ;; + *) printf "Script ownership (info): %s of %s document(s) are created by more than one mdlsource/ script — bin/script-owners.sh report lists them\n" "${OWN_CNT%% *}" "${OWN_CNT#* }" ;; + esac +fi + # Stage P is checked outside the numeric loop (bash 3.2 arrays need integer indices). N_PASS=0; N_PENDING=0; N_WAIVED=0; N_FAIL=0; N_MANUAL=0; NEXT_UP=""; NEXT_UP_STATUS=""; ATTENTION="" APPROVED_OVER_FAIL="" diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index 5eb0928f..33ab0bc7 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -119,7 +119,9 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # the generator reads the template from its own directory. The project runs it at Stage 3 # (skills/process-map.md Step 4) after every change to design/process-model.json, the # anchors, a wireframe or a BRD, and gate-check.sh check_stage_3 fails a stale render. -MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh progress-report.py done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" +# script-owners.sh joined on 2026-10-21 (#269): exec.sh runs it as its sibling before every MDL +# write (co-owned documents) and records each kept write in its ledger; restore-mpr.sh marks it. +MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh script-owners.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh progress-report.py done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" # Files in project-bin/ that are deliberately NOT installed into projects. The reverse check # below flags anything named by NEITHER list, so a new file in project-bin/ has to be either diff --git a/project-bin/exec.sh b/project-bin/exec.sh index 25545c0d..d6ff541f 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -514,7 +514,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}${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}${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}" 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" @@ -568,6 +568,7 @@ take_score() { # Not found, or the scan itself errors (exit 2) → one warning, and the exec goes on: mxcli # 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 warning's cell text (#269), set after the check if [ "$PATCH_MODE" != "1" ]; then _SCAN="" for _s in "${MXTK_ROOT:-}/bin/mdl-scan.sh" \ @@ -1124,6 +1125,34 @@ elif [ "${SKIP_CHECK:-0}" != "1" ] && [ -n "$PMXCLI" ]; then echo " ✓ check clean" fi +# ── Restatement warnings (#269; WARN only, never a refusal) ────────────────── +# A whole restatement (`create or modify|replace microflow|nanoflow|page|snippet`) is valid MDL +# whatever it replaces, so check, mxbuild and lint stay green while it reverts newer work. Three +# field apps had 99/579, 130/357 and 38/159 documents created by more than one mdlsource/ script; +# re-running an older one reverts the newer. script-owners.sh check: REVERT/UNCHECKED rows print +# as WARN lines and land in the BUILD-LOG detail; NOTE rows (a new owner without a supersedes +# header, first run only) print as one NOTE line and stay out of the log. The exec goes on and the +# exit code is whatever the rest of the chain decides. MXTK_RESTATE_GUARD=off skips it. Cost +# without a co-owner: one grep over mdlsource/ for the restated names, no mxcli call. +if [ "$PATCH_MODE" != "1" ] && [ "${MXTK_RESTATE_GUARD:-}" != "off" ] && [ -f "$SCRIPT" ] \ + && [ -f "$(dirname "$0")/script-owners.sh" ]; then + _so_out="$(PROJECT_ROOT="$PROJECT_ROOT" bash "$(dirname "$0")/script-owners.sh" check "$SCRIPT" "$PROJECT_ROOT" 2>/dev/null || true)" + _so_warn="$(printf '%s\n' "$_so_out" | grep -E '^(REVERT|UNCHECKED) ' || true)" + _so_note="$(printf '%s\n' "$_so_out" | grep '^NOTE ' || true)" + if [ -n "$_so_note" ]; then + echo "NOTE: new owner of $(printf '%s\n' "$_so_note" | cut -f2 | sed 's/^[A-Z]* //' | LC_ALL=C sort -u | tr '\n' ' ')— older owner(s): $(printf '%s\n' "$_so_note" | cut -f3 | LC_ALL=C sort -u | tr '\n' ' '); add '-- supersedes: ' (STOP 21)" + fi + if [ -n "$_so_warn" ]; then + _so_docs="$(printf '%s\n' "$_so_warn" | cut -f2 | sed 's/^[A-Z]* //' | LC_ALL=C sort -u)" + _so_n="$(printf '%s\n' "$_so_docs" | grep -c . || true)" + echo "WARN: restatement: $_so_n document(s) this script restates hold a newer owner's live work:" + printf '%s\n' "$_so_warn" | awk -F'\t' '{ printf " %s — %s\n", $2, $4 }' + echo " → edit the owning script or use alter (bin/script-owners.sh report; skills/learned-mdl-preflight.md STOP 21)" + RESTATE_NOTE="WARN reverts: $(printf '%s\n' "$_so_docs" | head -3 | tr '\n' ' ' | sed 's/ $//')" + if [ "$_so_n" -gt 3 ]; then RESTATE_NOTE="$RESTATE_NOTE +$((_so_n - 3))"; fi + fi +fi + # ── Gate must be able to run ───────────────────────────────────────────────── # Discovery happens BEFORE the snapshot and the write, because the answer decides whether # there is a write at all. Until 2026-09-17 a missing mxbuild produced a warning and the @@ -1202,7 +1231,9 @@ restore_snapshot() { echo " ⚠ No usable snapshot to restore from — the model is left as the script wrote it." elif [ ! -f "$RESTORER" ]; then echo " ⚠ bin/restore-mpr.sh is not installed — cannot auto-restore (run sync-project.sh)." - elif R_OUT=$(bash "$RESTORER" "$NEWEST_SNAP" 2>&1); then + # The snapshot was taken just before this exec, so script-owners.sh's ledger still matches it: + # no RESTORE marker (restore-mpr.sh writes one for every other restore). + elif R_OUT=$(MXTK_RESTORE_SAME_LEDGER=1 bash "$RESTORER" "$NEWEST_SNAP" 2>&1); then RESTORED=1 printf '%s\n' "$R_OUT" | sed 's/^/ → /' echo " → Auto-restored from: $NEWEST_SNAP" @@ -2070,6 +2101,12 @@ fi if [ "$PATCH_MODE" = 0 ] && [ -f "$(dirname "$0")/look-ledger.sh" ]; then bash "$(dirname "$0")/look-ledger.sh" owe "$SCRIPT" || true fi +# Last writer of each document this script names (script-owners.sh ledger), so a later check can +# tell a replay that reverts nothing from one that does. Same place and reason as LOOK owed: a +# restored or partial run exited above. No mxcli call: one awk over this script. +if [ "$PATCH_MODE" = 0 ] && [ -f "$(dirname "$0")/script-owners.sh" ]; then + PROJECT_ROOT="$PROJECT_ROOT" bash "$(dirname "$0")/script-owners.sh" record "$SCRIPT" 2>/dev/null || true +fi # ── Build plan page ────────────────────────────────────────────────────────── # Refresh architecture/progress-report.html so it tracks the build without anyone remembering to diff --git a/project-bin/restore-mpr.sh b/project-bin/restore-mpr.sh index fd8ba9ca..af6e7af8 100755 --- a/project-bin/restore-mpr.sh +++ b/project-bin/restore-mpr.sh @@ -94,4 +94,12 @@ else exit 1 fi +# script-owners.sh's last-writer ledger described the model before this restore: mark it, so rows +# above the marker stop counting and its next replay check asks the model (DESCRIBE) instead. +# exec.sh's own rollback sets MXTK_RESTORE_SAME_LEDGER=1: that snapshot predates nothing recorded. +_SO_LEDGER="$PROJECT_ROOT/.claude/loop/script-owners/written.tsv" +if [ -f "$_SO_LEDGER" ] && [ "${MXTK_RESTORE_SAME_LEDGER:-0}" != 1 ]; then + printf '%s\tRESTORE\t%s\n' "$(date +%s)" "$(basename "$SNAP")" >> "$_SO_LEDGER" || true +fi + echo "Restore complete. Verify with: ./mxcli docker check -p $MPR --no-update-widgets" diff --git a/project-bin/script-owners.sh b/project-bin/script-owners.sh new file mode 100755 index 00000000..a66463d8 --- /dev/null +++ b/project-bin/script-owners.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# script-owners.sh — which mdlsource/ scripts create or restate each document. +# +# bin/script-owners.sh docs one row per document the script creates or restates: +# KIND Module.Name line create|modify|replace +# bin/script-owners.sh report [project-root] every document owned by more than one script, run order +# bin/script-owners.sh count [project-root] " " (gate-check.sh reads it) +# bin/script-owners.sh check [project-root] +# one row per co-owner of a document the script restates: +# REVERT / UNCHECKED (exit 1, exec.sh WARNs) or NOTE (exit 0) +# bin/script-owners.sh record exec.sh, after a kept write: last writer of every doc it names +# bin/script-owners.sh last-writer "" that script's key, or nothing +# bin/script-owners.sh live one DESCRIBE: present | absent (exit 3: failed) +# +# A document is a microflow, nanoflow, page or snippet named by `create`, `create or modify` or +# `create or replace`. `alter` is not ownership. `check` looks only at what the script RESTATES +# (or modify / or replace): a plain `create` on an existing name is refused by mxcli. +# +# WHY (#269, field 2026-10-05..09, mxcli v0.25.0). Three toolkit-built apps had 99/579, 130/357 and +# 38/159 documents created by more than one script (worst case 5). Re-running an older script (a +# replay, a rebuild, an agent "re-applying slice 2") silently put back what the later scripts +# changed; check, mxbuild and lint stayed green. Nothing recorded which script owns a document. +# +# WHO WINS. `-- supersedes: 05-x.mdl, 07-y.mdl` in the new script, or `-- superseded-by: 12-z.mdl` in +# the old one (basenames or paths). For script S and another owner O of a document S restates: +# either header says S wins → nothing +# O runs before S, no header → NOTE (not WARN, not in BUILD-LOG), only the first time S +# runs (no ledger row yet): S is the new owner; add the header +# O runs after S, or O supersedes S → REVERT if O's write is live: replaying S reverts it +# Run order: path order with digit runs zero-padded per component (2-core before 11-fixes, 9-a +# before 10-b; no `sort -V`, older macOS sort lacks it) and a leading `done-` ignored +# (iterative-build-loop.md renames an executed script done-). +# +# "O's write is live". Neither S nor O has ever run → S is new: nothing. "Has run" is any of: a +# ledger row, a `done-` file name, or a docs/BUILD-LOG.md row naming it in backticks whose result +# says applied (the done-drift-check.sh pattern). The last two are committed, so a fresh clone or +# cloud session, whose ledger is empty, still sees a replay over committed newer work. Else the +# LAST-WRITER LEDGER: last writer is S or a script before S → O's work is not in the model. Else ONE +# DESCRIBE per document (absent → nothing to revert, e.g. a fresh model), at most 5 per check; the +# rest print as UNCHECKED. No ./mxcli → REVERT. The ledger knows only exec.sh's writes: restore-mpr.sh +# appends a RESTORE row and rows before the last one are ignored (DESCRIBE decides again). It cannot +# see a Studio Pro edit, nor a `git pull` / `git checkout` that changes the .mpr. +# +# LEDGER: /.claude/loop/script-owners/written.tsv, epoch key KIND Module.Name +# (key = root-relative path, done- removed), or epoch RESTORE . MACHINE-LOCAL, +# NOT COMMITTED: the folder carries its own `*` .gitignore (as app-restart.sh's does). So a fresh +# clone starts with an empty ledger. `record` compacts it to the last row per (script, document) +# plus the last RESTORE row, through a temp file and mv; exec.sh calls it while it holds the +# exec lock. +# +# Lexer: exec.sh's change-class blanking (comments, strings, $$-bodies, bracket depth), with string +# literals allowed to span lines. Golden input is real MDL: mxcli v0.23.0/v0.26.0 DESCRIBE captures, +# the StyleGallery mdlsource tree, skills/agents-examples (tests/wave2/test-restatement-guard.sh). +# *.test.mdl is skipped. mdlsource/ is /mdlsource, else /app/mdlsource (two-tree). +# Happy-path cost (no co-owner): one grep over mdlsource/ for the restated names, the lexer over +# the hits, no mxcli. Producers/consumers: mdlsource/ (every build step) and the ledger (exec.sh +# `record`, restore-mpr.sh) → exec.sh `check`, gate-check.sh `count`. bash 3.2, BSD awk, Git Bash. +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" + +usage() { sed -n '3,14p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; } + +_SO_AWK=' + function lex_line(line, n, i, c, c2, tmp, o, cl) { + clean = ""; n = length(line); i = 1 + while (i <= n) { + c = substr(line, i, 1); c2 = substr(line, i, 2) + if (instr) { if (c == "\047") instr = 0; i++; continue } + if (inblock) { if (c2 == "*/") { inblock = 0; i += 2 } else i++; continue } + if (indollar) { if (c2 == "$$") { indollar = 0; i += 2 } else i++; continue } + if (c2 == "/*") { inblock = 1; i += 2; continue } + if (c2 == "$$") { indollar = 1; i += 2; continue } + if (c2 == "--" || c2 == "//") break + if (c == "\047") { + i++; while (i <= n && substr(line, i, 1) != "\047") i++ + if (i > n) instr = 1 + i++; clean = clean "\047\047"; continue + } + if (c == "\"") { i++; continue } + clean = clean c; i++ + } + start_depth = depth + tmp = clean; o = gsub(/[({]/, "", tmp); tmp = clean; cl = gsub(/[)}]/, "", tmp) + depth += o - cl; if (depth < 0) depth = 0 + } + FNR == 1 { depth = 0; instr = 0; inblock = 0; indollar = 0 } + { + sub(/\r$/, "") + lex_line($0) + if (start_depth != 0) next + s = clean; sub(/^[ \t]+/, "", s) + if (tolower(s) !~ /^create[ \t]/) next + rest = substr(s, 7); sub(/^[ \t]+/, "", rest) + verb = "create" + if (tolower(rest) ~ /^or[ \t]+(modify|replace)[ \t]/) { + sub(/^[Oo][Rr][ \t]+/, "", rest) + verb = tolower(rest); sub(/[ \t].*$/, "", verb) + sub(/^[A-Za-z]+[ \t]+/, "", rest) + } + kind = tolower(rest); sub(/[ \t(].*$/, "", kind) + if (kind != "microflow" && kind != "nanoflow" && kind != "page" && kind != "snippet") next + sub(/^[A-Za-z]+[ \t]+/, "", rest) + if (!match(rest, /^[A-Za-z_][A-Za-z0-9_]*[ \t]*\.[ \t]*[A-Za-z_][A-Za-z0-9_]*/)) next + name = substr(rest, 1, RLENGTH); gsub(/[ \t]/, "", name) + if (INDEX) { k = toupper(kind) " " name; if (!((FILENAME, k) in seen)) { seen[FILENAME, k] = 1; print k "\t" FILENAME } } + else printf "%s\t%s\t%d\t%s\n", toupper(kind), name, FNR, verb + }' + +# Run-order key of a root-relative path: done- dropped from the file name, every digit run +# zero-padded to 10 so 2-core < 11-fixes and 9-a < 10-b under a byte sort. Prints "keypath". +_SO_ORDER=' + function pad(s, out, d) { + out = "" + while (match(s, /[0-9]+/)) { d = substr(s, RSTART, RLENGTH); while (length(d) < 10) d = "0" d + out = out substr(s, 1, RSTART - 1) d; s = substr(s, RSTART + RLENGTH) } + return out s + } + { n = split($0, a, "/"); sub(/^done-/, "", a[n]); k = pad(a[1]); for (i = 2; i <= n; i++) k = k "/" pad(a[i]); print k "\t" $0 }' + +so_docs() { awk -v INDEX=0 "$_SO_AWK" "$1"; } +so_key() { basename "$1" | sed 's/^done-//'; } # header-name identity +so_sortkey() { printf '%s/%s\n' "$(dirname "$1")" "$(so_key "$1")"; } # ledger key +so_before() { # true when path $1 runs strictly before path $2 + printf '%s\n%s\n' "$1" "$2" | awk "$_SO_ORDER" | awk -F'\t' 'NR == 1 { a = $1 } NR == 2 { exit !(a < $1) }' +} +so_header() { # $1 = script, $2 = supersedes|superseded-by → one name per line, done- removed + sed -nE "s/^[[:space:]]*--[[:space:]]*$2:[[:space:]]*(.*)$/\1/p" "$1" 2>/dev/null \ + | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/[[:space:]].*$//' \ + | sed -E 's#.*/##; s/^done-//' | grep -v '^$' || true +} +so_root() { + if [ -n "${1:-}" ]; then (cd "$1" 2>/dev/null && pwd) || echo "$1" + elif [ -n "${PROJECT_ROOT:-}" ]; then (cd "$PROJECT_ROOT" 2>/dev/null && pwd) || echo "$PROJECT_ROOT" + else pwd + fi +} +so_mdldir() { + if [ -d "$1/mdlsource" ]; then echo mdlsource + elif [ -d "$1/app/mdlsource" ]; then echo app/mdlsource + fi +} +so_scripts() { # $1 = root [, $2 = fixed-strings file: only scripts containing one] → run order + local d; d="$(so_mdldir "$1")"; [ -n "$d" ] || return 0 + if [ -n "${2:-}" ]; then ( cd "$1" && grep -rlF -f "$2" "$d" 2>/dev/null ) + else ( cd "$1" && find "$d" -type f -name '*.mdl' 2>/dev/null ) + fi | awk '/\.mdl$/ && !/\.test\.mdl$/' | awk "$_SO_ORDER" | LC_ALL=C sort | cut -f2 +} +so_index() { # $1 = root [, $2 = prefilter] → "KIND Module.Name script", run order, ONE awk + local f files=() + while IFS= read -r f; do [ -n "$f" ] && files+=("$f"); done </dev/null && pwd)/$(basename "$1")" + case "$abs" in "$2"/*) printf '%s\n' "${abs#"$2"/}" ;; *) echo "" ;; esac +} +# One DESCRIBE through the project's ./mxcli. Not found (captured, mxcli v0.26.0, all four kinds, +# tests/wave2/fixtures/restatement-guard/): exit 1, empty stdout, "Error: not found: " +# on stderr. A parse error or "no describable document" (v0.24.0) is a failed call, never absent. +so_live() { # $1 = root, $2 = KIND, $3 = Doc + local out err rc=0 all + out="$(cd "$1" && PROJECT_ROOT="$1" && . "$HERE/_common.sh" >/dev/null 2>&1 \ + && mx="$(find_project_mxcli)" && mpr="$(find_mpr 2>/dev/null)" \ + && "$mx" -p "$mpr" -c "DESCRIBE $2 $3" 2>&1 1 ? ", " : "") $2 } + END { m = 0 + for (i = 1; i <= n; i++) if (c[order[i]] > 1) { m++; printf " %s — %d scripts: %s\n", order[i], c[order[i]], s[order[i]] } + printf "SUMMARY\t%d\t%d\n", m, n }')" + M="$(printf '%s\n' "$SUMMARY" | awk -F'\t' '$1 == "SUMMARY" { print $2 }')" + N="$(printf '%s\n' "$SUMMARY" | awk -F'\t' '$1 == "SUMMARY" { print $3 }')" + if [ "$cmd" = count ]; then echo "${M:-0} ${N:-0}"; exit 0; fi + [ -n "$(so_mdldir "$ROOT")" ] || { echo "no mdlsource/ under $ROOT — nothing to report"; exit 0; } + printf '%s\n' "$SUMMARY" | grep -v '^SUMMARY' || true + echo "multi-owner: ${M:-0} of ${N:-0} document(s) created by more than one mdlsource/ script" + [ "${M:-0}" -gt 0 ] && echo " → edit the owning script, use alter, or mark the winner: -- supersedes: (skills/learned-mdl-preflight.md STOP 21)" + exit 0 + ;; + record) + [ -f "${2:-}" ] || usage + ROOT="$(so_root "")"; REL="$(so_rel "$2" "$ROOT")" + [ -n "$REL" ] || exit 0 # a scratch script outside the project owns nothing + LD="$(dirname "$ROOT/$LEDGER_REL")" + mkdir -p "$LD" 2>/dev/null || exit 0 + [ -f "$LD/.gitignore" ] || printf '*\n' > "$LD/.gitignore" + so_docs "$2" | awk -F'\t' -v k="$(so_sortkey "$REL")" -v t="$(date +%s)" '!s[$1 " " $2]++ { print t "\t" k "\t" $1 " " $2 }' >> "$ROOT/$LEDGER_REL" + # Compact: the last row per (script, document) and the last RESTORE row, order kept. + T="$(mktemp "$LD/.written.XXXXXX")" || exit 0 + awk -F'\t' '{ k = ($2 == "RESTORE") ? "RESTORE" : $2 "\t" $3; last[k] = NR; line[NR] = $0; key[NR] = k } + END { for (i = 1; i <= NR; i++) if (last[key[i]] == i) print line[i] }' "$ROOT/$LEDGER_REL" > "$T" \ + && mv "$T" "$ROOT/$LEDGER_REL" || rm -f "$T" + exit 0 + ;; + check) + [ -f "${2:-}" ] || usage + S="$2"; ROOT="$(so_root "${3:-}")" + S_REL="$(so_rel "$S" "$ROOT")"; S_KEY="$(so_key "$S")" + S_RAN=0; [ -n "$S_REL" ] && so_has_run "$ROOT" "$S_REL" && S_RAN=1 + DOCS="$(so_docs "$S" | awk -F'\t' '$4 != "create" { print $1 " " $2 }' | LC_ALL=C sort -u)" + [ -n "$DOCS" ] || exit 0 + # Only scripts containing a restated document's bare name are lexed (quoted or not, it is in + # the text): 600 scripts cost ~350 ms through the lexer, the grep a few. + PAT="$(mktemp "${TMPDIR:-/tmp}/so-pat.XXXXXX")" || exit 2 + printf '%s\n' "$DOCS" | sed 's/.*\.//' > "$PAT" + PAIRS="$(so_index "$ROOT" "$PAT" | DOCS="$DOCS" awk -F'\t' -v me="$S_REL" ' + BEGIN { n = split(ENVIRON["DOCS"], a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") w[a[i]] = 1 } + ($1 in w) && $2 != me')" + rm -f "$PAT" + [ -n "$PAIRS" ] || exit 0 + MY_SUP="$(so_header "$S" supersedes)"; MY_SUPBY="$(so_header "$S" superseded-by)" + OUT="$(NL_SEEN=""; NL_DESC=0 + # rc 0: O's work on doc $1 is not in the model; 1: it is (or unknown); 2: DESCRIBE cap reached. + not_live() { # $1 = "KIND Module.Name", $2 = O + local w live rc=1 + if [ "$S_RAN" = 0 ] && ! so_has_run "$ROOT" "$2"; then return 0; fi # S is new + case "$NL_SEEN" in *"|$1="*) rc="${NL_SEEN#*"|$1="}"; return "${rc%%|*}" ;; esac + w="$(so_last_writer "$ROOT" "$1")" + if [ -n "$w" ] && [ -n "$S_REL" ] && { [ "$w" = "$(so_sortkey "$S_REL")" ] || so_before "$w" "$S_REL"; }; then + rc=0 + elif [ "$NL_DESC" -ge 5 ]; then + return 2 + else + NL_DESC=$((NL_DESC + 1)) + live="$(so_live "$ROOT" "${1%% *}" "${1#* }")" && [ "$live" = absent ] && rc=0 + fi + NL_SEEN="$NL_SEEN|$1=$rc|" + return "$rc" + } + revert() { # $1 doc $2 O $3 message + local r=0; not_live "$1" "$2" || r=$? + case "$r" in + 0) ;; + 2) printf 'UNCHECKED\t%s\t%s\tCONFLICT (not checked: 5 DESCRIBE calls per exec) — %s\n' "$1" "$2" "$3" ;; + *) printf 'REVERT\t%s\t%s\t%s\n' "$1" "$2" "$3" ;; + esac + } + while IFS=' ' read -r d o; do + [ -n "$o" ] || continue + ob="$(so_key "$o")" + if printf '%s\n' "$MY_SUP" | grep -qxF "$ob" || so_header "$ROOT/$o" superseded-by | grep -qxF "$S_KEY"; then continue; fi + if printf '%s\n' "$MY_SUPBY" | grep -qxF "$ob" || so_header "$ROOT/$o" supersedes | grep -qxF "$S_KEY"; then + revert "$d" "$o" "superseded: $o supersedes this script — replaying it reverts $o" + elif [ -z "$S_REL" ]; then + printf 'REVERT\t%s\t%s\towned by %s; this script is outside the project\n' "$d" "$o" "$o" + elif so_before "$o" "$S_REL"; then + [ "$S_RAN" = 0 ] && printf "NOTE\t%s\t%s\tolder owner %s — the new owner should say '-- supersedes: %s'\n" "$d" "$o" "$o" "$ob" + else + revert "$d" "$o" "newer owner $o — replaying this script reverts it" + fi + done < 0', + ValidationMessage: 'The password cannot be empty.' + ) + textbox textBox1 ( + Label: 'Confirm password', + Attribute: ConfirmPassword, + Password: true, + Validation: 'length(toString($value)) > 0', + ValidationMessage: 'The password cannot be empty.' + ) + footer { + actionbutton microflowButton1 ( + Caption: 'Change', + Action: call microflow Administration.ChangePassword with (DisabledDuringExecution: false), + ButtonStyle: Success + ) + actionbutton cancelButton1 (Caption: 'Cancel', Action: cancel changes close page) + } + } + } + } + } +}; diff --git a/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.rc b/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.rc new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.rc @@ -0,0 +1 @@ +1 diff --git a/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.stderr b/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.stderr new file mode 100644 index 00000000..dd9bba45 --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-MICROFLOW.stderr @@ -0,0 +1,2 @@ +WARNING: This is a vibe-coded PoC, alpha quality, use with caution. +Error: microflow not found: Administration.NoSuchFlow diff --git a/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.rc b/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.rc new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.rc @@ -0,0 +1 @@ +1 diff --git a/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.stderr b/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.stderr new file mode 100644 index 00000000..9471667e --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-NANOFLOW.stderr @@ -0,0 +1,2 @@ +WARNING: This is a vibe-coded PoC, alpha quality, use with caution. +Error: nanoflow not found: Administration.NoSuchNano diff --git a/tests/wave2/fixtures/restatement-guard/notfound-PAGE.rc b/tests/wave2/fixtures/restatement-guard/notfound-PAGE.rc new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-PAGE.rc @@ -0,0 +1 @@ +1 diff --git a/tests/wave2/fixtures/restatement-guard/notfound-PAGE.stderr b/tests/wave2/fixtures/restatement-guard/notfound-PAGE.stderr new file mode 100644 index 00000000..a1d89b68 --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-PAGE.stderr @@ -0,0 +1,2 @@ +WARNING: This is a vibe-coded PoC, alpha quality, use with caution. +Error: page not found: Administration.NoSuchPage diff --git a/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.rc b/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.rc new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.rc @@ -0,0 +1 @@ +1 diff --git a/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.stderr b/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.stderr new file mode 100644 index 00000000..d0a31a59 --- /dev/null +++ b/tests/wave2/fixtures/restatement-guard/notfound-SNIPPET.stderr @@ -0,0 +1,2 @@ +WARNING: This is a vibe-coded PoC, alpha quality, use with caution. +Error: snippet not found: Administration.NoSuchSnippet diff --git a/tests/wave2/test-restatement-guard.sh b/tests/wave2/test-restatement-guard.sh new file mode 100755 index 00000000..c61fa4f5 --- /dev/null +++ b/tests/wave2/test-restatement-guard.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# test-restatement-guard.sh — #269: re-running an older script that owns the same document as a +# newer one silently reverted the newer work (exit 0, no word). exec.sh now WARNs through +# script-owners.sh; a new owner without a supersedes header gets one NOTE line, first run only. +# +# Usage: bash tests/wave2/test-restatement-guard.sh [path-to-exec.sh] +# script-owners.sh and restore-mpr.sh are taken from the same directory as exec.sh. +# +# Covers +# parser: `docs` over REAL MDL (mxcli v0.23.0 and v0.26.0 DESCRIBE captures, StyleGallery, +# skills/agents-examples) matches a grep of statement heads (36); StyleGallery 0 of 20. +# order: 2-core/ runs before 11-fixes/, 9-a before 10-b, done- ignored (a byte sort had them +# backwards and called a forward exec a replay). +# check: NOTE for a new owner (first run only), REVERT for a replay over a newer owner's live +# write, quiet when neither ran (S is new), supersedes / superseded-by, two-tree. +# ledger: record + last-writer, compaction, its own .gitignore, RESTORE marker (restore-mpr.sh), +# DESCRIBE cap of 5 per check (the rest UNCHECKED), the captured v0.26.0 not-found output. +# exec.sh: happy path = no WARN and zero DESCRIBE; replay WARN + BUILD-LOG cell, exit 0; NOTE +# stays out of BUILD-LOG; `ask` approval still exit 0; =off silent; the F3 scenario +# 05, 09 supersedes 05, replay 05, restore, replay 05 → WARN both times. +# NOTHING here touches a real .mpr, mxcli or mxbuild. +set -uo pipefail + +TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SUBJECT="${1:-$TOOLKIT/project-bin/exec.sh}" +case "$SUBJECT" in /*) ;; *) SUBJECT="$PWD/$SUBJECT" ;; esac +BIN="$(dirname "$SUBJECT")" +OWNERS="$BIN/script-owners.sh" +CAP="$TOOLKIT/tests/wave2/fixtures/context-pack/capture/describe" +CAP26="$TOOLKIT/tests/wave2/fixtures/restatement-guard" +REJ="$CAP/MICROFLOW__Sales.ACT_ApprovalStep_Reject.out" +APP="$CAP/MICROFLOW__Sales.ACT_ApprovalStep_Approve.out" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/restate.XXXXXX")" || 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"; } +has() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1 — missing '$3' in: $(printf '%s' "$2" | tail -4)" ;; esac; } +hasnt() { case "$2" in *"$3"*) bad "$1 — unexpected '$3'" ;; *) ok "$1" ;; esac; } +echo "== subject: $SUBJECT" +[ -f "$OWNERS" ] || bad "[setup] script-owners.sh not found beside exec.sh" +fix() { sed "s/A comment is required to reject./$1/" "$REJ"; } + +# ── parser over real MDL ───────────────────────────────────────────────────── +echo "-- parser" +GREP_N=0; DOCS_N=0 +for f in "$CAP"/*.out "$CAP26"/*.out "$TOOLKIT"/examples/stylegallery/mdlsource/gallery/*.mdl "$TOOLKIT"/skills/agents-examples/*.mdl; do + g="$(grep -ciE '^[[:space:]]*create[[:space:]]+(or[[:space:]]+(modify|replace)[[:space:]]+)?(microflow|nanoflow|page|snippet)[[:space:]]' "$f" || true)" + d="$(bash "$OWNERS" docs "$f" 2>/dev/null | grep -c . || true)" + GREP_N=$((GREP_N + g)); DOCS_N=$((DOCS_N + d)) +done +[ "$GREP_N" -eq 36 ] && ok "[setup] 36 statement heads in the golden files" || bad "[setup] golden grep found $GREP_N, expected 36" +[ "$DOCS_N" -eq "$GREP_N" ] && ok "docs lists every head ($DOCS_N)" || bad "docs listed $DOCS_N, grep $GREP_N" +has "a v0.26.0 page capture ('page X folder ...') parses" "$(bash "$OWNERS" docs "$CAP26/PAGE__Administration.ChangePasswordForm.out" 2>&1)" "PAGE Administration.ChangePasswordForm 2 modify" +OUT="$(bash "$OWNERS" count "$TOOLKIT/examples/stylegallery" 2>&1)" +[ "$OUT" = "0 20" ] && ok "StyleGallery tree: 0 of 20 co-owned" || bad "StyleGallery count '$OUT'" + +# ── run order ──────────────────────────────────────────────────────────────── +echo "-- run order: digit runs compare as numbers" +R="$WORK/order"; mkdir -p "$R/mdlsource/2-core" "$R/mdlsource/11-fixes" "$R/mdlsource/ph" +cp "$REJ" "$R/mdlsource/2-core/a.mdl"; fix "x" > "$R/mdlsource/11-fixes/b.mdl" +cp "$APP" "$R/mdlsource/ph/9-a.mdl"; cp "$APP" "$R/mdlsource/ph/done-10-b.mdl" +rchk() { ( cd "$R" && PROJECT_ROOT="$R" bash "$OWNERS" check "$1" "$R" ) 2>&1; } +OUT="$(bash "$OWNERS" report "$R")" +has "report: 2-core before 11-fixes" "$OUT" "2 scripts: mdlsource/2-core/a.mdl, mdlsource/11-fixes/b.mdl" +has "report: 9-a before done-10-b" "$OUT" "2 scripts: mdlsource/ph/9-a.mdl, mdlsource/ph/done-10-b.mdl" +OUT="$(rchk mdlsource/11-fixes/b.mdl)"; RC=$? +[ "$RC" -eq 0 ] && has "forward exec of 11-fixes: NOTE, older owner 2-core" "$OUT" "NOTE MICROFLOW Sales.ACT_ApprovalStep_Reject mdlsource/2-core/a.mdl older owner" || bad "11-fixes rc $RC: $OUT" +hasnt " not called a replay" "$OUT" "REVERT" +OUT="$(rchk mdlsource/ph/done-10-b.mdl)"; RC=$? +[ "$RC" -eq 0 ] && hasnt "re-run of done-10-b (it has run): 9-a is older, not a replay" "$OUT" "REVERT" || bad "10-b rc $RC: $OUT" + +# ── check + ledger, without an mxcli ───────────────────────────────────────── +echo "-- check and ledger" +O="$WORK/own"; mkdir -p "$O/mdlsource" +cp "$REJ" "$O/mdlsource/05-reject.mdl"; cp "$APP" "$O/mdlsource/06-approve.mdl"; fix "x" > "$O/mdlsource/09-reject-fix.mdl" +chk() { ( cd "$O" && PROJECT_ROOT="$O" bash "$OWNERS" check "$1" "$O" ) 2>&1; } +rec() { ( cd "$O" && PROJECT_ROOT="$O" bash "$OWNERS" record "$1" ) 2>&1; } +LG="$O/.claude/loop/script-owners/written.tsv" +OUT="$(bash "$OWNERS" report "$O")" +has "report names the co-owned microflow" "$OUT" "MICROFLOW Sales.ACT_ApprovalStep_Reject — 2 scripts: mdlsource/05-reject.mdl, mdlsource/09-reject-fix.mdl" +OUT="$(chk mdlsource/06-approve.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "a sole owner: nothing" || bad "sole owner rc $RC: $OUT" +OUT="$(chk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "neither 05 nor 09 ever ran: 05 is new, nothing (no DESCRIBE)" || bad "new 05 rc $RC: $OUT" +rec mdlsource/05-reject.mdl +[ -f "$O/.claude/loop/script-owners/.gitignore" ] && ok "ledger folder carries its own .gitignore (machine-local)" || bad "no ledger .gitignore" +OUT="$(chk mdlsource/09-reject-fix.mdl)"; RC=$? +[ "$RC" -eq 0 ] && has "09 first run: NOTE, older owner 05" "$OUT" "NOTE MICROFLOW Sales.ACT_ApprovalStep_Reject mdlsource/05-reject.mdl" || bad "09 first rc $RC: $OUT" +rec mdlsource/09-reject-fix.mdl +OUT="$(chk mdlsource/09-reject-fix.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "09 re-run: no NOTE (it has a ledger row)" || bad "09 re-run rc $RC: $OUT" +OUT="$(chk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 1 ] && has "replay 05 over 09's write (no ./mxcli to ask): REVERT" "$OUT" "REVERT MICROFLOW Sales.ACT_ApprovalStep_Reject mdlsource/09-reject-fix.mdl newer owner" || bad "replay rc $RC: $OUT" +rec mdlsource/05-reject.mdl +OUT="$(chk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 0 ] && ok "05 is the last writer now: re-running 05 reverts nothing" || bad "rc $RC: $OUT" +{ echo "-- supersedes: 05-reject.mdl"; fix "x"; } > "$O/mdlsource/09-reject-fix.mdl" +OUT="$(chk mdlsource/09-reject-fix.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "-- supersedes: settles it" || bad "supersedes rc $RC: $OUT" +rec mdlsource/09-reject-fix.mdl +OUT="$(chk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 1 ] && has "replaying a superseded script over the live newer write: REVERT" "$OUT" "superseded: mdlsource/09-reject-fix.mdl supersedes this script" || bad "superseded replay rc $RC" +cp "$REJ" "$O/mdlsource/09-reject-fix.mdl"; { echo "-- superseded-by: 09-reject-fix.mdl"; cat "$REJ"; } > "$O/mdlsource/05-reject.mdl" +OUT="$(chk mdlsource/09-reject-fix.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "-- superseded-by: in the old script settles it too" || bad "superseded-by rc $RC: $OUT" +OUT="$( ( cd "$O" && PROJECT_ROOT="$O" bash "$OWNERS" last-writer "MICROFLOW Sales.ACT_ApprovalStep_Reject" ) )" +[ "$OUT" = "mdlsource/09-reject-fix.mdl" ] && ok "last-writer: 09" || bad "last-writer '$OUT'" +N="$(grep -c 'mdlsource/05-reject.mdl MICROFLOW Sales.ACT_ApprovalStep_Reject' "$LG")" +[ "$N" = 1 ] && [ "$(wc -l < "$LG" | tr -d ' ')" = 2 ] && ok "compaction: one row per (script, document) after 5 records" || bad "ledger not compacted: $(cat "$LG")" +printf '1\tRESTORE\tsnapA\n2\tRESTORE\tsnapB\n' >> "$LG"; rec mdlsource/06-approve.mdl +[ "$(grep -c RESTORE "$LG")" = 1 ] && grep -q 'RESTORE snapB' "$LG" && ok "compaction keeps only the last RESTORE row" || bad "RESTORE rows: $(grep RESTORE "$LG")" +OUT="$( ( cd "$O" && PROJECT_ROOT="$O" bash "$OWNERS" last-writer "MICROFLOW Sales.ACT_ApprovalStep_Reject" ) )" +[ -z "$OUT" ] && ok "last-writer ignores rows before the last RESTORE" || bad "last-writer after RESTORE '$OUT'" +T="$WORK/twotree"; mkdir -p "$T/app/mdlsource"; cp "$REJ" "$T/app/mdlsource/05-reject.mdl"; cp "$REJ" "$T/app/mdlsource/09-reject-fix.mdl" +OUT="$(bash "$OWNERS" count "$T" 2>&1)" +[ "$OUT" = "1 1" ] && ok "two-tree: app/mdlsource/ found (1 of 1 co-owned)" || bad "two-tree count '$OUT'" + +echo "-- fresh clone (empty ledger): committed evidence that the newer owner ran" +F="$WORK/clone"; mkdir -p "$F/mdlsource" "$F/docs" +cp "$REJ" "$F/mdlsource/05-reject.mdl" +{ echo "-- supersedes: 05-reject.mdl"; fix "A reason is required."; } > "$F/mdlsource/done-09-reject-fix.mdl" +fchk() { ( cd "$F" && PROJECT_ROOT="$F" bash "$OWNERS" check "$1" "$F" ) 2>&1; } +OUT="$(fchk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 1 ] && has "done-09 supersedes 05, replay 05, no ledger: REVERT" "$OUT" "REVERT MICROFLOW Sales.ACT_ApprovalStep_Reject mdlsource/done-09-reject-fix.mdl" || bad "done- evidence rc $RC: $OUT" +mv "$F/mdlsource/done-09-reject-fix.mdl" "$F/mdlsource/09-reject-fix.mdl" +OUT="$(fchk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "09 never run (draft-ahead), no ledger: quiet" || bad "draft-ahead rc $RC: $OUT" +printf '| when (ISO-8601 local) | script | gate | result | detail |\n|---|---|---|---|---|\n| 2026-10-20T10:00:00+0000 | `09-reject-fix.mdl` | pass | ✅ applied | mxbuild clean |\n' > "$F/docs/BUILD-LOG.md" +OUT="$(fchk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 1 ] && has "BUILD-LOG ✅ applied row for 09, no ledger: REVERT" "$OUT" "REVERT MICROFLOW Sales.ACT_ApprovalStep_Reject mdlsource/09-reject-fix.mdl" || bad "BUILD-LOG evidence rc $RC: $OUT" +printf '| when (ISO-8601 local) | script | gate | result | detail |\n|---|---|---|---|---|\n| 2026-10-20T10:00:00+0000 | `09-reject-fix.mdl` | fail | ❌ gate failed | script rolled back |\n' > "$F/docs/BUILD-LOG.md" +OUT="$(fchk mdlsource/05-reject.mdl)"; RC=$? +[ "$RC" -eq 0 ] && [ -z "$OUT" ] && ok "a rolled-back BUILD-LOG row is not 'has run': quiet" || bad "rolled-back row rc $RC: $OUT" + +# ── fixture project for live + exec.sh ─────────────────────────────────────── +P="$WORK/proj" +mkdir -p "$P/bin" "$P/mprcontents" "$P/mdlsource" "$P/live" +printf 'not a real model\n' > "$P/Fixture.mpr"; printf 'bson\n' > "$P/mprcontents/a.mxunit" +cp "$TOOLKIT/project-bin/_common.sh" "$P/bin/" +cp "$BIN/restore-mpr.sh" "$P/bin/" 2>/dev/null || cp "$TOOLKIT/project-bin/restore-mpr.sh" "$P/bin/" +cp "$SUBJECT" "$P/bin/exec.sh"; [ -f "$OWNERS" ] && cp "$OWNERS" "$P/bin/" +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 +# Fake mxcli. exec copies the script into live/ (what DESCRIBE then answers for its document) and +# appends to the model; DESCRIBE answers live/__.out, else replays the CAPTURED v0.26.0 +# not-found stderr + exit code with this name. Bad.Parse answers the verbatim v0.24.0 +# "no describable document named" refusal. Every DESCRIBE is logged to describe.log. +cat > "$P/mxcli" <> "$P/mxcli" <<'MXCLI' +D="$(cd "$(dirname "$0")" && pwd)" +case "${1:-}" in + check) exit 0 ;; + exec) echo "fake mxcli exec"; echo "written by $2" >> "$D/Fixture.mpr" + n="$(grep -oE 'microflow "?Sales"?\."?[A-Za-z_]+' "$2" | head -1 | sed -E 's/.*\."?//')" + [ -n "$n" ] && cp "$2" "$D/live/MICROFLOW__Sales.$n.out"; exit 0 ;; +esac +q="${4:-}"; echo "$q" >> "$D/describe.log"; q="${q#DESCRIBE }"; name="${q##* }"; kind="${q% *}" +f="$D/live/${kind}__${name}.out" +[ -f "$f" ] && { cat "$f"; exit 0; } +if [ "$name" = Bad.Parse ]; then + echo "Error: no describable document named \"Bad.Parse\" found in the project; specify the type explicitly" >&2; exit 1 +fi +sed -E "s/not found: [A-Za-z_.]+/not found: $name/" "$CAP26/notfound-$kind.stderr" >&2; exit "$(cat "$CAP26/notfound-$kind.rc")" +MXCLI +cat > "$WORK/mxbuild" <<'MXB' +#!/usr/bin/env bash +OUT=""; for a in "$@"; do case "$a" in --write-errors=*) OUT="${a#--write-errors=}" ;; esac; done +if [ "${FAIL_GATE:-0}" = 1 ]; then printf '{"problems":[{"severity":"Error","errorCode":"CE1234","message":"boom"}]}' > "$OUT"; exit 0; fi +[ -n "$OUT" ] && printf '{"problems":[]}' > "$OUT"; exit 0 +MXB +chmod +x "$P/bin/"*.sh "$P/mxcli" "$WORK/mxbuild" +( cd "$P" && git init -q . && printf '.mpr-snapshots/\ndocs/\n.claude/\ndescribe.log\nlive/\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" +commit_model() { ( cd "$P" && git add -A && git -c user.email=t@t -c user.name=t commit -qm m ) >/dev/null 2>&1; } +pso() { ( cd "$P" && PROJECT_ROOT="$P" bash bin/script-owners.sh "$@" ) 2>&1; } + +echo "-- live: the captured not-found output" +for k in MICROFLOW NANOFLOW PAGE SNIPPET; do + OUT="$(pso live $k Sales.Nope)"; RC=$? + [ "$RC" -eq 0 ] && [ "$OUT" = absent ] && ok "captured v0.26.0 $k not-found reads absent" || bad "$k not-found rc $RC: $OUT" +done +OUT="$(pso live MICROFLOW Bad.Parse)"; RC=$? +[ "$RC" -eq 3 ] && ok "'no describable document' is a failed call, never absent" || bad "refusal rc $RC: $OUT" +cp "$REJ" "$P/live/" +OUT="$(pso live MICROFLOW Sales.ACT_ApprovalStep_Reject)" +[ "$OUT" = present ] && ok "an existing document reads present" || bad "present '$OUT'" +rm -f "$P/live/"* + +echo "-- DESCRIBE cap: at most 5 per check, the rest UNCHECKED" +C="$P/mdlsource"; : > "$C/01-many.mdl"; : > "$C/02-many.mdl" +for i in 1 2 3 4 5 6 7; do + printf 'create or modify microflow "Sales"."M%s" ()\nbegin\nend;\n/\n' "$i" >> "$C/01-many.mdl" + printf 'create or modify microflow "Sales"."M%s" ()\nbegin\nend;\n/\n' "$i" >> "$C/02-many.mdl" + printf 'x\n' > "$P/live/MICROFLOW__Sales.M$i.out" +done +pso record mdlsource/01-many.mdl >/dev/null; pso record mdlsource/02-many.mdl >/dev/null; rm -f "$P/describe.log" +OUT="$(pso check mdlsource/01-many.mdl "$P")"; RC=$? +[ "$RC" -eq 1 ] && [ "$(printf '%s\n' "$OUT" | grep -c '^REVERT')" = 5 ] && ok "5 documents checked by DESCRIBE → REVERT" || bad "cap rc $RC: $OUT" +[ "$(printf '%s\n' "$OUT" | grep -c '^UNCHECKED.*CONFLICT (not checked')" = 2 ] && ok "the other 2: CONFLICT (not checked)" || bad "unchecked rows: $OUT" +[ "$(wc -l < "$P/describe.log" | tr -d ' ')" = 5 ] && ok "exactly 5 DESCRIBE calls" || bad "$(wc -l < "$P/describe.log") DESCRIBE calls" +rm -f "$C/01-many.mdl" "$C/02-many.mdl" "$P/live/"* "$P/.claude/loop/script-owners/written.tsv" + +# ── exec.sh ────────────────────────────────────────────────────────────────── +run() { # runs exec.sh on $1; output on stdout; rc in $WORK/rc + commit_model + ( cd "$P" && SKIP_BASELINE=1 SKIP_DOCTOR=1 SP_RESTART=0 MXTK_NO_INSTALL=1 MXTK_NO_SCORE=1 \ + MXBUILD_PATH="$WORK/mxbuild" ./bin/exec.sh "$1" ) > "$WORK/out" 2>&1 + echo $? > "$WORK/rc"; cat "$WORK/out" +} +row() { grep "\`$1\`" "$LOG" 2>/dev/null | tail -1; } + +echo "-- exec.sh: happy path (sole owner) — no WARN, no DESCRIBE, ledger row" +printf 'create or modify microflow "Sales"."ACT_Solo" ()\nbegin\nend;\n/\n' > "$C/01-solo.mdl" +rm -f "$P/describe.log" +OUT="$(run mdlsource/01-solo.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && ok "happy path: exit 0" || bad "happy path rc $RC: $(tail -3 "$WORK/out")" +hasnt "happy path: no WARN/NOTE" "$OUT" "WARN: restatement" +[ ! -s "$P/describe.log" ] && ok "happy path: zero DESCRIBE calls" || bad "happy path ran $(wc -l < "$P/describe.log") DESCRIBE call(s)" +grep -q "mdlsource/01-solo.mdl MICROFLOW Sales.ACT_Solo" "$P/.claude/loop/script-owners/written.tsv" 2>/dev/null \ + && ok "a kept write is recorded in the ledger" || bad "no ledger row for the kept write" + +echo "-- exec.sh: its own rollback writes no RESTORE marker" +printf 'create or modify microflow "Sales"."ACT_Broken" ()\nbegin\nend;\n/\n' > "$C/02-broken.mdl" +LGP="$P/.claude/loop/script-owners/written.tsv" +OUT="$(FAIL_GATE=1 run mdlsource/02-broken.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 1 ] && has "[setup] the failed gate rolled the write back" "$OUT" "Auto-restored from" || bad "[setup] rollback rc $RC: $(tail -3 "$WORK/out")" +[ -f "$LGP" ] && ! grep -q 'RESTORE' "$LGP" && ok "rollback: no RESTORE row in the ledger" || bad "rollback wrote a RESTORE row: $(cat "$LGP" 2>/dev/null)" +hasnt "rollback: the rolled-back script is not recorded" "$(cat "$LGP" 2>/dev/null)" "02-broken.mdl" +rm -f "$C/02-broken.mdl" + +echo "-- exec.sh: 05, then 09 (supersedes 05), replay 05, restore, replay 05" +cp "$REJ" "$C/05-reject.mdl" +{ echo "-- supersedes: 05-reject.mdl"; fix "A reason is required."; } > "$C/09-reject-fix.mdl" +OUT="$(run mdlsource/05-reject.mdl)"; hasnt "05 first run: no WARN" "$OUT" "WARN: restatement" +OUT="$(run mdlsource/09-reject-fix.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && hasnt "09 (supersedes 05): no WARN, no NOTE" "$OUT" "restatement" || bad "09 rc $RC" +SNAP09="$(ls -dt "$P/.mpr-snapshots"/*/ | head -1)" +OUT="$(run mdlsource/05-reject.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && ok "replay 05: exit 0 (warn-first)" || bad "replay rc $RC" +has "replay 05: WARN names 09" "$OUT" "superseded: mdlsource/09-reject-fix.mdl supersedes this script" +has " BUILD-LOG cell" "$(row 05-reject.mdl)" "WARN reverts: Sales.ACT_ApprovalStep_Reject" +SNAPR="$(ls -dt "$P/.mpr-snapshots"/*/ | head -1)" # taken before the replay: holds 09's work +( cd "$P" && bash bin/restore-mpr.sh "$SNAPR" ) >/dev/null 2>&1 +fix "A reason is required." > "$P/live/MICROFLOW__Sales.ACT_ApprovalStep_Reject.out" # the model again holds 09's body +grep -q 'RESTORE' "$P/.claude/loop/script-owners/written.tsv" && ok "restore-mpr.sh appended a RESTORE row" || bad "no RESTORE row" +OUT="$(run mdlsource/05-reject.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && has "replay 05 after the restore: WARN again (ledger alone said 05 wrote last)" "$OUT" "WARN: restatement" || bad "after-restore rc $RC" + +echo "-- exec.sh: a new owner without a header — NOTE once, not in BUILD-LOG" +fix "Say why." > "$C/12-reject-again.mdl" +OUT="$(run mdlsource/12-reject-again.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && has "first run: one NOTE line" "$OUT" "NOTE: new owner of Sales.ACT_ApprovalStep_Reject" || bad "12 rc $RC: $(tail -3 "$WORK/out")" +hasnt " no WARN" "$OUT" "WARN: restatement" +hasnt " not in BUILD-LOG" "$(row 12-reject-again.mdl)" "WARN" +OUT="$(run mdlsource/12-reject-again.mdl)" +hasnt "second run: no NOTE" "$OUT" "NOTE: new owner" + +echo "-- exec.sh: exec approval ask stays warn-first; =off is silent" +OUT="$(MXTK_ROOT="$TOOLKIT" CLAUDE_EXEC_APPROVAL=ask run mdlsource/05-reject.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && has "ask: WARN and written, exit 0" "$OUT" "fake mxcli exec" || bad "ask rc $RC" +OUT="$(MXTK_RESTATE_GUARD=off run mdlsource/05-reject.mdl)"; RC=$(cat "$WORK/rc") +[ "$RC" -eq 0 ] && hasnt "off: no warning" "$OUT" "restatement" || bad "off rc $RC" + +echo "" +echo "== $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]