codemarp 0.1.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.
- codemarp/__init__.py +1 -0
- codemarp/analyzers/__init__.py +1 -0
- codemarp/analyzers/high_level.py +77 -0
- codemarp/analyzers/low_level.py +272 -0
- codemarp/analyzers/mid_level.py +162 -0
- codemarp/cli/__init__.py +1 -0
- codemarp/cli/main.py +159 -0
- codemarp/errors.py +22 -0
- codemarp/exporters/__init__.py +1 -0
- codemarp/exporters/json_exporter.py +9 -0
- codemarp/exporters/mermaid.py +116 -0
- codemarp/graph/__init__.py +1 -0
- codemarp/graph/builder.py +39 -0
- codemarp/graph/models.py +71 -0
- codemarp/parser/__init__.py +1 -0
- codemarp/parser/js_parser.py +3 -0
- codemarp/parser/python_parser.py +282 -0
- codemarp/pipeline/apply_view.py +40 -0
- codemarp/pipeline/build_bundle.py +54 -0
- codemarp/pipeline/export_all.py +80 -0
- codemarp/views/module_view.py +16 -0
- codemarp/views/subgraph.py +27 -0
- codemarp/views/trace.py +96 -0
- codemarp-0.1.0.dist-info/METADATA +358 -0
- codemarp-0.1.0.dist-info/RECORD +28 -0
- codemarp-0.1.0.dist-info/WHEEL +5 -0
- codemarp-0.1.0.dist-info/entry_points.txt +2 -0
- codemarp-0.1.0.dist-info/top_level.txt +1 -0
codemarp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from codemarp.graph.models import Edge, ModuleNode
|
|
2
|
+
from codemarp.parser.python_parser import ParsedPythonModule
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_high_level_edges(
|
|
6
|
+
parsed_modules: list[ParsedPythonModule],
|
|
7
|
+
modules: list[ModuleNode],
|
|
8
|
+
) -> tuple:
|
|
9
|
+
module_to_group = {module.id: aggregate_module_id(module.id) for module in modules}
|
|
10
|
+
group_ids = sorted(set(module_to_group.values()))
|
|
11
|
+
|
|
12
|
+
known_module_ids = set(module_to_group.keys())
|
|
13
|
+
edges: list[Edge] = []
|
|
14
|
+
|
|
15
|
+
for parsed in parsed_modules:
|
|
16
|
+
source_group = module_to_group.get(
|
|
17
|
+
parsed.module_id, aggregate_module_id(parsed.module_id)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
for imported in parsed.imports:
|
|
21
|
+
target_module = _resolve_local_import(imported, known_module_ids)
|
|
22
|
+
if not target_module:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
target_group = module_to_group.get(
|
|
26
|
+
target_module, aggregate_module_id(target_module)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if source_group != target_group:
|
|
30
|
+
edges.append(
|
|
31
|
+
Edge(
|
|
32
|
+
source=source_group,
|
|
33
|
+
target=target_group,
|
|
34
|
+
kind="imports",
|
|
35
|
+
label="imports",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return group_ids, _dedupe_edges(edges)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def aggregate_module_id(module_id: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Collapse deep module paths for the high-level graph.
|
|
45
|
+
|
|
46
|
+
- 3+ segments collapse to the first 2 segments:
|
|
47
|
+
codemarp.views.trace -> codemarp.views
|
|
48
|
+
- 1–2 segments stay as-is:
|
|
49
|
+
codemarp.errors -> codemarp.errors
|
|
50
|
+
codemarp.cli -> codemarp.cli
|
|
51
|
+
"""
|
|
52
|
+
segments = module_id.split(".")
|
|
53
|
+
if len(segments) >= 3:
|
|
54
|
+
return ".".join(segments[:2])
|
|
55
|
+
return module_id
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_local_import(import_name: str, known_module_ids: set[str]) -> str | None:
|
|
59
|
+
if import_name in known_module_ids:
|
|
60
|
+
return import_name
|
|
61
|
+
for module_id in sorted(known_module_ids, key=len, reverse=True):
|
|
62
|
+
if import_name.startswith(module_id + "."):
|
|
63
|
+
return module_id
|
|
64
|
+
if module_id.startswith(import_name + "."):
|
|
65
|
+
return module_id
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _dedupe_edges(edges: list[Edge]) -> list[Edge]:
|
|
70
|
+
seen = set()
|
|
71
|
+
out = []
|
|
72
|
+
for edge in edges:
|
|
73
|
+
key = (edge.source, edge.target, edge.kind)
|
|
74
|
+
if key not in seen:
|
|
75
|
+
seen.add(key)
|
|
76
|
+
out.append(edge)
|
|
77
|
+
return out
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from codemarp.graph.models import ControlFlowNode, Edge
|
|
6
|
+
from codemarp.parser.python_parser import find_function_node
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class LowLevelResult:
|
|
11
|
+
function_id: str
|
|
12
|
+
nodes: list[ControlFlowNode]
|
|
13
|
+
edges: list[Edge]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_low_level_view(root: str | Path, focus: str) -> LowLevelResult:
|
|
17
|
+
function_id, function_node = find_function_node(Path(root), focus)
|
|
18
|
+
builder = ControlFlowBuilder()
|
|
19
|
+
nodes, edges = builder.build_for_function(function_node)
|
|
20
|
+
return LowLevelResult(function_id=function_id, nodes=nodes, edges=edges)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ControlFlowBuilder:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.nodes: list[ControlFlowNode] = []
|
|
26
|
+
self.edges: list[Edge] = []
|
|
27
|
+
self._counter = 0
|
|
28
|
+
|
|
29
|
+
def build_for_function(
|
|
30
|
+
self,
|
|
31
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
32
|
+
) -> tuple[list[ControlFlowNode], list[Edge]]:
|
|
33
|
+
start = self._new_node("Start", "start", lineno=function_node.lineno)
|
|
34
|
+
exits = self._walk_statements(function_node.body, [start])
|
|
35
|
+
end = self._new_node(
|
|
36
|
+
"End",
|
|
37
|
+
"end",
|
|
38
|
+
lineno=getattr(function_node, "end_lineno", function_node.lineno),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
for exit_node in exits:
|
|
42
|
+
self._add_edge(exit_node, end)
|
|
43
|
+
|
|
44
|
+
return self.nodes, self.edges
|
|
45
|
+
|
|
46
|
+
def _walk_statements(
|
|
47
|
+
self,
|
|
48
|
+
statements: list[ast.stmt],
|
|
49
|
+
incoming: list[str],
|
|
50
|
+
) -> list[str]:
|
|
51
|
+
exits = incoming
|
|
52
|
+
for statement in statements:
|
|
53
|
+
exits = self._handle_statement(statement, exits)
|
|
54
|
+
return exits
|
|
55
|
+
|
|
56
|
+
def _handle_statement(
|
|
57
|
+
self, statement: ast.stmt, incoming: list[str], edge_label: str | None = None
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
if isinstance(statement, ast.If):
|
|
60
|
+
return self._handle_if(statement, incoming)
|
|
61
|
+
|
|
62
|
+
if isinstance(statement, (ast.For, ast.AsyncFor, ast.While)):
|
|
63
|
+
return self._handle_loop(statement, incoming)
|
|
64
|
+
|
|
65
|
+
if isinstance(statement, ast.Try):
|
|
66
|
+
node = self._new_node("Try/Except", "statement", lineno=statement.lineno)
|
|
67
|
+
for index, source in enumerate(incoming):
|
|
68
|
+
self._add_edge(source, node, label=edge_label if index == 0 else None)
|
|
69
|
+
return [node]
|
|
70
|
+
|
|
71
|
+
if isinstance(statement, ast.Return):
|
|
72
|
+
node = self._new_node("Return", "terminal", lineno=statement.lineno)
|
|
73
|
+
for index, source in enumerate(incoming):
|
|
74
|
+
self._add_edge(source, node, label=edge_label if index == 0 else None)
|
|
75
|
+
return [node]
|
|
76
|
+
|
|
77
|
+
if isinstance(statement, ast.Raise):
|
|
78
|
+
node = self._new_node("Raise", "terminal", lineno=statement.lineno)
|
|
79
|
+
for index, source in enumerate(incoming):
|
|
80
|
+
self._add_edge(source, node, label=edge_label if index == 0 else None)
|
|
81
|
+
return [node]
|
|
82
|
+
|
|
83
|
+
label = self._statement_label(statement)
|
|
84
|
+
node = self._new_node(label, "statement", lineno=statement.lineno)
|
|
85
|
+
for index, source in enumerate(incoming):
|
|
86
|
+
self._add_edge(source, node, label=edge_label if index == 0 else None)
|
|
87
|
+
return [node]
|
|
88
|
+
|
|
89
|
+
def _walk_branch_statements(
|
|
90
|
+
self,
|
|
91
|
+
statements: list[ast.stmt],
|
|
92
|
+
condition_node: str,
|
|
93
|
+
*,
|
|
94
|
+
branch_label: str,
|
|
95
|
+
) -> list[str]:
|
|
96
|
+
# if not statements:
|
|
97
|
+
# empty_branch = self._new_node(branch_label, "branch")
|
|
98
|
+
# self._add_edge(condition_node, empty_branch, label=branch_label)
|
|
99
|
+
# return [empty_branch]
|
|
100
|
+
|
|
101
|
+
if not statements:
|
|
102
|
+
return [condition_node]
|
|
103
|
+
|
|
104
|
+
first_exits = self._handle_statement(
|
|
105
|
+
statements[0], [condition_node], edge_label=branch_label
|
|
106
|
+
)
|
|
107
|
+
if len(statements) == 1:
|
|
108
|
+
return first_exits
|
|
109
|
+
return self._walk_statements(statements[1:], first_exits)
|
|
110
|
+
|
|
111
|
+
def _handle_if(self, statement: ast.If, incoming: list[str]) -> list[str]:
|
|
112
|
+
condition = self._new_node(
|
|
113
|
+
self._expr_label(statement.test),
|
|
114
|
+
"decision",
|
|
115
|
+
lineno=statement.lineno,
|
|
116
|
+
)
|
|
117
|
+
for source in incoming:
|
|
118
|
+
self._add_edge(source, condition)
|
|
119
|
+
|
|
120
|
+
# then_entry = self._new_node("Then", "branch", lineno=statement.lineno)
|
|
121
|
+
# else_entry = self._new_node("Else", "branch", lineno=statement.lineno)
|
|
122
|
+
|
|
123
|
+
# self._add_edge(condition, then_entry, label="True")
|
|
124
|
+
# self._add_edge(condition, else_entry, label="False")
|
|
125
|
+
|
|
126
|
+
then_exits = self._walk_branch_statements(
|
|
127
|
+
statement.body, condition, branch_label="True"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
else_exits = self._walk_branch_statements(
|
|
131
|
+
statement.orelse, condition, branch_label="False"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
then_empty = condition in then_exits
|
|
135
|
+
else_empty = condition in else_exits
|
|
136
|
+
|
|
137
|
+
non_terminals = [
|
|
138
|
+
node_id
|
|
139
|
+
for node_id in then_exits + else_exits
|
|
140
|
+
if node_id != condition and self._node_kind(node_id) != "terminal"
|
|
141
|
+
]
|
|
142
|
+
if non_terminals or then_empty or else_empty:
|
|
143
|
+
merge = self._new_node("Merge", "merge", lineno=statement.lineno)
|
|
144
|
+
|
|
145
|
+
for source in non_terminals:
|
|
146
|
+
self._add_edge(source, merge)
|
|
147
|
+
|
|
148
|
+
if then_empty:
|
|
149
|
+
self._add_edge(condition, merge, label="True")
|
|
150
|
+
if else_empty:
|
|
151
|
+
self._add_edge(condition, merge, label="False")
|
|
152
|
+
|
|
153
|
+
return [merge]
|
|
154
|
+
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
def _handle_loop(
|
|
158
|
+
self,
|
|
159
|
+
statement: ast.For | ast.AsyncFor | ast.While,
|
|
160
|
+
incoming: list[str],
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
loop_label = type(statement).__name__
|
|
163
|
+
loop = self._new_node(loop_label, "loop", lineno=statement.lineno)
|
|
164
|
+
for source in incoming:
|
|
165
|
+
self._add_edge(source, loop)
|
|
166
|
+
|
|
167
|
+
body_entry = self._new_node("Loop Body", "branch", lineno=statement.lineno)
|
|
168
|
+
self._add_edge(loop, body_entry, label="Iterate")
|
|
169
|
+
|
|
170
|
+
body_exits = (
|
|
171
|
+
self._walk_statements(statement.body, [body_entry])
|
|
172
|
+
if statement.body
|
|
173
|
+
else [body_entry]
|
|
174
|
+
)
|
|
175
|
+
for source in body_exits:
|
|
176
|
+
self._add_edge(source, loop, label="Next")
|
|
177
|
+
|
|
178
|
+
after_loop = self._new_node("After Loop", "merge", lineno=statement.lineno)
|
|
179
|
+
self._add_edge(loop, after_loop, label="Exit")
|
|
180
|
+
|
|
181
|
+
return [after_loop]
|
|
182
|
+
|
|
183
|
+
def _statement_label(self, statement: ast.stmt) -> str:
|
|
184
|
+
if isinstance(statement, ast.Assign):
|
|
185
|
+
if isinstance(statement.value, ast.Call): # simplify calls
|
|
186
|
+
return self._call_label(statement.value)
|
|
187
|
+
return "Assign"
|
|
188
|
+
|
|
189
|
+
if isinstance(statement, ast.AnnAssign):
|
|
190
|
+
if isinstance(statement.value, ast.Call):
|
|
191
|
+
return self._call_label(statement.value)
|
|
192
|
+
return "AnnAssign"
|
|
193
|
+
|
|
194
|
+
if isinstance(statement, ast.AugAssign):
|
|
195
|
+
return "AugAssign"
|
|
196
|
+
|
|
197
|
+
if isinstance(statement, ast.Expr):
|
|
198
|
+
if isinstance(statement.value, ast.Call):
|
|
199
|
+
return self._call_label(statement.value)
|
|
200
|
+
return self._expr_label(statement.value)
|
|
201
|
+
|
|
202
|
+
if isinstance(statement, ast.Pass):
|
|
203
|
+
return "Pass"
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return ast.unparse(statement)
|
|
207
|
+
except Exception:
|
|
208
|
+
return type(statement).__name__
|
|
209
|
+
|
|
210
|
+
def _expr_label(self, expr: ast.AST) -> str:
|
|
211
|
+
if isinstance(expr, ast.Call):
|
|
212
|
+
return self._call_label(expr)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
return ast.unparse(expr)
|
|
216
|
+
except Exception:
|
|
217
|
+
return type(expr).__name__
|
|
218
|
+
|
|
219
|
+
def _call_label(self, call: ast.Call) -> str:
|
|
220
|
+
callee = self._callable_name(call.func)
|
|
221
|
+
return f"{callee}(...)"
|
|
222
|
+
|
|
223
|
+
def _callable_name(self, node: ast.AST) -> str:
|
|
224
|
+
if isinstance(node, ast.Name):
|
|
225
|
+
return node.id
|
|
226
|
+
|
|
227
|
+
if isinstance(node, ast.Attribute):
|
|
228
|
+
parts: list[str] = []
|
|
229
|
+
current: ast.AST = node
|
|
230
|
+
|
|
231
|
+
while isinstance(current, ast.Attribute):
|
|
232
|
+
parts.append(current.attr)
|
|
233
|
+
current = current.value
|
|
234
|
+
|
|
235
|
+
if isinstance(current, ast.Name):
|
|
236
|
+
parts.append(current.id)
|
|
237
|
+
|
|
238
|
+
return ".".join(reversed(parts))
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
return ast.unparse(node)
|
|
242
|
+
except Exception:
|
|
243
|
+
return type(node).__name__
|
|
244
|
+
|
|
245
|
+
def _new_node(self, label: str, kind: str, *, lineno: int | None = None) -> str:
|
|
246
|
+
self._counter += 1
|
|
247
|
+
node_id = f"n{self._counter}"
|
|
248
|
+
self.nodes.append(
|
|
249
|
+
ControlFlowNode(
|
|
250
|
+
id=node_id,
|
|
251
|
+
label=label,
|
|
252
|
+
kind=kind,
|
|
253
|
+
lineno=lineno,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return node_id
|
|
257
|
+
|
|
258
|
+
def _add_edge(self, source: str, target: str, *, label: str | None = None) -> None:
|
|
259
|
+
self.edges.append(
|
|
260
|
+
Edge(
|
|
261
|
+
source=source,
|
|
262
|
+
target=target,
|
|
263
|
+
kind="control_flow",
|
|
264
|
+
label=label,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _node_kind(self, node_id: str) -> str:
|
|
269
|
+
for node in self.nodes:
|
|
270
|
+
if node.id == node_id:
|
|
271
|
+
return node.kind
|
|
272
|
+
raise KeyError(f"Unknown control-flow node id: {node_id}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from codemarp.graph.models import Edge, FunctionNode
|
|
2
|
+
from codemarp.parser.python_parser import ParsedPythonModule
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_mid_level_edges(
|
|
6
|
+
parsed_modules: list[ParsedPythonModule], functions: list[FunctionNode]
|
|
7
|
+
) -> list[Edge]:
|
|
8
|
+
edges = []
|
|
9
|
+
by_name = {}
|
|
10
|
+
by_id = {fn.id: fn for fn in functions}
|
|
11
|
+
by_module_and_name = {}
|
|
12
|
+
|
|
13
|
+
for fn in functions:
|
|
14
|
+
by_name.setdefault(fn.name, []).append(fn)
|
|
15
|
+
by_name.setdefault(fn.name.split(".")[-1], []).append(fn)
|
|
16
|
+
by_module_and_name[(fn.module_id, fn.name)] = fn
|
|
17
|
+
by_module_and_name[(fn.module_id, fn.name.split(".")[-1])] = fn
|
|
18
|
+
|
|
19
|
+
parsed_by_module = {parsed.module_id: parsed for parsed in parsed_modules}
|
|
20
|
+
|
|
21
|
+
for module in parsed_modules:
|
|
22
|
+
for caller_id, callee_name in module.calls:
|
|
23
|
+
target = _resolve_callee(
|
|
24
|
+
caller_module_id=module.module_id,
|
|
25
|
+
callee_name=callee_name,
|
|
26
|
+
parsed_by_module=parsed_by_module,
|
|
27
|
+
by_module_and_name=by_module_and_name,
|
|
28
|
+
by_name=by_name,
|
|
29
|
+
by_id=by_id,
|
|
30
|
+
)
|
|
31
|
+
if target:
|
|
32
|
+
edges.append(
|
|
33
|
+
Edge(
|
|
34
|
+
source=caller_id, target=target.id, kind="calls", label="calls"
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return _dedupe_edges(edges)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_callee(
|
|
41
|
+
caller_module_id: str,
|
|
42
|
+
callee_name: str,
|
|
43
|
+
parsed_by_module: dict,
|
|
44
|
+
by_module_and_name: dict,
|
|
45
|
+
by_name: dict,
|
|
46
|
+
by_id: dict,
|
|
47
|
+
) -> FunctionNode | None:
|
|
48
|
+
parsed_module = parsed_by_module[caller_module_id]
|
|
49
|
+
|
|
50
|
+
same_module = _resolve_same_module_call(
|
|
51
|
+
caller_module_id=caller_module_id,
|
|
52
|
+
callee_name=callee_name,
|
|
53
|
+
by_module_and_name=by_module_and_name,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if same_module:
|
|
57
|
+
return same_module
|
|
58
|
+
|
|
59
|
+
imported_symbol = _resolve_imported_symbol_call(
|
|
60
|
+
parsed_module=parsed_module,
|
|
61
|
+
callee_name=callee_name,
|
|
62
|
+
by_id=by_id,
|
|
63
|
+
by_module_and_name=by_module_and_name,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if imported_symbol:
|
|
67
|
+
return imported_symbol
|
|
68
|
+
|
|
69
|
+
imported_module = _resolve_imported_module_call(
|
|
70
|
+
parsed_module=parsed_module,
|
|
71
|
+
callee_name=callee_name,
|
|
72
|
+
by_module_and_name=by_module_and_name,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if imported_module:
|
|
76
|
+
return imported_module
|
|
77
|
+
|
|
78
|
+
return _resolve_unique_global_call(callee_name=callee_name, by_name=by_name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_same_module_call(
|
|
82
|
+
*,
|
|
83
|
+
caller_module_id: str,
|
|
84
|
+
callee_name: str,
|
|
85
|
+
by_module_and_name: dict[tuple[str, str], FunctionNode],
|
|
86
|
+
) -> FunctionNode | None:
|
|
87
|
+
return by_module_and_name.get((caller_module_id, callee_name))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_imported_symbol_call(
|
|
91
|
+
parsed_module: ParsedPythonModule,
|
|
92
|
+
callee_name: str,
|
|
93
|
+
by_id: dict,
|
|
94
|
+
by_module_and_name: dict,
|
|
95
|
+
) -> FunctionNode | None:
|
|
96
|
+
if "." in callee_name:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
for imported in parsed_module.imported_symbols:
|
|
100
|
+
visible_name = imported.alias or imported.name
|
|
101
|
+
if visible_name != callee_name:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
direct_id = f"{imported.module}:{imported.name}"
|
|
105
|
+
if direct_id in by_id:
|
|
106
|
+
return by_id[direct_id]
|
|
107
|
+
|
|
108
|
+
candidate = by_module_and_name.get((imported.module, imported.name))
|
|
109
|
+
if candidate:
|
|
110
|
+
return candidate
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_imported_module_call(
|
|
116
|
+
parsed_module: ParsedPythonModule,
|
|
117
|
+
callee_name: str,
|
|
118
|
+
by_module_and_name: dict,
|
|
119
|
+
) -> FunctionNode | None:
|
|
120
|
+
if "." not in callee_name:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
prefix, member = callee_name.split(".", 1)
|
|
124
|
+
|
|
125
|
+
for imported in parsed_module.imported_modules:
|
|
126
|
+
visible_name = imported.alias or imported.module.split(".")[-1]
|
|
127
|
+
if visible_name != prefix:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
return by_module_and_name.get((imported.module, member))
|
|
131
|
+
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_unique_global_call(
|
|
136
|
+
*,
|
|
137
|
+
callee_name: str,
|
|
138
|
+
by_name: dict[str, list[FunctionNode]],
|
|
139
|
+
) -> FunctionNode | None:
|
|
140
|
+
if _is_dotted_call(callee_name):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
matches = by_name.get(callee_name, [])
|
|
144
|
+
unique = list({fn.id: fn for fn in matches}.values())
|
|
145
|
+
if len(unique) == 1:
|
|
146
|
+
return unique[0]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_dotted_call(callee_name: str) -> bool:
|
|
151
|
+
return "." in callee_name
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dedupe_edges(edges: list[Edge]) -> list[Edge]:
|
|
155
|
+
seen: set[tuple[str, str, str]] = set()
|
|
156
|
+
out: list[Edge] = []
|
|
157
|
+
for edge in edges:
|
|
158
|
+
key = (edge.source, edge.target, edge.kind)
|
|
159
|
+
if key not in seen:
|
|
160
|
+
seen.add(key)
|
|
161
|
+
out.append(edge)
|
|
162
|
+
return out
|
codemarp/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
codemarp/cli/main.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from codemarp.analyzers.low_level import build_low_level_view
|
|
4
|
+
from codemarp.errors import codemarpError
|
|
5
|
+
from codemarp.pipeline.apply_view import ViewType, apply_view
|
|
6
|
+
from codemarp.pipeline.build_bundle import build_bundle
|
|
7
|
+
from codemarp.pipeline.export_all import export_all, export_low_level
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_command(
|
|
11
|
+
root: str,
|
|
12
|
+
out: str,
|
|
13
|
+
*,
|
|
14
|
+
view: ViewType,
|
|
15
|
+
focus: str | None = None,
|
|
16
|
+
module: str | None = None,
|
|
17
|
+
max_depth: int | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
build_result = build_bundle(root)
|
|
20
|
+
|
|
21
|
+
if view is ViewType.LOW:
|
|
22
|
+
assert focus is not None
|
|
23
|
+
low_view = build_low_level_view(root, focus)
|
|
24
|
+
export_low_level(build_result=build_result, low_view=low_view, out_dir=out)
|
|
25
|
+
|
|
26
|
+
print(f"Parsed {len(build_result.parsed_modules)} modules")
|
|
27
|
+
print(f"Discovered {len(build_result.bundle.functions)} functions")
|
|
28
|
+
print(f"View type: {view.value}")
|
|
29
|
+
print(f"Low-level view for {focus}")
|
|
30
|
+
print(f"Low-level view contains {len(low_view.nodes)} nodes")
|
|
31
|
+
print("Wrote graph.json")
|
|
32
|
+
print("Wrote high_level.mmd")
|
|
33
|
+
print("Wrote low_level.mmd")
|
|
34
|
+
print("Wrote low_level.json")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
graph_view = apply_view(
|
|
38
|
+
build_result.bundle,
|
|
39
|
+
view=view,
|
|
40
|
+
focus=focus,
|
|
41
|
+
module=module,
|
|
42
|
+
max_depth=max_depth,
|
|
43
|
+
)
|
|
44
|
+
export_all(build_result=build_result, view=graph_view, out_dir=out)
|
|
45
|
+
|
|
46
|
+
print(f"Parsed {len(build_result.parsed_modules)} modules")
|
|
47
|
+
print(f"Discovered {len(build_result.bundle.functions)} functions")
|
|
48
|
+
print(f"View type: {view.value}")
|
|
49
|
+
if view is ViewType.TRACE:
|
|
50
|
+
print(f"Focused trace from {focus}")
|
|
51
|
+
print(f"Trace contains {len(graph_view.functions)} functions")
|
|
52
|
+
if view is ViewType.MODULE:
|
|
53
|
+
print(f"Module view for {module}")
|
|
54
|
+
print(f"Module view contains {len(graph_view.functions)} functions")
|
|
55
|
+
if view is ViewType.REVERSE:
|
|
56
|
+
print(f"Reverse trace from {focus}")
|
|
57
|
+
print(f"Reverse trace contains {len(graph_view.functions)} functions")
|
|
58
|
+
print("Wrote graph.json")
|
|
59
|
+
print("Wrote high_level.mmd")
|
|
60
|
+
print("Wrote mid_level.mmd")
|
|
61
|
+
print("Wrote mid_level.json")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
parser = argparse.ArgumentParser(prog="codemarp", description="3-level code mapper")
|
|
66
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
67
|
+
|
|
68
|
+
analyze = subparsers.add_parser("analyze", help="Analyze a Python codebase")
|
|
69
|
+
analyze.add_argument("root", help="Path to the repository root")
|
|
70
|
+
analyze.add_argument("--out", default="./codemarp_out", help="Output directory")
|
|
71
|
+
analyze.add_argument(
|
|
72
|
+
"--view",
|
|
73
|
+
choices=[view.value for view in ViewType],
|
|
74
|
+
default=ViewType.FULL.value,
|
|
75
|
+
help="Graph view to export",
|
|
76
|
+
)
|
|
77
|
+
analyze.add_argument(
|
|
78
|
+
"--focus",
|
|
79
|
+
default=None,
|
|
80
|
+
help="Entrypoint for TRACE/REVERSE view (function id: module:function)",
|
|
81
|
+
)
|
|
82
|
+
analyze.add_argument(
|
|
83
|
+
"--module",
|
|
84
|
+
default=None,
|
|
85
|
+
help="Module id for MODULE view",
|
|
86
|
+
)
|
|
87
|
+
analyze.add_argument(
|
|
88
|
+
"--max-depth",
|
|
89
|
+
type=int,
|
|
90
|
+
default=None,
|
|
91
|
+
help="Maximum trace depth from the focused function",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return parser
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_analyze_args(
|
|
98
|
+
args: argparse.Namespace, parser: argparse.ArgumentParser
|
|
99
|
+
) -> None:
|
|
100
|
+
view = ViewType(args.view)
|
|
101
|
+
|
|
102
|
+
if view is ViewType.FULL:
|
|
103
|
+
if args.focus is not None:
|
|
104
|
+
parser.error("--focus cannot be used with --view full")
|
|
105
|
+
if args.module is not None:
|
|
106
|
+
parser.error("--module cannot be used with --view full")
|
|
107
|
+
if args.max_depth is not None:
|
|
108
|
+
parser.error("--max-depth cannot be used with --view full")
|
|
109
|
+
|
|
110
|
+
if view is ViewType.TRACE:
|
|
111
|
+
if not args.focus:
|
|
112
|
+
parser.error("--focus is required with --view trace")
|
|
113
|
+
if args.module is not None:
|
|
114
|
+
parser.error("--module cannot be used with --view trace")
|
|
115
|
+
|
|
116
|
+
if view is ViewType.MODULE:
|
|
117
|
+
if not args.module:
|
|
118
|
+
parser.error("--module is required with --view module")
|
|
119
|
+
if args.focus is not None:
|
|
120
|
+
parser.error("--focus cannot be used with --view module")
|
|
121
|
+
if args.max_depth is not None:
|
|
122
|
+
parser.error("--max-depth cannot be used with --view module")
|
|
123
|
+
|
|
124
|
+
if view is ViewType.REVERSE:
|
|
125
|
+
if not args.focus:
|
|
126
|
+
parser.error("--focus is required with --view reverse")
|
|
127
|
+
if args.module is not None:
|
|
128
|
+
parser.error("--module cannot be used with --view reverse")
|
|
129
|
+
|
|
130
|
+
if view is ViewType.LOW:
|
|
131
|
+
if not args.focus:
|
|
132
|
+
parser.error("--focus is required with --view low")
|
|
133
|
+
if args.module is not None:
|
|
134
|
+
parser.error("--module cannot be used with --view low")
|
|
135
|
+
if args.max_depth is not None:
|
|
136
|
+
parser.error("--max-depth cannot be used with --view low")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> None:
|
|
140
|
+
parser = build_parser()
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
|
|
143
|
+
if args.command == "analyze":
|
|
144
|
+
_validate_analyze_args(args, parser)
|
|
145
|
+
try:
|
|
146
|
+
analyze_command(
|
|
147
|
+
args.root,
|
|
148
|
+
args.out,
|
|
149
|
+
view=ViewType(args.view),
|
|
150
|
+
focus=args.focus,
|
|
151
|
+
module=args.module,
|
|
152
|
+
max_depth=args.max_depth,
|
|
153
|
+
)
|
|
154
|
+
except codemarpError as exc:
|
|
155
|
+
raise SystemExit(str(exc)) from exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
codemarp/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class codemarpError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ParseError(codemarpError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResolutionError(codemarpError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TraceError(ResolutionError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModuleViewError(ResolutionError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FocusFormatError(codemarpError):
|
|
22
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|