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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,13 @@ synthetic local, set literals, chained assignments, assignments to `global` name
lambdas and nested function definitions, analysed as functions of their own whose
captured variables are implicit parameters so taint flows through closures like through
any call, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard,
or, sequence, mapping and keyword class patterns with guards, laid out as an `if` chain
over length, index, membership, attribute and `isinstance` tests. Not yet: positional
class patterns, `nonlocal` assignments, classes defined inside functions, and a
conditional expression or comprehension inside a `while` condition. Blocks inside a `try`
or, sequence, mapping and class patterns with guards, laid out as an `if` chain over
length, index, membership, attribute and `isinstance` tests, positional class patterns
using the class's `__match_args__` or a dataclass's field order when the class is in the
module; `nonlocal` assignments, whose writes stay inside the nested function; classes
defined inside functions, whose methods are analysed as nested functions; and control flow
inside a `while` condition, recomputed every iteration. Left: a function using syntax
the frontend rejects is reported as a `syntax-error` for its file. Blocks inside a `try`
body carry exception edges to the handlers; `finally` is modelled on the normal path only. Methods of module-level classes are analysed like functions; other module-level
code is skipped. An import inside a function is shown where it runs, as an `import`
instruction naming the module, the bound name and the canonical symbol. A function using syntax outside this subset is reported by `--check` as
Expand Down
88 changes: 72 additions & 16 deletions src/coretrace_python/cfg/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import fields, is_dataclass, replace
from typing import Any, ClassVar

Expand Down Expand Up @@ -31,7 +32,10 @@ def __init__(self, block_id: BlockId) -> None:


