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,217 @@
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 6 of the level-3 dataflow ladder: bottom-up function summaries.
18
+
19
+ A summary is relational: which formal inputs (parameters, captures, read
20
+ globals) may flow to which formal outputs (the return value, caller-visible
21
+ parameter mutations, written globals). Summaries compose bottom-up over the
22
+ SCC condensation DAG of the call-graph oracle; within an SCC (mutual
23
+ recursion) all members iterate to a monotone fixpoint — the domains (formal
24
+ keys and qualified global names) are finite and effects only grow, so
25
+ termination is structural. k-limiting bounds the access-path vocabulary.
26
+
27
+ At statement granularity a callsite node is already a transformer (all its
28
+ defs depend on all its uses), so the composition step callee summaries
29
+ actually contribute is the *global footprint*: a callsite node gains the
30
+ callee's transitive global reads as uses and writes as defs, the reaching
31
+ definitions are re-solved, and flows are re-derived. External/unmodeled
32
+ callees default to conservative pass-through (their argument paths are
33
+ already weak-defined and used at the call statement).
34
+
35
+ Summary flow keys: ``param:NAME``, ``capture:NAME``, ``global:MODULE::NAME``
36
+ for inputs; ``return``, ``param:NAME`` (mutation), ``global:MODULE::NAME``
37
+ for outputs.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ from dataclasses import dataclass, field
44
+ from typing import Dict, List, Optional, Set, Tuple
45
+
46
+ from codeanalyzer.dataflow.access_paths import RETURN_PATH, base_of, suffix_of
47
+ from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
48
+ from codeanalyzer.dataflow.defuse import DDGEdge, ddg_edges
49
+ from codeanalyzer.dataflow.pdg import FunctionPDG
50
+ from codeanalyzer.dataflow.scc import strongly_connected_components
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CallSite:
55
+ """One resolved call at one CFG statement node (builder-provided)."""
56
+
57
+ node_id: int
58
+ targets: Tuple[str, ...] # callee signatures declared in the symbol table
59
+ # callee param name -> actual access path (None: a non-path expression)
60
+ arg_paths: Tuple[Tuple[str, Optional[str]], ...] = ()
61
+ line: int = -1
62
+
63
+ def arg_path_of(self, param: str) -> Optional[str]:
64
+ for name, path in self.arg_paths:
65
+ if name == param:
66
+ return path
67
+ return None
68
+
69
+
70
+ @dataclass
71
+ class FunctionInfo:
72
+ """Everything the interprocedural stages need about one callable."""
73
+
74
+ signature: str
75
+ pdg: FunctionPDG
76
+ oracle: TypeBasedAliasOracle
77
+ call_sites: List[CallSite] = field(default_factory=list)
78
+ # Nested callables defined at a statement node: (def_node_id, nested_sig).
79
+ nested_defs: List[Tuple[int, str]] = field(default_factory=list)
80
+
81
+
82
+ @dataclass
83
+ class FunctionSummary:
84
+ global_reads: Set[str] = field(default_factory=set)
85
+ global_writes: Set[str] = field(default_factory=set)
86
+ mutated_params: Set[str] = field(default_factory=set)
87
+ flows: Set[Tuple[str, str]] = field(default_factory=set)
88
+
89
+ def __eq__(self, other):
90
+ return (
91
+ isinstance(other, FunctionSummary)
92
+ and self.global_reads == other.global_reads
93
+ and self.global_writes == other.global_writes
94
+ and self.mutated_params == other.mutated_params
95
+ and self.flows == other.flows
96
+ )
97
+
98
+
99
+ def _is_global(path: str) -> bool:
100
+ return "::" in base_of(path)
101
+
102
+
103
+ def augmented_facts(info: FunctionInfo, summaries: Dict[str, FunctionSummary]):
104
+ """Per-node facts with callee global footprints injected at callsites."""
105
+ facts = {nid: f for nid, f in info.pdg.facts.items()}
106
+ out = {}
107
+ for nid, f in facts.items():
108
+ out[nid] = type(f)(defs=set(f.defs), uses=set(f.uses))
109
+ for cs in info.call_sites:
110
+ for target in cs.targets:
111
+ s = summaries.get(target)
112
+ if s is None:
113
+ continue
114
+ out[cs.node_id].uses |= s.global_reads
115
+ out[cs.node_id].defs |= s.global_writes
116
+ return out
117
+
118
+
119
+ def solve_function(
120
+ info: FunctionInfo, summaries: Dict[str, FunctionSummary]
121
+ ) -> Tuple[FunctionSummary, Dict[int, object], List[DDGEdge]]:
122
+ """One summary iteration: inject callee footprints, re-solve reaching
123
+ definitions, derive flows. Returns (summary, augmented facts, DDG)."""
124
+ facts = augmented_facts(info, summaries)
125
+ ddg = ddg_edges(info.pdg.cfg, facts, info.oracle)
126
+
127
+ # Forward adjacency over DDG ∪ CDG (a statement transforms all its
128
+ # inputs into all its outputs — statement-granularity posture).
129
+ adj: Dict[int, List[int]] = {}
130
+ for e in ddg:
131
+ adj.setdefault(e.source, []).append(e.target)
132
+ for e in info.pdg.edges:
133
+ if e.type == "CDG":
134
+ adj.setdefault(e.source, []).append(e.target)
135
+
136
+ entry = info.pdg.cfg.entry_id
137
+ scope = info.pdg.scope
138
+
139
+ # Seeds: the ENTRY-def DDG edges, grouped by formal key.
140
+ seeds: Dict[str, Set[int]] = {}
141
+ for e in ddg:
142
+ if e.source != entry:
143
+ continue
144
+ b = base_of(e.var)
145
+ if b == scope.self_name or b in scope.params:
146
+ key = f"param:{b}"
147
+ elif b in scope.captures:
148
+ key = f"capture:{b}"
149
+ elif _is_global(e.var):
150
+ key = f"global:{b}"
151
+ else:
152
+ continue
153
+ seeds.setdefault(key, set()).add(e.target)
154
+
155
+ def reach(start: Set[int]) -> Set[int]:
156
+ seen: Set[int] = set()
157
+ stack = list(start)
158
+ while stack:
159
+ n = stack.pop()
160
+ if n in seen:
161
+ continue
162
+ seen.add(n)
163
+ stack.extend(adj.get(n, []))
164
+ return seen
165
+
166
+ summary = FunctionSummary()
167
+ param_names = set(scope.params)
168
+ if scope.self_name:
169
+ param_names.add(scope.self_name)
170
+
171
+ for nid, f in facts.items():
172
+ if nid == entry:
173
+ continue
174
+ for d in f.defs:
175
+ b = base_of(d)
176
+ if _is_global(d):
177
+ summary.global_writes.add(b)
178
+ elif b in param_names and suffix_of(d):
179
+ summary.mutated_params.add(b)
180
+ for u in f.uses:
181
+ if _is_global(u):
182
+ summary.global_reads.add(base_of(u))
183
+
184
+ for key, start in seeds.items():
185
+ for nid in reach(start):
186
+ f = facts[nid]
187
+ if RETURN_PATH in f.defs:
188
+ summary.flows.add((key, "return"))
189
+ for d in f.defs:
190
+ b = base_of(d)
191
+ if _is_global(d):
192
+ summary.flows.add((key, f"global:{b}"))
193
+ elif b in param_names and suffix_of(d):
194
+ summary.flows.add((key, f"param:{b}"))
195
+
196
+ return summary, facts, ddg
197
+
198
+
199
+ def compute_summaries(
200
+ infos: Dict[str, FunctionInfo],
201
+ call_edges: List[Tuple[str, str]],
202
+ ) -> Dict[str, FunctionSummary]:
203
+ """Bottom-up composition over the SCC condensation DAG, monotone fixpoint
204
+ within each SCC."""
205
+ order = strongly_connected_components(sorted(infos), call_edges)
206
+ summaries: Dict[str, FunctionSummary] = {}
207
+ for scc in order:
208
+ members = [s for s in scc if s in infos]
209
+ changed = True
210
+ while changed:
211
+ changed = False
212
+ for sig in members:
213
+ new, _, _ = solve_function(infos[sig], summaries)
214
+ if summaries.get(sig) != new:
215
+ summaries[sig] = new
216
+ changed = True
217
+ return summaries
@@ -0,0 +1,26 @@
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
+ """The L3 (syntactic) alias oracle: two access paths alias iff they are the
18
+ identical path. Bypasses the type-based may-alias so def-use yields only
19
+ name-equality (textual) edges — the alias-derived edges are the L4 delta."""
20
+
21
+ from __future__ import annotations
22
+
23
+
24
+ class SyntacticOracle:
25
+ def may_alias(self, path_a: str, path_b: str) -> bool:
26
+ return path_a == path_b
@@ -19,7 +19,7 @@ plus the two writers (cypher snapshot / bolt incremental). Nothing here runs
19
19
  unless ``--emit neo4j`` (or ``--emit schema``) is selected.
