Files
codebase-memory-mcp/scripts/ci/test-impact-select.sh
T
Martin Vogel 57b557ced8 fix(test-impact): close the role table's fail-opens, follow literals through macros
The first commit of this branch is the Smart CI change as of the rebase
onto 9f85f104. A review of it found places where the engine could run
fewer tests than a change needs; each now runs everything or selects the
right tests, with a test that fails when its fix is reverted.

Engine:
- A `#define NAME "literal"` line counted as naming a role but selected
  nobody, so the tests that start the blocking-git probe through their
  marker macros would not have run. The scan now follows NAME, as a whole
  word, to the code that uses it, through chains of up to 4 macros; a
  define it cannot follow runs everything.
- main dispatches through the role table, so no edge leads from main to a
  handler. A table entry the engine cannot read, or whose handler is not
  exactly one function, was skipped; it now runs everything.
- In a runner file, only a test case names a role's literal. The runner's
  own helper that reads the marker no longer counts as a spawner, so a
  role only the runner names runs everything again.
- A handler reached first by a parse gap or by the spawn lift was never
  lifted. Role lifts and parse gaps now run to a fixpoint, before the
  runner check and again after the spawn lift.
- A reached sink that main calls (the blocking-git probe) counted as a way
  into main and hid the real one: #2644 now names the start-up path main
  -> cbm_index_supervisor_capture_build_fingerprint.
- No pointer past the end of a role line; the RUNNER_REACHED note clamps
  its prefix.

CI:
- pkg/npm/README.md, which test_cli reads, runs the test legs instead of
  the docs tier.
- The build rule lists the harness scripts the test legs run:
  run-test-wave.py, path-safety.sh, clean.sh.
- The revert guard rejects the ancestor's coverage when a diff fails.
- The vetoed selection is uploaded (test-selection-vetoed).

Docs: SMART_CI.md states the new fail-safe cases, the version that lacks
the new config keys, the selection base, and that the narrowed leg builds
with gcc while the coverage comes from clang.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-17 20:44:27 +02:00

