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"