20
20
  """
21
21
  from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
22
- from codeanalyzer.neo4j.catalog import (
22
+ from codeanalyzer.neo4j.schema import (
23
23
  MARKER_LABELS,
24
24
  NODE_LABELS,
25
25
  REL_TYPES,
@@ -44,7 +44,10 @@ from codeanalyzer.neo4j.rows import EdgeRow, GraphRows, NodeRow, chunk
44
44
  from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
45
45
  from codeanalyzer.utils import logger
46
46
 
47
- DESCENDANTS = "[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR|PY_HAS_CALLSITE*1..]"
47
+ DESCENDANTS = (
48
+ "[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR"
49
+ "|PY_HAS_CALLSITE|PY_HAS_CFG_NODE*1..]"
50
+ )
48
51
  BATCH = 1000
49
52
 
50
53
 
@@ -193,21 +196,33 @@ def _upsert_nodes(session, neo4j, nodes: List[NodeRow]) -> None:
193
196
  def _upsert_edges(session, neo4j, edges: List[EdgeRow]) -> None:
194
197
  groups: Dict[str, List[EdgeRow]] = {}
195
198
  for e in edges:
196
- key = f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}|{e.to_ref.label}.{e.to_ref.key_prop}"
199
+ key = (
200
+ f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}"
201
+ f"|{e.to_ref.label}.{e.to_ref.key_prop}|{e.key is not None}"
202
+ )
197
203
  groups.setdefault(key, []).append(e)
198
204
 
199
205
  for group in groups.values():
200
206
  first = group[0]
201
207
  from_ref, to_ref = first.from_ref, first.to_ref
208
+ # Discriminated relationships MERGE on ``{_k}`` so several distinct
209
+ # edges of one type coexist between the same endpoint pair (per-var
210
+ # PY_DDG, the true/false PY_CFG_NEXT pair). See EdgeRow.key.
211
+ rel_key = " {_k: row.k}" if first.key is not None else ""
202
212
  cypher = (
203
213
  f"UNWIND $rows AS row "
204
214
  f"MATCH (a:{from_ref.label} {{{from_ref.key_prop}: row.f}}) "
205
215
  f"MATCH (b:{to_ref.label} {{{to_ref.key_prop}: row.t}}) "
206
- f"MERGE (a)-[r:{first.type}]->(b) SET r += row.p"
216
+ f"MERGE (a)-[r:{first.type}{rel_key}]->(b) SET r += row.p"
207
217
  )
208
218
  for batch in chunk(group, BATCH):
209
219
  payload = [
210
- {"f": e.from_ref.value, "t": e.to_ref.value, "p": _to_params(e.props, neo4j)}
220
+ {
221
+ "f": e.from_ref.value,
222
+ "t": e.to_ref.value,
223
+ "k": e.key,
224
+ "p": _to_params(e.props, neo4j),
225
+ }
211
226
  for e in batch
212
227
  ]
213
228
  with session() as s:
@@ -115,24 +115,30 @@ def _node_statements(nodes: List[NodeRow]) -> List[str]:
115
115
  def _edge_statements(edges: List[EdgeRow]) -> List[str]:
116
116
  groups: Dict[str, List[EdgeRow]] = {}
117
117
  for e in edges:
118
- key = f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}|{e.to_ref.label}.{e.to_ref.key_prop}"
118
+ key = (
119
+ f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}"
120
+ f"|{e.to_ref.label}.{e.to_ref.key_prop}|{e.key is not None}"
121
+ )
119
122
  groups.setdefault(key, []).append(e)
120
123
 
121
124
  blocks: List[str] = []
122
125
  for group in groups.values():
123
126
  first = group[0]
124
127
  from_ref, to_ref = first.from_ref, first.to_ref
128
+ # Discriminated relationships MERGE on ``{_k}`` — see EdgeRow.key.
129
+ rel_key = " {_k: row.k}" if first.key is not None else ""
125
130
  for batch in chunk(group, BATCH):
126
131
  rows_lit = ",\n".join(
127
132
  f" {{f: {cypher_value(e.from_ref.value)}, t: {cypher_value(e.to_ref.value)}, "
128
- f"p: {cypher_map(e.props)}}}"
133
+ + (f"k: {cypher_value(e.key)}, " if first.key is not None else "")
134
+ + f"p: {cypher_map(e.props)}}}"
129
135
  for e in batch
130
136
  )
131
137
  blocks.append(
132
138
  f"UNWIND [\n{rows_lit}\n] AS row\n"
133
139
  f"MATCH (a:{from_ref.label} {{{from_ref.key_prop}: row.f}})\n"
134
140
  f"MATCH (b:{to_ref.label} {{{to_ref.key_prop}: row.t}})\n"
135
- f"MERGE (a)-[r:{first.type}]->(b)\n"
141
+ f"MERGE (a)-[r:{first.type}{rel_key}]->(b)\n"
136
142
  f"SET r += row.p;"
137
143
  )
138
144
  return blocks
@@ -28,17 +28,18 @@ from pathlib import Path
28
28
  from typing import Optional
29
29
 
30
30
  from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
31
- from codeanalyzer.neo4j.catalog import build_schema_document
31
+ from codeanalyzer.neo4j.schema import build_schema_document
32
32
  from codeanalyzer.neo4j.cypher import render_cypher
33
33
  from codeanalyzer.neo4j.project import project
34
34
  from codeanalyzer.options import AnalysisOptions
35
- from codeanalyzer.schema import PyApplication
35
+ from codeanalyzer.schema import Analysis
36
+ from codeanalyzer.schema.assign_ids import assign_ids
36
37
  from codeanalyzer.utils import logger
37
38
 
38
39
 
39
40
  def emit_schema(output: Optional[Path]) -> None:
40
41
  """Emit the Neo4j schema contract (``schema.json``) — a static artifact derived