175 lines
8.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-impact-select.sh — the PR selection with the team artifact: what
# `codebase-memory-mcp test-impact select` runs for this change.
#
# Shadow (default): REPORT-ONLY; pr.yml's test-impact-shadow job runs it
# continue-on-error and ci-ok does not need it.
# --gate DIR (pr.yml's select-tests job, only when the repository variable
# TEST_IMPACT_MODE is `gate`): also turns the answer into what the test legs
# run (scripts/test-impact/selection.py) — DIR/test-only.txt and
# DIR/optional-suites.txt for a narrowed selection — and writes
# `selection=narrowed` or `selection=all` to $GITHUB_OUTPUT. Anything but a
# well-formed narrowed answer, a lane that selects nothing included, is `all`.
#
# The base is the base side of the PR merge commit (HEAD^1 by default). Its
# bundle is fetched with scripts/ci/test-impact-bundle.sh, which only accepts
# the main-branch producer's own run for exactly that commit; only then does
# the selection pass --artifact-verified. Without a bundle the engine builds
# the graph itself and admits no coverage: every suite runs whole (the lane
# then reports run_all; the summary shows why each suite runs whole).
#
# Usage: scripts/ci/test-impact-select.sh --binary PATH --out DIR [--base REV]
# [--platform LABEL] [--gate DIR]
# Writes DIR/selection-engine.json and DIR/summary-engine.md (appended
# to $GITHUB_STEP_SUMMARY when set).
# Exit: 0 = selection written · 1 = the tooling broke · 2 = usage error.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BINARY="" OUT="" BASE="HEAD^1" PLATFORM="linux-x86_64-clang21" GATE=""
ANCESTOR_MAX=20 # first-parent commits before the merge base whose bundle may stand in
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--base) BASE="$2"; shift 2 ;;
--platform) PLATFORM="$2"; shift 2 ;;
--gate) GATE="$2"; shift 2 ;;
*) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] && [ -n "$OUT" ] || exit 2
mkdir -p "$OUT"
WORK="$(mktemp -d)"
CBM_RUNTIME_DIR="$(mktemp -d /tmp/cbm-rt.XXXXXX)" # sun_path must fit
export CBM_RUNTIME_DIR
trap 'rm -rf "$WORK" "$CBM_RUNTIME_DIR"' EXIT
export CBM_CACHE_DIR="$WORK/cache"
mkdir -p "$WORK/engine" && chmod 700 "$WORK/engine"
base="$(git -C "$ROOT" rev-parse "$BASE")"
artifact=()
bundle_rc=0
select_base="$base" # the commit the selection diffs against: the bundle's own
ancestor_note=""
"$ROOT/scripts/ci/test-impact-bundle.sh" --fetch "$base" "$WORK/bundle" || bundle_rc=$?
if [ "$bundle_rc" -eq 3 ]; then
# The producer skips main commits in merge bursts (GitHub keeps one pending
# run per concurrency group). Use the nearest first-parent ancestor A with
# a bundle instead, and diff against A: A..HEAD carries main's own commits
# since A as well as the PR, so A's coverage map stays exact for its base.
for candidate in $(git -C "$ROOT" rev-list --first-parent --max-count="$((ANCESTOR_MAX + 1))" "$base" | tail -n +2); do
rm -rf "$WORK/bundle"
bundle_rc=0
"$ROOT/scripts/ci/test-impact-bundle.sh" --fetch "$candidate" "$WORK/bundle" || bundle_rc=$?
if [ "$bundle_rc" -ne 3 ]; then
[ "$bundle_rc" -eq 0 ] && select_base="$candidate"
break
fi
done
if [ "$select_base" != "$base" ]; then
# Exact-revert guard: a file main changed after A that the PR changes
# back drops out of A..HEAD. Then A's map cannot speak for it: no coverage.
# A diff that fails proves nothing either: no coverage.
if git -C "$ROOT" diff --no-renames --name-only "$base" HEAD > "$WORK/pr-files.txt" &&
git -C "$ROOT" diff --no-renames --name-only "$select_base" HEAD > "$WORK/ancestor-files.txt"; then
missing=$(comm -23 <(sort -u "$WORK/pr-files.txt") <(sort -u "$WORK/ancestor-files.txt"))
else
missing="(git diff failed)"
fi
distance=$(git -C "$ROOT" rev-list --first-parent --count "$select_base..$base")
if [ -n "$missing" ]; then
echo "::warning::test-impact: ancestor bundle $select_base unusable (the change reverts main's own change in: $(echo "$missing" | head -3 | tr '\n' ' ')): selecting without coverage"
ancestor_note="ancestor $select_base rejected by the revert guard"
select_base="$base"
bundle_rc=3
else
ancestor_note="ancestor $select_base, $distance first-parent commits before the merge base"
fi
fi
fi
if [ "$bundle_rc" -eq 0 ]; then
artifact=(--artifact "$WORK/bundle" --artifact-commit "$select_base" --artifact-verified)
elif [ "$bundle_rc" -ne 3 ]; then
# No bundle means no coverage: more tests run, never fewer. Say so.
echo "::warning::test-impact bundle fetch failed (exit $bundle_rc): selecting without coverage"
fi
started=$(date +%s)
rm -f "$OUT/selection-vetoed.json"
select_rc=0
"$BINARY" test-impact select --repo "$ROOT" --base "$select_base" --work "$WORK/engine" \
--platform "$PLATFORM" ${artifact[@]+"${artifact[@]}"} \
--vetoed-out "$OUT/selection-vetoed.json" > "$OUT/selection-engine.json" \
2> "$WORK/select-stderr.txt" || select_rc=$?
cat "$WORK/select-stderr.txt" >&2 # the engine's diagnostic stays in the log too
[ "$select_rc" -eq 0 ] || exit 1
elapsed=$(( $(date +%s) - started ))
python3 - "$OUT/selection-engine.json" "$elapsed" "${#artifact[@]}" "$OUT/selection-vetoed.json" \
"$ancestor_note" "$WORK/select-stderr.txt" > "$OUT/summary-engine.md" <<'PY'
import collections, json, os, sys
answer = json.load(open(sys.argv[1]))
coverage = answer.get("receipt", {}).get("coverage", {})
def lane_lines(doc, prefix):
out = []
for lane in doc.get("lanes") or []:
suites = lane.get("suites") or []
whole = [s for s in suites if s.get("mode") == "whole"]
narrowed = {s["suite"]: len(s.get("tests") or []) for s in suites if s.get("mode") == "tests"}
why = collections.Counter(r for s in whole for r in (s.get("reasons") or []))
hist = ", ".join(f"{name} {n}" for name, n in why.most_common())
because = (lane.get("because") or {}).get("reasons") or []
out.append(f"{prefix}lane `{lane.get('lane')}` ({lane.get('decision')}): {len(whole)} whole"
f"{f' [{hist}]' if hist else ''}, {len(narrowed)} narrowed "
f"({sum(narrowed.values())} tests)"
f"{f'; lane reasons {because}' if lane.get('decision') == 'run_all' and because else ''}")
return out
run_all_reasons = answer.get("run_all_reasons") or []
run_all_rules = answer.get("run_all_rules") or []
reasons_text = f"; run-all reasons {run_all_reasons}" if run_all_reasons else ""
if run_all_rules:
reasons_text += f"; decided by rule(s) {run_all_rules}"
lines = ["### test-impact engine", "",
f"- decision: `{answer.get('decision')}` in {sys.argv[2]} s{reasons_text}",
f"- team artifact: {'fetched' if sys.argv[3] != '0' else 'none'}"
f"{f' ({sys.argv[5]})' if sys.argv[5] else ''}; coverage "
f"`{coverage.get('state')}` {coverage.get('rejection_reasons') or ''}"]
note = open(sys.argv[6]).read().strip() if os.path.exists(sys.argv[6]) else ""
if note:
lines.append(f"- engine note: `{note.splitlines()[0][:300]}`")
lines += lane_lines(answer, "- ")
if os.path.exists(sys.argv[4]):
lines.append("- the runner-main veto (RUNNER_REACHED) discarded this selection:")
lines += lane_lines(json.load(open(sys.argv[4])), " - ")
print("\n".join(lines))
PY
if [ -n "$GATE" ]; then
mkdir -p "$GATE"
selection=all
filter_rc=0
python3 "$ROOT/scripts/test-impact/selection.py" filter "$OUT/selection-engine.json" \
--lane unit --out "$GATE/test-only.txt" --optional-out "$GATE/optional-suites.txt" ||
filter_rc=$?
if [ "$filter_rc" -eq 0 ]; then
selection=narrowed
echo "- PR CI runs $(wc -l < "$GATE/test-only.txt" | tr -d ' ') selection tokens" \
>> "$OUT/summary-engine.md"
else
rm -f "$GATE/test-only.txt" "$GATE/optional-suites.txt"
echo "- PR CI runs every test (selection exit $filter_rc)" >> "$OUT/summary-engine.md"
fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "selection=$selection" >> "$GITHUB_OUTPUT"
fi
fi
cat "$OUT/summary-engine.md"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$OUT/summary-engine.md" >> "$GITHUB_STEP_SUMMARY"
fi