Files
codebase-memory-mcp/scripts/test-impact/selection.py
T
Martin Vogel 776bb119ac ci(test-impact): team artifact producer, PR shadow and the PR-CI narrowing switch
Producer. test-impact-artifact.yml publishes, on every push to main, the
commit's team artifact bundle: its frozen graph, built incrementally from the
previous bundle, and its per-test coverage map, re-run only for the suites
the push selects (scripts/ci/test-impact-publish.sh). A weekly run rebuilds
both from scratch and fails when the incremental graph's topology differs
from the full one. Bundles are Actions artifacts kept 30 days, never
committed.

Shadow. The report-only test-impact-shadow job also builds the checkout and
runs `test-impact select` against the base side of the PR merge commit
(scripts/ci/test-impact-select.sh). A bundle is used only when it comes from
a successful main-branch producer run for exactly that commit
(scripts/ci/test-impact-bundle.sh). The job stays continue-on-error and
outside ci-ok.

Narrowing switch, PR CI only. While the repository variable
TEST_IMPACT_MODE is `gate`, a select-tests job turns the engine's answer into
the tests the test-unix and test-windows legs run. Unset, nothing changes.
It fails open by construction:
- scripts/test-impact/selection.py is the only place an answer becomes a
  runner filter. Only a well-formed selected lane whose filter matches its
  own suite list narrows; run-all answers, a lane that selects nothing,
  unknown schemas or tokens, and any crash run everything.
- `test` passes a selection only from a successful, narrowed select-tests
  run; ci-ok does not need select-tests. A broken selection costs minutes,
  never coverage.
- scripts/run-tests-parallel.sh launches only the selected suites and keeps
  every guard on the selected list. A selected suite the build does not
  register fails the run, unless the engine saw its registration as
  conditional; then it is dropped with a notice, exactly as a full run of
  that build never runs it. Each runner judges its own tokens, so a selected
  test that does not exist fails its suite.
- The shard manifest records the selection's digest, and
  verify-shard-union.sh requires every shard of a leg to agree on it.
- Dry runs and releases never pass a selection (lane-wiring contract).

Also fixed on the way:
- publish crashed on any run-all push: a run-all lane carries
  "suites": null.
- the git-facts hostile-diff probe re-ran the runner with the parent's
  CBM_TEST_ONLY_FILE still set; under a narrowed run naming its suite it
  re-ran the spawning test, which spawned again. The child now clears it
  (a new test fails without that).
- the select summary crashed on the same null suites.

Contracts: tests/test_test_impact_selection.sh (Step 0z8),
tests/test_harness_selection.sh (Step 2c, the real runner through the real
harness), and new cases in the lane-gate and lane-wiring contracts.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-05 18:52:31 +02:00

