diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c13be6a..eabec440 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(lint): **the toolkit's CONV020 (page actions give user feedback) is now `MXTK-UX003` (`mxtk_ux003_action_user_feedback.star`), because mxcli v0.28.0 ships a built-in CONV020 ("Created object never persisted", mendixlabs/mxcli#1518) and has no duplicate-ID guard.** Captured on a public model (`tests/wave2/fixtures/lint-conv020-v028/`): v0.28.0 reported two CONV020 findings, one per rule, `-l` listed CONV020 twice, and `CONV020: enabled: false` switched both off. `bin/sync-project.sh` migrates projects: an unedited old copy (md5 of any shipped version, CR-insensitive, or the receipt row) is replaced, `lint-baseline.json` findings whose message is the toolkit rule's are rekeyed to MXTK-UX003 (the built-in's stay CONV020), and a `lint-rules-disabled.txt` line follows the file. A lint-config opt-out of CONV020 is carried over too: a `lint-rules-disabled.txt` line for the new file keeps MXTK-UX003 off (the key stays; on v0.28.0 it switches off only the built-in). It is read the way mxcli v0.28.0 reads it (yaml.v3, captured per spelling): `enabled:` `false`/`False`/`FALSE` or `n`/`no`/`off` in any of yaml.v3's casings (`No`, `NO`, `Off`, `OFF`…, plain or quoted), as a direct child of a bare, single- or double-quoted `CONV020` key or in its flow map, from the first of `.claude/lint-config.yaml`, `lint-config.yaml`, `.lint-config.yaml`; an `enabled` nested under `options:` and a quoted `"false"` (mxcli fails to load that config) are not opt-outs. An edited copy is left in place with a warning and manual steps (with the `lint-rules-disabled.txt` line to keep such an opt-out), and the new rule is not installed beside it. A counts-only baseline, `lint-config.yaml` keys and `PROJECT.md` lines that name CONV020 get a NOTE, not a guess, printed once. New toolkit rules take an `MXTK-` id (`lint-rules/README.md` "Rule IDs"). After any mxcli upgrade, `lint-gate.sh --update-baseline` now records `mxcli_version`, and `bin/doctor.sh` prints the re-baseline hint when the installed mxcli differs from the recorded one (a baseline with no recorded version gets a note, no command; `skills/lint-that-actually-runs.md`). Decision #12 — mxcli v0.28.0 test pass - process(routing, mdl-agent): **the mdl helper no longer preloads the kind-specific MDL skills at Stage 5; it loads them per script through `bin/mdl-rules.sh`** — lazy-load PR 2b. `learned-microflow-patterns.md`, `microflow-preflight.md` and `ui-preflight-pages.md` move to on-demand; `learned-mdl-preflight.md`, `design-spacing.md`, `learned-detection-gaps.md`, `security-is-not-a-later-script.md` and `retesting-learned-rules.md` become on-demand for mdl only (`mdl?`), unchanged for every other role; a new baseline `mdl-rules` row (Stages 5 and 6, so a Stage 6 mdl pack keeps the detection-gaps and retesting rules; mdl Stage 6 preload 9,476 words) replaces them. Baseline 72,891 → 63,877 words; mdl Stage 5 preload 29,388 → 14,600 (per script, after reading the `mdl-rules` output, the helper reads ≈21.4k words for page only, ≈22.5k for microflow+access, ≈28.1k for page+microflow, ≈29.5k for all kinds: ≈0–8k saved by kind; the A/B covered rule sufficiency with kinds pre-selected, not step 4a) (the 3,347-word core of `bin/mdl-rules.sh` counted, like the runbook slice). The mdl stub's workflow step 4a and report line ask for "STOP rows for kinds K: n/n checked". `context-pack.sh`'s "Rules" list names `/bin/mdl-rules.sh ` with the step's kinds (page / microflow / entity) — after this change the only route `microflow-preflight`, `security-is-not-a-later-script`, `design-spacing`, `learned-detection-gaps` and `retesting-learned-rules` have to a pack-fed helper (fixture: a page step and a logic step). `gate-check.sh` build-ready item 2 accepts `bin/mdl-rules.sh` as well as `ui-preflight-pages`, so a project synced after this change does not flip to FAIL (fixture: both layouts). No skill text changed — lazy-load plan, option C (maintainer) - new(mdl-rules): **`bin/mdl-rules.sh | --for ` prints the MDL rules one script needs, by the kinds of statement it writes** — lazy-load PR 2a. `bin/lib/mdl-rule-kinds.tsv` maps page / microflow / access / workflow / entity / any to sections located by file and heading text (never line numbers): the STOP rows by their `Kind` column, the kind-specific gotchas, `learned-microflow-patterns.md`, `microflow-preflight.md`, `ui-preflight-pages.md`, `design-spacing.md` §1–3, the detection-gaps register rows by construct, `security-is-not-a-later-script.md` and `retesting-learned-rules.md`. Output is the core (Step 0, the `any` STOP rows, gotchas, final self-check: 3,347 words) plus the kind sections, closed by "Rules for kinds K: STOP rows n/n listed" (page 10,223 words, microflow 10,186, access 4,730, entity 5,183, workflow 3,732), against the 29,388 words the mdl helper preloads at Stage 5 today. `--for` reuses exec.sh's statement classifier (`exec.sh --subjects`; Java actions and mappings count as microflow, demo users as access) and exits 1 if it fails; a heading that is gone fails loudly, never prints empty. `skills/generated/rules-.md` are the same output for harnesses that read files (Copilot, Cursor, Windsurf); `--check` (CI) fails on drift, on any STOP row, gotcha, register row or `##` section that reaches no kind (a section left out on purpose needs an `exclude` row with its reason), and on any register ERE that matches no row by itself, and on an unclosed code fence in a file it reads. No routing change yet: PR 2b demotes the preloads. Behaviour suite: `build-packs.sh --rules ""` expands the script in a pack. Fixture `tests/wave2/test-mdl-rules.sh` — lazy-load plan, option C (maintainer) - process(preflight): **`learned-mdl-preflight.md` moves its STOP-row narrative out of the baseline and gains a `Kind` column (page / microflow / access / workflow / any)** — lazy-load PR 1. The 15 retired rows collapse to one line listing their numbers, the version notes move, and each live row keeps its If/Then text plus one failure clause; the full rows, retired rows and version notes are in `process/baseline-incident-history.md` § PF1, verbatim (`check-moved-text.sh`: 43 of 43 removed lines found). The workflow `DECISION` STOP and the #879 read-first pointer stay in the skill. `Kind` sits at the end of each row so `mdl-scan`'s `| 🔎` markers still match. Baseline 73,946 → 72,891 words (−1,055). — maintainer (Option C lazy-loading plan) diff --git a/bin/doctor.sh b/bin/doctor.sh index 9fa42c9c..af52b3d9 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -1546,6 +1546,32 @@ fi # --- verdict --------------------------------------------------------------------------------- +# Line 2 of the receipt below: the installed mxcli's --version line. Read here, once (it was read +# inside the receipt block before), so the lint-baseline hint below needs no extra mxcli call. +FP_MXCLI="none" +[ -n "$PROJECT_DIR" ] && [ -x "$PROJECT_DIR/mxcli" ] && FP_MXCLI="$("$PROJECT_DIR/mxcli" --version 2>/dev/null | head -1)" + +# Lint baseline vs this mxcli. A new mxcli can add built-in rules (v0.28.0: CONV020) that raise +# lint on the next exec with no model change. lint-gate.sh --update-baseline records +# mxcli_version. Notes only: the exit code does not change, the gate's behaviour does not either. +_lb="${PROJECT_DIR:-.}/.claude/lint-baseline.json" +_lb_now="$(printf '%s\n' "$FP_MXCLI" | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1)" +if [ -n "$PROJECT_DIR" ] && [ -f "$_lb" ] && [ -n "$_lb_now" ]; then + _lb_was="$(sed -n 's/.*"mxcli_version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_lb" | head -1)" + if [ "$_lb_was" != "$_lb_now" ]; then + head_ "Lint baseline: $PROJECT_DIR" + # No recorded version is no evidence of a rise: name no command (a reflex re-baseline is what + # skills/lint-that-actually-runs.md warns against). A recorded, different one is evidence. + if [ -z "$_lb_was" ]; then + note "lint baseline records no mxcli version; if mxcli was upgraded since it was taken, new built-in rules can raise lint." + note " the next bin/lint-gate.sh --update-baseline records it; nothing to do until lint actually rises." + else + note "lint baseline was taken with mxcli $_lb_was; this is $_lb_now. New built-in rules can raise lint." + note " after reviewing the new findings: bin/lint-gate.sh --update-baseline --reason \"mxcli $_lb_now built-in rules\"" + fi + fi +fi + head_ "Verdict" # Leave a dated receipt in the project so gate-check can tell "doctor ran and passed" from @@ -1558,7 +1584,6 @@ if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then mkdir -p "$PROJECT_DIR/.claude" 2>/dev/null || true # Line 2 is the environment fingerprint: mxcli version, mxbuild path, arch. exec.sh re-runs # `doctor.sh --quick` when it changes or the receipt is over a day old (warn-only). - FP_MXCLI="$( [ -x "$PROJECT_DIR/mxcli" ] && "$PROJECT_DIR/mxcli" --version 2>/dev/null | head -1 || echo none)" { printf '%s %s fail=%s warn=%s%s\n' "$(date '+%Y-%m-%d %H:%M')" "$RECEIPT_VERDICT" "$FAIL" "$WARN" "$( [ "$QUICK" = 1 ] && echo ' quick')" printf 'fingerprint: %s | %s | %s\n' "$FP_MXCLI" "${MXBUILD:-no-mxbuild}" "$(uname -sm)" [ -n "$GATE_SELFTEST_LINE" ] && printf 'gate-selftest: %s\n' "$GATE_SELFTEST_LINE" diff --git a/bin/init-project.sh b/bin/init-project.sh index aed1111e..e7b7e277 100755 --- a/bin/init-project.sh +++ b/bin/init-project.sh @@ -436,7 +436,7 @@ fi # ── Starlark lint rules ────────────────────────────────────────────────────────────────── # mxcli seeds .claude/lint-rules/ with its own copies; three of them are broken as shipped # (two match no entity at all and report a clean pass forever). Install the toolkit's fixed -# versions over the stock ones, and conv020 — which mxcli does not ship — alongside them. +# versions over the stock ones, and mxtk_ux003 (MXTK-UX003) — which mxcli does not ship — alongside them. # Nothing a human edited is overwritten: see bin/lib/install-lint-rules.sh. # # THIS MUST RUN AFTER wire-agents.sh. That script runs `mxcli init`, and `mxcli init` re-seeds diff --git a/bin/lib/install-lint-rules.sh b/bin/lib/install-lint-rules.sh index 42f6560b..0b6eed58 100644 --- a/bin/lib/install-lint-rules.sh +++ b/bin/lib/install-lint-rules.sh @@ -17,7 +17,7 @@ # The gate keeps saying PASS over a rule that inspected nothing. # # The naive repair — "overwrite anything that lacks our marker" — destroys work: a project -# may have deliberately tuned a rule, and conv020 REQUIRES editing before it runs at all. +# may have deliberately tuned a rule, and mxtk_ux003 (MXTK-UX003) is configured per project by design. # So every file is classified before anything is written: # # missing -> install @@ -198,6 +198,289 @@ _mxtk_lint_receipt_finalize() { [ -z "$_pruned" ] || echo "Lint receipt: dropped row(s) for rules no longer shipped or disabled here:$_pruned" } +# _mxtk_lint_cfg_file : the lint config mxcli reads, in its own order +# (mdl/linter/config.go FindConfigFile): .claude/lint-config.yaml, lint-config.yaml, +# .lint-config.yaml. The first that exists wins; the others are not read. Prints nothing if none. +_mxtk_lint_cfg_file() { + for _cf in "$1/.claude/lint-config.yaml" "$1/lint-config.yaml" "$1/.lint-config.yaml"; do + [ -f "$_cf" ] && { printf '%s\n' "$_cf"; return 0; } + done + return 1 +} + +# _mxtk_lint_cfg_disabled : 0 when the file switches that rule off. +# Same reading as mxcli v0.28.0 (RuleConfigYAML.Enabled *bool, gopkg.in/yaml.v3 v3.0.1): false is +# a plain false|False|FALSE (YAML 1.2 core; a quoted "false" is a string, the config fails to load +# and every rule runs) or n|N|no|No|NO|off|Off|OFF, plain or quoted (yaml.v3's YAML 1.1 fallback +# for a typed bool, decode.go). Other spellings (fAlse, nO, 0) are not false. The rule key may be +# bare, 'single' or "double" quoted; `enabled` counts only as its DIRECT child: the first indent +# level under it, or a top-level key of a flow map on the key's own line. Verified per spelling +# on mxcli v0.28.0 (tests/wave2/test-lint-rule-rename.sh T1b). +_mxtk_lint_cfg_disabled() { + [ -f "$1" ] || return 1 + awk -v id="$2" -v sq="'" ' + function isoff(v, q) { + sub(/^[ \t]+/, "", v); sub(/[ \t]*(#.*)?$/, "", v) + if (v ~ /^(false|False|FALSE)$/) return 1 + q = substr(v, 1, 1) + if ((q == "\"" || q == sq) && length(v) > 1 && substr(v, length(v)) == q) v = substr(v, 2, length(v) - 2) + return v ~ /^(n|N|no|No|NO|off|Off|OFF)$/ + } + function enabledkv(kv) { # "enabled: " (key optionally quoted) -> isoff(v); else 0 + if (!match(kv, "^[ \t]*(\"enabled\"|" sq "enabled" sq "|enabled)[ \t]*:")) return 0 + return isoff(substr(kv, RLENGTH + 1)) + } + function flow(s, i, c, d, piece) { # top-level pairs of the flow map that opens s + i = index(s, "{"); if (!i) return 0 + d = 1; piece = "" + for (i++; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "{" || c == "[") d++ + else if (c == "}" || c == "]") { d--; if (d == 0) break } + if (d == 1 && c == ",") { if (enabledkv(piece)) return 1; piece = ""; continue } + piece = piece c + } + return enabledkv(piece) + } + { sub(/\r$/, "") } # a CRLF file (Git Bash, core.autocrlf): mxcli reads it, so must we + /^[[:space:]]*(#|$)/ { next } + { match($0, /^ */); ind = RLENGTH } + insec && ind <= secind { insec = 0 } + insec { + if (childind < 0) childind = ind + if (ind == childind && enabledkv($0)) found = 1 + next + } + match($0, "^[ \t]*(\"" id "\"|" sq id sq "|" id ")[ \t]*:") { + rest = substr($0, RLENGTH + 1) + if (rest ~ /^[ \t]*\{/) { if (flow(rest)) found = 1 } + else { insec = 1; secind = ind; childind = -1 } + } + END { exit found ? 0 : 1 }' "$1" +} + +# RENAMED RULES (MXTK_LINT_RENAMES in install-manifest.sh). First case: CONV020 -> MXTK-UX003, +# because mxcli v0.28.0 ships a built-in CONV020 and runs both rules under that one id. Per rename: +# 1. lint-rules-disabled.txt naming the old FILE -> rewritten to the new file (a file name is +# unambiguous, and leaving it would install the new rule over the user's opt-out). +# 2. the old file: an unedited copy (md5, CR-insensitive, equals a shipped version or the +# receipt row) is deleted; the loop below then installs the new one, unless the lint config +# mxcli reads (_mxtk_lint_cfg_file) switched the old id off (_mxtk_lint_cfg_disabled): that +# opt-out is written to lint-rules-disabled.txt under the new file, so the rule stays off (the +# config key is left as it is). An edited copy is left alone with a warning (saying how to keep +# such an opt-out), and the new file is held back (MXTK_LINT_RENAME_HOLD), since installing it +# beside the edited one would run the rule twice. A dry run lists a carried-over rule in +# MXTK_LINT_RENAME_DRYOFF so the loop does not also say it would install it. +# 3. lint-baseline.json, once no old copy remains: findings whose message matches the toolkit +# rule's are rekeyed to the new id and the counts moved with them. A finding with another +# message stays (mxcli's built-in). A counts-only baseline (pre-#239) cannot be split: NOTE, +# once (marker line in .claude/.mxtk-lint-notes). Python runs only when a finding under the +# old id looks like the toolkit rule's (grep pre-filter), so a baselined built-in costs nothing. +# 4. On a run that changed something: a NOTE for lint-config.yaml settings under the old id +# (saying so when its enabled: false was carried over in 2) and for PROJECT.md lines naming it. +# Neither file is rewritten: their text cannot say which rule it meant. A second run finds nothing to do and prints nothing. +_mxtk_lint_migrate_renames() { + _rn_proj="$1"; _rn_dry="$2"; _rn_src_dir="$3" + MXTK_LINT_RENAME_HOLD=""; MXTK_LINT_RENAME_DRYOFF="" + [ -n "${MXTK_LINT_RENAMES:-}" ] || return 0 + _rn_dir="$_rn_proj/.claude/lint-rules" + _rn_dis="$_rn_proj/.claude/lint-rules-disabled.txt" + _rn_base="$_rn_proj/.claude/lint-baseline.json" + _rn_cfg="$(_mxtk_lint_cfg_file "$_rn_proj" || true)"; _rn_cfgrel="${_rn_cfg#"$_rn_proj"/}" + # Committed on purpose, team-shared like .mxtk-lint-receipt: it records a NOTE about the committed + # lint-baseline.json as said, so one teammate re-baselining settles it for all. + _rn_notes="$_rn_proj/.claude/.mxtk-lint-notes" + while read -r _rn_old _rn_oid _rn_new _rn_nid _rn_md5s _rn_re; do + [ -n "$_rn_old" ] && [ -n "$_rn_re" ] || continue + _rn_did=0; _rn_hold=0; _rn_cfgoff=0 + + # 1. opt-out line + if [ -f "$_rn_dis" ] && awk -v o="$_rn_old" '$1 == o { f = 1 } END { exit f ? 0 : 1 }' "$_rn_dis"; then + if [ "$_rn_dry" -eq 1 ]; then + echo "Would carry over: .claude/lint-rules-disabled.txt $_rn_old -> $_rn_new" + MXTK_LINT_RENAME_DRYOFF="$MXTK_LINT_RENAME_DRYOFF $_rn_new" + else + awk -v o="$_rn_old" -v n="$_rn_new" ' + $1 == o { i = index($0, o); $0 = substr($0, 1, i - 1) n substr($0, i + length(o)) } { print }' \ + "$_rn_dis" > "$_rn_dis.tmp.$$" && mv "$_rn_dis.tmp.$$" "$_rn_dis" + echo "Lint opt-out carried over: .claude/lint-rules-disabled.txt now names $_rn_new (was $_rn_old)" + fi + _rn_did=1; MXTK_LINT_CHANGES=$((MXTK_LINT_CHANGES + 1)) + fi + + # 2. the old file + _rn_f="$_rn_dir/$_rn_old" + if [ -f "$_rn_f" ]; then + _rn_m="$(portable_md5 "$_rn_f")" + tr -d '\r' < "$_rn_f" > "$_rn_f.lf.$$"; _rn_ml="$(portable_md5 "$_rn_f.lf.$$")"; rm -f "$_rn_f.lf.$$" + _rn_rec="$(_mxtk_lint_receipt_get "$_rn_proj/.claude/.mxtk-lint-receipt" "$_rn_old" 2>/dev/null || true)" + _rn_clean=0 + case ",$_rn_md5s," in *",$_rn_m,"*|*",$_rn_ml,"*) _rn_clean=1 ;; esac + [ -n "$_rn_rec" ] && [ "$_rn_rec" = "$_rn_m" ] && _rn_clean=1 + if [ "$_rn_clean" -eq 1 ]; then + if [ "$_rn_dry" -eq 1 ]; then + echo "Would rename: .claude/lint-rules/$_rn_old -> $_rn_new ($_rn_oid -> $_rn_nid; your copy is an unedited toolkit version)" + else + echo "Lint rule renamed: .claude/lint-rules/$_rn_old -> $_rn_new (RULE_ID $_rn_oid -> $_rn_nid)." + echo " mxcli v0.28.0 ships a built-in $_rn_oid. Your copy was an unedited toolkit version, so the" + echo " current template replaces it." + if grep -q '^PROJECT_MODULES = ()' "$_rn_f"; then + echo " It was the old template with PROJECT_MODULES empty, so it inspected nothing. The new one" + echo " defaults to \"*\" and will report: review its findings, then" + echo " bin/lint-gate.sh --update-baseline --reason \"$_rn_nid now configured\"" + fi + rm -f "$_rn_f" + fi + # The project switched the old id off in lint-config.yaml (README's preferred opt-out). + # On mxcli v0.28.0+ that key reaches only the built-in, so carry the opt-out to the file. + if _mxtk_lint_cfg_disabled "$_rn_cfg" "$_rn_oid" && ! _mxtk_lint_is_disabled "$_rn_dis" "$_rn_new"; then + _rn_cfgoff=1 + if [ "$_rn_dry" -eq 1 ]; then + echo "Would carry over: $_rn_cfgrel $_rn_oid enabled: false -> .claude/lint-rules-disabled.txt $_rn_new" + MXTK_LINT_RENAME_DRYOFF="$MXTK_LINT_RENAME_DRYOFF $_rn_new" + else + [ -s "$_rn_dis" ] && [ -n "$(tail -c 1 "$_rn_dis")" ] && echo >> "$_rn_dis" + printf '%s # reason: carried over from %s %s: enabled: false (%s)\n' \ + "$_rn_new" "${_rn_cfg##*/}" "$_rn_oid" "$(date +%Y-%m-%d)" >> "$_rn_dis" + echo "Lint opt-out carried over: $_rn_cfgrel has $_rn_oid enabled: false, so" + echo " .claude/lint-rules-disabled.txt now names $_rn_new and it is not installed." + fi + fi + _rn_did=1; MXTK_LINT_CHANGES=$((MXTK_LINT_CHANGES + 1)) + else + _rn_hold=1; MXTK_LINT_RENAME_HOLD="$MXTK_LINT_RENAME_HOLD $_rn_new" + _mxtk_lint_warn \ + ".claude/lint-rules/$_rn_old is edited here, so it was NOT renamed to $_rn_new ($_rn_nid)." \ + "It still reports as $_rn_oid, the id mxcli v0.28.0 also gives a built-in rule. On that mxcli" \ + "both run under one id: one lint-config entry switches both, -r $_rn_oid runs both, lint-gate" \ + "sums them. $_rn_new was not installed beside it (the rule would run twice). By hand:" \ + " 1. cp $_rn_src_dir/$_rn_new $_rn_dir/" \ + " 2. copy your settings (PROJECT_MODULES, ACTION_PREFIX, any other edit) from $_rn_old into it" \ + " 3. delete $_rn_old, then re-run sync-project.sh: it rekeys lint-baseline.json" + if _mxtk_lint_cfg_disabled "$_rn_cfg" "$_rn_oid" && ! _mxtk_lint_is_disabled "$_rn_dis" "$_rn_new"; then + _mxtk_lint_warn \ + "$_rn_cfgrel switches $_rn_oid off; on mxcli v0.28.0 that key no longer reaches $_rn_new." \ + "To keep that opt-out, skip 1-2: add the line" \ + " $_rn_new # reason: " \ + "to .claude/lint-rules-disabled.txt (a listed rule is not installed; a copy left in" \ + ".claude/lint-rules/ would still run), then do 3." + fi + fi + fi + + # 3. the baseline + # Pre-filter: a findings baseline needs Python only when a key under the old id carries a + # message like the toolkit rule's (the regex with its anchors dropped: a superset, Python + # decides); a counts-only one only until its NOTE has printed. + _rn_need=0 + if [ "$_rn_hold" -eq 0 ] && [ -f "$_rn_base" ] && grep -q "\"$_rn_oid[|\"]" "$_rn_base"; then + if grep -q '"findings"' "$_rn_base"; then + grep -qE "\"$_rn_oid[|][^\"]*($(printf '%s' "$_rn_re" | tr -d '^'))" "$_rn_base" && _rn_need=1 + elif ! grep -qx "$_rn_oid counts-only" "$_rn_notes" 2>/dev/null; then + _rn_need=1 + fi + fi + if [ "$_rn_need" -eq 1 ]; then + if ! _rn_py="$(resolve_py 2>/dev/null)"; then + _mxtk_lint_warn "lint-baseline.json still keys findings as $_rn_oid and Python 3 was not found to" \ + "rekey the toolkit rule's to $_rn_nid. Install Python 3 and re-run sync-project.sh." + else + IFS= read -r -d '' _rn_pysrc <<'PY' || true +import json, os, re, sys +p, old, new = os.environ["RN_BASE"], os.environ["RN_OLD"], os.environ["RN_NEW"] +pat = re.compile(os.environ["RN_RE"]) +try: + d = json.load(open(p, encoding="utf-8")) + counts = d.get("counts") or {} +except (ValueError, OSError, AttributeError) as e: + print("ERR %s" % e); sys.exit(0) +f = d.get("findings") +if f is None: + print("NOFINDINGS %d" % counts.get(old, 0)); sys.exit(0) +moved = other = 0 +nf = {} +for k, n in f.items(): + if k.startswith(old + "|"): + parts = k.split("|", 3) + if len(parts) == 4 and pat.search(parts[3]): + k = new + k[len(old):] + moved += n + else: + other += n + nf[k] = nf.get(k, 0) + n +if moved and os.environ.get("RN_DRY") != "1": + left = counts.get(old, 0) - moved + counts.pop(old, None) + if left > 0: + counts[old] = left + counts[new] = counts.get(new, 0) + moved + d["counts"] = dict(sorted(counts.items())) + d["findings"] = dict(sorted(nf.items())) + with open(p, "w", encoding="utf-8") as fh: + json.dump(d, fh, indent=2) + fh.write("\n") +print("MOVED %d %d" % (moved, other)) +PY + _rn_out="$(RN_BASE="$(native_path "$_rn_base")" RN_OLD="$_rn_oid" RN_NEW="$_rn_nid" RN_RE="$_rn_re" \ + RN_DRY="$_rn_dry" "$_rn_py" -c "$_rn_pysrc" 2>&1)" + read -r _rn_k _rn_a _rn_b _rn_rest < $_rn_nid" + else + echo "Lint baseline: $_rn_a $_rn_oid finding(s) of the toolkit rule rekeyed to $_rn_nid (.claude/lint-baseline.json; commit it)." + fi + [ "${_rn_b:-0}" -gt 0 ] && echo " $_rn_b $_rn_oid finding(s) kept: their message is not the toolkit rule's (mxcli's built-in $_rn_oid)." + _rn_did=1; MXTK_LINT_CHANGES=$((MXTK_LINT_CHANGES + 1)) + fi ;; + NOFINDINGS) + if [ "${_rn_a:-0}" -gt 0 ]; then + echo "NOTE: .claude/lint-baseline.json holds $_rn_oid as a count only ($_rn_a; written before findings were" + echo " recorded), so the toolkit rule's findings cannot be told from mxcli's built-in $_rn_oid. Not" + echo " changed. Re-baseline: bin/lint-gate.sh --update-baseline --reason \"$_rn_oid renamed $_rn_nid\"" + echo " (Said once.)" + [ "$_rn_dry" -eq 1 ] || echo "$_rn_oid counts-only" >> "$_rn_notes" + fi ;; + *) _mxtk_lint_warn "could not rekey $_rn_oid in .claude/lint-baseline.json: ${_rn_out:-no output}" ;; + esac + fi + fi + + # 4. one-time notices + if [ "$_rn_did" -eq 1 ]; then + if [ -n "$_rn_cfg" ] && + grep -qE "^[[:space:]]*(\"$_rn_oid\"|'$_rn_oid'|$_rn_oid)[[:space:]]*:" "$_rn_cfg"; then + echo "NOTE: $_rn_cfgrel has settings under $_rn_oid. On mxcli v0.28.0+ they reach mxcli's" + if [ "$_rn_cfgoff" -eq 1 ]; then + echo " built-in $_rn_oid, not the toolkit rule. Its enabled: false was carried over: the" + echo " toolkit rule stays off through .claude/lint-rules-disabled.txt ($_rn_new)." + echo " The key now switches off only the built-in. Not changed: keep it, or remove it to run" + echo " the built-in." + elif _mxtk_lint_is_disabled "$_rn_dis" "$_rn_new" || + case " $MXTK_LINT_RENAME_DRYOFF " in *" $_rn_new "*) true ;; *) false ;; esac; then + echo " built-in $_rn_oid, not the toolkit rule, which is off here through" + echo " .claude/lint-rules-disabled.txt ($_rn_new). Not changed automatically." + else + echo " built-in $_rn_oid, not the toolkit rule. If they were meant for the toolkit rule, move them" + echo " under $_rn_nid. Not changed automatically." + fi + fi + if [ -f "$_rn_proj/PROJECT.md" ] && grep -q "$_rn_oid" "$_rn_proj/PROJECT.md"; then + echo "NOTE: PROJECT.md names $_rn_oid on line(s) $(grep -n "$_rn_oid" "$_rn_proj/PROJECT.md" | cut -d: -f1 | head -8 | paste -sd, -)." + echo " A decision about the toolkit's user-feedback rule now refers to $_rn_nid; one about" + echo " \"created object never persisted\" is mxcli's built-in. Not changed: the register is yours." + fi + fi + done </dev/null || true)" @@ -355,7 +642,7 @@ mxtk_install_lint_rules() { fi # ---- configurable and edited: this is the EXPECTED state, not drift ------------ - # conv020 ships with PROJECT_MODULES empty and refuses to run until it is filled in. + # mxtk_ux003 (MXTK-UX003) is configured per project (PROJECT_MODULES, ACTION_PREFIX). # Refreshing it would silently un-configure the rule and send it back to inspecting # nothing. Stay quiet unless the TEMPLATE moved, which is the only case where the # project has a decision to make. diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index 18b58358..44a2d8b1 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -160,11 +160,10 @@ MXTK_PROJECT_BIN_NOINSTALL="report-disposition-check.sh coverage-check-all.sh ts # after them in the list are toolkit-authored, never seeded by init. Copying the rest would pin every project # to whatever mxcli shipped the day someone copied them. # -# MXTK_LINT_RULES_CONFIGURABLE — installed once, then owned by the project. conv020 is not an -# mxcli rule at all (init never seeds it) and it REQUIRES editing: PROJECT_MODULES ships empty -# and the rule refuses to run until it is filled in. Refreshing it on sync would silently -# revert that configuration and the rule would go back to inspecting nothing. So: install if -# absent, report if the template moves, never overwrite. +# MXTK_LINT_RULES_CONFIGURABLE — installed once, then owned by the project. mxtk_ux003 +# (MXTK-UX003, formerly conv020/CONV020) is not an mxcli rule (init never seeds it) and MAY be +# edited: a project narrows PROJECT_MODULES or sets ACTION_PREFIX. Refreshing it on sync would silently revert that +# configuration. So: install if absent, report if the template moves, never overwrite. # # `mxcli init` OVERWRITES an edited .star without asking (verified 2026-08-18, mxcli v0.17.0: # a local edit was gone after a second `init` in the same directory; exit 0, no warning). A @@ -179,7 +178,20 @@ MXTK_LINT_RULES="conv010_act_microflow_content.star data_change_microflows.star # version the day a non-stock rule joined MXTK_LINT_RULES. Every name here must also be in # MXTK_LINT_RULES; the fixture asserts it. MXTK_LINT_RULES_STOCK="conv010_act_microflow_content.star data_change_microflows.star entity_business_key.star conv009_max_microflow_objects.star" -MXTK_LINT_RULES_CONFIGURABLE="conv020_action_user_feedback.star" +MXTK_LINT_RULES_CONFIGURABLE="mxtk_ux003_action_user_feedback.star" + +# MXTK_LINT_RENAMES — rules this toolkit has renamed, one per line: +# +# install-lint-rules.sh (_mxtk_lint_migrate_renames) retires a project's old copy ONLY when it is +# byte-identical (CR-insensitive) to a shipped version or to its receipt row, then rekeys the +# baseline findings whose message matches the regex. Anything else gets a notice, never a write. +# The md5s are every version of the old file in git, captured with: +# for c in $(git log --format=%h -- lint-rules/); do git show "$c:lint-rules/" | md5sum; done +# CONV020 -> MXTK-UX003 (2026-10-23): mxcli v0.28.0 ships a built-in CONV020 ("Created object never +# persisted", mendixlabs/mxcli#1518) and mxcli has no duplicate-ID guard. The regex matches only the +# toolkit rule's messages (finding + its three `_rule` self-reports); the built-in's read +# " 'M.F' creates $X (E) without committing it, ..." (mxcli rules/conv_created_never_persisted.go). +MXTK_LINT_RENAMES="conv020_action_user_feedback.star CONV020 mxtk_ux003_action_user_feedback.star MXTK-UX003 809ddca4d767a672ee811be51e25570b,a5caa8dacbcc6a64b28b4a4b2a0942e4,d7734ec8922edf2e136316c09b7bb9cf,215582a999ed3194c03922d6c4c40797 is triggered from a page and commits/imports/deletes data, but |^CONV020 (is unconfigured|matched no microflows|could not read any microflow activities)" # Files in lint-rules/ deliberately NOT installed. Same contract as MXTK_PROJECT_BIN_NOINSTALL: # the reverse check below flags anything named by no list, so a new rule has to be either diff --git a/bin/lib/sec014-notice.sh b/bin/lib/sec014-notice.sh index ee1fa38a..97fe5c7d 100644 --- a/bin/lib/sec014-notice.sh +++ b/bin/lib/sec014-notice.sh @@ -6,7 +6,7 @@ # .claude/lint-config.yaml (`rules: SEC014: options: external_roles: "A, B"`). Until it does, # the rule reports nothing on exec — a `_rule` blindness finding there would make lint-gate # exit PASS-BLIND on every exec of every synced project until someone edits a config file, -# which is the #229 pattern (an unconfigured CONV020 logged 20 LINT BLIND rows in two apps). +# which is the #229 pattern (an unconfigured toolkit CONV020, now MXTK-UX003, logged 20 LINT BLIND rows in two apps). # The not-yet-decided state is said HERE instead, on every sync, as a NOTE (not a counted # warning: a fresh scaffold has not decided yet, and a pristine sync must pass --strict), until # the option is set. A misconfigured value (a list, an unknown role) is still a `_rule` finding. diff --git a/bug-logs/mxcli-bugs.md b/bug-logs/mxcli-bugs.md index 5edc85bc..029d6628 100644 --- a/bug-logs/mxcli-bugs.md +++ b/bug-logs/mxcli-bugs.md @@ -2206,7 +2206,7 @@ sqlite3 .mxcli/catalog.db "SELECT DISTINCT ActionType FROM activities;" sqlite3 .mxcli/catalog.db "SELECT DISTINCT SourceType FROM refs;" ``` -Corrected `CONV010` and a `CONV020` written against probed values are in this repo under +Corrected `CONV010` and a `CONV020` (now MXTK-UX003) written against probed values are in this repo under [`lint-rules/`](../lint-rules/), with the trap and the fail-loudly self-check pattern documented in [`lint-rules/README.md`](../lint-rules/README.md). diff --git a/lint-rules/README.md b/lint-rules/README.md index 24fd51c0..58de4739 100644 --- a/lint-rules/README.md +++ b/lint-rules/README.md @@ -8,7 +8,7 @@ and configure the marked constants — **after** running `mxcli init`, never bef |---|---|---| | `conv009_max_microflow_objects.star` | CONV009 — a microflow over the object threshold. Replaces the stock rule, which hard-codes 15 and ignores the `maxObjects` option its own generated `lint-config.yaml` advertises | in `.claude/lint-config.yaml`, never in the file: `rules: CONV009: options: maxObjects: 20`, `countLoopBodies: true` (default 15 / false) | | `conv010_act_microflow_content.star` | `ACT_` page-action microflows holding business logic instead of delegating to `SUB_` | if your prefixes differ — `ACTION_PREFIX`, `DELEGATE_PREFIX` | -| `conv020_action_user_feedback.star` | Page-triggered microflows that commit / import / delete but tell the user nothing | no — `PROJECT_MODULES` defaults to `"*"` (all modules the lint run includes, minus `.claude/lint-vendor-modules.txt`); `ACTION_PREFIX` if your prefixes differ | +| `mxtk_ux003_action_user_feedback.star` | MXTK-UX003 (was CONV020, see "Rule IDs" below) — page-triggered microflows that commit / import / delete but tell the user nothing | no — `PROJECT_MODULES` defaults to `"*"` (all modules the lint run includes, minus `.claude/lint-vendor-modules.txt`); `ACTION_PREFIX` if your prefixes differ | | `data_change_microflows.star` | ARCH002 — persistent entities written from pages instead of microflows | no | | `entity_business_key.star` | ARCH003 — persistent entities with no UNIQUE NOT NULL business key | no | | `ux001_confirm_before_destructive.star` | UX001 — a button that deletes, approves, rejects, submits or archives (direct delete action, or a flow that deletes / is named so) without "Ask confirmation" | `DESTRUCTIVE_NAME_PATTERN` if your verbs differ | @@ -116,23 +116,46 @@ Candidates that are not rules yet, and the order they ship in: `process/lint-bac ## A rule that matches nothing now says so -`CONV010` and `CONV020` can only identify a page action by its name — `refs_to()` reports the +`CONV010` and `MXTK-UX003` can only identify a page action by its name — `refs_to()` reports the same `ref_kind` for a button click and a datasource, so the `ACT_` prefix is the only available signal. That prefix is Mendix's own Development Best Practices convention, but it is a convention: on a project that names things differently both rules matched nothing, found nothing, and reported a clean pass. Both now emit a `_rule` finding when the prefix matches zero microflows (CONV010 guards on the -project having any microflows at all; CONV020 on `PROJECT_MODULES` being non-empty and containing any). Verified by +project having any microflows at all; MXTK-UX003 on `PROJECT_MODULES` being non-empty and containing any). Verified by fixture on TestCLIApp — setting `ACTION_PREFIX` to an unused string produces the finding, and the real prefix produces none. `bin/lint-gate.sh` treats `module == "_rule"` as blindness, so this turns a silent pass into a gate failure rather than a line in a report nobody reads. Set `ACTION_PREFIX` / `DELEGATE_PREFIX` to your project's prefixes. CONV010 cannot be opted out (`mxcli init` seeds that file name, see below), so on a project without the `ACT_` convention set -`ACTION_PREFIX` to the prefix its page-action microflows do use. CONV020 can be opted out, as a +`ACTION_PREFIX` to the prefix its page-action microflows do use. MXTK-UX003 can be opted out, as a user decision, if the project has no such convention (see "Opting out of a rule"). +## Rule IDs + +A toolkit rule whose `RULE_ID` equals an mxcli built-in runs **beside** it, not instead of it: +mxcli has no duplicate-ID guard (`mdl/linter/linter.go`, `AddRule`). One `lint-config.yaml` entry +then switches both, `-r ` runs both, and `bin/lint-gate.sh` sums their findings into one count. +That happened on 2026-10-23: mxcli v0.28.0 shipped a built-in CONV020 ("Created object never +persisted", mendixlabs/mxcli#1518). The toolkit's user-feedback rule had the same id; it is now +**MXTK-UX003** (`mxtk_ux003_action_user_feedback.star`). + +- A **new** toolkit-authored rule takes an `MXTK-` id (`MXTK-`). mxcli never uses + that prefix. The older ones (`SEC014`, `PERF00x`, `UX00x`, `ERR001`, `LOOP001`, `TXT00x`, + `UI001`) sit in or beside mxcli's own families and can collide the same way; check + `mxcli lint --list-rules` after every mxcli upgrade. +- The **stock replacements** (`CONV009`, `CONV010`, `ARCH002`, `ARCH003`) keep the stock id on + purpose: they replace the file `mxcli init` seeds, so only one rule runs. +- A rename is migrated by `bin/sync-project.sh` (`MXTK_LINT_RENAMES` in + `bin/lib/install-manifest.sh`). An unedited project copy is replaced and the + `lint-baseline.json` findings whose message is the toolkit rule's are rekeyed. A + `lint-config.yaml` `enabled: false` under the old id is carried over to a + `lint-rules-disabled.txt` line for the new file, so an opted-out rule stays off. An edited copy + is left in place with a warning and manual steps. `lint-config.yaml` keys and `PROJECT.md` + lines that name the old id get a NOTE, because their text cannot say which rule they meant. + ## Opting out of a rule Disabling a rule is **the user's decision**, never an agent's: ask in chat and record the answer @@ -193,7 +216,7 @@ set, SEC014 reports nothing on exec, and `bin/sync-project.sh` prints a NOTE on lines to add (`bin/lib/sec014-notice.sh`), and `bin/lint-gate.sh` prints one uncounted note line on every run (no exit-code change, no lint row). It is not a `_rule` finding, because that would make lint-gate exit PASS-BLIND on every exec of every synced project until someone answered, which is -the #229 pattern an unconfigured CONV020 caused. Which roles are external is the user's answer, +the #229 pattern an unconfigured toolkit CONV020 (now MXTK-UX003) caused. Which roles are external is the user's answer, asked in chat. `external_roles: "none"` switches off a security rule, and so does `rules: SEC014: enabled: false`. Either one is set only on the user's answer and recorded as a decision row in `PROJECT.md`. An agent never sets one to make the NOTE go away. The first run after configuring raises SEC014 from 0 to N at once, and lint-gate @@ -218,9 +241,9 @@ therefore carries a `# mxtk-lint-rule:` header ending in an explicit terminator `STOCK-HASHES.txt` records the md5 of each rule as mxcli seeds it. Together those let a copy path tell six states apart: ours; ours-but-older (refresh); ours-without-the-header, from a project that applied the fixes by hand before this path existed (refresh, and say so); -untouched-stock (safe to replace); a configured `conv020` (never touch); and a file the project +untouched-stock (safe to replace); a configured `mxtk_ux003` (never touch); and a file the project wrote (report, never overwrite). `install-manifest.sh` names the rules in two lists — -`MXTK_LINT_RULES` for the toolkit-owned ones and `MXTK_LINT_RULES_CONFIGURABLE` for `conv020`, +`MXTK_LINT_RULES` for the toolkit-owned ones and `MXTK_LINT_RULES_CONFIGURABLE` for `mxtk_ux003`, which is installed once and never refreshed because refreshing it would silently discard the `PROJECT_MODULES` a project had configured. @@ -282,7 +305,7 @@ failure shape as `[].every()` passing on an empty span set — the instrument re because it measured nothing. Both rules here carry a self-check: if they inspected candidate microflows and *not one* -yielded a single activity, they emit a violation saying the rule checked nothing. `CONV020` +yielded a single activity, they emit a violation saying the rule checked nothing. `MXTK-UX003` additionally refuses to run while `PROJECT_MODULES` is still the empty toolkit default. Apply the same guard to any new rule. The test is one question: @@ -295,7 +318,7 @@ If yes, it needs a self-check before it is worth installing. ## Narrow beats comprehensive -`CONV020` deliberately ignores retrieves, changes without commit, non-page-reachable +`MXTK-UX003` deliberately ignores retrieves, changes without commit, non-page-reachable microflows, and validation-only paths. It flags roughly what a reviewer would flag by hand. Show-page, close-page and show-home-page actions count as feedback (presence only, not order). Refresh-in-client is not visible to the Starlark API, so a flow that only refreshes is still flagged until mxcli exposes it. A rule that fires on everything gets switched off, and a switched-off rule catches nothing — @@ -303,6 +326,6 @@ which is how `CONV010` and the lint gate both ended up idle. Known limitation, documented in the rule rather than hidden: `refs_to()` reports `ref_kind == "action"` for *every* page reference, so a button click and a grid datasource -are indistinguishable. `CONV020` therefore filters on the `ACT_` naming convention. A real +are indistinguishable. `MXTK-UX003` therefore filters on the `ACT_` naming convention. A real user action named something else is missed. That is a deliberate false-negative, chosen over firing a notification warning on every page load. diff --git a/lint-rules/conv020_action_user_feedback.star b/lint-rules/mxtk_ux003_action_user_feedback.star similarity index 89% rename from lint-rules/conv020_action_user_feedback.star rename to lint-rules/mxtk_ux003_action_user_feedback.star index 01d881b5..5152aeff 100644 --- a/lint-rules/conv020_action_user_feedback.star +++ b/lint-rules/mxtk_ux003_action_user_feedback.star @@ -1,4 +1,4 @@ -# mxtk-lint-rule: conv020_action_user_feedback +# mxtk-lint-rule: mxtk_ux003_action_user_feedback # Shipped by mxcli-project-toolkit. UNLIKE the other rules here this one MAY be edited: # PROJECT_MODULES defaults to "*" (every module the lint run includes, i.e. all but those # in .claude/lint-vendor-modules.txt); narrow it to a tuple to scope the rule down. @@ -6,7 +6,12 @@ # when the template changes it reports the change and leaves your configured copy alone. # See lint-rules/README.md. # --- end mxtk-lint-rule header --- -# CONV020: Completing user actions must tell the user what happened +# MXTK-UX003: Completing user actions must tell the user what happened +# +# MXTK-UX003 was CONV020 (conv020_action_user_feedback.star) until 2026-10-23. mxcli v0.28.0 +# ships a built-in CONV020 ("Created object never persisted") and has no duplicate-ID guard, so +# both ran under one ID. MXTK- is a toolkit-only prefix that mxcli does not use. sync-project.sh +# migrates an unedited project copy (MXTK_LINT_RENAMES in bin/lib/install-manifest.sh). # # A microflow that is triggered from a page and that COMPLETES WORK -- commits # created/changed objects, runs an import, deletes -- should give the user @@ -34,7 +39,7 @@ # differently gets no findings and no signal, which the self-check at the bottom now catches. ACTION_PREFIX = "ACT_" -RULE_ID = "CONV020" +RULE_ID = "MXTK-UX003" RULE_NAME = "ActionUserFeedback" DESCRIPTION = "Page-triggered microflows that commit, import or delete should show the user a message" CATEGORY = "quality" @@ -112,8 +117,8 @@ def check(): # the exact silent-false-pass shape this rule's self-check exists to prevent. if not PROJECT_MODULES: # empty tuple/string only; "*" is truthy return [violation( - message="CONV020 is unconfigured: PROJECT_MODULES is empty, so this rule inspected NOTHING.", - location=location(module="_rule", document_type="Microflow", document_name="CONV020"), + message="MXTK-UX003 is unconfigured: PROJECT_MODULES is empty, so this rule inspected NOTHING.", + location=location(module="_rule", document_type="Microflow", document_name="MXTK-UX003"), suggestion="Set PROJECT_MODULES at the top of this rule to "*" (all non-vendor modules) or to a tuple of module names.", )] @@ -231,15 +236,15 @@ def check(): # is a legitimate clean result and stays quiet. if in_scope > 0 and prefix_matched == 0: violations.append(violation( - message="CONV020 matched no microflows: none of the {} in PROJECT_MODULES start with '{}', so this rule inspected NOTHING. A clean result here means the naming convention does not match, not that every action gives feedback.".format(in_scope, ACTION_PREFIX), - location=location(module="_rule", document_type="Microflow", document_name="CONV020"), + message="MXTK-UX003 matched no microflows: none of the {} in PROJECT_MODULES start with '{}', so this rule inspected NOTHING. A clean result here means the naming convention does not match, not that every action gives feedback.".format(in_scope, ACTION_PREFIX), + location=location(module="_rule", document_type="Microflow", document_name="MXTK-UX003"), suggestion="Set ACTION_PREFIX at the top of this rule to the prefix this project uses for page-action microflows. Without a prefix convention this rule cannot tell a button click from a datasource and should be removed deliberately rather than left inert.", )) if inspected > 0 and not saw_any_activity: violations.append(violation( - message="CONV020 could not read any microflow activities ({} page-triggered microflows inspected, all reported zero). activities_for() is returning nothing -- this rule checked NOTHING. Do not read a clean result as a pass.".format(inspected), - location=location(module="_rule", document_type="Microflow", document_name="CONV020"), + message="MXTK-UX003 could not read any microflow activities ({} page-triggered microflows inspected, all reported zero). activities_for() is returning nothing -- this rule checked NOTHING. Do not read a clean result as a pass.".format(inspected), + location=location(module="_rule", document_type="Microflow", document_name="MXTK-UX003"), suggestion="Your mxcli build likely postdates the graphcatalog refactor that stubbed activities_for(). Use a build where it is populated, or rewrite this rule against a different API.", )) diff --git a/project-bin/exec.sh b/project-bin/exec.sh index ff462167..929cd06c 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -1034,7 +1034,7 @@ run_lint_gate() { elif [ -z "$_rules" ] && grep -q '^PASS' "$_lo"; then # lint-gate exits 1 for PASS-BLIND too. Logging that as "ROSE: see output" made a rule # that inspected nothing look like a rise with no rule named — 20 such rows in two - # field apps whose CONV020 shipped unconfigured (#229). It is an instrument fault. + # field apps whose toolkit CONV020 (now MXTK-UX003) shipped unconfigured (#229). It is an instrument fault. LINT_RC=3 LINT_DETAIL="lint BLIND: ${_blind:-a rule} inspected nothing (instrument fault, not a rise)" echo " ✗ lint: nothing rose, but ${_blind:-a rule} could not read the model — a clean count means nothing" diff --git a/project-bin/lint-gate.sh b/project-bin/lint-gate.sh index 4d835067..f5277647 100755 --- a/project-bin/lint-gate.sh +++ b/project-bin/lint-gate.sh @@ -282,6 +282,14 @@ PY fi fi +# The mxcli version a baseline was taken with, so bin/doctor.sh can say "re-baseline" after an +# upgrade (new built-in rules raise counts on the next exec with no model change). One --version +# call, on --update-baseline only; a check run never pays for it. +MXCLI_VER="" +if [ "$UPDATE" -eq 1 ]; then + MXCLI_VER="$("$MXCLI" --version 2>/dev/null | head -1 | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1)" +fi + # LINT_JSON= skips mxcli entirely and reads pre-captured lint output. This makes # the comparison logic testable against fixtures with no .mpr, no mxcli and no catalog # (a full cold run costs ~12 min on a project whose catalog needs rebuilding). @@ -302,7 +310,7 @@ fi [ -s "$OUT" ] || { echo "lint-gate: lint produced no output" >&2; exit 2; } BASELINE="$BASELINE" OUT="$OUT" UPDATE="$UPDATE" REASON="$REASON" APPROVED="$APPROVED" \ - SECURITY_FILE="$SECURITY_FILE" ACCEPT_LOG="$ACCEPT_LOG" "$PY" - <<'PY' + SECURITY_FILE="$SECURITY_FILE" ACCEPT_LOG="$ACCEPT_LOG" MXCLI_VER="$MXCLI_VER" "$PY" - <<'PY' import json, os, sys, collections, datetime, fnmatch out_path = os.environ["OUT"] @@ -378,7 +386,7 @@ sev = {v["ruleId"]: v["severity"] for v in viol} # A rule that did not actually run is a false PASS, not a clean result. Two forms, # and keying on only the first was a real bug in this script: -# 1. Self-reported blindness: CONV010/CONV020 emit module="_rule" when they inspect +# 1. Self-reported blindness: CONV010/MXTK-UX003 emit module="_rule" when they inspect # documents but activities_for() hands back nothing. # 2. A hard Starlark crash: mxcli reports these as severity=error with an EMPTY # module and a "Starlark rule error:" message prefix -- NOT as "_rule". Observed @@ -402,7 +410,7 @@ if crashed: # PASS-BLIND via `blind` above, which was computed before this filter. The filter applies to # check runs too, not only --update-baseline: when it ran on updates alone, the blind finding # was missing from the baseline but present in every check, so each later run reported it as -# "CONV020 0 -> 1 (+1)" — a rise — and exec.sh logged LINT ROSE for an instrument fault. +# "CONV020 0 -> 1 (+1)" (the toolkit rule, now MXTK-UX003) — a rise — and exec.sh logged LINT ROSE for an instrument fault. viol = [v for v in viol if v.get("module") != "_rule"] counts = collections.Counter(v["ruleId"] for v in viol) @@ -475,7 +483,8 @@ if update: "findings": dict(sorted(fcounts.items())), "note": "Per-rule finding counts on OUR modules, plus a multiset of findings " "(rule|module|document|message) so a rise can name the new ones. " - "Gate fails when any count rises."}, + "Gate fails when any count rises.", + **({"mxcli_version": os.environ["MXCLI_VER"]} if os.environ.get("MXCLI_VER") else {})}, open(base_path, "w"), indent=2) open(base_path, "a").write("\n") print("==> baseline written: %d findings across %d rules" % (len(viol), len(counts))) diff --git a/skills/lint-that-actually-runs.md b/skills/lint-that-actually-runs.md index de86a8db..401b7740 100644 --- a/skills/lint-that-actually-runs.md +++ b/skills/lint-that-actually-runs.md @@ -96,7 +96,7 @@ ARCH002 went 0 → 38 on the casing fix. That looked like a repair. All 38 were the rule had never skipped the platform modules its sibling ARCH003 always skipped, which was invisible while it was dead. With the same `SKIP_MODULES`, the same project measures **0**. -CONV010/CONV020 identify a page action only by an `ACT_` name prefix (`refs_to()` reports the same +CONV010/MXTK-UX003 identify a page action only by an `ACT_` name prefix (`refs_to()` reports the same `ref_kind` for a button click and a datasource, so the name is the only signal). That prefix is Mendix's Development Best Practices convention, but it is a convention: on a project naming things differently, both rules matched nothing and reported clean. @@ -131,6 +131,12 @@ all the same order of magnitude, and no project produced a single real error-sev the ratchet is the *only* mechanism available. `project-bin/lint-gate.sh` implements it (`--update-baseline` to accept current counts). +**After upgrading mxcli, re-baseline.** A new mxcli adds built-in rules, so the next exec reports a +rise that no change of yours caused (v0.28.0 added a built-in CONV020, "Created object never persisted"). +Look at the new findings, then run +`bin/lint-gate.sh --update-baseline --reason 'mxcli vX built-in rules'`. The baseline records +`mxcli_version`, and `bin/doctor.sh` prints this hint when the installed mxcli differs from it. + **Accepting a rise is a recorded decision, not a reflex.** Three apps built in four days re-baselined 61 times, every time by the agent whose script caused the rise; baselines grew 8 -> 757, 440 -> 677 and 9 -> 187, with security warnings accepted in the same batches as naming diff --git a/tests/test-lint-delivery.sh b/tests/test-lint-delivery.sh index d4ff956e..30b53bd5 100644 --- a/tests/test-lint-delivery.sh +++ b/tests/test-lint-delivery.sh @@ -42,7 +42,7 @@ P="$W/proj"; mkdir -p "$P/.claude"; echo "# PROJECT" > "$P/PROJECT.md" . "$W/tk/bin/lib/install-manifest.sh" >/dev/null 2>&1 . "$W/tk/bin/lib/install-lint-rules.sh" R="$P/.claude/lint-rules" -A="$R/data_change_microflows.star"; C="$R/conv020_action_user_feedback.star" +A="$R/data_change_microflows.star"; C="$R/mxtk_ux003_action_user_feedback.star" RECEIPT="$P/.claude/.mxtk-lint-receipt" echo "== 1 fresh install ==" @@ -94,14 +94,14 @@ ls "$R"/*.local-* >/dev/null 2>&1 && grep -q "LOCAL TUNING" "$R"/*.local-* \ && ok "backup holds the local work" || no "backup missing or empty" rm -f "$R"/*.local-* -echo "== 8 configured conv020 is left alone, silently ==" +echo "== 8 configured mxtk_ux003 is left alone, silently ==" sed 's/^PROJECT_MODULES = "\*"/PROJECT_MODULES = ("MyModule",)/' "$C" > "$C.t" && mv "$C.t" "$C" out=$(mxtk_install_lint_rules "$W/tk" "$P" 0 "" 2>&1) [ -z "$out" ] && ok "silent" || no "noisy about the expected state" grep -q 'PROJECT_MODULES = ("MyModule",)' "$C" && ok "config survived" || no "CONFIG WIPED" -echo "== 9 conv020 template moves while the project is configured ==" -printf '\n# template moved\n' >> "$W/tk/lint-rules/conv020_action_user_feedback.star" +echo "== 9 mxtk_ux003 template moves while the project is configured ==" +printf '\n# template moved\n' >> "$W/tk/lint-rules/mxtk_ux003_action_user_feedback.star" out=$(mxtk_install_lint_rules "$W/tk" "$P" 0 "" 2>&1) echo "$out" | grep -q "template has since changed" && ok "flagged the move" || no "silent on move" grep -q 'PROJECT_MODULES = ("MyModule",)' "$C" && ok "config STILL survived" || no "CONFIG WIPED" diff --git a/tests/wave2/fixtures/lint-conv020-v028/CAPTURE.txt b/tests/wave2/fixtures/lint-conv020-v028/CAPTURE.txt new file mode 100644 index 00000000..6acd9f77 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/CAPTURE.txt @@ -0,0 +1,18 @@ +Golden captures for tests/wave2/test-lint-rule-rename.sh. Verbatim mxcli output; only this note is +hand-written. Captured 2026-10-23 on Linux x86-64 by the toolkit's mxcli v0.28.0 test pass +(decision #13), against a fresh copy of the public model mendixlabs/mxcli testdata/pedapp @ 37855fe3 +(v0.28.0 tag; Mendix 11.13.0) with the toolkit's CONV020 user-feedback rule installed in +.claude/lint-rules/ and the scratch additions in conv020-setup.mdl (hand-written input, exec'd by the same binary). +Each .cmd holds the exact command, binary version + sha256 and model sha256 at capture. + + lint-conv020-v0.27.0.out v0.27.0: one CONV020 finding, the toolkit rule's. + lint-conv020-v0.28.0.out v0.28.0: two CONV020 findings, one per rule (the collision). + lint-conv020-only-v0.28.0.out v0.28.0 -r CONV020: runs both rules. + lint-conv020-disabled-v0.28.0.out v0.28.0 with lint-conv020-disabled.lint-config.yaml + (CONV020: enabled: false): switches BOTH off. + lint-list-rules-v0.28.0.out v0.28.0 lint -l: CONV020 listed twice + (CreatedObjectNeverPersisted, ActionUserFeedback). + lint-config-variants-v0.28.0.out v0.28.0, one run per lint-config spelling and location (enabled: + False/no/off/'no'/"false"/0, quoted keys, flow map, nested options, + root and .lint-config.yaml fallbacks): the violations' ruleIds per + run, tabulated by a script; its header says how. T1c's verdicts. diff --git a/tests/wave2/fixtures/lint-conv020-v028/conv020-setup.mdl b/tests/wave2/fixtures/lint-conv020-v028/conv020-setup.mdl new file mode 100644 index 00000000..8f303f98 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/conv020-setup.mdl @@ -0,0 +1,30 @@ +create module Shop; + +create persistent entity Shop.Item ( + Name: String(100) +); + +create microflow Shop.ACT_SaveItem () +begin + $Item = create Shop.Item (Name = 'x'); + commit $Item; +end; + +create microflow Shop.SUB_Seed () +begin + $Orphan = create Shop.Item (Name = 'never committed'); + log info node 'Seed' 'Seeded 1'; +end; + +create page Shop.Item_Overview ( + Title: 'Items', + Layout: Atlas_Core.Atlas_Default +) { + layoutgrid lg1 { + row { + column (DesktopWidth: 12) { + actionbutton btnSave (Caption: 'Save', Action: call microflow Shop.ACT_SaveItem) + } + } + } +}; diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-config-variants-v0.28.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-config-variants-v0.28.0.out new file mode 100644 index 00000000..8529da49 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-config-variants-v0.28.0.out @@ -0,0 +1,35 @@ +# mxcli lint -p PedApp.mpr --format json -m Shop, one run per lint config; ruleIds of the violations. +# binary: mxcli version v0.28.0 (2026-10-23T14:29:27Z) (7a1cb61f9e5e28c09f3c1247446cc611f6deca781355b93c5c6a29fd29d4e3ee) +# model: the lint-conv020-*.cmd capture model (pedapp + conv020-setup.mdl, toolkit conv020_action_user_feedback.star installed) +# captured 2026-10-23T21:49Z, Linux x86-64. 'CONV020 CONV020 SEC001' = both CONV020 rules run; 'SEC001' = both off. +# [files]: the config text went into the first; any others got "rules: {}". Config texts: the labels, +# as written into tests/wave2/test-lint-rule-rename.sh T1c. config-load-error = mxcli printed +# "Warning: failed to load lint config" and ran with defaults. +none [.claude/lint-config.yaml] CONV020 CONV020 SEC001 +enabled: false [.claude/lint-config.yaml] SEC001 +enabled: False [.claude/lint-config.yaml] SEC001 +enabled: FALSE [.claude/lint-config.yaml] SEC001 +enabled: fAlse [.claude/lint-config.yaml] config-load-error CONV020 CONV020 SEC001 +enabled: no [.claude/lint-config.yaml] SEC001 +enabled: No [.claude/lint-config.yaml] SEC001 +enabled: NO [.claude/lint-config.yaml] SEC001 +enabled: n [.claude/lint-config.yaml] SEC001 +enabled: N [.claude/lint-config.yaml] SEC001 +enabled: off [.claude/lint-config.yaml] SEC001 +enabled: Off [.claude/lint-config.yaml] SEC001 +enabled: OFF [.claude/lint-config.yaml] SEC001 +enabled: nO [.claude/lint-config.yaml] config-load-error CONV020 CONV020 SEC001 +enabled: 'no' [.claude/lint-config.yaml] SEC001 +enabled: "false" [.claude/lint-config.yaml] config-load-error CONV020 CONV020 SEC001 +enabled: 0 [.claude/lint-config.yaml] config-load-error CONV020 CONV020 SEC001 +enabled: true [.claude/lint-config.yaml] CONV020 CONV020 SEC001 +severity+enabled: no [.claude/lint-config.yaml] SEC001 +single-quoted key [.claude/lint-config.yaml] SEC001 +double-quoted key [.claude/lint-config.yaml] SEC001 +flow { enabled: False, severity } [.claude/lint-config.yaml] SEC001 +options: enabled: false (nested) [.claude/lint-config.yaml] CONV020 CONV020 SEC001 +flow nested options [.claude/lint-config.yaml] CONV020 CONV020 SEC001 +root lint-config.yaml [lint-config.yaml] SEC001 +root .lint-config.yaml [.lint-config.yaml] SEC001 +.claude empty, root off [lint-config.yaml .claude/lint-config.yaml] CONV020 CONV020 SEC001 +root empty, .lint-config off [.lint-config.yaml lint-config.yaml] CONV020 CONV020 SEC001 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.cmd b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.cmd new file mode 100644 index 00000000..c4ae3f46 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.cmd @@ -0,0 +1,7 @@ +binary: mxcli version v0.28.0 (2026-10-23T14:29:27Z) (7a1cb61f9e5e28c09f3c1247446cc611f6deca781355b93c5c6a29fd29d4e3ee) +cwd: /tmp/claude-0/v028-pass/models/lint-cfg +command: mxcli lint -p PedApp.mpr --format json -m Shop +model: PedApp.mpr +model-sha256-at-capture: edcd953d50f194bc66532f3989550e5770c5ca79d5e40597a95b70ce1629352f +mprcontents-tree-sha256: 0219fe8a320e910b6dc356db32a320700f1a1f9d88bc516c6c2154796d6a18b8 +exit: 0 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.out new file mode 100644 index 00000000..ea7ec570 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled-v0.28.0.out @@ -0,0 +1,21 @@ +{ + "violations": [ + { + "ruleId": "SEC001", + "severity": "warning", + "message": "Persistent entity 'Shop.Item' has no access rules", + "module": "Shop", + "document": "Item", + "documentType": "entity", + "documentId": "d910235b-6e6e-44e1-ad58-fcc40295ee28", + "suggestion": "GRANT \u003cRole\u003e ON Shop.Item (READ *)" + } + ], + "summary": { + "total": 1, + "errors": 0, + "warnings": 1, + "infos": 0, + "hints": 0 + } +} diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled.lint-config.yaml b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled.lint-config.yaml new file mode 100644 index 00000000..69adf957 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-disabled.lint-config.yaml @@ -0,0 +1,3 @@ +rules: + CONV020: + enabled: false diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.cmd b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.cmd new file mode 100644 index 00000000..f8cf1a5a --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.cmd @@ -0,0 +1,7 @@ +binary: mxcli version v0.28.0 (2026-10-23T14:29:27Z) (7a1cb61f9e5e28c09f3c1247446cc611f6deca781355b93c5c6a29fd29d4e3ee) +cwd: /tmp/claude-0/v028-pass/models/lint-new +command: mxcli lint -p PedApp.mpr --format json -m Shop -r CONV020 +model: PedApp.mpr +model-sha256-at-capture: edcd953d50f194bc66532f3989550e5770c5ca79d5e40597a95b70ce1629352f +mprcontents-tree-sha256: 0219fe8a320e910b6dc356db32a320700f1a1f9d88bc516c6c2154796d6a18b8 +exit: 0 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.out new file mode 100644 index 00000000..4aee685a --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-only-v0.28.0.out @@ -0,0 +1,30 @@ +{ + "violations": [ + { + "ruleId": "CONV020", + "severity": "warning", + "message": "Microflow 'Shop.SUB_Seed' creates $Orphan (Shop.Item) without committing it, and never commits, returns or passes it on: the object is discarded when the microflow ends.", + "module": "Shop", + "document": "SUB_Seed", + "documentType": "microflow", + "documentId": "316f01b6-c67d-4af6-a65d-309d230763cb", + "suggestion": "Commit $Orphan (or create it with commit), return it, or pass it on; if it is only a temporary value, use a non-persistent entity." + }, + { + "ruleId": "CONV020", + "severity": "warning", + "message": "'ACT_SaveItem' is triggered from a page and commits/imports/deletes data, but gives no outcome signal at all.", + "module": "Shop", + "document": "Shop.ACT_SaveItem", + "documentType": "Microflow", + "suggestion": "Add a Show Message (type Information -- 'Success' silently degrades to Information) stating what completed. If the work is handed to an async integration, say it was submitted, not that it arrived." + } + ], + "summary": { + "total": 2, + "errors": 0, + "warnings": 2, + "infos": 0, + "hints": 0 + } +} diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.cmd b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.cmd new file mode 100644 index 00000000..88003609 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.cmd @@ -0,0 +1,7 @@ +binary: mxcli version v0.27.0 (2026-10-15T13:45:04Z) (66e8fc63c0f23c6e7c1da96ed9c216f1d81502e0d30440e5f22ff79cca42a673) +cwd: /tmp/claude-0/v028-pass/models/lint-old +command: mxcli lint -p PedApp.mpr --format json -m Shop +model: PedApp.mpr +model-sha256-at-capture: edcd953d50f194bc66532f3989550e5770c5ca79d5e40597a95b70ce1629352f +mprcontents-tree-sha256: 0219fe8a320e910b6dc356db32a320700f1a1f9d88bc516c6c2154796d6a18b8 +exit: 0 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.out new file mode 100644 index 00000000..18075a46 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.27.0.out @@ -0,0 +1,30 @@ +{ + "violations": [ + { + "ruleId": "SEC001", + "severity": "warning", + "message": "Persistent entity 'Shop.Item' has no access rules", + "module": "Shop", + "document": "Item", + "documentType": "entity", + "documentId": "d910235b-6e6e-44e1-ad58-fcc40295ee28", + "suggestion": "GRANT \u003cRole\u003e ON Shop.Item (READ *)" + }, + { + "ruleId": "CONV020", + "severity": "warning", + "message": "'ACT_SaveItem' is triggered from a page and commits/imports/deletes data, but gives no outcome signal at all.", + "module": "Shop", + "document": "Shop.ACT_SaveItem", + "documentType": "Microflow", + "suggestion": "Add a Show Message (type Information -- 'Success' silently degrades to Information) stating what completed. If the work is handed to an async integration, say it was submitted, not that it arrived." + } + ], + "summary": { + "total": 2, + "errors": 0, + "warnings": 2, + "infos": 0, + "hints": 0 + } +} diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.cmd b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.cmd new file mode 100644 index 00000000..fb4423f9 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.cmd @@ -0,0 +1,7 @@ +binary: mxcli version v0.28.0 (2026-10-23T14:29:27Z) (7a1cb61f9e5e28c09f3c1247446cc611f6deca781355b93c5c6a29fd29d4e3ee) +cwd: /tmp/claude-0/v028-pass/models/lint-new +command: mxcli lint -p PedApp.mpr --format json -m Shop +model: PedApp.mpr +model-sha256-at-capture: edcd953d50f194bc66532f3989550e5770c5ca79d5e40597a95b70ce1629352f +mprcontents-tree-sha256: 0219fe8a320e910b6dc356db32a320700f1a1f9d88bc516c6c2154796d6a18b8 +exit: 0 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.out new file mode 100644 index 00000000..94d5d594 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-conv020-v0.28.0.out @@ -0,0 +1,40 @@ +{ + "violations": [ + { + "ruleId": "SEC001", + "severity": "warning", + "message": "Persistent entity 'Shop.Item' has no access rules", + "module": "Shop", + "document": "Item", + "documentType": "entity", + "documentId": "d910235b-6e6e-44e1-ad58-fcc40295ee28", + "suggestion": "GRANT \u003cRole\u003e ON Shop.Item (READ *)" + }, + { + "ruleId": "CONV020", + "severity": "warning", + "message": "Microflow 'Shop.SUB_Seed' creates $Orphan (Shop.Item) without committing it, and never commits, returns or passes it on: the object is discarded when the microflow ends.", + "module": "Shop", + "document": "SUB_Seed", + "documentType": "microflow", + "documentId": "316f01b6-c67d-4af6-a65d-309d230763cb", + "suggestion": "Commit $Orphan (or create it with commit), return it, or pass it on; if it is only a temporary value, use a non-persistent entity." + }, + { + "ruleId": "CONV020", + "severity": "warning", + "message": "'ACT_SaveItem' is triggered from a page and commits/imports/deletes data, but gives no outcome signal at all.", + "module": "Shop", + "document": "Shop.ACT_SaveItem", + "documentType": "Microflow", + "suggestion": "Add a Show Message (type Information -- 'Success' silently degrades to Information) stating what completed. If the work is handed to an async integration, say it was submitted, not that it arrived." + } + ], + "summary": { + "total": 3, + "errors": 0, + "warnings": 3, + "infos": 0, + "hints": 0 + } +} diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.cmd b/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.cmd new file mode 100644 index 00000000..0f20f412 --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.cmd @@ -0,0 +1,7 @@ +binary: mxcli version v0.28.0 (2026-10-23T14:29:27Z) (7a1cb61f9e5e28c09f3c1247446cc611f6deca781355b93c5c6a29fd29d4e3ee) +cwd: /tmp/claude-0/v028-pass/models/lint-new +command: mxcli lint -p PedApp.mpr -l +model: PedApp.mpr +model-sha256-at-capture: edcd953d50f194bc66532f3989550e5770c5ca79d5e40597a95b70ce1629352f +mprcontents-tree-sha256: 0219fe8a320e910b6dc356db32a320700f1a1f9d88bc516c6c2154796d6a18b8 +exit: 0 diff --git a/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.out b/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.out new file mode 100644 index 00000000..95c5679c --- /dev/null +++ b/tests/wave2/fixtures/lint-conv020-v028/lint-list-rules-v0.28.0.out @@ -0,0 +1,83 @@ +Connected to: PedApp.mpr (Mendix 11.13.0) +Loading cached catalog (built 21s ago, full mode)... +✓ Catalog ready (from cache) +Available lint rules: + + MPR001 (NamingConvention) - Checks that entities, microflows, pages, and enumerations follow naming conventions + Category: style, Severity: warning + + MPR002 (EmptyMicroflow) - Checks for microflows, nanoflows and rules that have no activities + Category: quality, Severity: warning + + MPR003 (DomainModelSize) - Checks that modules have no more than 15 persistent entities + Category: design, Severity: warning + + MPR004 (EmptyValidationFeedback) - Checks for validation feedback actions with empty message templates (CE0091) + Category: correctness, Severity: warning + + MPR005 (UnconfiguredImage) - Checks for IMAGE widgets with no source configured (crashes at runtime) + Category: correctness, Severity: warning + + MPR012 (LegacyImageWidget) - Checks for the legacy static/dynamic image widgets, which the React client does not support (CE0582) + Category: correctness, Severity: warning + + MPR013 (SnippetCallAppearance) - Checks for snippet calls with Class, Style, DynamicClasses or design properties, which never reach the page (a snippet call renders no element) + Category: correctness, Severity: warning + + MPR006 (EmptyContainer) - Checks for CONTAINER widgets with no children (valid and renders, but usually a leftover) + Category: quality, Severity: info + + MPR009 (GallerySelectionListener) - Gallery referenced by DataView's `DataSource: selection X` must set ItemSelectionMode: toggle (Studio Pro CE3637) + Category: correctness, Severity: error + + MPR010 (DataViewLayoutGrid) - A DataView containing input widgets (a form) should be wrapped in a layout grid so label and input widths render correctly + Category: design, Severity: warning + + QUAL006 (RequiredCaptionMissingDefaultLanguage) - Checks that every caption mxbuild requires (a tab page caption) has text in the project's default language (CE4899) + Category: quality, Severity: error + + MPR007 (PageNavigationSecurity) - Checks that pages used in navigation have at least one allowed role (CE0557), and that every user role can open its home page (at Production security, else CE2729 per widget) + Category: security, Severity: warning + + SEC001 (NoEntityAccessRules) - Checks that persistent entities have at least one access rule + Category: security, Severity: warning + + SEC002 (WeakPasswordPolicy) - Checks that the password policy requires at least 8 characters + Category: security, Severity: warning + + SEC003 (DemoUsersActive) - Checks that demo users are disabled when security level is Production + Category: security, Severity: warning + + SEC010 (PageEntityNotReadable) - Checks that the roles allowed on a page may read the entity each of its data widgets lists (at Production security it renders empty for them, silently) + Category: security, Severity: warning + + MPR008 (OverlappingActivities) - Microflow activities whose canvas positions overlap, typically caused by missing @position annotations in MDL + Category: correctness, Severity: warning + + MPR011 (LoopChildContainment) - Microflow activities positioned outside the loop container that holds them, which renders wrong in Studio Pro but passes mx check + Category: correctness, Severity: warning + + CONV011 (NoCommitInLoop) - Commits should not be inside loops, whether a commit action or a create/change with commit (N+1 performance issue) + Category: performance, Severity: warning + + CONV012 (ExclusiveSplitCaption) - Exclusive splits should have a meaningful caption describing the decision + Category: quality, Severity: info + + CONV013 (ErrorHandlingOnCalls) - External service calls (REST, web service, Java action) should have custom error handling + Category: quality, Severity: warning + + CONV014 (NoContinueErrorHandling) - Activities should not use 'Continue' error handling which silently swallows errors + Category: quality, Severity: warning + + CONV020 (CreatedObjectNeverPersisted) - A microflow creates an object of a persistent entity without committing it, and never commits, returns, passes on, stores or deletes it + Category: quality, Severity: warning + + MDL-FLOW01 (IrreducibleFlowGraph) - Microflow branch structure cannot be described faithfully as nested if/then/else + Category: quality, Severity: info + + MDL-MAP04 (ImportRangeOnObjectMapping) - Import activity stores a First range over an object-rooted mapping (throws at runtime) + Category: correctness, Severity: error + + CONV020 (ActionUserFeedback) - Page-triggered microflows that commit, import or delete should show the user a message + Category: quality, Severity: warning + diff --git a/tests/wave2/test-lint-gate-accept.sh b/tests/wave2/test-lint-gate-accept.sh index a8f8c315..90c5f6c5 100755 --- a/tests/wave2/test-lint-gate-accept.sh +++ b/tests/wave2/test-lint-gate-accept.sh @@ -95,14 +95,14 @@ mk "$(v CONV009 info 1)$(v MDL-FLOW01 info 3)$(v SEC003 warning 2)$(v CONV006 wa "$PY" - "$WORK/lint.json" <<'PY' import json,sys d=json.load(open(sys.argv[1])) -d["violations"].append({"ruleId":"CONV020","severity":"warning","module":"_rule","document":"","documentType":"", - "documentId":"","message":"CONV020 is unconfigured: PROJECT_MODULES is empty, so this rule inspected NOTHING.","suggestion":"s"}) +d["violations"].append({"ruleId":"MXTK-UX003","severity":"warning","module":"_rule","document":"","documentType":"", + "documentId":"","message":"MXTK-UX003 is unconfigured: PROJECT_MODULES is empty, so this rule inspected NOTHING.","suggestion":"s"}) json.dump(d,open(sys.argv[1],"w")) PY out=$(gate 2>&1); grc=$? [ "$grc" -eq 1 ] && ok "lint-gate still exits 1 for PASS-BLIND" || bad "gate rc=$grc" "$out" detail=$(cd "$P" && PROJECT_ROOT="$P" LINT_JSON="$WORK/lint.json" bash -c ". '$fn'; LINT_RC=0; run_lint_gate >/dev/null; echo \"\$LINT_RC|\$LINT_DETAIL\"") -case "$detail" in "3|lint BLIND: CONV020"*) ok "exec detail: ${detail#*|}" ;; *) bad "blind run not told apart from a rise" "$detail" ;; esac +case "$detail" in "3|lint BLIND: MXTK-UX003"*) ok "exec detail: ${detail#*|}" ;; *) bad "blind run not told apart from a rise" "$detail" ;; esac echo "T9 the metrics ledger does not class a blind run as a clean apply" ML="$TK/project-bin/metrics-ledger.sh" diff --git a/tests/wave2/test-lint-receipt-revert.sh b/tests/wave2/test-lint-receipt-revert.sh index 2a8a046d..c18a24b5 100755 --- a/tests/wave2/test-lint-receipt-revert.sh +++ b/tests/wave2/test-lint-receipt-revert.sh @@ -52,7 +52,7 @@ n=$(grep -cv '^#' "$R" 2>/dev/null || echo 0) [ "$n" -ge 3 ] && ok "receipt lists $n rules" || bad "receipt lists $n rules" grep -q '^conv010_act_microflow_content.star [0-9a-f]\{32\} stock$' "$R" && ok "conv010 row: name + md5 + kind stock" || bad "conv010 row missing" "$(cat "$R")" grep -q '^ux001_confirm_before_destructive.star [0-9a-f]\{32\} toolkit$' "$R" && ok "ux001 row: kind toolkit" || bad "ux001 row kind wrong" "$(cat "$R")" -grep -q '^conv020_action_user_feedback.star [0-9a-f]\{32\} configurable$' "$R" && ok "conv020 row: kind configurable" || bad "conv020 row kind wrong" "$(cat "$R")" +grep -q '^mxtk_ux003_action_user_feedback.star [0-9a-f]\{32\} configurable$' "$R" && ok "mxtk_ux003 row: kind configurable" || bad "mxtk_ux003 row kind wrong" "$(cat "$R")" echo "T1 rules as installed: no receipt complaint, normal ratchet" mk "$(v CONV010 warning 26)" @@ -204,13 +204,13 @@ out=$(inst2 "$P13") case "$out" in *"ux001_confirm_before_destructive.star is LOCALLY MODIFIED"*) ok "installer reports LOCALLY MODIFIED" ;; *) bad "not reported as a local edit" "$out" ;; esac [ "$(portable_md5 "$U1")" = "$um" ] && ok "ux001 not overwritten" || bad "ux001 overwritten" -echo "T14 a configured, headerless conv020 is not overwritten and not BLIND" -C2="$P13/.claude/lint-rules/conv020_action_user_feedback.star" +echo "T14 a configured, headerless mxtk_ux003 is not overwritten and not BLIND" +C2="$P13/.claude/lint-rules/mxtk_ux003_action_user_feedback.star" strip_header "$C2"; printf 'PROJECT_MODULES = "Orders"\n' >> "$C2"; cm=$(portable_md5 "$C2") out=$(gate2 "$P13" 2>&1); rc=$? -[ "$rc" -eq 0 ] && ok "lint-gate: rc 0 for a configured conv020" || bad "configured conv020 called a re-seed (rc $rc)" "$out" +[ "$rc" -eq 0 ] && ok "lint-gate: rc 0 for a configured mxtk_ux003" || bad "configured mxtk_ux003 called a re-seed (rc $rc)" "$out" out=$(inst2 "$P13") -[ "$(portable_md5 "$C2")" = "$cm" ] && ok "conv020 not overwritten" || bad "conv020 overwritten" "$out" +[ "$(portable_md5 "$C2")" = "$cm" ] && ok "mxtk_ux003 not overwritten" || bad "mxtk_ux003 overwritten" "$out" echo "T11 a rule the toolkit stops shipping: its receipt row is pruned, no permanent 'gone'" P11=$(newproj p11); inst2 "$P11" >/dev/null diff --git a/tests/wave2/test-lint-rule-rename.sh b/tests/wave2/test-lint-rule-rename.sh new file mode 100755 index 00000000..4257b8dd --- /dev/null +++ b/tests/wave2/test-lint-rule-rename.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# test-lint-rule-rename.sh — the toolkit's CONV020 rule is MXTK-UX003, and sync migrates projects. +# +# usage: bash tests/wave2/test-lint-rule-rename.sh bin/sync-project.sh +# +# Why (2026-10-23): mxcli v0.28.0 ships a built-in CONV020 ("Created object never persisted", +# mendixlabs/mxcli#1518) and mxcli has no duplicate-ID guard, so the toolkit's CONV020 +# (user feedback) ran beside it under one id: one lint-config entry switched both, -r CONV020 ran +# both, lint-gate summed them. The rule is now MXTK-UX003 (mxtk_ux003_action_user_feedback.star), +# and bin/lib/install-lint-rules.sh (_mxtk_lint_migrate_renames) migrates synced projects. +# +# Golden input. The old rule files are read from this repo's git history (every shipped version, +# the same ones MXTK_LINT_RENAMES lists by md5); without history, the last shipped version is +# rebuilt from the new file and must hash to a listed md5 or the fixture stops. Baselines are +# written by the project's REAL bin/lint-gate.sh --update-baseline from verbatim `mxcli lint +# --format json` captures on a public model, v0.27.0 and v0.28.0 (tests/wave2/fixtures/ +# lint-conv020-v028/CAPTURE.txt); the post-rename output is the v0.28.0 capture with only the +# toolkit finding's ruleId changed. T1b's baseline is the capture taken WITH the opt-out +# (lint-conv020-disabled-v0.28.0.out); its post-sync output adds the toolkit finding as MXTK-UX003 +# only when the project would run that rule (installed and not disabled), since a CONV020 key no +# longer reaches it. T1c's other spellings and config locations take their verdicts from +# lint-config-variants-v0.28.0.out (mxcli v0.28.0 run once per config). Projects are scaffolded by +# the REAL bin/init-project.sh and migrated by the REAL sync-project.sh, on both layouts +# (.mpr at the root, and under app/). doctor.sh runs --quick against a stub mxcli. +set -uo pipefail +SYNC="${1:?usage: $0 bin/sync-project.sh}" +case "$SYNC" in /*) ;; *) SYNC="$PWD/$SYNC" ;; esac +TK="$(cd "$(dirname "$SYNC")/.." && pwd)" +INIT="$TK/bin/init-project.sh" +. "$TK/bin/lib/portable.sh" +require_py +WORK="$(mktemp -d "${TMPDIR:-/tmp}/lint-rename.XXXXXX")" +export MXTK_LEAKGUARD_DENYFILE="$WORK/deny" MXTK_SYNC_SKIP_CLONE_CHECK=1 MXTK_NO_GUIDE=1 +trap '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/^/ /'; } + +OLD=conv020_action_user_feedback.star; NEW=mxtk_ux003_action_user_feedback.star +. "$TK/bin/lib/install-manifest.sh" >/dev/null 2>&1 +MD5S="$(printf '%s\n' "$MXTK_LINT_RENAMES" | awk -v o="$OLD" '$1 == o { print $5 }')" + +echo "T0 the rename is complete" +# Code (not comments) in a shipped .star: no CONV020 left, so no rule reports under that id. +code="$(grep -n 'CONV020' "$TK"/lint-rules/*.star | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)" +[ -z "$code" ] && ok "no lint-rules/*.star code says CONV020" || bad "a shipped .star still uses CONV020" "$code" +grep -q '^RULE_ID = "MXTK-UX003"$' "$TK/lint-rules/$NEW" && ok "RULE_ID = MXTK-UX003" || bad "RULE_ID not MXTK-UX003" +[ ! -e "$TK/lint-rules/$OLD" ] && ok "old file name gone from lint-rules/" || bad "lint-rules/$OLD still shipped" +case " $MXTK_LINT_RULES $MXTK_LINT_RULES_CONFIGURABLE " in *" $NEW "*) ok "manifest installs $NEW" ;; *) bad "manifest does not install $NEW" ;; esac +case " $MXTK_LINT_RULES $MXTK_LINT_RULES_CONFIGURABLE " in *" $OLD "*) bad "manifest still installs $OLD" ;; *) ok "manifest no longer installs $OLD" ;; esac +# Every shipped line naming CONV020 must also name MXTK-UX003 or say it is mxcli's built-in. +stray="$(cd "$TK" && grep -rn 'CONV020' bin project-bin project-tests lint-rules skills templates README.md 2>/dev/null \ + | grep -v 'MXTK-UX003' | grep -vi 'built-in' \ + | grep -v '^lint-rules/conv010_act_microflow_content.star:[0-9]*:.*Same guard as CONV020\.$' || true)" +[ -z "$stray" ] && ok "every CONV020 mention in shipped code/docs names MXTK-UX003 or the built-in" || bad "toolkit CONV020 left" "$stray" +# Every shipped version of the old file is in MXTK_LINT_RENAMES (captured from git, not typed). +VERS="$WORK/versions"; mkdir -p "$VERS" +if git -C "$TK" rev-parse --is-shallow-repository 2>/dev/null | grep -qx false; then + for c in $(git -C "$TK" log --format=%h -- "lint-rules/$OLD"); do git -C "$TK" show "$c:lint-rules/$OLD" > "$VERS/$c.star" 2>/dev/null || rm -f "$VERS/$c.star"; done +fi +if [ -n "$(ls "$VERS")" ]; then + miss=""; for f in "$VERS"/*.star; do case ",$MD5S," in *",$(portable_md5 "$f"),"*) ;; *) miss="$miss $(basename "$f")" ;; esac; done + [ -z "$miss" ] && ok "all $(ls "$VERS" | wc -l | tr -d ' ') shipped versions of $OLD are listed by md5" || bad "shipped versions missing from MXTK_LINT_RENAMES:$miss" +else + echo " note shallow clone: no git history; rebuilding the last shipped version from $NEW" +fi +LAST="$WORK/last-old.star" +sed '/^# MXTK-UX003 was CONV020 (conv020_action_user_feedback.star) until/,/^# migrates an unedited project copy/d' "$TK/lint-rules/$NEW" \ + | sed '11{/^#$/d;}' | sed 's/^# mxtk-lint-rule: mxtk_ux003_action_user_feedback$/# mxtk-lint-rule: conv020_action_user_feedback/; s/MXTK-UX003/CONV020/g' > "$LAST" +case ",$MD5S," in *",$(portable_md5 "$LAST"),"*) ok "rebuilt last old version hashes to a listed md5" ;; + *) bad "rebuilt old version $(portable_md5 "$LAST") is not a listed md5 — fixture input broken"; echo "pass=$PASS fail=$FAIL"; exit 1 ;; esac +EMPTY_OLD="" # a pre-2026-10-09 version (PROJECT_MODULES = ()), from history when available +for f in "$VERS"/*.star; do [ -f "$f" ] && grep -q '^PROJECT_MODULES = ()' "$f" && { EMPTY_OLD="$f"; break; }; done + +# --- golden captures: real mxcli lint output (tests/wave2/fixtures/lint-conv020-v028/CAPTURE.txt) --- +CAP="$TK/tests/wave2/fixtures/lint-conv020-v028" +echo "T0b the captures show the collision, and the migration tells the two rules apart" +ids() { "$PY" -c 'import json,sys; d=json.load(open(sys.argv[1])); print(" ".join(sorted(v["ruleId"] for v in d["violations"])))' "$1"; } +[ "$(ids "$CAP/lint-conv020-v0.27.0.out")" = "CONV020 SEC001" ] && ok "v0.27.0: one CONV020 (the toolkit's)" || bad "v0.27 capture unexpected" +[ "$(ids "$CAP/lint-conv020-v0.28.0.out")" = "CONV020 CONV020 SEC001" ] && ok "v0.28.0: two CONV020 findings, one per rule" || bad "v0.28 capture unexpected" +[ "$(ids "$CAP/lint-conv020-only-v0.28.0.out")" = "CONV020 CONV020" ] && ok "v0.28.0 -r CONV020 runs both" || bad "-r capture unexpected" +[ "$(ids "$CAP/lint-conv020-disabled-v0.28.0.out")" = "SEC001" ] && ok "v0.28.0 CONV020 enabled:false switches both off" || bad "disabled capture unexpected" +[ "$(grep -c '^ *CONV020 (' "$CAP/lint-list-rules-v0.28.0.out")" = 2 ] && ok "v0.28.0 lint -l lists CONV020 twice" || bad "list-rules capture unexpected" +grep -qE '^ *MXTK-' "$CAP/lint-list-rules-v0.28.0.out" && bad "MXTK- id already taken in v0.28.0" || ok "no MXTK- id among v0.28.0's rules: MXTK-UX003 is free" +RE="$(printf '%s\n' "$MXTK_LINT_RENAMES" | awk -v o="$OLD" '$1 == o { $1=$2=$3=$4=$5=""; sub(/^ +/, ""); print }')" +cls="$(RE="$RE" "$PY" -c ' +import json, os, re, sys +pat = re.compile(os.environ["RE"]) +for v in json.load(open(sys.argv[1]))["violations"]: + if v["ruleId"] == "CONV020": print("toolkit" if pat.search(v["message"]) else "builtin", v["document"]) +' "$CAP/lint-conv020-v0.28.0.out" | sort | paste -sd, -)" +[ "$cls" = "builtin SUB_Seed,toolkit Shop.ACT_SaveItem" ] && ok "migration regex: ACT_SaveItem = toolkit, SUB_Seed = built-in" || bad "regex misclassifies the captured findings" "$cls" + +# Post-rename v0.28.0 output: the capture with the toolkit finding's ruleId renamed (the only change +# the rename makes to what mxcli prints for that rule). +"$PY" -c ' +import json, sys +d = json.load(open(sys.argv[1])) +for v in d["violations"]: + if v["document"] == "Shop.ACT_SaveItem": v["ruleId"] = "MXTK-UX003" +json.dump(d, open(sys.argv[2], "w"), indent=2) +' "$CAP/lint-conv020-v0.28.0.out" "$WORK/after.json" +cp "$CAP/lint-conv020-v0.28.0.out" "$WORK/before.json" +stub_mxcli() { printf '#!/bin/sh\n[ "$1" = --version ] && echo "mxcli version %s (2026-10-23T14:29:27Z)"\nexit 0\n' "$2" > "$1/mxcli"; chmod +x "$1/mxcli"; } +count() { "$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["counts"].get(sys.argv[2], 0))' "$1/.claude/lint-baseline.json" "$2"; } +lintstate() { (cd "$1" && for f in .claude/lint-baseline.json .claude/.mxtk-lint-receipt .claude/lint-rules-disabled.txt .claude/lint-rules/*; do + [ -f "$f" ] && printf '%s %s\n' "$f" "$(portable_md5 "$f")"; done); } +# put_old : the project as it was before the rename — old file in place, +# new one absent, receipt row for the old name (md5 as installed). +put_old() { + cp "$2" "$1/.claude/lint-rules/$OLD"; rm -f "$1/.claude/lint-rules/$NEW" + grep -v "^$NEW " "$1/.claude/.mxtk-lint-receipt" > "$1/.r"; mv "$1/.r" "$1/.claude/.mxtk-lint-receipt" + printf '%s %s configurable\n' "$OLD" "$(portable_md5 "$2")" >> "$1/.claude/.mxtk-lint-receipt" +} +baseline_with() { (cd "$1" && LINT_JSON="$2" bash bin/lint-gate.sh --update-baseline >/dev/null 2>&1); } + +# One scaffold per layout, copied per case (init-project.sh takes ~10 s). +for L in root app; do + S="$WORK/scaffold-$L"; mkdir -p "$S" + if [ "$L" = app ]; then mkdir -p "$S/app"; : > "$S/app/Fixture.mpr"; else : > "$S/Fixture.mpr"; fi + "$INIT" "$S" >/dev/null 2>&1 + [ -f "$S/.claude/lint-rules/$NEW" ] && [ -f "$S/bin/lint-gate.sh" ] || { bad "[$L] init-project.sh did not install $NEW / bin/lint-gate.sh"; continue; } + stub_mxcli "$S" v0.27.0 + mk() { rm -rf "$WORK/$1"; cp -R "$WORK/scaffold-$L" "$WORK/$1"; echo "$WORK/$1"; } + + echo "T1 [$L] unedited old copy: renamed, baseline rekeyed, built-in finding kept, notices once" + P="$(mk "t1-$L")"; put_old "$P" "$LAST" + baseline_with "$P" "$WORK/before.json" + grep -q '"mxcli_version": "v0.27.0"' "$P/.claude/lint-baseline.json" && ok "[$L] lint-gate --update-baseline records mxcli_version" || bad "[$L] mxcli_version not recorded" "$(cat "$P/.claude/lint-baseline.json")" + [ "$(count "$P" CONV020)" = 2 ] && ok "[$L] pre-rename baseline (v0.28 capture): CONV020 2 (toolkit + built-in)" || bad "[$L] setup baseline wrong" + printf 'rules:\n CONV020:\n severity: error\n' > "$P/.claude/lint-config.yaml" + echo "- D-9: CONV020 opted out, no ACT_ convention. CONFIRMED" >> "$P/PROJECT.md" + out="$("$SYNC" "$P" 2>&1)" + [ ! -e "$P/.claude/lint-rules/$OLD" ] && cmp -s "$TK/lint-rules/$NEW" "$P/.claude/lint-rules/$NEW" \ + && ok "[$L] $OLD replaced by the current $NEW" || bad "[$L] file not migrated" "$out" + grep -q "^$NEW [0-9a-f]\{32\} configurable$" "$P/.claude/.mxtk-lint-receipt" && ! grep -q "^$OLD " "$P/.claude/.mxtk-lint-receipt" \ + && ok "[$L] receipt row moved to $NEW" || bad "[$L] receipt wrong" "$(cat "$P/.claude/.mxtk-lint-receipt")" + { [ "$(count "$P" MXTK-UX003)" = 1 ] && [ "$(count "$P" CONV020)" = 1 ]; } \ + && ok "[$L] baseline: MXTK-UX003 1, CONV020 1 (the built-in's)" || bad "[$L] baseline not mapped" "$(cat "$P/.claude/lint-baseline.json")" + grep -q '"CONV020|Shop|SUB_Seed|' "$P/.claude/lint-baseline.json" && grep -q '"MXTK-UX003|Shop|Shop.ACT_SaveItem|' "$P/.claude/lint-baseline.json" \ + && ok "[$L] findings rekeyed by message, built-in key untouched" || bad "[$L] findings keys wrong" + printf '%s' "$out" | grep -q "Lint rule renamed: .claude/lint-rules/$OLD -> $NEW" && ok "[$L] sync says it renamed" || bad "[$L] no rename line" "$out" + printf '%s' "$out" | grep -q "lint-config.yaml has settings under CONV020" && ok "[$L] NOTE for the lint-config CONV020 key" || bad "[$L] no lint-config note" "$out" + printf '%s' "$out" | grep -q "PROJECT.md names CONV020 on line" && ok "[$L] NOTE for PROJECT.md" || bad "[$L] no PROJECT.md note" "$out" + grep -q 'CONV020:' "$P/.claude/lint-config.yaml" && grep -q 'D-9: CONV020' "$P/PROJECT.md" && ok "[$L] lint-config.yaml and PROJECT.md not rewritten" || bad "[$L] a user file was rewritten" + out="$(cd "$P" && LINT_JSON="$WORK/after.json" bash bin/lint-gate.sh 2>&1)"; rc=$? + [ "$rc" -eq 0 ] && ok "[$L] lint-gate PASS on post-rename output (no false rise)" || bad "[$L] lint-gate rc $rc after the rename" "$out" + + echo "T2 [$L] a second sync is a no-op" + before="$(lintstate "$P")"; out="$("$SYNC" "$P" 2>&1)" + [ "$(lintstate "$P")" = "$before" ] && ok "[$L] no lint file changed" || bad "[$L] second sync changed files" "$(diff <(echo "$before") <(lintstate "$P"))" + printf '%s' "$out" | grep -qE "renamed|rekeyed|CONV020|carried over" && bad "[$L] second sync still talks about the rename" "$out" || ok "[$L] second sync silent about it" + + echo "T1b [$L] lint-config CONV020 enabled: false (README's preferred opt-out): carried over, rule stays off" + P="$(mk "t1b-$L")"; put_old "$P" "$LAST" + printf 'rules:\n CONV020:\n enabled: false # no ACT_ convention, PROJECT.md D-9\n' > "$P/.claude/lint-config.yaml" + cp "$P/.claude/lint-config.yaml" "$WORK/cfg-$L" + # What mxcli printed with that config (both rules off): no toolkit finding to baseline. + baseline_with "$P" "$CAP/lint-conv020-disabled-v0.28.0.out" + [ "$(count "$P" CONV020)" = 0 ] && ok "[$L] opt-out baseline holds no CONV020 finding" || bad "[$L] opt-out baseline wrong" + out="$("$SYNC" "$P" 2>&1)" + [ ! -e "$P/.claude/lint-rules/$OLD" ] && [ ! -e "$P/.claude/lint-rules/$NEW" ] \ + && ok "[$L] old copy retired, $NEW NOT installed" || bad "[$L] opt-out lost: $NEW installed" "$out" + grep -qE "^$NEW # reason: carried over from lint-config.yaml CONV020: enabled: false \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$" "$P/.claude/lint-rules-disabled.txt" 2>/dev/null \ + && ok "[$L] lint-rules-disabled.txt names $NEW with the carried-over reason" || bad "[$L] no disabled line" "$(cat "$P/.claude/lint-rules-disabled.txt" 2>&1)" + cmp -s "$P/.claude/lint-config.yaml" "$WORK/cfg-$L" && ok "[$L] lint-config.yaml CONV020 key left alone" || bad "[$L] lint-config.yaml rewritten" + printf '%s' "$out" | grep -q "enabled: false was carried over" && ok "[$L] NOTE says the opt-out was carried over" || bad "[$L] NOTE does not say carried over" "$out" + # mxcli v0.28 on the synced project: the CONV020 key switches off only the built-in; MXTK-UX003 + # reports whenever the project would run it. + "$PY" -c ' +import json, os, sys +d = json.load(open(sys.argv[1])) +p = sys.argv[3] +run = os.path.isfile(p + "/.claude/lint-rules/mxtk_ux003_action_user_feedback.star") and not any( + l.split()[:1] == ["mxtk_ux003_action_user_feedback.star"] and "reason:" in l + for l in (open(p + "/.claude/lint-rules-disabled.txt") if os.path.isfile(p + "/.claude/lint-rules-disabled.txt") else [])) +if run: + for v in json.load(open(sys.argv[2]))["violations"]: + if v["document"] == "Shop.ACT_SaveItem": v["ruleId"] = "MXTK-UX003"; d["violations"].append(v) +json.dump(d, open(sys.argv[4], "w"), indent=2) +' "$CAP/lint-conv020-disabled-v0.28.0.out" "$CAP/lint-conv020-v0.28.0.out" "$P" "$WORK/after-optout-$L.json" + out="$(cd "$P" && LINT_JSON="$WORK/after-optout-$L.json" bash bin/lint-gate.sh 2>&1)"; rc=$? + [ "$rc" -eq 0 ] && ok "[$L] lint-gate PASS after sync (no MXTK-UX003 0 -> 1)" || bad "[$L] lint-gate rc $rc after sync" "$out" + before="$(lintstate "$P")"; out="$("$SYNC" "$P" 2>&1)" + { [ "$(lintstate "$P")" = "$before" ] && ! printf '%s' "$out" | grep -qE "renamed|carried over|CONV020"; } \ + && ok "[$L] second sync: no change, silent" || bad "[$L] second sync not a no-op" "$out" + + echo "T1c [$L] other spellings of the opt-out, and where mxcli reads the config: carried over exactly when mxcli v0.28.0 switches CONV020 off" + # Expected verdicts are mxcli v0.28.0's own: each case's name is a line of the capture + # lint-config-variants-v0.28.0.out (that config on the pedapp capture model), and the fixture + # stops if the column below disagrees with it: SEC001 only = both CONV020 off. + # id | capture label | file(s) under the project, first gets the text | config text | 1 = mxcli switched CONV020 off + while IFS='|' read -r cid cn cf ct coff <&3; do + [ -n "$cid" ] || continue + capids="$(awk -v l="$cn" '{ k = substr($0, 1, 35); sub(/ +$/, "", k) } k == l { sub(/^[^]]*][ ]*/, ""); print; exit }' "$CAP/lint-config-variants-v0.28.0.out")" + case "$coff:$capids" in 1:SEC001|0:*"CONV020 CONV020 SEC001") ;; + *) bad "[$L] $cn: expected verdict $coff disagrees with the v0.28.0 capture ('$capids')"; continue ;; esac + P="$(mk "t1c-$cid-$L")"; put_old "$P" "$LAST"; rm -f "$P/.claude/lint-config.yaml" + for x in $cf; do printf "$ct" > "$P/$x"; ct='rules: {}\n'; done + if [ "$coff" = 1 ]; then baseline_with "$P" "$CAP/lint-conv020-disabled-v0.28.0.out"; else baseline_with "$P" "$WORK/before.json"; fi + out="$("$SYNC" "$P" 2>&1)" + if [ "$coff" = 1 ]; then + { [ ! -e "$P/.claude/lint-rules/$NEW" ] && grep -q "^$NEW # reason: carried over from " "$P/.claude/lint-rules-disabled.txt" 2>/dev/null; } \ + && ok "[$L] $cn: carried over, $NEW not installed" || bad "[$L] $cn: opt-out lost" "$out" + else + { [ -f "$P/.claude/lint-rules/$NEW" ] && ! grep -q "^$NEW " "$P/.claude/lint-rules-disabled.txt" 2>/dev/null; } \ + && ok "[$L] $cn: mxcli runs CONV020, so no carry-over; $NEW installed" || bad "[$L] $cn: carried over an opt-out mxcli ignores" "$out" + fi + # What mxcli v0.28.0 prints on the synced project: the built-in CONV020 as the config leaves it, + # plus the toolkit finding as MXTK-UX003 whenever the project would run that rule. + "$PY" -c ' +import json, os, sys +cap, off, p = json.load(open(sys.argv[1])), sys.argv[2] == "1", sys.argv[3] +dis = p + "/.claude/lint-rules-disabled.txt" +run = os.path.isfile(p + "/.claude/lint-rules/mxtk_ux003_action_user_feedback.star") and not any( + l.split()[:1] == ["mxtk_ux003_action_user_feedback.star"] and "reason:" in l + for l in (open(dis) if os.path.isfile(dis) else [])) +out = [] +for v in cap["violations"]: + if v["document"] == "Shop.ACT_SaveItem": + if run: v["ruleId"] = "MXTK-UX003"; out.append(v) + elif not (off and v["ruleId"] == "CONV020"): + out.append(v) +cap["violations"] = out +json.dump(cap, open(sys.argv[4], "w"), indent=2) +' "$CAP/lint-conv020-v0.28.0.out" "$coff" "$P" "$WORK/t1c.json" + o2="$(cd "$P" && LINT_JSON="$WORK/t1c.json" bash bin/lint-gate.sh 2>&1)"; rc=$? + [ "$rc" -eq 0 ] && ok "[$L] $cn: lint-gate PASS after sync" || bad "[$L] $cn: lint-gate rc $rc after sync" "$o2" + done 3<<'CASES' +False|enabled: False|.claude/lint-config.yaml|rules:\n CONV020:\n enabled: False\n|1 +no|severity+enabled: no|.claude/lint-config.yaml|rules:\n CONV020:\n severity: info\n enabled: no\n|1 +sqkey|single-quoted key|.claude/lint-config.yaml|rules:\n 'CONV020':\n enabled: false\n|1 +flow|flow { enabled: False, severity }|.claude/lint-config.yaml|rules:\n CONV020: { enabled: False, severity: info }\n|1 +nested|options: enabled: false (nested)|.claude/lint-config.yaml|rules:\n CONV020:\n options:\n enabled: false\n|0 +qfalse|enabled: "false"|.claude/lint-config.yaml|rules:\n CONV020:\n enabled: "false"\n|0 +root|root lint-config.yaml|lint-config.yaml|rules:\n CONV020:\n enabled: false\n|1 +precedence|.claude empty, root off|lint-config.yaml .claude/lint-config.yaml|rules:\n CONV020:\n enabled: false\n|0 +crlf|enabled: false|.claude/lint-config.yaml|rules:\r\n CONV020:\r\n enabled: false\r\n|1 +CASES + + echo "T1d [$L] dry run with the opt-out: says it would carry over, not that it would install $NEW" + P="$(mk "t1d-$L")"; put_old "$P" "$LAST" + printf 'rules:\n CONV020:\n enabled: false\n' > "$P/.claude/lint-config.yaml" + out="$("$SYNC" "$P" --dry-run 2>&1)" + { printf '%s' "$out" | grep -q "Would carry over: .claude/lint-config.yaml CONV020 enabled: false" \ + && ! printf '%s' "$out" | grep -q "Would install: .claude/lint-rules/$NEW"; } \ + && ok "[$L] dry run: carry-over only" || bad "[$L] dry run also announces installing $NEW" "$out" + printf '%s' "$out" | grep -q "move them" && bad "[$L] NOTE still says to move the key under MXTK-UX003" "$out" || ok "[$L] no move-the-key advice when the rule stays off" + + echo "T2b [$L] baseline taken on v0.27.0: rename maps it; the v0.28 upgrade then rises in CONV020 only" + P="$(mk "t2b-$L")"; put_old "$P" "$LAST" + baseline_with "$P" "$CAP/lint-conv020-v0.27.0.out" + "$SYNC" "$P" >/dev/null 2>&1 + { [ "$(count "$P" MXTK-UX003)" = 1 ] && [ "$(count "$P" CONV020)" = 0 ]; } && ok "[$L] v0.27 baseline: CONV020 1 -> MXTK-UX003 1" || bad "[$L] v0.27 baseline not mapped" "$(cat "$P/.claude/lint-baseline.json")" + out="$(cd "$P" && LINT_JSON="$WORK/after.json" bash bin/lint-gate.sh 2>&1)"; rc=$? + { [ "$rc" -eq 1 ] && printf '%s' "$out" | grep -qE '^ +CONV020 +warning +0 -> 1' && ! printf '%s' "$out" | grep -qE '^ +MXTK-UX003 +warning'; } \ + && ok "[$L] after the upgrade only the built-in CONV020 rises (the re-baseline the doctor hint is for)" || bad "[$L] post-upgrade rise wrong (rc $rc)" "$out" + + echo "T3 [$L] edited old copy: left alone, new rule held back, warning, baseline untouched" + P="$(mk "t3-$L")"; put_old "$P" "$LAST" + sed 's/^PROJECT_MODULES = "\*"$/PROJECT_MODULES = ("Orders",)/' "$LAST" > "$P/.claude/lint-rules/$OLD" + baseline_with "$P" "$WORK/before.json"; em="$(portable_md5 "$P/.claude/lint-rules/$OLD")"; bm="$(portable_md5 "$P/.claude/lint-baseline.json")" + out="$("$SYNC" "$P" 2>&1)" + [ "$(portable_md5 "$P/.claude/lint-rules/$OLD")" = "$em" ] && ok "[$L] edited $OLD byte-identical" || bad "[$L] edited copy touched" + [ ! -e "$P/.claude/lint-rules/$NEW" ] && ok "[$L] $NEW not installed beside it" || bad "[$L] $NEW installed: the rule would run twice" + [ "$(portable_md5 "$P/.claude/lint-baseline.json")" = "$bm" ] && ok "[$L] baseline untouched" || bad "[$L] baseline mapped while CONV020 still runs" + printf '%s' "$out" | grep -q "$OLD is edited here, so it was NOT renamed" && printf '%s' "$out" | grep -q "cp .*$NEW" \ + && ok "[$L] manual-migration warning with steps" || bad "[$L] no manual-migration warning" "$out" + before="$(lintstate "$P")"; "$SYNC" "$P" >/dev/null 2>&1 + [ "$(lintstate "$P")" = "$before" ] && ok "[$L] second sync changes nothing" || bad "[$L] second sync changed the held project" + # The user follows the steps: next sync maps the baseline. + cp "$TK/lint-rules/$NEW" "$P/.claude/lint-rules/"; sed -i.b 's/^PROJECT_MODULES = "\*"$/PROJECT_MODULES = ("Orders",)/' "$P/.claude/lint-rules/$NEW" + rm -f "$P/.claude/lint-rules/$NEW.b" "$P/.claude/lint-rules/$OLD" + out="$("$SYNC" "$P" 2>&1)" + [ "$(count "$P" MXTK-UX003)" = 1 ] && grep -q 'PROJECT_MODULES = ("Orders",)' "$P/.claude/lint-rules/$NEW" \ + && ok "[$L] after the manual steps: baseline rekeyed, configured copy kept" || bad "[$L] manual path not completed" "$out" + + echo "T3b [$L] edited old copy + lint-config opt-out: the warning says how to keep it, and following it works" + P="$(mk "t3b-$L")"; put_old "$P" "$LAST" + sed 's/^PROJECT_MODULES = "\*"$/PROJECT_MODULES = ("Orders",)/' "$LAST" > "$P/.claude/lint-rules/$OLD" + printf 'rules:\n CONV020:\n enabled: false\n' > "$P/.claude/lint-config.yaml" + out="$("$SYNC" "$P" 2>&1)" + { printf '%s' "$out" | grep -q "To keep that opt-out" && printf '%s' "$out" | grep -qF "$NEW # reason: "; } \ + && ok "[$L] hold warning names the lint-rules-disabled.txt line" || bad "[$L] hold warning silent about the opt-out" "$out" + printf '%s # reason: no ACT_ convention\n' "$NEW" >> "$P/.claude/lint-rules-disabled.txt"; rm -f "$P/.claude/lint-rules/$OLD" + out="$("$SYNC" "$P" 2>&1)" + [ ! -e "$P/.claude/lint-rules/$NEW" ] && ok "[$L] after the steps: $NEW stays uninstalled" || bad "[$L] $NEW installed despite the opt-out" "$out" + + echo "T4 [$L] opt-out line names the old file: carried over, rule stays uninstalled" + P="$(mk "t4-$L")"; rm -f "$P/.claude/lint-rules/$NEW" + printf '%s # reason: no ACT_ convention (PROJECT.md D-9)\n' "$OLD" > "$P/.claude/lint-rules-disabled.txt" + printf 'rules:\n "CONV020":\n severity: error\n' > "$P/.claude/lint-config.yaml" + out="$("$SYNC" "$P" 2>&1)" + { printf '%s' "$out" | grep -q 'lint-config.yaml has settings under CONV020' && printf '%s' "$out" | grep -q 'which is off here through' \ + && ! printf '%s' "$out" | grep -q 'move them'; } \ + && ok "[$L] NOTE (double-quoted key) says the rule is off, no move-the-key advice" || bad "[$L] NOTE wrong for an opted-out rule" "$out" + grep -q "^$NEW # reason: no ACT_ convention (PROJECT.md D-9)$" "$P/.claude/lint-rules-disabled.txt" && ok "[$L] disabled line rewritten, reason kept" || bad "[$L] disabled line" "$(cat "$P/.claude/lint-rules-disabled.txt")" + [ ! -e "$P/.claude/lint-rules/$NEW" ] && ok "[$L] opted-out rule not installed under its new name" || bad "[$L] opt-out lost: $NEW installed" "$out" + + echo "T5 [$L] counts-only baseline (pre-#239): NOTE, not a guess" + P="$(mk "t5-$L")"; put_old "$P" "$LAST" + printf '{\n "counts": {\n "CONV020": 3\n },\n "total": 3\n}\n' > "$P/.claude/lint-baseline.json"; bm="$(portable_md5 "$P/.claude/lint-baseline.json")" + out="$("$SYNC" "$P" 2>&1)" + [ "$(portable_md5 "$P/.claude/lint-baseline.json")" = "$bm" ] && ok "[$L] counts-only baseline unchanged" || bad "[$L] counts-only baseline rewritten" + printf '%s' "$out" | grep -q "holds CONV020 as a count only" && ok "[$L] NOTE says it cannot split the count" || bad "[$L] no count-only NOTE" "$out" + out="$("$SYNC" "$P" 2>&1)" + printf '%s' "$out" | grep -q "as a count only" && bad "[$L] count-only NOTE printed again on the second sync" "$out" || ok "[$L] count-only NOTE printed once (second sync silent)" + [ "$(portable_md5 "$P/.claude/lint-baseline.json")" = "$bm" ] && ok "[$L] counts-only baseline still unchanged" || bad "[$L] second sync rewrote the baseline" + + echo "T6 [$L] CRLF copy of a shipped version, and the empty-PROJECT_MODULES template" + P="$(mk "t6-$L")"; awk '{ printf "%s\r\n", $0 }' "$LAST" > "$WORK/crlf.star"; put_old "$P" "$WORK/crlf.star" + sed -i.b "s/^$OLD [0-9a-f]* /$OLD 00000000000000000000000000000000 /" "$P/.claude/.mxtk-lint-receipt"; rm -f "$P/.claude/.mxtk-lint-receipt.b" + out="$("$SYNC" "$P" 2>&1)" + [ ! -e "$P/.claude/lint-rules/$OLD" ] && [ -f "$P/.claude/lint-rules/$NEW" ] && ok "[$L] CRLF unedited copy migrated (no receipt match needed)" || bad "[$L] CRLF copy not migrated" "$out" + if [ -n "$EMPTY_OLD" ]; then + P="$(mk "t6b-$L")"; put_old "$P" "$EMPTY_OLD"; out="$("$SYNC" "$P" 2>&1)" + [ -f "$P/.claude/lint-rules/$NEW" ] && printf '%s' "$out" | grep -q "PROJECT_MODULES empty, so it inspected nothing" \ + && ok "[$L] empty-PROJECT_MODULES template migrated, with the will-report note" || bad "[$L] old empty template" "$out" + else + echo " note [$L] no git history: empty-PROJECT_MODULES case skipped" + fi + + echo "T7 [$L] doctor: hint when the baseline's mxcli differs, quiet when it matches" + D="$WORK/doc-$L"; mkdir -p "$D/.claude" "$WORK/home" + if [ "$L" = app ]; then mkdir -p "$D/app"; : > "$D/app/App.mpr"; else : > "$D/App.mpr"; fi + stub_mxcli "$D" v0.28.0 + dr() { HOME="$WORK/home" bash "$TK/bin/doctor.sh" --quick "$D" 2>&1; } + printf '{\n "counts": {},\n "total": 0,\n "mxcli_version": "v0.27.0"\n}\n' > "$D/.claude/lint-baseline.json" + out="$(dr)" + printf '%s' "$out" | grep -q "lint baseline was taken with mxcli v0.27.0; this is v0.28.0" \ + && printf '%s' "$out" | grep -qF -- "--update-baseline --reason \"mxcli v0.28.0 built-in rules\"" && ok "[$L] doctor prints the re-baseline hint" || bad "[$L] no hint" "$out" + printf '{\n "counts": {},\n "total": 0,\n "mxcli_version": "v0.28.0"\n}\n' > "$D/.claude/lint-baseline.json" + out="$(dr)" + printf '%s' "$out" | grep -q "lint baseline" && bad "[$L] hint printed when versions match" "$out" || ok "[$L] quiet when versions match" + printf '{\n "counts": {},\n "total": 0\n}\n' > "$D/.claude/lint-baseline.json" + out="$(dr)" + printf '%s' "$out" | grep -q "lint baseline records no mxcli version" && ok "[$L] says so when the baseline predates the field" || bad "[$L] no note for an unversioned baseline" "$out" + printf '%s' "$out" | grep -qF -- "--update-baseline --reason" && bad "[$L] unversioned baseline: re-baseline command printed with no evidence of a rise" "$out" \ + || ok "[$L] unversioned baseline: no re-baseline command, says the next one records it" +done + +echo "pass=$PASS fail=$FAIL" +[ "$FAIL" -eq 0 ]