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.
Files changed (84) hide show
  1. {codemap_python-0.1.5 → codemap_python-0.1.6}/PKG-INFO +1 -1
  2. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/architecture_engine.py +5 -0
  3. codemap_python-0.1.6/analysis/graph/entrypoint_detector.py +217 -0
  4. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_app.py +7 -6
  5. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/PKG-INFO +1 -1
  6. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/SOURCES.txt +1 -0
  7. {codemap_python-0.1.5 → codemap_python-0.1.6}/pyproject.toml +1 -1
  8. codemap_python-0.1.6/tests/test_entrypoint_detector.py +60 -0
  9. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/app.py +82 -76
  10. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/static/app.js +903 -387
  11. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/static/styles.css +613 -103
  12. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/templates/index.html +73 -57
  13. codemap_python-0.1.5/analysis/graph/entrypoint_detector.py +0 -1
  14. {codemap_python-0.1.5 → codemap_python-0.1.6}/README.md +0 -0
  15. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/__init__.py +0 -0
  16. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/__init__.py +0 -0
  17. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/dependency_cycles.py +0 -0
  18. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/architecture/risk_radar.py +0 -0
  19. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/__init__.py +0 -0
  20. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_extractor.py +0 -0
  21. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_graph_builder.py +0 -0
  22. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/call_resolver.py +0 -0
  23. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/context_models.py +0 -0
  24. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/cross_file_resolver.py +0 -0
  25. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/execution_tracker.py +0 -0
  26. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/flow_builder.py +0 -0
  27. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/call_graph/models.py +0 -0
  28. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/__init__.py +0 -0
  29. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/ast_context.py +0 -0
  30. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/ast_parser.py +0 -0
  31. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/class_extractor.py +0 -0
  32. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/function_extractor.py +0 -0
  33. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/core/import_extractor.py +0 -0
  34. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/__init__.py +0 -0
  35. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/docstring_extractor.py +0 -0
  36. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/explain_runner.py +0 -0
  37. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/repo_summary_generator.py +0 -0
  38. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/return_analyzer.py +0 -0
  39. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/risk_flags.py +0 -0
  40. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/signature_extractor.py +0 -0
  41. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/explain/summary_generator.py +0 -0
  42. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/__init__.py +0 -0
  43. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/callgraph_index.py +0 -0
  44. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/graph/impact_analyzer.py +0 -0
  45. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/__init__.py +0 -0
  46. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/import_resolver.py +0 -0
  47. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/indexing/symbol_index.py +0 -0
  48. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/runners/__init__.py +0 -0
  49. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/runners/phase4_runner.py +0 -0
  50. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/__init__.py +0 -0
  51. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/ast_helpers.py +0 -0
  52. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/bom_handler.py +0 -0
  53. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/cache_manager.py +0 -0
  54. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/path_resolver.py +0 -0
  55. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/progress_spinner.py +0 -0
  56. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/repo_fetcher.py +0 -0
  57. {codemap_python-0.1.5 → codemap_python-0.1.6}/analysis/utils/repo_walk.py +0 -0
  58. {codemap_python-0.1.5 → codemap_python-0.1.6}/cli.py +0 -0
  59. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_cli.py +0 -0
  60. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/dependency_links.txt +0 -0
  61. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/entry_points.txt +0 -0
  62. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/requires.txt +0 -0
  63. {codemap_python-0.1.5 → codemap_python-0.1.6}/codemap_python.egg-info/top_level.txt +0 -0
  64. {codemap_python-0.1.5 → codemap_python-0.1.6}/security_utils.py +0 -0
  65. {codemap_python-0.1.5 → codemap_python-0.1.6}/setup.cfg +0 -0
  66. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cache_cli_commands.py +0 -0
  67. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cache_retention.py +0 -0
  68. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_cli_invalid_escape_warnings.py +0 -0
  69. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_codemap_cli_entrypoint.py +0 -0
  70. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_explain_runner_collection.py +0 -0
  71. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_no_key_persistence.py +0 -0
  72. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_registry_session_mode.py +0 -0
  73. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_repo_walk_filters.py +0 -0
  74. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_security_cli_integration.py +0 -0
  75. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_security_redaction.py +0 -0
  76. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_summary_generator.py +0 -0
  77. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_symbol_explain_cache.py +0 -0
  78. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_symbol_info_endpoint.py +0 -0
  79. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_ui_private_mode_security.py +0 -0
  80. {codemap_python-0.1.5 → codemap_python-0.1.6}/tests/test_ui_retention_controls.py +0 -0
  81. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/__init__.py +0 -0
  82. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/device_id.py +0 -0
  83. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/utils/__init__.py +0 -0
  84. {codemap_python-0.1.5 → codemap_python-0.1.6}/ui/utils/registry_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codemap-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Local Python code analysis tool - understand architecture, dependencies, and call graphs
5
5
  Author-email: ADITYA <aditykushwaha69@gmail.com>
6
6
  License-Expression: MIT
@@ -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
- repo_prefix=repo_prefix,
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codemap-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Local Python code analysis tool - understand architecture, dependencies, and call graphs
5
5
  Author-email: ADITYA <aditykushwaha69@gmail.com>
6
6
  License-Expression: MIT
@@ -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.5"
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
- explain = _load_json(ctx["explain_path"], {})
587
- resolved_calls = _load_json(ctx["resolved_calls_path"], [])
588
- return {"explain": explain, "resolved_calls": resolved_calls}
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
- resolved_calls: List[Dict[str, Any]],
636
- ) -> Dict[str, Any]:
637
- called_by: List[Dict[str, Any]] = []
638
- used_in: List[Dict[str, Any]] = []
639
- calls_counter: Counter[str] = Counter()
640
-
641
- for call in resolved_calls:
642
- caller_fqn = call.get("caller_fqn")
643
- callee_fqn = call.get("callee_fqn")
644
- file_path = call.get("file", "")
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
- explain = _load_json(ctx["explain_path"], {})
847
- resolved_calls = _load_json(ctx["resolved_calls_path"], [])
848
-
849
- callees_map: Dict[str, List[str]] = {}
850
- callers_map: Dict[str, List[str]] = {}
851
- edge_counts: Dict[tuple, int] = {}
852
-
853
- for call in resolved_calls:
854
- caller = call.get("caller_fqn")
855
- if not caller:
856
- continue
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
- index = {
868
- "explain": explain,
869
- "callees_map": callees_map,
870
- "callers_map": callers_map,
871
- "edge_counts": edge_counts,
872
- }
873
- GRAPH_INDEX_CACHE[cache_key] = {"signature": signature, "index": index}
874
- return index
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
- explain = _load_json(ctx["explain_path"], {})
1960
- obj = explain.get(fqn)
1961
- if not obj:
1962
- return JSONResponse(status_code=404, content={"ok": False, "error": "NOT_FOUND", "fqn": fqn})
1963
- resolved_calls = _load_json(ctx["resolved_calls_path"], [])
1964
- result = dict(obj)
1965
- result["connections"] = _build_symbol_connections(ctx, fqn, explain, resolved_calls)
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")