deploy-guard-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deploy_guard/__init__.py +10 -0
- deploy_guard/__main__.py +4 -0
- deploy_guard/analysis/__init__.py +39 -0
- deploy_guard/analysis/callgraph.py +149 -0
- deploy_guard/analysis/context.py +31 -0
- deploy_guard/analysis/findings.py +333 -0
- deploy_guard/analysis/nullability.py +461 -0
- deploy_guard/analysis/paths.py +219 -0
- deploy_guard/cli.py +190 -0
- deploy_guard/config.py +72 -0
- deploy_guard/engine.py +270 -0
- deploy_guard/explain/__init__.py +16 -0
- deploy_guard/explain/explainer.py +504 -0
- deploy_guard/explain/render.py +60 -0
- deploy_guard/explain/traceback_parse.py +89 -0
- deploy_guard/frontend/__init__.py +10 -0
- deploy_guard/frontend/python_cfg.py +332 -0
- deploy_guard/frontend/python_frontend.py +182 -0
- deploy_guard/generators/__init__.py +11 -0
- deploy_guard/generators/base.py +12 -0
- deploy_guard/generators/import_smoke.py +94 -0
- deploy_guard/ingest/__init__.py +5 -0
- deploy_guard/ingest/discover.py +166 -0
- deploy_guard/ir/__init__.py +24 -0
- deploy_guard/ir/cfg.py +114 -0
- deploy_guard/ir/model.py +128 -0
- deploy_guard/py.typed +0 -0
- deploy_guard/report/__init__.py +5 -0
- deploy_guard/report/render.py +262 -0
- deploy_guard/store.py +50 -0
- deploy_guard_engine-0.1.0.dist-info/METADATA +163 -0
- deploy_guard_engine-0.1.0.dist-info/RECORD +35 -0
- deploy_guard_engine-0.1.0.dist-info/WHEEL +4 -0
- deploy_guard_engine-0.1.0.dist-info/entry_points.txt +3 -0
- deploy_guard_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
deploy_guard/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Deployment Guard Engine.
|
|
2
|
+
|
|
3
|
+
A local engine that reads a Python or Java codebase, reasons about every
|
|
4
|
+
branch and return path, and blocks a deploy when the logic says production
|
|
5
|
+
would break.
|
|
6
|
+
|
|
7
|
+
The public entrypoint is the ``deploy_guard`` CLI (see :mod:`deploy_guard.cli`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
deploy_guard/__main__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Analysis passes over the shared IR / CFG.
|
|
2
|
+
|
|
3
|
+
* ``paths`` - path enumeration -> behavior spec
|
|
4
|
+
* ``nullability`` - flow-sensitive None analysis (merges at joins)
|
|
5
|
+
* ``callgraph`` - best-effort intra-project call graph
|
|
6
|
+
* ``context`` - whole-project context shared by the per-function passes
|
|
7
|
+
* ``findings`` - the rules, mapped onto the deployment gate
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from deploy_guard.analysis.callgraph import CallGraph, CallSite, build_call_graph
|
|
11
|
+
from deploy_guard.analysis.context import AnalysisContext
|
|
12
|
+
from deploy_guard.analysis.findings import Finding, analyze_function
|
|
13
|
+
from deploy_guard.analysis.nullability import NV, NullabilityAnalysis, NullFinding
|
|
14
|
+
from deploy_guard.analysis.paths import (
|
|
15
|
+
BehaviorEntry,
|
|
16
|
+
BehaviorSpec,
|
|
17
|
+
ExecPath,
|
|
18
|
+
PathSet,
|
|
19
|
+
behavior_spec,
|
|
20
|
+
enumerate_paths,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Finding",
|
|
25
|
+
"analyze_function",
|
|
26
|
+
"NullabilityAnalysis",
|
|
27
|
+
"NullFinding",
|
|
28
|
+
"NV",
|
|
29
|
+
"CallGraph",
|
|
30
|
+
"CallSite",
|
|
31
|
+
"build_call_graph",
|
|
32
|
+
"AnalysisContext",
|
|
33
|
+
"ExecPath",
|
|
34
|
+
"PathSet",
|
|
35
|
+
"BehaviorEntry",
|
|
36
|
+
"BehaviorSpec",
|
|
37
|
+
"enumerate_paths",
|
|
38
|
+
"behavior_spec",
|
|
39
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""A best-effort intra-project call graph.
|
|
2
|
+
|
|
3
|
+
Full import resolution is out of scope; instead every function is indexed by
|
|
4
|
+
its qualified name, its bare name, and its ``Class.method`` suffix, and call
|
|
5
|
+
sites are resolved against those indexes. A call resolves when exactly one
|
|
6
|
+
project function matches; otherwise it is kept as an unresolved name.
|
|
7
|
+
|
|
8
|
+
Two things depend on this:
|
|
9
|
+
|
|
10
|
+
* caller-aware ``inconsistent-return`` - only flag a value/None function when
|
|
11
|
+
a caller actually uses the result without a guard;
|
|
12
|
+
* ``explain`` - analyse every project frame in a traceback and trace where a
|
|
13
|
+
``None`` entered.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from deploy_guard.ir.model import FunctionDef, Project
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CallSite:
|
|
27
|
+
caller: str # qualname of the enclosing function
|
|
28
|
+
callee_name: str # the name as written (bare or dotted tail)
|
|
29
|
+
callee_qualname: str | None # resolved project function, or None
|
|
30
|
+
node: ast.Call
|
|
31
|
+
lineno: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CallGraph:
|
|
36
|
+
functions: dict[str, FunctionDef] = field(default_factory=dict)
|
|
37
|
+
_by_bare: dict[str, list[str]] = field(default_factory=dict)
|
|
38
|
+
_by_method: dict[str, list[str]] = field(default_factory=dict)
|
|
39
|
+
call_sites: list[CallSite] = field(default_factory=list)
|
|
40
|
+
_callers: dict[str, list[CallSite]] = field(default_factory=dict)
|
|
41
|
+
_callees: dict[str, list[CallSite]] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
# -- lookups --------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def function(self, qualname: str) -> FunctionDef | None:
|
|
46
|
+
return self.functions.get(qualname)
|
|
47
|
+
|
|
48
|
+
def resolve(self, name: str) -> FunctionDef | None:
|
|
49
|
+
if name in self.functions:
|
|
50
|
+
return self.functions[name]
|
|
51
|
+
bare = name.rsplit(".", 1)[-1]
|
|
52
|
+
hits = self._by_bare.get(bare) or []
|
|
53
|
+
if len(hits) == 1:
|
|
54
|
+
return self.functions[hits[0]]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def callers_of(self, qualname: str) -> list[CallSite]:
|
|
58
|
+
return list(self._callers.get(qualname, ()))
|
|
59
|
+
|
|
60
|
+
def callees_of(self, qualname: str) -> list[CallSite]:
|
|
61
|
+
return list(self._callees.get(qualname, ()))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_call_graph(project: Project) -> CallGraph:
|
|
65
|
+
cg = CallGraph()
|
|
66
|
+
|
|
67
|
+
for _module, fn in project.iter_functions():
|
|
68
|
+
cg.functions[fn.qualname] = fn
|
|
69
|
+
bare = fn.name
|
|
70
|
+
cg._by_bare.setdefault(bare, []).append(fn.qualname)
|
|
71
|
+
if fn.is_method:
|
|
72
|
+
# Class.method (drop the module prefix)
|
|
73
|
+
parts = fn.qualname.split(".")
|
|
74
|
+
if len(parts) >= 2:
|
|
75
|
+
cg._by_method.setdefault(".".join(parts[-2:]), []).append(fn.qualname)
|
|
76
|
+
|
|
77
|
+
for _module, fn in project.iter_functions():
|
|
78
|
+
if not isinstance(fn.raw, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
79
|
+
continue
|
|
80
|
+
enclosing_class = _enclosing_class(fn.qualname)
|
|
81
|
+
for call in _iter_calls(fn.raw):
|
|
82
|
+
name = _callee_name(call.func)
|
|
83
|
+
if name is None:
|
|
84
|
+
continue
|
|
85
|
+
qn = _resolve_call(cg, name, call.func, enclosing_class)
|
|
86
|
+
site = CallSite(
|
|
87
|
+
caller=fn.qualname,
|
|
88
|
+
callee_name=name,
|
|
89
|
+
callee_qualname=qn,
|
|
90
|
+
node=call,
|
|
91
|
+
lineno=getattr(call, "lineno", fn.span.lineno),
|
|
92
|
+
)
|
|
93
|
+
cg.call_sites.append(site)
|
|
94
|
+
if qn is not None:
|
|
95
|
+
cg._callers.setdefault(qn, []).append(site)
|
|
96
|
+
cg._callees.setdefault(fn.qualname, []).append(site)
|
|
97
|
+
|
|
98
|
+
return cg
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _iter_calls(node: ast.AST) -> Iterator[ast.Call]:
|
|
104
|
+
for sub in ast.walk(node):
|
|
105
|
+
if isinstance(sub, ast.Call):
|
|
106
|
+
yield sub
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _callee_name(func: ast.AST) -> str | None:
|
|
110
|
+
if isinstance(func, ast.Name):
|
|
111
|
+
return func.id
|
|
112
|
+
if isinstance(func, ast.Attribute):
|
|
113
|
+
if isinstance(func.value, ast.Name):
|
|
114
|
+
return f"{func.value.id}.{func.attr}"
|
|
115
|
+
return func.attr
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _enclosing_class(qualname: str) -> str | None:
|
|
120
|
+
parts = qualname.split(".")
|
|
121
|
+
return parts[-2] if len(parts) >= 3 else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_call(
|
|
125
|
+
cg: CallGraph, name: str, func: ast.AST, enclosing_class: str | None
|
|
126
|
+
) -> str | None:
|
|
127
|
+
# self.method() / cls.method()
|
|
128
|
+
if (
|
|
129
|
+
isinstance(func, ast.Attribute)
|
|
130
|
+
and isinstance(func.value, ast.Name)
|
|
131
|
+
and func.value.id in ("self", "cls")
|
|
132
|
+
and enclosing_class
|
|
133
|
+
):
|
|
134
|
+
key = f"{enclosing_class}.{func.attr}"
|
|
135
|
+
hits = cg._by_method.get(key) or []
|
|
136
|
+
if len(hits) == 1:
|
|
137
|
+
return hits[0]
|
|
138
|
+
|
|
139
|
+
# module.func() or obj.method()
|
|
140
|
+
tail = name.rsplit(".", 1)[-1]
|
|
141
|
+
hits = cg._by_bare.get(tail) or []
|
|
142
|
+
if len(hits) == 1:
|
|
143
|
+
return hits[0]
|
|
144
|
+
if len(hits) > 1 and "." in name:
|
|
145
|
+
prefix = name.rsplit(".", 1)[0]
|
|
146
|
+
narrowed = [q for q in hits if f".{prefix}." in f".{q}." or q.startswith(prefix + ".")]
|
|
147
|
+
if len(narrowed) == 1:
|
|
148
|
+
return narrowed[0]
|
|
149
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Whole-project context shared by the per-function analyses.
|
|
2
|
+
|
|
3
|
+
Built once per scan, after every function's CFG and behavior spec exist, so
|
|
4
|
+
that a single function's analysis can ask questions about the rest of the
|
|
5
|
+
project (does this callee return None? who calls me and how?).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from deploy_guard.analysis.callgraph import CallGraph
|
|
13
|
+
from deploy_guard.analysis.paths import BehaviorSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AnalysisContext:
|
|
18
|
+
callgraph: CallGraph
|
|
19
|
+
specs: dict[str, BehaviorSpec] = field(default_factory=dict)
|
|
20
|
+
# qualnames of project functions that can return None on some path
|
|
21
|
+
returns_none: set[str] = field(default_factory=set)
|
|
22
|
+
# qualnames that return a non-None value on some path
|
|
23
|
+
returns_value: set[str] = field(default_factory=set)
|
|
24
|
+
|
|
25
|
+
def callee_can_be_none(self, name: str) -> str | None:
|
|
26
|
+
"""If ``name`` resolves to a project function that can return None,
|
|
27
|
+
return that function's qualname; else None."""
|
|
28
|
+
fn = self.callgraph.resolve(name)
|
|
29
|
+
if fn is not None and fn.qualname in self.returns_none:
|
|
30
|
+
return fn.qualname
|
|
31
|
+
return None
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""First-pass flow findings, derived from the CFG and the behavior spec.
|
|
2
|
+
|
|
3
|
+
Every finding names a rule, a severity that maps onto the deployment gate
|
|
4
|
+
(``block`` / ``review`` / ``warn``), and a source location. M2 adds the
|
|
5
|
+
data-flow findings (null deref, escaping exception) with concrete repros.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from deploy_guard.analysis.nullability import NullabilityAnalysis
|
|
14
|
+
from deploy_guard.analysis.paths import BehaviorSpec
|
|
15
|
+
from deploy_guard.ir.cfg import CFG, Terminator
|
|
16
|
+
from deploy_guard.ir.model import FunctionDef
|
|
17
|
+
|
|
18
|
+
# block/review/warn feed the deployment gate; note is informational only and
|
|
19
|
+
# is never a gate input - it is shown in a separate, quiet section.
|
|
20
|
+
SEVERITIES = ("block", "review", "warn", "note")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Finding:
|
|
25
|
+
rule: str
|
|
26
|
+
severity: str
|
|
27
|
+
message: str
|
|
28
|
+
qualname: str
|
|
29
|
+
file: str
|
|
30
|
+
lineno: int
|
|
31
|
+
detail: str = ""
|
|
32
|
+
# Identifies findings that are "the same finding in a copy-pasted function"
|
|
33
|
+
# so the report can collapse them. Set by the engine.
|
|
34
|
+
group_key: str = ""
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if self.severity not in SEVERITIES:
|
|
38
|
+
raise ValueError(f"bad severity {self.severity!r}")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def location(self) -> str:
|
|
42
|
+
return f"{self.file}:{self.lineno}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_MUTABLE_DEFAULT_CALLS = {"list", "dict", "set", "bytearray"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _return_shape(cfg: CFG) -> tuple[bool, bool, set[int]]:
|
|
49
|
+
"""(has explicit `return <non-None>`, has a None-valued exit, exit line nos).
|
|
50
|
+
|
|
51
|
+
Computed over reachable blocks only, straight from block terminators - no
|
|
52
|
+
path enumeration, so it is unaffected by the path-explosion cap.
|
|
53
|
+
"""
|
|
54
|
+
reachable = cfg.reachable_ids()
|
|
55
|
+
has_value_return = False
|
|
56
|
+
has_none_exit = False
|
|
57
|
+
none_exit_lines: set[int] = set()
|
|
58
|
+
for bid in reachable:
|
|
59
|
+
block = cfg.blocks[bid]
|
|
60
|
+
if block.terminator == Terminator.RETURN:
|
|
61
|
+
value = getattr(block.term_node, "value", None)
|
|
62
|
+
is_none = value is None or (
|
|
63
|
+
isinstance(value, ast.Constant) and value.value is None
|
|
64
|
+
)
|
|
65
|
+
if is_none:
|
|
66
|
+
has_none_exit = True
|
|
67
|
+
none_exit_lines.add(getattr(block.term_node, "lineno", 0) or 0)
|
|
68
|
+
else:
|
|
69
|
+
has_value_return = True
|
|
70
|
+
elif block.terminator == Terminator.IMPLICIT_RETURN:
|
|
71
|
+
has_none_exit = True
|
|
72
|
+
line = block.first_line or 0
|
|
73
|
+
if line:
|
|
74
|
+
none_exit_lines.add(line)
|
|
75
|
+
return has_value_return, has_none_exit, {ln for ln in none_exit_lines if ln}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _inconsistent_return_severity(fn, ctx) -> tuple[str | None, str]:
|
|
79
|
+
"""Decide how loud an inconsistent-return should be, using callers.
|
|
80
|
+
|
|
81
|
+
review - at least one in-project caller uses the result without a guard
|
|
82
|
+
note - every caller discards or guards it, or nothing calls it
|
|
83
|
+
None - suppress (should not happen here)
|
|
84
|
+
"""
|
|
85
|
+
if ctx is None:
|
|
86
|
+
return "review", ""
|
|
87
|
+
|
|
88
|
+
callers = ctx.callgraph.callers_of(fn.qualname)
|
|
89
|
+
if not callers:
|
|
90
|
+
return (
|
|
91
|
+
"note",
|
|
92
|
+
" [no in-project caller uses this result - dead, or called dynamically]",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
unguarded: list[str] = []
|
|
96
|
+
for site in callers:
|
|
97
|
+
caller_fn = ctx.callgraph.function(site.caller)
|
|
98
|
+
if caller_fn is None:
|
|
99
|
+
continue
|
|
100
|
+
usage = _classify_call_usage(caller_fn, site.node)
|
|
101
|
+
if usage in ("deref", "return"):
|
|
102
|
+
unguarded.append(f"{site.caller.rsplit('.', 1)[-1]}:{site.lineno}")
|
|
103
|
+
|
|
104
|
+
if unguarded:
|
|
105
|
+
shown = ", ".join(sorted(set(unguarded))[:5])
|
|
106
|
+
return "review", f" [used without a guard by: {shown}]"
|
|
107
|
+
return (
|
|
108
|
+
"note",
|
|
109
|
+
" [every caller checks the result or discards it - likely intentional]",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _classify_call_usage(caller_fn, call_node: ast.Call) -> str:
|
|
114
|
+
root = caller_fn.raw
|
|
115
|
+
if not isinstance(root, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
116
|
+
return "unknown"
|
|
117
|
+
parent = None
|
|
118
|
+
for node in ast.walk(root):
|
|
119
|
+
for child in ast.iter_child_nodes(node):
|
|
120
|
+
if child is call_node:
|
|
121
|
+
parent = node
|
|
122
|
+
break
|
|
123
|
+
if parent is not None:
|
|
124
|
+
break
|
|
125
|
+
if parent is None:
|
|
126
|
+
return "unknown"
|
|
127
|
+
|
|
128
|
+
if isinstance(parent, (ast.Attribute, ast.Subscript)) and parent.value is call_node:
|
|
129
|
+
return "deref"
|
|
130
|
+
if isinstance(parent, ast.Return):
|
|
131
|
+
return "return"
|
|
132
|
+
if isinstance(parent, ast.Expr):
|
|
133
|
+
return "discard"
|
|
134
|
+
if isinstance(parent, ast.BoolOp):
|
|
135
|
+
return "guard" # `call() or default`, `call() and ...`
|
|
136
|
+
if isinstance(parent, ast.Assign):
|
|
137
|
+
targets = [t.id for t in parent.targets if isinstance(t, ast.Name)]
|
|
138
|
+
if not targets:
|
|
139
|
+
return "assign"
|
|
140
|
+
name = targets[0]
|
|
141
|
+
src = ast.unparse(root)
|
|
142
|
+
deref = f"{name}." in src or f"{name}[" in src
|
|
143
|
+
guarded = (
|
|
144
|
+
f"if {name}" in src
|
|
145
|
+
or f"{name} is None" in src
|
|
146
|
+
or f"{name} is not None" in src
|
|
147
|
+
or f"not {name}" in src
|
|
148
|
+
)
|
|
149
|
+
if deref and not guarded:
|
|
150
|
+
return "deref"
|
|
151
|
+
if guarded:
|
|
152
|
+
return "guard"
|
|
153
|
+
return "assign"
|
|
154
|
+
return "unknown"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _declares_optional(returns: str | None) -> bool:
|
|
158
|
+
"""True when the return annotation admits None on purpose."""
|
|
159
|
+
if not returns:
|
|
160
|
+
return False
|
|
161
|
+
text = returns.replace(" ", "")
|
|
162
|
+
return (
|
|
163
|
+
"Optional[" in text
|
|
164
|
+
or "|None" in text
|
|
165
|
+
or "None|" in text
|
|
166
|
+
or text == "None"
|
|
167
|
+
or "Union[" in text and "None" in text
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def analyze_function(
|
|
172
|
+
fn: FunctionDef, cfg: CFG, spec: BehaviorSpec, ctx=None
|
|
173
|
+
) -> list[Finding]:
|
|
174
|
+
file = str(fn.span.file)
|
|
175
|
+
findings: list[Finding] = []
|
|
176
|
+
|
|
177
|
+
def add(rule: str, severity: str, message: str, lineno: int, detail: str = "") -> None:
|
|
178
|
+
findings.append(
|
|
179
|
+
Finding(
|
|
180
|
+
rule=rule,
|
|
181
|
+
severity=severity,
|
|
182
|
+
message=message,
|
|
183
|
+
qualname=fn.qualname,
|
|
184
|
+
file=file,
|
|
185
|
+
lineno=lineno,
|
|
186
|
+
detail=detail,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# 1. inconsistent return (computed from the CFG, so it scales past the
|
|
191
|
+
# path-enumeration cap). A declared Optional/None-union return means
|
|
192
|
+
# None is intended - skip those.
|
|
193
|
+
if not _declares_optional(fn.returns):
|
|
194
|
+
value_return, none_exit, none_exit_lines = _return_shape(cfg)
|
|
195
|
+
if value_return and none_exit:
|
|
196
|
+
where = (
|
|
197
|
+
f"None-returning path(s) end near line(s) {sorted(none_exit_lines)}"
|
|
198
|
+
if none_exit_lines
|
|
199
|
+
else "one path falls off the end of the function"
|
|
200
|
+
)
|
|
201
|
+
severity, caller_note = _inconsistent_return_severity(fn, ctx)
|
|
202
|
+
if severity is not None:
|
|
203
|
+
add(
|
|
204
|
+
"inconsistent-return",
|
|
205
|
+
severity,
|
|
206
|
+
"returns a value on some paths but None on others - callers "
|
|
207
|
+
"that use the result hit AttributeError/TypeError on the None path",
|
|
208
|
+
fn.span.lineno,
|
|
209
|
+
detail=(where + caller_note),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 1b. None-dereference (flow-sensitive nullability) ------------------
|
|
213
|
+
for nf in NullabilityAnalysis(fn, cfg, ctx).find_none_derefs():
|
|
214
|
+
when = (
|
|
215
|
+
"reached when " + " and ".join(nf.witness)
|
|
216
|
+
if nf.witness
|
|
217
|
+
else "on the straight-line path - no branch guards this access"
|
|
218
|
+
)
|
|
219
|
+
if nf.definite:
|
|
220
|
+
add(
|
|
221
|
+
"none-dereference",
|
|
222
|
+
"block",
|
|
223
|
+
f"`{nf.var}` is None here - this access raises "
|
|
224
|
+
"AttributeError/TypeError when the line runs",
|
|
225
|
+
nf.lineno,
|
|
226
|
+
detail=when,
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
add(
|
|
230
|
+
"none-dereference",
|
|
231
|
+
"review",
|
|
232
|
+
f"`{nf.var}` can be None here ({nf.reason}) and is dereferenced "
|
|
233
|
+
"without a guard",
|
|
234
|
+
nf.lineno,
|
|
235
|
+
detail=when,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# 2. unreachable code -------------------------------------------------
|
|
239
|
+
for block in cfg.unreachable_blocks():
|
|
240
|
+
line = block.first_line or fn.span.lineno
|
|
241
|
+
add(
|
|
242
|
+
"unreachable-code",
|
|
243
|
+
"warn",
|
|
244
|
+
"statements here can never execute - a preceding branch always "
|
|
245
|
+
"returns, raises, breaks or continues",
|
|
246
|
+
line,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# 3. path explosion (informational note, not a finding) --------------
|
|
250
|
+
if spec.truncated:
|
|
251
|
+
add(
|
|
252
|
+
"path-explosion",
|
|
253
|
+
"note",
|
|
254
|
+
"very branchy function - the per-path behavior table is capped "
|
|
255
|
+
"(bug detection is unaffected); consider splitting it up",
|
|
256
|
+
fn.span.lineno,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# 4/5. exception-handler smells + 6. mutable defaults ----------------
|
|
260
|
+
node = fn.raw
|
|
261
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
262
|
+
_handler_findings(node, add)
|
|
263
|
+
_mutable_default_findings(node, add)
|
|
264
|
+
|
|
265
|
+
return findings
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _handler_findings(node: ast.AST, add) -> None:
|
|
269
|
+
for handler in ast.walk(node):
|
|
270
|
+
if not isinstance(handler, ast.ExceptHandler):
|
|
271
|
+
continue
|
|
272
|
+
body = [s for s in handler.body if not _is_docstring(s)]
|
|
273
|
+
only_pass = len(body) == 1 and isinstance(body[0], ast.Pass)
|
|
274
|
+
only_ellipsis = (
|
|
275
|
+
len(body) == 1
|
|
276
|
+
and isinstance(body[0], ast.Expr)
|
|
277
|
+
and isinstance(body[0].value, ast.Constant)
|
|
278
|
+
and body[0].value.value is Ellipsis
|
|
279
|
+
)
|
|
280
|
+
has_reraise = any(
|
|
281
|
+
isinstance(s, ast.Raise) for s in ast.walk(handler)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if handler.type is None:
|
|
285
|
+
add(
|
|
286
|
+
"bare-except",
|
|
287
|
+
"warn",
|
|
288
|
+
"bare `except:` also swallows KeyboardInterrupt and SystemExit; "
|
|
289
|
+
"catch a specific exception type",
|
|
290
|
+
handler.lineno,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if (only_pass or only_ellipsis) and not has_reraise:
|
|
294
|
+
caught = ast.unparse(handler.type) if handler.type is not None else "everything"
|
|
295
|
+
add(
|
|
296
|
+
"swallowed-exception",
|
|
297
|
+
"review",
|
|
298
|
+
f"exception ({caught}) is caught and silently discarded - a real "
|
|
299
|
+
"failure here becomes invisible in production",
|
|
300
|
+
handler.lineno,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _mutable_default_findings(node: ast.FunctionDef | ast.AsyncFunctionDef, add) -> None:
|
|
305
|
+
args = node.args
|
|
306
|
+
defaults = list(args.defaults) + [d for d in args.kw_defaults if d is not None]
|
|
307
|
+
for default in defaults:
|
|
308
|
+
label = _mutable_default_label(default)
|
|
309
|
+
if label is not None:
|
|
310
|
+
add(
|
|
311
|
+
"mutable-default-arg",
|
|
312
|
+
"warn",
|
|
313
|
+
f"default argument {label} is created once and shared across every "
|
|
314
|
+
"call - mutating it leaks state between calls",
|
|
315
|
+
getattr(default, "lineno", node.lineno),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _mutable_default_label(node: ast.expr) -> str | None:
|
|
320
|
+
if isinstance(node, (ast.List, ast.Dict, ast.Set)):
|
|
321
|
+
return {ast.List: "[]", ast.Dict: "{}", ast.Set: "set literal"}[type(node)]
|
|
322
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
323
|
+
if node.func.id in _MUTABLE_DEFAULT_CALLS and not node.args and not node.keywords:
|
|
324
|
+
return f"{node.func.id}()"
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _is_docstring(stmt: ast.stmt) -> bool:
|
|
329
|
+
return (
|
|
330
|
+
isinstance(stmt, ast.Expr)
|
|
331
|
+
and isinstance(stmt.value, ast.Constant)
|
|
332
|
+
and isinstance(stmt.value.value, str)
|
|
333
|
+
)
|