From bdf4abb66f6ead2e6c10573829a43316b43ac709 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 12:54:20 +0800 Subject: [PATCH 1/2] fix(sync): item-3 rewrite works on BSD awk and no longer reports a rewrite that failed Fixes #426 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + bin/sync-project.sh | 75 ++++++++++++++++--------------- tests/wave2/test-exec-approval.sh | 30 +++++++++++++ 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b7acee..04dc32ca 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(sync): **the exec-approval item-3 rewrite now works on BSD awk (stock macOS) and `sync-project.sh` no longer prints "Rewrote" for a rewrite that failed.** `_ea_rewrite_item3` passed the multi-line replacement through `awk -v`, which BSD awk rejects ("newline in string", exit 2); the file stayed unchanged and sync still reported the rewrite on every run. The replacement is now streamed from a temp file (as the Q9 repair does), the helper returns 2 when awk fails, and the caller prints a `WARN:` instead of "Rewrote" and does not count a change. `test-exec-approval.sh` gains 3 assertions (failing-awk shim: no "Rewrote", a WARN, file unchanged). No other multi-line `awk -v` call site exists (the rest are single-line values) ([#426](https://github.com/MendixMau/mxcli-project-toolkit/issues/426)) — MendixMau - fix(journey-runner): **a journey file without a string `id` now warns naming the file and uses its basename as the id, so captures are named `-stepN.png` instead of `undefined-stepN.png`; two journeys ending up with the same id (explicit, or a basename that collides with an explicit id) are refused with exit 2 before the browser opens, naming both files.** The only new refusal is a duplicate id; existing id-less journey files keep running. `journey-examples.md` field table updated ([#383](https://github.com/MendixMau/mxcli-project-toolkit/issues/383)) — joshuamoesa - fix(lint-gate): **a ratchet rise now lists the findings that are NEW, not three arbitrary members of the rule.** The baseline keeps a multiset of findings (rule, module, document, message) next to the counts; on a rise the gate prints the surplus per rule, up to three per rule (the rest in `.claude/loop/lint-last.json`), labelled as new or changed findings next to the net rise. An old counts-only baseline falls back to the first three and says they are not necessarily new. Not done here: pre-exec snapshot attribution, a per-slice growth cap, a slice-close lint check (the rest of [#239](https://github.com/MendixMau/mxcli-project-toolkit/issues/239)). Test: `tests/wave2/test-lint-gate-new-findings.sh` — MendixMau (#239) - fix(conformance-check): read ledger rows whose pointer cell is bare (no backticks) and treat status `planned` as not-built; a ledger whose acceptance text is also bare prose is still not measured (acceptance must hold a backticked SHOW/DESCRIBE), but it now exits 2 with a FAULT that says rows were found and the text is prose, instead of "shape changed or glob matched nothing" (TK-006, partial: that ledger shape is diagnosed, not fixed) — field report TK-006 diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 5a8fa567..36178637 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1511,15 +1511,46 @@ _ea_rewrite_item3() { # $1=file $2=start-prefix $3=end-prefix $4=replacemen grep -qF -- "$needle" <<<"$_blk" || return 1 fi [ "$DRY_RUN" -eq 1 ] && return 0 - _tmp="$(mktemp "${TMPDIR:-/tmp}/eaitem3.XXXXXX")" || return 1 - awk -v start="$start" -v endp="$endp" -v repl="$repl" ' + _tmp="$(mktemp "${TMPDIR:-/tmp}/eaitem3.XXXXXX")" || return 2 + _rf="$(mktemp "${TMPDIR:-/tmp}/eaitem3r.XXXXXX")" || { rm -f "$_tmp"; return 2; } + # BSD awk (stock macOS) rejects a newline inside -v ("awk: newline in string"), so the + # replacement text is streamed from a file, as the Q9 repair does. Return 2 (not 0) when awk + # fails so the caller warns instead of reporting a rewrite that never happened. + printf '%s\n' "$repl" > "$_rf" + if awk -v start="$start" -v endp="$endp" -v rf="$_rf" ' BEGIN { skip = 0 } { if (skip) { if (index($0, endp) == 1) { skip = 0 } else { next } } - if (!skip && index($0, start) == 1) { print repl; skip = 1; next } + if (!skip && index($0, start) == 1) { + while ((getline l < rf) > 0) print l + close(rf); skip = 1; next } print } - ' "$f" > "$_tmp" && cat "$_tmp" > "$f" - rm -f "$_tmp" - return 0 + ' "$f" > "$_tmp" && [ -s "$_tmp" ]; then + cat "$_tmp" > "$f" + rm -f "$_tmp" "$_rf" + return 0 + fi + rm -f "$_tmp" "$_rf" + return 2 +} +# Report one item-3 rewrite attempt: $1=rc of _ea_rewrite_item3 $2=label $3=dry text $4=done text +_ea_report_item3() { + case "$1" in + 0) if [ "$DRY_RUN" -eq 1 ]; then echo "Would rewrite: $2 item 3 — $3" + else echo "Rewrote: $2 item 3 — $4"; fi + CHANGES=$((CHANGES + 1)) ;; + 2) echo "WARN: $2 item 3 rewrite FAILED (awk error or temp file); file left unchanged — re-run sync, or edit item 3 by hand." ;; + esac +} +_ea_try_item3() { # $1=file $2=label $3=md/hash text $4=prefix of "3. **" — tries both wordings + _p="$4"; _rc=0 + _ea_rewrite_item3 "$1" "${_p}3. **Never run" "${_p}4. **" "$3" || _rc=$? + if [ "$_rc" -ne 1 ]; then + _ea_report_item3 "$_rc" "$2" "carries the old \"without asking the user first — every time\" wording" "the old \"without asking the user first — every time\" wording is now the exec-approval knob (bin/exec-approval.sh)." + return + fi + _rc=0 + _ea_rewrite_item3 "$1" "${_p}3. **Before any" "${_p}4. **" "$3" '`ask` (the default)' || _rc=$? + _ea_report_item3 "$_rc" "$2" "still says \`ask\` is the default; exec approval now defaults to \`auto\`" "flipped from \"\`ask\` (the default)\" wording to auto-default (bin/exec-approval.sh)." } if [ "$WIRED" -eq 1 ]; then _EA_NEW3_MD="$(wiring_item3_md "$TOOLKIT_ROOT")" @@ -1527,38 +1558,10 @@ if [ "$WIRED" -eq 1 ]; then for rel in AGENTS.md CLAUDE.md .github/copilot-instructions.md .cursorrules .windsurfrules; do f="$PROJECT_DIR/$rel" [ -f "$f" ] || continue - if _ea_rewrite_item3 "$f" "3. **Never run" "4. **" "$_EA_NEW3_MD"; then - if [ "$DRY_RUN" -eq 1 ]; then - echo "Would rewrite: $rel item 3 — carries the old \"without asking the user first — every time\" wording" - else - echo "Rewrote: $rel item 3 — the old \"without asking the user first — every time\" wording is now the exec-approval knob (bin/exec-approval.sh)." - fi - CHANGES=$((CHANGES + 1)) - elif _ea_rewrite_item3 "$f" "3. **Before any" "4. **" "$_EA_NEW3_MD" '`ask` (the default)'; then - if [ "$DRY_RUN" -eq 1 ]; then - echo "Would rewrite: $rel item 3 — still says \`ask\` is the default; exec approval now defaults to \`auto\`" - else - echo "Rewrote: $rel item 3 — flipped from \"\`ask\` (the default)\" wording to auto-default (bin/exec-approval.sh)." - fi - CHANGES=$((CHANGES + 1)) - fi + _ea_try_item3 "$f" "$rel" "$_EA_NEW3_MD" "" done f="$PROJECT_DIR/.aider.conf.yml" - if [ -f "$f" ] && _ea_rewrite_item3 "$f" "# 3. **Never run" "# 4. **" "$_EA_NEW3_HASH"; then - if [ "$DRY_RUN" -eq 1 ]; then - echo "Would rewrite: .aider.conf.yml item 3 — carries the old \"without asking the user first — every time\" wording" - else - echo "Rewrote: .aider.conf.yml item 3 — the old \"without asking the user first — every time\" wording is now the exec-approval knob (bin/exec-approval.sh)." - fi - CHANGES=$((CHANGES + 1)) - elif [ -f "$f" ] && _ea_rewrite_item3 "$f" "# 3. **Before any" "# 4. **" "$_EA_NEW3_HASH" '`ask` (the default)'; then - if [ "$DRY_RUN" -eq 1 ]; then - echo "Would rewrite: .aider.conf.yml item 3 — still says \`ask\` is the default; exec approval now defaults to \`auto\`" - else - echo "Rewrote: .aider.conf.yml item 3 — flipped from \"\`ask\` (the default)\" wording to auto-default (bin/exec-approval.sh)." - fi - CHANGES=$((CHANGES + 1)) - fi + [ -f "$f" ] && _ea_try_item3 "$f" ".aider.conf.yml" "$_EA_NEW3_HASH" "# " fi # ── Per-harness auto-run allow-list: report only, never write ──────────────────────────── diff --git a/tests/wave2/test-exec-approval.sh b/tests/wave2/test-exec-approval.sh index 271eb957..b2f3ab2e 100755 --- a/tests/wave2/test-exec-approval.sh +++ b/tests/wave2/test-exec-approval.sh @@ -315,6 +315,36 @@ else bad "sync-project.sh not found/executable at $SYNC — cannot check the 2026-09-15 repair" fi +# --- sync-project.sh: a failed item-3 rewrite is reported as a failure, never as "Rewrote" ---- +# (#426) A shim awk that always exits 2 stands in for BSD awk rejecting the replacement. +if [ -x "$SYNC" ]; then + SP3="$WORK/syncproj3"; mkdir -p "$SP3/bin" "$WORK/failawk" + : > "$SP3/Fixture.mpr" + printf 'placeholder\n' > "$SP3/PROJECT.md" + printf 'placeholder\n' > "$SP3/CLAUDE.local.md" + printf 'placeholder\n' > "$SP3/intake.md" + mkdir -p "$SP3/.github" + for f in AGENTS.md CLAUDE.md .github/copilot-instructions.md .cursorrules .windsurfrules; do + printf '%s\n' "$OLDBLOCK" > "$SP3/$f" + done + # all six entry points present, so wire-agents --check passes and only the item-3 repair runs + printf '%s\n' "$OLDBLOCK" | sed 's/^/# /' | sed 's/[[:space:]]*$//' > "$SP3/.aider.conf.yml" + # fails only the replacement-streaming call (the one that passes rf=), like BSD awk would + printf '#!/bin/sh\nfor a in "$@"; do case "$a" in rf=*) echo "awk: simulated failure" >&2; exit 2;; esac; done\nexec /usr/bin/awk "$@"\n' > "$WORK/failawk/awk" + chmod +x "$WORK/failawk/awk" + BEFORE3="$(cat "$SP3/CLAUDE.md")" + OUT4="$(PATH="$WORK/failawk:$PATH" bash "$SYNC" "$SP3" 2>&1)" + printf '%s' "$OUT4" | grep -q 'Rewrote: CLAUDE.md item 3' \ + && bad "sync-project.sh: prints 'Rewrote' for an item-3 rewrite that failed" \ + || ok "sync-project.sh: no 'Rewrote' message when the item-3 rewrite fails" + printf '%s' "$OUT4" | grep -q 'WARN: CLAUDE.md item 3 rewrite FAILED' \ + && ok "sync-project.sh: failed item-3 rewrite is reported as a WARN" \ + || bad "sync-project.sh: failed item-3 rewrite gave no WARN" + [ "$BEFORE3" = "$(cat "$SP3/CLAUDE.md")" ] \ + && ok "sync-project.sh: file left unchanged when the rewrite fails" \ + || bad "sync-project.sh: file changed although the rewrite failed" +fi + echo "" echo "PASS=$PASS FAIL=$FAIL" [ "$FAIL" -eq 0 ] From 4424c2ac2d96a33b752b0620f82b5c9bf4dfb74a Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 13:06:03 +0800 Subject: [PATCH 2/2] fix(sync): a failed item-3 rewrite goes through warn() and is counted The failure line was a plain echo, so WARNINGS stayed unchanged and sync could still close with 'up to date' and pass --strict. Also return 2 when the replacement file cannot be written. The test syncs the same fixture with a working awk for a baseline and requires the six failures to add to the warning count (2 -> 8); on the previous head it stays 2 -> 2. Co-Authored-By: Claude Opus 5.5 --- bin/sync-project.sh | 4 ++-- tests/wave2/test-exec-approval.sh | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 36178637..10e92bf3 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1516,7 +1516,7 @@ _ea_rewrite_item3() { # $1=file $2=start-prefix $3=end-prefix $4=replacemen # BSD awk (stock macOS) rejects a newline inside -v ("awk: newline in string"), so the # replacement text is streamed from a file, as the Q9 repair does. Return 2 (not 0) when awk # fails so the caller warns instead of reporting a rewrite that never happened. - printf '%s\n' "$repl" > "$_rf" + printf '%s\n' "$repl" > "$_rf" || { rm -f "$_tmp" "$_rf"; return 2; } if awk -v start="$start" -v endp="$endp" -v rf="$_rf" ' BEGIN { skip = 0 } { if (skip) { if (index($0, endp) == 1) { skip = 0 } else { next } } @@ -1538,7 +1538,7 @@ _ea_report_item3() { 0) if [ "$DRY_RUN" -eq 1 ]; then echo "Would rewrite: $2 item 3 — $3" else echo "Rewrote: $2 item 3 — $4"; fi CHANGES=$((CHANGES + 1)) ;; - 2) echo "WARN: $2 item 3 rewrite FAILED (awk error or temp file); file left unchanged — re-run sync, or edit item 3 by hand." ;; + 2) warn "$2 item 3 rewrite FAILED (awk error or temp file); file left unchanged." "Re-run sync, or edit item 3 by hand." ;; esac } _ea_try_item3() { # $1=file $2=label $3=md/hash text $4=prefix of "3. **" — tries both wordings diff --git a/tests/wave2/test-exec-approval.sh b/tests/wave2/test-exec-approval.sh index b2f3ab2e..2ba0c887 100755 --- a/tests/wave2/test-exec-approval.sh +++ b/tests/wave2/test-exec-approval.sh @@ -333,13 +333,22 @@ if [ -x "$SYNC" ]; then printf '#!/bin/sh\nfor a in "$@"; do case "$a" in rf=*) echo "awk: simulated failure" >&2; exit 2;; esac; done\nexec /usr/bin/awk "$@"\n' > "$WORK/failawk/awk" chmod +x "$WORK/failawk/awk" BEFORE3="$(cat "$SP3/CLAUDE.md")" + cp -R "$SP3" "$SP3.base" + OUT4B="$(bash "$SYNC" "$SP3.base" 2>&1)" OUT4="$(PATH="$WORK/failawk:$PATH" bash "$SYNC" "$SP3" 2>&1)" printf '%s' "$OUT4" | grep -q 'Rewrote: CLAUDE.md item 3' \ && bad "sync-project.sh: prints 'Rewrote' for an item-3 rewrite that failed" \ || ok "sync-project.sh: no 'Rewrote' message when the item-3 rewrite fails" - printf '%s' "$OUT4" | grep -q 'WARN: CLAUDE.md item 3 rewrite FAILED' \ + printf '%s' "$OUT4" | grep -q '⚠️ CLAUDE.md item 3 rewrite FAILED' \ && ok "sync-project.sh: failed item-3 rewrite is reported as a WARN" \ || bad "sync-project.sh: failed item-3 rewrite gave no WARN" + # the failures must reach the WARNINGS counter: the same fixture synced with a working awk + # gives the baseline count, and the six failed rewrites must add to it + N0="$(printf '%s' "$OUT4B" | sed -n 's/^\([0-9][0-9]*\) warning(s).*/\1/p')" + N1="$(printf '%s' "$OUT4" | sed -n 's/^\([0-9][0-9]*\) warning(s).*/\1/p')" + [ "${N1:-0}" -ge $(( ${N0:-0} + 6 )) ] \ + && ok "sync-project.sh: failed item-3 rewrites are counted as warnings (${N0:-0} -> ${N1:-0})" \ + || bad "sync-project.sh: failed item-3 rewrites not counted as warnings (${N0:-0} -> ${N1:-0})" [ "$BEFORE3" = "$(cat "$SP3/CLAUDE.md")" ] \ && ok "sync-project.sh: file left unchanged when the rewrite fails" \ || bad "sync-project.sh: file changed although the rewrite failed"