code2flow-toon 0.2.4__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.
- code2flow/__init__.py +47 -0
- code2flow/__main__.py +6 -0
- code2flow/analysis/__init__.py +17 -0
- code2flow/analysis/call_graph.py +210 -0
- code2flow/analysis/cfg.py +293 -0
- code2flow/analysis/coupling.py +77 -0
- code2flow/analysis/data_analysis.py +249 -0
- code2flow/analysis/dfg.py +224 -0
- code2flow/analysis/smells.py +192 -0
- code2flow/cli.py +464 -0
- code2flow/core/__init__.py +36 -0
- code2flow/core/analyzer.py +765 -0
- code2flow/core/config.py +177 -0
- code2flow/core/models.py +194 -0
- code2flow/core/streaming_analyzer.py +666 -0
- code2flow/exporters/__init__.py +17 -0
- code2flow/exporters/base.py +13 -0
- code2flow/exporters/json_exporter.py +17 -0
- code2flow/exporters/llm_exporter.py +199 -0
- code2flow/exporters/mermaid_exporter.py +67 -0
- code2flow/exporters/toon.py +401 -0
- code2flow/exporters/yaml_exporter.py +108 -0
- code2flow/llm_flow_generator.py +451 -0
- code2flow/llm_task_generator.py +263 -0
- code2flow/mermaid_generator.py +481 -0
- code2flow/nlp/__init__.py +23 -0
- code2flow/nlp/config.py +174 -0
- code2flow/nlp/entity_resolution.py +326 -0
- code2flow/nlp/intent_matching.py +297 -0
- code2flow/nlp/normalization.py +122 -0
- code2flow/nlp/pipeline.py +388 -0
- code2flow/patterns/__init__.py +0 -0
- code2flow/patterns/detector.py +168 -0
- code2flow/refactor/__init__.py +0 -0
- code2flow/refactor/prompt_engine.py +150 -0
- code2flow/visualizers/__init__.py +0 -0
- code2flow/visualizers/graph.py +196 -0
- code2flow_toon-0.2.4.dist-info/METADATA +599 -0
- code2flow_toon-0.2.4.dist-info/RECORD +43 -0
- code2flow_toon-0.2.4.dist-info/WHEEL +5 -0
- code2flow_toon-0.2.4.dist-info/entry_points.txt +2 -0
- code2flow_toon-0.2.4.dist-info/licenses/LICENSE +201 -0
- code2flow_toon-0.2.4.dist-info/top_level.txt +1 -0
code2flow/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
code2flow - Optimized Python Code Flow Analysis Tool
|
|
3
|
+
|
|
4
|
+
A high-performance tool for analyzing Python code control flow, data flow,
|
|
5
|
+
and call graphs with caching and parallel processing.
|
|
6
|
+
|
|
7
|
+
Includes NLP Processing Pipeline for query normalization, intent matching,
|
|
8
|
+
and entity resolution with multilingual support.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.4"
|
|
12
|
+
__author__ = "STTS Project"
|
|
13
|
+
|
|
14
|
+
# Core analysis components
|
|
15
|
+
from .core.analyzer import ProjectAnalyzer
|
|
16
|
+
from .core.config import Config, FAST_CONFIG
|
|
17
|
+
from .core.models import AnalysisResult, FunctionInfo, ClassInfo, Pattern
|
|
18
|
+
|
|
19
|
+
# NLP Processing Pipeline
|
|
20
|
+
from .nlp import (
|
|
21
|
+
NLPPipeline,
|
|
22
|
+
QueryNormalizer,
|
|
23
|
+
IntentMatcher,
|
|
24
|
+
EntityResolver,
|
|
25
|
+
NLPConfig,
|
|
26
|
+
FAST_NLP_CONFIG,
|
|
27
|
+
PRECISE_NLP_CONFIG,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Core
|
|
32
|
+
"ProjectAnalyzer",
|
|
33
|
+
"Config",
|
|
34
|
+
"FAST_CONFIG",
|
|
35
|
+
"AnalysisResult",
|
|
36
|
+
"FunctionInfo",
|
|
37
|
+
"ClassInfo",
|
|
38
|
+
"Pattern",
|
|
39
|
+
# NLP Pipeline
|
|
40
|
+
"NLPPipeline",
|
|
41
|
+
"QueryNormalizer",
|
|
42
|
+
"IntentMatcher",
|
|
43
|
+
"EntityResolver",
|
|
44
|
+
"NLPConfig",
|
|
45
|
+
"FAST_NLP_CONFIG",
|
|
46
|
+
"PRECISE_NLP_CONFIG",
|
|
47
|
+
]
|
code2flow/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Analysis package for code2flow."""
|
|
2
|
+
|
|
3
|
+
from .cfg import CFGExtractor
|
|
4
|
+
from .dfg import DFGExtractor
|
|
5
|
+
from .call_graph import CallGraphExtractor
|
|
6
|
+
from .coupling import CouplingAnalyzer
|
|
7
|
+
from .smells import SmellDetector
|
|
8
|
+
from .data_analysis import DataAnalyzer
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'CFGExtractor',
|
|
12
|
+
'DFGExtractor',
|
|
13
|
+
'CallGraphExtractor',
|
|
14
|
+
'CouplingAnalyzer',
|
|
15
|
+
'SmellDetector',
|
|
16
|
+
'DataAnalyzer'
|
|
17
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Call graph extractor using AST."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Optional, Set, List, Dict
|
|
5
|
+
import astroid
|
|
6
|
+
|
|
7
|
+
from ..core.config import Config
|
|
8
|
+
from ..core.models import AnalysisResult, FlowEdge
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CallGraphExtractor(ast.NodeVisitor):
|
|
12
|
+
"""Extract call graph from AST."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: Config):
|
|
15
|
+
self.config = config
|
|
16
|
+
self.result = AnalysisResult()
|
|
17
|
+
self.module_name = ""
|
|
18
|
+
self.file_path = ""
|
|
19
|
+
|
|
20
|
+
# Context
|
|
21
|
+
self.function_stack = []
|
|
22
|
+
self.class_stack = []
|
|
23
|
+
self.imports = {}
|
|
24
|
+
self.astroid_tree = None
|
|
25
|
+
|
|
26
|
+
def extract(self, tree: ast.AST, module_name: str, file_path: str) -> AnalysisResult:
|
|
27
|
+
"""Extract call graph from AST."""
|
|
28
|
+
self.result = AnalysisResult()
|
|
29
|
+
self.module_name = module_name
|
|
30
|
+
self.file_path = file_path
|
|
31
|
+
self.function_stack = []
|
|
32
|
+
self.class_stack = []
|
|
33
|
+
self.imports = {}
|
|
34
|
+
|
|
35
|
+
# Try to get astroid tree for better resolution
|
|
36
|
+
try:
|
|
37
|
+
self.astroid_tree = astroid.MANAGER.ast_from_file(file_path)
|
|
38
|
+
except Exception:
|
|
39
|
+
self.astroid_tree = None
|
|
40
|
+
|
|
41
|
+
self.visit(tree)
|
|
42
|
+
self._calculate_metrics()
|
|
43
|
+
return self.result
|
|
44
|
+
|
|
45
|
+
def _calculate_metrics(self):
|
|
46
|
+
"""Calculate fan-in and fan-out metrics."""
|
|
47
|
+
# First, populate called_by for all functions
|
|
48
|
+
for caller_name, caller_info in self.result.functions.items():
|
|
49
|
+
for callee_name in caller_info.calls:
|
|
50
|
+
if callee_name in self.result.functions:
|
|
51
|
+
self.result.functions[callee_name].called_by.append(caller_name)
|
|
52
|
+
|
|
53
|
+
# Then calculate metrics
|
|
54
|
+
for func_name, func_info in self.result.functions.items():
|
|
55
|
+
fan_out = len(set(func_info.calls))
|
|
56
|
+
fan_in = len(set(func_info.called_by))
|
|
57
|
+
|
|
58
|
+
self.result.metrics[func_name] = {
|
|
59
|
+
"fan_in": fan_in,
|
|
60
|
+
"fan_out": fan_out,
|
|
61
|
+
"complexity": getattr(func_info, 'complexity', 1) # Placeholder for now
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def visit_Import(self, node: ast.Import):
|
|
65
|
+
"""Track imports."""
|
|
66
|
+
for alias in node.names:
|
|
67
|
+
name = alias.asname if alias.asname else alias.name
|
|
68
|
+
self.imports[name] = alias.name
|
|
69
|
+
self.result.imports[name] = alias.name
|
|
70
|
+
|
|
71
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
72
|
+
"""Track from imports."""
|
|
73
|
+
module = node.module or ""
|
|
74
|
+
for alias in node.names:
|
|
75
|
+
name = alias.asname if alias.asname else alias.name
|
|
76
|
+
full_name = f"{module}.{alias.name}" if module else alias.name
|
|
77
|
+
self.imports[name] = full_name
|
|
78
|
+
self.result.imports[name] = full_name
|
|
79
|
+
|
|
80
|
+
def visit_ClassDef(self, node: ast.ClassDef):
|
|
81
|
+
"""Visit class definition."""
|
|
82
|
+
self.class_stack.append(node.name)
|
|
83
|
+
|
|
84
|
+
# Store class info
|
|
85
|
+
self.result.classes[node.name] = {
|
|
86
|
+
'file': self.file_path,
|
|
87
|
+
'line': node.lineno,
|
|
88
|
+
'methods': [m.name for m in node.body if isinstance(m, ast.FunctionDef)],
|
|
89
|
+
'bases': [self._expr_to_str(b) for b in node.bases]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for stmt in node.body:
|
|
93
|
+
self.visit(stmt)
|
|
94
|
+
|
|
95
|
+
self.class_stack.pop()
|
|
96
|
+
|
|
97
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
98
|
+
"""Visit function definition and track calls within it."""
|
|
99
|
+
func_name = self._qualified_name(node.name)
|
|
100
|
+
self.function_stack.append(func_name)
|
|
101
|
+
|
|
102
|
+
# Visit body to find calls
|
|
103
|
+
for stmt in node.body:
|
|
104
|
+
self.visit(stmt)
|
|
105
|
+
|
|
106
|
+
self.function_stack.pop()
|
|
107
|
+
|
|
108
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
109
|
+
"""Visit async function."""
|
|
110
|
+
self.visit_FunctionDef(node)
|
|
111
|
+
|
|
112
|
+
def visit_Call(self, node: ast.Call):
|
|
113
|
+
"""Track function calls."""
|
|
114
|
+
if not self.function_stack:
|
|
115
|
+
self.generic_visit(node)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
caller = self.function_stack[-1]
|
|
119
|
+
callee = self._resolve_call(node.func)
|
|
120
|
+
|
|
121
|
+
# If ast-based resolution failed or returned None.sth, try astroid
|
|
122
|
+
if (not callee or 'None.' in callee) and self.astroid_tree:
|
|
123
|
+
astroid_callee = self._resolve_with_astroid(node)
|
|
124
|
+
if astroid_callee:
|
|
125
|
+
callee = astroid_callee
|
|
126
|
+
|
|
127
|
+
if callee and caller in self.result.functions:
|
|
128
|
+
self.result.functions[caller].calls.append(callee)
|
|
129
|
+
|
|
130
|
+
# Create call edge
|
|
131
|
+
edge = FlowEdge(
|
|
132
|
+
source=-1, # Will be resolved
|
|
133
|
+
target=-1,
|
|
134
|
+
edge_type="call",
|
|
135
|
+
metadata={'caller': caller, 'callee': callee}
|
|
136
|
+
)
|
|
137
|
+
self.result.call_edges.append(edge)
|
|
138
|
+
|
|
139
|
+
self.generic_visit(node)
|
|
140
|
+
|
|
141
|
+
def _qualified_name(self, name: str) -> str:
|
|
142
|
+
"""Get fully qualified name."""
|
|
143
|
+
parts = [self.module_name]
|
|
144
|
+
if self.class_stack:
|
|
145
|
+
parts.append(self.class_stack[-1])
|
|
146
|
+
parts.append(name)
|
|
147
|
+
return '.'.join(parts)
|
|
148
|
+
|
|
149
|
+
def _resolve_call(self, node: ast.AST) -> Optional[str]:
|
|
150
|
+
"""Resolve a call to its full name."""
|
|
151
|
+
if isinstance(node, ast.Name):
|
|
152
|
+
# Simple function call
|
|
153
|
+
if node.id in self.imports:
|
|
154
|
+
return self.imports[node.id]
|
|
155
|
+
return f"{self.module_name}.{node.id}"
|
|
156
|
+
|
|
157
|
+
elif isinstance(node, ast.Attribute):
|
|
158
|
+
# Method or module.function call
|
|
159
|
+
parts = []
|
|
160
|
+
current = node
|
|
161
|
+
|
|
162
|
+
while isinstance(current, ast.Attribute):
|
|
163
|
+
parts.append(current.attr)
|
|
164
|
+
current = current.value
|
|
165
|
+
|
|
166
|
+
if isinstance(current, ast.Name):
|
|
167
|
+
parts.append(current.id)
|
|
168
|
+
parts.reverse()
|
|
169
|
+
|
|
170
|
+
# Check if root is an import
|
|
171
|
+
root = parts[0]
|
|
172
|
+
if root in self.imports:
|
|
173
|
+
return f"{self.imports[root]}.{'.'.join(parts[1:])}"
|
|
174
|
+
|
|
175
|
+
# Check for self/cls
|
|
176
|
+
if root in ('self', 'cls') and self.class_stack:
|
|
177
|
+
return f"{self.module_name}.{self.class_stack[-1]}.{'.'.join(parts[1:])}"
|
|
178
|
+
|
|
179
|
+
return f"{self.module_name}.{'.'.join(parts)}"
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _resolve_with_astroid(self, node: ast.Call) -> Optional[str]:
|
|
184
|
+
"""Use astroid to infer the call target."""
|
|
185
|
+
if not self.astroid_tree:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
# Find the corresponding astroid node by line/col
|
|
190
|
+
# This is a bit slow but robust
|
|
191
|
+
for astroid_node in self.astroid_tree.nodes_of_class(astroid.Call):
|
|
192
|
+
if astroid_node.lineno == node.lineno and astroid_node.col_offset == node.col_offset:
|
|
193
|
+
# Infer the targets
|
|
194
|
+
inferred = astroid_node.func.infer()
|
|
195
|
+
for target in inferred:
|
|
196
|
+
if hasattr(target, 'qname'):
|
|
197
|
+
return target.qname()
|
|
198
|
+
break
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def _expr_to_str(self, node: ast.AST) -> str:
|
|
204
|
+
"""Convert AST expression to string."""
|
|
205
|
+
if node is None:
|
|
206
|
+
return ""
|
|
207
|
+
try:
|
|
208
|
+
return ast.unparse(node) if hasattr(ast, 'unparse') else str(node)
|
|
209
|
+
except:
|
|
210
|
+
return str(node)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Control Flow Graph (CFG) extractor using AST."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..core.config import Config
|
|
8
|
+
from ..core.models import AnalysisResult, FlowNode, FlowEdge, FunctionInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CFGExtractor(ast.NodeVisitor):
|
|
12
|
+
"""Extract Control Flow Graph from AST."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: Config):
|
|
15
|
+
self.config = config
|
|
16
|
+
self.result = AnalysisResult()
|
|
17
|
+
self.module_name = ""
|
|
18
|
+
self.file_path = ""
|
|
19
|
+
self.node_counter = 0
|
|
20
|
+
|
|
21
|
+
# Context tracking
|
|
22
|
+
self.function_stack = []
|
|
23
|
+
self.class_stack = []
|
|
24
|
+
self.current_node = None
|
|
25
|
+
self.entry_nodes = {} # Function -> entry node ID
|
|
26
|
+
|
|
27
|
+
def extract(self, tree: ast.AST, module_name: str, file_path: str) -> AnalysisResult:
|
|
28
|
+
"""Extract CFG from AST."""
|
|
29
|
+
self.result = AnalysisResult()
|
|
30
|
+
self.module_name = module_name
|
|
31
|
+
self.file_path = file_path
|
|
32
|
+
self.node_counter = 0
|
|
33
|
+
|
|
34
|
+
self.visit(tree)
|
|
35
|
+
return self.result
|
|
36
|
+
|
|
37
|
+
def new_node(self, node_type: str, label: str, **kwargs) -> int:
|
|
38
|
+
"""Create new flow node."""
|
|
39
|
+
node_id = self.node_counter
|
|
40
|
+
self.node_counter += 1
|
|
41
|
+
|
|
42
|
+
node = FlowNode(
|
|
43
|
+
id=node_id,
|
|
44
|
+
type=node_type,
|
|
45
|
+
label=label,
|
|
46
|
+
function=self.function_stack[-1] if self.function_stack else None,
|
|
47
|
+
file=self.file_path,
|
|
48
|
+
line=kwargs.get('line'),
|
|
49
|
+
column=kwargs.get('column'),
|
|
50
|
+
conditions=kwargs.get('conditions', []),
|
|
51
|
+
data_flow=kwargs.get('data_flow', [])
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self.result.nodes[node_id] = node
|
|
55
|
+
return node_id
|
|
56
|
+
|
|
57
|
+
def connect(self, source: Optional[int], target: Optional[int],
|
|
58
|
+
edge_type: str = "control", condition: Optional[str] = None):
|
|
59
|
+
"""Create edge between nodes."""
|
|
60
|
+
if source is not None and target is not None:
|
|
61
|
+
edge = FlowEdge(
|
|
62
|
+
source=source,
|
|
63
|
+
target=target,
|
|
64
|
+
edge_type=edge_type,
|
|
65
|
+
condition=condition
|
|
66
|
+
)
|
|
67
|
+
self.result.cfg_edges.append(edge)
|
|
68
|
+
|
|
69
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
70
|
+
"""Visit function definition."""
|
|
71
|
+
func_name = self._qualified_name(node.name)
|
|
72
|
+
self.function_stack.append(func_name)
|
|
73
|
+
|
|
74
|
+
# Create entry node
|
|
75
|
+
entry = self.new_node("FUNC", f"FUNC:{func_name}", line=node.lineno)
|
|
76
|
+
self.entry_nodes[func_name] = entry
|
|
77
|
+
|
|
78
|
+
# Track previous node
|
|
79
|
+
prev_node = self.current_node
|
|
80
|
+
self.current_node = entry
|
|
81
|
+
|
|
82
|
+
# Store function info
|
|
83
|
+
func_info = FunctionInfo(
|
|
84
|
+
name=node.name,
|
|
85
|
+
qualified_name=func_name,
|
|
86
|
+
file=self.file_path,
|
|
87
|
+
line_start=node.lineno,
|
|
88
|
+
line_end=node.end_lineno or node.lineno,
|
|
89
|
+
args=[arg.arg for arg in node.args.args]
|
|
90
|
+
)
|
|
91
|
+
self.result.functions[func_name] = func_info
|
|
92
|
+
|
|
93
|
+
# Visit body
|
|
94
|
+
for stmt in node.body:
|
|
95
|
+
self.visit(stmt)
|
|
96
|
+
|
|
97
|
+
# Create exit node
|
|
98
|
+
exit_node = self.new_node("RETURN", f"RETURN:{func_name}",
|
|
99
|
+
line=node.end_lineno or node.lineno)
|
|
100
|
+
self.connect(self.current_node, exit_node)
|
|
101
|
+
|
|
102
|
+
# Restore context
|
|
103
|
+
self.function_stack.pop()
|
|
104
|
+
self.current_node = prev_node
|
|
105
|
+
|
|
106
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
107
|
+
"""Visit async function definition."""
|
|
108
|
+
self.visit_FunctionDef(node) # Treat same as sync for CFG
|
|
109
|
+
|
|
110
|
+
def visit_If(self, node: ast.If):
|
|
111
|
+
"""Visit if statement."""
|
|
112
|
+
# Create condition node
|
|
113
|
+
condition = self._extract_condition(node.test)
|
|
114
|
+
cond_node = self.new_node("IF", condition, line=node.lineno)
|
|
115
|
+
self.connect(self.current_node, cond_node, condition=condition)
|
|
116
|
+
|
|
117
|
+
# Save current for branches
|
|
118
|
+
branch_entry = cond_node
|
|
119
|
+
|
|
120
|
+
# Visit then branch
|
|
121
|
+
then_last = []
|
|
122
|
+
for stmt in node.body:
|
|
123
|
+
prev = self.current_node
|
|
124
|
+
self.current_node = branch_entry
|
|
125
|
+
self.visit(stmt)
|
|
126
|
+
then_last.append(self.current_node)
|
|
127
|
+
branch_entry = self.current_node
|
|
128
|
+
|
|
129
|
+
# Visit else branch
|
|
130
|
+
else_last = []
|
|
131
|
+
if node.orelse:
|
|
132
|
+
branch_entry = cond_node
|
|
133
|
+
for stmt in node.orelse:
|
|
134
|
+
prev = self.current_node
|
|
135
|
+
self.current_node = branch_entry
|
|
136
|
+
self.visit(stmt)
|
|
137
|
+
else_last.append(self.current_node)
|
|
138
|
+
branch_entry = self.current_node
|
|
139
|
+
|
|
140
|
+
# Merge point
|
|
141
|
+
merge_node = self.new_node("MERGE", "merge", line=node.end_lineno)
|
|
142
|
+
for last in then_last + else_last:
|
|
143
|
+
self.connect(last, merge_node)
|
|
144
|
+
|
|
145
|
+
self.current_node = merge_node
|
|
146
|
+
|
|
147
|
+
def visit_For(self, node: ast.For):
|
|
148
|
+
"""Visit for loop."""
|
|
149
|
+
# Create loop header
|
|
150
|
+
iter_str = self._expr_to_str(node.iter)
|
|
151
|
+
target_str = self._expr_to_str(node.target)
|
|
152
|
+
loop_header = self.new_node("FOR", f"for {target_str} in {iter_str}",
|
|
153
|
+
line=node.lineno)
|
|
154
|
+
self.connect(self.current_node, loop_header)
|
|
155
|
+
|
|
156
|
+
# Loop body
|
|
157
|
+
body_entry = loop_header
|
|
158
|
+
body_last = []
|
|
159
|
+
for stmt in node.body:
|
|
160
|
+
self.current_node = body_entry
|
|
161
|
+
self.visit(stmt)
|
|
162
|
+
body_last.append(self.current_node)
|
|
163
|
+
body_entry = self.current_node
|
|
164
|
+
|
|
165
|
+
# Back edge to header
|
|
166
|
+
for last in body_last:
|
|
167
|
+
self.connect(last, loop_header, edge_type="loop")
|
|
168
|
+
|
|
169
|
+
# Exit (after loop)
|
|
170
|
+
exit_node = self.new_node("EXIT_LOOP", "exit_loop", line=node.end_lineno)
|
|
171
|
+
self.connect(loop_header, exit_node) # False branch
|
|
172
|
+
self.current_node = exit_node
|
|
173
|
+
|
|
174
|
+
def visit_While(self, node: ast.While):
|
|
175
|
+
"""Visit while loop."""
|
|
176
|
+
# Loop header with condition
|
|
177
|
+
condition = self._extract_condition(node.test)
|
|
178
|
+
loop_header = self.new_node("WHILE", f"while {condition}", line=node.lineno)
|
|
179
|
+
self.connect(self.current_node, loop_header)
|
|
180
|
+
|
|
181
|
+
# Loop body
|
|
182
|
+
body_entry = loop_header
|
|
183
|
+
body_last = []
|
|
184
|
+
for stmt in node.body:
|
|
185
|
+
self.current_node = body_entry
|
|
186
|
+
self.visit(stmt)
|
|
187
|
+
body_last.append(self.current_node)
|
|
188
|
+
body_entry = self.current_node
|
|
189
|
+
|
|
190
|
+
# Back edge
|
|
191
|
+
for last in body_last:
|
|
192
|
+
self.connect(last, loop_header, edge_type="loop")
|
|
193
|
+
|
|
194
|
+
# Exit
|
|
195
|
+
exit_node = self.new_node("EXIT_LOOP", "exit_loop", line=node.end_lineno)
|
|
196
|
+
self.connect(loop_header, exit_node, condition="False")
|
|
197
|
+
self.current_node = exit_node
|
|
198
|
+
|
|
199
|
+
def visit_Try(self, node: ast.Try):
|
|
200
|
+
"""Visit try statement."""
|
|
201
|
+
try_entry = self.new_node("TRY", "try", line=node.lineno)
|
|
202
|
+
self.connect(self.current_node, try_entry)
|
|
203
|
+
|
|
204
|
+
# Try body
|
|
205
|
+
self.current_node = try_entry
|
|
206
|
+
for stmt in node.body:
|
|
207
|
+
self.visit(stmt)
|
|
208
|
+
try_last = self.current_node
|
|
209
|
+
|
|
210
|
+
# Except handlers
|
|
211
|
+
for handler in node.handlers:
|
|
212
|
+
handler_node = self.new_node("EXCEPT", self._format_except(handler),
|
|
213
|
+
line=handler.lineno)
|
|
214
|
+
self.connect(try_entry, handler_node, edge_type="exception")
|
|
215
|
+
|
|
216
|
+
self.current_node = handler_node
|
|
217
|
+
for stmt in handler.body:
|
|
218
|
+
self.visit(stmt)
|
|
219
|
+
|
|
220
|
+
# Merge
|
|
221
|
+
merge = self.new_node("MERGE", "merge", line=node.end_lineno)
|
|
222
|
+
self.connect(try_last, merge)
|
|
223
|
+
self.current_node = merge
|
|
224
|
+
|
|
225
|
+
def visit_Assign(self, node: ast.Assign):
|
|
226
|
+
"""Visit assignment."""
|
|
227
|
+
targets = [self._expr_to_str(t) for t in node.targets]
|
|
228
|
+
value = self._expr_to_str(node.value)
|
|
229
|
+
label = f"{' = '.join(targets)} = {value[:50]}"
|
|
230
|
+
|
|
231
|
+
assign_node = self.new_node("ASSIGN", label, line=node.lineno)
|
|
232
|
+
self.connect(self.current_node, assign_node)
|
|
233
|
+
self.current_node = assign_node
|
|
234
|
+
|
|
235
|
+
def visit_Return(self, node: ast.Return):
|
|
236
|
+
"""Visit return statement."""
|
|
237
|
+
value = self._expr_to_str(node.value) if node.value else "None"
|
|
238
|
+
return_node = self.new_node("RETURN", f"return {value[:50]}", line=node.lineno)
|
|
239
|
+
self.connect(self.current_node, return_node)
|
|
240
|
+
self.current_node = return_node
|
|
241
|
+
|
|
242
|
+
def visit_Expr(self, node: ast.Expr):
|
|
243
|
+
"""Visit expression statement."""
|
|
244
|
+
if isinstance(node.value, ast.Call):
|
|
245
|
+
# Function call
|
|
246
|
+
call_name = self._expr_to_str(node.value.func)
|
|
247
|
+
args = [self._expr_to_str(a) for a in node.value.args]
|
|
248
|
+
label = f"CALL {call_name}({', '.join(args)})"[:80]
|
|
249
|
+
|
|
250
|
+
call_node = self.new_node("CALL", label, line=node.lineno)
|
|
251
|
+
self.connect(self.current_node, call_node)
|
|
252
|
+
self.current_node = call_node
|
|
253
|
+
|
|
254
|
+
# Track call in function info
|
|
255
|
+
if self.function_stack:
|
|
256
|
+
func_name = self.function_stack[-1]
|
|
257
|
+
if func_name in self.result.functions:
|
|
258
|
+
self.result.functions[func_name].calls.add(call_name)
|
|
259
|
+
else:
|
|
260
|
+
self.generic_visit(node)
|
|
261
|
+
|
|
262
|
+
def _qualified_name(self, name: str) -> str:
|
|
263
|
+
"""Get fully qualified name."""
|
|
264
|
+
parts = [self.module_name]
|
|
265
|
+
if self.class_stack:
|
|
266
|
+
parts.append(self.class_stack[-1])
|
|
267
|
+
parts.append(name)
|
|
268
|
+
return '.'.join(parts)
|
|
269
|
+
|
|
270
|
+
def _extract_condition(self, node: ast.AST) -> str:
|
|
271
|
+
"""Extract condition as string."""
|
|
272
|
+
try:
|
|
273
|
+
return ast.unparse(node) if hasattr(ast, 'unparse') else str(node)[:50]
|
|
274
|
+
except:
|
|
275
|
+
return str(node)[:50]
|
|
276
|
+
|
|
277
|
+
def _expr_to_str(self, node: ast.AST) -> str:
|
|
278
|
+
"""Convert AST expression to string."""
|
|
279
|
+
if node is None:
|
|
280
|
+
return "None"
|
|
281
|
+
try:
|
|
282
|
+
return ast.unparse(node) if hasattr(ast, 'unparse') else str(node)
|
|
283
|
+
except:
|
|
284
|
+
return str(node)
|
|
285
|
+
|
|
286
|
+
def _format_except(self, handler: ast.ExceptHandler) -> str:
|
|
287
|
+
"""Format except handler."""
|
|
288
|
+
if handler.type:
|
|
289
|
+
type_str = self._expr_to_str(handler.type)
|
|
290
|
+
if handler.name:
|
|
291
|
+
return f"except {type_str} as {handler.name}"
|
|
292
|
+
return f"except {type_str}"
|
|
293
|
+
return "except"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Analysis of coupling between modules."""
|
|
2
|
+
from typing import Dict, List, Set, Any
|
|
3
|
+
from ..core.models import AnalysisResult
|
|
4
|
+
|
|
5
|
+
class CouplingAnalyzer:
|
|
6
|
+
"""Analyze coupling between modules."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, result: AnalysisResult):
|
|
9
|
+
self.result = result
|
|
10
|
+
|
|
11
|
+
def analyze(self) -> Dict[str, Any]:
|
|
12
|
+
"""Perform coupling analysis."""
|
|
13
|
+
coupling_data = {
|
|
14
|
+
"module_interactions": self._analyze_module_interactions(),
|
|
15
|
+
"data_leakage": self._detect_data_leakage(),
|
|
16
|
+
"shared_state": self._detect_shared_state()
|
|
17
|
+
}
|
|
18
|
+
self.result.coupling = coupling_data
|
|
19
|
+
return coupling_data
|
|
20
|
+
|
|
21
|
+
def _analyze_module_interactions(self) -> Dict[str, Set[str]]:
|
|
22
|
+
"""Track which modules call which other modules."""
|
|
23
|
+
interactions = {}
|
|
24
|
+
for func_name, func_info in self.result.functions.items():
|
|
25
|
+
caller_mod = func_info.module or func_name.split('.')[0]
|
|
26
|
+
if caller_mod not in interactions:
|
|
27
|
+
interactions[caller_mod] = set()
|
|
28
|
+
|
|
29
|
+
for callee in func_info.calls:
|
|
30
|
+
callee_mod = callee.split('.')[0]
|
|
31
|
+
if callee_mod != caller_mod:
|
|
32
|
+
interactions[caller_mod].add(callee_mod)
|
|
33
|
+
|
|
34
|
+
# Convert sets to lists for JSON serialization
|
|
35
|
+
return {k: list(v) for k, v in interactions.items()}
|
|
36
|
+
|
|
37
|
+
def _detect_data_leakage(self) -> List[Dict[str, Any]]:
|
|
38
|
+
"""Detect when a module mutates data defined in another module."""
|
|
39
|
+
leakages = []
|
|
40
|
+
# Heuristic: if a function in module A mutates an object passed from module B
|
|
41
|
+
# This is simplified: we look for mutations where the scope module
|
|
42
|
+
# is different from the variable's likely origin.
|
|
43
|
+
for mutation in self.result.mutations:
|
|
44
|
+
scope_parts = mutation.scope.split('.')
|
|
45
|
+
mut_mod = scope_parts[0]
|
|
46
|
+
|
|
47
|
+
# If the variable name looks like it belongs to another module (e.g. 'other_mod.data')
|
|
48
|
+
if '.' in mutation.variable:
|
|
49
|
+
origin_mod = mutation.variable.split('.')[0]
|
|
50
|
+
if origin_mod != mut_mod and origin_mod in self.result.modules:
|
|
51
|
+
leakages.append({
|
|
52
|
+
"variable": mutation.variable,
|
|
53
|
+
"mutator_module": mut_mod,
|
|
54
|
+
"origin_module": origin_mod,
|
|
55
|
+
"line": mutation.line,
|
|
56
|
+
"file": mutation.file
|
|
57
|
+
})
|
|
58
|
+
return leakages
|
|
59
|
+
|
|
60
|
+
def _detect_shared_state(self) -> List[Dict[str, Any]]:
|
|
61
|
+
"""Detect modules that access/mutate the same global/shared variables."""
|
|
62
|
+
shared = []
|
|
63
|
+
variable_accessors = {} # var -> set(modules)
|
|
64
|
+
|
|
65
|
+
for mutation in self.result.mutations:
|
|
66
|
+
mut_mod = mutation.scope.split('.')[0]
|
|
67
|
+
if mutation.variable not in variable_accessors:
|
|
68
|
+
variable_accessors[mutation.variable] = set()
|
|
69
|
+
variable_accessors[mutation.variable].add(mut_mod)
|
|
70
|
+
|
|
71
|
+
for var, mods in variable_accessors.items():
|
|
72
|
+
if len(mods) > 1:
|
|
73
|
+
shared.append({
|
|
74
|
+
"variable": var,
|
|
75
|
+
"modules": list(mods)
|
|
76
|
+
})
|
|
77
|
+
return shared
|