41
- from the in-repo catalog, independent of any analyzed project. With no
42
+ from the in-repo schema, independent of any analyzed project. With no
42
43
  ``output`` it prints to stdout."""
43
44
  doc = json.dumps(build_schema_document(), indent=2) + "\n"
44
45
  if output is None:
@@ -49,11 +50,15 @@ def emit_schema(output: Optional[Path]) -> None:
49
50
  logger.info(f"Neo4j schema written to {output / 'schema.json'}")
50
51
 
51
52
 
52
- def emit_neo4j(app: PyApplication, options: AnalysisOptions) -> None:
53
+ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
53
54
  """Project the analysis to a graph and write it: a live Bolt push when
54
55
  ``--neo4j-uri`` is set, otherwise a self-contained ``graph.cypher`` snapshot."""
55
56
  app_name = options.app_name or Path(options.input).resolve().name
56
- rows = project(app, app_name)
57
+ # ``assign_ids`` is idempotent: it stamps every module/class/callable with its
58
+ # canonical ``can://`` id and returns the ``signature -> id`` map the projection
59
+ # keys nodes on, so the JSON and Neo4j projections agree.
60
+ sig_to_id = assign_ids(analysis.application, app_name)
61
+ rows = project(analysis.application, app_name, sig_to_id, analyzer=analysis.analyzer)
57
62
 
58
63
  if options.neo4j_uri:
59
64
  cfg = BoltConfig(