diff --git a/CHANGELOG.md b/CHANGELOG.md index 20342404..f1a67c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 : 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 diff --git a/bin/check-portability.sh b/bin/check-portability.sh index 5ab3a874..444dee0f 100755 --- a/bin/check-portability.sh +++ b/bin/check-portability.sh @@ -131,7 +131,7 @@ while IFS= read -r f; do case "$txt" in *'$('*'<<'*) _pc="$(printf '%s' "$txt" | sed -e 's/<<\` 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 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" diff --git a/bin/lib/obligation-check.sh b/bin/lib/obligation-check.sh index af7b25ac..1370417a 100755 --- a/bin/lib/obligation-check.sh +++ b/bin/lib/obligation-check.sh @@ -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 → prints "+" and returns 0 if the mark has @@ -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 diff --git a/bin/lib/skill-routing.sh b/bin/lib/skill-routing.sh index 5dac2636..c0f3dbd8 100644 --- a/bin/lib/skill-routing.sh +++ b/bin/lib/skill-routing.sh @@ -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 } @@ -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 ;; diff --git a/bin/lib/triage-template.sh b/bin/lib/triage-template.sh index 75d4f85d..c26084bb 100644 --- a/bin/lib/triage-template.sh +++ b/bin/lib/triage-template.sh @@ -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")" } diff --git a/project-bin/_common.sh b/project-bin/_common.sh index 3a58af7a..6393967f 100755 --- a/project-bin/_common.sh +++ b/project-bin/_common.sh @@ -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 — an executable mxbuild whose install (Studio Pro diff --git a/project-bin/app-restart.sh b/project-bin/app-restart.sh index b18ffe19..6e509929 100755 --- a/project-bin/app-restart.sh +++ b/project-bin/app-restart.sh @@ -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 ;; diff --git a/project-bin/check-design-portability.sh b/project-bin/check-design-portability.sh index 1bbacb70..010fcf65 100755 --- a/project-bin/check-design-portability.sh +++ b/project-bin/check-design-portability.sh @@ -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 @@ -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 @@ -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 , 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 diff --git a/project-bin/check-page-shell.sh b/project-bin/check-page-shell.sh index 54221a2c..54c416b9 100755 --- a/project-bin/check-page-shell.sh +++ b/project-bin/check-page-shell.sh @@ -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 diff --git a/project-bin/check-sp-health.sh b/project-bin/check-sp-health.sh index fd7c279c..9ac674b1 100755 --- a/project-bin/check-sp-health.sh +++ b/project-bin/check-sp-health.sh @@ -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" diff --git a/project-bin/close-task.sh b/project-bin/close-task.sh index 3aae8430..378597a1 100755 --- a/project-bin/close-task.sh +++ b/project-bin/close-task.sh @@ -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 diff --git a/project-bin/test-stack-up.sh b/project-bin/test-stack-up.sh index 17af56ad..69316327 100755 --- a/project-bin/test-stack-up.sh +++ b/project-bin/test-stack-up.sh @@ -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 } diff --git a/tests/wave2/run-all.sh b/tests/wave2/run-all.sh index ac6627f9..14f2bc8a 100755 --- a/tests/wave2/run-all.sh +++ b/tests/wave2/run-all.sh @@ -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; } diff --git a/tests/wave2/test-pipefail-grep-q.sh b/tests/wave2/test-pipefail-grep-q.sh new file mode 100755 index 00000000..15088bcb --- /dev/null +++ b/tests/wave2/test-pipefail-grep-q.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Fixture for the `producer | grep -q` + `set -o pipefail` false negative. +# +# usage: test-pipefail-grep-q.sh /path/to/bin/lib/skill-routing.sh +# +# The defect: `grep -q` exits on its first match. If the producer on the left of the pipe +# still has output to write, that write hits a closed pipe, the producer dies of SIGPIPE +# (status 141), and under `set -o pipefail` the PIPELINE reports failure — so "found it" +# reads as "not found". Whether the producer had finished writing before grep exited is a +# scheduling race, which is why `bin/render-routing.sh --check` intermittently reported +# `UNROUTED skills/iterative-build-loop.md` although its row exists (the routed-path list is +# ~4.8 KB, more than one stdio buffer, so `printf` writes it in two chunks). Measured on the +# real table before the fix: 15 of 100 calls of routing_unrouted_skills named a routed skill. +# +# The fix is a here-string (`grep -q -- "$x" <<<"$list"`): bash writes the whole input +# before grep starts, so nothing can be killed mid-write. This fixture makes the race +# deterministic instead of probabilistic: the matching line comes FIRST and is followed by +# ~1 MB of padding — far beyond the 64 KB pipe buffer — so on the old code the producer is +# always still writing when grep exits. +# +# T1 routing_unrouted_skills: a routed skill must not be reported unrouted. +# T2 routing_stale_exemptions: an exempted-AND-routed file must still be reported. +# T3 static guard: the scripts fixed alongside (all run under pipefail, directly or via the +# script that sources them) carry no `... | grep -q` pipeline again. + +set -uo pipefail + +LIB="${1:?usage: test-pipefail-grep-q.sh /path/to/bin/lib/skill-routing.sh}" +case "$LIB" in /*) ;; *) LIB="$PWD/$LIB" ;; esac +TK="$(cd "$(dirname "$LIB")/../.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pipefail-grepq.XXXXXX")" +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/^/ FAIL-detail: /'; } + +# A scratch root with one skill, and a routing table whose FIRST row routes it, then ~16,000 +# padding rows (about 1 MB of routed paths). +ROOT="$WORK/root" +mkdir -p "$ROOT/skills" +printf '# Early\n' > "$ROOT/skills/early.md" +TSV="$WORK/skill-routing.tsv" +{ + printf 'Early skill\tskills/early.md\tany\tall\t5\tsituational\tverify\n' + i=0 + while [ "$i" -lt 16000 ]; do + printf 'Padding row %d\tskills/padding/a-long-padding-path-to-overflow-the-pipe-buffer-%05d.md\tany\tall\t5\tsituational\tverify\n' "$i" "$i" + i=$((i+1)) + done +} > "$TSV" +EMPTY_EX="$WORK/exempt-empty.tsv"; : > "$EMPTY_EX" +STALE_EX="$WORK/exempt-stale.tsv"; printf 'skills/early.md\tfixture: exempted and routed\n' > "$STALE_EX" + +# Sourced in a subshell with pipefail on, as bin/render-routing.sh runs it (set -euo pipefail). +run_lib() { # + ( set -o pipefail + MXTK_ROUTING_TSV="$TSV" MXTK_ROUTING_EXEMPT="$1" + # shellcheck source=/dev/null + . "$LIB" + "$2" "$ROOT" ) +} + +echo "== T1: a routed skill is not reported UNROUTED (match first, 1 MB after it) ==" +bad1=0 +for n in 1 2 3; do + out="$(run_lib "$EMPTY_EX" routing_unrouted_skills 2>&1)" + [ -z "$out" ] || { bad1=1; bad "run $n: routed skill reported unrouted" "$out"; break; } +done +[ "$bad1" = 0 ] && ok "routing_unrouted_skills is empty on 3 of 3 runs" + +echo "== T2: an exempted-AND-routed file is still reported (same race, opposite sign) ==" +out="$(run_lib "$STALE_EX" routing_stale_exemptions 2>&1)" +case "$out" in + *'skills/early.md — exempted AND routed'*) ok "stale exemption reported" ;; + *) bad "stale exemption missed" "$out" ;; +esac + +echo "== T3: no '| grep -q' pipeline left in the scripts fixed for this defect ==" +# gate-check.sh's `find_artifact ... | grep -q .` lines are exempt: find_artifact ends in +# `-print -quit`, so its producer writes at most one line and is finished before grep exits. +hits="" +for f in bin/lib/skill-routing.sh bin/lib/obligation-check.sh bin/lib/triage-template.sh \ + bin/gate-check.sh bin/check-portability.sh \ + project-bin/_common.sh project-bin/check-design-portability.sh project-bin/check-page-shell.sh \ + project-bin/close-task.sh project-bin/check-sp-health.sh project-bin/app-restart.sh \ + project-bin/test-stack-up.sh; do + [ -f "$TK/$f" ] || { hits="$hits +$f: missing"; continue; } + h="$(grep -nE '(^|[^|])\|[[:space:]]*grep[[:space:]]+(-[A-Za-z]*q|[^|]*[[:space:]]-[A-Za-z]*q)' "$TK/$f" \ + | grep -vE '^[0-9]+:[[:space:]]*#' | grep -v 'find_artifact' | sed "s|^|$f:|")" + [ -n "$h" ] && hits="$hits +$h" +done +if [ -z "$hits" ]; then ok "0 pipelines into grep -q across 12 scripts" +else bad "pipeline into grep -q found (use a here-string: grep -q -- PAT <<<\"\$x\")" "${hits#?}"; fi + +echo +echo "pipefail-grep-q: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]