Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m

## Unreleased

- process(testing): **unit tests are chosen per flow, not per module: a module brief lists `Unit flows:` (one `- ACT_X: reason` line each) and `affected-tests.sh` runs only the listed flows the change touched.** Follow-up to #417 on the maintainer's policy that unit-testing everything is overkill (logic and integrations only, UI journeys catch the rest). Module done runs the `--module` modules' listed flows only; a list wins for its module even under `Unit tests default: yes`; bullets (`-`, `*`, `+`, `1.`, `1)`) may be separated by blank lines, carry `(note)` or `Module.` forms, or sit inline (`Unit flows: A: money, B: rest`), a `## Unit flows` heading works with or without a colon, a line that cannot be read warns with its text, and a Unit flows block that yields no flow warns; under default yes a test is dropped only when every module it names has a list that excludes it; CRLF briefs are read; `Unit tests: yes` or `Unit modules:` with no list keeps the module-level selection; a flow listed without a reason, with no test, with a different casing than the model, or not in the model (`SHOW MICROFLOWS IN`), and a module the model lacks, warn. Test: wave2 `test-affected-tests.sh` L10 (35 checks). Baseline unchanged at 75,194 words (budget 80,000) — MendixMau
- fix(sync-project, doctor): **a project whose `.claude/settings.json` lacks the `look-ledger.sh seen` hook now gets it back on the next sync, and `doctor.sh` warns when it is missing.** In a lab project the look ledger read "0 of 31 pages seen" while 22 of them had screenshots: `install-claude-permissions.sh` (the only writer of the hook) had never run there, `sync-project.sh` only printed a warning in a long report, and `doctor.sh` checked the allow-list but not the two hooks, so `seen.tsv` stayed empty and any gate built on the ledger would have stalled the build. `install-claude-permissions.sh` gains `--hooks-only` (add the SessionStart and PostToolUse hooks, touch no allow, deny or ask entry, prune nothing, write nothing to `settings.local.json`); `sync-project.sh` runs it when the hooks are missing (`--dry-run` says what it would add); `doctor.sh` names the missing hook and the fix. `tests/wave2/test-look-ledger-hook-repair.sh` reproduces the gap, then proves detect, repair, idempotence and that a Read of a screenshot writes a seen row. Review follow-up: sync repairs only a project that installed the profile before (sidecar with a non-empty allow, or `.mxtk/permission-profile`; checked by `--consent-check`), otherwise it WARNs with the one-line `--hooks-only` command; `.mxtk/no-hook-repair` is the documented opt-out (sync and doctor); `--hooks-only` creates no empty allow/deny, leaves no `.bak`, keeps the file's indent and non-ASCII, and leaves a hand-formatted file untouched (exit 3, lines printed). — lab project field run (night 2026-10-16)
- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free `PBUG-NN`, project-local so it never collides with the toolkit's BUG-n; the public number is the issue; the one-line instruction sits in learned-detection-gaps.md). The script normalises Unicode (NFKC, zero-width and lookalike letters, names split by a line break), redacts every title and body (home and spaced paths, user and host names, emails, IPv4/IPv6, tokens, key=value, JSON and `--flag value` secrets, hex tokens, AWS keys, any dotted hostname, server names from config files, URLs other than the two repos matched exactly and cut to the repo prefix plus /issues/N or /pull/N, a URL scheme from a fixed list (jdbc, postgres, mysql, mongodb, redis, amqp, ssh, git and the like) stays and loses the host, any other scheme becomes `<url>`, the project's .mpr, module, entity, attribute, page and microflow names (distinctive ones inside identifiers; ordinary-word and short names only as whole words or segments, never inside mxcli verbs, toolkit file names or other words), Authorization and cookie header values, `$LEAKGUARD_DENY` as a regex plus the project's .leakguard-deny), builds the versions line from version-shaped strings only, scans again with the same matcher and refuses a bug that still looks unsafe: a refused bug gets no duplicate search, no URL and no text in `--links`/`--json`. Default is a dry run with likely duplicates; posting needs `--file PBUG-NN` or `--all` and a y/N per bug read from /dev/tty only (no stdin test hook; a pty cannot be told from a human, so the shipped permissions deny `file-bugs.sh` and `file-bugs.py` with `--file` or `--all`, abbreviations are refused, and a human runs posting with `!`), and a deny list must exist and be valid (an invalid pattern counts as missing) or posting, `--links` and `--json` are refused, passes `--repo= --title= --body-file=` to gh, keeps the log's file mode; `--links` caps title and body under the URL limit. `tests/wave2/test-file-bugs.sh` covers each case with a stubbed gh and a pty helper — MendixMau
- learn(unit-test): **new on-demand skill for writing and running a microflow unit test (`.test.mdl`, `mxcli test`) whose assertion is live: what is worth a test and what is not, equality-only `@expect` (BUG-LOCAL-30), no file-level header (BUG-LOCAL-29), `--list` count against `/` separators before any run, `test-scratch.sh` so the live app survives (#278), the BUG-157 after-startup trap, a positive control that must fail, and a four-line report.** It gathers the how that was spread over `testing-shape.md`, `rest-between-mendix-apps.md` and the bug log into one file a user can ask for outside the pipeline; whether and when unit tests run stays with the project's test strategy. Routed ondemand for lead, mdl, test and review; one row in `testing-shape.md`'s owner table. Field evidence: projects on mxcli v0.19 to v0.27, Mendix 11.13 to 11.15, 2026-08 to 2026-10 — MendixMau
Expand Down
142 changes: 136 additions & 6 deletions project-bin/affected-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@
# one `unit off` line and NEVER a `run ... .test.mdl` row. `Unit modules:` takes names split
# by comma or space; `#` and `( ... )` comments, a leading `- ` bullet, any case and `none`
# are read; a name that matches no module warns on stderr.
# 4. PER FLOW (2026-10-16, after #417). A module brief may list the flows that get unit tests:
# Unit flows:
# - ACT_Order_CalculateTotal: money calculation
# (a bullet per flow, `Name: reason`, `Name (note): reason` or inline `Unit flows: A, B`; blank
# lines between bullets are fine, a heading or any other line ends the list; `Name` may be
# qualified `Module.Name`; a `<placeholder>` is ignored; a line that cannot be read warns).
# A module with a list is flipped by it and selected per flow: at the change moment (scope
# affected) only the tests naming a LISTED flow the change touched (the flow itself, or a caller
# within --depth; a data change in the module touches every listed flow); at the module moment
# only for the --module/subject modules; at deploy every test runs. A test is mapped to its flow
# as every selection here is: the test file names `Module.Flow` (grep -i -w -F). A flow listed
# without a reason warns; a listed flow no test names warns (the test is owed); a name
# `SHOW MICROFLOWS IN <M>` does not list, a different casing, or a module the model lacks warns
# (best effort: skipped under --no-model or when mxcli cannot run). `Unit tests: yes` or
# `Unit modules:` with NO list keeps the module-level selection. Under `Unit tests default: yes`
# a module with a list is narrowed to it, but a test is dropped only when EVERY module it names has a list that excludes it; a test naming any module without a list stays (that module is on).
# Journeys are not affected by any of this.
#
# HOW THE SELECTION IS MADE (judgement-free facts only; skills-over-scripts.md):
Expand Down Expand Up @@ -146,6 +162,7 @@ fi

# ── Unit gate: off unless the project default is yes, the row says so, or a module is flipped ──
UNIT_DEFAULT="$(awk '/^[ \t*]*Unit tests default:/ { v = $0; sub(/^[^:]*:/, "", v); gsub(/[ \t*`]/, "", v); print tolower(v); exit }' PROJECT.md 2>/dev/null)"
[ -d "$WORK" ] && : > "$WORK/unitflows"
FLIPPED="$(awk 'tolower($0) ~ /^[ \t*>-]*unit modules:/ { v = $0; sub(/^[^:]*:/, "", v); sub(/#.*/, "", v); gsub(/\([^)]*\)/, "", v)
gsub(/[ \t*`,]+/, " ", v); n = split(v, a, " "); o = ""
for (i = 1; i <= n; i++) if (tolower(a[i]) != "none") o = o " " a[i]
Expand All @@ -158,13 +175,53 @@ for _b in architecture/modules/*/module-brief.md architecture/modules/*-brief.md
/^\|/ { split($0, c, "|"); k = tolower(c[2]); v = tolower(c[3]); gsub(/[ \t*`]/, "", v)
if (k ~ /^[ \t]*unit[ \t]*\(/ && v ~ /^yes/) f = 1 }
END { exit !f }' "$_b"; then FLIPPED="$FLIPPED $_m"; fi
# Unit flows: a bullet list `- [Module.]Flow[ (note)][: reason]` under a `Unit flows:` line, or inline
# `Unit flows: A, B` (comma-split, each may carry `: reason`), `-`/`*`/`+`/numbered bullets, or a `## Unit flows`
# heading, optionally `(note)`. Blank lines are skipped; an indented non-bullet line continues the previous
# reason; the block ends at the next heading or an unindented `Word words:` field; any other line warns.
# A line in the block that cannot be read warns with its text, never dropped silently.
awk -v m="$_m" -v b="$_b" '
function trim(t) { gsub(/^[ \t]+|[ \t]+$/, "", t); return t }
function warn(t) { print "warning: Unit flows in " b ": cannot read \"" t "\"; write `- ACT_Name: reason`" > "/dev/stderr" }
function item(l, nm, rest, par, r, i) {
gsub(/`|\*\*/, "", l); l = trim(l)
if (l == "") return
if (substr(l, 1, 1) == "<") { seen++; return }
if (!match(l, /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?/)) { warn(l); return }
nm = substr(l, 1, RLENGTH); rest = trim(substr(l, RLENGTH + 1)); par = ""
if (substr(rest, 1, 1) == "(") { i = index(rest, ")"); if (i) { par = substr(rest, 2, i - 2); rest = trim(substr(rest, i + 1)) } }
if (rest == "") r = par
else if (substr(rest, 1, 1) == ":") { r = trim(substr(rest, 2)); if (r == "") r = par }
else if (rest ~ /^-[ \t]/) r = trim(substr(rest, 2))
else { warn(l); return }
seen++; print (index(nm, ".") ? nm : m "." nm) "\t" r
}
function flush() { if (pend != "") item(pend); pend = "" }
{ sub(/\r$/, "") }
# header: `Unit flows` (any heading level or plain), optional `(note)`, optional `:` and inline items
tolower($0) ~ /^[ \t*>#-]*unit flows[ \t*]*(\([^)]*\))?[ \t*]*(:.*)?$/ {
flush(); on = 1; hdr = 1; v = $0; sub(/^[^:]*(\([^)]*\))?[^:]*:?/, "", v)
if (index($0, ":") == 0 || $0 ~ /^[^:]*\([^)]*:/) v = ""
gsub(/`|\*\*/, "", v); v = trim(v)
if (v != "") { k = split(v, a, ","); for (j = 1; j <= k; j++) item(a[j]) }
next }
!on { next }
/^[ \t]*$/ { next }
/^[ \t]*([-*+]|[0-9]+[.)])[ \t]/ { flush(); l = $0; sub(/^[ \t]*([-*+]|[0-9]+[.)])[ \t]+/, "", l); pend = l; next }
/^[ \t]+[^ \t]/ && !/^[ \t]*#/ { if (pend != "") pend = pend " " trim($0); next }
/^[ \t]*#/ { flush(); on = 0; next }
/^[A-Za-z][A-Za-z]*([ \t]+[A-Za-z]+)*[ \t]*:/ { flush(); on = 0; next }
{ flush(); warn(trim($0)); next }
END { flush(); if (hdr && !seen) print "warning: Unit flows in " b " looks like a Unit flows block but lists no flow; write `- ACT_Name: reason` lines under `Unit flows:`" > "/dev/stderr" }' "$_b" >> "$WORK/unitflows"
done
# a module with a Unit flows list is flipped by it
for _lm in $(cut -f1 "$WORK/unitflows" | sed 's/\..*//' | sort -u); do FLIPPED="$FLIPPED $_lm"; done
UNIT_GATED=0; UNIT_OFF_WHY=""
if [ "$MOMENT" != deploy ] && [ "${UNIT_DEFAULT#yes}" = "$UNIT_DEFAULT" ]; then
if [ -n "$UNIT_DEFAULT" ]; then _dw="PROJECT.md Unit tests default: $UNIT_DEFAULT"
else _dw="PROJECT.md has no 'Unit tests default:' line, treated as no"; fi
if [ -n "$FLIPPED" ]; then UNIT_GATED=1; [ "$U" = none ] && U="$DU"
else U=none; UNIT_OFF_WHY="($_dw; flip a module: PROJECT.md 'Unit modules: <M>' or 'Unit tests: yes' in its brief)"; fi
else U=none; UNIT_OFF_WHY="($_dw; flip a module: PROJECT.md 'Unit modules: <M>', or 'Unit tests: yes' or a 'Unit flows:' list in its brief)"; fi
FROM="$FROM; unit: $_dw"
fi
out "strategy $MOMENT unit=$U journeys=$J ($FROM)"
Expand Down Expand Up @@ -298,22 +355,95 @@ if [ -n "$FLIPPED" ]; then
for m in $FLIPPED; do
_k="$(awk -v m="$m" 'tolower($0) == tolower(m) { print; exit }' "$WORK/known")"
if [ -n "$_k" ]; then _canon="$_canon $_k"
else _canon="$_canon $m"; echo "warning: Unit modules names '$m', which matches no module in the tests, briefs or this change" >&2; fi
else _canon="$_canon $m" # a module named only by a Unit flows list is checked against the model below
awk -F'\t' -v m="$m" 'tolower(substr($1, 1, index($1, ".") - 1)) == tolower(m) { c = 1 } END { exit !c }' "$WORK/unitflows" ||
echo "warning: Unit modules names '$m', which matches no module in the tests, briefs or this change" >&2; fi
done
FLIPPED="$_canon"
fi
select_unit "$U" > "$WORK/unit.sel"
canon_mod() { awk -v m="$1" 'tolower($0) == tolower(m) { print; f = 1; exit } END { if (!f) print m }' "$WORK/known" 2>/dev/null || echo "$1"; }
# list_sel <module>: the tests of that module's listed flows at the current scope; warns on an owed test
list_sel() {
case "$U" in none) return ;; module) grep -qixF "$1" "$WORK/modules" || return ;; esac
awk -F'\t' -v m="$1" 'tolower(substr($1, 1, index($1, ".") - 1)) == tolower(m) { print $1 "\t" $2 }' "$WORK/unitflows" |
while IFS=' ' read -r _fl _why; do
if [ "$U" = affected ]; then grep -qixF "$_fl" "$WORK/touched" || grep -qixF "$1" "$WORK/datamods" || continue; fi
_hit=0
while IFS= read -r f; do
grep -q -i -w -F "$_fl" "$f" 2>/dev/null && { _hit=1; printf '%s\tnames listed flow %s%s\n' "$f" "$_fl" "${_why:+ ($_why)}"; }
done < "$WORK/tests"
[ "$_hit" = 1 ] || echo "warning: Unit flows lists $_fl, which no .test.mdl names; its unit test is owed" >&2
done
}
names_for_unit | sort -u > "$WORK/touched"
modules_for_unit > "$WORK/datamods"
LISTMODS=""
if [ -s "$WORK/unitflows" ] && [ "$MOMENT" != deploy ]; then
for m in $(cut -f1 "$WORK/unitflows" | sed 's/\..*//' | sort -u); do LISTMODS="$LISTMODS
$(canon_mod "$m")"; done
LISTMODS="$(printf '%s\n' "$LISTMODS" | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/ $//')"
awk -F'\t' '$2 == "" { print "warning: Unit flows lists " $1 " without a reason; one line why it needs a unit test (logic or integration)" > "/dev/stderr" }' "$WORK/unitflows"
# unknown flow names: best effort, SHOW MICROFLOWS IN <M> (skipped without a model)
if [ "$NO_MODEL" != 1 ]; then
_mx="${MXCLI:-$(find_project_mxcli 2>/dev/null || true)}"; _mpr="$(find_mpr 2>/dev/null || true)"
if [ -n "$_mx" ] && [ -x "$_mx" ] && [ -n "$_mpr" ]; then
_lm="$(cut -f1 "$WORK/unitflows" | sed 's/\..*//' | sort -u)"
_q="$(printf '%s\n' "$_lm" | sed 's/.*/SHOW MICROFLOWS IN &/' | awk '{ printf "%s%s", (NR > 1 ? "; " : ""), $0 }')"
: > "$WORK/mf.missing"
if ! "$_mx" -p "$_mpr" -c "$_q" > "$WORK/mf.out" 2>/dev/null; then
# one call per module: missing = its error says so, or it fails while another module answers
: > "$WORK/mf.out"; : > "$WORK/mf.fail"; _okn=0
for _m in $_lm; do
if "$_mx" -p "$_mpr" -c "SHOW MICROFLOWS IN $_m" >> "$WORK/mf.out" 2> "$WORK/mf.one"; then _okn=$((_okn+1))
elif grep -qiE 'not found|does not exist|no such module|unknown module' "$WORK/mf.one"; then echo "$_m" >> "$WORK/mf.missing"
else echo "$_m" >> "$WORK/mf.fail"; fi
done
[ "$_okn" -gt 0 ] && cat "$WORK/mf.fail" >> "$WORK/mf.missing"
while IFS= read -r _m; do echo "warning: module $_m not found in the model (Unit flows lists it)" >&2; done < "$WORK/mf.missing"
fi
if grep -q '^| ' "$WORK/mf.out"; then
awk -F'|' '/^\| *[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]* *\|/ { n = $2; gsub(/ /, "", n); print n }' "$WORK/mf.out" | sort -u > "$WORK/mf.names"
cut -f1 "$WORK/unitflows" | sort -u | while IFS= read -r _f; do
grep -qixF "${_f%%.*}" "$WORK/mf.missing" && continue
grep -qxF "$_f" "$WORK/mf.names" && continue
_real="$(grep -ixF "$_f" "$WORK/mf.names" | head -1)"
if [ -n "$_real" ]; then echo "warning: Unit flows lists '$_f' but the model has '$_real' (casing differs)" >&2
else echo "warning: Unit flows names '$_f', which is not a microflow in the model" >&2; fi
done
fi
fi
fi
fi
if [ "$UNIT_GATED" = 1 ]; then # default no, some modules flipped: only those modules' tests
: > "$WORK/unit.gated"
for m in $FLIPPED; do
while IFS=' ' read -r f w; do grep -qE "(^|[^A-Za-z0-9_])$m\." "$f" 2>/dev/null && printf '%s\t%s\n' "$f" "$w"; done < "$WORK/unit.sel"
done | awk -F'\t' '!seen[$1]++' > "$WORK/unit.gated"
mv "$WORK/unit.gated" "$WORK/unit.sel"
case " $LISTMODS " in
*" $m "*) list_sel "$m" >> "$WORK/unit.gated" ;; # per flow: the listed flows' tests only
*)
while IFS=' ' read -r f w; do grep -qE "(^|[^A-Za-z0-9_])$m\." "$f" 2>/dev/null && printf '%s\t%s\n' "$f" "$w"; done < "$WORK/unit.sel" >> "$WORK/unit.gated" ;;
esac
done
awk -F'\t' '!seen[$1]++' "$WORK/unit.gated" > "$WORK/unit.sel"
elif [ -n "$LISTMODS" ]; then # default yes: a module with a list is narrowed to it, the rest stay on
: > "$WORK/unit.ls"; for m in $LISTMODS; do list_sel "$m" >> "$WORK/unit.ls"; done
while IFS=' ' read -r f w; do
# governed by a list only when EVERY module the test names is a listed module; any other module is on, keep the test
_gov="$(awk -v lm="$LISTMODS" 'BEGIN { n = split(tolower(lm), L, " "); for (i = 1; i <= n; i++) isl[L[i]] = 1 }
{ s = $0; while (match(s, /(^|[^A-Za-z0-9_.$])[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_]/)) {
q = substr(s, RSTART, RLENGTH); sub(/^[^A-Za-z_]/, "", q); sub(/\..*/, "", q); q = tolower(q)
if (q in isl) hl = 1; else if (q != "system") ho = 1
s = substr(s, RSTART + RLENGTH) } }
END { print (hl && !ho) ? 1 : 0 }' "$f" 2>/dev/null)"
[ "$_gov" = 1 ] || printf '%s\t%s\n' "$f" "$w"
done < "$WORK/unit.sel" > "$WORK/unit.new"
cat "$WORK/unit.ls" >> "$WORK/unit.new"
awk -F'\t' '!seen[$1]++' "$WORK/unit.new" > "$WORK/unit.sel"
fi
select_journeys "$J" > "$WORK/journey.sel"
if [ -n "$UNIT_OFF_WHY" ]; then out "unit off $UNIT_OFF_WHY"
elif [ -s "$WORK/unit.sel" ]; then sed 's/^/unit /' "$WORK/unit.sel"
else out "unit none scope $U: $( [ "$U" = none ] && echo 'strategy says none' || echo "0 of $(wc -l < "$WORK/tests" | tr -d ' ') .test.mdl file(s) match${FLIPPED:+ (unit on only for:$FLIPPED)}")"; fi
else out "unit none scope $U: $( [ "$U" = none ] && echo 'strategy says none' || echo "0 of $(wc -l < "$WORK/tests" | tr -d ' ') .test.mdl file(s) match${FLIPPED:+ (unit on only for:$FLIPPED${LISTMODS:+; per listed flow in:$LISTMODS})}")"; fi
if [ -s "$WORK/journey.sel" ]; then sed 's/^/journey /' "$WORK/journey.sel"
else out "journey none scope $J: $( [ "$J" = none ] && echo 'strategy says none' || echo "0 of $(wc -l < "$WORK/journeys" | tr -d ' ') journey file(s) in $JDIR/ and tests/e2e/ match")"; fi

Expand Down
Loading
Loading