codemap-python 0.1.5__tar.gz → 0.1.6__tar.gz
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.
- {codemap_python-0.1.5 → codemap_python-0.1.6}/PKG-INFO +1 -1
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/architecture_engine.py +5 -0
- codemap_python-0.1.6/analysis/graph/entrypoint_detector.py +217 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_app.py +7 -6
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/PKG-INFO +1 -1
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/SOURCES.txt +1 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/pyproject.toml +1 -1
- codemap_python-0.1.6/tests/test_entrypoint_detector.py +60 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/app.py +82 -76
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/static/app.js +903 -387
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/static/styles.css +613 -103
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/templates/index.html +73 -57
- codemap_python-0.1.5/analysis/graph/entrypoint_detector.py +0 -1
- {codemap_python-0.1.5 → codemap_python-0.1.6}/README.md +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/dependency_cycles.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/risk_radar.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_graph_builder.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_resolver.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/context_models.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/cross_file_resolver.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/execution_tracker.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/flow_builder.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/models.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/ast_context.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/ast_parser.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/class_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/function_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/import_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/docstring_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/explain_runner.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/repo_summary_generator.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/return_analyzer.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/risk_flags.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/signature_extractor.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/summary_generator.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/callgraph_index.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/impact_analyzer.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/import_resolver.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/symbol_index.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/runners/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/runners/phase4_runner.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/ast_helpers.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/bom_handler.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/cache_manager.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/path_resolver.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/progress_spinner.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/repo_fetcher.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/repo_walk.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/cli.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_cli.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/dependency_links.txt +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/entry_points.txt +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/requires.txt +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/top_level.txt +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/security_utils.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/setup.cfg +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cache_cli_commands.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cache_retention.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cli_invalid_escape_warnings.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_codemap_cli_entrypoint.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_explain_runner_collection.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_no_key_persistence.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_registry_session_mode.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_repo_walk_filters.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_security_cli_integration.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_security_redaction.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_summary_generator.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_symbol_explain_cache.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_symbol_info_endpoint.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_ui_private_mode_security.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_ui_retention_controls.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/device_id.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/utils/__init__.py +0 -0
- {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/utils/registry_manager.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from collections import defaultdict
|
|
4
4
|
from typing import Dict, Any, Optional, Set
|
|
5
|
+
from analysis.graph.entrypoint_detector import detect_entry_points
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def _kind_for_fqn(fqn: str, repo_prefix: str) -> str:
|
|
@@ -52,6 +53,7 @@ def _infer_repo_prefix(nodes: Set[str]) -> str:
|
|
|
52
53
|
def compute_architecture_metrics(
|
|
53
54
|
callgraph,
|
|
54
55
|
symbol_index,
|
|
56
|
+
repo_dir: Optional[str] = None,
|
|
55
57
|
repo_prefix: Optional[str] = None,
|
|
56
58
|
top_k: int = 25,
|
|
57
59
|
fanout_threshold: int = 10,
|
|
@@ -134,6 +136,8 @@ def compute_architecture_metrics(
|
|
|
134
136
|
"edges": int(edges_per_file.get(fp, 0)),
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
entry_points = detect_entry_points(repo_dir=repo_dir, repo_prefix=prefix) if repo_dir else []
|
|
140
|
+
|
|
137
141
|
return {
|
|
138
142
|
"ok": True,
|
|
139
143
|
"repo_prefix": prefix,
|
|
@@ -142,6 +146,7 @@ def compute_architecture_metrics(
|
|
|
142
146
|
"dead_symbols": sorted(dead_symbols),
|
|
143
147
|
"orchestrators": sorted(orchestrators),
|
|
144
148
|
"critical_symbols": sorted(critical),
|
|
149
|
+
"entry_points": entry_points,
|
|
145
150
|
"top_fan_in": top_fan_in,
|
|
146
151
|
"top_fan_out": top_fan_out,
|
|
147
152
|
},
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
6
|
+
|
|
7
|
+
from analysis.utils.bom_handler import read_and_parse_python_file
|
|
8
|
+
from analysis.utils.repo_walk import filter_skipped_dirs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_ROUTE_DECORATORS = {
|
|
12
|
+
"get",
|
|
13
|
+
"post",
|
|
14
|
+
"put",
|
|
15
|
+
"delete",
|
|
16
|
+
"patch",
|
|
17
|
+
"options",
|
|
18
|
+
"head",
|
|
19
|
+
"websocket",
|
|
20
|
+
"route",
|
|
21
|
+
"api_route",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_CLI_DECORATOR_SUFFIXES = {
|
|
25
|
+
".command",
|
|
26
|
+
".group",
|
|
27
|
+
".callback",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _collect_python_files(repo_dir: str) -> List[str]:
|
|
32
|
+
py_files: List[str] = []
|
|
33
|
+
for root, dirs, files in os.walk(repo_dir):
|
|
34
|
+
dirs[:] = filter_skipped_dirs(dirs)
|
|
35
|
+
for file_name in files:
|
|
36
|
+
if not file_name.endswith(".py"):
|
|
37
|
+
continue
|
|
38
|
+
if file_name.startswith("__") and file_name != "__main__.py":
|
|
39
|
+
continue
|
|
40
|
+
py_files.append(os.path.join(root, file_name))
|
|
41
|
+
return sorted(py_files)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _file_to_module(file_path: str, repo_root: str, repo_prefix: str) -> str:
|
|
45
|
+
rel = os.path.relpath(os.path.abspath(file_path), os.path.abspath(repo_root)).replace(os.sep, ".")
|
|
46
|
+
if rel.endswith(".py"):
|
|
47
|
+
rel = rel[:-3]
|
|
48
|
+
prefix = str(repo_prefix or os.path.basename(os.path.abspath(repo_root).rstrip("\\/"))).strip()
|
|
49
|
+
return f"{prefix}.{rel}" if prefix else rel
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dotted_name(node: ast.AST) -> str:
|
|
53
|
+
if isinstance(node, ast.Call):
|
|
54
|
+
return _dotted_name(node.func)
|
|
55
|
+
if isinstance(node, ast.Name):
|
|
56
|
+
return node.id
|
|
57
|
+
if isinstance(node, ast.Attribute):
|
|
58
|
+
parent = _dotted_name(node.value)
|
|
59
|
+
return f"{parent}.{node.attr}" if parent else node.attr
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _str_constant(node: Optional[ast.AST]) -> str:
|
|
64
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
65
|
+
return node.value
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _main_guard(node: ast.AST) -> bool:
|
|
70
|
+
if not isinstance(node, ast.If):
|
|
71
|
+
return False
|
|
72
|
+
test = node.test
|
|
73
|
+
if not isinstance(test, ast.Compare) or len(test.ops) != 1 or len(test.comparators) != 1:
|
|
74
|
+
return False
|
|
75
|
+
left = test.left
|
|
76
|
+
right = test.comparators[0]
|
|
77
|
+
if not isinstance(test.ops[0], ast.Eq):
|
|
78
|
+
return False
|
|
79
|
+
return (
|
|
80
|
+
isinstance(left, ast.Name)
|
|
81
|
+
and left.id == "__name__"
|
|
82
|
+
and isinstance(right, ast.Constant)
|
|
83
|
+
and right.value == "__main__"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _EntryPointVisitor(ast.NodeVisitor):
|
|
88
|
+
def __init__(self, module: str, file_path: str):
|
|
89
|
+
self.module = module
|
|
90
|
+
self.file_path = file_path
|
|
91
|
+
self.class_stack: List[str] = []
|
|
92
|
+
self.entries: List[Dict[str, Any]] = []
|
|
93
|
+
self._seen: Set[Tuple[str, str, int]] = set()
|
|
94
|
+
|
|
95
|
+
def _fqn_for(self, name: str) -> str:
|
|
96
|
+
if self.class_stack:
|
|
97
|
+
return f"{self.module}.{'.'.join(self.class_stack)}.{name}"
|
|
98
|
+
return f"{self.module}.{name}"
|
|
99
|
+
|
|
100
|
+
def _add_entry(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
kind: str,
|
|
104
|
+
title: str,
|
|
105
|
+
reason: str,
|
|
106
|
+
line: int,
|
|
107
|
+
fqn: Optional[str] = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
key = (str(kind), str(fqn or title), int(line or 1))
|
|
110
|
+
if key in self._seen:
|
|
111
|
+
return
|
|
112
|
+
self._seen.add(key)
|
|
113
|
+
self.entries.append(
|
|
114
|
+
{
|
|
115
|
+
"kind": kind,
|
|
116
|
+
"title": title,
|
|
117
|
+
"reason": reason,
|
|
118
|
+
"fqn": fqn or "",
|
|
119
|
+
"file": self.file_path,
|
|
120
|
+
"line": int(line or 1),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _record_function_entrypoints(self, node: ast.AST, name: str, decorators: List[ast.AST]) -> None:
|
|
125
|
+
fqn = self._fqn_for(name)
|
|
126
|
+
for decorator in decorators:
|
|
127
|
+
dotted = _dotted_name(decorator)
|
|
128
|
+
if not dotted:
|
|
129
|
+
continue
|
|
130
|
+
last = dotted.split(".")[-1].lower()
|
|
131
|
+
if last in _ROUTE_DECORATORS:
|
|
132
|
+
path = ""
|
|
133
|
+
if isinstance(decorator, ast.Call) and decorator.args:
|
|
134
|
+
path = _str_constant(decorator.args[0])
|
|
135
|
+
method_label = "Web route" if last in {"route", "api_route"} else last.upper()
|
|
136
|
+
title = f"{method_label} {path}".strip()
|
|
137
|
+
reason = "This function looks like a web request entry point."
|
|
138
|
+
self._add_entry(
|
|
139
|
+
kind="api_route",
|
|
140
|
+
title=title,
|
|
141
|
+
reason=reason,
|
|
142
|
+
line=getattr(node, "lineno", 1),
|
|
143
|
+
fqn=fqn,
|
|
144
|
+
)
|
|
145
|
+
if any(dotted.endswith(suffix) for suffix in _CLI_DECORATOR_SUFFIXES) or dotted.startswith("click.") or dotted.startswith("typer."):
|
|
146
|
+
self._add_entry(
|
|
147
|
+
kind="cli_command",
|
|
148
|
+
title=f"CLI command: {name}",
|
|
149
|
+
reason="This function looks like a command-line entry point.",
|
|
150
|
+
line=getattr(node, "lineno", 1),
|
|
151
|
+
fqn=fqn,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not self.class_stack and name == "main":
|
|
155
|
+
self._add_entry(
|
|
156
|
+
kind="script_start",
|
|
157
|
+
title="Script start: main()",
|
|
158
|
+
reason="This is a common starting function for running the file directly.",
|
|
159
|
+
line=getattr(node, "lineno", 1),
|
|
160
|
+
fqn=fqn,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
164
|
+
self.class_stack.append(node.name)
|
|
165
|
+
self.generic_visit(node)
|
|
166
|
+
self.class_stack.pop()
|
|
167
|
+
|
|
168
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
169
|
+
self._record_function_entrypoints(node, node.name, list(node.decorator_list or []))
|
|
170
|
+
self.generic_visit(node)
|
|
171
|
+
|
|
172
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
173
|
+
self._record_function_entrypoints(node, node.name, list(node.decorator_list or []))
|
|
174
|
+
self.generic_visit(node)
|
|
175
|
+
|
|
176
|
+
def visit_If(self, node: ast.If) -> None:
|
|
177
|
+
if _main_guard(node):
|
|
178
|
+
module_fqn = f"{self.module}.<module>"
|
|
179
|
+
self._add_entry(
|
|
180
|
+
kind="script_start",
|
|
181
|
+
title="Run this file directly",
|
|
182
|
+
reason="This file has a __main__ block, so it can start execution directly.",
|
|
183
|
+
line=getattr(node, "lineno", 1),
|
|
184
|
+
fqn=module_fqn,
|
|
185
|
+
)
|
|
186
|
+
for child in ast.walk(node):
|
|
187
|
+
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
|
|
188
|
+
called_name = child.func.id
|
|
189
|
+
if called_name:
|
|
190
|
+
self._add_entry(
|
|
191
|
+
kind="script_start",
|
|
192
|
+
title=f"Script start: {called_name}()",
|
|
193
|
+
reason="This function is called from the file's __main__ block.",
|
|
194
|
+
line=getattr(child, "lineno", getattr(node, "lineno", 1)),
|
|
195
|
+
fqn=f"{self.module}.{called_name}",
|
|
196
|
+
)
|
|
197
|
+
self.generic_visit(node)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def detect_entry_points(repo_dir: str, repo_prefix: str = "") -> List[Dict[str, Any]]:
|
|
201
|
+
repo_root = os.path.abspath(repo_dir)
|
|
202
|
+
prefix = str(repo_prefix or os.path.basename(repo_root.rstrip("\\/"))).strip()
|
|
203
|
+
rows: List[Dict[str, Any]] = []
|
|
204
|
+
|
|
205
|
+
for file_path in _collect_python_files(repo_root):
|
|
206
|
+
try:
|
|
207
|
+
tree = read_and_parse_python_file(file_path)
|
|
208
|
+
except Exception:
|
|
209
|
+
continue
|
|
210
|
+
module = _file_to_module(file_path, repo_root, prefix)
|
|
211
|
+
visitor = _EntryPointVisitor(module=module, file_path=file_path)
|
|
212
|
+
visitor.visit(tree)
|
|
213
|
+
rows.extend(visitor.entries)
|
|
214
|
+
|
|
215
|
+
order = {"api_route": 0, "cli_command": 1, "script_start": 2}
|
|
216
|
+
rows.sort(key=lambda item: (order.get(str(item.get("kind", "")), 99), str(item.get("file", "")), int(item.get("line", 1))))
|
|
217
|
+
return rows[:50]
|
|
@@ -1410,12 +1410,13 @@ def api_analyze(args) -> int:
|
|
|
1410
1410
|
if symbol_snapshot:
|
|
1411
1411
|
symbol_index.load_snapshot(symbol_snapshot)
|
|
1412
1412
|
|
|
1413
|
-
repo_prefix = os.path.basename(os.path.abspath(repo_dir).rstrip("\\/"))
|
|
1414
|
-
arch_payload = compute_architecture_metrics(
|
|
1415
|
-
callgraph=callgraph,
|
|
1416
|
-
symbol_index=symbol_index,
|
|
1417
|
-
|
|
1418
|
-
|
|
1413
|
+
repo_prefix = os.path.basename(os.path.abspath(repo_dir).rstrip("\\/"))
|
|
1414
|
+
arch_payload = compute_architecture_metrics(
|
|
1415
|
+
callgraph=callgraph,
|
|
1416
|
+
symbol_index=symbol_index,
|
|
1417
|
+
repo_dir=repo_dir,
|
|
1418
|
+
repo_prefix=repo_prefix,
|
|
1419
|
+
)
|
|
1419
1420
|
dep_payload = compute_dependency_cycle_metrics(
|
|
1420
1421
|
resolved_calls=resolved_calls,
|
|
1421
1422
|
repo_prefix=repo_prefix,
|
|
@@ -59,6 +59,7 @@ tests/test_cache_cli_commands.py
|
|
|
59
59
|
tests/test_cache_retention.py
|
|
60
60
|
tests/test_cli_invalid_escape_warnings.py
|
|
61
61
|
tests/test_codemap_cli_entrypoint.py
|
|
62
|
+
tests/test_entrypoint_detector.py
|
|
62
63
|
tests/test_explain_runner_collection.py
|
|
63
64
|
tests/test_no_key_persistence.py
|
|
64
65
|
tests/test_registry_session_mode.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codemap-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Local Python code analysis tool - understand architecture, dependencies, and call graphs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from analysis.graph.entrypoint_detector import detect_entry_points
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestEntrypointDetector(unittest.TestCase):
|
|
12
|
+
def test_detects_routes_cli_and_script_starts(self):
|
|
13
|
+
repo_dir = os.path.join(PROJECT_ROOT, "tests", "_tmp_entrypoint_repo")
|
|
14
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
15
|
+
try:
|
|
16
|
+
os.makedirs(os.path.join(repo_dir, "api"), exist_ok=True)
|
|
17
|
+
os.makedirs(os.path.join(repo_dir, "cli"), exist_ok=True)
|
|
18
|
+
|
|
19
|
+
with open(os.path.join(repo_dir, "api", "routes.py"), "w", encoding="utf-8") as f:
|
|
20
|
+
f.write(
|
|
21
|
+
"from fastapi import APIRouter\n"
|
|
22
|
+
"router = APIRouter()\n\n"
|
|
23
|
+
"@router.get('/items')\n"
|
|
24
|
+
"def list_items():\n"
|
|
25
|
+
" return []\n"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
with open(os.path.join(repo_dir, "cli", "commands.py"), "w", encoding="utf-8") as f:
|
|
29
|
+
f.write(
|
|
30
|
+
"import click\n\n"
|
|
31
|
+
"@click.command()\n"
|
|
32
|
+
"def run():\n"
|
|
33
|
+
" return 1\n"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
with open(os.path.join(repo_dir, "__main__.py"), "w", encoding="utf-8") as f:
|
|
37
|
+
f.write(
|
|
38
|
+
"def main():\n"
|
|
39
|
+
" return 42\n\n"
|
|
40
|
+
"if __name__ == '__main__':\n"
|
|
41
|
+
" main()\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
rows = detect_entry_points(repo_dir, repo_prefix="demo_repo")
|
|
45
|
+
kinds = [row["kind"] for row in rows]
|
|
46
|
+
titles = [row["title"] for row in rows]
|
|
47
|
+
|
|
48
|
+
self.assertIn("api_route", kinds)
|
|
49
|
+
self.assertIn("cli_command", kinds)
|
|
50
|
+
self.assertIn("script_start", kinds)
|
|
51
|
+
self.assertIn("GET /items", titles)
|
|
52
|
+
self.assertIn("CLI command: run", titles)
|
|
53
|
+
self.assertIn("Run this file directly", titles)
|
|
54
|
+
self.assertIn("Script start: main()", titles)
|
|
55
|
+
finally:
|
|
56
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|
|
@@ -77,8 +77,9 @@ templates = Jinja2Templates(directory=templates_dir)
|
|
|
77
77
|
# This avoids issues when Jinja2 tries to cache templates containing the Request context
|
|
78
78
|
templates.env.cache = NoCache()
|
|
79
79
|
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
|
80
|
-
SEARCH_INDEX_CACHE: Dict[str, List[Dict[str, Any]]] = {}
|
|
81
|
-
GRAPH_INDEX_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
80
|
+
SEARCH_INDEX_CACHE: Dict[str, List[Dict[str, Any]]] = {}
|
|
81
|
+
GRAPH_INDEX_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
82
|
+
REPO_DATA_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
def _load_json(path: str, default: Any) -> Any:
|
|
@@ -582,10 +583,20 @@ def _rel_file(ctx: Dict[str, str], file_path: str) -> str:
|
|
|
582
583
|
return file_path.replace("\\", "/")
|
|
583
584
|
|
|
584
585
|
|
|
585
|
-
def _load_repo_data(ctx: Dict[str, str]) -> Dict[str, Any]:
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
586
|
+
def _load_repo_data(ctx: Dict[str, str]) -> Dict[str, Any]:
|
|
587
|
+
cache_key = ctx["repo_hash"]
|
|
588
|
+
explain_mtime = os.path.getmtime(ctx["explain_path"]) if os.path.exists(ctx["explain_path"]) else -1
|
|
589
|
+
resolved_mtime = os.path.getmtime(ctx["resolved_calls_path"]) if os.path.exists(ctx["resolved_calls_path"]) else -1
|
|
590
|
+
signature = f"{explain_mtime}:{resolved_mtime}"
|
|
591
|
+
cached = REPO_DATA_CACHE.get(cache_key)
|
|
592
|
+
if cached and cached.get("signature") == signature:
|
|
593
|
+
return cached["data"]
|
|
594
|
+
|
|
595
|
+
explain = _load_json(ctx["explain_path"], {})
|
|
596
|
+
resolved_calls = _load_json(ctx["resolved_calls_path"], [])
|
|
597
|
+
data = {"explain": explain, "resolved_calls": resolved_calls}
|
|
598
|
+
REPO_DATA_CACHE[cache_key] = {"signature": signature, "data": data}
|
|
599
|
+
return data
|
|
589
600
|
|
|
590
601
|
|
|
591
602
|
def _repo_registry_data() -> List[Dict[str, Any]]:
|
|
@@ -628,41 +639,20 @@ def _repo_registry_data() -> List[Dict[str, Any]]:
|
|
|
628
639
|
return items
|
|
629
640
|
|
|
630
641
|
|
|
631
|
-
def _build_symbol_connections(
|
|
632
|
-
ctx: Dict[str, str],
|
|
633
|
-
fqn: str,
|
|
634
|
-
explain: Dict[str, Any],
|
|
635
|
-
|
|
636
|
-
) -> Dict[str, Any]:
|
|
637
|
-
called_by
|
|
638
|
-
used_in
|
|
639
|
-
calls_counter: Counter[str] = Counter()
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
line = int(call.get("line", -1))
|
|
646
|
-
|
|
647
|
-
if callee_fqn == fqn:
|
|
648
|
-
item = {
|
|
649
|
-
"fqn": caller_fqn,
|
|
650
|
-
"file": _rel_file(ctx, file_path),
|
|
651
|
-
"line": line,
|
|
652
|
-
}
|
|
653
|
-
called_by.append(item)
|
|
654
|
-
used_in.append(item)
|
|
655
|
-
|
|
656
|
-
if caller_fqn == fqn and callee_fqn:
|
|
657
|
-
calls_counter[callee_fqn] += 1
|
|
658
|
-
|
|
659
|
-
called_by.sort(key=lambda x: (x.get("file", ""), int(x.get("line", -1)), x.get("fqn", "")))
|
|
660
|
-
used_in.sort(key=lambda x: (x.get("file", ""), int(x.get("line", -1)), x.get("fqn", "")))
|
|
661
|
-
|
|
662
|
-
calls: List[Dict[str, Any]] = []
|
|
663
|
-
for callee_fqn, count in sorted(calls_counter.items(), key=lambda x: (x[0].lower(), x[1])):
|
|
664
|
-
parts = callee_fqn.split(".")
|
|
665
|
-
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
642
|
+
def _build_symbol_connections(
|
|
643
|
+
ctx: Dict[str, str],
|
|
644
|
+
fqn: str,
|
|
645
|
+
explain: Dict[str, Any],
|
|
646
|
+
graph_index: Dict[str, Any],
|
|
647
|
+
) -> Dict[str, Any]:
|
|
648
|
+
called_by = list(graph_index.get("called_by_map", {}).get(fqn, []))
|
|
649
|
+
used_in = list(called_by)
|
|
650
|
+
calls_counter: Counter[str] = graph_index.get("outgoing_counts_map", {}).get(fqn, Counter())
|
|
651
|
+
|
|
652
|
+
calls: List[Dict[str, Any]] = []
|
|
653
|
+
for callee_fqn, count in sorted(calls_counter.items(), key=lambda x: (x[0].lower(), x[1])):
|
|
654
|
+
parts = callee_fqn.split(".")
|
|
655
|
+
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
666
656
|
name = f"{parts[-2]}.{parts[-1]}"
|
|
667
657
|
else:
|
|
668
658
|
name = parts[-1]
|
|
@@ -833,7 +823,7 @@ def _analysis_version_from_cache(cache_dir: str) -> str:
|
|
|
833
823
|
return ""
|
|
834
824
|
|
|
835
825
|
|
|
836
|
-
def _build_graph_index(ctx: Dict[str, str]) -> Dict[str, Any]:
|
|
826
|
+
def _build_graph_index(ctx: Dict[str, str]) -> Dict[str, Any]:
|
|
837
827
|
cache_key = ctx["repo_hash"]
|
|
838
828
|
resolved_mtime = os.path.getmtime(ctx["resolved_calls_path"]) if os.path.exists(ctx["resolved_calls_path"]) else -1
|
|
839
829
|
explain_mtime = os.path.getmtime(ctx["explain_path"]) if os.path.exists(ctx["explain_path"]) else -1
|
|
@@ -843,35 +833,51 @@ def _build_graph_index(ctx: Dict[str, str]) -> Dict[str, Any]:
|
|
|
843
833
|
if cached and cached.get("signature") == signature:
|
|
844
834
|
return cached["index"]
|
|
845
835
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
836
|
+
repo_data = _load_repo_data(ctx)
|
|
837
|
+
explain = repo_data["explain"]
|
|
838
|
+
resolved_calls = repo_data["resolved_calls"]
|
|
839
|
+
|
|
840
|
+
callees_map: Dict[str, List[str]] = {}
|
|
841
|
+
callers_map: Dict[str, List[str]] = {}
|
|
842
|
+
edge_counts: Dict[tuple, int] = {}
|
|
843
|
+
called_by_map: Dict[str, List[Dict[str, Any]]] = {}
|
|
844
|
+
outgoing_counts_map: Dict[str, Counter[str]] = {}
|
|
845
|
+
|
|
846
|
+
for call in resolved_calls:
|
|
847
|
+
caller = call.get("caller_fqn")
|
|
848
|
+
if not caller:
|
|
849
|
+
continue
|
|
857
850
|
callee = call.get("callee_fqn")
|
|
858
851
|
if not callee:
|
|
859
852
|
raw_name = str(call.get("callee") or "<unknown>").strip()
|
|
860
853
|
callee = f"external::{raw_name}"
|
|
861
|
-
|
|
862
|
-
callees_map.setdefault(caller, []).append(callee)
|
|
863
|
-
callers_map.setdefault(callee, []).append(caller)
|
|
864
|
-
edge_key = (caller, callee)
|
|
865
|
-
edge_counts[edge_key] = edge_counts.get(edge_key, 0) + 1
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
854
|
+
|
|
855
|
+
callees_map.setdefault(caller, []).append(callee)
|
|
856
|
+
callers_map.setdefault(callee, []).append(caller)
|
|
857
|
+
edge_key = (caller, callee)
|
|
858
|
+
edge_counts[edge_key] = edge_counts.get(edge_key, 0) + 1
|
|
859
|
+
outgoing_counts_map.setdefault(caller, Counter())[callee] += 1
|
|
860
|
+
called_by_map.setdefault(callee, []).append(
|
|
861
|
+
{
|
|
862
|
+
"fqn": caller,
|
|
863
|
+
"file": _rel_file(ctx, call.get("file", "")),
|
|
864
|
+
"line": int(call.get("line", -1)),
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
for rows in called_by_map.values():
|
|
869
|
+
rows.sort(key=lambda x: (x.get("file", ""), int(x.get("line", -1)), x.get("fqn", "")))
|
|
870
|
+
|
|
871
|
+
index = {
|
|
872
|
+
"explain": explain,
|
|
873
|
+
"callees_map": callees_map,
|
|
874
|
+
"callers_map": callers_map,
|
|
875
|
+
"edge_counts": edge_counts,
|
|
876
|
+
"called_by_map": called_by_map,
|
|
877
|
+
"outgoing_counts_map": outgoing_counts_map,
|
|
878
|
+
}
|
|
879
|
+
GRAPH_INDEX_CACHE[cache_key] = {"signature": signature, "index": index}
|
|
880
|
+
return index
|
|
875
881
|
|
|
876
882
|
|
|
877
883
|
def _normalize_ui_state(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -1949,21 +1955,21 @@ def api_file(path: str = Query(...), repo: Optional[str] = Query(default=None)):
|
|
|
1949
1955
|
|
|
1950
1956
|
|
|
1951
1957
|
@app.get("/api/symbol")
|
|
1952
|
-
def api_symbol(fqn: str = Query(...), repo: Optional[str] = Query(default=None)):
|
|
1958
|
+
def api_symbol(fqn: str = Query(...), repo: Optional[str] = Query(default=None)):
|
|
1953
1959
|
ctx = _repo_ctx(repo) if repo else _active_repo_ctx()
|
|
1954
1960
|
if not ctx:
|
|
1955
1961
|
return _no_active_repo_response()
|
|
1956
1962
|
if not _has_analysis_cache(ctx):
|
|
1957
1963
|
return _missing_cache_response()
|
|
1958
1964
|
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
result = dict(obj)
|
|
1965
|
-
result["connections"] = _build_symbol_connections(ctx, fqn, explain,
|
|
1966
|
-
return {"ok": True, "result": result}
|
|
1965
|
+
graph_index = _build_graph_index(ctx)
|
|
1966
|
+
explain = graph_index["explain"]
|
|
1967
|
+
obj = explain.get(fqn)
|
|
1968
|
+
if not obj:
|
|
1969
|
+
return JSONResponse(status_code=404, content={"ok": False, "error": "NOT_FOUND", "fqn": fqn})
|
|
1970
|
+
result = dict(obj)
|
|
1971
|
+
result["connections"] = _build_symbol_connections(ctx, fqn, explain, graph_index)
|
|
1972
|
+
return {"ok": True, "result": result}
|
|
1967
1973
|
|
|
1968
1974
|
|
|
1969
1975
|
@app.get("/api/usages")
|