Skip to content

fix(scopes): visit expressions in parameter annotations - #267

Merged
SizzleUnrlsd merged 9 commits into
CoreTrace:mainfrom
sebas8866:fix/annotation-expression-scopes
Oct 22, 2026
Merged

SizzleUnrlsd merged 9 commits into
CoreTrace:mainfrom
sebas8866:fix/annotation-expression-scopes

Conversation

@sebas8866

Copy link
Copy Markdown
Contributor

Closes #262.

The scope collector processes parameter default expressions, but not parameter annotations. When an annotation contains a lambda or comprehension, the engine's directory-analysis pass later asks for that nested expression's scope and raises KeyError instead of producing a report.

Visit represented parameter annotation expressions in their enclosing scope, before opening the declared function's scope. This registers scopes created inside positional, keyword-only, *args and **kwargs annotations without changing binding ownership of function parameters.

Tests: The baseline failed all 4 new tests; the corrected branch passed 6/6 targeted stdlib unittest tests in Python 3.11 under an offline, network-disabled, read-only, resource-limited Docker container. Tests cover CLI directory --check, annotation lambdas and comprehensions, and lexical free-variable resolution. Full mypy/pytest/ruff suites were not run because development tooling was unavailable in the permitted offline environment; no dependencies were installed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T20:01:38.862885Z 925a49a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 925a49a06e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/coretrace_python/semantic/scopes.py Outdated
self.expression(decorator, scope)
for parameter in node.parameters:
if parameter.annotation is not None:
self.expression(parameter.annotation, scope)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve builtin fallback for postponed annotation walruses

With from __future__ import annotations on supported Python 3.11–3.13, visiting an annotation as a normal expression lets a comprehension's NamedExpr register its target as a module binding even though the annotation is stored as a string. For example, after def f(x: [(eval := i) for i in ()]): ..., a later function's eval(input()) still calls the builtin at runtime, but this binding makes SymbolTable.resolve return None because builtin fallback is only used for UNBOUND; consequently both the dangerous-eval and code-injection findings disappear. Register the nested annotation scope without treating this postponed target as a concrete module binding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in fix(scopes): bind nothing from postponed annotations. An annotation Python never evaluates, under from __future__ import annotations or on a local variable, still registers the scopes of its lambdas and comprehensions, but binds nothing in the scope it stands in. Evaluated annotations bind as before. Your example now reports dangerous-eval and code-injection, for a file and for a project (tests/test_annotation_bindings.py).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction, in fix(scopes): keep what annotations declare in functions: a postponed annotation binds nothing at module level only. In a function, Python declares the target as a local whether the annotation is evaluated or not, so a nested function may declare it nonlocal, and the scope table now keeps that declaration. Your example is at module level and still reports dangerous-eval and code-injection.

Under from __future__ import annotations, an annotation is never evaluated, yet an assignment expression in one bound its target where the function is defined: a builtin it named, such as eval, resolved to a binding that never exists, and its calls were not reported. Such an annotation still registers the scopes of its lambdas and comprehensions, but binds nothing in the scope it stands in.
A variable annotation kept its expression in the HIR without registering its scopes, so a lambda or comprehension there raised KeyError. Register them as for parameters; a local variable's annotation is never evaluated (PEP 526) and binds nothing.
@SizzleUnrlsd

Copy link
Copy Markdown
Contributor

Commits added on top of 925a49a, which is kept as is:

Commit Change
style(tests): sort the imports of the annotation scope tests ruff check reported I001 in the new test file
test(scopes): cover the bindings of evaluated annotations what this PR brings for annotations evaluated at definition
fix(scopes): bind nothing from postponed annotations the review below
fix(scopes): register the scopes of variable annotations the same crash on x: (lambda: 1)
docs: state where an assignment expression in an annotation binds docs/usage.md

Postponed annotations bound names they never assign

The scope collector visited a parameter annotation as an ordinary expression. An assignment expression in one therefore bound its target in the scope where the function is defined. Under from __future__ import annotations, the annotation is kept as a string and never evaluated, so that binding never exists at run time:

from __future__ import annotations

def f(x: [(eval := i) for i in ()]):
    pass

def run():
    eval(input())

At run time, eval is still the builtin. With 925a49a it resolved to the module binding, so dangerous-eval and code-injection were no longer reported. On main, analysing the file alone reported both, and analysing its directory raised KeyError.

The collector now treats annotations Python never evaluates differently:

  • such an annotation still introduces the scopes of its lambdas and comprehensions, because later passes look them up;
  • it binds nothing in the scope it stands in;
  • an evaluated annotation binds as before.

The rule depends on no particular name. The module's from __future__ import annotations decides: an absolute import that names annotations.

Variable annotations

An annotated variable without a value keeps its annotation in the HIR, for example x: (lambda: 1) at module level or y: [v for v in ()] in a function. Its scopes were not registered, which raised the same KeyError, in both modes for a module variable. They are now registered the same way:

  • a local variable's annotation is never evaluated (PEP 526), so it binds nothing;
  • a module or class variable's annotation binds where it stands, unless the module postpones annotations.

Results

Findings reported, dangerous-eval and code-injection together:

Input main, file main, directory This branch, file and directory
the example above both KeyError both
the same annotation without the assignment both KeyError both
x: (lambda: 1) at module level KeyError KeyError both
y: [v for v in ()] in a function both KeyError both
the example above without the __future__ import both KeyError none

