codeanalyzer-python 0.3.1__py3-none-any.whl → 1.0.1__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.
- codeanalyzer/__main__.py +86 -4
- codeanalyzer/core.py +175 -82
- codeanalyzer/dataflow/__init__.py +35 -0
- codeanalyzer/dataflow/access_paths.py +563 -0
- codeanalyzer/dataflow/alias.py +93 -0
- codeanalyzer/dataflow/builder.py +688 -0
- codeanalyzer/dataflow/cfg.py +605 -0
- codeanalyzer/dataflow/defuse.py +113 -0
- codeanalyzer/dataflow/dominance.py +140 -0
- codeanalyzer/dataflow/identity.py +91 -0
- codeanalyzer/dataflow/pdg.py +100 -0
- codeanalyzer/dataflow/scalpel_oracle.py +269 -0
- codeanalyzer/dataflow/scc.py +91 -0
- codeanalyzer/dataflow/sdg.py +424 -0
- codeanalyzer/dataflow/slicing.py +93 -0
- codeanalyzer/dataflow/summaries.py +217 -0
- codeanalyzer/dataflow/syntactic.py +26 -0
- codeanalyzer/neo4j/bolt.py +19 -4
- codeanalyzer/neo4j/cypher.py +9 -3
- codeanalyzer/neo4j/emit.py +8 -3
- codeanalyzer/neo4j/project.py +241 -60
- codeanalyzer/neo4j/rows.py +18 -15
- codeanalyzer/neo4j/schema.py +43 -7
- codeanalyzer/options/options.py +4 -0
- codeanalyzer/schema/__init__.py +19 -0
- codeanalyzer/schema/assign_ids.py +37 -0
- codeanalyzer/schema/call_graph_ids.py +12 -0
- codeanalyzer/schema/ids.py +23 -0
- codeanalyzer/schema/l1_body.py +29 -0
- codeanalyzer/schema/l2_callees.py +36 -0
- codeanalyzer/schema/py_schema.py +141 -30
- codeanalyzer/semantic_analysis/call_graph.py +24 -27
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +77 -16
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +65 -27
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/METADATA +248 -61
- codeanalyzer_python-1.0.1.dist-info/RECORD +59 -0
- codeanalyzer_python-0.3.1.dist-info/RECORD +0 -39
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""Stage 3a of the level-3 dataflow ladder: the access-path variable model.
|
|
18
|
+
|
|
19
|
+
An access path is ``base(.field | [*])*`` — ``x``, ``x.f``, ``x.f.g``,
|
|
20
|
+
``arr[*]`` (all subscripts collapse to ``[*]``). Depth is k-limited (default
|
|
21
|
+
3): ``x.f.g.h`` with k=3 becomes ``x.f.g.*``, which conservatively interferes
|
|
22
|
+
with every deeper path. The string form is the ``var`` label of every DDG
|
|
23
|
+
edge.
|
|
24
|
+
|
|
25
|
+
Bases are classified per function scope: ``local``, ``param``, ``self`` (the
|
|
26
|
+
first parameter of a method), ``global`` (module binding — explicit ``global``
|
|
27
|
+
declaration or a free name not bound in an enclosing function), ``capture``
|
|
28
|
+
(free name bound in an enclosing function), and the pseudo-base ``<return>``.
|
|
29
|
+
|
|
30
|
+
Per-statement facts (defs / uses) follow the documented Python rules:
|
|
31
|
+
|
|
32
|
+
- Compound statements contribute only their *header* expressions (the CFG is
|
|
33
|
+
statement-level; bodies are separate nodes).
|
|
34
|
+
- Comprehension target variables live in their own scope: they are neither
|
|
35
|
+
defs nor uses of the enclosing statement (Python 3 semantics), while the
|
|
36
|
+
iterable and free names remain uses.
|
|
37
|
+
- A nested ``def``/``class`` statement defines its name and *uses* every
|
|
38
|
+
enclosing-scope variable the nested body captures (the closure binding is
|
|
39
|
+
over-approximated to the definition site) plus decorators and defaults.
|
|
40
|
+
- Calls mutate, over-approximately: the receiver base of a method call and
|
|
41
|
+
every argument that is itself an access path (a mutable reference) are
|
|
42
|
+
weak-defined at the call statement. Sound-leaning by contract; refined
|
|
43
|
+
precision is downstream's job.
|
|
44
|
+
- ``del x`` is a def (the name is re-bound to "undefined").
|
|
45
|
+
- ``return e`` uses ``e`` and defines the pseudo-path ``<return>``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import ast
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
53
|
+
|
|
54
|
+
from codeanalyzer.dataflow.cfg import ControlFlowGraph
|
|
55
|
+
|
|
56
|
+
RETURN_PATH = "<return>"
|
|
57
|
+
|
|
58
|
+
# Base-kind vocabulary (recorded per function for the SDG's formal nodes).
|
|
59
|
+
BASE_KINDS = ("local", "param", "self", "global", "capture")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def k_limit(path: str, k: int) -> str:
|
|
63
|
+
"""Truncate an access path to k dotted components; a truncated path ends
|
|
64
|
+
in ``.*`` and interferes with everything deeper (``x.f.g.h`` with k=3 →
|
|
65
|
+
``x.f.g.*``). ``[*]`` rides on its owning component."""
|
|
66
|
+
parts = path.split(".")
|
|
67
|
+
if len(parts) <= k:
|
|
68
|
+
return path
|
|
69
|
+
return ".".join(parts[:k]) + ".*"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def interferes(use: str, definition: str) -> bool:
|
|
73
|
+
"""Path interference without aliasing: exact match, prefix in either
|
|
74
|
+
direction (a write to ``x`` reaches a read of ``x.f``; a write to ``x.f``
|
|
75
|
+
reaches a read of ``x``), and truncation wildcards."""
|
|
76
|
+
if use == definition:
|
|
77
|
+
return True
|
|
78
|
+
u, d = use.rstrip("*").rstrip("."), definition.rstrip("*").rstrip(".")
|
|
79
|
+
return (
|
|
80
|
+
u == d
|
|
81
|
+
or u.startswith(d + ".")
|
|
82
|
+
or d.startswith(u + ".")
|
|
83
|
+
or u.startswith(d + "[")
|
|
84
|
+
or d.startswith(u + "[")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def suffix_of(path: str) -> str:
|
|
89
|
+
"""The field suffix after the base — the part aliasing preserves."""
|
|
90
|
+
base_end = len(path)
|
|
91
|
+
for i, ch in enumerate(path):
|
|
92
|
+
if ch in ".[":
|
|
93
|
+
base_end = i
|
|
94
|
+
break
|
|
95
|
+
return path[base_end:]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def base_of(path: str) -> str:
|
|
99
|
+
for i, ch in enumerate(path):
|
|
100
|
+
if ch in ".[":
|
|
101
|
+
return path[:i]
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class FunctionScope:
|
|
107
|
+
"""Name classification for one callable."""
|
|
108
|
+
|
|
109
|
+
params: List[str] = field(default_factory=list)
|
|
110
|
+
self_name: Optional[str] = None
|
|
111
|
+
locals_: Set[str] = field(default_factory=set)
|
|
112
|
+
globals_: Set[str] = field(default_factory=set)
|
|
113
|
+
captures: Set[str] = field(default_factory=set)
|
|
114
|
+
|
|
115
|
+
def kind_of(self, base: str) -> str:
|
|
116
|
+
if base == self.self_name:
|
|
117
|
+
return "self"
|
|
118
|
+
if base in self.params:
|
|
119
|
+
return "param"
|
|
120
|
+
if base in self.captures:
|
|
121
|
+
return "capture"
|
|
122
|
+
if base in self.globals_:
|
|
123
|
+
return "global"
|
|
124
|
+
if base in self.locals_:
|
|
125
|
+
return "local"
|
|
126
|
+
return "global" # unknown free name: a module/builtin binding
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class StatementFacts:
|
|
131
|
+
"""Defs and uses (k-limited access-path strings) of one CFG node."""
|
|
132
|
+
|
|
133
|
+
defs: Set[str] = field(default_factory=set)
|
|
134
|
+
uses: Set[str] = field(default_factory=set)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _assigned_names(func: ast.AST) -> Set[str]:
|
|
138
|
+
"""Names bound anywhere in the function body (not descending into nested
|
|
139
|
+
def/class bodies): assignment targets, loop targets, with-as, except-as,
|
|
140
|
+
imports, nested def/class names, del targets, walrus targets."""
|
|
141
|
+
names: Set[str] = set()
|
|
142
|
+
|
|
143
|
+
def collect_target(t: ast.AST) -> None:
|
|
144
|
+
if isinstance(t, ast.Name):
|
|
145
|
+
names.add(t.id)
|
|
146
|
+
elif isinstance(t, (ast.Tuple, ast.List)):
|
|
147
|
+
for el in t.elts:
|
|
148
|
+
collect_target(el)
|
|
149
|
+
elif isinstance(t, ast.Starred):
|
|
150
|
+
collect_target(t.value)
|
|
151
|
+
# Attribute/Subscript targets bind no *name*.
|
|
152
|
+
|
|
153
|
+
def walk(node: ast.AST) -> None:
|
|
154
|
+
for child in ast.iter_child_nodes(node):
|
|
155
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
156
|
+
names.add(child.name)
|
|
157
|
+
continue # nested scope
|
|
158
|
+
if isinstance(child, ast.Lambda):
|
|
159
|
+
continue
|
|
160
|
+
if isinstance(child, ast.Assign):
|
|
161
|
+
for t in child.targets:
|
|
162
|
+
collect_target(t)
|
|
163
|
+
elif isinstance(child, (ast.AugAssign, ast.AnnAssign)):
|
|
164
|
+
collect_target(child.target)
|
|
165
|
+
elif isinstance(child, (ast.For, ast.AsyncFor)):
|
|
166
|
+
collect_target(child.target)
|
|
167
|
+
elif isinstance(child, (ast.With, ast.AsyncWith)):
|
|
168
|
+
for item in child.items:
|
|
169
|
+
if item.optional_vars is not None:
|
|
170
|
+
collect_target(item.optional_vars)
|
|
171
|
+
elif isinstance(child, ast.ExceptHandler):
|
|
172
|
+
if child.name:
|
|
173
|
+
names.add(child.name)
|
|
174
|
+
elif isinstance(child, (ast.Import, ast.ImportFrom)):
|
|
175
|
+
for alias in child.names:
|
|
176
|
+
names.add((alias.asname or alias.name).split(".")[0])
|
|
177
|
+
elif isinstance(child, ast.NamedExpr):
|
|
178
|
+
collect_target(child.target)
|
|
179
|
+
elif isinstance(child, ast.Delete):
|
|
180
|
+
for t in child.targets:
|
|
181
|
+
collect_target(t)
|
|
182
|
+
walk(child)
|
|
183
|
+
|
|
184
|
+
walk(func)
|
|
185
|
+
return names
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _declared(func: ast.AST, decl_type) -> Set[str]:
|
|
189
|
+
names: Set[str] = set()
|
|
190
|
+
|
|
191
|
+
def walk(node: ast.AST) -> None:
|
|
192
|
+
for child in ast.iter_child_nodes(node):
|
|
193
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(child, decl_type):
|
|
196
|
+
names.update(child.names)
|
|
197
|
+
walk(child)
|
|
198
|
+
|
|
199
|
+
walk(func)
|
|
200
|
+
return names
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _param_names(func: ast.AST) -> List[str]:
|
|
204
|
+
a = func.args
|
|
205
|
+
names = [p.arg for p in getattr(a, "posonlyargs", [])] + [p.arg for p in a.args]
|
|
206
|
+
if a.vararg:
|
|
207
|
+
names.append(a.vararg.arg)
|
|
208
|
+
names.extend(p.arg for p in a.kwonlyargs)
|
|
209
|
+
if a.kwarg:
|
|
210
|
+
names.append(a.kwarg.arg)
|
|
211
|
+
return names
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def free_names(func: ast.AST) -> Set[str]:
|
|
215
|
+
"""Names the callable reads but does not bind — candidates for capture
|
|
216
|
+
(if bound in an enclosing function) or module globals. Includes the free
|
|
217
|
+
names of its own nested callables (capture transits scopes)."""
|
|
218
|
+
bound = set(_param_names(func)) | _assigned_names(func) | _declared(func, ast.Global)
|
|
219
|
+
used: Set[str] = set()
|
|
220
|
+
|
|
221
|
+
def walk(node: ast.AST) -> None:
|
|
222
|
+
for child in ast.iter_child_nodes(node):
|
|
223
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
224
|
+
used.update(free_names(child) - {child.name})
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(child, ast.Lambda):
|
|
227
|
+
lam_bound = set(_param_names(child))
|
|
228
|
+
for name in _names_loaded(child.body):
|
|
229
|
+
if name not in lam_bound:
|
|
230
|
+
used.add(name)
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
|
|
233
|
+
used.add(child.id)
|
|
234
|
+
walk(child)
|
|
235
|
+
|
|
236
|
+
walk(func)
|
|
237
|
+
return used - bound
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _names_loaded(node: ast.AST) -> Set[str]:
|
|
241
|
+
out: Set[str] = set()
|
|
242
|
+
for n in ast.walk(node):
|
|
243
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
|
|
244
|
+
out.add(n.id)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def build_scope(func: ast.AST, enclosing_locals: Set[str]) -> FunctionScope:
|
|
249
|
+
"""Classify every base name the callable touches. ``enclosing_locals`` is
|
|
250
|
+
the union of locals/params of all enclosing callables (for capture vs
|
|
251
|
+
global disambiguation)."""
|
|
252
|
+
params = _param_names(func)
|
|
253
|
+
scope = FunctionScope(params=params)
|
|
254
|
+
if params and isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
255
|
+
decorators = {ast.unparse(d) for d in func.decorator_list}
|
|
256
|
+
if params[0] in ("self", "cls") and "staticmethod" not in decorators:
|
|
257
|
+
scope.self_name = params[0]
|
|
258
|
+
scope.globals_ = _declared(func, ast.Global)
|
|
259
|
+
nonlocals = _declared(func, ast.Nonlocal)
|
|
260
|
+
scope.locals_ = _assigned_names(func) - scope.globals_ - nonlocals
|
|
261
|
+
free = (free_names(func) | nonlocals) - set(params)
|
|
262
|
+
scope.captures = {n for n in free if n in enclosing_locals}
|
|
263
|
+
scope.globals_ |= free - scope.captures
|
|
264
|
+
return scope
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class _PathExtractor:
|
|
268
|
+
"""Turns the header expressions of one statement into def/use path sets."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, scope: FunctionScope, k: int):
|
|
271
|
+
self.scope = scope
|
|
272
|
+
self.k = k
|
|
273
|
+
|
|
274
|
+
# -- expression → path (None when the expression is not a path) ---------
|
|
275
|
+
|
|
276
|
+
def path_of(self, expr: ast.expr) -> Optional[str]:
|
|
277
|
+
if isinstance(expr, ast.Name):
|
|
278
|
+
return expr.id
|
|
279
|
+
if isinstance(expr, ast.Attribute):
|
|
280
|
+
inner = self.path_of(expr.value)
|
|
281
|
+
return None if inner is None else k_limit(f"{inner}.{expr.attr}", self.k)
|
|
282
|
+
if isinstance(expr, ast.Subscript):
|
|
283
|
+
inner = self.path_of(expr.value)
|
|
284
|
+
return None if inner is None else k_limit(f"{inner}[*]", self.k)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
# -- uses ----------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def uses_in(self, expr: ast.expr) -> Set[str]:
|
|
290
|
+
"""All access paths read by an expression. Comprehension targets are
|
|
291
|
+
scoped out; nested lambda bodies contribute their free names only."""
|
|
292
|
+
uses: Set[str] = set()
|
|
293
|
+
self._collect_uses(expr, uses, shadowed=set())
|
|
294
|
+
return uses
|
|
295
|
+
|
|
296
|
+
def _collect_uses(self, expr: ast.expr, out: Set[str], shadowed: Set[str]) -> None:
|
|
297
|
+
if isinstance(expr, ast.Name):
|
|
298
|
+
if isinstance(expr.ctx, ast.Load) and expr.id not in shadowed:
|
|
299
|
+
out.add(expr.id)
|
|
300
|
+
return
|
|
301
|
+
if isinstance(expr, (ast.Attribute, ast.Subscript)):
|
|
302
|
+
p = self.path_of(expr)
|
|
303
|
+
if p is not None and base_of(p) not in shadowed:
|
|
304
|
+
out.add(p)
|
|
305
|
+
if isinstance(expr, ast.Subscript):
|
|
306
|
+
self._collect_uses(expr.slice, out, shadowed)
|
|
307
|
+
return
|
|
308
|
+
# Not a pure path (e.g. f(x).g): fall through to children.
|
|
309
|
+
if isinstance(expr, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
310
|
+
inner_shadow = set(shadowed)
|
|
311
|
+
for comp in expr.generators:
|
|
312
|
+
# The iterable of the first generator evaluates in the
|
|
313
|
+
# enclosing scope; targets shadow from then on.
|
|
314
|
+
self._collect_uses(comp.iter, out, inner_shadow)
|
|
315
|
+
inner_shadow |= _names_loaded_targets(comp.target)
|
|
316
|
+
for cond in comp.ifs:
|
|
317
|
+
self._collect_uses(cond, out, inner_shadow)
|
|
318
|
+
if isinstance(expr, ast.DictComp):
|
|
319
|
+
self._collect_uses(expr.key, out, inner_shadow)
|
|
320
|
+
self._collect_uses(expr.value, out, inner_shadow)
|
|
321
|
+
else:
|
|
322
|
+
self._collect_uses(expr.elt, out, inner_shadow)
|
|
323
|
+
return
|
|
324
|
+
if isinstance(expr, ast.Lambda):
|
|
325
|
+
lam_shadow = shadowed | set(_param_names(expr))
|
|
326
|
+
self._collect_uses(expr.body, out, lam_shadow)
|
|
327
|
+
return
|
|
328
|
+
for child in ast.iter_child_nodes(expr):
|
|
329
|
+
if isinstance(child, ast.expr):
|
|
330
|
+
self._collect_uses(child, out, shadowed)
|
|
331
|
+
elif isinstance(child, (ast.comprehension, ast.keyword)):
|
|
332
|
+
for sub in ast.iter_child_nodes(child):
|
|
333
|
+
if isinstance(sub, ast.expr):
|
|
334
|
+
self._collect_uses(sub, out, shadowed)
|
|
335
|
+
|
|
336
|
+
# -- defs ----------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def defs_of_target(self, target: ast.expr) -> Set[str]:
|
|
339
|
+
defs: Set[str] = set()
|
|
340
|
+
if isinstance(target, ast.Name):
|
|
341
|
+
defs.add(target.id)
|
|
342
|
+
elif isinstance(target, (ast.Attribute, ast.Subscript)):
|
|
343
|
+
p = self.path_of(target)
|
|
344
|
+
if p is not None:
|
|
345
|
+
defs.add(p)
|
|
346
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
347
|
+
for el in target.elts:
|
|
348
|
+
defs.update(self.defs_of_target(el))
|
|
349
|
+
elif isinstance(target, ast.Starred):
|
|
350
|
+
defs.update(self.defs_of_target(target.value))
|
|
351
|
+
return defs
|
|
352
|
+
|
|
353
|
+
def target_reads(self, target: ast.expr) -> Set[str]:
|
|
354
|
+
"""Reads implied by a compound target: ``p.f = v`` reads ``p``;
|
|
355
|
+
``a[i] = v`` reads ``a`` and ``i``."""
|
|
356
|
+
reads: Set[str] = set()
|
|
357
|
+
if isinstance(target, (ast.Attribute, ast.Subscript)):
|
|
358
|
+
inner = self.path_of(target.value)
|
|
359
|
+
if inner is not None:
|
|
360
|
+
reads.add(inner)
|
|
361
|
+
else:
|
|
362
|
+
self._collect_uses(target.value, reads, set())
|
|
363
|
+
if isinstance(target, ast.Subscript):
|
|
364
|
+
self._collect_uses(target.slice, reads, set())
|
|
365
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
366
|
+
for el in target.elts:
|
|
367
|
+
reads.update(self.target_reads(el))
|
|
368
|
+
elif isinstance(target, ast.Starred):
|
|
369
|
+
reads.update(self.target_reads(target.value))
|
|
370
|
+
return reads
|
|
371
|
+
|
|
372
|
+
# -- call mutation (documented over-approximation) -----------------------
|
|
373
|
+
|
|
374
|
+
def mutation_defs(self, expr: ast.expr) -> Set[str]:
|
|
375
|
+
"""Weak defs of the *contents* of receiver/argument objects (``xs.*``
|
|
376
|
+
— suffixed, so a call mutation is never confused with a local
|
|
377
|
+
rebinding, which is not caller-visible)."""
|
|
378
|
+
defs: Set[str] = set()
|
|
379
|
+
for call in _calls_in(expr):
|
|
380
|
+
if isinstance(call.func, ast.Attribute):
|
|
381
|
+
receiver = self.path_of(call.func.value)
|
|
382
|
+
if receiver is not None:
|
|
383
|
+
defs.add(k_limit(receiver + ".*", self.k))
|
|
384
|
+
for arg in list(call.args) + [kw.value for kw in call.keywords]:
|
|
385
|
+
p = self.path_of(arg)
|
|
386
|
+
if p is not None:
|
|
387
|
+
defs.add(k_limit(p + ".*", self.k))
|
|
388
|
+
return defs
|
|
389
|
+
|
|
390
|
+
def receiver_uses(self, expr: ast.expr) -> Set[str]:
|
|
391
|
+
"""Whole-object reads at call sites: a method call reads its receiver
|
|
392
|
+
(dispatch + any field the callee touches — the alias oracle matches
|
|
393
|
+
field writes through other names against this bare-base use)."""
|
|
394
|
+
uses: Set[str] = set()
|
|
395
|
+
for call in _calls_in(expr):
|
|
396
|
+
if isinstance(call.func, ast.Attribute):
|
|
397
|
+
receiver = self.path_of(call.func.value)
|
|
398
|
+
if receiver is not None:
|
|
399
|
+
uses.add(receiver)
|
|
400
|
+
return uses
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _names_loaded_targets(target: ast.expr) -> Set[str]:
|
|
404
|
+
out: Set[str] = set()
|
|
405
|
+
for n in ast.walk(target):
|
|
406
|
+
if isinstance(n, ast.Name):
|
|
407
|
+
out.add(n.id)
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _calls_in(expr: ast.expr) -> List[ast.Call]:
|
|
412
|
+
calls: List[ast.Call] = []
|
|
413
|
+
stack: List[ast.AST] = [expr]
|
|
414
|
+
while stack:
|
|
415
|
+
node = stack.pop()
|
|
416
|
+
if isinstance(node, ast.Call):
|
|
417
|
+
calls.append(node)
|
|
418
|
+
for child in ast.iter_child_nodes(node):
|
|
419
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
420
|
+
continue
|
|
421
|
+
stack.append(child)
|
|
422
|
+
return calls
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def qualify_globals(paths: Set[str], scope: FunctionScope, qualifier: str) -> Set[str]:
|
|
426
|
+
"""Rewrite global bases to their module-qualified form ``module::name``
|
|
427
|
+
(``::`` keeps the qualifier out of the field-path grammar). Builtins stay
|
|
428
|
+
bare — they carry no cross-module dataflow worth modeling."""
|
|
429
|
+
import builtins as _builtins
|
|
430
|
+
|
|
431
|
+
out: Set[str] = set()
|
|
432
|
+
for p in paths:
|
|
433
|
+
b = base_of(p)
|
|
434
|
+
if (
|
|
435
|
+
"::" not in b
|
|
436
|
+
and b != RETURN_PATH
|
|
437
|
+
and scope.kind_of(b) == "global"
|
|
438
|
+
and not hasattr(_builtins, b)
|
|
439
|
+
):
|
|
440
|
+
out.add(f"{qualifier}::{b}" + p[len(b):])
|
|
441
|
+
else:
|
|
442
|
+
out.add(p)
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def statement_facts(
|
|
447
|
+
cfg: ControlFlowGraph,
|
|
448
|
+
func: ast.AST,
|
|
449
|
+
scope: FunctionScope,
|
|
450
|
+
k: int,
|
|
451
|
+
global_qualifier: Optional[str] = None,
|
|
452
|
+
) -> Dict[int, StatementFacts]:
|
|
453
|
+
"""Defs/uses per CFG node id. Compound statements contribute only their
|
|
454
|
+
header expressions; ENTRY defines every param/self/global/capture base
|
|
455
|
+
the function touches (the incoming state). With ``global_qualifier`` set
|
|
456
|
+
(the interprocedural build), global bases become ``module::name``."""
|
|
457
|
+
ex = _PathExtractor(scope, k)
|
|
458
|
+
facts: Dict[int, StatementFacts] = {}
|
|
459
|
+
|
|
460
|
+
for node in cfg.nodes:
|
|
461
|
+
f = StatementFacts()
|
|
462
|
+
stmt = node.ast_node
|
|
463
|
+
|
|
464
|
+
def call_fx(expr: ast.expr) -> None:
|
|
465
|
+
"""Call effects on the current facts: over-approximate mutation
|
|
466
|
+
defs plus the whole-object receiver read."""
|
|
467
|
+
f.defs |= ex.mutation_defs(expr)
|
|
468
|
+
f.uses |= ex.receiver_uses(expr)
|
|
469
|
+
|
|
470
|
+
if node.kind == "entry":
|
|
471
|
+
f.defs = set(scope.params) | set(scope.captures)
|
|
472
|
+
if scope.self_name:
|
|
473
|
+
f.defs.add(scope.self_name)
|
|
474
|
+
# Globals the function reads arrive with the incoming state too.
|
|
475
|
+
f.defs |= scope.globals_
|
|
476
|
+
elif stmt is None:
|
|
477
|
+
pass # exit
|
|
478
|
+
elif isinstance(stmt, ast.Assign):
|
|
479
|
+
f.uses = ex.uses_in(stmt.value)
|
|
480
|
+
for t in stmt.targets:
|
|
481
|
+
f.defs |= ex.defs_of_target(t)
|
|
482
|
+
f.uses |= ex.target_reads(t)
|
|
483
|
+
call_fx(stmt.value)
|
|
484
|
+
elif isinstance(stmt, ast.AugAssign):
|
|
485
|
+
f.uses = ex.uses_in(stmt.value) | ex.defs_of_target(stmt.target) | ex.target_reads(stmt.target)
|
|
486
|
+
f.defs = ex.defs_of_target(stmt.target)
|
|
487
|
+
call_fx(stmt.value)
|
|
488
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
489
|
+
if stmt.value is not None:
|
|
490
|
+
f.uses = ex.uses_in(stmt.value)
|
|
491
|
+
f.defs = ex.defs_of_target(stmt.target)
|
|
492
|
+
f.uses |= ex.target_reads(stmt.target)
|
|
493
|
+
call_fx(stmt.value)
|
|
494
|
+
elif isinstance(stmt, ast.Return):
|
|
495
|
+
if stmt.value is not None:
|
|
496
|
+
f.uses = ex.uses_in(stmt.value)
|
|
497
|
+
call_fx(stmt.value)
|
|
498
|
+
f.defs.add(RETURN_PATH)
|
|
499
|
+
elif isinstance(stmt, ast.If):
|
|
500
|
+
f.uses = ex.uses_in(stmt.test)
|
|
501
|
+
call_fx(stmt.test)
|
|
502
|
+
elif isinstance(stmt, ast.While):
|
|
503
|
+
f.uses = ex.uses_in(stmt.test)
|
|
504
|
+
call_fx(stmt.test)
|
|
505
|
+
elif isinstance(stmt, (ast.For, ast.AsyncFor)):
|
|
506
|
+
f.uses = ex.uses_in(stmt.iter)
|
|
507
|
+
f.defs = ex.defs_of_target(stmt.target)
|
|
508
|
+
call_fx(stmt.iter)
|
|
509
|
+
f.uses |= ex.target_reads(stmt.target)
|
|
510
|
+
elif isinstance(stmt, (ast.With, ast.AsyncWith)):
|
|
511
|
+
for item in stmt.items:
|
|
512
|
+
f.uses |= ex.uses_in(item.context_expr)
|
|
513
|
+
call_fx(item.context_expr)
|
|
514
|
+
if item.optional_vars is not None:
|
|
515
|
+
f.defs |= ex.defs_of_target(item.optional_vars)
|
|
516
|
+
elif isinstance(stmt, ast.ExceptHandler):
|
|
517
|
+
if stmt.type is not None:
|
|
518
|
+
f.uses = ex.uses_in(stmt.type)
|
|
519
|
+
if stmt.name:
|
|
520
|
+
f.defs.add(stmt.name)
|
|
521
|
+
elif isinstance(stmt, (ast.Raise, ast.Assert)):
|
|
522
|
+
for sub in ast.iter_child_nodes(stmt):
|
|
523
|
+
if isinstance(sub, ast.expr):
|
|
524
|
+
f.uses |= ex.uses_in(sub)
|
|
525
|
+
call_fx(sub)
|
|
526
|
+
elif isinstance(stmt, ast.Expr):
|
|
527
|
+
f.uses = ex.uses_in(stmt.value)
|
|
528
|
+
call_fx(stmt.value)
|
|
529
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
530
|
+
f.defs.add(stmt.name)
|
|
531
|
+
captured = free_names(stmt) & (scope.locals_ | set(scope.params) | scope.captures)
|
|
532
|
+
f.uses |= captured
|
|
533
|
+
for d in stmt.decorator_list:
|
|
534
|
+
f.uses |= ex.uses_in(d)
|
|
535
|
+
for default in list(stmt.args.defaults) + [
|
|
536
|
+
d for d in stmt.args.kw_defaults if d is not None
|
|
537
|
+
]:
|
|
538
|
+
f.uses |= ex.uses_in(default)
|
|
539
|
+
elif isinstance(stmt, ast.ClassDef):
|
|
540
|
+
f.defs.add(stmt.name)
|
|
541
|
+
for d in list(stmt.decorator_list) + list(stmt.bases):
|
|
542
|
+
f.uses |= ex.uses_in(d)
|
|
543
|
+
elif isinstance(stmt, ast.Delete):
|
|
544
|
+
for t in stmt.targets:
|
|
545
|
+
f.defs |= ex.defs_of_target(t)
|
|
546
|
+
elif isinstance(stmt, (ast.Import, ast.ImportFrom)):
|
|
547
|
+
for alias in stmt.names:
|
|
548
|
+
f.defs.add((alias.asname or alias.name).split(".")[0])
|
|
549
|
+
elif isinstance(stmt, (ast.Global, ast.Nonlocal, ast.Pass, ast.Break, ast.Continue)):
|
|
550
|
+
pass
|
|
551
|
+
else: # pragma: no cover — future statement kinds stay sound
|
|
552
|
+
for sub in ast.iter_child_nodes(stmt):
|
|
553
|
+
if isinstance(sub, ast.expr):
|
|
554
|
+
f.uses |= ex.uses_in(sub)
|
|
555
|
+
|
|
556
|
+
f.defs = {k_limit(p, k) for p in f.defs}
|
|
557
|
+
f.uses = {k_limit(p, k) for p in f.uses}
|
|
558
|
+
if global_qualifier is not None:
|
|
559
|
+
f.defs = qualify_globals(f.defs, scope, global_qualifier)
|
|
560
|
+
f.uses = qualify_globals(f.uses, scope, global_qualifier)
|
|
561
|
+
facts[node.id] = f
|
|
562
|
+
|
|
563
|
+
return facts
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""Stage 5a of the level-3 dataflow ladder: the may-alias oracle.
|
|
18
|
+
|
|
19
|
+
Python has no in-process Andersen-style points-to library, so the locked
|
|
20
|
+
substrate decision (#67) is the **type-based MVP stub**: two access paths may
|
|
21
|
+
alias iff they share a non-empty field suffix and their bases' inferred types
|
|
22
|
+
are compatible — where an unknown type is compatible with everything
|
|
23
|
+
(sound-leaning by contract). Bare locals never alias each other (Python has
|
|
24
|
+
no pointers to locals; closure and global sharing ride the capture/global
|
|
25
|
+
mechanisms instead).
|
|
26
|
+
|
|
27
|
+
The oracle is frozen: downstream stages call :meth:`may_alias` and never
|
|
28
|
+
reach into its internals, so upgrading to a real points-to substrate later is
|
|
29
|
+
a drop-in replacement.
|
|
30
|
+
|
|
31
|
+
Type information comes from the symbol table Jedi already populated
|
|
32
|
+
(``PyVariableDeclaration.type`` / ``PyCallableParameter.type``); the oracle
|
|
33
|
+
works with whatever subset is present.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from typing import Dict, Optional
|
|
39
|
+
|
|
40
|
+
from codeanalyzer.dataflow.access_paths import base_of, suffix_of
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize(type_name: Optional[str]) -> Optional[str]:
|
|
44
|
+
if not type_name:
|
|
45
|
+
return None
|
|
46
|
+
t = type_name.strip()
|
|
47
|
+
# `Optional[X]`, `X | None`, quotes, module prefixes: compare last simple name.
|
|
48
|
+
for wrapper in ("Optional[", "typing.Optional["):
|
|
49
|
+
if t.startswith(wrapper) and t.endswith("]"):
|
|
50
|
+
t = t[len(wrapper):-1]
|
|
51
|
+
t = t.split("|")[0].strip()
|
|
52
|
+
t = t.split("[")[0].strip()
|
|
53
|
+
return t.split(".")[-1] or None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TypeBasedAliasOracle:
|
|
57
|
+
"""``may_alias(p1, p2)`` for access paths in one function scope.
|
|
58
|
+
|
|
59
|
+
``base_types`` maps base names to their inferred type names (absent or
|
|
60
|
+
``None`` = unknown = may alias anything with the same suffix).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, base_types: Optional[Dict[str, Optional[str]]] = None):
|
|
64
|
+
self._types = {k: _normalize(v) for k, v in (base_types or {}).items()}
|
|
65
|
+
|
|
66
|
+
def may_alias(self, path_a: str, path_b: str) -> bool:
|
|
67
|
+
if path_a == path_b:
|
|
68
|
+
return True
|
|
69
|
+
suffix_a, suffix_b = suffix_of(path_a), suffix_of(path_b)
|
|
70
|
+
if not suffix_a and not suffix_b:
|
|
71
|
+
# Two distinct bare bases never alias (locals are not
|
|
72
|
+
# addressable); base sharing rides assignments in the DDG.
|
|
73
|
+
return False
|
|
74
|
+
# Field-sensitive up to prefix compatibility: identical suffixes may
|
|
75
|
+
# denote one location; a bare base (whole-object read/write) observes
|
|
76
|
+
# every field of its object, so an empty suffix is prefix-compatible
|
|
77
|
+
# with any; wildcards from k-truncation match anything deeper.
|
|
78
|
+
sa = suffix_a.rstrip("*").rstrip(".")
|
|
79
|
+
sb = suffix_b.rstrip("*").rstrip(".")
|
|
80
|
+
prefix_compatible = (
|
|
81
|
+
sa == sb
|
|
82
|
+
or sa.startswith(sb)
|
|
83
|
+
or sb.startswith(sa)
|
|
84
|
+
or suffix_a.endswith("*")
|
|
85
|
+
or suffix_b.endswith("*")
|
|
86
|
+
)
|
|
87
|
+
if not prefix_compatible:
|
|
88
|
+
return False
|
|
89
|
+
type_a = self._types.get(base_of(path_a))
|
|
90
|
+
type_b = self._types.get(base_of(path_b))
|
|
91
|
+
if type_a is None or type_b is None:
|
|
92
|
+
return True # unknown: conservatively compatible
|
|
93
|
+
return type_a == type_b
|