Static Analysis Engines Research¶
When you'd use this
Analyze code without running it — AST walking, control flow and data flow.
Analyze code without running it — linters, type checkers, and security scanners built on the AST.
What you'll learn¶
- What static analysis is and why it matters
- Walk the AST to find issues
- Detect unused imports and count definitions
- The idea of control-flow and data-flow analysis
- The tools built on these ideas (flake8, mypy, ruff)
Analysis without execution¶
Reason about code by inspecting its structure, not by running it.
Static analysis examines source code without running it — the opposite of testing, which runs code to observe behavior. It's how linters catch bugs, type checkers verify correctness, and security scanners find vulnerabilities, all before a single line executes. Python makes this unusually accessible because the standard library ships a full parser: the ast module turns source into a tree you can inspect.
source code ──ast.parse──▶ AST (tree) ──walk/analyze──▶ findings
"import os" Import node "unused import"
Every Python linter and type checker is, at its core, a program that parses your code into an AST and walks it looking for patterns.
Walking the AST¶
Traverse the syntax tree to collect facts about code.
ast.walk yields every node in the tree. Here's a real analysis — finding unused imports by comparing imported names against used names. Fully runnable:
import ast
source = '''
import os
import sys
def greet(name):
print(sys.argv)
return "hi " + name
'''
tree = ast.parse(source)
# Collect imported top-level names
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imported.add(alias.asname or alias.name.split(".")[0])
# Collect every name referenced anywhere
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
unused = sorted(imported - used)
print("imported:", sorted(imported))
print("used:", sorted(used))
print("unused imports:", unused)
Output:
The analyzer correctly flags os as unused (sys is referenced via sys.argv, os never is) — without running the code. This is exactly what flake8's F401 "imported but unused" check does, in miniature. The whole technique is: parse → walk → compare sets of facts.
Counting definitions is just as direct:
funcs = [n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]
print("functions:", funcs) # -> ['greet']
Control flow and data flow¶
Track execution paths and how values move to find deeper issues.
Real analysis engines go deeper than node-counting, building two graphs:
- Control-Flow Graph (CFG) — models the possible execution paths (branches, loops, returns). It answers "can this line ever be reached?" (dead-code detection) and "does every path return a value?"
- Data-Flow Analysis — tracks how values move through the program. It answers "is this variable used before assignment?", "is this assignment ever read?" (dead store), and powers type inference.
def f(x): CFG: [entry]
if x > 0: │
y = 1 [x > 0?]
print(y) # y may ╱ ╲
# be unbound [y=1] (skip)
╲ ╱
[print(y)] ← data-flow: is y always defined here? NO
Data-flow analysis on this CFG would flag that y might be used before assignment — a real bug a simple AST walk misses, because it requires reasoning about paths, not just nodes.
From AST to a real rule¶
Turn a traversal into an actual lint/security rule.
A minimal linter rule is a function that walks the tree and yields findings:
import ast
def check_bare_except(source: str) -> list[str]:
"""Flag `except:` with no exception type (catches everything, hides bugs)."""
findings = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.ExceptHandler) and node.type is None:
findings.append(f"line {node.lineno}: bare 'except:' — specify an exception type")
return findings
code = '''
try:
risky()
except:
pass
'''
print(check_bare_except(code))
# -> ["line 4: bare 'except:' — specify an exception type"]
This is the entire shape of a linter plugin: match a node pattern, report a location. Scale that to hundreds of rules and you have flake8 or pylint.
The example above illustrates the pattern
The unused-import and function-count examples are run-verified. The check_bare_except snippet follows the same tested ast.walk pattern; the line number depends on the exact source layout.
The tools built on this¶
ruff, mypy, bandit, and semgrep as production examples.
You rarely write an analysis engine from scratch — you use ones built on exactly these ideas:
| Tool | What it does | Technique |
|---|---|---|
| flake8 / pyflakes | Style + logic lint (unused imports, undefined names) | AST walking |
| pylint | Deeper lint (design smells, refactoring hints) | AST + inference |
| mypy / pyright | Static type checking | AST + data-flow + type inference |
| ruff | Extremely fast linter/formatter | AST, written in Rust |
| bandit | Security issues | AST pattern matching |
ruff is worth calling out — it reimplemented much of this in Rust and is orders of magnitude faster, which is why it's rapidly become the default. But conceptually it does what our examples do: parse, walk, match, report.
Practice exercises¶
- Extend the unused-import detector to handle
from x import y(ImportFromnodes). - Write a checker that flags functions longer than N statements (walk
FunctionDef, count body nodes). - Detect
== Nonecomparisons and suggestis Noneinstead (look forComparenodes). - Write a rule that finds mutable default arguments (
def f(x=[])) — a classic Python bug. - Explain a bug that requires data-flow analysis (paths) to catch, which a single AST walk cannot.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.