mcp-vector-search 1.0.3__py3-none-any.whl → 1.1.22__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 (63) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +48 -1
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +35 -0
  7. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  8. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  9. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  10. mcp_vector_search/analysis/collectors/smells.py +325 -0
  11. mcp_vector_search/analysis/debt.py +516 -0
  12. mcp_vector_search/analysis/interpretation.py +685 -0
  13. mcp_vector_search/analysis/metrics.py +74 -1
  14. mcp_vector_search/analysis/reporters/__init__.py +3 -1
  15. mcp_vector_search/analysis/reporters/console.py +424 -0
  16. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  17. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  18. mcp_vector_search/analysis/storage/__init__.py +93 -0
  19. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  20. mcp_vector_search/analysis/storage/schema.py +245 -0
  21. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  22. mcp_vector_search/analysis/trends.py +308 -0
  23. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  24. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  25. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  26. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  27. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  28. mcp_vector_search/cli/commands/analyze.py +665 -11
  29. mcp_vector_search/cli/commands/chat.py +193 -0
  30. mcp_vector_search/cli/commands/index.py +600 -2
  31. mcp_vector_search/cli/commands/index_background.py +467 -0
  32. mcp_vector_search/cli/commands/search.py +194 -1
  33. mcp_vector_search/cli/commands/setup.py +64 -13
  34. mcp_vector_search/cli/commands/status.py +302 -3
  35. mcp_vector_search/cli/commands/visualize/cli.py +26 -10
  36. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +8 -4
  37. mcp_vector_search/cli/commands/visualize/graph_builder.py +167 -234
  38. mcp_vector_search/cli/commands/visualize/server.py +304 -15
  39. mcp_vector_search/cli/commands/visualize/templates/base.py +60 -6
  40. mcp_vector_search/cli/commands/visualize/templates/scripts.py +2100 -65
  41. mcp_vector_search/cli/commands/visualize/templates/styles.py +1297 -88
  42. mcp_vector_search/cli/didyoumean.py +5 -0
  43. mcp_vector_search/cli/main.py +16 -5
  44. mcp_vector_search/cli/output.py +134 -5
  45. mcp_vector_search/config/thresholds.py +89 -1
  46. mcp_vector_search/core/__init__.py +16 -0
  47. mcp_vector_search/core/database.py +39 -2
  48. mcp_vector_search/core/embeddings.py +24 -0
  49. mcp_vector_search/core/git.py +380 -0
  50. mcp_vector_search/core/indexer.py +445 -84
  51. mcp_vector_search/core/llm_client.py +9 -4
  52. mcp_vector_search/core/models.py +88 -1
  53. mcp_vector_search/core/relationships.py +473 -0
  54. mcp_vector_search/core/search.py +1 -1
  55. mcp_vector_search/mcp/server.py +795 -4
  56. mcp_vector_search/parsers/python.py +285 -5
  57. mcp_vector_search/utils/gitignore.py +0 -3
  58. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +3 -2
  59. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/RECORD +62 -39
  60. mcp_vector_search/cli/commands/visualize.py.original +0 -2536
  61. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +0 -0
  62. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +0 -0
  63. {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from loguru import logger
12
12
  from rich.console import Console
13
13
 
14
+ from ....analysis.trends import TrendTracker
14
15
  from ....core.database import ChromaVectorDatabase
15
16
  from ....core.directory_index import DirectoryIndex
16
17
  from ....core.project import ProjectManager
@@ -19,6 +20,77 @@ from .state_manager import VisualizationState
19
20
  console = Console()
20
21
 
21
22
 
23
+ def extract_chunk_name(content: str, fallback: str = "chunk") -> str:
24
+ """Extract first meaningful word from chunk content for labeling.
25
+
26
+ Args:
27
+ content: The chunk's code content
28
+ fallback: Fallback name if no meaningful word found
29
+
30
+ Returns:
31
+ First meaningful identifier found in the content
32
+
33
+ Examples:
34
+ >>> extract_chunk_name("def calculate_total(...)")
35
+ 'calculate_total'
36
+ >>> extract_chunk_name("class UserManager:")
37
+ 'UserManager'
38
+ >>> extract_chunk_name("# Comment about users")
39
+ 'users'
40
+ >>> extract_chunk_name("import pandas as pd")
41
+ 'pandas'
42
+ """
43
+ import re
44
+
45
+ # Skip common keywords that aren't meaningful as chunk labels
46
+ skip_words = {
47
+ "def",
48
+ "class",
49
+ "function",
50
+ "const",
51
+ "let",
52
+ "var",
53
+ "import",
54
+ "from",
55
+ "return",
56
+ "if",
57
+ "else",
58
+ "elif",
59
+ "for",
60
+ "while",
61
+ "try",
62
+ "except",
63
+ "finally",
64
+ "with",
65
+ "as",
66
+ "async",
67
+ "await",
68
+ "yield",
69
+ "self",
70
+ "this",
71
+ "true",
72
+ "false",
73
+ "none",
74
+ "null",
75
+ "undefined",
76
+ "public",
77
+ "private",
78
+ "protected",
79
+ "static",
80
+ "export",
81
+ "default",
82
+ }
83
+
84
+ # Find all words (alphanumeric + underscore, at least 2 chars)
85
+ words = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]+\b", content)
86
+
87
+ for word in words:
88
+ if word.lower() not in skip_words:
89
+ return word
90
+
91
+ return fallback
92
+
93
+
22
94
  def get_subproject_color(subproject_name: str, index: int) -> str:
23
95
  """Get a consistent color for a subproject.
24
96
 
@@ -226,6 +298,13 @@ async def build_graph_data(
226
298
  console.print(f"[green]✓[/green] Loaded {len(dir_index.directories)} directories")
227
299
  for dir_path_str, directory in dir_index.directories.items():
228
300
  dir_id = f"dir_{hash(dir_path_str) & 0xFFFFFFFF:08x}"
301
+
302
+ # Compute parent directory ID (convert Path to string for JSON serialization)
303
+ parent_dir_id = None
304
+ parent_path_str = str(directory.parent_path) if directory.parent_path else None
305
+ if parent_path_str:
306
+ parent_dir_id = f"dir_{hash(parent_path_str) & 0xFFFFFFFF:08x}"
307
+
229
308
  dir_nodes[dir_path_str] = {
230
309
  "id": dir_id,
231
310
  "name": directory.name,
@@ -236,6 +315,8 @@ async def build_graph_data(
236
315
  "complexity": 0,
237
316
  "depth": directory.depth,
238
317
  "dir_path": dir_path_str,
318
+ "parent_id": parent_dir_id, # Link to parent directory
319
+ "parent_path": parent_path_str, # String for JSON serialization
239
320
  "file_count": directory.file_count,
240
321
  "subdirectory_count": directory.subdirectory_count,
241
322
  "total_chunks": directory.total_chunks,
@@ -245,6 +326,7 @@ async def build_graph_data(
245
326
  }
246
327
 
247
328
  # Create file nodes from chunks
329
+ # First pass: create file node entries
248
330
  for chunk in chunks:
249
331
  file_path_str = str(chunk.file_path)
250
332
  file_path = Path(file_path_str)
@@ -277,10 +359,17 @@ async def build_graph_data(
277
359
  "end_line": 0,
278
360
  "complexity": 0,
279
361
  "depth": len(file_path.parts) - 1,
280
- "parent_dir_id": parent_dir_id,
281
- "parent_dir_path": parent_dir_str,
362
+ "parent_id": parent_dir_id, # Consistent with directory nodes
363
+ "parent_path": parent_dir_str,
364
+ "chunk_count": 0, # Will be computed below
282
365
  }
283
366
 
367
+ # Second pass: count chunks per file (pre-compute for consistent sizing)
368
+ for chunk in chunks:
369
+ file_path_str = str(chunk.file_path)
370
+ if file_path_str in file_nodes:
371
+ file_nodes[file_path_str]["chunk_count"] += 1
372
+
284
373
  # Add directory nodes to graph
285
374
  for dir_node in dir_nodes.values():
286
375
  nodes.append(dir_node)
@@ -289,237 +378,84 @@ async def build_graph_data(
289
378
  for file_node in file_nodes.values():
290
379
  nodes.append(file_node)
291
380
 
292
- # Compute semantic relationships for code chunks
293
- console.print("[cyan]Computing semantic relationships...[/cyan]")
294
- code_chunks = [c for c in chunks if c.chunk_type in ["function", "method", "class"]]
295
- semantic_links = []
296
-
297
- # Pre-compute top 5 semantic relationships for each code chunk
298
- for i, chunk in enumerate(code_chunks):
299
- if i % 20 == 0: # Progress indicator every 20 chunks
300
- console.print(f"[dim]Processed {i}/{len(code_chunks)} chunks[/dim]")
301
-
302
- try:
303
- # Search for similar chunks using the chunk's content
304
- similar_results = await database.search(
305
- query=chunk.content[:500], # Use first 500 chars for query
306
- limit=6, # Get 6 (exclude self = 5)
307
- similarity_threshold=0.3, # Lower threshold to catch more relationships
308
- )
309
-
310
- # Filter out self and create semantic links
311
- for result in similar_results:
312
- # Construct target chunk_id from file_path and line numbers
313
- target_chunk = next(
314
- (
315
- c
316
- for c in chunks
317
- if str(c.file_path) == str(result.file_path)
318
- and c.start_line == result.start_line
319
- and c.end_line == result.end_line
320
- ),
321
- None,
322
- )
323
-
324
- if not target_chunk:
325
- continue
326
-
327
- target_chunk_id = target_chunk.chunk_id or target_chunk.id
328
-
329
- # Skip self-references
330
- if target_chunk_id == (chunk.chunk_id or chunk.id):
331
- continue
332
-
333
- # Add semantic link with similarity score
334
- if result.similarity_score >= 0.2:
335
- semantic_links.append(
336
- {
337
- "source": chunk.chunk_id or chunk.id,
338
- "target": target_chunk_id,
339
- "type": "semantic",
340
- "similarity": result.similarity_score,
341
- }
342
- )
343
-
344
- # Only keep top 5
345
- if (
346
- len(
347
- [
348
- link
349
- for link in semantic_links
350
- if link["source"] == (chunk.chunk_id or chunk.id)
351
- ]
352
- )
353
- >= 5
354
- ):
355
- break
356
-
357
- except Exception as e:
358
- logger.debug(
359
- f"Failed to compute semantic relationships for {chunk.chunk_id}: {e}"
381
+ # Link directories to their parent directories
382
+ for dir_node in dir_nodes.values():
383
+ if dir_node.get("parent_id"):
384
+ links.append(
385
+ {
386
+ "source": dir_node["parent_id"],
387
+ "target": dir_node["id"],
388
+ "type": "dir_containment",
389
+ }
360
390
  )
361
- continue
362
391
 
392
+ # Skip ALL relationship computation at startup for instant loading
393
+ # Relationships are lazy-loaded on-demand via /api/relationships/{chunk_id}
394
+ # This avoids the expensive 5+ minute semantic computation
395
+ caller_map: dict = {} # Empty - callers lazy-loaded via API
363
396
  console.print(
364
- f"[green]✓[/green] Computed {len(semantic_links)} semantic relationships"
397
+ "[green]✓[/green] Skipping relationship computation (lazy-loaded on node expand)"
365
398
  )
366
399
 
367
- def extract_function_calls(code: str) -> set[str]:
368
- """Extract actual function calls from Python code using AST.
369
-
370
- Returns set of function names that are actually called (not just mentioned).
371
- Avoids false positives from comments, docstrings, and string literals.
372
-
373
- Args:
374
- code: Python source code to analyze
375
-
376
- Returns:
377
- Set of function names that are actually called in the code
378
- """
379
- import ast
400
+ # Add chunk nodes
401
+ for chunk in chunks:
402
+ chunk_id = chunk.chunk_id or chunk.id
380
403
 
381
- calls = set()
382
- try:
383
- tree = ast.parse(code)
384
- for node in ast.walk(tree):
385
- if isinstance(node, ast.Call):
386
- # Handle direct calls: foo()
387
- if isinstance(node.func, ast.Name):
388
- calls.add(node.func.id)
389
- # Handle method calls: obj.foo() - extract 'foo'
390
- elif isinstance(node.func, ast.Attribute):
391
- calls.add(node.func.attr)
392
- return calls
393
- except SyntaxError:
394
- # If code can't be parsed (incomplete, etc.), fall back to empty set
395
- # This is safer than false positives from naive substring matching
396
- return set()
397
-
398
- # Compute external caller relationships
399
- console.print("[cyan]Computing external caller relationships...[/cyan]")
400
- import time
401
-
402
- start_time = time.time()
403
- caller_map = {} # Map chunk_id -> list of caller info
404
-
405
- logger.info(f"Processing {len(code_chunks)} code chunks for external callers...")
406
- for chunk_idx, chunk in enumerate(code_chunks):
407
- if chunk_idx % 50 == 0: # Progress every 50 chunks
408
- elapsed = time.time() - start_time
409
- logger.info(
410
- f"Progress: {chunk_idx}/{len(code_chunks)} chunks ({elapsed:.1f}s elapsed)"
404
+ # Generate meaningful chunk name
405
+ chunk_name = chunk.function_name or chunk.class_name
406
+ if not chunk_name:
407
+ # Extract meaningful name from content
408
+ chunk_name = extract_chunk_name(
409
+ chunk.content, fallback=f"chunk_{chunk.start_line}"
411
410
  )
412
- console.print(
413
- f"[dim]Progress: {chunk_idx}/{len(code_chunks)} chunks ({elapsed:.1f}s)[/dim]"
411
+ logger.debug(
412
+ f"Generated chunk name '{chunk_name}' for {chunk.chunk_type} at {chunk.file_path}:{chunk.start_line}"
414
413
  )
415
- chunk_id = chunk.chunk_id or chunk.id
416
- file_path = str(chunk.file_path)
417
- function_name = chunk.function_name or chunk.class_name
418
-
419
- if not function_name:
420
- continue
421
-
422
- # Search for other chunks that reference this function/class name
423
- other_chunks_count = 0
424
- for other_chunk in chunks:
425
- other_chunks_count += 1
426
- if chunk_idx % 50 == 0 and other_chunks_count % 500 == 0: # Inner progress
427
- logger.debug(
428
- f" Chunk {chunk_idx}: Scanning {other_chunks_count}/{len(chunks)} chunks"
429
- )
430
- other_file_path = str(other_chunk.file_path)
431
-
432
- # Only track EXTERNAL callers (different file)
433
- if other_file_path == file_path:
434
- continue
435
-
436
- # Extract actual function calls using AST (avoids false positives)
437
- actual_calls = extract_function_calls(other_chunk.content)
438
-
439
- # Check if this function is actually called (not just mentioned in comments)
440
- if function_name in actual_calls:
441
- other_chunk_id = other_chunk.chunk_id or other_chunk.id
442
- other_name = (
443
- other_chunk.function_name
444
- or other_chunk.class_name
445
- or f"L{other_chunk.start_line}"
446
- )
447
-
448
- # Skip __init__ functions as callers - they are noise in "called by" lists
449
- # (every class calls __init__ when constructing objects)
450
- if other_name == "__init__":
451
- continue
452
-
453
- if chunk_id not in caller_map:
454
- caller_map[chunk_id] = []
455
-
456
- # Store caller information
457
- caller_map[chunk_id].append(
458
- {
459
- "file": other_file_path,
460
- "chunk_id": other_chunk_id,
461
- "name": other_name,
462
- "type": other_chunk.chunk_type,
463
- }
464
- )
465
-
466
- logger.debug(
467
- f"Found actual call: {other_name} ({other_file_path}) -> "
468
- f"{function_name} ({file_path})"
469
- )
470
-
471
- # Count total caller relationships
472
- total_callers = sum(len(callers) for callers in caller_map.values())
473
- elapsed_total = time.time() - start_time
474
- logger.info(f"Completed external caller computation in {elapsed_total:.1f}s")
475
- console.print(
476
- f"[green]✓[/green] Found {total_callers} external caller relationships ({elapsed_total:.1f}s)"
477
- )
478
414
 
479
- # Detect circular dependencies in caller relationships
480
- console.print("[cyan]Detecting circular dependencies...[/cyan]")
481
- cycles = detect_cycles(chunks, caller_map)
482
-
483
- # Mark cycle links
484
- cycle_links = []
485
- if cycles:
486
- console.print(f"[yellow]⚠ Found {len(cycles)} circular dependencies[/yellow]")
487
-
488
- # For each cycle, create links marking the cycle
489
- for cycle in cycles:
490
- # Create links for the cycle path: A → B → C → A
491
- for i in range(len(cycle)):
492
- source = cycle[i]
493
- target = cycle[(i + 1) % len(cycle)] # Wrap around to form cycle
494
- cycle_links.append(
495
- {
496
- "source": source,
497
- "target": target,
498
- "type": "caller",
499
- "is_cycle": True,
500
- }
501
- )
502
- else:
503
- console.print("[green]✓[/green] No circular dependencies detected")
415
+ # Determine parent_id: use parent_chunk_id if exists, else use file node ID
416
+ file_path_str = str(chunk.file_path)
417
+ parent_id = chunk.parent_chunk_id
418
+ if not parent_id and file_path_str in file_nodes:
419
+ # Top-level chunk: set parent to file node for proper tree structure
420
+ parent_id = file_nodes[file_path_str]["id"]
504
421
 
505
- # Add chunk nodes
506
- for chunk in chunks:
507
- chunk_id = chunk.chunk_id or chunk.id
508
422
  node = {
509
423
  "id": chunk_id,
510
- "name": chunk.function_name or chunk.class_name or f"L{chunk.start_line}",
424
+ "name": chunk_name,
511
425
  "type": chunk.chunk_type,
512
- "file_path": str(chunk.file_path),
426
+ "file_path": file_path_str,
513
427
  "start_line": chunk.start_line,
514
428
  "end_line": chunk.end_line,
515
429
  "complexity": chunk.complexity_score,
516
- "parent_id": chunk.parent_chunk_id,
430
+ "parent_id": parent_id, # Now properly set for all chunks
517
431
  "depth": chunk.chunk_depth,
518
432
  "content": chunk.content, # Add content for code viewer
519
433
  "docstring": chunk.docstring,
520
434
  "language": chunk.language,
521
435
  }
522
436
 
437
+ # Add structural analysis metrics if available
438
+ if (
439
+ hasattr(chunk, "cognitive_complexity")
440
+ and chunk.cognitive_complexity is not None
441
+ ):
442
+ node["cognitive_complexity"] = chunk.cognitive_complexity
443
+ if (
444
+ hasattr(chunk, "cyclomatic_complexity")
445
+ and chunk.cyclomatic_complexity is not None
446
+ ):
447
+ node["cyclomatic_complexity"] = chunk.cyclomatic_complexity
448
+ if hasattr(chunk, "complexity_grade") and chunk.complexity_grade is not None:
449
+ node["complexity_grade"] = chunk.complexity_grade
450
+ if hasattr(chunk, "code_smells") and chunk.code_smells:
451
+ node["smells"] = chunk.code_smells
452
+ if hasattr(chunk, "smell_count") and chunk.smell_count is not None:
453
+ node["smell_count"] = chunk.smell_count
454
+ if hasattr(chunk, "quality_score") and chunk.quality_score is not None:
455
+ node["quality_score"] = chunk.quality_score
456
+ if hasattr(chunk, "lines_of_code") and chunk.lines_of_code is not None:
457
+ node["lines_of_code"] = chunk.lines_of_code
458
+
523
459
  # Add caller information if available
524
460
  if chunk_id in caller_map:
525
461
  node["callers"] = caller_map[chunk_id]
@@ -532,20 +468,8 @@ async def build_graph_data(
532
468
  nodes.append(node)
533
469
  chunk_id_map[node["id"]] = len(nodes) - 1
534
470
 
535
- # Link directories to their parent directories (hierarchical structure)
536
- for dir_path_str, dir_info in dir_index.directories.items():
537
- if dir_info.parent_path:
538
- parent_path_str = str(dir_info.parent_path)
539
- if parent_path_str in dir_nodes:
540
- parent_dir_id = f"dir_{hash(parent_path_str) & 0xFFFFFFFF:08x}"
541
- child_dir_id = f"dir_{hash(dir_path_str) & 0xFFFFFFFF:08x}"
542
- links.append(
543
- {
544
- "source": parent_dir_id,
545
- "target": child_dir_id,
546
- "type": "dir_hierarchy",
547
- }
548
- )
471
+ # NOTE: Directory parent→child links already created above via dir_containment
472
+ # (removed duplicate dir_hierarchy link creation that caused duplicate paths)
549
473
 
550
474
  # Link directories to subprojects in monorepos (simple flat structure)
551
475
  if subprojects:
@@ -563,10 +487,10 @@ async def build_graph_data(
563
487
 
564
488
  # Link files to their parent directories
565
489
  for _file_path_str, file_node in file_nodes.items():
566
- if file_node.get("parent_dir_id"):
490
+ if file_node.get("parent_id"):
567
491
  links.append(
568
492
  {
569
- "source": file_node["parent_dir_id"],
493
+ "source": file_node["parent_id"],
570
494
  "target": file_node["id"],
571
495
  "type": "dir_containment",
572
496
  }
@@ -593,23 +517,22 @@ async def build_graph_data(
593
517
  {
594
518
  "source": f"subproject_{chunk.subproject_name}",
595
519
  "target": chunk_id,
520
+ "type": "subproject_containment",
596
521
  }
597
522
  )
598
523
 
599
- # Link to parent chunk
524
+ # Link to parent chunk (class -> method hierarchy)
600
525
  if chunk.parent_chunk_id and chunk.parent_chunk_id in chunk_id_map:
601
526
  links.append(
602
527
  {
603
528
  "source": chunk.parent_chunk_id,
604
529
  "target": chunk_id,
530
+ "type": "chunk_hierarchy", # Explicitly mark chunk parent-child relationships
605
531
  }
606
532
  )
607
533
 
608
- # Add semantic relationship links
609
- links.extend(semantic_links)
610
-
611
- # Add cycle links
612
- links.extend(cycle_links)
534
+ # Semantic and caller relationships are lazy-loaded via /api/relationships/{chunk_id}
535
+ # No relationship links at startup for instant loading
613
536
 
614
537
  # Parse inter-project dependencies for monorepos
615
538
  if subprojects:
@@ -626,6 +549,10 @@ async def build_graph_data(
626
549
  # Get stats
627
550
  stats = await database.get_stats()
628
551
 
552
+ # Load trend data for time series visualization
553
+ trend_tracker = TrendTracker(project_manager.project_root)
554
+ trend_summary = trend_tracker.get_trend_summary(days=90) # Last 90 days
555
+
629
556
  # Build final graph data
630
557
  graph_data = {
631
558
  "nodes": nodes,
@@ -637,6 +564,7 @@ async def build_graph_data(
637
564
  "is_monorepo": len(subprojects) > 0,
638
565
  "subprojects": list(subprojects.keys()) if subprojects else [],
639
566
  },
567
+ "trends": trend_summary, # Include trend data for visualization
640
568
  }
641
569
 
642
570
  return graph_data
@@ -703,7 +631,12 @@ def apply_state(graph_data: dict, state: VisualizationState) -> dict:
703
631
  filtered_links.append(link)
704
632
  elif state.view_mode.value in ("tree_root", "tree_expanded"):
705
633
  # In tree modes, show containment edges only
706
- if link.get("type") in ("dir_containment", "dir_hierarchy"):
634
+ # Must include file_containment to link code chunks to their parent files
635
+ if link.get("type") in (
636
+ "dir_containment",
637
+ "dir_hierarchy",
638
+ "file_containment",
639
+ ):
707
640
  filtered_links.append(link)
708
641
 
709
642
  return {