135 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Turn a test-impact answer (`codebase-memory-mcp test-impact select`) into
what a CI consumer runs.
selection.py suites ANSWER
One line per suite whose tests the answer selects, or a single `*` when a
lane runs everything. The team-artifact producer re-runs exactly these
suites with coverage (scripts/ci/test-impact-publish.sh).
selection.py filter ANSWER --lane LANE --out FILE [--optional-out FILE2]
exit 0 narrowed: FILE holds the lane's CBM_TEST_ONLY_FILE lines, and
FILE2 the selected suites the engine saw registered only
conditionally (CONDITIONAL): a build may legitimately lack them
(scripts/run-tests-parallel.sh, CBM_TEST_SELECTION_OPTIONAL)
exit 11 the lane selects nothing: FILE is empty
anything else: run everything (FILE and FILE2 are removed)
It fails safe by construction: a run-all answer, a lane it cannot find, an
unknown schema or filter encoding, a token that is not `suite` or
`suite:test`, and a filter that disagrees with the lane's own suite list all
mean "run everything", and so does any crash (exit 1).
"""
import argparse
import json
import os
import re
import sys
SCHEMAS = {"cbm.test_impact.v0", "cbm.test_impact.v1"}
TOKEN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?$")
NARROWED, NOTHING, RUN_ALL = 0, 11, 10
def suites(answer):
if answer.get("decision") == "run_all":
return ["*"]
out = []
for lane in answer.get("lanes") or []:
if lane.get("decision") == "run_all" or lane.get("suites") is None:
return ["*"]
out += [suite["suite"] for suite in lane["suites"]]
return sorted(set(out))
def run_all(why):
print(f"selection: run everything: {why}", file=sys.stderr)
return RUN_ALL
def lane_filter(answer, name):
"""-> (exit code, lines)"""
if answer.get("schema") not in SCHEMAS:
return run_all(f"unknown schema {answer.get('schema')!r}"), []
if answer.get("decision") == "run_all":
return run_all("the answer runs everything"), []
lanes = [lane for lane in answer.get("lanes") or [] if lane.get("lane") == name]
if len(lanes) != 1:
return run_all(f"lane {name!r} appears {len(lanes)} times"), []
lane = lanes[0]
decision = lane.get("decision")
if decision == "skip":
if lane.get("suites") or lane.get("runner_filter") is not None:
return run_all("a skipped lane that still lists suites or a filter"), []
return NOTHING, []
if decision != "selected" or not isinstance(lane.get("suites"), list):
return run_all(f"lane decision {decision!r}"), []
flt = lane.get("runner_filter")
if not isinstance(flt, dict) or flt.get("encoding") != "cbm-test-only-lines" or \
not isinstance(flt.get("value"), str):
return run_all("no cbm-test-only-lines filter"), []
lines = [line for line in flt["value"].split("\n") if line]
bad = [line for line in lines if not TOKEN.match(line)]
if not lines or bad:
return run_all(f"filter tokens unusable ({bad[:3] or 'empty'})"), []
expected = set()
for suite in lane["suites"]:
if suite.get("mode") == "whole":
expected.add(suite["suite"])
elif suite.get("mode") == "tests" and suite.get("tests"):
expected |= {f"{suite['suite']}:{test['test']}" for test in suite["tests"]}
else:
return run_all(f"suite {suite.get('suite')!r} has mode {suite.get('mode')!r}"), []
if set(lines) != expected:
return run_all("the filter disagrees with the lane's suite list"), []
return NARROWED, lines
def conditional_suites(answer, name):
"""The lane's selected suites whose registration the engine saw as conditional."""
for lane in answer.get("lanes") or []:
if lane.get("lane") == name:
return sorted({suite["suite"] for suite in lane.get("suites") or []
if "CONDITIONAL" in (suite.get("whole_reason") or [])})
return []
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = parser.add_subparsers(dest="command", required=True)
s = sub.add_parser("suites")
s.add_argument("answer")
f = sub.add_parser("filter")
f.add_argument("answer")
f.add_argument("--lane", required=True)
f.add_argument("--out", required=True)
f.add_argument("--optional-out")
args = parser.parse_args()
if args.command == "suites":
with open(args.answer, encoding="utf-8") as handle:
print("\n".join(suites(json.load(handle))))
return 0
for stale in (args.out, args.optional_out):
if stale and os.path.exists(stale):
os.remove(stale)
try:
with open(args.answer, encoding="utf-8") as handle:
answer = json.load(handle)
except (OSError, ValueError) as error:
return run_all(f"unreadable answer ({error})")
if not isinstance(answer, dict):
return run_all("the answer is not an object")
code, lines = lane_filter(answer, args.lane)
if code in (NARROWED, NOTHING):
with open(args.out, "w", encoding="utf-8") as handle:
handle.write("".join(line + "\n" for line in lines))
if args.optional_out:
optional = conditional_suites(answer, args.lane) if code == NARROWED else []
with open(args.optional_out, "w", encoding="utf-8") as handle:
handle.write("".join(suite + "\n" for suite in optional))
return code
if __name__ == "__main__":
sys.exit(main())