codegraph-nav 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.
- codegraph_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Token-Efficient Renderer - Compact codebase visualization for LLMs.
|
|
3
|
+
|
|
4
|
+
This module generates ASCII tree representations of codebases with inline
|
|
5
|
+
"micro-metadata" that packs maximum information into minimum tokens.
|
|
6
|
+
|
|
7
|
+
Key Innovation: Instead of verbose JSON, each file line contains:
|
|
8
|
+
├── api_client.py [C:Auth M:login,logout] (Hub:3←)
|
|
9
|
+
|
|
10
|
+
This conveys: file name, main class, key methods, and hub status
|
|
11
|
+
in ~50 characters vs ~500+ characters of equivalent JSON.
|
|
12
|
+
|
|
13
|
+
Token Savings: Typically 60-80% reduction compared to JSON output.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> renderer = TokenEfficientRenderer(code_map)
|
|
17
|
+
>>> print(renderer.render_skeleton_tree())
|
|
18
|
+
|
|
19
|
+
my-project/
|
|
20
|
+
├── src/
|
|
21
|
+
│ ├── api/
|
|
22
|
+
│ │ ├── client.py [C:APIClient M:get,post,delete] (Hub:5←)
|
|
23
|
+
│ │ └── routes.py [F:handle_request,validate] (3←)
|
|
24
|
+
│ └── core/
|
|
25
|
+
│ ├── config.py [C:Config M:load,save] (Hub:8←)
|
|
26
|
+
│ └── utils.py [F:helper,format_date]
|
|
27
|
+
└── tests/
|
|
28
|
+
└── test_api.py [F:test_client,test_routes]
|
|
29
|
+
|
|
30
|
+
═══ Summary ═══
|
|
31
|
+
28 files · 142 symbols · 12 hubs
|
|
32
|
+
Top Hubs: config.py(8←), client.py(5←), utils.py(4←)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from enum import Enum
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
# Default limits for token-efficient rendering (configurable)
|
|
43
|
+
DEFAULT_MAX_CLASSES = 2
|
|
44
|
+
DEFAULT_MAX_METHODS_PER_CLASS = 3
|
|
45
|
+
DEFAULT_MAX_FUNCTIONS = 3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HubLevel(Enum):
|
|
49
|
+
"""Hub importance levels based on import count."""
|
|
50
|
+
|
|
51
|
+
NONE = 0 # 0-1 importers
|
|
52
|
+
LOW = 1 # 2 importers
|
|
53
|
+
MEDIUM = 2 # 3-4 importers
|
|
54
|
+
HIGH = 3 # 5+ importers
|
|
55
|
+
CRITICAL = 4 # 8+ importers
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class FileMicroMeta:
|
|
60
|
+
"""Compact metadata for a single file.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
path: Relative file path.
|
|
64
|
+
classes: List of class names.
|
|
65
|
+
functions: List of function names.
|
|
66
|
+
methods: Dict of class -> method names.
|
|
67
|
+
imports_count: Number of files this imports.
|
|
68
|
+
importers_count: Number of files importing this.
|
|
69
|
+
lines: Total lines of code.
|
|
70
|
+
has_tests: Whether file appears to be a test file.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
path: str
|
|
74
|
+
classes: list[str] = field(default_factory=list)
|
|
75
|
+
functions: list[str] = field(default_factory=list)
|
|
76
|
+
methods: dict[str, list[str]] = field(default_factory=dict)
|
|
77
|
+
imports_count: int = 0
|
|
78
|
+
importers_count: int = 0
|
|
79
|
+
lines: int = 0
|
|
80
|
+
has_tests: bool = False
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def hub_level(self) -> HubLevel:
|
|
84
|
+
"""Determine hub level from importer count."""
|
|
85
|
+
if self.importers_count >= 8:
|
|
86
|
+
return HubLevel.CRITICAL
|
|
87
|
+
elif self.importers_count >= 5:
|
|
88
|
+
return HubLevel.HIGH
|
|
89
|
+
elif self.importers_count >= 3:
|
|
90
|
+
return HubLevel.MEDIUM
|
|
91
|
+
elif self.importers_count >= 2:
|
|
92
|
+
return HubLevel.LOW
|
|
93
|
+
return HubLevel.NONE
|
|
94
|
+
|
|
95
|
+
def format_micro(
|
|
96
|
+
self,
|
|
97
|
+
max_width: int = 60,
|
|
98
|
+
max_classes: int = DEFAULT_MAX_CLASSES,
|
|
99
|
+
max_methods: int = DEFAULT_MAX_METHODS_PER_CLASS,
|
|
100
|
+
max_functions: int = DEFAULT_MAX_FUNCTIONS,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Format as compact micro-metadata string.
|
|
103
|
+
|
|
104
|
+
Format: [C:ClassName M:method1,method2] or [F:func1,func2]
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
max_width: Maximum width for the metadata portion.
|
|
108
|
+
max_classes: Maximum number of classes to show.
|
|
109
|
+
max_methods: Maximum methods per class to show.
|
|
110
|
+
max_functions: Maximum standalone functions to show.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Compact metadata string.
|
|
114
|
+
"""
|
|
115
|
+
parts = []
|
|
116
|
+
|
|
117
|
+
# Classes with their methods
|
|
118
|
+
if self.classes:
|
|
119
|
+
for cls in self.classes[:max_classes]:
|
|
120
|
+
methods = self.methods.get(cls, [])[:max_methods]
|
|
121
|
+
if methods:
|
|
122
|
+
parts.append(f"C:{cls} M:{','.join(methods)}")
|
|
123
|
+
else:
|
|
124
|
+
parts.append(f"C:{cls}")
|
|
125
|
+
|
|
126
|
+
# Standalone functions (not methods)
|
|
127
|
+
standalone_funcs = [f for f in self.functions if not f.startswith("_")][:max_functions]
|
|
128
|
+
if standalone_funcs and not self.classes:
|
|
129
|
+
parts.append(f"F:{','.join(standalone_funcs)}")
|
|
130
|
+
elif standalone_funcs and len(parts) < 2:
|
|
131
|
+
# Add some functions if we have room
|
|
132
|
+
parts.append(f"F:{','.join(standalone_funcs[:2])}")
|
|
133
|
+
|
|
134
|
+
# Hub indicator
|
|
135
|
+
hub_str = ""
|
|
136
|
+
if self.importers_count >= 2:
|
|
137
|
+
hub_str = f" ({self.importers_count}←)"
|
|
138
|
+
|
|
139
|
+
if not parts:
|
|
140
|
+
return hub_str.strip()
|
|
141
|
+
|
|
142
|
+
meta = f"[{' '.join(parts)}]"
|
|
143
|
+
|
|
144
|
+
# Truncate if too long
|
|
145
|
+
if len(meta) + len(hub_str) > max_width:
|
|
146
|
+
available = max_width - len(hub_str) - 5 # "[...]"
|
|
147
|
+
if available > 10:
|
|
148
|
+
meta = f"[{meta[1:available]}...]"
|
|
149
|
+
else:
|
|
150
|
+
meta = "[...]"
|
|
151
|
+
|
|
152
|
+
return f"{meta}{hub_str}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class TreeNode:
|
|
157
|
+
"""Node in the file tree structure."""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
is_file: bool = False
|
|
161
|
+
meta: FileMicroMeta | None = None
|
|
162
|
+
children: dict[str, "TreeNode"] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
def get_stats(self) -> tuple[int, int, int]:
|
|
165
|
+
"""Get recursive stats: (file_count, symbol_count, hub_count)."""
|
|
166
|
+
if self.is_file:
|
|
167
|
+
symbols = len(self.meta.classes) + len(self.meta.functions) if self.meta else 0
|
|
168
|
+
is_hub = 1 if self.meta and self.meta.importers_count >= 3 else 0
|
|
169
|
+
return (1, symbols, is_hub)
|
|
170
|
+
|
|
171
|
+
files, symbols, hubs = 0, 0, 0
|
|
172
|
+
for child in self.children.values():
|
|
173
|
+
f, s, h = child.get_stats()
|
|
174
|
+
files += f
|
|
175
|
+
symbols += s
|
|
176
|
+
hubs += h
|
|
177
|
+
return (files, symbols, hubs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TokenEfficientRenderer:
|
|
181
|
+
"""Renders codebase structure with minimal token usage.
|
|
182
|
+
|
|
183
|
+
This class takes a code map (from CodeNavigator) and renders it as a
|
|
184
|
+
compact ASCII tree with inline micro-metadata. The goal is to give
|
|
185
|
+
LLMs maximum context with minimum tokens.
|
|
186
|
+
|
|
187
|
+
Attributes:
|
|
188
|
+
code_map: The loaded code map dictionary.
|
|
189
|
+
files: Dict of file path -> FileMicroMeta.
|
|
190
|
+
tree: Root TreeNode of the file structure.
|
|
191
|
+
hub_threshold: Minimum importers to be considered a hub.
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
>>> # From code map file
|
|
195
|
+
>>> renderer = TokenEfficientRenderer.from_file('.codegraph.json')
|
|
196
|
+
>>> print(renderer.render_skeleton_tree())
|
|
197
|
+
|
|
198
|
+
>>> # From code map dict
|
|
199
|
+
>>> renderer = TokenEfficientRenderer(code_map_dict)
|
|
200
|
+
>>> output = renderer.render_skeleton_tree(max_depth=3)
|
|
201
|
+
|
|
202
|
+
>>> # Compare token usage
|
|
203
|
+
>>> stats = renderer.get_token_stats()
|
|
204
|
+
>>> print(f"Saved {stats['savings_percent']:.1f}% tokens")
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
# Tree drawing characters
|
|
208
|
+
PIPE = "│"
|
|
209
|
+
ELBOW = "└──"
|
|
210
|
+
TEE = "├──"
|
|
211
|
+
BLANK = " "
|
|
212
|
+
PIPE_PREFIX = "│ "
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
code_map: dict[str, Any],
|
|
217
|
+
hub_threshold: int = 3,
|
|
218
|
+
dependency_graph: Any = None, # Optional DependencyGraph
|
|
219
|
+
max_classes: int = DEFAULT_MAX_CLASSES,
|
|
220
|
+
max_methods: int = DEFAULT_MAX_METHODS_PER_CLASS,
|
|
221
|
+
max_functions: int = DEFAULT_MAX_FUNCTIONS,
|
|
222
|
+
root_path: str | None = None,
|
|
223
|
+
):
|
|
224
|
+
"""Initialize the renderer.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
code_map: Code map dictionary from CodeNavigator.
|
|
228
|
+
hub_threshold: Min importers to be a hub (default: 3).
|
|
229
|
+
dependency_graph: Optional DependencyGraph for hub detection.
|
|
230
|
+
max_classes: Max classes to show per file (default: 2).
|
|
231
|
+
max_methods: Max methods per class to show (default: 3).
|
|
232
|
+
max_functions: Max standalone functions to show (default: 3).
|
|
233
|
+
root_path: Root path for the codebase (optional).
|
|
234
|
+
"""
|
|
235
|
+
self.code_map = code_map
|
|
236
|
+
self.hub_threshold = hub_threshold
|
|
237
|
+
self.dependency_graph = dependency_graph
|
|
238
|
+
self.max_classes = max_classes
|
|
239
|
+
self.max_methods = max_methods
|
|
240
|
+
self.max_functions = max_functions
|
|
241
|
+
self.root_path = root_path
|
|
242
|
+
self.files: dict[str, FileMicroMeta] = {}
|
|
243
|
+
self.tree: TreeNode | None = None
|
|
244
|
+
|
|
245
|
+
self._parse_code_map()
|
|
246
|
+
self._build_tree()
|
|
247
|
+
|
|
248
|
+
if dependency_graph:
|
|
249
|
+
self._apply_dependency_data()
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def from_file(cls, path: str, **kwargs) -> "TokenEfficientRenderer":
|
|
253
|
+
"""Create renderer from a code map JSON file.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
path: Path to .codegraph.json file.
|
|
257
|
+
**kwargs: Additional arguments for __init__.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Initialized TokenEfficientRenderer.
|
|
261
|
+
"""
|
|
262
|
+
with open(path, encoding="utf-8") as f:
|
|
263
|
+
code_map = json.load(f)
|
|
264
|
+
return cls(code_map, **kwargs)
|
|
265
|
+
|
|
266
|
+
def _parse_code_map(self) -> None:
|
|
267
|
+
"""Parse code map into FileMicroMeta objects."""
|
|
268
|
+
files_data = self.code_map.get("files", {})
|
|
269
|
+
|
|
270
|
+
for file_path, file_info in files_data.items():
|
|
271
|
+
symbols = file_info.get("symbols", [])
|
|
272
|
+
|
|
273
|
+
classes = []
|
|
274
|
+
functions = []
|
|
275
|
+
methods = defaultdict(list)
|
|
276
|
+
|
|
277
|
+
for sym in symbols:
|
|
278
|
+
sym_type = sym.get("type", "")
|
|
279
|
+
sym_name = sym.get("name", "")
|
|
280
|
+
parent = sym.get("parent")
|
|
281
|
+
|
|
282
|
+
if sym_type == "class":
|
|
283
|
+
classes.append(sym_name)
|
|
284
|
+
elif sym_type == "function":
|
|
285
|
+
functions.append(sym_name)
|
|
286
|
+
elif sym_type == "method" and parent:
|
|
287
|
+
methods[parent].append(sym_name)
|
|
288
|
+
|
|
289
|
+
# Calculate approximate lines
|
|
290
|
+
lines = 0
|
|
291
|
+
for sym in symbols:
|
|
292
|
+
sym_lines = sym.get("lines", [0, 0])
|
|
293
|
+
if isinstance(sym_lines, list) and len(sym_lines) >= 2:
|
|
294
|
+
lines = max(lines, sym_lines[1])
|
|
295
|
+
|
|
296
|
+
# Detect test files
|
|
297
|
+
has_tests = (
|
|
298
|
+
"test" in file_path.lower()
|
|
299
|
+
or file_path.startswith("tests/")
|
|
300
|
+
or any(f.startswith("test_") for f in functions)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
self.files[file_path] = FileMicroMeta(
|
|
304
|
+
path=file_path,
|
|
305
|
+
classes=classes,
|
|
306
|
+
functions=functions,
|
|
307
|
+
methods=dict(methods),
|
|
308
|
+
lines=lines,
|
|
309
|
+
has_tests=has_tests,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _apply_dependency_data(self) -> None:
|
|
313
|
+
"""Apply dependency graph data to file metadata."""
|
|
314
|
+
if not self.dependency_graph:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
for path, meta in self.files.items():
|
|
318
|
+
if path in self.dependency_graph.nodes:
|
|
319
|
+
node = self.dependency_graph.nodes[path]
|
|
320
|
+
meta.imports_count = node.out_degree
|
|
321
|
+
meta.importers_count = node.in_degree
|
|
322
|
+
|
|
323
|
+
def _build_tree(self) -> None:
|
|
324
|
+
"""Build tree structure from file paths."""
|
|
325
|
+
self.tree = TreeNode(name="", is_file=False)
|
|
326
|
+
|
|
327
|
+
for file_path, meta in self.files.items():
|
|
328
|
+
parts = Path(file_path).parts
|
|
329
|
+
current = self.tree
|
|
330
|
+
|
|
331
|
+
for i, part in enumerate(parts):
|
|
332
|
+
if i == len(parts) - 1:
|
|
333
|
+
# File node
|
|
334
|
+
current.children[part] = TreeNode(
|
|
335
|
+
name=part,
|
|
336
|
+
is_file=True,
|
|
337
|
+
meta=meta,
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
# Directory node
|
|
341
|
+
if part not in current.children:
|
|
342
|
+
current.children[part] = TreeNode(name=part)
|
|
343
|
+
current = current.children[part]
|
|
344
|
+
|
|
345
|
+
def render_skeleton_tree(
|
|
346
|
+
self,
|
|
347
|
+
max_depth: int = 0,
|
|
348
|
+
show_meta: bool = True,
|
|
349
|
+
show_summary: bool = True,
|
|
350
|
+
collapse_threshold: int = 10,
|
|
351
|
+
project_name: str | None = None,
|
|
352
|
+
) -> str:
|
|
353
|
+
"""Render the codebase as a compact ASCII tree.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
max_depth: Maximum directory depth (0 = unlimited).
|
|
357
|
+
show_meta: Include micro-metadata on each file.
|
|
358
|
+
show_summary: Include summary section at end.
|
|
359
|
+
collapse_threshold: Collapse dirs with more files than this.
|
|
360
|
+
project_name: Override project name in output.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Formatted ASCII tree string.
|
|
364
|
+
|
|
365
|
+
Example output:
|
|
366
|
+
my-project/
|
|
367
|
+
├── src/
|
|
368
|
+
│ ├── api/
|
|
369
|
+
│ │ ├── client.py [C:APIClient M:get,post] (5←)
|
|
370
|
+
│ │ └── routes.py [F:handle,validate] (3←)
|
|
371
|
+
│ └── core/
|
|
372
|
+
│ └── config.py [C:Config M:load] (Hub:8←)
|
|
373
|
+
└── tests/
|
|
374
|
+
└── test_api.py [F:test_client]
|
|
375
|
+
|
|
376
|
+
═══ Summary ═══
|
|
377
|
+
28 files · 142 symbols · 12 hubs
|
|
378
|
+
"""
|
|
379
|
+
lines = []
|
|
380
|
+
|
|
381
|
+
# Header
|
|
382
|
+
name = project_name or self.code_map.get("root", "project").split("/")[-1]
|
|
383
|
+
lines.append(f"{name}/")
|
|
384
|
+
|
|
385
|
+
# Render tree
|
|
386
|
+
assert self.tree is not None # set by _build_tree() in __init__
|
|
387
|
+
self._render_node(
|
|
388
|
+
self.tree,
|
|
389
|
+
lines,
|
|
390
|
+
prefix="",
|
|
391
|
+
is_last=True,
|
|
392
|
+
depth=0,
|
|
393
|
+
max_depth=max_depth,
|
|
394
|
+
show_meta=show_meta,
|
|
395
|
+
collapse_threshold=collapse_threshold,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Summary
|
|
399
|
+
if show_summary:
|
|
400
|
+
lines.append("")
|
|
401
|
+
lines.append("═══ Summary ═══")
|
|
402
|
+
stats = self._get_summary_stats()
|
|
403
|
+
lines.append(
|
|
404
|
+
f"{stats['files']} files · {stats['symbols']} symbols · {stats['hubs']} hubs"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if stats["top_hubs"]:
|
|
408
|
+
hub_strs = [f"{h[0]}({h[1]}←)" for h in stats["top_hubs"][:5]]
|
|
409
|
+
lines.append(f"Top Hubs: {', '.join(hub_strs)}")
|
|
410
|
+
|
|
411
|
+
return "\n".join(lines)
|
|
412
|
+
|
|
413
|
+
def _render_node(
|
|
414
|
+
self,
|
|
415
|
+
node: TreeNode,
|
|
416
|
+
lines: list[str],
|
|
417
|
+
prefix: str,
|
|
418
|
+
is_last: bool,
|
|
419
|
+
depth: int,
|
|
420
|
+
max_depth: int,
|
|
421
|
+
show_meta: bool,
|
|
422
|
+
collapse_threshold: int,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Recursively render a tree node."""
|
|
425
|
+
# Check depth limit
|
|
426
|
+
if max_depth > 0 and depth > max_depth:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
# Separate directories and files
|
|
430
|
+
dirs = []
|
|
431
|
+
files = []
|
|
432
|
+
for name, child in sorted(node.children.items()):
|
|
433
|
+
if child.is_file:
|
|
434
|
+
files.append((name, child))
|
|
435
|
+
else:
|
|
436
|
+
dirs.append((name, child))
|
|
437
|
+
|
|
438
|
+
all_items = dirs + files
|
|
439
|
+
|
|
440
|
+
for i, (name, child) in enumerate(all_items):
|
|
441
|
+
is_last_item = i == len(all_items) - 1
|
|
442
|
+
connector = self.ELBOW if is_last_item else self.TEE
|
|
443
|
+
new_prefix = prefix + (self.BLANK if is_last_item else self.PIPE_PREFIX)
|
|
444
|
+
|
|
445
|
+
if child.is_file:
|
|
446
|
+
# File with micro-metadata
|
|
447
|
+
line = f"{prefix}{connector} {name}"
|
|
448
|
+
if show_meta and child.meta:
|
|
449
|
+
meta_str = child.meta.format_micro(
|
|
450
|
+
max_classes=self.max_classes,
|
|
451
|
+
max_methods=self.max_methods,
|
|
452
|
+
max_functions=self.max_functions,
|
|
453
|
+
)
|
|
454
|
+
if meta_str:
|
|
455
|
+
line += f" {meta_str}"
|
|
456
|
+
lines.append(line)
|
|
457
|
+
else:
|
|
458
|
+
# Directory
|
|
459
|
+
file_count, symbol_count, hub_count = child.get_stats()
|
|
460
|
+
|
|
461
|
+
# Check for single-child directory flattening
|
|
462
|
+
flat_path = name
|
|
463
|
+
current = child
|
|
464
|
+
while len(current.children) == 1:
|
|
465
|
+
only_child_name = list(current.children.keys())[0]
|
|
466
|
+
only_child = current.children[only_child_name]
|
|
467
|
+
if only_child.is_file:
|
|
468
|
+
break
|
|
469
|
+
flat_path = f"{flat_path}/{only_child_name}"
|
|
470
|
+
current = only_child
|
|
471
|
+
|
|
472
|
+
# Collapse large directories
|
|
473
|
+
if file_count > collapse_threshold and max_depth > 0 and depth >= max_depth - 1:
|
|
474
|
+
dir_stats = f"({file_count} files, {symbol_count} symbols)"
|
|
475
|
+
lines.append(f"{prefix}{connector} {flat_path}/ {dir_stats}")
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
# Directory with stats hint
|
|
479
|
+
dir_line = f"{prefix}{connector} {flat_path}/"
|
|
480
|
+
if file_count > 5:
|
|
481
|
+
dir_line += f" ({file_count} files)"
|
|
482
|
+
lines.append(dir_line)
|
|
483
|
+
|
|
484
|
+
# Recurse
|
|
485
|
+
self._render_node(
|
|
486
|
+
current,
|
|
487
|
+
lines,
|
|
488
|
+
new_prefix,
|
|
489
|
+
is_last_item,
|
|
490
|
+
depth + 1,
|
|
491
|
+
max_depth,
|
|
492
|
+
show_meta,
|
|
493
|
+
collapse_threshold,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def _get_summary_stats(self) -> dict[str, Any]:
|
|
497
|
+
"""Calculate summary statistics."""
|
|
498
|
+
total_files = len(self.files)
|
|
499
|
+
total_symbols = sum(len(m.classes) + len(m.functions) for m in self.files.values())
|
|
500
|
+
|
|
501
|
+
# Find hubs
|
|
502
|
+
hubs = [
|
|
503
|
+
(Path(m.path).name, m.importers_count)
|
|
504
|
+
for m in self.files.values()
|
|
505
|
+
if m.importers_count >= self.hub_threshold
|
|
506
|
+
]
|
|
507
|
+
hubs.sort(key=lambda x: x[1], reverse=True)
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
"files": total_files,
|
|
511
|
+
"symbols": total_symbols,
|
|
512
|
+
"hubs": len(hubs),
|
|
513
|
+
"top_hubs": hubs[:10],
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
def render_dependency_flow(
|
|
517
|
+
self,
|
|
518
|
+
top_n: int = 15,
|
|
519
|
+
show_chains: bool = True,
|
|
520
|
+
) -> str:
|
|
521
|
+
"""Render dependency flow visualization.
|
|
522
|
+
|
|
523
|
+
Similar to REPO_B's depgraph.go but more compact.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
top_n: Number of top dependencies to show.
|
|
527
|
+
show_chains: Show dependency chains (A → B → C).
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Formatted dependency flow string.
|
|
531
|
+
"""
|
|
532
|
+
if not self.dependency_graph:
|
|
533
|
+
return "⚠ No dependency graph available. Initialize with dependency_graph parameter."
|
|
534
|
+
|
|
535
|
+
lines = []
|
|
536
|
+
lines.append("═══ Dependency Flow ═══")
|
|
537
|
+
lines.append("")
|
|
538
|
+
|
|
539
|
+
# Group files by directory
|
|
540
|
+
by_dir = defaultdict(list)
|
|
541
|
+
for path, meta in self.files.items():
|
|
542
|
+
dir_name = str(Path(path).parent)
|
|
543
|
+
if dir_name == ".":
|
|
544
|
+
dir_name = "root"
|
|
545
|
+
by_dir[dir_name].append((path, meta))
|
|
546
|
+
|
|
547
|
+
# Show each directory's dependencies
|
|
548
|
+
for dir_name in sorted(by_dir.keys()):
|
|
549
|
+
dir_files = by_dir[dir_name]
|
|
550
|
+
has_deps = any(m.imports_count > 0 for _, m in dir_files)
|
|
551
|
+
if not has_deps:
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
lines.append(f"┌─ {dir_name}/")
|
|
555
|
+
|
|
556
|
+
for path, meta in sorted(dir_files, key=lambda x: x[1].importers_count, reverse=True):
|
|
557
|
+
if meta.imports_count == 0 and meta.importers_count == 0:
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
name = Path(path).stem
|
|
561
|
+
|
|
562
|
+
# Show import relationships
|
|
563
|
+
if self.dependency_graph and path in self.dependency_graph.nodes:
|
|
564
|
+
node = self.dependency_graph.nodes[path]
|
|
565
|
+
imports = node.resolved_imports[:3] # Max 3
|
|
566
|
+
|
|
567
|
+
if imports:
|
|
568
|
+
import_names = [Path(i).stem for i in imports]
|
|
569
|
+
arrow = "───▶"
|
|
570
|
+
if meta.importers_count >= 3:
|
|
571
|
+
arrow = "═══▶" # Hub gets bold arrow
|
|
572
|
+
|
|
573
|
+
if len(imports) == 1:
|
|
574
|
+
lines.append(f"│ {name} {arrow} {import_names[0]}")
|
|
575
|
+
else:
|
|
576
|
+
lines.append(f"│ {name} {arrow} {', '.join(import_names)}")
|
|
577
|
+
if len(node.resolved_imports) > 3:
|
|
578
|
+
lines.append(f"│ +{len(node.resolved_imports) - 3} more")
|
|
579
|
+
|
|
580
|
+
lines.append("└─")
|
|
581
|
+
lines.append("")
|
|
582
|
+
|
|
583
|
+
# Hub summary
|
|
584
|
+
hubs = self._get_summary_stats()["top_hubs"]
|
|
585
|
+
if hubs:
|
|
586
|
+
lines.append("─" * 40)
|
|
587
|
+
hub_strs = [f"{h[0]}({h[1]}←)" for h in hubs[:6]]
|
|
588
|
+
lines.append(f"HUBS: {', '.join(hub_strs)}")
|
|
589
|
+
|
|
590
|
+
return "\n".join(lines)
|
|
591
|
+
|
|
592
|
+
def render_compact_index(
|
|
593
|
+
self,
|
|
594
|
+
include_signatures: bool = False,
|
|
595
|
+
group_by: str = "file", # "file", "type", "directory"
|
|
596
|
+
) -> str:
|
|
597
|
+
"""Render ultra-compact symbol index.
|
|
598
|
+
|
|
599
|
+
Even more compact than the tree - just lists key symbols.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
include_signatures: Include function signatures.
|
|
603
|
+
group_by: How to group symbols.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Compact symbol index string.
|
|
607
|
+
"""
|
|
608
|
+
lines = []
|
|
609
|
+
|
|
610
|
+
if group_by == "type":
|
|
611
|
+
# Group by symbol type
|
|
612
|
+
classes = []
|
|
613
|
+
functions = []
|
|
614
|
+
|
|
615
|
+
for path, meta in self.files.items():
|
|
616
|
+
short_path = Path(path).stem
|
|
617
|
+
for cls in meta.classes[: self.max_classes]:
|
|
618
|
+
methods = meta.methods.get(cls, [])[: self.max_methods]
|
|
619
|
+
if methods:
|
|
620
|
+
classes.append(f"{short_path}.{cls}({','.join(methods)})")
|
|
621
|
+
else:
|
|
622
|
+
classes.append(f"{short_path}.{cls}")
|
|
623
|
+
|
|
624
|
+
for func in meta.functions[: self.max_functions]:
|
|
625
|
+
if not func.startswith("_"):
|
|
626
|
+
functions.append(f"{short_path}.{func}")
|
|
627
|
+
|
|
628
|
+
if classes:
|
|
629
|
+
lines.append(f"Classes: {', '.join(classes[:20])}")
|
|
630
|
+
if len(classes) > 20:
|
|
631
|
+
lines.append(f" +{len(classes) - 20} more classes")
|
|
632
|
+
|
|
633
|
+
if functions:
|
|
634
|
+
lines.append(f"Functions: {', '.join(functions[:30])}")
|
|
635
|
+
if len(functions) > 30:
|
|
636
|
+
lines.append(f" +{len(functions) - 30} more functions")
|
|
637
|
+
|
|
638
|
+
else: # group_by == "file" or "directory"
|
|
639
|
+
for path in sorted(self.files.keys()):
|
|
640
|
+
meta = self.files[path]
|
|
641
|
+
if not meta.classes and not meta.functions:
|
|
642
|
+
continue
|
|
643
|
+
|
|
644
|
+
short = Path(path).stem
|
|
645
|
+
symbols = []
|
|
646
|
+
|
|
647
|
+
for cls in meta.classes[: self.max_classes]:
|
|
648
|
+
methods = meta.methods.get(cls, [])[: self.max_methods]
|
|
649
|
+
if methods:
|
|
650
|
+
symbols.append(f"C:{cls}({','.join(methods)})")
|
|
651
|
+
else:
|
|
652
|
+
symbols.append(f"C:{cls}")
|
|
653
|
+
|
|
654
|
+
for func in meta.functions[: self.max_functions]:
|
|
655
|
+
if not func.startswith("_"):
|
|
656
|
+
symbols.append(f"F:{func}")
|
|
657
|
+
|
|
658
|
+
if symbols:
|
|
659
|
+
lines.append(f"{short}: {' '.join(symbols)}")
|
|
660
|
+
|
|
661
|
+
return "\n".join(lines)
|
|
662
|
+
|
|
663
|
+
def get_token_stats(self) -> dict[str, Any]:
|
|
664
|
+
"""Compare token usage between JSON and tree output.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
Dict with token comparison statistics.
|
|
668
|
+
"""
|
|
669
|
+
import json
|
|
670
|
+
|
|
671
|
+
# Original JSON size
|
|
672
|
+
json_output = json.dumps(self.code_map, indent=2)
|
|
673
|
+
json_chars = len(json_output)
|
|
674
|
+
|
|
675
|
+
# Compact JSON
|
|
676
|
+
compact_json = json.dumps(self.code_map, separators=(",", ":"))
|
|
677
|
+
compact_chars = len(compact_json)
|
|
678
|
+
|
|
679
|
+
# Tree output
|
|
680
|
+
tree_output = self.render_skeleton_tree()
|
|
681
|
+
tree_chars = len(tree_output)
|
|
682
|
+
|
|
683
|
+
# Compact index
|
|
684
|
+
index_output = self.render_compact_index()
|
|
685
|
+
index_chars = len(index_output)
|
|
686
|
+
|
|
687
|
+
# Approximate token counts (rough: 4 chars ≈ 1 token)
|
|
688
|
+
json_tokens = json_chars // 4
|
|
689
|
+
compact_tokens = compact_chars // 4
|
|
690
|
+
tree_tokens = tree_chars // 4
|
|
691
|
+
index_tokens = index_chars // 4
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
"json_chars": json_chars,
|
|
695
|
+
"json_tokens_approx": json_tokens,
|
|
696
|
+
"compact_json_chars": compact_chars,
|
|
697
|
+
"compact_json_tokens_approx": compact_tokens,
|
|
698
|
+
"tree_chars": tree_chars,
|
|
699
|
+
"tree_tokens_approx": tree_tokens,
|
|
700
|
+
"index_chars": index_chars,
|
|
701
|
+
"index_tokens_approx": index_tokens,
|
|
702
|
+
"savings_vs_json": json_chars - tree_chars,
|
|
703
|
+
"savings_percent": (
|
|
704
|
+
((json_chars - tree_chars) / json_chars) * 100 if json_chars > 0 else 0
|
|
705
|
+
),
|
|
706
|
+
"savings_vs_compact": compact_chars - tree_chars,
|
|
707
|
+
"compact_savings_percent": (
|
|
708
|
+
((compact_chars - tree_chars) / compact_chars) * 100 if compact_chars > 0 else 0
|
|
709
|
+
),
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def render_skeleton_tree(
|
|
714
|
+
file_nodes: dict[str, Any] | str,
|
|
715
|
+
max_depth: int = 0,
|
|
716
|
+
show_meta: bool = True,
|
|
717
|
+
project_name: str | None = None,
|
|
718
|
+
) -> str:
|
|
719
|
+
"""Convenience function to render a skeleton tree.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
file_nodes: Either a code map dict or path to .codegraph.json.
|
|
723
|
+
max_depth: Maximum directory depth (0 = unlimited).
|
|
724
|
+
show_meta: Include micro-metadata.
|
|
725
|
+
project_name: Override project name.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Formatted ASCII tree string.
|
|
729
|
+
|
|
730
|
+
Example:
|
|
731
|
+
>>> tree = render_skeleton_tree('.codegraph.json')
|
|
732
|
+
>>> print(tree)
|
|
733
|
+
"""
|
|
734
|
+
if isinstance(file_nodes, str):
|
|
735
|
+
renderer = TokenEfficientRenderer.from_file(file_nodes)
|
|
736
|
+
else:
|
|
737
|
+
renderer = TokenEfficientRenderer(file_nodes)
|
|
738
|
+
|
|
739
|
+
return renderer.render_skeleton_tree(
|
|
740
|
+
max_depth=max_depth,
|
|
741
|
+
show_meta=show_meta,
|
|
742
|
+
project_name=project_name,
|
|
743
|
+
)
|