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.
- mcp_vector_search/__init__.py +3 -3
- mcp_vector_search/analysis/__init__.py +48 -1
- mcp_vector_search/analysis/baseline/__init__.py +68 -0
- mcp_vector_search/analysis/baseline/comparator.py +462 -0
- mcp_vector_search/analysis/baseline/manager.py +621 -0
- mcp_vector_search/analysis/collectors/__init__.py +35 -0
- mcp_vector_search/analysis/collectors/cohesion.py +463 -0
- mcp_vector_search/analysis/collectors/coupling.py +1162 -0
- mcp_vector_search/analysis/collectors/halstead.py +514 -0
- mcp_vector_search/analysis/collectors/smells.py +325 -0
- mcp_vector_search/analysis/debt.py +516 -0
- mcp_vector_search/analysis/interpretation.py +685 -0
- mcp_vector_search/analysis/metrics.py +74 -1
- mcp_vector_search/analysis/reporters/__init__.py +3 -1
- mcp_vector_search/analysis/reporters/console.py +424 -0
- mcp_vector_search/analysis/reporters/markdown.py +480 -0
- mcp_vector_search/analysis/reporters/sarif.py +377 -0
- mcp_vector_search/analysis/storage/__init__.py +93 -0
- mcp_vector_search/analysis/storage/metrics_store.py +762 -0
- mcp_vector_search/analysis/storage/schema.py +245 -0
- mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
- mcp_vector_search/analysis/trends.py +308 -0
- mcp_vector_search/analysis/visualizer/__init__.py +90 -0
- mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
- mcp_vector_search/analysis/visualizer/exporter.py +484 -0
- mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
- mcp_vector_search/analysis/visualizer/schemas.py +525 -0
- mcp_vector_search/cli/commands/analyze.py +665 -11
- mcp_vector_search/cli/commands/chat.py +193 -0
- mcp_vector_search/cli/commands/index.py +600 -2
- mcp_vector_search/cli/commands/index_background.py +467 -0
- mcp_vector_search/cli/commands/search.py +194 -1
- mcp_vector_search/cli/commands/setup.py +64 -13
- mcp_vector_search/cli/commands/status.py +302 -3
- mcp_vector_search/cli/commands/visualize/cli.py +26 -10
- mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +8 -4
- mcp_vector_search/cli/commands/visualize/graph_builder.py +167 -234
- mcp_vector_search/cli/commands/visualize/server.py +304 -15
- mcp_vector_search/cli/commands/visualize/templates/base.py +60 -6
- mcp_vector_search/cli/commands/visualize/templates/scripts.py +2100 -65
- mcp_vector_search/cli/commands/visualize/templates/styles.py +1297 -88
- mcp_vector_search/cli/didyoumean.py +5 -0
- mcp_vector_search/cli/main.py +16 -5
- mcp_vector_search/cli/output.py +134 -5
- mcp_vector_search/config/thresholds.py +89 -1
- mcp_vector_search/core/__init__.py +16 -0
- mcp_vector_search/core/database.py +39 -2
- mcp_vector_search/core/embeddings.py +24 -0
- mcp_vector_search/core/git.py +380 -0
- mcp_vector_search/core/indexer.py +445 -84
- mcp_vector_search/core/llm_client.py +9 -4
- mcp_vector_search/core/models.py +88 -1
- mcp_vector_search/core/relationships.py +473 -0
- mcp_vector_search/core/search.py +1 -1
- mcp_vector_search/mcp/server.py +795 -4
- mcp_vector_search/parsers/python.py +285 -5
- mcp_vector_search/utils/gitignore.py +0 -3
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +3 -2
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/RECORD +62 -39
- mcp_vector_search/cli/commands/visualize.py.original +0 -2536
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +0 -0
- {mcp_vector_search-1.0.3.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +0 -0
- {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
|
-
"
|
|
281
|
-
"
|
|
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
|
-
#
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
397
|
+
"[green]✓[/green] Skipping relationship computation (lazy-loaded on node expand)"
|
|
365
398
|
)
|
|
366
399
|
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
413
|
-
f"
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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":
|
|
424
|
+
"name": chunk_name,
|
|
511
425
|
"type": chunk.chunk_type,
|
|
512
|
-
"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":
|
|
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
|
-
#
|
|
536
|
-
|
|
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("
|
|
490
|
+
if file_node.get("parent_id"):
|
|
567
491
|
links.append(
|
|
568
492
|
{
|
|
569
|
-
"source": file_node["
|
|
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
|
-
#
|
|
609
|
-
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
|
-
|
|
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 {
|