The last row is intended:

  • Python 3.11 to 3.13: without the import, the annotation is evaluated when f is defined, so the comprehension may bind eval in the module. The engine resolves a name the module may bind to that binding, as it already does on main for the same comprehension outside an annotation, or for if cond: eval = print.
  • Python 3.14: it defers every annotation (PEP 649), but the engine follows 3.11 to 3.13, the versions it supports.

Tests

The new file tests/test_annotation_bindings.py has 15 tests. No existing test changes, apart from the import order above.

  • Evaluated annotations:
    • a parameter annotation binds where its function is defined, in the module or in an enclosing function;
    • a lambda keeps its own bindings, even in a postponed annotation.
  • Postponed parameter annotations:
    • they bind nothing, in the module or in a function;
    • only the future statement postpones: from .__future__ import annotations, another future feature and import __future__ do not;
    • assignment expressions after a postponed annotation still bind;
    • the example above reports both findings, for a file and for a project.
  • Variable annotations:
    • a project with a lambda in a module variable's annotation and a comprehension in a local's is analysed;
    • a module variable's annotation binds;
    • a local variable's annotation does not, nor does a postponed one.

On 925a49a, 7 of them fail: the postponed parameter cases, the two examples and three of the variable cases. The other 8 pass there and pin behaviour the change keeps.

Each of these mutations of the fix makes at least one test fail:

  • bind from postponed annotations;
  • drop every binding inside annotations, including a lambda's;
  • accept a relative __future__ import;
  • accept any future feature;
  • accept import __future__;
  • keep the postponed state after an annotation;
  • evaluate local variable annotations;
  • ignore postponement for variables;
  • never evaluate variable annotations.

Verification

  • Python 3.12, this branch merged with main at 40a501a:
    • mypy and ruff check .: clean;
    • unit suite: 2,390 passed;
    • regression: 19 of 19;
    • Enterprise plugins at b1c4ed3: 978 passed.
  • Python 3.14 on macOS, the same merge:
    • unit suite: 2,389 passed and 1 failed. The failure is test_packaging, from the environment, as on main;
    • regression: 18 of 19. full-stack-fastapi-template differs because of PEP 758 syntax that only 3.14 accepts, as on main.

Python's compiler declares the target of an assignment expression in an annotation whether the annotation is evaluated or not: in a function, the name is a local, which a nested function may declare nonlocal. Binding nothing from postponed and local variable annotations dropped that declaration, so a valid nonlocal raised ScopeError and the flow through it was lost. Only a module's names exist once assigned: only there does an annotation that is never evaluated bind nothing.
@SizzleUnrlsd

Copy link
Copy Markdown
Contributor

Three more commits, after a review of the previous ones:

Commit Change
fix(scopes): keep what annotations declare in functions the correction below
test(scopes): cover a nonlocal of a name a postponed annotation declares the reproduction below, for a file and for a project
docs: state that annotations declare their names in functions docs/usage.md

Correction: annotations still declare names in functions

fix(scopes): bind nothing from postponed annotations went too far. Python's compiler declares the target of an assignment expression in an annotation whether the annotation is evaluated or not.

In a function, the name is a local of the function either way. On 3.11, 3.12 and 3.13, symtable reports it as local, assigned in all four cases: parameter or local variable annotation, postponed or not. A nested function may then declare it nonlocal:

from __future__ import annotations
import os

def run():
    def declared(x: [(cmd := i) for i in ()]):
        pass
    def set_cmd():
        nonlocal cmd
        cmd = input()
    set_cmd()
    os.system(cmd)

The previous commits dropped that declaration:

  • analysing the file alone raised ScopeError (no binding for nonlocal 'cmd');
  • analysing the project reported a syntax-error instead of the injection.

Run with stand-ins for input and os.system, the program does pass the input to the command.

The rule is now:

  • in a module, whose names exist only once assigned, an annotation that is never evaluated binds nothing, and an eval it names stays the builtin;
  • in a function, an annotation declares its targets whether it is evaluated or not.

This also corrects my earlier comment: a local variable's annotation is never evaluated (PEP 526), but it still declares its targets in the function.

Results

Input main Previous head Now
the reproduction above ScopeError for a file, syntax-error for a project the same command-injection
eval named by a postponed annotation in a function, then called in that function both findings for a file, KeyError for a project both findings none
eval named by a postponed annotation at module level, then called in a function both findings for a file, KeyError for a project both findings both findings

The second row is intended. eval is a local of the function that is never assigned, so calling it raises UnboundLocalError before input() runs.

Tests

In tests/test_annotation_bindings.py, two tests asserted the wrong rule. They now assert the declaration:

  • test_a_postponed_annotation_in_a_function_binds_nothing_there becomes test_a_postponed_annotation_in_a_function_still_declares_there;
  • test_a_local_variable_annotation_binds_nothing_in_its_function becomes test_a_local_variable_annotation_declares_in_its_function, run with and without the __future__ import.

Two new tests cover the reproduction above, for a file and for a project:

  • on the previous head, they fail, with ScopeError and with a syntax-error finding;
  • on 925a49a, they pass.

Each of these mutations makes at least one test fail:

  • functions losing the declarations of their annotations;
  • modules keeping bindings from postponed annotations;
  • evaluated module annotations binding nothing.

Verification

Python 3.12, this branch merged with main at 40a501a:

  • mypy and ruff check .: clean;
  • unit suite: 2,393 passed;
  • regression: 19 of 19;
  • Enterprise plugins at b1c4ed3: 978 passed.

@SizzleUnrlsd
SizzleUnrlsd merged commit ff49a2c into CoreTrace:main Oct 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scopes: a lambda or comprehension in a parameter annotation crashes --check on a directory

2 participants