Skip to content
Closed
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 @@ -17,6 +17,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are.

## Unreleased

- fix(routing, gate-check, obligation-check, project-bin): **`render-routing.sh --check` no longer reports a routed skill as UNROUTED at random — every `producer | grep -q` under `set -o pipefail` in the non-owned scripts is now a here-string.** `grep -q` exits on its first match; a producer still writing gets SIGPIPE (141) and pipefail turns "found" into "not found". The routed-path list is ~4.8 KB, two stdio writes, so the race was live: 15 of 100 calls of `routing_unrouted_skills` on the real table named a routed skill before the fix, 0 of 100 after. 28 sites in 12 scripts (skill-routing, obligation-check, triage-template, gate-check stage-0 sign-off and stage-4 closing rows, check-portability, `_common.sh` version match, design-portability, page-shell, close-task, check-sp-health, app-restart/test-stack-up `tasklist` probe). New fixture `tests/wave2/test-pipefail-grep-q.sh` makes the race deterministic (match first, 1 MB after it): fails 10 of 10 runs on master, passes after. CI flake seen in review — MendixMau
- learn(testing-shape, test-result-audit): **three test-strength rules, each with a denominator: refusals probed on the server, every undeclared 4xx a failure, one clean-database final run** ([#172](https://github.com/MendixMau/mxcli-project-toolkit/issues/172)). A field comparison of two builds against one frozen spec found the toolkit build checked permission refusals only in the client (a hidden menu item), logged unexpected 400/422 responses as information, and reached 20/20 by stitching three runs. `testing-shape.md` §7 gains three report lines (`N of N restricted pairs probed server-side`, `undeclared 4xx/5xx: 0 of M requests — each fails its step`, `one clean-DB run <id>: N of N scenarios incl. timers, not stitched`); `test-result-audit.md` gains check 2e, which carries the reasons and the field evidence and asks for those numbers. Both baseline skills stay inside the 80,000-word baseline budget (79,997): the reasons live in the on-demand audit skill, and two redundant sentences in `testing-shape.md` §4's verdict paragraphs were folded (the empty-set corollary is stated in the Trace row, `harness-architecture.md` and `report-schema.md`). The backing inbox note is trimmed to its unpromoted "Keep" items. — MendixMau
- learn(learned-detection-gaps, module-review): **two UI defects every model gate passes: a lazy-loaded combo box filter hides its preset, and a theme that pins row heights by position lets the next row cover an inserted widget** ([#299](https://github.com/MendixMau/mxcli-project-toolkit/issues/299)). Both passed check, mxbuild, lint and the scripted checks on a 127-module production app and were found only in the running app. Two register rows with the first oracle and the pre-build check (pluggable properties that change *when* data loads; grep the theme for fixed `height` on positional row classes before an insert), and two LOOK lines in `module-review.md` §4c with denominators (`N of N filters show their preset on first load`, `N of N widgets inserted into an existing container fully visible`). — jopterhorst
- fix(app-restart, test-stack-up): **a port held by another process before `run --local` is named and reported as "instrument blocked" (exit 4), never killed** ([#262](https://github.com/MendixMau/mxcli-project-toolkit/issues/262)). Studio Pro had started its own runtime on the app/admin ports a restart needed; `mxcli run --local` then failed with "Authentication failed" (the admin API answering was Studio Pro's), the next journey ran against Studio Pro's runtime, and four more runs were NOT RUN. `app-restart.sh` now checks the app port and the admin port (record's `adminPort`, else app + 10) before stopping anything — a holder outside the recorded process tree exits 4 with its name, pid and parent — and again after the stop, where anything still holding a port is by definition not ours. `test-stack-up.sh` runs the same check wherever it would tell you to start `run --local` (`--no-docker`, no Docker daemon, `--check` with a leftover record), on the record's ports, `RUN_LOCAL_APP_PORT`/`RUN_LOCAL_ADMIN_PORT`, or mxcli's defaults 8080/8090. Holder lookup: lsof, then ss, then netstat + tasklist on Windows; unnamed holders still block when nothing of ours is running. Fixtures: `test-app-restart.sh` +2 cases (14 assertions, all fail on the old script), `test-stack-up-local.sh` T7 (4 of 6 fail on the old script). — AlvinnYeo
Expand Down
4 changes: 2 additions & 2 deletions bin/check-portability.sh
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ while IFS= read -r f; do
case "$txt" in
*'$('*'<<'*)
_pc="$(printf '%s' "$txt" | sed -e 's/<<<//g' -e 's/\$(([^)]*))//g')"
if printf '%s' "$_pc" | grep -Eq '\$\([^)]*<<[^<]'; then
if grep -Eq -- '\$\([^)]*<<[^<]' <<<"$_pc"; then
report "$f" "$ln" "heredoc opened inside \$(...) — unparseable on bash 3.2" \
"Read the heredoc into a variable first (IFS= read -r -d '' var <<EOF ... EOF), then use \"\$var\" inside \$(...)."
fi ;;
Expand All @@ -151,7 +151,7 @@ while IFS= read -r f; do
*awk*)
# A backslash inside a $(...) substitution is the shell's, not the value's (e.g.
# -v ok="$(... | tr '\n' ' ')"): strip substitutions before looking.
if printf '%s' "$txt" | sed 's/\$([^)]*)//g' | grep -Eq -- '-v [A-Za-z_][A-Za-z_0-9]*="[^"]*\\'; then
if grep -Eq -- '-v [A-Za-z_][A-Za-z_0-9]*="[^"]*\\' <<<"$(printf '%s' "$txt" | sed 's/\$([^)]*)//g')"; then
report "$f" "$ln" "backslash inside an awk -v value (gawk rewrites escapes, mawk does not)" \
'Pass patterns without backslashes: [|] for a literal pipe, [.] for a dot, or read the regex from ENVIRON["re"].'
fi ;;
Expand Down
10 changes: 5 additions & 5 deletions bin/gate-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -825,13 +825,13 @@ check_stage_0() {
}' "$f")
if [ -z "$signer" ]; then
echo "FAIL|no non-empty 'Confirmed by:' line inside the '## Sign-off' section of $f — anything the user actually said is enough (\"confirmed in chat\", \"agreed, move on\"); it only has to not be the shipped placeholder"
elif printf '%s' "$signer" | grep -q '\[' && [ "$ENTRY_MODE" = "existing-app-change" ] \
elif grep -q '\[' <<<"$signer" && [ "$ENTRY_MODE" = "existing-app-change" ] \
&& [ -s "$PROJECT_DIR/analysis/app-report.json" ]; then
# Mapped and waiting for a change is a normal state in this mode (existing-app-change.md
# §"Map the app first"): there is nothing to sign off yet, so this is not-started, not wrong.
# Without it a parked project read "needs attention" forever (existing-app field run, 2026-09-22).
echo "PENDING|app mapped (analysis/app-report.html), waiting on the change — triage.md is signed off at Stage 0b, once the user says what to work on and what it touches is written ($f, ## Sign-off)"
elif printf '%s' "$signer" | grep -q '\['; then
elif grep -q '\[' <<<"$signer"; then
echo "FAIL|'Confirmed by:' still holds the shipped placeholder: \"$signer\" — replace it with whatever the user actually said (\"confirmed in chat 2026-08-20\" is fine; a full name is not required) ($f, ## Sign-off)"
else
echo "PASS|triage.md signed off by \"$signer\" [$f]"
Expand Down Expand Up @@ -1208,7 +1208,7 @@ stage4_closing_rows() {
rows="$(tr -d '\r' < "$plan" | grep -E '^\|[[:space:]]*[0-9]+[A-Za-z.]*[[:space:]]*\|')"
# Rows with a Kind cell are the plan's steps; a numbered question or decision table elsewhere
# in the file is not. Plans written before the Kind column keep every numbered row.
printf '%s\n' "$rows" | grep -qE '\|[[:space:]]*`?(BRIEF|BUILD|PROVE|RUN|HARNESS)`?[[:space:]]*\|' \
grep -qE -- '\|[[:space:]]*`?(BRIEF|BUILD|PROVE|RUN|HARNESS)`?[[:space:]]*\|' <<<"$rows" \
&& rows="$(printf '%s\n' "$rows" | grep -E '\|[[:space:]]*`?(BRIEF|BUILD|PROVE|RUN|HARNESS)`?[[:space:]]*\|')"
if [ -z "$rows" ]; then
echo "OK|no numbered rows (closing rows not counted)"
Expand All @@ -1221,7 +1221,7 @@ stage4_closing_rows() {
| sed -nE 's/.*verify-module\.sh[`"[:space:]]+([A-Za-z0-9_]+).*/\1/p' | sort -u)"
if [ -n "$declared" ]; then
for m in $declared; do
printf '%s\n' "$rows" | grep 'verify-module\.sh' | grep -qw -- "$m" || missing="$missing $m"
grep -qw -- "$m" <<<"$(grep 'verify-module\.sh' <<<"$rows")" || missing="$missing $m"
done
n="$(printf '%s\n' "$declared" | wc -l | tr -d ' ')"
else
Expand All @@ -1235,7 +1235,7 @@ stage4_closing_rows() {
local why=""
[ -n "$missing" ] && why="no \`verify-module.sh <Module>\` close row for:$missing"
[ "$ncoh" -lt "$need" ] && why="${why:+$why; }$ncoh of $need coherence-pass row(s) for $n modules"
printf '%s' "$last" | grep -qE 'gate-check\.sh[^|]*[[:space:]]5([^0-9]|$)' \
grep -qE -- 'gate-check\.sh[^|]*[[:space:]]5([^0-9]|$)' <<<"$last" \
|| why="${why:+$why; }the last numbered row is not \`gate-check.sh <project> 5\`"
if [ -n "$why" ]; then
echo "FAIL|build-plan.md is missing its closing rows (brd-to-build-plan.md, \"three closing rows\"): $why — add them; nothing that walks the plan does a step it does not list"
Expand Down
8 changes: 4 additions & 4 deletions bin/lib/obligation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ _ob_find() {
# A denominator is a first line that counts something out of something: "12 of 12 pages reviewed".
# Deliberately loose — the wording belongs to the skill, only the shape is mechanical.
_ob_has_denominator() {
head -c 4000 "$1" 2>/dev/null | tr -d '\r' \
| grep -qiE '[0-9]+[[:space:]]+of[[:space:]]+[0-9]+|[0-9]+[[:space:]]*/[[:space:]]*[0-9]+'
grep -qiE -- '[0-9]+[[:space:]]+of[[:space:]]+[0-9]+|[0-9]+[[:space:]]*/[[:space:]]*[0-9]+' \
<<<"$(head -c 4000 "$1" 2>/dev/null | tr -d '\r')"
}

# _ob_stale <project-root> <artifact> → prints "<hash>+<n>" and returns 0 if the mark has
Expand Down Expand Up @@ -501,8 +501,8 @@ mxtk_obligations_reverse_check() {
# skills — an MDL cookbook and a datagrid note among them — and a warning listing 46
# names is one nobody reads, which is the same as no check at all.
[ "$group" = "verify" ] || continue
printf '%s\n' "$named" | grep -qxF "$path" && continue
printf '%s' "$MXTK_OBLIGATIONS_NOROUTE" | awk '{print $1}' | grep -qxF "$name" && continue
grep -qxF -- "$path" <<<"$named" && continue
grep -qxF -- "$name" <<<"$(awk '{print $1}' <<<"$MXTK_OBLIGATIONS_NOROUTE")" && continue
unnamed="$unnamed $name"
done < "$tsv"
if [ -n "$unnamed" ]; then
Expand Down
4 changes: 2 additions & 2 deletions bin/lib/skill-routing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ routing_unrouted_skills() {
rel="${f#"$root"/}"
# Exact line match — a substring test would let skills/foo.md hide behind a routed
# skills/x-foo.md.
printf '%s\n' "$routed" | grep -qxF "$rel" && continue
grep -qxF -- "$rel" <<<"$routed" && continue
_routing_is_exempt "$rel" || echo "$rel"
done
}
Expand All @@ -491,7 +491,7 @@ routing_stale_exemptions() {
*)
if [ ! -e "$root/$ex" ]; then
echo "$ex — exempted file no longer exists"
elif printf '%s\n' "$routed" | grep -qxF "$ex"; then
elif grep -qxF -- "$ex" <<<"$routed"; then
echo "$ex — exempted AND routed; drop the exemption"
fi
;;
Expand Down
3 changes: 1 addition & 2 deletions bin/lib/triage-template.sh
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,5 @@ mxtk_triage_is_pristine() {
# more robust than pairing it with the comment opener.
#
# awk rather than `sed '/a/,/b/d'`: sed's range would not close on a one-line header.
awk '/MXTK-TRIAGE-STUB/{skip=1} skip{if(/-->/){skip=0}; next} {print}' "$f" \
| grep -q '{{[^}]*}}'
grep -q '{{[^}]*}}' <<<"$(awk '/MXTK-TRIAGE-STUB/{skip=1} skip{if(/-->/){skip=0}; next} {print}' "$f")"
}
2 changes: 1 addition & 1 deletion project-bin/_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ mxtk_version_in_name() {
v3=$(printf '%s' "$2" | cut -d. -f1-3)
[ -n "$v3" ] || return 1
re=$(printf '%s' "$v3" | sed 's/\./\\./g')
printf '%s\n' "$base" | grep -Eq "(^|[^0-9.])${re}(\.[0-9]+)?([^0-9.]|\.[^0-9]|\$)"
grep -Eq -- "(^|[^0-9.])${re}(\.[0-9]+)?([^0-9.]|\.[^0-9]|\$)" <<<"$base"
}

# mxtk_mxbuild_for_version <version> — an executable mxbuild whose install (Studio Pro
Expand Down
2 changes: 1 addition & 1 deletion project-bin/app-restart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ hs_str() { sed -n "s/^ \"$1\": *\"\(.*\)\",\{0,1\} *$/\1/p" "$2" 2>/dev/null |
pid_alive() {
[ -n "${1:-}" ] || return 1
case "$(mxtk_platform)" in
windows) tasklist //FI "PID eq $1" //NH 2>/dev/null | grep -q " $1 " ;; # mxcli.exe records a Windows pid
windows) grep -q " $1 " <<<"$(tasklist //FI "PID eq $1" //NH 2>/dev/null)" ;; # mxcli.exe records a Windows pid
*) kill -0 "$1" 2>/dev/null || return 1
# a zombie answers kill -0 until something reaps it; on Linux /proc says Z
case "$(sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1)" in Z) return 1 ;; esac ;;
Expand Down
16 changes: 8 additions & 8 deletions project-bin/check-design-portability.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ if [ -z "$ROOT_DECL" ]; then
printf ' check below is measured against an assumed 10px Atlas root rather than yours.\n\n'
ROOT_DECL="10px (ASSUMED — no deployment/web/theme.compiled.css)"
ROOT_IS_16=0
elif printf '%s' "$ROOT_DECL" | grep -qE '^ *(16px|100%|1rem|1em)'; then
elif grep -qE -- '^ *(16px|100%|1rem|1em)' <<<"$ROOT_DECL"; then
ROOT_IS_16=1
else
ROOT_IS_16=0
Expand All @@ -116,7 +116,7 @@ for f in $TARGETS; do
case "$txt" in *design-portability-ok:*) continue ;; esac

# 1. rem, against a root that is not 16px.
if [ "$ROOT_IS_16" -eq 0 ] && printf '%s' "$txt" | grep -qE '[0-9.]+rem'; then
if [ "$ROOT_IS_16" -eq 0 ] && grep -qE -- '[0-9.]+rem' <<<"$txt"; then
report "$f" "$ln" "rem unit, but the app root font-size is $ROOT_DECL" \
'Every rem here renders at that fraction of a 16px-root design. Author component sizes in px, or restate the root in the app stylesheet.'
fi
Expand All @@ -140,28 +140,28 @@ for f in $TARGETS; do
# a DOM fact you measured in a running app, recorded next to the tag.
case "$txt" in *'{'*)
sel="${txt%%\{*}"
if printf '%s' "$sel" | grep -qE '(^|[[:space:]>+~,])(b|i|table|thead|tbody|th|td|tr|ul|ol|li)([[:space:].:,>+~]|$)'; then
if grep -qE -- '(^|[[:space:]>+~,])(b|i|table|thead|tbody|th|td|tr|ul|ol|li)([[:space:].:,>+~]|$)' <<<"$sel"; then
report "$f" "$ln" "selector styles through an HTML element Mendix does not emit" \
'Mendix emits div/span/button. table|th|td|tr never appear (DataGrid2 is role="grid" divs). Restate as a class-only rule and give the class a widget carrier.'
fi
# 2b. A child-tag selector that DOES match, by accident, and outranks its own class.
# `.foo span` is 0-1-1 and beats the class-only `.foo-value` at 0-1-0, so the caption
# rule paints the headline. This is worse than a dead rule: it renders, wrongly.
if printf '%s' "$sel" | grep -qE '\.[A-Za-z_-][A-Za-z0-9_-]*[[:space:]]+(span|div|button)([[:space:].:,]|$)'; then
if grep -qE -- '\.[A-Za-z_-][A-Za-z0-9_-]*[[:space:]]+(span|div|button)([[:space:].:,]|$)' <<<"$sel"; then
report "$f" "$ln" "class-descendant-tag selector outranks your own class rules" \
'DYNAMICTEXT emits <span>, so this matches by accident at 0-1-1 and beats a class-only rule at 0-1-0. Give the inner widget its own class.'
fi
# 3. Row alternation driven from the DOM.
# Each listview row is wrapped in its own div.mx-dataview > div.mx-dataview-content,
# so EVERY row is :nth-of-type(1) in its own parent. Alternation must come from data.
if printf '%s' "$sel" | grep -qE ':nth-(of-type|child|last-child|last-of-type)'; then
if grep -qE -- ':nth-(of-type|child|last-child|last-of-type)' <<<"$sel"; then
report "$f" "$ln" "positional selector on Mendix-generated rows" \
'Each listview row sits in its own div.mx-dataview wrapper, so every row is :nth-of-type(1). Drive alternation from data via DynamicClasses.'
fi
# WARN: background without colour on a non-state selector.
if printf '%s' "$txt" | grep -qE 'background(-color)?[[:space:]]*:' \
&& ! printf '%s' "$txt" | grep -qE '(^|[;{[:space:]])color[[:space:]]*:' \
&& ! printf '%s' "$sel" | grep -qE ':(hover|focus|active|disabled|visited)'; then
if grep -qE -- 'background(-color)?[[:space:]]*:' <<<"$txt" \
&& ! grep -qE -- '(^|[;{[:space:]])color[[:space:]]*:' <<<"$txt" \
&& ! grep -qE -- ':(hover|focus|active|disabled|visited)' <<<"$sel"; then
warn "$f" "$ln" "paints a background but not a text colour" \
'Text colour is then inherited from whatever widget carries the class. A rule that paints a background should paint its text.'
fi
Expand Down
2 changes: 1 addition & 1 deletion project-bin/check-page-shell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ for f in $TARGETS; do
# it, and nothing in a default page tree does.
col="$(grep -oE 'main\{[^}]*max-width:[0-9]+px' "$wf" | grep -oE '[0-9]+px' | head -1)"
if [ -n "$col" ]; then
if ! printf '%s' "$body" | grep -qE "Class:[^)]*(page-column|page-body|kt-page|container|max-w|measure)"; then
if ! grep -qE -- "Class:[^)]*(page-column|page-body|kt-page|container|max-w|measure)" <<<"$body"; then
report "$f:$ln" "page $page is built full-bleed; its wireframe draws a $col column" \
"Wrap the page body in the project's page-column container (ds.css supplies 'margin: 0 auto'; the wireframe narrows it to $col). Uncapped, this renders at the full layout region — measured at 1360px on a 900px design."
fi
Expand Down
2 changes: 1 addition & 1 deletion project-bin/check-sp-health.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ if [[ -z "$HEAVY_LINE" ]]; then
exit 3
fi

if echo "$HEAVY_LINE" | grep -qE "$IDLE_PATTERN"; then
if grep -qE -- "$IDLE_PATTERN" <<<"$HEAVY_LINE"; then
echo "healthy: idle, parked in a normal wait call — not hung"
echo " $HEAVY_LINE"
rm -f "$SAMPLE_FILE"
Expand Down
2 changes: 1 addition & 1 deletion project-bin/close-task.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ echo
DRIFT_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/done-drift-check.sh"
if [ -x "$DRIFT_SCRIPT" ]; then
drift_out=$("$DRIFT_SCRIPT" "$ROOT" 2>/dev/null)
if printf '%s\n' "$drift_out" | grep -qE '^- `|may be stale'; then
if grep -qE -- '^- `|may be stale' <<<"$drift_out"; then
printf '%s\n' "$drift_out" | tail -n +3
echo
owed=1
Expand Down
2 changes: 1 addition & 1 deletion project-bin/test-stack-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ pid_alive() {
[ -n "$1" ] || return 1
case "$(mxtk_platform)" in
# mxcli.exe records a Windows pid; Git Bash's kill only knows MSYS pids.
windows) tasklist //FI "PID eq $1" //NH 2>/dev/null | grep -q " $1 " ;;
windows) grep -q " $1 " <<<"$(tasklist //FI "PID eq $1" //NH 2>/dev/null)" ;;
*) kill -0 "$1" 2>/dev/null ;;
esac
}
Expand Down
2 changes: 1 addition & 1 deletion tests/wave2/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ for t in "$DIR"/test-*.sh; do

subject=""
if [ -n "$subj_name" ]; then
for cand in "$TOOLKIT/bin/$subj_name" "$TOOLKIT/project-bin/$subj_name" \
for cand in "$TOOLKIT/bin/$subj_name" "$TOOLKIT/bin/lib/$subj_name" "$TOOLKIT/project-bin/$subj_name" \
"$TOOLKIT/skills/$subj_name" "$TOOLKIT/claude-hooks/hooks/$subj_name" \
"$TOOLKIT/$subj_name"; do
[ -f "$cand" ] && { subject="$cand"; break; }
Expand Down
Loading
Loading