class _Builder:
def __init__(self, function: nodes.Function) -> None:
def __init__(
self, function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None
) -> None:
self.match_args = dict(match_args or {})
self.function = function
self.blocks: dict[BlockId, BasicBlock] = {}
self.counters: dict[str, int] = {}
Expand Down Expand Up @@ -59,12 +63,28 @@ def desugared(self, node: nodes.Statement, block: _Open) -> tuple[nodes.Statemen

pending: list[nodes.Statement] = []
if isinstance(node, nodes.While):
if _has_control_flow(node.condition):
raise CFGError(
f"{node.span.display()}: conditional expressions and comprehensions in a "
"loop condition are not supported yet"
)
return node, block
if not _has_control_flow(node.condition):
return node, block
# ``while <control flow>:`` recomputes its condition every iteration: it
# becomes ``while True`` with the condition laid out at the top of the body
# and a ``break``; the ``else`` clause runs before that ``break``.
inner: list[nodes.Statement] = []
condition = self.hoist(node.condition, inner)
stop = nodes.If(
nodes.UnaryOp("not", condition, node.span),
(*node.orelse, nodes.Break(node.span)),
(),
node.span,
)
return (
replace(
node,
condition=nodes.Constant(True, node.span),
body=(*inner, stop, *node.body),
orelse=(),
),
block,
)
if isinstance(node, nodes.Assign | nodes.AugAssign):
node = replace(node, target=self.hoist(node.target, pending), value=self.hoist(node.value, pending))
elif isinstance(node, nodes.ExpressionStatement):
Expand Down Expand Up @@ -430,16 +450,17 @@ def class_pattern(
"""``Cls(name=pattern)``: an instance of the class whose attributes match."""

span = node.span
if node.patterns:
raise CFGError(
f"{span.display()}: positional class patterns need the class's __match_args__ "
"and are not supported yet"
)
conditions: list[nodes.Expression] = [
nodes.Call(nodes.Name("isinstance", span), (subject, node.cls), (), span)
]
bindings: list[nodes.Statement] = []
for name, sub in zip(node.keyword_names, node.keyword_patterns, strict=True):
# Positional sub-patterns match the attributes ``__match_args__`` names, known
# for the module's classes; an unknown class gets a conservative position.
known = self.match_args.get(node.cls.identifier, ()) if isinstance(node.cls, nodes.Name) else ()
positional = [
(known[i] if i < len(known) else f"_match_arg_{i}", sub) for i, sub in enumerate(node.patterns)
]
for name, sub in (*positional, *zip(node.keyword_names, node.keyword_patterns, strict=True)):
condition, inner = self.pattern(sub, nodes.Attribute(subject, name, span))
conditions.append(condition)
bindings.extend(inner)
Expand Down Expand Up @@ -521,13 +542,48 @@ def _rename_target(target: nodes.Target, renames: dict[str, str]) -> nodes.Targe
return renamed


def build_cfg(function: nodes.Function) -> CFG:
return _Builder(function).build()
def build_cfg(function: nodes.Function, match_args: Mapping[str, tuple[str, ...]] | None = None) -> CFG:
return _Builder(function, match_args).build()


def match_args_of(module: nodes.Module) -> dict[str, tuple[str, ...]]:
"""``__match_args__`` of the module's classes: explicit, or the field order of a
dataclass (bare annotated declarations and assignments, in order)."""

found: dict[str, tuple[str, ...]] = {}
for statement in module.body:
if not isinstance(statement, nodes.Class):
continue
explicit = None
fields: list[str] = []
for member in statement.body:
if isinstance(member, nodes.Assign) and isinstance(member.target, nodes.Name):
if member.target.identifier == "__match_args__" and isinstance(member.value, nodes.Tuple | nodes.List):
explicit = tuple(
e.value for e in member.value.elements if isinstance(e, nodes.Constant) and isinstance(e.value, str)
)
elif not member.target.identifier.startswith("_"):
fields.append(member.target.identifier)
elif isinstance(member, nodes.Declaration):
fields.append(member.name)
if explicit is not None:
found[statement.name] = explicit
elif any(_is_dataclass(d) for d in statement.decorators):
found[statement.name] = tuple(fields)
return found


def _is_dataclass(decorator: nodes.Expression) -> bool:
if isinstance(decorator, nodes.Call):
decorator = decorator.callee
if isinstance(decorator, nodes.Name):
return decorator.identifier == "dataclass"
return isinstance(decorator, nodes.Attribute) and decorator.name == "dataclass"


class CFGAnalysis(FunctionAnalysis[CFG]):
name: ClassVar[str] = "cfg.function"

@classmethod
def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> CFG:
return build_cfg(function)
return build_cfg(function, match_args_of(ctx.module))
11 changes: 9 additions & 2 deletions src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,16 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
if isinstance(node, ast.Assign):
return nodes.Assign(self.target(node.targets[0]), self.expression(node.value), span)
if isinstance(node, ast.AnnAssign):
# The annotation never affects behaviour; a bare declaration does nothing.
# The annotation never affects behaviour; a bare declaration binds nothing
# but names a dataclass field or an annotated local.
if node.value is None:
return nodes.Pass(span)
annotation: nodes.Expression | None = None
try:
annotation = self.expression(node.annotation)
except HIRBuildError:
annotation = None
name = node.target.id if isinstance(node.target, ast.Name) else None
return nodes.Declaration(name, annotation, span) if name is not None else nodes.Pass(span)
return nodes.Assign(self.target(node.target), self.expression(node.value), span)
if isinstance(node, ast.AugAssign):
operator = _BINARY_OPERATORS.get(type(node.op))
Expand Down
10 changes: 10 additions & 0 deletions src/coretrace_python/hir/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ class Delete:
span: SourceSpan


@dataclass(frozen=True, slots=True)
class Declaration:
"""``name: annotation`` without a value: a dataclass field or an annotated local."""

name: str
annotation: Expression | None
span: SourceSpan


@dataclass(frozen=True, slots=True)
class ValuePattern:
"""``case <expression>``: equality with a value."""
Expand Down Expand Up @@ -545,6 +554,7 @@ class Class:
| Pass
| Delete
| Match
| Declaration
| Assert
| If
| While
Expand Down
43 changes: 36 additions & 7 deletions src/coretrace_python/ir/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
Instruction,
Jump,
LoadLocal,
MakeClass,
MakeFunction,
ModuleIR,
Raise,
Expand All @@ -65,6 +66,7 @@
)
from coretrace_python.semantic import SEMANTIC_ANALYSES
from coretrace_python.semantic.scopes import (
BindingKind,
Resolution,
ResolutionKind,
Scope,
Expand Down Expand Up @@ -134,7 +136,10 @@ def expression(self, node: nodes.Expression) -> Value:
if isinstance(node, nodes.Name):
resolution = self.resolve(node.identifier)
if resolution.kind is ResolutionKind.FREE:
# A captured variable is an implicit parameter of the nested function.
# A captured variable is an implicit parameter of the nested function; a
# ``nonlocal`` one lives in a local slot initialised from it.
if node.identifier in self.nonlocals:
return self.emit(LoadLocal(self.new_value(), node.span, node.identifier))
if node.identifier in self.parameters:
return self.parameters[node.identifier]
self.fail(node, "closures are not supported yet")
Expand Down Expand Up @@ -223,8 +228,8 @@ def store(self, target: nodes.Target, value: Value) -> None:
if resolution.kind is ResolutionKind.GLOBAL:
self.emit_effect(SetGlobal(None, target.span, target.identifier, value))
return
if resolution.kind is not ResolutionKind.LOCAL:
self.fail(target, "assignment to a nonlocal name is not supported yet")
if resolution.kind is not ResolutionKind.LOCAL and target.identifier not in self.nonlocals:
self.fail(target, "assignment to a free variable that is not declared nonlocal")
self.emit_effect(StoreLocal(None, target.span, target.identifier, value))
elif isinstance(target, nodes.Attribute):
object_value = self.expression(target.value)
Expand All @@ -244,6 +249,18 @@ def statement(self, node: nodes.Statement) -> None:
if isinstance(node, nodes.Assign):
self.store(node.target, self.expression(node.value))
return
if isinstance(node, nodes.Declaration):
return
if isinstance(node, nodes.Class):
# A local class is a value bound to its name; bases and decorators run here,
# its methods are analysed as nested functions.
for decorator in node.decorators:
self.expression(decorator)
for base in node.bases:
self.expression(base)
made_class = self.emit(MakeClass(self.new_value(), node.span, node.name))
self.store(nodes.Name(node.name, node.span), made_class)
return
if isinstance(node, nodes.Delete):
for target in node.targets:
if isinstance(target, nodes.Subscript):
Expand Down Expand Up @@ -308,7 +325,7 @@ def statement(self, node: nodes.Statement) -> None:
written = alias.name if isinstance(node, nodes.Import) else module
self.emit_effect(Import(None, alias.span, written, symbol, bound))
return
if isinstance(node, nodes.Pass | nodes.Global):
if isinstance(node, nodes.Pass | nodes.Global | nodes.Nonlocal):
# Declarations are already applied by the semantic analyses.
return
self.fail(node)
Expand Down Expand Up @@ -365,9 +382,15 @@ def function(self, node: nodes.Function) -> FunctionIR:
for parameter, value in zip(node.parameters, parameter_values, strict=False)
if parameter.name not in reassigned
}
# Captured variables come after the explicit parameters; they are never
# reassigned, an assignment would have made them local.
self.parameters.update(zip(captured, parameter_values[len(node.parameters) :], strict=True))
# Captured variables come after the explicit parameters. A ``nonlocal`` one is
# assigned in this body, so it lives in a local slot initialised from the
# captured value; the others are never reassigned.
bindings = self.scopes.scope_for(node).bindings
self.nonlocals = frozenset(
name for name in captured if name in bindings and bindings[name].kind is BindingKind.NONLOCAL
)
captured_values = dict(zip(captured, parameter_values[len(node.parameters) :], strict=True))
self.parameters.update({n: v for n, v in captured_values.items() if n not in self.nonlocals})
blocks: list[BasicBlock] = []
for cfg_block in self.cfg.blocks.values():
self.instructions = []
Expand All @@ -376,6 +399,8 @@ def function(self, node: nodes.Function) -> FunctionIR:
for parameter, value in zip(node.parameters, parameter_values, strict=False):
if parameter.name in reassigned:
self.emit_effect(StoreLocal(None, parameter.span, parameter.name, value))
for name in sorted(self.nonlocals):
self.emit_effect(StoreLocal(None, node.span, name, captured_values[name]))
for statement in cfg_block.statements:
self.statement(statement)
terminator = self.terminator(cfg_block)
Expand Down Expand Up @@ -508,6 +533,10 @@ def walk(node: Node) -> None:
_collect(lambda_function(node), into)
return
if isinstance(node, nodes.Class):
# The methods of a class defined inside a function are nested functions.
for member in node.body:
if isinstance(member, nodes.Function):
_collect(member, into)
return
for child in children(node):
walk(child)
Expand Down
11 changes: 11 additions & 0 deletions src/coretrace_python/ir/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ class MakeFunction(Operands):
captured: tuple[Value, ...] = ()


@dataclass(frozen=True)
class MakeClass(Operands):
"""A class defined inside a function, bound to its name; its methods are analysed
as nested functions."""

result: Value
location: SourceSpan
name: str


@dataclass(frozen=True)
class BuildString(Operands):
"""An f-string: its constant and formatted parts, in order."""
Expand Down Expand Up @@ -374,6 +384,7 @@ class Import(Operands):
| BuildString
| BuildSlice
| MakeFunction
| MakeClass
| WithEnter
| Catch
| Await
Expand Down
3 changes: 3 additions & 0 deletions src/coretrace_python/ir/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Instruction,
Jump,
LoadLocal,
MakeClass,
MakeFunction,
ModuleIR,
Phi,
Expand Down Expand Up @@ -102,6 +103,8 @@ def _instruction(instruction: Instruction) -> str:
return f"del_item {_value(instruction.object)}, {_value(instruction.key)}"
if isinstance(instruction, DelAttr):
return f'del_attr {_value(instruction.object)}, "{instruction.attribute}"'
if isinstance(instruction, MakeClass):
return f'{_value(instruction.result)} = make_class "{instruction.name}"'
if isinstance(instruction, SetGlobal):
return f'set_global "{instruction.name}", {_value(instruction.value)}'
if isinstance(instruction, BuildString):
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None:
elif isinstance(node, nodes.Delete):
for target in node.targets:
self.target(target, scope)
elif isinstance(node, nodes.Declaration):
scope.bind(node.name, BindingKind.LOCAL, node.span)
elif not isinstance(node, nodes.Pass | nodes.Break | nodes.Continue):
raise TypeError(f"unknown statement: {node!r}")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def test_cached_modules_still_serve_project_plugins_and_correlation(tmp_path: Pa


def test_unsupported_and_syntax_notes_are_cached_too(tmp_path: Path) -> None:
root = project(tmp_path / "src", {"nested.py": "def outer():\n class Inner:\n pass\n return Inner\n"})
root = project(tmp_path / "src", {"nested.py": "def outer():\n break\n"})
cache = ProjectCache(tmp_path / "cache")

first = engine.analyze_project(root, [PLUGINS], cache=cache)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_closures.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ def test_make_function_carries_the_captured_values() -> None:
assert [(m.name, len(m.captured)) for m in made] == [("run", 2), ("lambda_8_11", 0)]


def test_nonlocal_assignments_are_still_reported() -> None:
def test_nonlocal_assignments_are_analysed_inside_the_writer() -> None:
findings = check("def outer():\n n = 0\n def bump():\n nonlocal n\n n = n + 1\n bump()\n return n\n")
assert [(f.rule_id, f.function) for f in findings] == [("unsupported-syntax", "bump")]
assert findings == ()


# --------------------------------------------------------------------------- taint through closures
Expand Down
4 changes: 2 additions & 2 deletions tests/test_config_secrets_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def test_project_coverage_reflects_notes(tmp_path: Path) -> None:
tmp_path,
{
"clean.py": "def a():\n return 1\n\ndef b():\n return 2\n",
"partial.py": "def ok():\n return 1\n\ndef odd():\n class Inner:\n pass\n return Inner\n",
"partial.py": "def ok():\n return 1\n\ndef odd():\n break\n",
"broken.py": "def (:\n",
"legacy.py": b"\xff\xfeprint('x')\n",
},
Expand All @@ -194,7 +194,7 @@ def test_project_coverage_reflects_notes(tmp_path: Path) -> None:

def test_single_file_analysis_carries_coverage() -> None:
analysis = engine.analyze_file(
SourceManager().add_source("one.py", "def a():\n return 1\n\ndef b():\n class C:\n pass\n"), [PLUGINS]
SourceManager().add_source("one.py", "def a():\n return 1\n\ndef b():\n break\n"), [PLUGINS]
)
assert analysis.coverage.summary() == "coverage: 1/1 files, 1/2 functions"
assert [f.rule_id for f in analysis.findings] == ["unsupported-syntax"]
Expand Down
Loading
Loading