litellm/tests/vacuous_tests/inventory.py
mateo d145aa6bf8 fix(tests): stop misreading named exceptions and coverage timeouts in the vacuous tooling
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 09:10:22 +00:00

650 lines
25 KiB
Python

"""Stage A of the vacuous-test audit: a static inventory of *candidate* vacuous tests.
A vacuous test is one that cannot fail when the code it claims to test is
broken. That property is not statically decidable, so this script only
produces candidates; `mutation_probe.py` (Stage B) is what actually decides,
by mutating the lines a single test covers and checking whether the test
notices.
Two jobs:
1. Ratchet (CI). `--check` compares the candidates found now against the ones
named in `inventory_baseline.json` and fails on any candidate the baseline
does not already name, so a new vacuous test cannot land by taking the slot
of one that was fixed. Regenerate with `--update-baseline` after a cleanup
or a rename.
2. Queue (automation). `--queue N` prints the next N candidates for the daily
run, skipping anything Stage B has already cleared in
`verified_not_vacuous.json`. `--todays-area` keeps a run inside one area,
rotating by date so each PR stays reviewable by one owner and no state file
is needed.
Usage:
python tests/vacuous_tests/inventory.py --report
python tests/vacuous_tests/inventory.py --check
python tests/vacuous_tests/inventory.py --update-baseline
python tests/vacuous_tests/inventory.py --areas
python tests/vacuous_tests/inventory.py --queue 15 --todays-area
python tests/vacuous_tests/inventory.py --queue 15 --area tests/litellm_utils_tests
"""
from __future__ import annotations
import argparse
import ast
import json
import os
import sys
import warnings
from collections import Counter
from dataclasses import dataclass
from datetime import date
from typing import Dict, FrozenSet, Iterable, List, Optional, Sequence, Set, Tuple, Union
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TOOL_DIR = os.path.join(REPO_ROOT, "tests", "vacuous_tests")
BASELINE_PATH = os.path.join(TOOL_DIR, "inventory_baseline.json")
CLEARED_PATH = os.path.join(TOOL_DIR, "verified_not_vacuous.json")
TESTS_ROOT = os.path.join(REPO_ROOT, "tests")
# Buckets, most to least specific. A test is reported under exactly one bucket
# (the first that matches) so counts stay stable when a test has several
# problems.
BUCKETS = (
"dead_skip",
"swallowed_failure",
"trivial_assert",
"mock_tautology",
"no_assert",
)
# Directories whose tests are out of scope: they talk to live providers, take
# minutes, or measure performance rather than behaviour, so "does a mutant kill
# it" is either unaffordable or meaningless.
EXCLUDED_DIRS = (
"tests/load_tests",
"tests/benchmarks",
"tests/e2e",
"tests/multi_instance_e2e_tests",
"tests/old_proxy_tests",
"tests/pass_through_tests",
"tests/vacuous_tests",
)
MOCK_FACTORIES = {"Mock", "MagicMock", "AsyncMock", "NonCallableMock", "patch"}
ASSERT_CALL_PREFIXES = ("assert_", "assert", "check_", "verify_", "expect_")
PYTEST_ASSERT_FUNCS = {"raises", "fail", "approx", "warns", "deprecated_call"}
# Handler bodies made up only of these are swallowing the failure.
SWALLOWING_CALLS = {"skip", "xfail", "print", "warn", "debug", "info", "warning"}
ASSERTION_CATCHERS = frozenset({"AssertionError", "Exception", "BaseException"})
# Attributes that only ever hold what the test itself configured or recorded on a mock.
MOCK_CONFIG_ATTRS = ("return_value", "side_effect", "call_args", "await_args", "call_args_list")
@dataclass(frozen=True)
class Candidate:
path: str # repo-relative
lineno: int
name: str # dotted: Class.test_method or test_func
bucket: str
evidence: str
@property
def test_id(self) -> str:
return f"{self.path}::{'::'.join(self.name.split('.'))}"
def to_json(self) -> Dict[str, object]:
return {
"test_id": self.test_id,
"path": self.path,
"lineno": self.lineno,
"name": self.name,
"bucket": self.bucket,
"evidence": self.evidence,
}
TestFunction = Union[ast.FunctionDef, ast.AsyncFunctionDef]
def _decorator_names(node: TestFunction) -> List[str]:
names = []
for dec in node.decorator_list:
target = dec.func if isinstance(dec, ast.Call) else dec
names.append(ast.unparse(target))
return names
def _decorators(node: TestFunction) -> List[ast.expr]:
return list(node.decorator_list)
def _call_name(node: ast.AST) -> Optional[str]:
if not isinstance(node, ast.Call):
return None
func = node.func
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return None
def _mock_expression(value: ast.expr, mock_names: Set[str]) -> bool:
"""True for a mock factory call or an attribute chain rooted at a known mock.
Deliberately narrow: an expression that merely *passes* a mock into real code
(`result = handler(mock_client)`) is not mock-derived, because production code
still decides the outcome.
"""
node = value.value if isinstance(value, ast.Await) else value
if isinstance(node, ast.Call):
return _call_name(node) in MOCK_FACTORIES
if isinstance(node, ast.Name):
return node.id in mock_names
while isinstance(node, ast.Attribute):
node = node.value
return isinstance(node, ast.Name) and node.id in mock_names
def _mock_bound_names(fn: TestFunction) -> Set[str]:
"""Names in `fn` bound to a Mock/patch result, transitively."""
mock_names: Set[str] = set()
def rhs_is_mock(value: ast.expr) -> bool:
return _mock_expression(value, mock_names)
# Two passes so `b = a.child` picks up a mock bound later in source order.
for _ in range(2):
for node in ast.walk(fn):
targets: List[ast.expr] = []
value: Optional[ast.expr] = None
if isinstance(node, ast.Assign):
targets, value = list(node.targets), node.value
elif isinstance(node, ast.AnnAssign) and node.value is not None:
targets, value = [node.target], node.value
elif isinstance(node, (ast.With, ast.AsyncWith)):
for item in node.items:
if item.optional_vars is not None and rhs_is_mock(item.context_expr):
targets.append(item.optional_vars)
value = item.context_expr
if value is None or not rhs_is_mock(value):
continue
for target in targets:
for sub in ast.walk(target):
if isinstance(sub, ast.Name):
mock_names.add(sub.id)
# Fixture/decorator-injected mocks: `@patch(...)` passes the mock in as an
# argument, conventionally named mock_*.
for arg in fn.args.args:
if arg.arg.startswith("mock") or arg.arg.endswith("_mock"):
mock_names.add(arg.arg)
return mock_names
def _local_names(node: ast.AST) -> Set[str]:
return {sub.id for sub in ast.walk(node) if isinstance(sub, ast.Name)}
def _is_assertive_node(node: ast.AST) -> bool:
if isinstance(node, ast.Assert):
return True
name = _call_name(node)
if name is None:
return False
if name in PYTEST_ASSERT_FUNCS:
return True
if name.startswith(ASSERT_CALL_PREFIXES):
return True
return False
def _has_assertion(fn: TestFunction) -> bool:
return any(_is_assertive_node(node) for node in ast.walk(fn))
def _is_truthy_constant(node: ast.expr) -> bool:
if isinstance(node, ast.Constant):
return bool(node.value)
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
return bool(node.elts)
if isinstance(node, ast.Dict):
return bool(node.keys)
return False
def _constant_locals(fn: TestFunction) -> Set[str]:
"""Names assigned a truthy literal exactly once and never rebound."""
assigned: Dict[str, int] = {}
literal: Set[str] = set()
for node in ast.walk(fn):
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
assigned[target.id] = assigned.get(target.id, 0) + 1
if _is_truthy_constant(node.value):
literal.add(target.id)
return {name for name in literal if assigned.get(name) == 1}
def _unconditional_skip(fn: TestFunction, decorators: Iterable[ast.expr]) -> Optional[str]:
for dec in decorators:
target = dec.func if isinstance(dec, ast.Call) else dec
rendered = ast.unparse(target)
if rendered.endswith("mark.skip"):
return f"@{rendered}"
if rendered.endswith("mark.skipif") and isinstance(dec, ast.Call) and dec.args:
if _is_truthy_constant(dec.args[0]):
return f"@{rendered}(<always true>)"
body = [s for s in fn.body if not _is_docstring(s)]
if body and isinstance(body[0], ast.Expr):
name = _call_name(body[0].value)
if name in {"skip", "xfail"} and isinstance(body[0].value, ast.Call):
func = body[0].value.func
if isinstance(func, ast.Attribute) and ast.unparse(func).startswith("pytest."):
return f"unconditional pytest.{name}() as first statement"
return None
def _is_docstring(stmt: ast.stmt) -> bool:
return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str)
def _handler_swallows(handler: ast.ExceptHandler) -> bool:
"""True when the handler cannot surface the failure it caught."""
body = [s for s in handler.body if not _is_docstring(s)]
if not body:
return True
for stmt in body:
if isinstance(stmt, (ast.Pass, ast.Continue, ast.Break)):
continue
if isinstance(stmt, ast.Return) and stmt.value is None:
continue
if isinstance(stmt, ast.Expr):
name = _call_name(stmt.value)
if name in SWALLOWING_CALLS:
continue
return False
return True
def _swallowed_assertion(fn: TestFunction) -> Optional[str]:
for node in ast.walk(fn):
if not isinstance(node, ast.Try):
continue
if not any(_is_assertive_node(sub) for stmt in node.body for sub in ast.walk(stmt)):
continue
for handler in node.handlers:
# `except KeyError: pass` around an assert is deliberate setup
# tolerance; only a handler that can eat the AssertionError counts.
if not _swallows_assertion_errors(handler):
continue
if _handler_swallows(handler):
return f"assert inside try/{ast.unparse(handler.type) if handler.type else 'except'} whose handler swallows the failure (line {handler.lineno})"
return None
def _caught_names(handler: ast.ExceptHandler) -> FrozenSet[str]:
"""The exception names a handler catches, unqualified and tuples flattened.
Matching the unparsed text instead reads `HTTPException` as broad.
"""
if handler.type is None:
return frozenset()
caught = handler.type.elts if isinstance(handler.type, ast.Tuple) else [handler.type]
return frozenset(
node.attr if isinstance(node, ast.Attribute) else node.id
for node in caught
if isinstance(node, (ast.Attribute, ast.Name))
)
def _swallows_assertion_errors(handler: ast.ExceptHandler) -> bool:
return handler.type is None or bool(_caught_names(handler) & ASSERTION_CATCHERS)
def _trivial_assert(fn: TestFunction, constant_names: Set[str]) -> Optional[str]:
for node in ast.walk(fn):
if not isinstance(node, ast.Assert):
continue
test = node.test
if _is_truthy_constant(test):
return f"`assert {ast.unparse(test)}` (line {node.lineno})"
if isinstance(test, ast.Name) and test.id in constant_names:
return f"`assert {test.id}` where {test.id} is a literal (line {node.lineno})"
if isinstance(test, ast.Compare) and len(test.comparators) == 1:
if ast.unparse(test.left) == ast.unparse(test.comparators[0]):
return f"`assert {ast.unparse(test)}` compares a value with itself (line {node.lineno})"
return None
def _mock_tautology(fn: TestFunction, mock_names: Set[str]) -> Optional[str]:
if not mock_names:
return None
for node in ast.walk(fn):
if not isinstance(node, ast.Assert):
continue
test = node.test
if not isinstance(test, ast.Compare) or len(test.comparators) != 1:
continue
sides = (test.left, test.comparators[0])
if not all(_mock_expression(side, mock_names) for side in sides):
continue
if not all(any(attr in ast.unparse(side) for attr in MOCK_CONFIG_ATTRS) for side in sides):
continue
if _local_names(test) - mock_names:
continue
return f"`assert {ast.unparse(test)}` compares two mock-derived values (line {node.lineno})"
return None
def _module_level_skip(tree: ast.Module) -> Optional[str]:
for node in tree.body:
targets = node.targets if isinstance(node, ast.Assign) else []
if not any(isinstance(t, ast.Name) and t.id == "pytestmark" for t in targets):
continue
rendered = ast.unparse(node.value)
if "mark.skip" in rendered and "skipif" not in rendered:
return "module-level pytestmark skip"
return None
def _is_main_guard(node: ast.If) -> bool:
test = node.test
return (
isinstance(test, ast.Compare)
and isinstance(test.left, ast.Name)
and test.left.id == "__name__"
and len(test.comparators) == 1
and isinstance(test.comparators[0], ast.Constant)
and test.comparators[0].value == "__main__"
)
def _nested_scopes(child: ast.stmt) -> Tuple[Sequence[ast.stmt], ...]:
if isinstance(child, ast.If):
# Nothing under `if __name__ == "__main__"` runs on import, so pytest never sees it
return (child.orelse,) if _is_main_guard(child) else (child.body, child.orelse)
if isinstance(child, ast.Try):
return (child.body, child.orelse, child.finalbody, *(handler.body for handler in child.handlers))
if isinstance(child, (ast.With, ast.AsyncWith)):
return (child.body,)
if isinstance(child, (ast.For, ast.AsyncFor, ast.While)):
return (child.body, child.orelse)
return ()
def _scope_statements(body: Sequence[ast.stmt]) -> List[ast.stmt]:
"""Every statement pytest sees at this scope.
A `def test_x` guarded by `if` or `try` still binds on the module or class,
so pytest collects it; only another function's body is a different scope.
"""
nested = [stmt for child in body for group in _nested_scopes(child) for stmt in group]
return list(body) + (_scope_statements(nested) if nested else [])
def classify_file(path: str, source: str) -> List[Candidate]:
rel = os.path.relpath(path, REPO_ROOT).replace(os.sep, "/")
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
tree = ast.parse(source)
except SyntaxError:
return []
module_skip = _module_level_skip(tree)
candidates: List[Candidate] = []
def visit(node: Union[ast.Module, ast.ClassDef], prefix: str) -> None:
for child in _scope_statements(node.body):
if isinstance(child, ast.ClassDef):
if child.name.startswith("Test"):
visit(child, f"{prefix}{child.name}.")
continue
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
# pytest's default python_functions is `test*`, so `testFoo` counts too
if not child.name.startswith("test"):
continue
found = classify_test(child, module_skip)
if found is not None:
bucket, evidence = found
candidates.append(
Candidate(
path=rel,
lineno=child.lineno,
name=f"{prefix}{child.name}",
bucket=bucket,
evidence=evidence,
)
)
visit(tree, "")
return candidates
def classify_test(fn: TestFunction, module_skip: Optional[str]) -> Optional[Tuple[str, str]]:
decorators = _decorators(fn)
skip = module_skip or _unconditional_skip(fn, decorators)
if skip:
return "dead_skip", skip
swallowed = _swallowed_assertion(fn)
if swallowed:
return "swallowed_failure", swallowed
trivial = _trivial_assert(fn, _constant_locals(fn))
if trivial:
return "trivial_assert", trivial
mock_names = _mock_bound_names(fn)
tautology = _mock_tautology(fn, mock_names)
if tautology:
return "mock_tautology", tautology
if not _has_assertion(fn):
return "no_assert", "no assert, pytest.raises/fail, or assert_* call in the test body"
return None
def iter_test_files(root: str = TESTS_ROOT) -> Iterable[str]:
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = os.path.relpath(dirpath, REPO_ROOT).replace(os.sep, "/")
if any(rel_dir == ex or rel_dir.startswith(ex + "/") for ex in EXCLUDED_DIRS):
dirnames[:] = []
continue
dirnames[:] = sorted(d for d in dirnames if d not in {"__pycache__", ".pytest_cache"})
for filename in sorted(filenames):
if filename.endswith(".py") and (filename.startswith("test_") or filename.endswith("_test.py")):
yield os.path.join(dirpath, filename)
def collect(root: str = TESTS_ROOT) -> List[Candidate]:
candidates: List[Candidate] = []
for path in iter_test_files(root):
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
candidates.extend(classify_file(path, handle.read()))
return sorted(candidates, key=lambda c: (c.path, c.lineno))
def to_identities(candidates: Iterable[Candidate]) -> Dict[str, Dict[str, List[str]]]:
"""Which tests are candidates, per file and bucket.
The baseline records names, not counts, so that a fixed test being replaced
by a newly vacuous one in the same file cannot ride through on an unchanged
count.
"""
grouped: Dict[str, Dict[str, List[str]]] = {}
for candidate in candidates:
grouped.setdefault(candidate.path, {}).setdefault(candidate.bucket, []).append(candidate.name)
return {
path: {bucket: sorted(names) for bucket, names in sorted(buckets.items())}
for path, buckets in sorted(grouped.items())
}
def load_json(path: str, default: object) -> object:
if not os.path.exists(path):
return default
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
def cleared_ids() -> Set[str]:
data = load_json(CLEARED_PATH, {})
if isinstance(data, dict):
return set(data.get("test_ids", []))
return set()
def write_baseline(identities: Dict[str, Dict[str, List[str]]]) -> None:
totals: Dict[str, int] = {}
for buckets in identities.values():
for bucket, names in buckets.items():
totals[bucket] = totals.get(bucket, 0) + len(names)
payload = {
"_comment": (
"Ratchet baseline for tests/vacuous_tests/inventory.py. It names every known "
"candidate per file and bucket, and any candidate missing from it fails the "
"check. Regenerate with --update-baseline after a cleanup or a rename."
),
"totals": dict(sorted(totals.items())),
"files": identities,
}
with open(BASELINE_PATH, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=False)
handle.write("\n")
def regressions(
identities: Dict[str, Dict[str, List[str]]],
baseline_files: Dict[str, Dict[str, List[str]]],
) -> List[str]:
return sorted(
f"{path}::{name} is a new {bucket} candidate"
for path, buckets in identities.items()
for bucket, names in buckets.items()
for name in names
if name not in baseline_files.get(path, {}).get(bucket, [])
)
def check_against_baseline(identities: Dict[str, Dict[str, List[str]]]) -> int:
baseline = load_json(BASELINE_PATH, None)
if baseline is None:
print(
f"ERROR: no baseline at {os.path.relpath(BASELINE_PATH, REPO_ROOT)}; run with --update-baseline",
file=sys.stderr,
)
return 1
base_files: Dict[str, Dict[str, List[str]]] = baseline["files"]
failures = regressions(identities, base_files)
if failures:
print("Vacuous-test ratchet failed. New candidate vacuous tests:\n", file=sys.stderr)
for line in failures:
print(f" - {line}", file=sys.stderr)
print(
"\nEach bucket is explained in tests/vacuous_tests/README.md. Make the new "
"test assert something a mutant can break. A renamed or moved candidate "
"lands here too; if this is a rename, or a deliberate assert-by-not-raising "
"test with a docstring saying so, regenerate the baseline with:\n"
" python tests/vacuous_tests/inventory.py --update-baseline",
file=sys.stderr,
)
return 1
improvements = sum(
1
for path, buckets in base_files.items()
for bucket, names in buckets.items()
for name in names
if name not in identities.get(path, {}).get(bucket, [])
)
print(f"Vacuous-test ratchet OK ({improvements} candidate(s) below baseline).")
return 0
def print_report(candidates: List[Candidate]) -> None:
totals: Dict[str, int] = {bucket: 0 for bucket in BUCKETS}
for candidate in candidates:
totals[candidate.bucket] += 1
print(f"candidate vacuous tests: {len(candidates)} across {len({c.path for c in candidates})} files")
for bucket in BUCKETS:
print(f" {bucket:<20} {totals[bucket]}")
def area_of(path: str) -> str:
parts = path.split("/")
return "/".join(parts[:3]) if len(parts) > 3 else os.path.dirname(path)
def areas(candidates: Sequence[Candidate]) -> Tuple[Tuple[str, int], ...]:
cleared = cleared_ids()
open_candidates = tuple(c for c in candidates if c.test_id not in cleared)
return tuple(
sorted(
Counter(area_of(c.path) for c in open_candidates).items(),
key=lambda item: (-item[1], item[0]),
)
)
def rotated_area(candidates: Sequence[Candidate], day: date) -> Optional[str]:
"""Pick one area per day without storing state, so reviewers get one area per PR."""
ranked = areas(candidates)
return ranked[day.toordinal() % len(ranked)][0] if ranked else None
def print_queue(candidates: List[Candidate], limit: int, area: Optional[str]) -> None:
cleared = cleared_ids()
queue = [c for c in candidates if c.test_id not in cleared and (area is None or c.path.startswith(area))]
# Most-specific buckets first: they are the highest-confidence candidates,
# so the daily run spends its mutation budget where it pays off.
order = {bucket: index for index, bucket in enumerate(BUCKETS)}
queue.sort(key=lambda c: (order[c.bucket], c.path, c.lineno))
print(json.dumps([c.to_json() for c in queue[:limit]], indent=2))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail if counts grew over baseline")
parser.add_argument("--update-baseline", action="store_true")
parser.add_argument("--report", action="store_true", help="print per-bucket totals")
parser.add_argument("--json", metavar="PATH", help="write the full candidate list")
parser.add_argument("--queue", type=int, metavar="N", help="print the next N candidates")
parser.add_argument("--area", help="restrict --queue to a path prefix")
parser.add_argument("--areas", action="store_true", help="print candidate counts per area")
parser.add_argument(
"--todays-area",
action="store_true",
help="print the area this day's run should take, rotating by date",
)
parser.add_argument("--root", default=TESTS_ROOT, help="tests root to scan")
args = parser.parse_args()
candidates = collect(args.root)
identities = to_identities(candidates)
if args.json:
with open(args.json, "w", encoding="utf-8") as handle:
json.dump([c.to_json() for c in candidates], handle, indent=2)
handle.write("\n")
if args.update_baseline:
write_baseline(identities)
print(f"wrote {os.path.relpath(BASELINE_PATH, REPO_ROOT)}")
today = rotated_area(candidates, date.today())
if args.areas:
for area, count in areas(candidates):
print(f" {area:<50} {count}")
if args.todays_area and not args.queue:
print(today or "")
if args.queue:
print_queue(candidates, args.queue, args.area or (today if args.todays_area else None))
if args.report or not (
args.check or args.update_baseline or args.queue or args.json or args.areas or args.todays_area
):
print_report(candidates)
if args.check:
return check_against_baseline(identities)
return 0
if __name__ == "__main__":
sys.exit(main())