diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 34aad4f31..c9d8a641f 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -111,8 +111,83 @@ detect-blocking-io` resolve to the same repo-root path). JSON findings include
`code` for model-assisted or manual review. `priority` is a deterministic
review ordering from operation type, not proof of a bug. Bare-name same-file
calls are resolved by function name, so duplicate helper names in one file can
-conservatively over-report async reachability. It is intentionally
-informational and is not run from CI in this round.
+conservatively over-report async reachability. The call graph also resolves
+multi-hop `self.`/`cls.` attribute chains (`self.store.flush()`) and local
+variables or parameters traced back — within the same function only — to a
+`self.`/`cls.` attribute (`store = self.store; store.flush()`); both fall back
+to the same bare-method-name resolution as an unresolvable receiver, so they
+share its over-report risk rather than adding a new kind. Deeper cross-function
+or cross-module aliasing is out of scope and stays an unreported false
+negative.
+
+That same-function alias tracing is deliberately narrower than the symbolic
+names `dotted_name()` builds for blocking-call pattern matching elsewhere in
+this module: receiver/alias extraction uses a restricted extractor that only
+recognizes `Name`/`Attribute` chains, so a `Call` or `Subscript` result (e.g.
+`factory().flush()`, or `client = factory(); client.flush()` /
+`client = clients[0]; client.flush()`) is never treated as inheriting its
+base's alias-worthiness — including when the unsupported node is buried
+deeper in the chain (`factory().client.flush()`, `clients[0].client.flush()`):
+an unrecognized shape anywhere in the chain makes the whole receiver
+unresolved, it never falls back to just the chain's trailing attribute name,
+or that name alone could still collide with an unrelated traced parameter or
+local alias. Reassigning a traced name to a non-traceable value (anything
+other than a `self.`/`cls.` attribute or an already-traced name) kills its
+alias instead of leaving it traceable, so a stale alias from an earlier
+assignment cannot keep exposing an unrelated same-named method after the
+variable is reassigned to something else; the assignment's right-hand side is
+always analyzed against the alias state as it stood *before* this kill-or-add
+update, matching Python's own evaluate-then-bind order, so
+`client = client.flush()` still resolves that call against `client`'s prior
+(pre-reassignment) alias instead of the state after it's gone. `if`/`else`
+branches get isolated alias state — an alias added in one branch cannot leak
+into the other — and the state after the whole `if` is the union of what each
+branch produced (a conservative may-alias join), so the result no longer
+depends on which branch is textually `body` vs. `orelse`. This branch
+isolation is deliberately scoped to `ast.If` only; `ast.Try`/`ast.Match` have
+different, more complex control-flow semantics and keep the older unisolated
+traversal. Finally, a function's decorators and parameter defaults are
+analyzed in the *enclosing* scope rather than the new function's own, and
+parameter/return annotations get the same enclosing-scope treatment unless
+the module postpones annotation evaluation (`from __future__ import
+annotations`), in which case they are skipped entirely, in either scope —
+those expressions run at definition time, before the function has ever been
+called (or, when postponed, never run at all), so a call there is never
+attributed to the function being defined (it moves to whatever scope actually
+contains the `def`, e.g. the enclosing function, or disappears if that scope
+is module/class level and therefore never async-reachable). PEP 695
+type-parameter bounds are not visited in either scope: CPython evaluates each
+one lazily, in its own hidden function, only if something like `T.__bound__`
+is actually accessed, never as part of running the `def` statement itself.
+A `lambda`'s body and a bare generator expression's element/filters/later
+`for` clauses are excluded from traversal ONLY while walking another
+function's own definition-time expressions (decorators, parameter defaults/
+annotations, return annotation): there, we know structurally that the
+enclosing `def` statement is executing right now, and neither a lambda body
+nor a generator's element runs just because the lambda/generator object is
+created — only a lambda's own parameter defaults and a generator's
+outermost iterable are genuinely eager at that moment. This exclusion is
+absolute and has no exceptions: even a lambda that is immediately invoked at
+its own definition site (`(lambda: ...)()`), or a generator passed directly
+to an eager-consuming builtin, is still excluded when it appears inside
+another function's decorator/default/annotation — a narrow, intentional
+limitation given how rarely a definition-time expression contains an
+executed call at all, preferred over special-casing specific shapes there.
+
+Everywhere else — module level, class bodies, and ordinary function-body
+statements — a lambda body or generator expression's element is scanned
+unconditionally, the same conservative, over-report-rather-than-infer stance
+this file already takes for reachability elsewhere (the `ast.If` may-alias
+union, the bare-name call-graph resolution). This file does not attempt to
+distinguish a lambda that is invoked immediately, invoked later through a
+stored variable, passed as a callback, or never called at all, nor a
+generator that is consumed by an eager builtin (`list`, `sum`, `any`, etc.),
+wrapped in another lazy iterator (`map`, `filter`), or never consumed —
+telling these apart in the general case would mean inferring evaluation
+order and consumption across arbitrary code rather than reading a fixed,
+structural fact, so none of them are special-cased; all are scanned the
+same way. This is intentionally informational and is not run from CI in
+this round.
For a diff-scoped view of the same findings, `scripts/scan_changed_blocking_io.py`
(repo root) reports findings on the added lines of `git diff ...HEAD`
diff --git a/backend/tests/support/detectors/blocking_io_static.py b/backend/tests/support/detectors/blocking_io_static.py
index e09ac54ba..3848bb7c2 100644
--- a/backend/tests/support/detectors/blocking_io_static.py
+++ b/backend/tests/support/detectors/blocking_io_static.py
@@ -293,6 +293,32 @@ def dotted_name(node: ast.AST | None) -> str | None:
return None
+def _simple_receiver_name(node: ast.AST | None) -> str | None:
+ """Like `dotted_name`, but only for Name/Attribute chains.
+
+ `dotted_name` intentionally unwraps `ast.Call` and `ast.Subscript` to build a
+ symbolic name for blocking-call pattern matching (`visit_Call` /
+ `_blocking_rule` / `_sync_http_client_factory_base`). Reusing that for
+ receiver/alias tracking is wrong: it would make a Call or Subscript result
+ inherit its base's alias-worthiness (e.g. treating `factory()` as if it
+ were the traced name `factory`), which this restricted extractor refuses
+ to do by simply not recognizing those node shapes at all -- including when
+ one is buried further down the chain (`factory().client`,
+ `clients[0].client`): an unsupported node anywhere in the chain makes the
+ whole receiver None, it never falls back to just the trailing attribute
+ name, or that trailing name alone could still collide with an unrelated
+ traced parameter or local alias of the same name.
+ """
+ if isinstance(node, ast.Name):
+ return node.id
+ if isinstance(node, ast.Attribute):
+ parent = _simple_receiver_name(node.value)
+ if parent is None:
+ return None
+ return f"{parent}.{node.attr}"
+ return None
+
+
def relative_to_repo(path: Path, repo_root: Path = REPO_ROOT) -> str:
try:
return path.resolve().relative_to(repo_root.resolve()).as_posix()
@@ -314,6 +340,7 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
self.relative_path = relative_path
self.source_lines = source_lines
self.import_aliases: dict[str, str] = {}
+ self.postponed_annotations = False
self.class_stack: list[str] = []
self.function_stack: list[_FunctionContext] = []
self.module_context = _FunctionContext("", None, False)
@@ -325,7 +352,19 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
self.functions_by_name: dict[str, list[str]] = defaultdict(list)
self.call_refs: dict[str, list[_CallRef]] = defaultdict(list)
self.path_like_name_stack: list[set[str]] = []
+ self.local_receiver_alias_stack: list[set[str]] = []
self.potential_findings: list[_PotentialFinding] = []
+ # True only while walking ANOTHER function's own decorators,
+ # parameter defaults/annotations, or return annotation (see
+ # `_visit_function`) -- expressions that run at definition time, in
+ # the enclosing scope, before the function being defined has ever
+ # been called. `visit_Lambda`/`visit_GeneratorExp` apply their
+ # defaults-only/outermost-iterable-only lazy traversal only while
+ # this is set; it is always restored to `False` before `_visit_function`
+ # returns, and no definition-time expression can itself contain a
+ # nested `def`/`class` statement (only expressions), so this never
+ # needs to be a stack.
+ self._in_definition_time_expression = False
@property
def current_function(self) -> _FunctionContext | None:
@@ -348,6 +387,15 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module is None:
return
+ if node.module == "__future__" and any(alias.name == "annotations" for alias in node.names):
+ # `from __future__ import annotations` (PEP 563) makes CPython skip
+ # evaluating parameter/return annotations at runtime entirely --
+ # they are kept as unevaluated strings -- so a call written in one
+ # never actually runs at definition time in this module, in either
+ # scope. This is always visited before any function def that could
+ # use it: the statement is required to appear before any other
+ # code in the file (`ast.parse` itself rejects it elsewhere).
+ self.postponed_annotations = True
for alias in node.names:
local_name = alias.asname or alias.name
self.import_aliases[local_name] = f"{node.module}.{alias.name}"
@@ -366,14 +414,28 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
self._visit_function(node, is_async=True)
def visit_Assign(self, node: ast.Assign) -> None:
+ # Visit the RHS before recording anything about the assignment's own
+ # target(s): Python evaluates the value before binding it, so a call
+ # in the RHS (e.g. `client = client.flush()`) must see the receiver-
+ # alias state as it stood immediately BEFORE this assignment, not
+ # after. Updating/killing the target's alias first would make the
+ # target's own old alias disappear before the RHS that still runs
+ # under it gets a chance to be resolved.
+ self.visit(node.value)
self._record_sync_http_client_targets(node.value, node.targets)
- self.generic_visit(node)
+ self._record_local_receiver_alias_targets(node.value, node.targets)
+ for target in node.targets:
+ self.visit(target)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
self._record_path_like_annotation(node.annotation, [node.target])
if node.value is not None:
+ # Same RHS-before-target-update ordering as visit_Assign above.
+ self.visit(node.value)
self._record_sync_http_client_targets(node.value, [node.target])
- self.generic_visit(node)
+ self._record_local_receiver_alias_targets(node.value, [node.target])
+ self.visit(node.target)
+ self.visit(node.annotation)
def visit_With(self, node: ast.With) -> None:
temporary_clients: dict[str, str | None] = {}
@@ -397,6 +459,33 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
else:
current_clients[name] = previous
+ def visit_If(self, node: ast.If) -> None:
+ # `ast.If` is the only branching construct given isolated, merged alias
+ # state: `body` and `orelse` are mutually exclusive at runtime, so an
+ # alias added in one must not leak into the other, but a name aliased in
+ # either branch might still be aliased after the `if` (a conservative
+ # may-alias join) -- and the result must not depend on which branch is
+ # textually `body` vs `orelse`. `ast.Try`/`ast.Match` have different,
+ # more complex control-flow semantics (exception edges, multiple
+ # mutually exclusive case bodies) and are deliberately out of scope
+ # here; they keep the prior unisolated `generic_visit` behavior.
+ self.visit(node.test)
+ if not self.local_receiver_alias_stack:
+ for statement in node.body:
+ self.visit(statement)
+ for statement in node.orelse:
+ self.visit(statement)
+ return
+ before = set(self.current_local_receiver_aliases)
+ for statement in node.body:
+ self.visit(statement)
+ after_body = set(self.current_local_receiver_aliases)
+ self.local_receiver_alias_stack[-1] = set(before)
+ for statement in node.orelse:
+ self.visit(statement)
+ after_orelse = self.current_local_receiver_aliases
+ self.local_receiver_alias_stack[-1] = after_body | after_orelse
+
def visit_Call(self, node: ast.Call) -> None:
current = self.current_context
call_name = self._canonical_name(dotted_name(node.func))
@@ -405,6 +494,58 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
self._record_blocking_candidate(node, call_name, current)
self.generic_visit(node)
+ def visit_Lambda(self, node: ast.Lambda) -> None:
+ # A lambda's parameter defaults run eagerly when the lambda object
+ # itself is created (same timing as a regular function's defaults),
+ # but its body only runs later, whenever/if the lambda is actually
+ # called -- possibly in a completely different scope, possibly
+ # never. That distinction is only meaningful while walking ANOTHER
+ # function's definition-time expressions (see `_visit_function`):
+ # there, we know structurally that the enclosing `def` statement is
+ # executing right now, and a nested lambda's body is categorically
+ # not part of that execution, no matter how (or whether) the lambda
+ # is later used. Everywhere else -- module level, class bodies, and
+ # ordinary function-body statements -- a lambda is scanned
+ # unconditionally, like any other expression: this file does not
+ # attempt to prove whether/when a lambda sitting in a variable,
+ # passed as a callback, invoked immediately, or invoked later through
+ # a stored name is actually called. That is the same conservative,
+ # over-report-rather-than-infer stance this file already takes for
+ # reachability elsewhere (e.g. `visit_If`'s may-alias union, or the
+ # bare-name call-graph resolution in `_record_call_ref`).
+ if not self._in_definition_time_expression:
+ self.generic_visit(node)
+ return
+ for default in node.args.defaults:
+ self.visit(default)
+ for default in node.args.kw_defaults:
+ if default is not None:
+ self.visit(default)
+
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
+ # Same principle as `visit_Lambda` above: only the outermost `for`'s
+ # iterable is evaluated eagerly, to build the generator object; the
+ # element expression, any `if` filters, and any additional `for`
+ # clauses live inside the generator's own frame and only run once/if
+ # it is iterated -- which may never happen. This is only meaningful
+ # while walking another function's definition-time expressions;
+ # everywhere else a generator expression is scanned unconditionally,
+ # regardless of what (if anything) it is later passed to. This file
+ # does not distinguish a builtin that consumes its argument eagerly
+ # (`list(...)`, `sum(...)`) from one that wraps it in another lazy
+ # iterator (`map(...)`) or leaves it unconsumed in a variable --
+ # telling those apart in general means inferring evaluation order
+ # across arbitrary code, not reading a fixed, structural fact. List/
+ # set/dict comprehensions always run their implicit scope immediately
+ # as part of building the result, regardless of context, so they are
+ # left fully eager unconditionally and are not given a matching
+ # override.
+ if not self._in_definition_time_expression:
+ self.generic_visit(node)
+ return
+ if node.generators:
+ self.visit(node.generators[0].iter)
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef, *, is_async: bool) -> None:
qualname = ".".join((*self.class_stack, node.name)) if self.class_stack else node.name
class_name = self.class_stack[-1] if self.class_stack else None
@@ -413,14 +554,57 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
self.functions_by_name[node.name].append(qualname)
if class_name is not None:
self.class_methods[class_name].add(node.name)
+
+ # Decorators and parameter defaults run at definition time in the
+ # ENCLOSING scope, not inside the function body -- visit them before
+ # pushing this function's own context. Otherwise a call/receiver there
+ # (e.g. a default value referencing an outer variable that happens to
+ # share a parameter's name) gets misattributed to the function being
+ # defined instead of to whatever actually executes it. Parameter and
+ # return annotations get the same enclosing-scope treatment unless
+ # this module postpones annotation evaluation (`from __future__
+ # import annotations`), in which case they never execute at runtime at
+ # all, in either scope, so they are skipped entirely. PEP 695
+ # type-parameter bounds (`node.type_params`) are not visited at all,
+ # in either scope: CPython evaluates each one lazily, in its own
+ # hidden function, only when something like `T.__bound__` is actually
+ # accessed -- never as part of running this `def` statement.
+ # `_in_definition_time_expression` marks this whole traversal so
+ # `visit_Lambda`/`visit_GeneratorExp` know a lambda body or generator
+ # element reached here is not eager either -- purely because of
+ # where it sits, not because of its own shape. It is always restored
+ # to `False` below before this function's own body is visited.
+ self._in_definition_time_expression = True
+ for decorator in node.decorator_list:
+ self.visit(decorator)
+ self._visit_definition_time_arguments(node.args)
+ if node.returns is not None and not self.postponed_annotations:
+ self.visit(node.returns)
+ self._in_definition_time_expression = False
+
self.function_stack.append(context)
self.sync_http_client_stack.append({})
self.path_like_name_stack.append(set(_path_like_argument_names(node.args, self._canonical_name)))
- self.generic_visit(node)
+ self.local_receiver_alias_stack.append(set(_all_argument_names(node.args)))
+ for statement in node.body:
+ self.visit(statement)
+ self.local_receiver_alias_stack.pop()
self.path_like_name_stack.pop()
self.sync_http_client_stack.pop()
self.function_stack.pop()
+ def _visit_definition_time_arguments(self, arguments: ast.arguments) -> None:
+ for default in arguments.defaults:
+ self.visit(default)
+ for default in arguments.kw_defaults:
+ if default is not None:
+ self.visit(default)
+ if self.postponed_annotations:
+ return
+ for argument in _iter_arguments(arguments):
+ if argument.annotation is not None:
+ self.visit(argument.annotation)
+
def _canonical_name(self, name: str | None) -> str | None:
if name is None:
return None
@@ -437,15 +621,36 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
return
if not isinstance(node.func, ast.Attribute):
return
- receiver = dotted_name(node.func.value)
+ receiver = _simple_receiver_name(node.func.value)
if receiver in {"self", "cls"}:
self.call_refs[current.qualname].append(_CallRef(node.func.attr, current.class_name, self_method=True))
return
+ if self._is_traceable_same_function_receiver(receiver):
+ # Multi-hop self./cls. attribute chains (self.store.flush()) and local
+ # variables/parameters traced back -- within this same function only --
+ # to a self./cls. attribute or a parameter (store = self.store;
+ # store.flush()) cannot be resolved to a specific class without full
+ # type inference. Fall back to the same conservative same-file,
+ # bare-method-name resolution already used below for receivers that
+ # cannot be resolved to a name at all, rather than dropping the edge.
+ self.call_refs[current.qualname].append(_CallRef(node.func.attr, current.class_name, self_method=False))
+ return
# Keep same-module direct calls through canonical aliases out of the call graph.
# External calls are handled as blocking candidates instead.
if "." not in call_name:
self.call_refs[current.qualname].append(_CallRef(call_name, current.class_name, self_method=False))
+ def _is_traceable_same_function_receiver(self, receiver: str | None) -> bool:
+ # True when `receiver` is a self./cls.-rooted attribute chain, or a name
+ # traced -- within the current function only -- back to one of those or
+ # to a parameter. Deliberately no cross-function/cross-module alias or
+ # type inference; see `local_receiver_alias_stack` and
+ # `_record_local_receiver_alias_targets`.
+ if receiver is None:
+ return False
+ root = receiver.split(".", 1)[0]
+ return root in {"self", "cls"} or root in self.current_local_receiver_aliases
+
def _record_blocking_candidate(self, node: ast.Call, call_name: str, current: _FunctionContext) -> None:
rule = self._blocking_rule(node, call_name)
if rule is None:
@@ -512,6 +717,10 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
def current_path_like_names(self) -> set[str]:
return self.path_like_name_stack[-1] if self.path_like_name_stack else set()
+ @property
+ def current_local_receiver_aliases(self) -> set[str]:
+ return self.local_receiver_alias_stack[-1] if self.local_receiver_alias_stack else set()
+
def _record_path_like_annotation(self, annotation: ast.AST, targets: Iterable[ast.AST]) -> None:
if not self.path_like_name_stack or not _is_path_annotation(annotation, self._canonical_name):
return
@@ -526,6 +735,29 @@ class BlockingIOStaticVisitor(ast.NodeVisitor):
for name in _iter_assigned_names(target):
current_clients[name] = client_base
+ def _record_local_receiver_alias_targets(self, value: ast.AST, targets: Iterable[ast.AST]) -> None:
+ # Every assignment to a name previously in `current_local_receiver_aliases`
+ # must resolve that name's traceability from scratch -- not only add new
+ # traceable names -- or a stale alias from an earlier, unrelated value
+ # would keep exposing same-named blocking methods (dead code below this
+ # point) forever. A non-traceable value (an unrecognized shape, or a
+ # Call/Subscript result -- see `_simple_receiver_name`) therefore kills
+ # the name instead of leaving it untouched.
+ if not self.local_receiver_alias_stack:
+ return
+ current_aliases = self.current_local_receiver_aliases
+ assigned_names = [name for target in targets for name in _iter_assigned_names(target)]
+ if not assigned_names:
+ return
+ dotted = _simple_receiver_name(value)
+ root = dotted.split(".", 1)[0] if dotted is not None else None
+ is_traceable = root is not None and (root in {"self", "cls"} or root in current_aliases)
+ for name in assigned_names:
+ if is_traceable:
+ current_aliases.add(name)
+ else:
+ current_aliases.discard(name)
+
def _sync_http_client_factory_base(self, node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -573,17 +805,30 @@ def _is_path_annotation(annotation: ast.AST | None, canonical_name: Callable[[st
return False
-def _path_like_argument_names(arguments: ast.arguments, canonical_name: Callable[[str | None], str | None]) -> Iterable[str]:
+def _iter_arguments(arguments: ast.arguments) -> Iterable[ast.arg]:
candidates = [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
if arguments.vararg is not None:
candidates.append(arguments.vararg)
if arguments.kwarg is not None:
candidates.append(arguments.kwarg)
- for argument in candidates:
+ yield from candidates
+
+
+def _path_like_argument_names(arguments: ast.arguments, canonical_name: Callable[[str | None], str | None]) -> Iterable[str]:
+ for argument in _iter_arguments(arguments):
if _is_path_annotation(argument.annotation, canonical_name):
yield argument.arg
+def _all_argument_names(arguments: ast.arguments) -> Iterable[str]:
+ # Every parameter name of a function, unfiltered by annotation -- used to
+ # seed same-function receiver-alias tracing (see
+ # `local_receiver_alias_stack`) so a parameter used directly as a call
+ # receiver (e.g. a constructor-injected dependency) is traceable too.
+ for argument in _iter_arguments(arguments):
+ yield argument.arg
+
+
def _iter_assigned_names(target: ast.AST) -> Iterable[str]:
if isinstance(target, ast.Name):
yield target.id
diff --git a/backend/tests/test_detect_blocking_io_static.py b/backend/tests/test_detect_blocking_io_static.py
index 4615781e7..142f02e7d 100644
--- a/backend/tests/test_detect_blocking_io_static.py
+++ b/backend/tests/test_detect_blocking_io_static.py
@@ -105,6 +105,128 @@ def test_scan_file_detects_self_helper_reached_from_async_method(tmp_path: Path)
assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+def test_scan_file_detects_self_attribute_chain_helper_reached_from_async_method(tmp_path: Path) -> None:
+ """self.store.flush() is one hop deeper than the self.flush() case above.
+
+ The receiver (`self.store`) is not the literal Name `self`, so it used to
+ fall through every branch in `_record_call_ref` and be silently dropped
+ from the call graph -- a false negative for a real blocking call reachable
+ from async code.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self):
+ return self.store.flush()
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+ assert findings[0]["blocking_call"]["symbol"] == "open"
+
+
+def test_scan_file_detects_local_variable_alias_of_self_attribute(tmp_path: Path) -> None:
+ """store = self.store; store.flush() must resolve the same as self.store.flush().
+
+ The local variable is traced back, within the same function, to a
+ self.-rooted attribute -- the same one-hop-back scope as the constructor
+ parameter case below, just through an intermediate local name.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self):
+ store = self.store
+ return store.flush()
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+ assert findings[0]["blocking_call"]["symbol"] == "open"
+
+
+def test_scan_file_detects_constructor_parameter_used_directly_as_receiver(tmp_path: Path) -> None:
+ """A function parameter (e.g. a constructor-injected dependency) used
+ directly as a call receiver, with no self./cls. attribute in between,
+ is the other local-alias origin the fix traces (same function only).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def _flush_via(self, store):
+ store.flush()
+
+ async def get(self, store):
+ return self._flush_via(store)
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+ assert findings[0]["blocking_call"]["symbol"] == "open"
+
+
+def test_scan_file_does_not_trace_unrelated_local_variables_as_receivers(tmp_path: Path) -> None:
+ """Scope guardrail: a local variable with no traceable origin (not a
+ parameter, not assigned from a self./cls. attribute or another traced
+ name) must NOT be resolved through the bare-method-name fallback, or the
+ fix would reintroduce broad false positives the tool already avoids.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Unrelated:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ def helper():
+ data = compute()
+ data.flush()
+
+ async def route():
+ return helper()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
def test_json_output_uses_concise_review_record_schema(tmp_path: Path, capsys) -> None:
source_file = _write_python(
tmp_path / "sample.py",
@@ -419,3 +541,873 @@ def test_parse_errors_are_reported_as_findings(tmp_path: Path) -> None:
assert findings[0]["blocking_call"]["category"] == "PARSE_ERROR"
assert findings[0]["priority"] == "MEDIUM"
assert f"{source_file.name}:1:18" in detector.format_text(detector.scan_file(source_file, repo_root=tmp_path))
+
+
+def test_scan_file_does_not_treat_call_result_as_receiver_alias(tmp_path: Path) -> None:
+ """factory().flush() must not resolve like a traced self./cls./parameter alias.
+
+ `dotted_name()` intentionally unwraps `ast.Call` to build a symbolic name for
+ blocking-call pattern matching elsewhere in this module, but reusing that
+ unwrap for alias/receiver extraction would treat a call's result as if it
+ inherited the callee's own alias-worthiness. `factory` is traceable here (a
+ parameter of `get`), so `dotted_name(factory())` unwraps to "factory" and
+ used to incorrectly link this call to `Store.flush` below.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ async def get(self, factory):
+ return factory().flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_does_not_treat_call_result_assigned_to_local_as_receiver_alias(tmp_path: Path) -> None:
+ """client = factory(); client.flush() must not alias client to a traced receiver.
+
+ Same unwrap problem as the direct-call case above, one assignment removed:
+ `dotted_name(factory())` returns "factory" (a traced parameter), so `client`
+ was incorrectly added to the same-function alias set.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ async def get(self, factory):
+ client = factory()
+ return client.flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_does_not_treat_subscript_result_as_receiver_alias(tmp_path: Path) -> None:
+ """client = clients[0]; client.flush() must not alias client either.
+
+ `dotted_name()` also unwraps `ast.Subscript`, so `dotted_name(clients[0])`
+ returned "clients" (a traced parameter), incorrectly aliasing `client`.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ async def get(self, clients):
+ client = clients[0]
+ return client.flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_kills_alias_after_non_traceable_reassignment(tmp_path: Path) -> None:
+ """A non-traceable reassignment must clear a name's existing alias, not just fail to add one.
+
+ `client` starts aliased to `self.store` (traceable), then is reassigned to
+ `NonBlockingClient()` (not traceable). The old code only ever added names to
+ the alias set and never removed them, so `client` stayed aliased forever and
+ `client.flush()` after the reassignment still resolved to `Store.flush`.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class NonBlockingClient:
+ def close(self):
+ pass
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self):
+ client = self.store
+ client = NonBlockingClient()
+ return client.flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_if_else_alias_tracking_is_order_independent_across_branches(tmp_path: Path) -> None:
+ """Reversing which branch aliases client vs. uses it must not change the result.
+
+ Without a `visit_If` override, `body` and `orelse` shared one mutable alias
+ set with no isolation or restore between them, so an alias assigned in
+ whichever branch is visited first (always `body`, then `orelse`) leaked into
+ the other -- making the finding depend on branch position instead of
+ program semantics. Both variants below (same statements, swapped between
+ the `if` and `else`) must produce identical output; in this pair neither
+ reference is preceded by its assignment on the same execution path, so the
+ correct output for both is empty.
+ """
+ body_first_root = tmp_path / "body_first"
+ orelse_first_root = tmp_path / "orelse_first"
+ body_first_root.mkdir()
+ orelse_first_root.mkdir()
+
+ variant_assign_in_body = _write_python(
+ body_first_root / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self, flag):
+ if flag:
+ client = self.store
+ else:
+ client.flush()
+ return None
+ """,
+ )
+ variant_assign_in_orelse = _write_python(
+ orelse_first_root / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self, flag):
+ if flag:
+ client.flush()
+ else:
+ client = self.store
+ return None
+ """,
+ )
+
+ findings_body_first = _payload(variant_assign_in_body, body_first_root)
+ findings_orelse_first = _payload(variant_assign_in_orelse, orelse_first_root)
+
+ assert findings_body_first == findings_orelse_first == []
+
+
+def test_if_else_alias_union_carries_forward_after_the_if_statement(tmp_path: Path) -> None:
+ """A name aliased in only one branch is still (conservatively) aliased after the if.
+
+ This is the other half of the may-alias join: `client` is only assigned
+ inside the `if` body, but since either branch might have run, code after
+ the whole `if` statement must still treat `client` as possibly aliased.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self, flag):
+ if flag:
+ client = self.store
+ else:
+ pass
+ return client.flush()
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+
+
+def test_scan_file_does_not_treat_default_value_expression_as_inside_new_function(tmp_path: Path) -> None:
+ """A default value referencing an outer variable must not be misattributed to the function being defined.
+
+ `receiver` here is a module-level object, unrelated to `route`'s own
+ parameter that happens to share its name. `_visit_function` used to push
+ the new function's context -- and its parameter-seeded alias set, which
+ includes the literal name "receiver" -- before visiting defaults, so
+ `receiver.flush()` incorrectly looked like route's own traced parameter
+ calling its own method.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ receiver = Store()
+
+ async def route(receiver=receiver.flush()):
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_definition_time_expressions_are_still_attributed_to_the_enclosing_function(tmp_path: Path) -> None:
+ """Decorators/defaults/annotations must move to the enclosing scope, not disappear.
+
+ `helper` is a nested, never-called, sync function -- if its default value's
+ blocking call were (incorrectly) attributed to `helper` itself, the finding
+ would be silently dropped (helper is neither async nor reachable from
+ anything async). Fixing the scope must move the finding to the actual
+ enclosing scope that runs it (`outer_async`, which is async), not just make
+ it disappear.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import time
+
+ async def outer_async():
+ def helper(x=time.sleep(1)):
+ return x
+ return None
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "outer_async"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "time.sleep"
+
+
+def test_decorator_default_annotation_and_return_annotation_all_use_enclosing_scope(tmp_path: Path) -> None:
+ """All four definition-time expression positions must resolve in the enclosing scope.
+
+ Covers the decorator list, a parameter default, a parameter annotation, and
+ the return annotation in one signature -- each pinned to a distinct,
+ unconditionally-recognized blocking symbol so a regression in any one
+ position produces a non-empty result.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+ import shutil
+ import time
+
+ def _mark(value):
+ def _wrap(fn):
+ return fn
+ return _wrap
+
+ @_mark(time.sleep(1))
+ async def route(x=os.listdir("."), y: shutil.rmtree("z") = None) -> time.sleep(2):
+ return x
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_pep695_type_parameter_bound_is_never_treated_as_definition_time_eager(tmp_path: Path) -> None:
+ """PEP 695 type-parameter bounds are lazily evaluated, not definition-time code.
+
+ CPython compiles a `type_params` bound (`T` in `def helper[T: ](...)`)
+ into its own hidden, lazily-invoked function: it only runs if and when
+ something accesses `T.__bound__` (e.g. a type checker or `typing` runtime
+ introspection), never as part of executing the `def` statement itself --
+ not in the function's own scope, and not in the enclosing one either. An
+ earlier version of this fix moved the previous `generic_visit` walk of
+ `type_params` into the enclosing scope alongside decorators/defaults/
+ annotations, on the assumption it was equally eager; that assumption was
+ wrong, so bounds are not visited in either scope now.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import time
+
+ async def outer_async():
+ def helper[T: time.sleep(1)](x: T) -> T:
+ return x
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_traces_call_result_reassignment_using_pre_assignment_alias_state(tmp_path: Path) -> None:
+ """client = client.flush() must resolve the RHS against the PRE-assignment alias state.
+
+ Python evaluates an assignment's RHS before binding its target. `client`
+ starts aliased to `self.store` (traceable); reassigning it to
+ `client.flush()`'s result correctly kills the alias for code AFTER this
+ statement, but the call `client.flush()` itself happens first, while
+ `client` was still aliased, and must still be recorded -- updating/killing
+ the target's alias before visiting the RHS made this call invisible.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self):
+ client = self.store
+ client = client.flush()
+ return client
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+ assert findings[0]["blocking_call"]["symbol"] == "open"
+
+
+def test_scan_file_traces_annassign_call_result_reassignment_using_pre_assignment_alias_state(tmp_path: Path) -> None:
+ """Same pre-assignment-alias-state requirement, through an annotated assignment.
+
+ `visit_AnnAssign` shares `visit_Assign`'s RHS-then-target-update ordering
+ requirement; this pins it separately so a fix to one path cannot silently
+ leave the other regressed.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ def __init__(self, store):
+ self.store = store
+
+ async def get(self):
+ client: object = self.store
+ client: object = client.flush()
+ return client
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "Store.flush"
+ assert findings[0]["event_loop_exposure"] == "ASYNC_REACHABLE_SAME_FILE"
+ assert findings[0]["blocking_call"]["symbol"] == "open"
+
+
+def test_scan_file_does_not_treat_call_rooted_attribute_chain_as_receiver_alias(tmp_path: Path) -> None:
+ """factory().client.flush() must not collapse to the traced name "client".
+
+ `_simple_receiver_name` fell back to returning the trailing attribute name
+ whenever the recursive parent lookup came back unsupported (e.g. a Call),
+ instead of refusing the whole chain. That let `factory().client` collapse
+ to plain "client", which -- because "client" is also a parameter here --
+ was then treated exactly like a bare `client.flush()` call on that
+ parameter, incorrectly linking this call to `Store.flush` below even
+ though `client` is never referenced in this expression at all.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ async def get(self, factory, client):
+ return factory().client.flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_does_not_treat_subscript_rooted_attribute_chain_as_receiver_alias(tmp_path: Path) -> None:
+ """clients[0].client.flush() must not collapse to the traced name "client" either.
+
+ Same fallback gap as the Call-rooted case above, with `ast.Subscript`
+ (rather than `ast.Call`) underneath the final attribute access.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ class Store:
+ def flush(self):
+ with open("out.txt", "w") as handle:
+ handle.write("x")
+
+ class Router:
+ async def get(self, clients, client):
+ return clients[0].client.flush()
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_does_not_treat_postponed_annotation_as_definition_time_eager(tmp_path: Path) -> None:
+ """`from __future__ import annotations` defers ALL annotation evaluation.
+
+ With this future import active, CPython never evaluates parameter/return
+ annotations at runtime at all (they are kept as unevaluated strings in
+ `__annotations__`) -- so a blocking call written in one must not be
+ attributed to definition time in any scope, unlike the same shape without
+ the future import (see
+ `test_decorator_default_annotation_and_return_annotation_all_use_enclosing_scope`).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ from __future__ import annotations
+
+ import os
+
+ async def outer_async():
+ def helper(x: os.listdir(".")) -> os.listdir("."):
+ return x
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_does_not_treat_lambda_body_as_definition_time_eager(tmp_path: Path) -> None:
+ """A lambda default's BODY must not be treated as running at definition time.
+
+ `helper`'s default value creates a lambda object (eager), but
+ `os.listdir(".")` inside its body only runs if and when that lambda is
+ later called -- which this snippet never does.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def outer_async():
+ def helper(callback=lambda: os.listdir(".")):
+ return callback
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_still_treats_lambda_own_default_as_definition_time_eager(tmp_path: Path) -> None:
+ """Contrast case: a lambda's OWN parameter default is genuinely eager.
+
+ Unlike the lambda's body above, its parameter defaults are evaluated
+ immediately, when the lambda object itself is created -- so this must
+ still be attributed to the enclosing scope like any other default.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def outer_async():
+ def helper(callback=lambda flag=os.listdir("."): flag):
+ return callback
+ return None
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "outer_async"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_does_not_treat_generator_element_as_definition_time_eager(tmp_path: Path) -> None:
+ """A bare generator expression's element only runs once iterated, if ever.
+
+ `helper`'s default builds a generator object (eager), but `os.listdir(x)`
+ -- the element expression -- is only evaluated lazily as the generator is
+ iterated, which this snippet never does.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def outer_async():
+ def helper(items=(os.listdir(x) for x in [1])):
+ return items
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_still_treats_generator_outer_iterable_as_definition_time_eager(tmp_path: Path) -> None:
+ """Contrast case: a generator's OUTERMOST iterable IS evaluated eagerly.
+
+ Only the first `for`'s iterable runs immediately, to build the generator
+ object -- so a blocking call there (unlike the element case above) must
+ still be attributed to the enclosing scope.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def outer_async():
+ def helper(items=(x for x in os.listdir("."))):
+ return items
+ return None
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "outer_async"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_immediately_invoked_lambda_as_eager(tmp_path: Path) -> None:
+ """An immediately invoked lambda's body is scanned like any other expression.
+
+ `(lambda: ...)()` calls the lambda at the exact expression that defines
+ it, in ordinary function-body code -- outside another function's
+ definition-time expressions (see `_visit_function`), a lambda's body is
+ always scanned regardless of how or whether it is invoked (see also
+ `test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ return (lambda: os.listdir("."))()
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_generator_passed_to_list_as_eager(tmp_path: Path) -> None:
+ """A generator passed to `list(...)` is scanned like any other expression.
+
+ In ordinary function-body code -- outside another function's
+ definition-time expressions (see `_visit_function`) -- a generator's
+ element is always scanned regardless of what, if anything, consumes it
+ (see also `test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`
+ and `test_scan_file_treats_generator_wrapped_in_map_as_eager`).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ return list(os.listdir(path) for path in ["."])
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_reviewer_repro_both_eager_shapes_together(tmp_path: Path) -> None:
+ """Integration check mirroring the exact review repro.
+
+ Both eager shapes -- an immediately invoked lambda and a generator
+ consumed by `list(...)` -- appear together in one function, each on its
+ own line, and each must produce its own independent finding.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ first = (lambda: os.listdir("."))()
+ second = list(os.listdir(path) for path in ["."])
+ return first, second
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 2
+ assert {finding["location"]["function"] for finding in findings} == {"route"}
+ assert {finding["event_loop_exposure"] for finding in findings} == {"DIRECT_ASYNC"}
+ assert {finding["blocking_call"]["symbol"] for finding in findings} == {"os.listdir"}
+ assert len({finding["location"]["line"] for finding in findings}) == 2
+
+
+def test_scan_file_treats_generator_consumed_by_any_builtin_as_eager(tmp_path: Path) -> None:
+ """A generator's element is scanned no matter what consumes it.
+
+ In ordinary function-body code, this file does not try to distinguish a
+ builtin that eagerly materializes its argument into a container
+ (`list`, `set`, `tuple`, `sorted`, `frozenset`, `dict`) from one that
+ eagerly reduces it to a scalar (`sum`, `any`, `all`, `min`, `max`) --
+ every one of them is scanned the same way, unconditionally, along with a
+ builtin that instead wraps the generator in another lazy iterator
+ without consuming it at all (see
+ `test_scan_file_treats_generator_wrapped_in_map_as_eager`).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ a = set(os.listdir(p) for p in ["a"])
+ b = tuple(os.listdir(p) for p in ["b"])
+ c = sorted(os.listdir(p) for p in ["c"])
+ d = frozenset(os.listdir(p) for p in ["d"])
+ e = dict((p, os.listdir(p)) for p in ["e"])
+ f = sum(len(os.listdir(p)) for p in ["f"])
+ g = any(os.listdir(p) for p in ["g"])
+ h = all(os.listdir(p) for p in ["h"])
+ i = min(len(os.listdir(p)) for p in ["i"])
+ j = max(len(os.listdir(p)) for p in ["j"])
+ return a, b, c, d, e, f, g, h, i, j
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 10
+ assert {finding["location"]["function"] for finding in findings} == {"route"}
+ assert {finding["blocking_call"]["symbol"] for finding in findings} == {"os.listdir"}
+ assert {finding["event_loop_exposure"] for finding in findings} == {"DIRECT_ASYNC"}
+
+
+def test_scan_file_treats_generator_reduced_by_sum_as_eager(tmp_path: Path) -> None:
+ """`sum(...)` iterates its generator argument immediately, same as `list(...)`.
+
+ A reducer builtin that folds a generator down to a scalar still consumes
+ it eagerly to do so, so ordinary code scans it the same as any other
+ generator (see also
+ `test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`).
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ return sum(len(os.listdir(path)) for path in ["."])
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_does_not_treat_immediately_invoked_lambda_in_definition_time_default_as_eager(tmp_path: Path) -> None:
+ """Definition-time expressions never scan a nested lambda's body, even an IIFE.
+
+ `helper`'s default value is itself an immediately invoked lambda, so its
+ body does run the instant `outer_async` evaluates the default. This file
+ does not special-case that: any lambda body or generator element reached
+ while walking another function's decorators, parameter defaults/
+ annotations, or return annotation is always excluded, full stop (see
+ `_visit_function`), the same as an uninvoked one (see
+ `test_scan_file_does_not_treat_lambda_body_as_definition_time_eager`
+ above). This is a narrow, intentional limitation specific to
+ definition-time expressions -- contrast with
+ `test_scan_file_treats_immediately_invoked_lambda_as_eager`, where the
+ identical shape outside a definition-time context is scanned.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def outer_async():
+ def helper(flag=(lambda: os.listdir("."))()):
+ return flag
+ return None
+ """,
+ )
+
+ assert detector.scan_file(source_file, repo_root=tmp_path) == []
+
+
+def test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code(tmp_path: Path) -> None:
+ """Ordinary code scans a lambda's body even if it is never called.
+
+ Unlike a lambda used as another function's decorator/default/annotation
+ value (see
+ `test_scan_file_does_not_treat_lambda_body_as_definition_time_eager`
+ above), a lambda created inside a function body is not part of that
+ narrow, definition-time-only exclusion. It is scanned unconditionally --
+ the same conservative, over-report-rather-than-infer stance this file
+ takes for reachability everywhere else: it does not try to prove that a
+ lambda (or any other reachable code) is never actually invoked.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ callback = lambda: os.listdir(".")
+ return callback
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_lambda_invoked_through_stored_variable_as_eager(tmp_path: Path) -> None:
+ """A lambda stored in a local and called through that name is still scanned.
+
+ This is the same unconditional lambda-body scan as
+ `test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`
+ just above: ordinary code does not track which variable a lambda value
+ ends up in or whether/how it is later called, so calling it through the
+ stored name makes no difference to the outcome.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ callback = lambda: os.listdir(".")
+ return callback()
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_bare_unconsumed_generator_as_eager_in_ordinary_code(tmp_path: Path) -> None:
+ """Ordinary code scans a generator's element even if it is never iterated.
+
+ Unlike a generator used as another function's decorator/default/
+ annotation value (see
+ `test_scan_file_does_not_treat_generator_element_as_definition_time_eager`
+ above), a generator expression created inside a function body is not
+ part of that narrow, definition-time-only exclusion. It is scanned
+ unconditionally -- the same stance as
+ `test_scan_file_treats_bare_uninvoked_lambda_as_eager_in_ordinary_code`.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ items = (os.listdir(path) for path in ["."])
+ return items
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"
+
+
+def test_scan_file_treats_generator_wrapped_in_map_as_eager(tmp_path: Path) -> None:
+ """`map(...)` wraps a generator without consuming it, but is still scanned.
+
+ `map(str, gen)` returns another lazy iterator -- it does not iterate
+ `gen` at all until the map object itself is consumed, later or never.
+ Ordinary code does not try to tell this apart from a builtin that
+ consumes the generator eagerly (see
+ `test_scan_file_treats_generator_consumed_by_any_builtin_as_eager`) or a
+ bare, unconsumed generator (see
+ `test_scan_file_treats_bare_unconsumed_generator_as_eager_in_ordinary_code`)
+ -- all three are scanned the same way, since telling them apart in
+ general means inferring evaluation order across arbitrary code rather
+ than reading a fixed, structural fact.
+ """
+ source_file = _write_python(
+ tmp_path / "sample.py",
+ """
+ import os
+
+ async def route():
+ return map(str, (os.listdir(path) for path in ["."]))
+ """,
+ )
+
+ findings = _payload(source_file, tmp_path)
+
+ assert len(findings) == 1
+ assert findings[0]["location"]["function"] == "route"
+ assert findings[0]["event_loop_exposure"] == "DIRECT_ASYNC"
+ assert findings[0]["blocking_call"]["symbol"] == "os.listdir"