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,269 @@
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 4 of the level-3/4 dataflow ladder: the primary L4 may-alias oracle.
18
+
19
+ ``python-scalpel`` (SMAT-Lab/Scalpel) is the substrate decided by the Stage-0
20
+ spike (issue #70): not a turnkey points-to engine but an **SSA + copy/const**
21
+ producer. :class:`ScalpelAliasOracle` consumes its *solved* state — it never
22
+ forks or re-runs Scalpel's solver — and turns the copy/const records into
23
+ per-function copy-closure equivalence classes:
24
+
25
+ ``from scalpel.SSA.const import SSA``
26
+ ``ssa_results, const_dict = SSA().compute_SSA(func_cfg)``
27
+
28
+ ``const_dict`` maps ``(name, version)`` to the ``ast`` value node that defined
29
+ that SSA name. A value that is an ``ast.Name`` is a whole-object **copy edge**
30
+ (``b = a`` ⇒ ``('b', 0) -> Name 'a'``); a value that is an ``ast.Attribute`` is
31
+ an attribute-path copy (``q = p.x`` ⇒ ``('q', 0) -> Attribute p.x``). The
32
+ transitive closure of these edges is a union-find over access-path strings.
33
+
34
+ ``may_alias(path_a, path_b)`` is TRUE iff the paths are identical, or their
35
+ bases share a copy-closure class (or the whole paths do) *and* their field
36
+ suffixes are prefix-compatible (the same suffix logic the frozen
37
+ :class:`~codeanalyzer.dataflow.alias.TypeBasedAliasOracle` uses). Anything the
38
+ copy closure cannot resolve — unrelated bases, constructs Scalpel does not
39
+ model (heap points-to, two distinct params, container elements) — is delegated
40
+ to a wrapped ``TypeBasedAliasOracle``, which supplies the type-guided verdict
41
+ (incompatible concrete types ⇒ not aliased; unknown type ⇒ may-alias).
42
+
43
+ The oracle is **sound-leaning**: adding copy edges only ever yields *more*
44
+ may-alias answers, and every uncertainty widens to the type-based fallback
45
+ rather than silently returning ``False``. All Scalpel use is guarded — a build
46
+ or query failure degrades to the wrapped fallback, never an exception.
47
+
48
+ The public interface is frozen and identical to ``TypeBasedAliasOracle``:
49
+ ``may_alias(path_a: str, path_b: str) -> bool``.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import ast
55
+ import re
56
+ from typing import Dict, Optional
57
+
58
+ from codeanalyzer.dataflow.access_paths import base_of, suffix_of
59
+ from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
60
+ from codeanalyzer.utils import logger
61
+
62
+ # All subscripts collapse to ``[*]`` to match the access-path grammar
63
+ # (``base(.field | [*])*``) the rest of the dataflow ladder speaks.
64
+ _SUBSCRIPT = re.compile(r"\[[^\[\]]*\]")
65
+
66
+ # Log the "Scalpel unavailable → fallback" notice at most once per process so a
67
+ # large project does not spam one line per function.
68
+ _fallback_logged = False
69
+
70
+
71
+ def _normalize_path(path: str) -> str:
72
+ return _SUBSCRIPT.sub("[*]", path)
73
+
74
+
75
+ def _suffix_prefix_compatible(path_a: str, path_b: str) -> bool:
76
+ """Field-suffix compatibility, identical to the frozen
77
+ ``TypeBasedAliasOracle`` rule: identical suffixes may denote one location; a
78
+ bare base (whole-object access) observes every field, so an empty suffix is
79
+ compatible with any; k-truncation wildcards (``*``) match anything deeper."""
80
+ suffix_a, suffix_b = suffix_of(path_a), suffix_of(path_b)
81
+ sa = suffix_a.rstrip("*").rstrip(".")
82
+ sb = suffix_b.rstrip("*").rstrip(".")
83
+ return bool(
84
+ sa == sb
85
+ or sa.startswith(sb)
86
+ or sb.startswith(sa)
87
+ or suffix_a.endswith("*")
88
+ or suffix_b.endswith("*")
89
+ )
90
+
91
+
92
+ def _note_fallback(reason: str) -> None:
93
+ global _fallback_logged
94
+ if not _fallback_logged:
95
+ logger.info(
96
+ "Scalpel may-alias oracle unavailable (%s); using TypeBasedAliasOracle fallback.",
97
+ reason,
98
+ )
99
+ _fallback_logged = True
100
+
101
+
102
+ class ScalpelAliasOracle:
103
+ """L4 may-alias oracle backed by Scalpel's SSA copy/const facts.
104
+
105
+ Construct from a raw ``const_dict`` (``(name, version) -> ast value``) or,
106
+ more commonly, via :meth:`from_function`, which imports Scalpel and computes
107
+ the SSA state for a function AST. ``base_types`` (base name → inferred type)
108
+ feeds the wrapped :class:`TypeBasedAliasOracle` used for everything the copy
109
+ closure cannot decide.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ const_dict: Optional[dict] = None,
115
+ base_types: Optional[Dict[str, Optional[str]]] = None,
116
+ fallback: Optional[TypeBasedAliasOracle] = None,
117
+ ):
118
+ self._fallback = fallback or TypeBasedAliasOracle(base_types)
119
+ self._parent: Dict[str, str] = {}
120
+ self._seen: set[str] = set()
121
+ try:
122
+ self._build_classes(const_dict or {})
123
+ except Exception: # pragma: no cover — never let a build quirk escape
124
+ logger.debug(
125
+ "scalpel copy-closure build failed; oracle will lean on fallback",
126
+ exc_info=True,
127
+ )
128
+
129
+ # -- construction --------------------------------------------------------
130
+
131
+ @classmethod
132
+ def from_function(
133
+ cls,
134
+ func_ast: ast.AST,
135
+ base_types: Optional[Dict[str, Optional[str]]] = None,
136
+ fallback: Optional[TypeBasedAliasOracle] = None,
137
+ name: Optional[str] = None,
138
+ ) -> "ScalpelAliasOracle":
139
+ """Build from a function AST by consuming Scalpel's solved SSA state.
140
+
141
+ Imports Scalpel lazily (``ImportError`` if the optional dependency is
142
+ absent) and reuses the *same source* both graphs are built from — the
143
+ function's unparsed text — so the join is identity, not a fuzzy match.
144
+ Raises on any build failure; :func:`make_alias_oracle` is the total,
145
+ never-raising entry point callers should prefer.
146
+ """
147
+ from scalpel.SSA.const import SSA
148
+ from scalpel.cfg import CFGBuilder
149
+
150
+ src = ast.unparse(func_ast)
151
+ fname = name or getattr(func_ast, "name", None)
152
+ module_cfg = CFGBuilder().build_from_src(fname or "module", src)
153
+ func_cfg = cls._select_func_cfg(module_cfg, fname)
154
+ if func_cfg is None:
155
+ raise ValueError("scalpel produced no function CFG for the given AST")
156
+ # Consume the solved state; never re-run the solver ourselves.
157
+ _ssa_results, const_dict = SSA().compute_SSA(func_cfg)
158
+ return cls(const_dict, base_types=base_types, fallback=fallback)
159
+
160
+ @staticmethod
161
+ def _select_func_cfg(module_cfg, fname: Optional[str]):
162
+ """Pick the target function's CFG out of the module CFG's
163
+ ``functioncfgs`` (keyed ``(entry_id, func_name)``)."""
164
+ cfgs = getattr(module_cfg, "functioncfgs", None) or {}
165
+ if fname is not None:
166
+ for key, fcfg in cfgs.items():
167
+ if isinstance(key, tuple) and len(key) >= 2 and key[1] == fname:
168
+ return fcfg
169
+ return next(iter(cfgs.values()), None)
170
+
171
+ # -- copy-closure over const_dict ----------------------------------------
172
+
173
+ def _build_classes(self, const_dict: dict) -> None:
174
+ for key, value in const_dict.items():
175
+ lhs = self._key_to_path(key)
176
+ rhs = self._value_to_path(value)
177
+ if lhs is None or rhs is None:
178
+ continue
179
+ self._union(lhs, rhs)
180
+
181
+ @staticmethod
182
+ def _key_to_path(key) -> Optional[str]:
183
+ name = key[0] if isinstance(key, tuple) and key else key
184
+ if not isinstance(name, str):
185
+ return None
186
+ return _normalize_path(name)
187
+
188
+ @staticmethod
189
+ def _value_to_path(value) -> Optional[str]:
190
+ # ``ast.Name`` value ⇒ whole-object copy edge (name ↔ name).
191
+ if isinstance(value, ast.Name):
192
+ return _normalize_path(value.id)
193
+ # ``ast.Attribute`` value ⇒ attribute-path copy (name ↔ base.field...).
194
+ if isinstance(value, ast.Attribute):
195
+ try:
196
+ return _normalize_path(ast.unparse(value))
197
+ except Exception:
198
+ return None
199
+ return None
200
+
201
+ # -- union-find ----------------------------------------------------------
202
+
203
+ def _find(self, x: str) -> str:
204
+ self._parent.setdefault(x, x)
205
+ root = x
206
+ while self._parent[root] != root:
207
+ root = self._parent[root]
208
+ while self._parent[x] != root: # path compression
209
+ self._parent[x], x = root, self._parent[x]
210
+ return root
211
+
212
+ def _union(self, a: str, b: str) -> None:
213
+ self._seen.add(a)
214
+ self._seen.add(b)
215
+ ra, rb = self._find(a), self._find(b)
216
+ if ra != rb:
217
+ self._parent[rb] = ra
218
+
219
+ def _merged(self, a: str, b: str) -> bool:
220
+ # Only trust a shared root when *both* tokens were actually observed in
221
+ # the copy closure — otherwise two distinct unseen tokens are singleton
222
+ # classes and must not be treated as related.
223
+ return a in self._seen and b in self._seen and self._find(a) == self._find(b)
224
+
225
+ # -- frozen interface ----------------------------------------------------
226
+
227
+ def may_alias(self, path_a: str, path_b: str) -> bool:
228
+ if path_a == path_b:
229
+ return True
230
+ try:
231
+ na, nb = _normalize_path(path_a), _normalize_path(path_b)
232
+ base_a, base_b = base_of(na), base_of(nb)
233
+ if base_a == base_b:
234
+ # Same object: purely field-sensitive (distinct fields do not
235
+ # alias); matches the frozen TypeBasedAliasOracle decision.
236
+ return _suffix_prefix_compatible(na, nb)
237
+ # Distinct bases that Scalpel proved to be copies (or whole paths
238
+ # that are copies) alias iff their suffixes are prefix-compatible.
239
+ if self._merged(base_a, base_b) or self._merged(na, nb):
240
+ return _suffix_prefix_compatible(na, nb)
241
+ except Exception:
242
+ logger.debug(
243
+ "scalpel may_alias failed; delegating to fallback", exc_info=True
244
+ )
245
+ # Unresolved by the copy closure: hand off to the type-guided fallback
246
+ # (sound-leaning — when uncertain it over-approximates to True).
247
+ return self._fallback.may_alias(path_a, path_b)
248
+
249
+
250
+ def make_alias_oracle(pycallable, func_ast, base_types) -> object:
251
+ """Total selector for the L4 may-alias oracle.
252
+
253
+ Returns a :class:`ScalpelAliasOracle` when ``python-scalpel`` is importable
254
+ *and* builds successfully on ``func_ast``; otherwise logs once (INFO) and
255
+ returns a :class:`TypeBasedAliasOracle` over ``base_types``. Never raises —
256
+ mirrors how ``core._get_pycg_call_graph`` degrades on a missing/failed PyCG.
257
+ """
258
+ fallback = TypeBasedAliasOracle(base_types)
259
+ try:
260
+ return ScalpelAliasOracle.from_function(
261
+ func_ast, base_types=base_types, fallback=fallback
262
+ )
263
+ except ImportError:
264
+ _note_fallback("python-scalpel not installed")
265
+ return fallback
266
+ except Exception:
267
+ _note_fallback("scalpel alias build failed")
268
+ logger.debug("scalpel alias oracle build error", exc_info=True)
269
+ return fallback
@@ -0,0 +1,91 @@
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 5b of the level-3 dataflow ladder: SCC condensation of the call graph.
18
+
19
+ The call graph is a frozen oracle (level-1 Jedi edges, provenance-merged with
20
+ level-2 PyCG when enabled); Tarjan condenses it into strongly connected
21
+ components, and the condensation DAG in reverse topological order is the
22
+ bottom-up processing schedule for summary composition — callees before
23
+ callers, one monotone fixpoint per SCC (mutual recursion).
24
+
25
+ Iterative Tarjan (no recursion — real projects overflow Python's stack), with
26
+ sorted tie-breaking so the schedule is deterministic.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import Dict, List, Set, Tuple
32
+
33
+
34
+ def strongly_connected_components(
35
+ nodes: List[str], edges: List[Tuple[str, str]]
36
+ ) -> List[List[str]]:
37
+ """Tarjan SCCs in reverse topological order (callees before callers).
38
+ Deterministic: nodes are visited in sorted order and members sorted."""
39
+ adj: Dict[str, List[str]] = {n: [] for n in nodes}
40
+ for s, t in sorted(set(edges)):
41
+ if s in adj and t in adj:
42
+ adj[s].append(t)
43
+
44
+ index_of: Dict[str, int] = {}
45
+ lowlink: Dict[str, int] = {}
46
+ on_stack: Set[str] = set()
47
+ stack: List[str] = []
48
+ sccs: List[List[str]] = []
49
+ counter = [0]
50
+
51
+ for root in sorted(adj):
52
+ if root in index_of:
53
+ continue
54
+ # Iterative DFS: (node, iterator position over successors).
55
+ work: List[Tuple[str, int]] = [(root, 0)]
56
+ while work:
57
+ node, i = work.pop()
58
+ if i == 0:
59
+ index_of[node] = lowlink[node] = counter[0]
60
+ counter[0] += 1
61
+ stack.append(node)
62
+ on_stack.add(node)
63
+ recurse = False
64
+ successors = adj[node]
65
+ while i < len(successors):
66
+ succ = successors[i]
67
+ i += 1
68
+ if succ not in index_of:
69
+ work.append((node, i))
70
+ work.append((succ, 0))
71
+ recurse = True
72
+ break
73
+ if succ in on_stack:
74
+ lowlink[node] = min(lowlink[node], index_of[succ])
75
+ if recurse:
76
+ continue
77
+ if lowlink[node] == index_of[node]:
78
+ component: List[str] = []
79
+ while True:
80
+ member = stack.pop()
81
+ on_stack.discard(member)
82
+ component.append(member)
83
+ if member == node:
84
+ break
85
+ sccs.append(sorted(component))
86
+ if work:
87
+ parent = work[-1][0]
88
+ lowlink[parent] = min(lowlink[parent], lowlink[node])
89
+
90
+ # Tarjan emits SCCs in reverse topological order already.
91
+ return sccs