Orchestrata Ultimate › Gates › affected-selection
Gate: affected-selection (RUL-040)
| Purpose | Run only the tests a change can affect, and say by name why when a change cannot be bounded |
| Where it runs | scripts/run_tests.py --affected <range> step 0.75, called by scripts/hooks/pre-push and by deploy/ship.sh verify |
| Trigger | Every push with committed changes against origin/master; every ship |
| Exact command | .venv/bin/python scripts/run_tests.py --affected "origin/master...<sha>" (hook); the selector alone: python scripts/affected_tests.py --since <rev> [--json] or --files <path>... |
| Blocks | A refused verdict aborts the run ("affected-refusal") naming each missing fact, unless --affected-decision <citation> records a human decision. bounded and named_full run the resolved subset |
| Ruling / ADR | RUL-040 (Enforcement: gate scripts/affected_tests.py RUL-040, gate scripts/run_tests.py RUL-040); RUL-029; RUL-032; ADR-0118 |
Why it exists
RUL-040 (Mohamed, 2026-10-20): "never escalate=true is meaningless, it must be measured
never the whole suite that is a huge bug", and "LLM doesn't determine that, deterministic
code through blast radius". The selector's own docstring records why: "2026-08-11: a
2-file deploy/hook change escalated to the full 32k suite because these edges were
invisible". Before that, the 2026-09-21 ruling: "No test that passes must run twice ... If
the hook escalates to a full run then the logic is wrong." 2669f4882 (2026-10-21)
removed the boolean escalate arm: the selector emits a verdict row per changed file.
How a change is resolved
scripts/affected_tests.py (module docstring, AST toolkit) combines three sources:
- The import closure from
plans/ast-ultimate.json(src) andplans/ast-tests.json(tests): for a changedsrc/module, walk importers backwards to depth 3 (MAX_DEPTH) and select every test module that imports any of them. - Reference edges (
plans/reference-edges.json, fromscripts/extract_reference_edges.py): subprocess argv literals (how hook tests bind to.claude/hooks/*.sh), pytest-bdd scenario bindings, Prologconsult(...)paths, and reads of module-level path constants. - The manifest (
plans/affected-tests-manifest.json): only dynamic leftovers, plus thescripts/X.py -> tests/scripts/test_X.pystem rule.
The floor tests/infrastructure always runs (SMOKE_FLOOR).
Per-file verdicts. Each changed file gets a row: resolved (with the edges that
selected tests), inert (documentation with zero reference edges: smoke floor only),
named_full (a change whose honest blast radius is the whole suite, recorded by name), or
refused (no resolution). A changed file outside src/ and tests/ with no edges and no
manifest coverage is an untested-file defect to report, not a reason to run the suite
(_UNTESTED_IS_NOT_A_FULL_SUITE). The run-level verdict is bounded, named_full or
refused.
Plans in run_tests.py (_plan_affected_run, AST toolkit):
| Verdict | What runs | Report |
|---|---|---|
bounded |
the resolved subset | "subset run — non-authoritative, full suite still owed" |
named_full |
still only the resolved subset; "The whole suite is NEVER auto-run — the name is the verdict" | named_full lists each path and reason |
refused, no decision |
nothing: abort | the refused reasons, named |
refused, with --affected-decision |
the bounded remainder | the decision and the refusal rows |
every row inert |
nothing beyond passing (GATE-AFFECTED, 68024751d, 2026-10-21): infra, gates and smoke floor are skipped |
"all changes inert (non-code)" |
Real output
The item-3 resolution of 2026-10-22 (affected-item3.json in the session scratchpad):
verdict bounded, with rows such as
.claude/hooks/inject-codebase-map.sh: resolved via 1 reference edge(s) (tests/scripts/test_session_start_hooks.py) and
docs/CODEBASE_MAP.md: documentation with zero reference edges — certified inert, smoke floor only. The run that followed selected 134 tests in 7 modules.
Typical failures and how to fix them
| Failure | Cause | Fix |
|---|---|---|
REFUSED ... outside the serialized import closure |
plans/ast-ultimate.json lacks a new src/ file, for example after restoring the committed snapshot (improvement log F3, D14) |
Refresh the snapshot before the run; never restore a generated input between measuring and dispatching |
origin/master not found — cannot bound the blast radius |
no base in this checkout | Fetch, or pass --affected-decision with a citation |
file or directory not found at collection |
the selection kept deleted test paths transferred to codetools (375509928, 2026-10-22: 70 modules did not run) |
Fixed: every absent path now leaves the selection |
Refusal on .opencode/*, empty __init__.py, build scripts |
fixed in 306e9eb5e (2026-10-21): they are inert |
— |
A named_full row |
a dependency, env or root-conftest change | Read the name; run more only if the blast radius requires it |
Open against the ruling. RUL-040 asks for method-level blast radius and a function-level
reuse ledger. In this tree the reuse ledger is module-level (scripts/test_green_ledger.py
docstring); codetools has a select-affected verb (8f82f29f, 2026-10-20) that RUL-040's
Stage 0 chose as the seam (e7570a88d). Whether orchestrata's pre-push consumes that verb
today is not measured.