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.
Files changed (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. 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
+ )