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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ from deploy_guard.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
+ )