From c8baa25e1c1dde7e31971f0f601598257703ad5d Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:16:06 -0300 Subject: [PATCH 01/14] fix(coherence): extrator de exit codes ciente de aspas duplas e de # em string O strip de comentarios rodava antes da maquina de estados, entao um '#' dentro de aspas simples cortava a linha e desbalanceava o estado. Agora comentarios e strings saem numa unica varredura de caracteres, com estado de aspas simples e duplas atravessando linhas. Auto-teste com script sintetico em heredoc. --- scripts/coherence_test.sh | 77 ++++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index af2b915..9be25ec 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -64,37 +64,70 @@ tree_scripts() { ' "$1" | grep -oE '[A-Za-z0-9_.-]+\.sh' | sort -u } -# gate_exit_codes — the exit literals in the *bash body* of gate.sh: comments -# and single-quoted strings (the embedded perl watchdog has its own 124/127) -# are stripped first, so only what the gate can really return is left. -gate_exit_codes() { +# strip_quotes — reads a shell script on stdin and prints its bash body with +# quoted text and comments removed. One character-by-character pass does both, +# because doing them in two passes is wrong: stripping comments first mutilates +# a '# ...' that lives inside a string, and stripping strings first swallows a +# quote (an apostrophe, say) that lives inside a comment. The quoting state +# survives across lines, which is what the embedded multi-line perl watchdog of +# gate.sh needs. Each quoted run collapses to a single space so nothing on +# either side of it gets glued together. +strip_quotes() { local q q=$(printf '\047') - sed -e 's/^[[:space:]]*#.*$//' -e 's/[[:space:]]#.*$//' scripts/gate.sh | awk -v q="$q" ' { - n = split($0, a, q) - 1 - if (insq) { - if (n % 2 == 0) next # still inside the quoted string - insq = 0 - line = a[2] - for (i = 3; i <= n + 1; i++) line = line q a[i] - print line - next + out = ""; prev = ""; n = length($0) + for (i = 1; i <= n; i++) { + c = substr($0, i, 1) + if (insq) { # nothing escapes inside '\''...'\'' + if (c == q) insq = 0 + } else if (indq) { + if (c == "\\") i++ # backslash escapes the next char + else if (c == "\"") indq = 0 + } else { + if (c == "#" && (i == 1 || prev == " " || prev == "\t")) break + if (c == q) { insq = 1; out = out " " } + else if (c == "\"") { indq = 1; out = out " " } + else out = out c + } + prev = c } - if (n % 2 == 1) { # opens a string that stays open - insq = 1 - line = a[1] - for (i = 2; i <= n; i++) line = line q a[i] - print line - next - } - print + print out } - ' | grep -oE '(^|[^A-Za-z0-9_])exit[[:space:]]+[0-9]+' | + ' +} + +# exit_codes — the exit literals in the *bash body* read from stdin, so only +# what the script can really return is left (the perl watchdog's own 124/127 +# live inside a quoted string and do not count). +exit_codes() { + strip_quotes | + grep -oE '(^|[^A-Za-z0-9_])exit[[:space:]]+[0-9]+' | grep -oE '[0-9]+$' | sort -u } +gate_exit_codes() { exit_codes < scripts/gate.sh; } + +# 0. The suite's own scanners work. ------------------------------------------ +# Every invariant below is only as trustworthy as the extractor that feeds it, +# so the extractor is exercised here on a synthetic script — a heredoc, never +# a file on disk — whose single real exit is 7. +got=$(exit_codes <<'SYNTH' +X="exit 99" +Y='exit 98' +# exit 97 +echo hi # exit 96 +exit 7 +Z='first line +second # exit 95 +exit 94' +SYNTH +) +check "exit-code extractor sees past quotes and comments" \ + "$([[ $got == 7 ]] && echo 0 || echo 1)" \ + "extracted [$(printf '%s' "$got" | tr '\n' ' ')], expected [7]" + # 1. The rollback command is one string, in every file that documents it. ----- for f in $ROLLBACK_FILES; do if grep -q -F -- "$ROLLBACK" "$f" 2>/dev/null; then From 71f1c392aa23f98f8609027eeaec5f8b1b06edd0 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:18:06 -0300 Subject: [PATCH 02/14] fix(coherence): varredura de arquivos tolerante a espaco no nome find|xargs quebrava um nome com espaco em varios argumentos: uma string morta escondida num arquivo assim passava batido. A varredura agora e NUL-safe de ponta a ponta e a raiz virou parametro, o que permite o auto-teste num mktemp -d. Nome com quebra de linha continua fora de alcance, por comentario. --- scripts/coherence_test.sh | 44 ++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index 9be25ec..8af32aa 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -3,8 +3,9 @@ # than one file at once. A doc drift that used to be caught by reading — a # rollback command typed slightly differently, an exit code the READMEs never # heard of, a script missing from the file tree — fails here instead. -# Read-only: it opens the repository's files and never writes, stages or runs -# anything in it. +# Read-only over the repository: it opens the repo's files and never writes, +# stages or runs anything inside it. Its self-tests need files to scan, so they +# build them in a mktemp -d of their own and delete it on the way out. # Usage: coherence_test.sh (exit 0 = every invariant held) set -u @@ -42,19 +43,33 @@ check() { # check if [[ $2 -eq 0 ]]; then pass "$1"; else fail "$1" "$3"; fi } -# repo_files — every file of the repo except .git and this suite. The suite -# quotes the very strings it forbids, so scanning itself would fail on purpose. +# repo_files [root] — every file under (default: the repo) except .git +# and this suite, NUL-separated. The suite quotes the very strings it forbids, +# so scanning itself would fail on purpose. The root is a parameter so the +# self-tests below can point the scanner at a throwaway directory. +# NUL keeps names with spaces intact end to end; a name with a newline in it is +# still out of reach (grep -l prints one path per line) and is not defended. repo_files() { - find . -type f -not -path './.git/*' -not -path "./$SELF" | sort + local root=${1:-.} + find "$root" -type f -not -path "$root/.git/*" -not -path "$root/$SELF" -print0 } -# count_matches — occurrences (not lines) across repo_files +# count_matches [root] — occurrences (not lines) across repo_files. +# /dev/null is passed to grep so it never falls back to reading stdin when the +# scan comes up empty. count_matches() { local n - n=$(repo_files | xargs grep -o -F -- "$1" 2>/dev/null | wc -l) + n=$(repo_files "${2:-.}" | xargs -0 grep -o -F -- "$1" /dev/null 2>/dev/null | wc -l) printf '%s' "$((n))" } +# files_with [root] — the files under that contain the fixed +# string, one per line, sorted (the sort belongs here, not in find: the NUL +# stream must stay in find's order all the way to grep). +files_with() { + repo_files "${2:-.}" | xargs -0 grep -l -F -- "$1" /dev/null 2>/dev/null | sort +} + # tree_scripts — the *.sh names listed in the README's file tree tree_scripts() { awk ' @@ -128,6 +143,16 @@ check "exit-code extractor sees past quotes and comments" \ "$([[ $got == 7 ]] && echo 0 || echo 1)" \ "extracted [$(printf '%s' "$got" | tr '\n' ' ')], expected [7]" +# The file scanner has to reach a file whose name has a space in it, or a dead +# string could hide in one. Exercised on a throwaway tree, never in the repo. +SELFTMP=$(mktemp -d) +trap 'rm -rf "$SELFTMP"' EXIT +printf '%s\n' '--isolate-workspaces' > "$SELFTMP/a b.txt" +got=$(files_with '--isolate-workspaces' "$SELFTMP") +check "file scanner reads a file name with a space" \ + "$([[ $got == "$SELFTMP/a b.txt" ]] && echo 0 || echo 1)" \ + "found [$got], expected [$SELFTMP/a b.txt]" + # 1. The rollback command is one string, in every file that documents it. ----- for f in $ROLLBACK_FILES; do if grep -q -F -- "$ROLLBACK" "$f" 2>/dev/null; then @@ -181,7 +206,7 @@ check "gate.sh only exits documented codes" \ # Each one described a protocol that no longer exists; a copy left behind # contradicts the current one. while IFS= read -r dead; do - hits=$(repo_files | xargs grep -l -F -- "$dead" 2>/dev/null) + hits=$(files_with "$dead") if [[ -z $hits ]]; then pass "dead string absent: $dead" else @@ -198,8 +223,7 @@ DEAD # The exception: phase-3-structure.md explains why a "pure git mv" commit is # not enough. Anywhere else the phrase would be the old, wrong instruction. GITMV='pure `git mv`' -hits=$(repo_files | xargs grep -l -F -- "$GITMV" 2>/dev/null | - grep -v '^\./references/phase-3-structure\.md$') +hits=$(files_with "$GITMV" | grep -v '^\./references/phase-3-structure\.md$') check "\"$GITMV\" only in references/phase-3-structure.md" \ "$([[ -z $hits ]] && echo 0 || echo 1)" "$hits" From c9ef6c3dbb55fbdb8373ada1dae931eaa23c79a1 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:18:53 -0300 Subject: [PATCH 03/14] fix(coherence): deriva os arquivos de protocolo em vez de lista-los A lista fixa nao alcancava uma reference nova que passasse a mandar rodar o gate. Agora ela sai de quem menciona scripts/gate.sh, com piso obrigatorio nos tres arquivos atuais para a invariante nunca virar vacua. READMEs ficam de fora de proposito: apresentam a skill, nao guiam o passo a passo. --- scripts/coherence_test.sh | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index 8af32aa..77f7b00 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -26,9 +26,13 @@ references/phase-2-consolidation.md references/phase-3-structure.md \ scripts/rollback_test.sh" # Files that spell out the protocol step by step: whoever tells the reader to -# run the gate also has to tell them to stage first. -PROTOCOL_FILES="SKILL.md references/phase-2-consolidation.md \ -references/phase-3-structure.md" +# run the gate also has to tell them to stage first. Derived, not listed — a +# new reference that starts calling the gate has to answer for it too, and a +# hand-kept list would silently leave it out. The READMEs are excluded on +# purpose: they present the skill, they do not walk the reader through it. +protocol_files() { + grep -l -F -- 'scripts/gate.sh' SKILL.md references/*.md 2>/dev/null | sort +} pass() { total=$((total+1)); echo "ok: $1"; } @@ -236,10 +240,22 @@ check "\"$GITMV\" appears exactly once in references/phase-3-structure.md" \ # 4. Whoever documents the gate as a step also documents staging. ------------- # The gate reads the working tree, so a protocol that runs it without `git # add -A` first checks something the commit will not contain. -for f in $PROTOCOL_FILES; do - if ! grep -q -F -- 'scripts/gate.sh' "$f" 2>/dev/null; then - fail "gate step pairs with git add -A in $f" "no longer mentions scripts/gate.sh" - elif grep -q -F -- 'git add -A' "$f" 2>/dev/null; then +derived=$(protocol_files) + +# Floor: the three files that carry the protocol today have to be in the +# derived list. Without it, a rename or a dropped mention would empty the list +# and the invariant would pass over nothing at all. +for f in SKILL.md references/phase-2-consolidation.md references/phase-3-structure.md; do + if printf '%s\n' "$derived" | grep -qx -F -- "$f"; then + pass "$f is derived as a protocol file" + else + fail "$f is derived as a protocol file" \ + "no longer mentions scripts/gate.sh — derived list: $(printf '%s' "$derived" | tr '\n' ' ')" + fi +done + +for f in $derived; do + if grep -q -F -- 'git add -A' "$f" 2>/dev/null; then pass "gate step pairs with git add -A in $f" else fail "gate step pairs with git add -A in $f" \ From addecce2ee0f014f747e3f64793a5ef6835c799f Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:19:56 -0300 Subject: [PATCH 04/14] =?UTF-8?q?fix(rollback-test):=20asser=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20status=20por=20linha,=20n=C3=A3o=20por=20igualdade=20to?= =?UTF-8?q?tal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O caso do arquivo nao rastreado exigia a saida inteira do porcelain igual a '?? c.txt': qualquer entrada extra e ruido, nao quebra da propriedade. Agora ele exige a linha presente. Onde o vazio E a propriedade (casos 1, 2, 4 e 5) a igualdade continua. --- scripts/rollback_test.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/rollback_test.sh b/scripts/rollback_test.sh index 23fb4f7..02405b5 100755 --- a/scripts/rollback_test.sh +++ b/scripts/rollback_test.sh @@ -83,6 +83,16 @@ assert_status() { # assert_status return 0 } +assert_status_has() { # assert_status_has + # For cases where the property is one entry, not the whole report: an extra + # unrelated line is noise, and demanding equality would make the test brittle + # for no gain. Where emptiness itself is the property, assert_status stays. + local st; st=$(g "$1" status --porcelain) + printf '%s\n' "$st" | grep -q -F -- "$2" || + note_fail "git status --porcelain: got '$st', want a line '$2'" + return 0 +} + # 1. A staged deletion is fully undone. ------------------------------------- begin "staged deletion comes back" REPO=$(new_repo staged-deletion) @@ -112,7 +122,7 @@ printf 'c1\n' > "$REPO/c.txt" rollback "$REPO" assert_exists "$REPO/c.txt" assert_content "$REPO/c.txt" c1 -assert_status "$REPO" "?? c.txt" +assert_status_has "$REPO" "?? c.txt" end # 4. A rename is undone on both ends. --------------------------------------- From a0d7a3ab71ba08d7d00851f5928b031c346211be Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:22:48 -0300 Subject: [PATCH 05/14] =?UTF-8?q?fix(gate):=20pytest=20sem=20teste=20colet?= =?UTF-8?q?ado=20(exit=205)=20=C3=A9=20YELLOW=20cap,=20n=C3=A3o=20RED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest sai 5 quando não coleta nenhum teste. O gate lia isso como falha e reprovava um repo cuja suíte mora em outro lugar — o oposto do que o cap YELLOW existe para fazer. run() passa a aceitar a forma estendida ::: aquele rc vira "nada coletado", não vermelho. O watchdog continua com prioridade absoluta: 124 sai 4 antes de qualquer outra classificação. --- scripts/gate.sh | 19 +++++++++++++++++-- scripts/gate_test.sh | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index ff3672e..eb9ba60 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -97,17 +97,32 @@ guard() { esac } -# run +# run +# is typecheck|test|both, optionally extended as ::: +# that is the runner's "I collected nothing" code, and it is a YELLOW cap +# (the check did not run) instead of RED. pytest is the case: exit 5 means zero +# tests collected, which would otherwise sink a repo whose suite lives elsewhere. run() { local kind=$1; shift local rc + local no_tests_rc="" nt_label="" + case $kind in + *:*) nt_label=${kind#*:}; no_tests_rc=${nt_label%%:*}; nt_label=${nt_label#*:} + kind=${kind%%:*} ;; + esac echo "[gate] $*" guard "$@" rc=$? + # The watchdog keeps absolute priority: a check killed at the timeout is + # inconclusive, never "no tests collected", whatever code it happens to share. if [[ -n $WATCHDOG && $rc -eq 124 ]]; then echo "[gate] TIMEOUT after ${GATE_TIMEOUT}s at '$*'" >&2 exit 4 fi + if [[ -n $no_tests_rc && $rc -eq $no_tests_rc ]]; then + no_tests "$nt_label" "no tests collected (exit $rc)" + return 0 + fi if [[ $rc -ne 0 ]]; then echo "[gate] RED at '$*'" >&2 exit 1 @@ -228,7 +243,7 @@ if [[ -f pyproject.toml || -f setup.py || -f setup.cfg || -f requirements.txt ]] fi if [[ -f pytest.ini || -d tests || -d test ]] || grep -qs '^\[tool\.pytest' pyproject.toml \ || grep -qs '^\[tool:pytest\]' setup.cfg || grep -qs '^\[pytest\]' tox.ini; then - py_run test python-tests pytest -q + py_run "test:5:python" python-tests pytest -q fi fi diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 705d9df..9a4d545 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -203,6 +203,14 @@ mkdir -p "$TMP/py-uv/tests" printf '[project]\nname = "f"\n' > "$TMP/py-uv/pyproject.toml" touch "$TMP/py-uv/uv.lock" "$TMP/py-uv/tests/test_x.py" +# pytest exits 5 when it collects no test: green-but-empty, not a pass. +mkdir -p "$TMP/py-pytest-no-tests/tests" +printf '[tool.mypy]\n[tool.pytest.ini_options]\n' > "$TMP/py-pytest-no-tests/pyproject.toml" + +# ...and with pytest as the only configured check, nothing at all ran. +mkdir -p "$TMP/py-pytest-only-no-tests" +printf '[tool.pytest.ini_options]\n' > "$TMP/py-pytest-only-no-tests/pyproject.toml" + mkdir -p "$TMP/py-pyright" printf '[tool.pyright]\n' > "$TMP/py-pyright/pyproject.toml" @@ -228,6 +236,8 @@ stub "$FAIL" dotnet 1 stub_body "$HANG" go 'sleep 30' stub "$UV" uv 0 GO="$TMP/stubs-go"; stub "$GO" go 0 +# pytest's "no tests collected" code, with a passing mypy next to it. +PY5="$TMP/stubs-py5"; stub "$PY5" pytest 5; stub "$PY5" mypy 0 # watchdog sandbox: delegators that record how gate.sh called them, plus a # minimal PATH with no timeout/gtimeout so the perl backend can be forced. @@ -276,6 +286,10 @@ case_run py-venv 0 "$TMP/py-venv" "$BASE" ".venv/bin/mypy" " case_run py-no-tools 3 "$TMP/py-no-tools" "$BASE" "toolchain 'mypy' missing" "looked in" case_run py-uv 0 "$TMP/py-uv" "$UV:$BASE" "uv run pytest" "checks=test" case_run py-pyright 0 "$TMP/py-pyright" "$OK:$BASE" "checks=typecheck" +case_run py-pytest-no-tests 0 "$TMP/py-pytest-no-tests" "$PY5:$BASE" \ + "checks=typecheck" "not counted" "no tests collected (exit 5)" +case_run py-pytest-only-no-tests 3 "$TMP/py-pytest-only-no-tests" "$PY5:$BASE" \ + "no runnable checks" "no tests collected (exit 5)" case_run go-with-tests 0 "$TMP/go-with-tests" "$GO:$BASE" "checks=typecheck,test" "GREEN" case_run go-no-tests 0 "$TMP/go-no-tests" "$GO:$BASE" "checks=typecheck" "not counted" '!go test' case_run dotnet-no-tests 0 "$TMP/dotnet-no-tests" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' From 8b203ff0e4e313b7f05dce4c13d54256c73f811f Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:24:03 -0300 Subject: [PATCH 06/14] =?UTF-8?q?fix(gate):=20Rust=20s=C3=B3=20conta=20tes?= =?UTF-8?q?t=20com=20evid=C3=AAncia=20de=20teste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'cargo test' num crate sem nenhum teste sai 0 dizendo "0 passed", e o gate contava isso como suíte verde — a mesma armadilha que o Go já cobria. Agora exige evidência antes de rodar: um .rs sob tests/ ou um #[test] / #[cfg(test)] nas fontes, sempre ignorando target/, que é saída de build. Sem evidência, cargo test não roda e o veredito cai no cap YELLOW. --- scripts/gate.sh | 18 +++++++++++++++++- scripts/gate_test.sh | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index eb9ba60..84caafd 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -228,7 +228,23 @@ fi if [[ -f Cargo.toml ]]; then if command -v cargo >/dev/null; then run typecheck cargo check --all-targets --quiet - run test cargo test --quiet + # 'cargo test' on a crate with no test exits 0 reporting "0 passed" — the + # same trap as 'go test'. A Rust test is either an integration file under + # tests/ or a #[test]/#[cfg(test)] item in the sources; target/ is build + # output and never evidence. /dev/null keeps grep off stdin when the scan + # comes up empty, and head -1 is what makes the result the whole scan's, + # not the last xargs batch's. + rust_tests=$(find . -name '*.rs' -path '*/tests/*' -not -path './target/*' \ + -print -quit 2>/dev/null) + if [[ -z $rust_tests ]]; then + rust_tests=$(find . -name '*.rs' -not -path './target/*' -print0 2>/dev/null \ + | xargs -0 grep -lE '#\[test\]|#\[cfg\(test\)\]' /dev/null 2>/dev/null | head -1) + fi + if [[ -n $rust_tests ]]; then + run test cargo test --quiet + else + no_tests rust "no #[test] or tests/*.rs found" + fi else missing Cargo.toml cargo; fi fi diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 9a4d545..7b36556 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -225,6 +225,22 @@ mkdir -p "$TMP/go-no-tests/vendor/dep" printf 'module f\n\ngo 1.21\n' > "$TMP/go-no-tests/go.mod" printf 'package dep\n' > "$TMP/go-no-tests/vendor/dep/vendored_test.go" +# Rust: evidence is a #[test] in the sources or a file under tests/. +mkdir -p "$TMP/rust-with-tests/src" +printf '[package]\nname = "f"\n' > "$TMP/rust-with-tests/Cargo.toml" +printf 'pub fn f() {}\n\n#[test]\nfn t() {}\n' > "$TMP/rust-with-tests/src/lib.rs" + +mkdir -p "$TMP/rust-tests-dir/src" "$TMP/rust-tests-dir/tests" +printf '[package]\nname = "f"\n' > "$TMP/rust-tests-dir/Cargo.toml" +printf 'pub fn f() {}\n' > "$TMP/rust-tests-dir/src/lib.rs" +printf 'fn it() {}\n' > "$TMP/rust-tests-dir/tests/it.rs" + +# target/ is build output: a test attribute in there is not the crate's suite. +mkdir -p "$TMP/rust-no-tests/src" "$TMP/rust-no-tests/target/debug/tests" +printf '[package]\nname = "f"\n' > "$TMP/rust-no-tests/Cargo.toml" +printf 'fn main() {}\n' > "$TMP/rust-no-tests/src/main.rs" +printf '#[test]\nfn t() {}\n' > "$TMP/rust-no-tests/target/debug/tests/dep.rs" + mkdir -p "$TMP/go-hang" printf 'module f\n\ngo 1.21\n' > "$TMP/go-hang/go.mod" printf 'package f\n' > "$TMP/go-hang/x_test.go" @@ -236,6 +252,7 @@ stub "$FAIL" dotnet 1 stub_body "$HANG" go 'sleep 30' stub "$UV" uv 0 GO="$TMP/stubs-go"; stub "$GO" go 0 +CARGO="$TMP/stubs-cargo"; stub "$CARGO" cargo 0 # pytest's "no tests collected" code, with a passing mypy next to it. PY5="$TMP/stubs-py5"; stub "$PY5" pytest 5; stub "$PY5" mypy 0 @@ -292,6 +309,9 @@ case_run py-pytest-only-no-tests 3 "$TMP/py-pytest-only-no-tests" "$PY5:$BASE" \ "no runnable checks" "no tests collected (exit 5)" case_run go-with-tests 0 "$TMP/go-with-tests" "$GO:$BASE" "checks=typecheck,test" "GREEN" case_run go-no-tests 0 "$TMP/go-no-tests" "$GO:$BASE" "checks=typecheck" "not counted" '!go test' +case_run rust-with-tests 0 "$TMP/rust-with-tests" "$CARGO:$BASE" "checks=typecheck,test" "GREEN" +case_run rust-tests-dir 0 "$TMP/rust-tests-dir" "$CARGO:$BASE" "checks=typecheck,test" "GREEN" +case_run rust-no-tests 0 "$TMP/rust-no-tests" "$CARGO:$BASE" "checks=typecheck" "not counted" '!cargo test' case_run dotnet-no-tests 0 "$TMP/dotnet-no-tests" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' case_run dotnet-sub-no-t 0 "$TMP/dotnet-sub-no-tests" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' From 7afdb257c35d89fb4562082a0f18a31e0f20f856 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:28:24 -0300 Subject: [PATCH 07/14] =?UTF-8?q?feat(gate):=20escala=20TERM=E2=86=92KILL?= =?UTF-8?q?=20sob=20GNU=20timeout=20quando=20o=20backend=20suporta=20-k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O timeout do coreutils manda só TERM. Um check que ignora o sinal continua vivo e o gate espera por ele para sempre — justamente o caso que o watchdog existe para cobrir. Passa a usar -k 2, que agenda o KILL 2s depois, a mesma escalada que o backend perl já faz na mão. Nem todo timeout entende a flag (o do BusyBox não), então a capacidade é sondada uma vez na resolução do backend, com um comando que não trava. Sonda que falha — inclusive por falta de 'true' no PATH — cai no comportamento de hoje, sem a flag. --- scripts/gate.sh | 16 +++++++++++++++- scripts/gate_test.sh | 27 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index 84caafd..bd6cad1 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -80,6 +80,7 @@ my $rc = $?; exit(($rc & 127) ? 128 + ($rc & 127) : ($rc >> 8));' WATCHDOG="" +WD_KILL_AFTER="" if [[ $GATE_TIMEOUT -gt 0 ]]; then if command -v timeout >/dev/null; then WATCHDOG=timeout elif command -v gtimeout >/dev/null; then WATCHDOG=gtimeout @@ -88,10 +89,23 @@ if [[ $GATE_TIMEOUT -gt 0 ]]; then fi fi +# GNU timeout only sends TERM: a check that ignores it stays alive and the gate +# waits for it forever. -k asks for a KILL 2s later, the same escalation the perl +# backend already does by hand. Not every timeout has the flag (BusyBox does +# not), so probe it once, here, with a command that cannot hang. Failing the +# probe — including a PATH without 'true' — means running without -k, which is +# exactly today's behaviour. +case $WATCHDOG in + timeout|gtimeout) + if "$WATCHDOG" -k 2 1 true >/dev/null 2>&1; then WD_KILL_AFTER="-k 2"; fi ;; +esac + # guard — runs the command under the resolved watchdog, if any guard() { case $WATCHDOG in - timeout|gtimeout) "$WATCHDOG" "$GATE_TIMEOUT" "$@" ;; + timeout|gtimeout) + # shellcheck disable=SC2086 # WD_KILL_AFTER is a flag pair or empty, by design + "$WATCHDOG" $WD_KILL_AFTER "$GATE_TIMEOUT" "$@" ;; perl) perl -e "$PERL_WATCHDOG" "$GATE_TIMEOUT" "$@" ;; *) "$@" ;; esac diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 7b36556..047afb6 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -62,6 +62,19 @@ stub_log() { chmod +x "$1/$2" } +# stub_log_probe — same delegator, but it answers the -k +# capability probe the way GNU timeout does: 'timeout -k 2 1 true' exits 0 and +# leaves no trace, so the log only ever holds the real invocation that follows. +stub_log_probe() { + mkdir -p "$1" + { + printf '#!/bin/sh\n' + printf 'if [ "$1" = -k ] && [ "$3" = 1 ] && [ "$4" = true ]; then exit 0; fi\n' + printf 'printf %%s "$*" > "%s"\nexit 124\n' "$3" + } > "$1/$2" + chmod +x "$1/$2" +} + assert_log() { # assert_log local name=$1 file=$2 want=$3 got total=$((total+1)) @@ -262,11 +275,13 @@ WD_T="$TMP/wd-timeout"; WD_LOG_T="$TMP/wd-timeout.log" WD_G="$TMP/wd-gtimeout"; WD_LOG_G="$TMP/wd-gtimeout.log" stub_log "$WD_T" timeout "$WD_LOG_T" 124 stub_log "$WD_G" gtimeout "$WD_LOG_G" 124 +WD_K="$TMP/wd-kill-after"; WD_LOG_K="$TMP/wd-kill-after.log" +stub_log_probe "$WD_K" timeout "$WD_LOG_K" GO124="$TMP/stubs-go-124"; stub "$GO124" go 124 MINI="$TMP/mini-path" link_bin "$MINI" bash sh perl ps find grep sleep -reset_logs() { rm -f "$WD_LOG_T" "$WD_LOG_G"; } +reset_logs() { rm -f "$WD_LOG_T" "$WD_LOG_G" "$WD_LOG_K"; } # A check whose grandchild calls setsid: it leaves the gate's process group, so # 'kill -PGID' alone would leave it running past the timeout. It records its own @@ -323,6 +338,9 @@ elapsed_lt hang-is-bounded 10 # Which backend the gate picks, and with which timeout, is contract: all three # have to exit 124, and the order timeout > gtimeout > perl must hold on any # machine. The delegators make that observable without waiting for a real hang. +# These first delegators fail the -k capability probe (they exit 124 for every +# argument list, the probe included), so they also pin the degraded shape: a +# backend without -k is called exactly as before, with no extra flag. reset_logs GATE_ENV="GATE_TIMEOUT=2" case_run wd-timeout-dispatch 4 "$TMP/go-hang" "$WD_T:$GO:$BASE" "TIMEOUT after 2s" @@ -339,6 +357,13 @@ GATE_ENV="GATE_TIMEOUT=2" case_run wd-gtimeout-over-perl 4 "$TMP/go-hang" "$WD_G:$GO:$MINI" "TIMEOUT after 2s" assert_log wd-gtimeout-args "$WD_LOG_G" "2 go build ./..." +# A backend that does answer the probe gets -k: TERM at the timeout, KILL 2s +# later, so a check that ignores TERM cannot hold the gate open. +reset_logs +GATE_ENV="GATE_TIMEOUT=2" +case_run wd-kill-after-passthrough 4 "$TMP/go-hang" "$WD_K:$GO:$BASE" "TIMEOUT after 2s" +assert_log wd-kill-after-args "$WD_LOG_K" "-k 2 2 go build ./..." + # The perl backend is the one that only shows up on a machine without coreutils; # a minimal PATH keeps it covered even when the suite runs on Linux. if command -v perl >/dev/null; then From 20a3a3a9c3c59bdfcff1a8e1cbb918b95dd563b5 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:32:45 -0300 Subject: [PATCH 08/14] fix(gate): watchdog confere identidade do pid antes de sinalizar sobrevivente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entre o snapshot da árvore e a varredura pós-kill existe a janela do TERM→KILL, 2s em que o sistema pode reciclar um pid da lista. O que morresse ali e voltasse como outro processo levava TERM e KILL sem ter relação nenhuma com o check. O snapshot passa a guardar o start time de cada processo (ps -eo pid=,ppid=,lstart=) e a varredura repete o ps: só sinaliza pid cujo start time ainda confere. Parse por regex de "dois inteiros e o resto", porque lstart tem largura variável e quebra qualquer divisão por campos. Onde o ps não entende lstart (BusyBox), nenhuma linha casa e o watchdog volta ao formato antigo, sinalizando por pid — degradação, não falha. Custo: um ps a mais, e só quando o timeout dispara. --- scripts/gate.sh | 37 +++++++++++++++++++++++++++++++------ scripts/gate_test.sh | 44 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index bd6cad1..20d820e 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -34,8 +34,10 @@ incomplete=0 # ps; without it the sweep silently degrades to the group kill), so under perl # a plain setsid does not escape — it changes the group, not the parent. GNU # timeout/gtimeout kill the group only, with no sweep: there a setsid child -# does escape. Residual race, accepted: a pid snapshotted and recycled during -# the 2s TERM→KILL grace can be signalled by mistake. +# does escape. A pid recycled during the TERM→KILL grace is no longer signalled +# by mistake: the snapshot records each process start time and the sweep only +# touches a survivor whose start time still matches. Where ps has no lstart +# (BusyBox), the sweep falls back to matching by pid alone, as before. GATE_TIMEOUT=${GATE_TIMEOUT:-900} case $GATE_TIMEOUT in ''|*[!0-9]*) @@ -52,14 +54,27 @@ my $pid = fork(); if (!defined $pid) { exit 127; } if (!$pid) { setpgrp(0,0); exec @ARGV; exit 127; } $SIG{ALRM} = sub { - my (%kids, @tree, @queue, %seen); - if (open(my $ps, "-|", "ps", "-eo", "pid=,ppid=")) { + my (%kids, %start, %now, @tree, @queue, %seen); + my $have_start = 0; + if (open(my $ps, "-|", "ps", "-eo", "pid=,ppid=,lstart=")) { while (<$ps>) { - my ($c, $p) = /^\s*(\d+)\s+(\d+)/ or next; + my ($c, $p, $st) = /^\s*(\d+)\s+(\d+)\s+(.*?)\s*$/ or next; push @{$kids{$p}}, $c; + $start{$c} = $st; + $have_start = 1; } close $ps; } + if (!$have_start) { + %kids = (); + if (open(my $ps, "-|", "ps", "-eo", "pid=,ppid=")) { + while (<$ps>) { + my ($c, $p) = /^\s*(\d+)\s+(\d+)/ or next; + push @{$kids{$p}}, $c; + } + close $ps; + } + } @queue = ($pid); $seen{$pid} = 1; while (@queue) { my $cur = shift @queue; @@ -69,7 +84,17 @@ $SIG{ALRM} = sub { } } kill "TERM", -$pid; sleep 2; kill "KILL", -$pid; - @tree = grep { $_ > 1 && $_ != $$ && kill(0, $_) } @tree; + if ($have_start && @tree) { + if (open(my $ps, "-|", "ps", "-eo", "pid=,ppid=,lstart=")) { + while (<$ps>) { + my ($c, $p, $st) = /^\s*(\d+)\s+(\d+)\s+(.*?)\s*$/ or next; + $now{$c} = $st; + } + close $ps; + } + } + @tree = grep { $_ > 1 && $_ != $$ && kill(0, $_) && + (!$have_start || !exists $start{$_} || $now{$_} eq $start{$_}) } @tree; if (@tree) { kill "TERM", @tree; sleep 1; kill "KILL", grep { kill(0, $_) } @tree; } exit 124; }; diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 047afb6..056a411 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -9,11 +9,14 @@ GATE="$(cd "$(dirname "$0")" && pwd)/gate.sh" TMP=$(mktemp -d) # The escapee case spawns a process outside the gate's process group on # purpose; the suite kills it on the way out so a failure never leaks it. +# More than one case spawns one, so the sweep is a glob over every pid file. ESCAPEE_PID_FILE="" cleanup() { - if [[ -n $ESCAPEE_PID_FILE && -s $ESCAPEE_PID_FILE ]]; then - kill -KILL "$(cat "$ESCAPEE_PID_FILE")" 2>/dev/null - fi + local f + for f in "$TMP"/escapee*.pid; do + [[ -s $f ]] || continue + kill -KILL "$(cat "$f")" 2>/dev/null + done rm -rf "$TMP" } trap cleanup EXIT @@ -281,6 +284,11 @@ GO124="$TMP/stubs-go-124"; stub "$GO124" go 124 MINI="$TMP/mini-path" link_bin "$MINI" bash sh perl ps find grep sleep +# The same sandbox without ps: the perl watchdog cannot map the process tree, +# so the sweep degrades to killing the group. The timeout itself must not. +NOPS="$TMP/nops-path" +link_bin "$NOPS" bash sh perl find grep sleep + reset_logs() { rm -f "$WD_LOG_T" "$WD_LOG_G" "$WD_LOG_K"; } # A check whose grandchild calls setsid: it leaves the gate's process group, so @@ -288,16 +296,25 @@ reset_logs() { rm -f "$WD_LOG_T" "$WD_LOG_G" "$WD_LOG_K"; } # pid and sleeps for a bounded time, never longer than the suite. Its output goes # to /dev/null on purpose: holding the case's pipe open would make case_run wait # for it, which hides the very leak this case is about. -ESCAPE="$TMP/stubs-escape" -ESCAPEE_PID_FILE="$TMP/escapee.pid" -mkdir -p "$ESCAPE" -cat > "$ESCAPE/go" < + mkdir -p "$1" + cat > "$1/go" <", "$ESCAPEE_PID_FILE") or exit 1; +perl -e 'use POSIX; POSIX::setsid(); open(F, ">", "$2") or exit 1; print F \$\$; close F; sleep 60' >/dev/null 2>&1 & sleep 45 EOF -chmod +x "$ESCAPE/go" + chmod +x "$1/go" +} + +ESCAPE="$TMP/stubs-escape" +ESCAPEE_PID_FILE="$TMP/escapee.pid" +make_escapee "$ESCAPE" "$ESCAPEE_PID_FILE" + +# The same check, for the sandbox without ps: its own pid file so the two +# escapees never share one and the exit trap can reap both. +ESCAPE_NOPS="$TMP/stubs-escape-nops" +make_escapee "$ESCAPE_NOPS" "$TMP/escapee-nops.pid" # matrix --------------------------------------------------------------- case_run bad-path 2 "$TMP/nope" - "bad path" @@ -379,6 +396,15 @@ if command -v perl >/dev/null; then case_run wd-escapee 4 "$TMP/go-hang" "$ESCAPE:$MINI" "TIMEOUT after 2s" elapsed_lt wd-escapee-is-bounded 10 assert_reaped wd-escapee-reaped "$ESCAPEE_PID_FILE" + + # Without ps there is no tree to snapshot and no start time to confirm, so + # the watchdog only kills the group: the escapee survives, and asserting + # otherwise would be freezing a limitation as a promise. What is contract + # here is that the degraded path still returns exit 4 on schedule instead of + # dying on the missing ps or hanging on the ps that never answers. + GATE_ENV="GATE_TIMEOUT=2" + case_run wd-no-ps 4 "$TMP/go-hang" "$ESCAPE_NOPS:$NOPS" "TIMEOUT after 2s" + elapsed_lt wd-no-ps-is-bounded 10 else echo "skip: wd-perl-forced (no perl on this machine)" fi From 0a63f30cd51a3ab68c7213bffde6f5cbebe00b9c Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:37:28 -0300 Subject: [PATCH 09/14] =?UTF-8?q?fix(gate):=20passa=20a=20solu=C3=A7=C3=A3?= =?UTF-8?q?o=20expl=C3=ADcita=20quando=20h=C3=A1=20exatamente=20um=20.sln?= =?UTF-8?q?=20na=20raiz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uma raiz com .sln e .csproj de nomes-base diferentes faz o 'dotnet build' sem argumento morrer com MSB1011 — o gate dava RED num repo que compila. Com exatamente uma solução não há escolha a fazer: o gate a nomeia. Duas ou mais é decisão real e ele se abstém; um .csproj sozinho já não é ambíguo, e por isso só sln/slnx entram na conta. --- scripts/gate.sh | 16 ++++++++++++++-- scripts/gate_test.sh | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index 20d820e..cd74b54 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -332,9 +332,18 @@ fi # markers — on 10.0 the mstest template matches only through the MSTest token. DOTNET_TEST_MARKERS='Microsoft\.NET\.Test\.Sdk||xunit|NUnit|MSTest' dotnet_targets=() +dotnet_root_arg="" if compgen -G '*.sln' >/dev/null || compgen -G '*.slnx' >/dev/null \ || compgen -G '*.??proj' >/dev/null; then dotnet_targets=(.) + # A root holding a solution and a project whose base names differ makes + # 'dotnet build' with no argument fail with MSB1011 (more than one target) on + # a repo that compiles. With exactly one solution there is nothing to choose: + # name it. Two or more is a real decision and the gate abstains — a lone + # .csproj is already unambiguous, so only sln/slnx count here. + slns=() + for f in *.sln *.slnx; do [[ -e $f ]] && slns+=("$f"); done + if [[ ${#slns[@]} -eq 1 ]]; then dotnet_root_arg=${slns[0]}; fi else for p in */*.??proj src/*/*.??proj; do [[ -e $p ]] && dotnet_targets+=("$p") @@ -351,8 +360,11 @@ if [[ ${#dotnet_targets[@]} -gt 0 ]]; then # the verdict caps at YELLOW — fail-safe, promote by hand if so. grep -qsE "$DOTNET_TEST_MARKERS" ./*.??proj ./*/*.??proj ./src/*/*.??proj \ && has_tests=1 - run typecheck dotnet build --nologo -v minimal - if [[ $has_tests -eq 1 ]]; then run test dotnet test --nologo -v minimal + # shellcheck disable=SC2086 # the :+ expansion is one argument or none + run typecheck dotnet build --nologo -v minimal ${dotnet_root_arg:+"$dotnet_root_arg"} + if [[ $has_tests -eq 1 ]]; then + # shellcheck disable=SC2086 # same expansion, same reason + run test dotnet test --nologo -v minimal ${dotnet_root_arg:+"$dotnet_root_arg"} else no_tests dotnet "no test project found"; fi else run typecheck dotnet build --nologo -v minimal "$target" diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 056a411..1074100 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -194,6 +194,18 @@ TESTPROJ=' "$TMP/dotnet-root/App.csproj" printf '%s\n' "$TESTPROJ" > "$TMP/dotnet-sub/src/App/App.fsproj" +# One solution next to a project of a different base name: the gate has to name +# the solution, or the real 'dotnet build' fails with MSB1011. +mkdir -p "$TMP/dotnet-sln" +printf '%s\n' "$TESTPROJ" > "$TMP/dotnet-sln/App.csproj" +printf 'Microsoft Visual Studio Solution File\n' > "$TMP/dotnet-sln/MyApp.sln" + +# Two solutions is a choice the gate does not get to make: it abstains and +# calls dotnet with no argument, exactly as before. +mkdir -p "$TMP/dotnet-two-slns" +printf 'Microsoft Visual Studio Solution File\n' > "$TMP/dotnet-two-slns/A.sln" +printf 'Microsoft Visual Studio Solution File\n' > "$TMP/dotnet-two-slns/B.sln" + mkdir -p "$TMP/dotnet-no-tests" "$TMP/dotnet-sub-no-tests/src/App" printf '\n' > "$TMP/dotnet-no-tests/App.csproj" printf '\n' > "$TMP/dotnet-sub-no-tests/src/App/App.fsproj" @@ -329,6 +341,11 @@ case_run partial-none-ran 3 "$TMP/go-only" "$BASE" "nothing ran" case_run dotnet-green 0 "$TMP/dotnet-root" "$OK:$BASE" "checks=typecheck,test" "GREEN" case_run dotnet-red 1 "$TMP/dotnet-root" "$FAIL:$BASE" "RED" case_run dotnet-subdir 0 "$TMP/dotnet-sub" "$OK:$BASE" "src/App/App.fsproj" "GREEN" +case_run dotnet-sln 0 "$TMP/dotnet-sln" "$OK:$BASE" \ + "dotnet build --nologo -v minimal MyApp.sln" \ + "dotnet test --nologo -v minimal MyApp.sln" "GREEN" +case_run dotnet-two-slns 0 "$TMP/dotnet-two-slns" "$OK:$BASE" \ + "dotnet build --nologo -v minimal$" "checks=typecheck" case_run jvm-hybrid 0 "$TMP/jvm-hybrid" "$OK:$BASE" "mvn -q test" "gradle test" "GREEN" case_run py-setupcfg 0 "$TMP/py-setupcfg" "$OK:$BASE" "checks=typecheck,test" "GREEN" case_run py-venv 0 "$TMP/py-venv" "$BASE" ".venv/bin/mypy" ".venv/bin/pytest" "GREEN" From 117e3c2597991e5127faaf9cd54f469e5863e735 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:38:19 -0300 Subject: [PATCH 10/14] fix(gate): varre marcadores de teste .NET com find e prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os três globs fixos perdiam um projeto de teste em src/App/Tests/ e contavam como suíte um marcador esquecido em bin/. A varredura agora desce cinco níveis podando bin, obj, node_modules e .git. O /dev/null no grep evita que ele leia stdin quando a varredura volta vazia, e o head -1 fecha o pipe no primeiro acerto. --- scripts/gate.sh | 16 ++++++++++++---- scripts/gate_test.sh | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index cd74b54..8be19ee 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -356,10 +356,18 @@ if [[ ${#dotnet_targets[@]} -gt 0 ]]; then # 0 — same trap as 'go test' on a repo with no _test.go file. if [[ $target == . ]]; then has_tests=0 - # Three fixed depths only; a test project nested deeper is missed and - # the verdict caps at YELLOW — fail-safe, promote by hand if so. - grep -qsE "$DOTNET_TEST_MARKERS" ./*.??proj ./*/*.??proj ./src/*/*.??proj \ - && has_tests=1 + # Walks the tree down to five levels instead of three fixed globs, so a + # test project under src/App/Tests/ still counts. bin/obj/node_modules + # and .git are pruned: a marker in build output is leftover, not the + # repo's suite. /dev/null keeps grep from reading stdin when the scan + # comes back empty, and head -1 closes the pipe on the first hit. + if [[ -n $(find . -maxdepth 5 \ + \( -name bin -o -name obj -o -name node_modules -o -name .git \) \ + -prune -o -name '*.??proj' -print0 \ + | xargs -0 grep -lE "$DOTNET_TEST_MARKERS" /dev/null 2>/dev/null \ + | head -1) ]]; then + has_tests=1 + fi # shellcheck disable=SC2086 # the :+ expansion is one argument or none run typecheck dotnet build --nologo -v minimal ${dotnet_root_arg:+"$dotnet_root_arg"} if [[ $has_tests -eq 1 ]]; then diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 1074100..15411bc 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -206,6 +206,16 @@ mkdir -p "$TMP/dotnet-two-slns" printf 'Microsoft Visual Studio Solution File\n' > "$TMP/dotnet-two-slns/A.sln" printf 'Microsoft Visual Studio Solution File\n' > "$TMP/dotnet-two-slns/B.sln" +# The test project is nested deeper than the old three fixed globs reached. +mkdir -p "$TMP/dotnet-deep/src/App/Tests" +printf '\n' > "$TMP/dotnet-deep/App.csproj" +printf '%s\n' "$TESTPROJ" > "$TMP/dotnet-deep/src/App/Tests/Tests.csproj" + +# A marker under bin/ is build output, not the repo's suite. +mkdir -p "$TMP/dotnet-prune-bin/bin/Debug" +printf '\n' > "$TMP/dotnet-prune-bin/App.csproj" +printf '%s\n' "$TESTPROJ" > "$TMP/dotnet-prune-bin/bin/Debug/Leftover.csproj" + mkdir -p "$TMP/dotnet-no-tests" "$TMP/dotnet-sub-no-tests/src/App" printf '\n' > "$TMP/dotnet-no-tests/App.csproj" printf '\n' > "$TMP/dotnet-sub-no-tests/src/App/App.fsproj" @@ -363,6 +373,8 @@ case_run rust-tests-dir 0 "$TMP/rust-tests-dir" "$CARGO:$BASE" "checks=typeche case_run rust-no-tests 0 "$TMP/rust-no-tests" "$CARGO:$BASE" "checks=typecheck" "not counted" '!cargo test' case_run dotnet-no-tests 0 "$TMP/dotnet-no-tests" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' case_run dotnet-sub-no-t 0 "$TMP/dotnet-sub-no-tests" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' +case_run dotnet-deep 0 "$TMP/dotnet-deep" "$OK:$BASE" "checks=typecheck,test" "dotnet test" "GREEN" +case_run dotnet-prune-bin 0 "$TMP/dotnet-prune-bin" "$OK:$BASE" "checks=typecheck" "not counted" '!dotnet test' GATE_ENV="GATE_TIMEOUT=2" case_run hang 4 "$TMP/go-hang" "$HANG:$BASE" "TIMEOUT after 2s" From 14ad4cc1a66c782df101d578cdb3215fe81f2566 Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:42:13 -0300 Subject: [PATCH 11/14] =?UTF-8?q?ci:=20roda=20as=20tr=C3=AAs=20su=C3=ADtes?= =?UTF-8?q?=20em=20ubuntu=20e=20macos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db4bee8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +# The three suites on both target platforms. Linux exercises the GNU timeout +# backend (with -k) and procps; macOS proves bash 3.2 compatibility with the +# stock /bin/bash — never the Homebrew bash the runner also ships. +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - run: bash scripts/test.sh + macos: + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - run: /bin/bash --version && /bin/bash scripts/test.sh From acd6959c119695d4c1054b5cba908c4537cd29cd Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:43:37 -0300 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20contrato=20do=20124,=20limites=20?= =?UTF-8?q?.NET/Rust=20revistos=20e=20contagens=20das=20su=C3=ADtes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 24 +++++++++++++++++------- README.md | 26 ++++++++++++++++++-------- SKILL.md | 3 ++- scripts/gate.sh | 4 +++- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/README.en.md b/README.en.md index 2aefb76..8be812f 100644 --- a/README.en.md +++ b/README.en.md @@ -1,4 +1,4 @@ -**English** · [Português](README.md) +**English** · [Português](README.md) · [![ci](https://github.com/CRangelP/codebase-cleanup/actions/workflows/ci.yml/badge.svg)](https://github.com/CRangelP/codebase-cleanup/actions/workflows/ci.yml) # codebase-cleanup @@ -102,13 +102,16 @@ toolchain stubs, the rollback suite builds throwaway repositories inside a your git config is never read nor written —, and the coherence suite only reads files. +CI runs the three suites on every push and PR: ubuntu (real GNU `timeout`, +procps) and macOS with the stock `/bin/bash` 3.2. + The suites also run outside macOS. In a Linux container the hang case exercises the real GNU `timeout` instead of the perl backend: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validated 2026-08: 40/40 cases, 5/5 properties, 43/43 invariants +# validated 2026-08: 53/53 cases, 5/5 properties, 49/49 invariants ``` The .NET heuristic was validated against the real SDK @@ -157,7 +160,9 @@ into one of three levels: | RED | no tests and no typecheck | diagnoses only; nothing is deleted | A stack with no test file at all does not count as tested: the gate does not -run the empty suite and the level stays at YELLOW. If your suite lives outside +run the empty suite and the level stays at YELLOW. That covers Go and .NET +with no test file, a Rust crate with no `tests/*.rs` and no `#[test]`, and a +pytest run that exits 5 having collected nothing. If your suite lives outside the usual place, promoting it is your call — the gate never promotes itself. With the level announced, it creates the cleanup branch and proceeds: @@ -216,10 +221,15 @@ rollback discards is what the skill itself created. - RED level returns a report, not a cleanup. If the project has neither tests nor typecheck, the first step is to create a minimal verification; the skill points the way in the report itself. -- A root with a `.sln` and a `.csproj` whose base names differ makes - `dotnet build` with no argument fail with MSB1011, and the gate reports RED - on a repo that compiles. It fails closed (nothing gets promoted unduly): - run the gate by hand pointing at the solution, or align the names. +- Exit 124 is reserved for the watchdog, exactly as in GNU `timeout`: a + check that legitimately exits 124 under an active watchdog reads as TIMEOUT. +- With a single `.sln`/`.slnx` at the root the gate passes it explicitly to + `dotnet`; with two or more it abstains and invokes with no argument, and + the ambiguity is MSBuild's again. It fails closed: run the gate by hand + pointing at the solution. +- A Rust crate whose tests exist only as doc-tests (or come out of a macro) + falls to the YELLOW cap — the evidence searched for is `tests/*.rs` or + `#[test]` in the sources. Promote by hand if the suite lives elsewhere. - A folder with no git falls into RED as well, even with typecheck and tests passing. With no commit there is no rollback, and the rollback is what holds up the autonomy of the rest of the pipeline. diff --git a/README.md b/README.md index 895e3c8..0089204 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[English](README.en.md) · **Português** +[English](README.en.md) · **Português** · [![ci](https://github.com/CRangelP/codebase-cleanup/actions/workflows/ci.yml/badge.svg)](https://github.com/CRangelP/codebase-cleanup/actions/workflows/ci.yml) # codebase-cleanup @@ -99,13 +99,16 @@ toolchain, o rollback cria repositórios descartáveis dentro de um `mktemp -d`, com `HOME` redirecionado e identidade de commit passada por `-c` — sua config do git não é lida nem escrita —, e a de coerência só lê arquivos. +A CI roda as três suítes a cada push e PR: ubuntu (GNU `timeout` real, +procps) e macOS com o `/bin/bash` 3.2 de fábrica. + As suítes também rodam fora do macOS. Num container Linux, o caso de hang exercita o GNU `timeout` real em vez do backend perl: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validado em 08/2026: 40/40 casos, 5/5 propriedades, 43/43 invariantes +# validado em 08/2026: 53/53 casos, 5/5 propriedades, 49/49 invariantes ``` A heurística .NET foi validada contra o SDK real (`mcr.microsoft.com/dotnet/sdk:8.0` @@ -154,8 +157,10 @@ e se classifica em um de três níveis: | RED | sem testes e sem typecheck | só diagnostica; nada é deletado | Stack sem nenhum arquivo de teste não conta como testado: o gate não roda a -suíte vazia e o nível fica em YELLOW. Se a sua suíte mora fora do lugar -padrão, a promoção é sua — o gate não se promove sozinho. +suíte vazia e o nível fica em YELLOW. Vale para Go e .NET sem arquivo de +teste, para crate Rust sem `tests/*.rs` nem `#[test]`, e para pytest que sai +5 sem coletar nada. Se a sua suíte mora fora do lugar padrão, a promoção é +sua — o gate não se promove sozinho. Com o nível anunciado, ela cria a branch de limpeza e segue: @@ -211,10 +216,15 @@ rollback joga fora foi ela mesma que criou. - Nível RED devolve relatório, não limpeza. Se o projeto não tem teste nem typecheck, o primeiro passo é criar uma verificação mínima; a skill aponta o caminho no próprio relatório. -- Raiz com `.sln` e `.csproj` de nomes-base diferentes lado a lado faz o - `dotnet build` sem argumento falhar com MSB1011, e o gate dá RED num repo - que compila. A falha é fechada (nada é promovido indevidamente): rode o - gate manual apontando a solução, ou alinhe os nomes. +- Exit 124 é reservado ao watchdog, igual ao GNU `timeout`: um check que + legitimamente sai 124 sob watchdog ativo é lido como TIMEOUT. +- Com uma única `.sln`/`.slnx` na raiz o gate a passa explícita ao `dotnet`; + com duas ou mais ele se abstém e invoca sem argumento, e a ambiguidade + volta a ser do MSBuild. Falha fechada: rode o gate manual apontando a + solução. +- Crate Rust cujos testes existem só como doc-tests (ou gerados por macro) + cai no cap YELLOW — a evidência procurada é `tests/*.rs` ou `#[test]` no + fonte. Promova à mão se a suíte vive em outro lugar. - Pasta sem git também cai em RED, mesmo com typecheck e testes passando. Sem commit não existe rollback, e é o rollback que sustenta a autonomia do resto do pipeline. diff --git a/SKILL.md b/SKILL.md index 63a61d3..df8ce7b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -113,7 +113,8 @@ disables). In the exit-3 cases, finish the gate by hand before classifying. nothing about the code: treat it as red (rollback, record what timed out in `CLEANUP_PROGRESS.md`) and never promote it to GREEN. On the Step 0 baseline, exit 4 means the safety net could not be measured — report it and do not run -autonomously. +autonomously. Exit code 124 is reserved for the watchdog, exactly as in GNU +timeout: a check that legitimately exits 124 is read as a timeout. | Signal | Level | Behavior | |---|---|---| diff --git a/scripts/gate.sh b/scripts/gate.sh index 8be19ee..2c7cd76 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -27,7 +27,9 @@ incomplete=0 # --- Watchdog --------------------------------------------------------- # A hanging check (a test waiting on a port, a REPL, a prompt) would freeze the # gate forever. Resolve one backend up front; all three exit 124 on timeout, -# copying GNU timeout so run() only has to know that number. +# copying GNU timeout so run() only has to know that number. Contract: 124 is +# reserved for the watchdog, exactly as in GNU timeout — a check that +# legitimately exits 124 under an active watchdog is read as TIMEOUT. # Limit: what still escapes the kill is a double fork already reparented to # init/launchd when the alarm fires, and anything created between the snapshot # and the kill. The perl backend also sweeps descendants by parent pid (needs From 905dff81b456bba680619c0f64de7c0c6bcdbded Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 13:56:07 -0300 Subject: [PATCH 13/14] fix(review): vendor fora do scan Rust, fallback ps sem lstart testado, asserts exatos --- README.en.md | 2 +- README.md | 2 +- scripts/coherence_test.sh | 6 +++--- scripts/gate.sh | 6 ++++-- scripts/gate_test.sh | 28 ++++++++++++++++++++++++++++ scripts/rollback_test.sh | 2 +- scripts/test.sh | 2 +- 7 files changed, 39 insertions(+), 9 deletions(-) diff --git a/README.en.md b/README.en.md index 8be812f..99094ae 100644 --- a/README.en.md +++ b/README.en.md @@ -111,7 +111,7 @@ exercises the real GNU `timeout` instead of the perl backend: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validated 2026-08: 53/53 cases, 5/5 properties, 49/49 invariants +# validated 2026-08: 57/57 cases, 5/5 properties, 49/49 invariants ``` The .NET heuristic was validated against the real SDK diff --git a/README.md b/README.md index 0089204..51932f4 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ exercita o GNU `timeout` real em vez do backend perl: ```bash docker run --rm -v "$PWD":/repo:ro node:22-bookworm bash -c \ 'apt-get update -qq && apt-get install -y -qq procps && cd /repo && bash scripts/test.sh' -# validado em 08/2026: 53/53 casos, 5/5 propriedades, 49/49 invariantes +# validado em 08/2026: 57/57 casos, 5/5 propriedades, 49/49 invariantes ``` A heurística .NET foi validada contra o SDK real (`mcr.microsoft.com/dotnet/sdk:8.0` diff --git a/scripts/coherence_test.sh b/scripts/coherence_test.sh index 77f7b00..7c31951 100755 --- a/scripts/coherence_test.sh +++ b/scripts/coherence_test.sh @@ -83,7 +83,7 @@ tree_scripts() { ' "$1" | grep -oE '[A-Za-z0-9_.-]+\.sh' | sort -u } -# strip_quotes — reads a shell script on stdin and prints its bash body with +# bash_body — reads a shell script on stdin and prints its bash body with # quoted text and comments removed. One character-by-character pass does both, # because doing them in two passes is wrong: stripping comments first mutilates # a '# ...' that lives inside a string, and stripping strings first swallows a @@ -91,7 +91,7 @@ tree_scripts() { # survives across lines, which is what the embedded multi-line perl watchdog of # gate.sh needs. Each quoted run collapses to a single space so nothing on # either side of it gets glued together. -strip_quotes() { +bash_body() { local q q=$(printf '\047') awk -v q="$q" ' @@ -121,7 +121,7 @@ strip_quotes() { # what the script can really return is left (the perl watchdog's own 124/127 # live inside a quoted string and do not count). exit_codes() { - strip_quotes | + bash_body | grep -oE '(^|[^A-Za-z0-9_])exit[[:space:]]+[0-9]+' | grep -oE '[0-9]+$' | sort -u } diff --git a/scripts/gate.sh b/scripts/gate.sh index 2c7cd76..b0c2bec 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -271,14 +271,16 @@ if [[ -f Cargo.toml ]]; then run typecheck cargo check --all-targets --quiet # 'cargo test' on a crate with no test exits 0 reporting "0 passed" — the # same trap as 'go test'. A Rust test is either an integration file under - # tests/ or a #[test]/#[cfg(test)] item in the sources; target/ is build + # tests/ or a #[test]/#[cfg(test)] item in the sources; target/ and vendor/ are # output and never evidence. /dev/null keeps grep off stdin when the scan # comes up empty, and head -1 is what makes the result the whole scan's, # not the last xargs batch's. rust_tests=$(find . -name '*.rs' -path '*/tests/*' -not -path './target/*' \ + -not -path './vendor/*' \ -print -quit 2>/dev/null) if [[ -z $rust_tests ]]; then - rust_tests=$(find . -name '*.rs' -not -path './target/*' -print0 2>/dev/null \ + rust_tests=$(find . -name '*.rs' -not -path './target/*' \ + -not -path './vendor/*' -print0 2>/dev/null \ | xargs -0 grep -lE '#\[test\]|#\[cfg\(test\)\]' /dev/null 2>/dev/null | head -1) fi if [[ -n $rust_tests ]]; then diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index 15411bc..f293511 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -279,6 +279,12 @@ printf '[package]\nname = "f"\n' > "$TMP/rust-no-tests/Cargo.toml" printf 'fn main() {}\n' > "$TMP/rust-no-tests/src/main.rs" printf '#[test]\nfn t() {}\n' > "$TMP/rust-no-tests/target/debug/tests/dep.rs" +# Vendored dependencies carry their own tests; they are not this crate's suite. +mkdir -p "$TMP/rust-vendor/src" "$TMP/rust-vendor/vendor/dep/tests" +printf '[package]\nname = "f"\n' > "$TMP/rust-vendor/Cargo.toml" +printf 'fn main() {}\n' > "$TMP/rust-vendor/src/main.rs" +printf '#[test]\nfn t() {}\n' > "$TMP/rust-vendor/vendor/dep/tests/it.rs" + mkdir -p "$TMP/go-hang" printf 'module f\n\ngo 1.21\n' > "$TMP/go-hang/go.mod" printf 'package f\n' > "$TMP/go-hang/x_test.go" @@ -338,6 +344,20 @@ make_escapee "$ESCAPE" "$ESCAPEE_PID_FILE" ESCAPE_NOPS="$TMP/stubs-escape-nops" make_escapee "$ESCAPE_NOPS" "$TMP/escapee-nops.pid" +# A ps that rejects lstart (BusyBox shape): delegates the plain pid=,ppid= form +# to the real ps and fails on anything mentioning lstart. +NOLS="$TMP/nols-path" +mkdir -p "$NOLS" +REAL_PS=$(command -v ps) +cat > "$NOLS/ps" </dev/null; then GATE_ENV="GATE_TIMEOUT=2" case_run wd-no-ps 4 "$TMP/go-hang" "$ESCAPE_NOPS:$NOPS" "TIMEOUT after 2s" elapsed_lt wd-no-ps-is-bounded 10 + + # ps present but without lstart support (BusyBox shape): the watchdog must + # fall back to pid-only matching and still reap the escapee. + GATE_ENV="GATE_TIMEOUT=2" + case_run wd-ps-no-lstart 4 "$TMP/go-hang" "$ESCAPE_NOLS:$NOLS:$MINI" "TIMEOUT after 2s" + elapsed_lt wd-ps-no-lstart-is-bounded 10 + assert_reaped wd-ps-no-lstart-reaped "$TMP/escapee-nols.pid" else echo "skip: wd-perl-forced (no perl on this machine)" fi diff --git a/scripts/rollback_test.sh b/scripts/rollback_test.sh index 02405b5..271731c 100755 --- a/scripts/rollback_test.sh +++ b/scripts/rollback_test.sh @@ -88,7 +88,7 @@ assert_status_has() { # assert_status_has # unrelated line is noise, and demanding equality would make the test brittle # for no gain. Where emptiness itself is the property, assert_status stays. local st; st=$(g "$1" status --porcelain) - printf '%s\n' "$st" | grep -q -F -- "$2" || + printf '%s\n' "$st" | grep -qx -F -- "$2" || note_fail "git status --porcelain: got '$st', want a line '$2'" return 0 } diff --git a/scripts/test.sh b/scripts/test.sh index 792e241..1a20fa6 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,6 +7,6 @@ set -u cd "$(dirname "$0")" || exit 2 for suite in gate_test.sh rollback_test.sh coherence_test.sh; do echo "=== $suite" - bash "$suite" || { echo "=== $suite FAILED — stopping here"; exit 1; } + "${BASH:-bash}" "$suite" || { echo "=== $suite FAILED — stopping here"; exit 1; } done echo "=== gate_test.sh, rollback_test.sh and coherence_test.sh: all green" From cec9610efbf804aedf250236c55808221fb01b9d Mon Sep 17 00:00:00 2001 From: Cleber Rangel Date: Sat, 8 Aug 2026 14:07:54 -0300 Subject: [PATCH 14/14] =?UTF-8?q?fix(test):=20casos=20PARTIAL=20imunes=20?= =?UTF-8?q?=C3=A0s=20toolchains=20do=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/gate_test.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/gate_test.sh b/scripts/gate_test.sh index f293511..d0b3fb0 100755 --- a/scripts/gate_test.sh +++ b/scripts/gate_test.sh @@ -25,6 +25,7 @@ total=0 BASE="/usr/bin:/bin" + # Extra environment for the next case_run only ("VAR=value VAR2=value2"). # A single string instead of an array: bash 3.2 has no associative arrays and # word splitting is enough for the assignments used here. @@ -312,6 +313,12 @@ GO124="$TMP/stubs-go-124"; stub "$GO124" go 124 MINI="$TMP/mini-path" link_bin "$MINI" bash sh perl ps find grep sleep +# Sandbox with the gate's own needs and ZERO stack toolchains. The PARTIAL +# and missing-toolchain cases must not depend on what the host happens to +# have in /usr/bin — a GitHub ubuntu runner ships go there, a laptop does not. +NOTOOL="$TMP/notool-path" +link_bin "$NOTOOL" bash sh env grep find xargs head perl ps sleep true + # The same sandbox without ps: the perl watchdog cannot map the process tree, # so the sweep degrades to killing the group. The timeout itself must not. NOPS="$TMP/nops-path" @@ -364,10 +371,10 @@ case_run empty 3 "$TMP/empty" - "no runnable check case_run js-green 0 "$TMP/js-green" - "checks=typecheck,test" "GREEN" case_run js-test-only 0 "$TMP/js-test-only" - "checks=test" "YELLOW" case_run js-red 1 "$TMP/js-red" - "RED" -case_run js-no-node 3 "$TMP/js-green" "$BASE" "toolchain 'node' missing" +case_run js-no-node 3 "$TMP/js-green" "$NOTOOL" "toolchain 'node' missing" case_run js-bad-json 3 "$TMP/js-bad-json" - "unparseable" -case_run polyglot-partial 3 "$TMP/polyglot" "$OK:$BASE" "checks=test" "some detected stack" -case_run partial-none-ran 3 "$TMP/go-only" "$BASE" "nothing ran" +case_run polyglot-partial 3 "$TMP/polyglot" "$OK:$NOTOOL" "checks=test" "some detected stack" +case_run partial-none-ran 3 "$TMP/go-only" "$NOTOOL" "nothing ran" case_run dotnet-green 0 "$TMP/dotnet-root" "$OK:$BASE" "checks=typecheck,test" "GREEN" case_run dotnet-red 1 "$TMP/dotnet-root" "$FAIL:$BASE" "RED" case_run dotnet-subdir 0 "$TMP/dotnet-sub" "$OK:$BASE" "src/App/App.fsproj" "GREEN"