codeanalyzer-python 0.3.0__py3-none-any.whl → 1.0.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.
Files changed (46) hide show
  1. codeanalyzer/__main__.py +77 -4
  2. codeanalyzer/core.py +174 -72
  3. codeanalyzer/dataflow/__init__.py +35 -0
  4. codeanalyzer/dataflow/access_paths.py +563 -0
  5. codeanalyzer/dataflow/alias.py +93 -0
  6. codeanalyzer/dataflow/builder.py +688 -0
  7. codeanalyzer/dataflow/cfg.py +605 -0
  8. codeanalyzer/dataflow/defuse.py +113 -0
  9. codeanalyzer/dataflow/dominance.py +140 -0
  10. codeanalyzer/dataflow/identity.py +91 -0
  11. codeanalyzer/dataflow/pdg.py +100 -0
  12. codeanalyzer/dataflow/scalpel_oracle.py +269 -0
  13. codeanalyzer/dataflow/scc.py +91 -0
  14. codeanalyzer/dataflow/sdg.py +424 -0
  15. codeanalyzer/dataflow/slicing.py +93 -0
  16. codeanalyzer/dataflow/summaries.py +217 -0
  17. codeanalyzer/dataflow/syntactic.py +26 -0
  18. codeanalyzer/neo4j/__init__.py +1 -1
  19. codeanalyzer/neo4j/bolt.py +19 -4
  20. codeanalyzer/neo4j/cypher.py +9 -3
  21. codeanalyzer/neo4j/emit.py +10 -5
  22. codeanalyzer/neo4j/project.py +307 -60
  23. codeanalyzer/neo4j/rows.py +18 -15
  24. codeanalyzer/neo4j/schema.py +297 -15
  25. codeanalyzer/options/options.py +4 -0
  26. codeanalyzer/provenance.py +61 -0
  27. codeanalyzer/schema/__init__.py +19 -0
  28. codeanalyzer/schema/assign_ids.py +37 -0
  29. codeanalyzer/schema/call_graph_ids.py +12 -0
  30. codeanalyzer/schema/ids.py +23 -0
  31. codeanalyzer/schema/l1_body.py +29 -0
  32. codeanalyzer/schema/l2_callees.py +36 -0
  33. codeanalyzer/schema/py_schema.py +175 -26
  34. codeanalyzer/semantic_analysis/call_graph.py +24 -27
  35. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +10 -10
  36. codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
  37. codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
  38. codeanalyzer/syntactic_analysis/symbol_table_builder.py +103 -20
  39. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +233 -43
  40. codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
  41. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +1 -1
  42. codeanalyzer/neo4j/catalog.py +0 -245
  43. codeanalyzer_python-0.3.0.dist-info/RECORD +0 -38
  44. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
  45. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
  46. {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.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