diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c349266..53a3e5a4 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 +- new(sync-project): **`bin/sync-project.sh --only-bin [--only-bin …]` installs only the named `project-bin/` scripts into the project's `bin/` and touches nothing else; `--upgrade-bin` is now documented as what it is — a full sync that may also overwrite a modified copy.** A field run used `--upgrade-bin exec.sh --upgrade-bin _common.sh --upgrade-bin test-scratch.sh` as "install just these scripts" and got a full sync: 244 tracked files changed, one deleted, ~30 added, git hooks reinstalled. `--only-bin` decides everything before writing anything (a name outside the install manifest, NOINSTALL scripts included, a path-like or empty name, a locally modified copy without `--upgrade-bin `/`--force`, or an older copy the full sync's `_common.sh` refresh hold would keep, exits 1 with the project untouched; names are never glob-expanded), backs up an overwritten copy, and prints every file it wrote. The remedy lines in `app-restart.sh`, `test-scratch.sh`, `test-stack-up.sh`, `doctor.sh` and `harvest-learnings.sh` now name `--only-bin`. Fixture `tests/wave2/test-sync-only-bin.sh` (40 checks, single- and two-tree): one `git status` change, hooks and skills untouched, while a full `--upgrade-bin` sync of the same project installs other scripts and the hooks. — field run, issue #390 - new(bug-lookup): **`bin/bug-lookup.sh` ranks the ledger against a whole pasted error message.** One word (a CE code, a BUG id, a keyword) keeps the full-entry path unchanged; several words print the top 3 entries, each with header, score and one excerpt line, and `--full N` prints the N-th in full. Scoring is one POSIX awk pass: distinct query terms weighted by IDF, CE codes and BUG ids x3, header terms x1.5, an exact BUG id +100. A "no confident match" banner is printed below 40% term-weight coverage or for a CE code no entry mentions. `--exact` keeps the old literal phrase match for a quoted multi-word keyword. Measured on 40 labelled dev queries (the set the weights were tuned on): top-3 9% before (the literal match finds almost nothing in a pasted message) vs 94% now, top-1 89%, confident-wrong 8%. On 18 held-out queries written before the ranker ran on them: top-1 92%, top-3 100%, confident-wrong 17%. All 3 wrong answers are no-match rows without a CE code: the 40% coverage floor caught none of the 4 such rows across both sets, and coverage does not separate them from correct answers. Only the unknown-CE rule makes "no match" work today. The fixture skips, with a note, any no-match row whose CE code has since entered the ledger. About 45 ms per query vs about 950 ms (mawk; about 300 ms on BWK awk); at most 179 words printed. The banner also fires on 3 correct dev answers. Identical output under gawk, mawk and BWK awk 20121220/20180827/20231127; macOS's 20200816 build itself is not tested. All arguments are joined (`"$*"`; it read only `$1` before), so an unquoted multi-word query is ranked too. The banner exits 0 (it still lists the nearest 3); 1 is no match. The script header no longer caches the ledger's size. The routing row and CLAUDE.md mention the pasted message (+9 words on the mdl and gate stubs), and the `mxcli-bugs` row drops its stale "32k words". Fixture `tests/wave2/test-bug-lookup.sh` (floors on both sets; the PASS count depends on the awks installed; 9 checks fail on master). Field evidence: the 2026-10-15 golden-set run. — maintainer request, MendixMau - chore(migration skills): **duplicated migration and build-loop rules become pointers to their single source** (CLAUDE.md: "single source of the rule; every other mention defers here"). `migrate-general.md` §9 (nine OS 11 → Mendix mapping tables, about 115 lines) becomes a pointer to `source-os11.md` § Concept Mapping, after its lines not found there (the Data/Client Action implication, the sequence-from-10000 rationale, the Permission row, "Mendix does not enforce the layering", the ODC reasons) move into `source-os11.md`; its own OS 11 scope note goes with it. `migrate-general.md` §4 NPE lifecycle points at `learned-microflow-patterns.md` § NPE as Form Backing Object (the NPE-associations paragraph, found only here, stays). `migration-pipeline.md`'s restated extractor loop and validator checklist point at `extractor-quality-loop.md` § The Loop. The five "BUG-02 fixed in v0.13.0" notes in `iterative-build-loop.md`, `migrate-general.md` and `migration-pipeline.md` become `bin/bug-lookup.sh BUG-02` pointers (the struck-through handoff row in `migrate-general.md` §7 is deleted as history; the `iterative-build-loop.md` note stays because `test-bug09-13.sh` T6 keeps it on purpose); `migrate-outsystems.md` stops calling `migrate-general.md` the concept mapping. Same commit, to keep the companion PR under the 10-file cap: `iterative-build-loop.md`'s step-13 navigation and "every displayed field" bullets point at `module-review.md` § 4c, its wireframe drift-sync paragraph at `conversion-runbook.md` § 3b, and `checkpoint-cutover.md`'s What's Next at the runbook's Stage 7 row. References to the removed text are re-pointed: the audit-field standard (`migrate-outsystems.md`) and module layering (`migration-pipeline.md`) at `source-os11.md`, and `source-triage.md`'s "Phase 2's quality checklist" at `extractor-quality-loop.md` Validator Requirements; `source-os11.md`'s DAO and UI tables gain the remaining §9 details. Only always-loaded change: that `source-triage.md` line (−2 words); baseline 73,522 → 73,520. — authoring audit, maintainer request - fix(routing): **nine routing triggers rewritten so the words a user or agent actually types reach the row. BM25 over the 55-phrasing audit set: full trigger text 36/55 → 47/55 top-1, 46/55 → 55/55 top-3; rendered stub text (the part before the first " — ", which on-demand stub rows show) 34/55 → 42/55 top-1, 39/55 → 47/55 top-3; no phrasing ranks lower in either.** Seven phrasings missed with every audit method: "page shows 0 to 0 of 0" (empty-widget-triage), "mx check CE0066 on my access rule" (bug-lookup), "should this be a script or a skill" and "about to write a bash checker…" (skills-over-scripts), "the test wrote a row but my DB query shows nothing" (learned-local-db-confusion), "about to go live and move the old data over" (checkpoint-cutover), "half the buttons on the page do nothing" (wiring-sweep). Two CSS phrasings sat at rank 4–5 (learned-css-that-never-applied). Each trigger now front-loads the symptom words (CLAUDE.md skill-authoring rule 1), and the new words sit before the first " — ", so the stub text is a superset of the old stub text: every content word of the old stub and of the old full trigger survives, checked per row. wiring-sweep keeps "Every module before it is called done" first. module-review ("Is the module done? … once-over") and change-intake ("it also needs Y (a field, a column, a button)") were tightened from rank 2 to rank 1. Only column 3 of `bin/lib/skill-routing.tsv` changed, then re-rendered. Rows grow +6 to +15 words, and the baseline file total is unchanged (73,522). The measurement is in-sample: the rewrites were written against the same 55 phrasings. — routing retrieval audit 2026-10-15, MendixMau diff --git a/bin/doctor.sh b/bin/doctor.sh index 703c2b3d..ef25d8b6 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -318,7 +318,7 @@ ps_apply() { _psa_ex="$PROJECT_DIR/bin/exec.sh" if [ ! -x "$_psa_ex" ] || ! grep -q 'PATCH_MODE' "$_psa_ex" 2>/dev/null; then bad "property sets: not repaired — this project's bin/exec.sh has no --patch, and the repair is only run through it" - note "upgrade it first: bin/sync-project.sh $PROJECT_DIR --upgrade-bin exec.sh" + note "upgrade it first: bin/sync-project.sh $PROJECT_DIR --only-bin exec.sh --upgrade-bin exec.sh" return 0 fi note "repairing through bin/exec.sh --patch (snapshot, fix, mxbuild gate, kept only on a verified gate)…" diff --git a/bin/harvest-learnings.sh b/bin/harvest-learnings.sh index 5f9fccf4..0e3a77a1 100755 --- a/bin/harvest-learnings.sh +++ b/bin/harvest-learnings.sh @@ -190,8 +190,8 @@ COMMITSEOF STALE_N=$((STALE_N + 1)) # , never $PROJECT_DIR: the draft is destined for the public inbox and an # absolute path is a home-directory leak the pre-commit guard refuses (caught 2026-09-14). - printf -- '- bin/%s — STALE: identical to shipped %s (%s); fix: bin/sync-project.sh --upgrade-bin %s\n\n' \ - "$base" "$MATCH_SHORT" "$MATCH_DATE" "$base" >> "$TMP" + printf -- '- bin/%s — STALE: identical to shipped %s (%s); fix: bin/sync-project.sh --only-bin %s --upgrade-bin %s\n\n' \ + "$base" "$MATCH_SHORT" "$MATCH_DATE" "$base" "$base" >> "$TMP" continue fi diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 513c0810..1770bc32 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -12,6 +12,14 @@ # A locally modified script is REPORTED, never overwritten, unless --upgrade-bin names it # (and then the local copy is backed up first). Six of this machine's projects have # hand-hardened their exec.sh; a blind-overwrite sync would have destroyed all six. +# --upgrade-bin is NOT a scoped install: it only lifts that refusal, and the FULL sync +# below still runs (skills, CLAUDE.md, hooks, tests/e2e, deletions). +# - --only-bin X [--only-bin Y]: the scoped install. Copies ONLY the named project-bin/ +# scripts into /bin/, prints each file written, and touches nothing else — no +# section below runs. Same refusal for a locally modified copy (unless --upgrade-bin names +# it, or --force), and an unknown name exits 1 before anything is written. Field run, +# issue #390: an "--upgrade-bin exec.sh" install changed 244 tracked files, deleted one, +# added ~30 and reinstalled the git hooks. # - tests/e2e/ engine: same three-way contract as bin/ (missing / unedited older copy / # locally modified). --no-refresh turns the middle case back into a report for both. # - triage.md: installs it if the project predates the Stage 0 scaffold, refreshes it only @@ -83,6 +91,17 @@ AGENTS="$MXTK_AGENTS" # "Is this copy an unedited older toolkit version?" — the test that lets sections 4 and 4b # refresh a stale bin/ or tests/e2e/ copy without a flag. See the file's header. . "$SCRIPT_DIR/lib/past-stock.sh" +TK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# copy_kind — classify a project copy that exists: +# "same", "stock:" (an unedited older toolkit version), or "edited". +# Defined up here because --only-bin (which exits before any section runs) classifies too. +copy_kind() { + if cmp -s "$2" "$3"; then echo same; return; fi + # project-bin/X was bin/X before the project-bin split; its older versions live there. + _ck_when="$(mxtk_past_stock "$TK_ROOT" "$1" "$3" "bin/${1##*/}")" && { echo "stock:$_ck_when"; return; } + echo edited +} # Is this agent file an UNTOUCHED stub (safe to overwrite), or completed work? # Two conditions, both required: it still carries the STUB GENERATED banner AND it still @@ -128,6 +147,8 @@ w_mkdir() { } STRICT=0 UPGRADE_BIN="" +ONLY_BIN="" +FORCE=0 UPGRADE_LINT="" REPAIR_INTAKE=0 ADOPT_ROUTING=0 @@ -136,7 +157,9 @@ NO_REFRESH=0 PROJECT_DIR="" USAGE="Usage: $0 [--diff-completed] [--dry-run] [--strict] [--upgrade-bin ] [--upgrade-lint-rules ] - [--repair-intake] [--adopt-routing] [--pin-models] [--no-refresh]" + [--repair-intake] [--adopt-routing] [--pin-models] [--no-refresh] + $0 --only-bin [--only-bin ...] [--upgrade-bin ] + [--force] [--dry-run] [--no-refresh]" while [ $# -gt 0 ]; do case "$1" in --diff-completed) DIFF_COMPLETED=1; shift ;; @@ -146,6 +169,14 @@ while [ $# -gt 0 ]; do --adopt-routing) ADOPT_ROUTING=1; shift ;; --pin-models) PIN_MODELS=1; shift ;; --no-refresh) NO_REFRESH=1; shift ;; + --force) FORCE=1; shift ;; + --only-bin) + shift + [ $# -gt 0 ] || { echo "--only-bin needs a script name from the toolkit's project-bin/" >&2; exit 1; } + # One bare name per flag. An empty or space-holding value would split into nothing (and + # "0 file(s) written", exit 0) or into several names the caller never typed. + case "$1" in ""|*[[:space:]]*) echo "--only-bin needs ONE script name, got '$1'" >&2; exit 1 ;; esac + ONLY_BIN="$ONLY_BIN $1"; shift ;; --upgrade-bin) shift [ $# -gt 0 ] || { echo "--upgrade-bin needs a script name or 'all'" >&2; exit 1; } @@ -171,6 +202,13 @@ while [ $# -gt 0 ]; do echo " --strict exit 3 if any warning was emitted (for CI / gate-check)" echo " --upgrade-bin X accept the toolkit version of a LOCALLY MODIFIED bin/ crash-net" echo " script (or 'all'). The project copy is backed up first." + echo " NOT a scoped install: the full sync still runs. To install" + echo " just some scripts, use --only-bin." + echo " --only-bin X copy ONLY project-bin/X into /bin/ (repeatable) and" + echo " nothing else: no skills, CLAUDE.md, hooks, tests or deletions." + echo " A locally modified copy is refused unless --upgrade-bin names" + echo " it or --force is given (backed up first either way)." + echo " --force with --only-bin: overwrite locally modified copies too." echo " --upgrade-lint-rules X accept the toolkit version of a LOCALLY MODIFIED" echo " .claude/lint-rules/ rule (or 'all'), backing the copy up first." echo " --repair-intake accept the offered repair of a stale, still-verbatim intake" @@ -195,6 +233,13 @@ if [ -z "$PROJECT_DIR" ]; then echo "$USAGE" >&2 exit 1 fi +if [ -z "$ONLY_BIN" ]; then + [ "$FORCE" -eq 0 ] || { echo "--force only applies to --only-bin" >&2; exit 1; } +elif [ "$DIFF_COMPLETED$REPAIR_INTAKE$ADOPT_ROUTING$PIN_MODELS" != "0000" ] || [ -n "$UPGRADE_LINT" ]; then + echo "--only-bin installs bin/ scripts and nothing else; it cannot be combined with" >&2 + echo "--diff-completed, --repair-intake, --adopt-routing, --pin-models or --upgrade-lint-rules." >&2 + exit 1 +fi if [ ! -d "$PROJECT_DIR" ]; then echo "Error: project directory does not exist: $PROJECT_DIR" >&2 exit 1 @@ -202,6 +247,103 @@ fi PROJECT_NAME="$(basename "$(cd "$PROJECT_DIR" && pwd)")" +# --- --only-bin: the scoped install (issue #390) ------------------------------------------ +# Everything is decided before anything is written: an unknown name or a refused overwrite +# exits 1 with the project untouched. bin/ is /bin on both layouts (single-tree, +# and two-tree with the .mpr under app/) — the same place the crash-net section writes. +# Names must be in the install manifest ($MXTK_PROJECT_BIN): a file that sits in project-bin/ +# but is deliberately not installed (MXTK_PROJECT_BIN_NOINSTALL) is refused like any other. +# The full sync's refresh hold applies too: an unedited older copy is NOT refreshed on top of a +# hand-edited bin/_common.sh unless --upgrade-bin names it (or all) or --force is given. +# `set -f` while splitting the name lists: `--only-bin '*'` must never glob into the cwd. +if [ -n "$ONLY_BIN" ]; then + OB_SRC="$TK_ROOT/project-bin" + OB_DST="$PROJECT_DIR/bin" + ob_wired=0 + for m in bin PROJECT.md intake.md CLAUDE.local.md; do [ -e "$PROJECT_DIR/$m" ] && ob_wired=1; done + [ "$ob_wired" -eq 1 ] || { echo "Error: $PROJECT_DIR has no PROJECT.md, intake.md, CLAUDE.local.md or bin/ — not a wired project. Nothing written." >&2; exit 1; } + ob_err=0; ob_plan=""; ob_hold="" + if [ "$NO_REFRESH" -eq 0 ] && [ -f "$OB_DST/_common.sh" ] && [ -f "$OB_SRC/_common.sh" ] \ + && [ "$(copy_kind project-bin/_common.sh "$OB_SRC/_common.sh" "$OB_DST/_common.sh")" = edited ]; then + ob_hold="_common.sh" + fi + set -f + for s in $ONLY_BIN; do + case "$s" in */*|.*|"") echo "Error: --only-bin takes a bare file name from project-bin/, got '$s'" >&2; ob_err=1; continue ;; esac + src="$OB_SRC/$s"; dst="$OB_DST/$s" + if [ ! -f "$src" ]; then echo "Error: no such script in the toolkit's project-bin/: $s" >&2; ob_err=1; continue; fi + case " $MXTK_PROJECT_BIN " in *" $s "*) ;; *) + case " $MXTK_PROJECT_BIN_NOINSTALL " in + *" $s "*) echo "Error: $s is deliberately not installed into projects (MXTK_PROJECT_BIN_NOINSTALL in bin/lib/install-manifest.sh)" >&2 ;; + *) echo "Error: $s is not in the install manifest (MXTK_PROJECT_BIN in bin/lib/install-manifest.sh)" >&2 ;; + esac + ob_err=1; continue ;; + esac + if [ ! -f "$dst" ]; then ob_plan="$ob_plan install:$s"; continue; fi + kind="$(copy_kind "project-bin/$s" "$src" "$dst")" + if [ "$kind" = same ]; then ob_plan="$ob_plan same:$s"; continue; fi + if [ "$FORCE" -eq 1 ] || case " $UPGRADE_BIN " in *" all "*|*" $s "*) true ;; *) false ;; esac; then + ob_plan="$ob_plan upgrade:$s"; continue + fi + if [ "${kind#stock:}" != "$kind" ]; then + if [ "$NO_REFRESH" -eq 0 ] && { [ -z "$ob_hold" ] || [ "$s" = "$ob_hold" ]; }; then + ob_plan="$ob_plan refresh:$s"; continue + fi + if [ -n "$ob_hold" ] && [ "$NO_REFRESH" -eq 0 ]; then + echo "⚠️ bin/$s is an unedited toolkit copy of ${kind#stock:}, NOT refreshed: bin/$ob_hold is" >&2 + echo " locally edited and $s depends on it. Accept both together: $0 $PROJECT_DIR --upgrade-bin all" >&2 + echo " Scoped: $0 $PROJECT_DIR --only-bin $ob_hold --only-bin $s --upgrade-bin $ob_hold --upgrade-bin $s" >&2 + else + echo "Refused: bin/$s is an unedited toolkit copy of ${kind#stock:} and --no-refresh keeps it." >&2 + fi + else + echo "Refused: bin/$s is LOCALLY MODIFIED. See what differs: diff -u $src $dst" >&2 + echo " Accept the toolkit version (your copy is backed up): add --upgrade-bin $s (or --force)" >&2 + fi + ob_err=1 + done + set +f + [ "$ob_err" -eq 0 ] || { echo "--only-bin: nothing written." >&2; exit 1; } + echo "=== Scoped bin/ install for $PROJECT_DIR (--only-bin; nothing else is touched) ===" + [ -d "$OB_DST" ] || w_mkdir "$OB_DST" + ob_n=0 + set -f + for item in $ob_plan; do + act="${item%%:*}"; s="${item#*:}"; src="$OB_SRC/$s"; dst="$OB_DST/$s" + if [ "$act" = same ]; then + if [ -x "$src" ] && [ ! -x "$dst" ]; then + if [ "$DRY_RUN" -eq 1 ]; then echo "Would repair exec bit: bin/$s"; else chmod +x "$dst"; echo "Repaired exec bit: bin/$s"; fi + ob_n=$((ob_n + 1)) + else + echo "Unchanged: bin/$s (already the toolkit version)" + fi + continue + fi + if [ "$DRY_RUN" -eq 1 ]; then + if [ "$act" = upgrade ]; then + echo "Would upgrade: bin/$s — local copy would be backed up first (bin/$s.local-)" + ob_n=$((ob_n + 2)) + else + echo "Would write: bin/$s ($act)"; ob_n=$((ob_n + 1)) + fi + continue + fi + if [ "$act" = upgrade ]; then + bak="$dst.local-$(date +%Y%m%d%H%M%S)" + cp "$dst" "$bak"; chmod -x "$bak" + echo "Wrote: bin/$(basename "$bak") (backup of your locally modified copy)" + ob_n=$((ob_n + 1)) + fi + cp "$src" "$dst" + if [ -x "$src" ]; then chmod +x "$dst"; fi + echo "Wrote: bin/$s ($act)" + ob_n=$((ob_n + 1)) + done + set +f + echo "--only-bin: $ob_n file(s) $( [ "$DRY_RUN" -eq 1 ] && echo 'would be written' || echo 'written'); nothing else was touched." + exit 0 +fi + # --- 0. --diff-completed: the reverse path ---------------------------------------------- # Sync only ever flowed downhill. A completed agent was cut off from its template in BOTH # directions: the project never saw template improvements, and the toolkit never saw what @@ -969,16 +1111,6 @@ CRASHNET_SRC="$(cd "$SCRIPT_DIR/.." && pwd)/project-bin" # on — not crash-net per se, but "missing -> install, drifted -> report, never # blind-overwrite" applies identically to a project's tuned copy of either. CRASHNET_FILES="$MXTK_PROJECT_BIN" -TK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -# copy_kind — classify a project copy that exists: -# "same", "stock:" (an unedited older toolkit version), or "edited". -copy_kind() { - if cmp -s "$2" "$3"; then echo same; return; fi - # project-bin/X was bin/X before the project-bin split; its older versions live there. - _ck_when="$(mxtk_past_stock "$TK_ROOT" "$1" "$3" "bin/${1##*/}")" && { echo "stock:$_ck_when"; return; } - echo edited -} # The refresh hold: a hand-edited shared library keeps every older copy that depends on it as # it is (reported), because a refreshed caller on an edited library has never been run. diff --git a/project-bin/app-restart.sh b/project-bin/app-restart.sh index 9fc69e81..4126cad8 100755 --- a/project-bin/app-restart.sh +++ b/project-bin/app-restart.sh @@ -143,7 +143,7 @@ mask() { # print argv joined, with the value after any password/secret/token/ke # with the remedy, instead of dying mid-restart on "command not found". command -v mxtk_port_holder >/dev/null 2>&1 || { echo "app-restart: bin/_common.sh is older than this script (no mxtk_port_holder)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # blocked "|" — exit 4, the holder named, nothing of it touched. blocked() { echo "✗ app-restart: instrument blocked — port :$1 is held by $(mxtk_holder_text "$2"), not this project's run --local." >&2 @@ -160,7 +160,7 @@ blocked() { # _common.sh that predates them. command -v mxtest_left >/dev/null 2>&1 && command -v mxtk_pid_is_mxcli_run >/dev/null 2>&1 || { echo "app-restart: bin/_common.sh is older than this script (no mxtest_left / mxtk_pid_is_mxcli_run)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # in_our_tree — the recorded pid itself, or a descendant of it (the runtime mxcli started). in_our_tree() { [ "$ALIVE" = 1 ] && mxtk_pid_in_tree "$1" "$PID"; } diff --git a/project-bin/test-scratch.sh b/project-bin/test-scratch.sh index 09f019fe..9b1b5663 100755 --- a/project-bin/test-scratch.sh +++ b/project-bin/test-scratch.sh @@ -67,7 +67,7 @@ else echo "✗ test-scratch: no mxcli (project root or PATH)." >&2; exit 3; fi # "no injection left" and copying a model that recurses in its after-startup. command -v mxtest_left >/dev/null 2>&1 && command -v mxtest_refusal >/dev/null 2>&1 || { echo "test-scratch: bin/_common.sh is older than this script (no mxtest_left / mxtest_refusal)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # A model whose after-startup is an mxcli test injection must not be copied and tested: the copy # carries it, and mxcli test then chains the injection to itself (#244 observation 1). diff --git a/project-bin/test-stack-up.sh b/project-bin/test-stack-up.sh index 09f7f009..55208809 100755 --- a/project-bin/test-stack-up.sh +++ b/project-bin/test-stack-up.sh @@ -160,7 +160,7 @@ esac # the remedy instead of dying mid-run on "command not found". command -v mxtk_pid_runs_deployment >/dev/null 2>&1 || { echo "test-stack-up: bin/_common.sh is older than this script (no mxtk_pid_runs_deployment)." >&2 - echo " → /bin/sync-project.sh $ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; } diff --git a/tests/wave2/test-sync-only-bin.sh b/tests/wave2/test-sync-only-bin.sh new file mode 100755 index 00000000..e4494e94 --- /dev/null +++ b/tests/wave2/test-sync-only-bin.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Fixture for sync-project.sh --only-bin, the scoped install (issue #390, field finding 1). +# +# The field run that prompted it: "sync-project.sh --upgrade-bin exec.sh ..." was +# handed out as "install just these scripts". --upgrade-bin only lifts the overwrite refusal; +# the full sync still ran and changed 244 tracked files, deleted one, added ~30 and +# reinstalled the git hooks. --only-bin must write the named bin/ scripts and NOTHING else. +# +# usage: test-sync-only-bin.sh /path/to/sync-project.sh +# +# Each case runs on BOTH layouts (single-tree, and two-tree with the .mpr under app/): +# O1 --only-bin restore-mpr.sh on a git-committed project: git status shows exactly one +# change (bin/restore-mpr.sh), although a full sync would have done more (control) +# O2 hooks are not touched (removed hooks stay removed; a full sync reinstalls them) +# O3 skills are not touched, and the whole tree outside bin/ is byte-identical +# O4 an unknown script exits non-zero and writes nothing; so does a path-like name +# O5 a locally modified copy is refused (exit 1, nothing written), accepted with +# --upgrade-bin or --force, backed up first, and every written file is printed +# O6 the full sync's refresh hold: an unedited OLDER exec.sh is refreshed on a clean +# _common.sh (control) but refused on a hand-edited one, unless --upgrade-bin names it; +# --dry-run of that upgrade says a backup would be written, and writes nothing +# O7 a project-bin/ file outside the install manifest (NOINSTALL) is refused +# O8 --only-bin '*' from a cwd full of script names installs nothing (no glob expansion) +# O9 --only-bin '' is a usage error +# +# Every fixture is built by the REAL bin/init-project.sh under /tmp. No real project is touched. +export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" +export MXTK_SYNC_SKIP_CLONE_CHECK=1 +set -uo pipefail + +SYNC="${1:?usage: test-sync-only-bin.sh /path/to/sync-project.sh}" +case "$SYNC" in /*) ;; *) SYNC="$PWD/$SYNC" ;; esac +TK="$(cd "$(dirname "$SYNC")/.." && pwd)" +# O6 needs an older exec.sh the toolkit really shipped (CI checks out full history). +OLD_EXEC_REV="$(git -C "$TK" log --format=%H -n2 -- project-bin/exec.sh 2>/dev/null | sed -n 2p)" +INIT="$TK/bin/init-project.sh" +WORK="$(mktemp -d /tmp/onlybin.XXXXXX)" +trap 'rm -f "$MXTK_LEAKGUARD_DENYFILE"; rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } + +# Path, exec bit and content hash of every file, .git/hooks included (that is where the +# reinstall lands) but the rest of .git excluded (index stat noise). $2 = a path to leave out. +fingerprint() { + ( cd "$1" && find . -type f ! -path './.git/*' -o -type f -path './.git/hooks/*' ) 2>/dev/null \ + | LC_ALL=C sort | while read -r f; do + [ "$f" = "./${2:-//none}" ] && continue + if [ -x "$1/$f" ]; then x=x; else x=-; fi + printf '%s %s %s\n' "$f" "$x" "$(md5 -q "$1/$f" 2>/dev/null || md5sum "$1/$f" | cut -d' ' -f1)" + done +} + +# A committed project in which a FULL sync has work to do: two bin/ scripts missing, the git +# hooks removed, and a skills tree a sync must not touch. +mkproj() { + d="$WORK/$1-$2"; mkdir -p "$d" + if [ "$2" = two ]; then mkdir -p "$d/app"; : > "$d/app/Fixture.mpr"; else : > "$d/Fixture.mpr"; fi + git -C "$d" init -q + MXTK_NO_GUIDE=1 "$INIT" "$d" >/dev/null 2>&1 + mkdir -p "$d/.claude/skills/demo" "$d/.ai-context/skills" + echo "# demo skill" > "$d/.claude/skills/demo/SKILL.md" + echo "# local skill" > "$d/.ai-context/skills/local.md" + rm -f "$d/bin/restore-mpr.sh" "$d/bin/snapshot-mpr.sh" + find "$d/.git/hooks" -type f ! -name '*.sample' -exec rm -f {} + + git -C "$d" add -A >/dev/null 2>&1 + git -C "$d" -c user.name=fx -c user.email=t@t commit -qm fixture --no-verify >/dev/null 2>&1 + echo "$d" +} +# git status lines (ignored files included) that differ from the snapshot $2 — what a run changed. +gst() { git -C "$1" status --porcelain --ignored | LC_ALL=C sort; } +st_delta() { gst "$1" | LC_ALL=C comm -3 - <(printf '%s\n' "$2"); } +hooks_of() { find "$1/.git/hooks" -type f ! -name '*.sample' | LC_ALL=C sort; } + +for L in single two; do + echo "== layout: $L ==" + P="$(mkproj o1 "$L")" + B="$(fingerprint "$P" bin/restore-mpr.sh)"; S0="$(gst "$P")" + OUT="$("$SYNC" "$P" --only-bin restore-mpr.sh 2>&1)"; RC=$? + ST="$(st_delta "$P" "$S0")" + [ "$RC" -eq 0 ] && ok "O1[$L] exit 0" || bad "O1[$L] exit $RC" "$OUT" + [ "$ST" = "?? bin/restore-mpr.sh" ] && ok "O1[$L] git status shows exactly bin/restore-mpr.sh" \ + || bad "O1[$L] git status is not exactly one change" "$ST" + cmp -s "$TK/project-bin/restore-mpr.sh" "$P/bin/restore-mpr.sh" && [ -x "$P/bin/restore-mpr.sh" ] \ + && ok "O1[$L] bin/restore-mpr.sh is the toolkit copy, executable" || bad "O1[$L] restore-mpr.sh not installed" + printf '%s' "$OUT" | grep -q '^Wrote: bin/restore-mpr.sh' && printf '%s' "$OUT" | grep -q '1 file(s) written' \ + && ok "O1[$L] prints the file written" || bad "O1[$L] written file not printed" "$OUT" + [ -z "$(hooks_of "$P")" ] && ok "O2[$L] hooks not touched (still absent)" || bad "O2[$L] hooks were installed" "$(hooks_of "$P")" + [ "$B" = "$(fingerprint "$P" bin/restore-mpr.sh)" ] && ok "O3[$L] skills and every other file byte-identical" \ + || bad "O3[$L] something besides bin/restore-mpr.sh changed" "$(diff <(echo "$B") <(fingerprint "$P" bin/restore-mpr.sh))" + + # Control: the same project under the old instruction does far more — the fixture discriminates. + "$SYNC" "$P" --upgrade-bin restore-mpr.sh >/dev/null 2>&1 + { [ -f "$P/bin/snapshot-mpr.sh" ] && [ -n "$(hooks_of "$P")" ]; } \ + && ok "control[$L] a full --upgrade-bin sync installs other scripts and the hooks" \ + || bad "control[$L] the full sync did not do more — O1-O3 prove nothing" + + P="$(mkproj o4 "$L")"; B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin restore-mpr.sh --only-bin no-such-script.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'no-such-script.sh' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O4[$L] unknown script: exit $RC, named, nothing written (not even the valid one)" \ + || bad "O4[$L] unknown script: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin ../bin/gate-check.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ "$B" = "$(fingerprint "$P")" ] && ok "O4[$L] path-like name refused" || bad "O4[$L] path-like name: rc=$RC" "$OUT" + + P="$(mkproj o5 "$L")" + printf '#!/usr/bin/env bash\n# LOCAL HARDENING\nexit 0\n' > "$P/bin/exec.sh" + git -C "$P" -c user.name=fx -c user.email=t@t commit -qam local --no-verify >/dev/null 2>&1 + B="$(fingerprint "$P")"; S0="$(gst "$P")" + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q 'LOCALLY MODIFIED' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O5[$L] locally modified copy refused, nothing written" || bad "O5[$L] refusal: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh 2>&1)"; RC=$? + BAK="$(find "$P/bin" -name 'exec.sh.local-*' | head -1)" + { [ "$RC" -eq 0 ] && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" && [ -n "$BAK" ] \ + && grep -q 'LOCAL HARDENING' "$BAK" && printf '%s' "$OUT" | grep -q '2 file(s) written'; } \ + && ok "O5[$L] --upgrade-bin names it: upgraded, backed up, both files printed" || bad "O5[$L] --upgrade-bin: rc=$RC" "$OUT" + ST="$(st_delta "$P" "$S0")" + { [ "$(printf '%s\n' "$ST" | wc -l | tr -d ' ')" = 2 ] && printf '%s' "$ST" | grep -q '^ M bin/exec.sh$' \ + && printf '%s' "$ST" | grep -q '^?? bin/exec.sh.local-'; } \ + && ok "O5[$L] git status: exec.sh + its backup, nothing else" || bad "O5[$L] extra changes" "$ST" + printf '#!/usr/bin/env bash\n# LOCAL AGAIN\n' > "$P/bin/exec.sh" + "$SYNC" "$P" --only-bin exec.sh --force >/dev/null 2>&1 && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" \ + && ok "O5[$L] --force overwrites (after a backup)" || bad "O5[$L] --force did not overwrite" + + P="$(mkproj o6 "$L")" + if [ -z "$OLD_EXEC_REV" ]; then + bad "O6[$L] no older project-bin/exec.sh in the toolkit's git history (shallow clone?)" + else + old_exec() { git -C "$TK" show "$OLD_EXEC_REV:project-bin/exec.sh" > "$P/bin/exec.sh"; chmod +x "$P/bin/exec.sh"; } + old_exec + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q '^Wrote: bin/exec.sh (refresh)' \ + && ok "O6[$L] control: older unedited exec.sh refreshed on a clean _common.sh" || bad "O6[$L] control: rc=$RC" "$OUT" + old_exec; printf '# local hardening\n' >> "$P/bin/_common.sh" + git -C "$P" -c user.name=fx -c user.email=t@t commit -qam held --no-verify >/dev/null 2>&1 + B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q 'NOT refreshed: bin/_common.sh is' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O6[$L] held on an edited _common.sh: refused with the full sync's message, nothing written" || bad "O6[$L] hold: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh --dry-run 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q 'local copy would be backed up first' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O6[$L] --dry-run upgrade names the backup and writes nothing" || bad "O6[$L] dry-run: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" && [ -n "$(find "$P/bin" -name 'exec.sh.local-*')" ] \ + && ok "O6[$L] --upgrade-bin exec.sh lifts the hold (backed up)" || bad "O6[$L] upgrade: rc=$RC" "$OUT" + fi + + P="$(mkproj o7 "$L")"; B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin report-disposition-check.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'NOINSTALL' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O7[$L] NOINSTALL script refused, nothing written" || bad "O7[$L] NOINSTALL: rc=$RC" "$OUT" + + G="$WORK/glob-$L"; mkdir -p "$G"; : > "$G/restore-mpr.sh"; : > "$G/snapshot-mpr.sh" + OUT="$(cd "$G" && "$SYNC" "$P" --only-bin '*' 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ ! -e "$P/bin/restore-mpr.sh" ] && [ ! -e "$P/bin/snapshot-mpr.sh" ] && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O8[$L] --only-bin '*' is not glob-expanded: refused, nothing written" || bad "O8[$L] glob: rc=$RC" "$OUT" + + OUT="$("$SYNC" "$P" --only-bin '' 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ "$B" = "$(fingerprint "$P")" ] && ok "O9[$L] --only-bin '' is a usage error" || bad "O9[$L] empty name: rc=$RC" "$OUT" +done + +echo "" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ]