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 diff --git a/README.en.md b/README.en.md index 2aefb76..99094ae 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: 57/57 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..51932f4 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: 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` @@ -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/coherence_test.sh b/scripts/coherence_test.sh index af2b915..7c31951 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 @@ -25,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"; } @@ -42,19 +47,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 ' @@ -64,37 +83,80 @@ 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() { +# 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 +# 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. +bash_body() { 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 - } - 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 + 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 } - 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() { + bash_body | + 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]" + +# 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 @@ -148,7 +210,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 @@ -165,8 +227,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" @@ -179,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" \ diff --git a/scripts/gate.sh b/scripts/gate.sh index ff3672e..b0c2bec 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -27,15 +27,19 @@ 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 # 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 +56,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 +86,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; }; @@ -80,6 +107,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,26 +116,54 @@ 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 } -# 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 @@ -213,7 +269,25 @@ 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/ 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/*' \ + -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 + run test cargo test --quiet + else + no_tests rust "no #[test] or tests/*.rs found" + fi else missing Cargo.toml cargo; fi fi @@ -228,7 +302,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 @@ -262,9 +336,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") @@ -277,12 +360,23 @@ 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 - run typecheck dotnet build --nologo -v minimal - if [[ $has_tests -eq 1 ]]; then run test dotnet test --nologo -v minimal + # 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 + # 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 705d9df..d0b3fb0 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 @@ -22,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. @@ -62,6 +66,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)) @@ -178,6 +195,28 @@ 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" + +# 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" @@ -203,6 +242,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" @@ -217,6 +264,28 @@ 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" + +# 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" @@ -228,6 +297,9 @@ 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 # 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. @@ -235,27 +307,63 @@ 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"; } +# 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" +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 # 'kill -PGID' alone would leave it running past the timeout. It records its own # 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" + +# 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" < 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" @@ -305,6 +431,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 @@ -320,6 +453,22 @@ 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 + + # 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 23fb4f7..271731c 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 -qx -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. --------------------------------------- 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"