Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion docs/guides/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,39 @@ The algorithm is:
gateway production module importable by bare name), so the AST
resolver bridges those edges the same way it does for the
sys.path-injected packages. See §7 for the full rationale.

**Barrel transparency (#3182).** Packages whose `__init__.py` is a
*pure re-export barrel* (the shape mandated by
`docs/guides/decomposition-pattern.md`: docstring, imports —
including the `try/except ImportError` dual-import idiom — and an
`__all__` of string constants, nothing else) are treated
transparently by the walk. When a submodule behind such a barrel
changes, a consumer of the barrel joins the closure only if its
source statically uses a symbol backed by a tainted submodule —
via `from pkg import X`, attribute access on a whole-module
import (`pkg.X`), or a dotted string literal such as a
`unittest.mock.patch("pkg._sub.f")` target. Anything the analysis
cannot bound (impure barrel, star import, the module object
escaping into non-attribute contexts, unparsable source) falls
back to full taint, and a changed module whose transparent
closure reaches **zero** tests falls back to its opaque closure
(the *never-zero ratchet* — transparency may sharpen a selection,
never empty one). Editing a barrel `__init__.py` itself keeps the
full package-mode blast radius. Known accepted gap: a consumer
that imports a barrel only for a submodule's import-time side
effects (referencing no symbol) is not selected when that
submodule changes. This is *wider* than "the barrel is impure":
a perfectly pure re-export barrel can still front a submodule
whose import is load-bearing (registering into a global on
import), and a consumer relying on that registration while using
only an unrelated symbol is missed. The backstop is not the
purity classifier but `make test-all` — full-suite CI ground
truth — exactly as for the dynamic-import heuristic, so a
transparency miss costs a narrowed inner-loop run, never a
merge-gating one. Registration that runs at the barrel's *own*
import time (e.g. gateway's `@app.route`) is the separate case the
classifier *does* catch: it makes the `__init__.py` itself impure,
so the barrel stays opaque.
6. **Map modules → test files.** Intersect the downstream set with
the pre-collected set of every `test_*.py` / `*_test.py` file
in the graph. The selector emits the resulting set of test file
Expand All @@ -119,7 +152,12 @@ The algorithm is:
8. **Run pytest.** The `make test` recipe pipes stdout into
`pytest $(SELECTED) -v $(PYTEST_ARGS)`. If the selector emits
zero lines, the recipe skips the pytest invocation and prints
`no tests selected`.
`no tests selected`. Selected files are emitted **direct
importers first** (ordered by import distance from the changed
modules, alphabetical within a distance tier; #3182): pytest
collects files in the order given, so a wide selection surfaces
the most likely failure in the first files run rather than
wherever the alphabet put it.

A green narrow run **does not** update the LKG sidecar — only
`make test-all` writes LKG, because only a full-suite green proves
Expand Down
19 changes: 18 additions & 1 deletion scripts/select_tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,22 +140,31 @@
TEST_ROOT_DIRS,
)

# Graph + closure + bare-name resolver + PYTEST_ARGS classifier.
# Graph + closure + bare-name resolver + barrel transparency (#3182)
# + PYTEST_ARGS classifier.
from ._graph import (
_TEST_ROOT_PREFIXES,
GraphBundle,
_barrel_name_forms,
_barrel_symbols_backed_by,
_direct_importers,
_enumerate_source_paths,
_extract_imports,
_module_to_filesystem_path,
_scan_dynamic_imports,
_used_symbols,
_walk_upstream_combined,
_walk_upstream_with_depth,
build_bare_name_index,
build_bare_name_upstream_edges,
build_barrel_exports,
build_graph,
is_dynamic_import_touched,
map_modules_to_test_files,
parse_barrel_exports,
pytest_args_have_explicit_path,
reverse_closure,
reverse_closure_with_depth,
)

# Git, sidecar, baseline, and changed-files helpers.
Expand Down Expand Up @@ -230,18 +239,26 @@
# Graph helpers (public + private)
"GraphBundle",
"_TEST_ROOT_PREFIXES",
"_barrel_name_forms",
"_barrel_symbols_backed_by",
"_direct_importers",
"_enumerate_source_paths",
"_extract_imports",
"_module_to_filesystem_path",
"_scan_dynamic_imports",
"_used_symbols",
"_walk_upstream_combined",
"_walk_upstream_with_depth",
"build_bare_name_index",
"build_bare_name_upstream_edges",
"build_barrel_exports",
"build_graph",
"is_dynamic_import_touched",
"map_modules_to_test_files",
"parse_barrel_exports",
"pytest_args_have_explicit_path",
"reverse_closure",
"reverse_closure_with_depth",
# CLI helpers (public + private)
"_build_arg_parser",
"_fnmatch",
Expand Down
33 changes: 30 additions & 3 deletions scripts/select_tests/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@
from ._graph import (
GraphBundle,
_walk_upstream_combined,
_walk_upstream_with_depth,
build_graph,
is_dynamic_import_touched,
map_modules_to_test_files,
pytest_args_have_explicit_path,
reverse_closure,
reverse_closure_with_depth,
)
from ._io import (
RecordGoodValidationError,
Expand Down Expand Up @@ -514,13 +516,28 @@ def _run_narrow_or_fallback(repo_root: Path) -> int:
# neither analysis can see (e.g., subprocess invocation, runtime
# plugin discovery, or a bare-name import we haven't taught the
# resolver about).
rescue_depths: dict[str, int] = {}
if bundle is not None and trigger is None and module_path_pairs:
zero_downstream_offenders: list[str] = []
for module, _path in module_path_pairs:
if module in bundle.all_test_modules:
continue # editing a test pulls only itself; that's fine
reachable = _walk_upstream_combined(bundle, [module])
if not (reachable & bundle.all_test_modules):
if reachable & bundle.all_test_modules:
continue
# The (barrel-transparent, #3182) walk found no tests for
# this module. Distinguish a true blind spot from
# transparency filtering every consumer: the never-zero
# ratchet — transparency may sharpen a module's selection
# but never zero it out, so when the OPAQUE walk still
# reaches tests, select those instead of widening. Only
# when neither walk reaches a test does the pre-#3182
# full-suite trigger fire.
opaque_depths = _walk_upstream_with_depth(bundle, {module: 0}, barrel_aware=False)
if set(opaque_depths) & bundle.all_test_modules:
for m, d in opaque_depths.items():
rescue_depths[m] = min(rescue_depths.get(m, d), d)
else:
zero_downstream_offenders.append(module)
if zero_downstream_offenders:
trigger = f"no downstream tests for changed module: {zero_downstream_offenders[0]}"
Expand Down Expand Up @@ -556,8 +573,18 @@ def _run_narrow_or_fallback(repo_root: Path) -> int:
# ---- Narrow path ----
assert bundle is not None # narrowed above

closure = reverse_closure(bundle, module_path_pairs)
test_files = map_modules_to_test_files(bundle, closure, repo_root)
closure_depths = reverse_closure_with_depth(bundle, module_path_pairs)
for module, depth in rescue_depths.items():
if module not in closure_depths or depth < closure_depths[module]:
closure_depths[module] = depth
test_files = map_modules_to_test_files(bundle, set(closure_depths), repo_root)
# Emit direct-importers-first (#3182): pytest collects files in the
# order given on the command line, so sorting by import distance
# from the changed modules surfaces the most likely failure early
# in a wide selection. `map_modules_to_test_files` returns the
# paths alphabetically; the stable sort keeps that as the
# within-depth tiebreak.
test_files.sort(key=lambda p: closure_depths.get(path_to_module(p) or "", 1 << 30))
selected_count = len(test_files)

for path in test_files:
Expand Down
Loading
Loading