codegraphcontext 0.1.4__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {codegraphcontext-0.1.4/src/codegraphcontext.egg-info → codegraphcontext-0.1.5}/PKG-INFO +7 -1
  2. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/README.md +6 -0
  3. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/pyproject.toml +1 -1
  4. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/core/jobs.py +19 -0
  5. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/core/watcher.py +28 -9
  6. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/server.py +126 -11
  7. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/tools/code_finder.py +52 -1
  8. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/tools/graph_builder.py +127 -3
  9. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5/src/codegraphcontext.egg-info}/PKG-INFO +7 -1
  10. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/LICENSE +0 -0
  11. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/setup.cfg +0 -0
  12. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/__init__.py +0 -0
  13. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/__main__.py +0 -0
  14. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/cli/__init__.py +0 -0
  15. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/cli/main.py +0 -0
  16. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/cli/setup_wizard.py +0 -0
  17. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/core/__init__.py +0 -0
  18. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/core/database.py +0 -0
  19. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/prompts.py +0 -0
  20. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/tools/__init__.py +0 -0
  21. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/tools/import_extractor.py +0 -0
  22. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext/tools/system.py +0 -0
  23. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext.egg-info/SOURCES.txt +0 -0
  24. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext.egg-info/dependency_links.txt +0 -0
  25. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext.egg-info/entry_points.txt +0 -0
  26. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext.egg-info/requires.txt +0 -0
  27. {codegraphcontext-0.1.4 → codegraphcontext-0.1.5}/src/codegraphcontext.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraphcontext
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: An MCP server that indexes local code into a graph database to provide context to AI assistants.
5
5
  Author-email: Shashank Shekhar Singh <shashankshekharsingh1205@gmail.com>
6
6
  License: MIT License
@@ -123,6 +123,12 @@ Once the server is running, you can interact with it through your AI assistant u
123
123
  OR
124
124
  - "Keep the code graph updated for the project I'm working on at `~/dev/main-app`."
125
125
 
126
+ When you ask to watch a directory, the system performs two actions at once:
127
+ 1. It kicks off a full scan to index all the code in that directory. This process runs in the background, and you'll receive a `job_id` to track its progress.
128
+ 2. It begins watching the directory for any file changes to keep the graph updated in real-time.
129
+
130
+ This means you can start by simply telling the system to watch a directory, and it will handle both the initial indexing and the continuous updates automatically.
131
+
126
132
  ### Querying and Understanding Code
127
133
 
128
134
  - **Finding where code is defined:**
@@ -72,6 +72,12 @@ Once the server is running, you can interact with it through your AI assistant u
72
72
  OR
73
73
  - "Keep the code graph updated for the project I'm working on at `~/dev/main-app`."
74
74
 
75
+ When you ask to watch a directory, the system performs two actions at once:
76
+ 1. It kicks off a full scan to index all the code in that directory. This process runs in the background, and you'll receive a `job_id` to track its progress.
77
+ 2. It begins watching the directory for any file changes to keep the graph updated in real-time.
78
+
79
+ This means you can start by simply telling the system to watch a directory, and it will handle both the initial indexing and the continuous updates automatically.
80
+
75
81
  ### Querying and Understanding Code
76
82
 
77
83
  - **Finding where code is defined:**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codegraphcontext"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "An MCP server that indexes local code into a graph database to provide context to AI assistants."
5
5
  authors = [{ name = "Shashank Shekhar Singh", email = "shashankshekharsingh1205@gmail.com" }]
6
6
  readme = "README.md"
@@ -5,6 +5,8 @@ from datetime import datetime, timedelta
5
5
  from dataclasses import dataclass, asdict
6
6
  from enum import Enum
7
7
  from typing import Any, Dict, List, Optional
8
+ from pathlib import Path
9
+
8
10
 
9
11
  class JobStatus(Enum):
10
12
  """Job status enumeration"""
@@ -90,6 +92,23 @@ class JobManager:
90
92
  with self.lock:
91
93
  return list(self.jobs.values())
92
94
 
95
+ def find_active_job_by_path(self, path: str) -> Optional[JobInfo]:
96
+ """Finds the most recent, active job for a given path."""
97
+ with self.lock:
98
+ path_obj = Path(path).resolve()
99
+
100
+ matching_jobs = sorted(
101
+ [job for job in self.jobs.values() if job.path and Path(job.path).resolve() == path_obj],
102
+ key=lambda j: j.start_time,
103
+ reverse=True
104
+ )
105
+
106
+ for job in matching_jobs:
107
+ if job.status in [JobStatus.PENDING, JobStatus.RUNNING]:
108
+ return job
109
+
110
+ return None
111
+
93
112
  def cleanup_old_jobs(self, max_age_hours: int = 24):
94
113
  """Clean up jobs older than specified hours"""
95
114
  cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
@@ -10,6 +10,7 @@ from watchdog.events import FileSystemEventHandler
10
10
  # The actual object is passed in __init__
11
11
  if typing.TYPE_CHECKING:
12
12
  from codegraphcontext.tools.graph_builder import GraphBuilder
13
+ from codegraphcontext.core.jobs import JobManager
13
14
 
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -69,22 +70,40 @@ class DebouncedEventHandler(FileSystemEventHandler):
69
70
 
70
71
  class CodeWatcher:
71
72
  """Manages file system watching in a background thread."""
72
- def __init__(self, graph_builder: "GraphBuilder"):
73
+ def __init__(self, graph_builder: "GraphBuilder", job_manager: "JobManager"):
73
74
  self.graph_builder = graph_builder
75
+ self.job_manager = job_manager
74
76
  self.observer = Observer()
75
77
  self.watched_paths = set()
76
78
  self.event_handler = DebouncedEventHandler(self.graph_builder)
77
79
 
78
80
  def watch_directory(self, path: str):
79
- """Starts watching a directory if not already watched."""
80
- path = str(Path(path).resolve())
81
- if path in self.watched_paths:
82
- logger.info(f"Path already being watched: {path}")
83
- return
81
+ """
82
+ Starts watching a directory and returns the job ID for the initial scan if found.
83
+ """
84
+ path_obj = Path(path).resolve()
85
+ path_str = str(path_obj)
86
+
87
+ if path_str in self.watched_paths:
88
+ logger.info(f"Path already being watched: {path_str}")
89
+ return {"message": f"Path already being watched: {path_str}"}
84
90
 
85
- self.observer.schedule(self.event_handler, path, recursive=True)
86
- self.watched_paths.add(path)
87
- logger.info(f"Started watching for code changes in: {path}")
91
+ self.observer.schedule(self.event_handler, path_str, recursive=True)
92
+ self.watched_paths.add(path_str)
93
+ logger.info(f"Started watching for code changes in: {path_str}")
94
+
95
+ active_job = self.job_manager.find_active_job_by_path(path_str)
96
+
97
+ response = {
98
+ "message": f"Started watching {path_str} for changes."
99
+ }
100
+ if active_job:
101
+ response["job_id"] = active_job.job_id
102
+ response["message"] += f" An initial scan is in progress under job ID: {active_job.job_id}"
103
+ else:
104
+ response["note"] = "No active initial scan was found for this path. The watcher will only process new changes."
105
+
106
+ return response
88
107
 
89
108
  def start(self):
90
109
  """Starts the observer thread."""
@@ -60,7 +60,7 @@ class MCPServer:
60
60
  self.code_finder = CodeFinder(self.db_manager)
61
61
  self.import_extractor = ImportExtractor()
62
62
 
63
- self.code_watcher = CodeWatcher(self.graph_builder)
63
+ self.code_watcher = CodeWatcher(self.graph_builder, self.job_manager)
64
64
 
65
65
  self._init_tools()
66
66
 
@@ -69,7 +69,7 @@ class MCPServer:
69
69
  self.tools = {
70
70
  "add_code_to_graph": {
71
71
  "name": "add_code_to_graph",
72
- "description": "Add code from a local folder to the graph. Returns a job ID for background processing.",
72
+ "description": "Performs a one-time scan of a local folder to add its code to the graph. Ideal for indexing libraries, dependencies, or projects not being actively modified. Returns a job ID for background processing.",
73
73
  "inputSchema": {
74
74
  "type": "object",
75
75
  "properties": {
@@ -117,7 +117,7 @@ class MCPServer:
117
117
  },
118
118
  "watch_directory": {
119
119
  "name": "watch_directory",
120
- "description": "Start watching a directory for code changes and automatically update the graph.",
120
+ "description": "Performs an initial scan of a directory and then continuously monitors it for changes, automatically keeping the graph up-to-date. Ideal for projects under active development. Returns a job ID for the initial scan.",
121
121
  "inputSchema": {
122
122
  "type": "object",
123
123
  "properties": { "path": {"type": "string", "description": "Path to directory to watch"} },
@@ -166,6 +166,47 @@ class MCPServer:
166
166
  "properties": {},
167
167
  "additionalProperties": False
168
168
  }
169
+ },
170
+ "calculate_cyclomatic_complexity": {
171
+ "name": "calculate_cyclomatic_complexity",
172
+ "description": "Calculate the cyclomatic complexity of a specific function to measure its complexity.",
173
+ "inputSchema": {
174
+ "type": "object",
175
+ "properties": {
176
+ "function_name": {"type": "string", "description": "The name of the function to analyze."},
177
+ "file_path": {"type": "string", "description": "Optional: The full path to the file containing the function for a more specific query."}
178
+ },
179
+ "required": ["function_name"]
180
+ }
181
+ },
182
+ "find_most_complex_functions": {
183
+ "name": "find_most_complex_functions",
184
+ "description": "Find the most complex functions in the codebase based on cyclomatic complexity.",
185
+ "inputSchema": {
186
+ "type": "object",
187
+ "properties": {
188
+ "limit": {"type": "integer", "description": "The maximum number of complex functions to return.", "default": 10}
189
+ }
190
+ }
191
+ },
192
+ "list_indexed_repositories": {
193
+ "name": "list_indexed_repositories",
194
+ "description": "List all indexed repositories.",
195
+ "inputSchema": {
196
+ "type": "object",
197
+ "properties": {}
198
+ }
199
+ },
200
+ "delete_repository": {
201
+ "name": "delete_repository",
202
+ "description": "Delete an indexed repository from the graph.",
203
+ "inputSchema": {
204
+ "type": "object",
205
+ "properties": {
206
+ "repo_path": {"type": "string", "description": "The path of the repository to delete."}
207
+ },
208
+ "required": ["repo_path"]
209
+ }
169
210
  }
170
211
  # Other tools like list_imports, add_package_to_graph can be added here following the same pattern
171
212
  }
@@ -276,6 +317,70 @@ class MCPServer:
276
317
  debug_log(f"Error finding dead code: {str(e)}")
277
318
  return {"error": f"Failed to find dead code: {str(e)}"}
278
319
 
320
+ def calculate_cyclomatic_complexity_tool(self, **args) -> Dict[str, Any]:
321
+ """Tool to calculate cyclomatic complexity for a given function."""
322
+ function_name = args.get("function_name")
323
+ file_path = args.get("file_path")
324
+
325
+ try:
326
+ debug_log(f"Calculating cyclomatic complexity for function: {function_name}")
327
+ results = self.code_finder.get_cyclomatic_complexity(function_name, file_path)
328
+
329
+ response = {
330
+ "success": True,
331
+ "function_name": function_name,
332
+ "results": results
333
+ }
334
+ if file_path:
335
+ response["file_path"] = file_path
336
+
337
+ return response
338
+ except Exception as e:
339
+ debug_log(f"Error calculating cyclomatic complexity: {str(e)}")
340
+ return {"error": f"Failed to calculate cyclomatic complexity: {str(e)}"}
341
+
342
+ def find_most_complex_functions_tool(self, **args) -> Dict[str, Any]:
343
+ """Tool to find the most complex functions."""
344
+ limit = args.get("limit", 10)
345
+ try:
346
+ debug_log(f"Finding the top {limit} most complex functions.")
347
+ results = self.code_finder.find_most_complex_functions(limit)
348
+ return {
349
+ "success": True,
350
+ "limit": limit,
351
+ "results": results
352
+ }
353
+ except Exception as e:
354
+ debug_log(f"Error finding most complex functions: {str(e)}")
355
+ return {"error": f"Failed to find most complex functions: {str(e)}"}
356
+
357
+ def list_indexed_repositories_tool(self, **args) -> Dict[str, Any]:
358
+ """Tool to list indexed repositories."""
359
+ try:
360
+ debug_log("Listing indexed repositories.")
361
+ results = self.code_finder.list_indexed_repositories()
362
+ return {
363
+ "success": True,
364
+ "repositories": results
365
+ }
366
+ except Exception as e:
367
+ debug_log(f"Error listing indexed repositories: {str(e)}")
368
+ return {"error": f"Failed to list indexed repositories: {str(e)}"}
369
+
370
+ def delete_repository_tool(self, **args) -> Dict[str, Any]:
371
+ """Tool to delete a repository from the graph."""
372
+ repo_path = args.get("repo_path")
373
+ try:
374
+ debug_log(f"Deleting repository: {repo_path}")
375
+ self.graph_builder.delete_repository_from_graph(repo_path)
376
+ return {
377
+ "success": True,
378
+ "message": f"Repository '{repo_path}' deleted successfully."
379
+ }
380
+ except Exception as e:
381
+ debug_log(f"Error deleting repository: {str(e)}")
382
+ return {"error": f"Failed to delete repository: {str(e)}"}
383
+
279
384
  def watch_directory_tool(self, **args) -> Dict[str, Any]:
280
385
  """Tool to start watching a directory."""
281
386
  path = args.get("path")
@@ -283,17 +388,23 @@ class MCPServer:
283
388
  return {"error": f"Invalid path provided: {path}. Must be a directory."}
284
389
 
285
390
  try:
286
- initial_scan_result = self.add_code_to_graph_tool(path=path, is_dependency=False)
287
- if "error" in initial_scan_result:
288
- return initial_scan_result
391
+ # First, ensure the code is added/scanned
392
+ scan_job_result = self.add_code_to_graph_tool(path=path, is_dependency=False)
393
+ if "error" in scan_job_result:
394
+ return scan_job_result
289
395
 
290
- self.code_watcher.watch_directory(path)
396
+ # Now, start the watcher
397
+ watch_result = self.code_watcher.watch_directory(path)
291
398
 
292
- return {
399
+ # Combine results
400
+ final_result = {
293
401
  "success": True,
294
- "message": f"Initial scan started (Job ID: {initial_scan_result.get('job_id')}). Now watching for live changes in {path}.",
295
- "instructions": "Changes to .py files in this directory will now be automatically updated in the graph."
402
+ "message": f"Initial scan started for {path}. Now watching for live changes.",
403
+ "job_id": scan_job_result.get("job_id"),
404
+ "details": watch_result
296
405
  }
406
+ return final_result
407
+
297
408
  except Exception as e:
298
409
  logger.error(f"Failed to start watching directory {path}: {e}")
299
410
  return {"error": f"Failed to start watching directory: {str(e)}"}
@@ -555,7 +666,11 @@ class MCPServer:
555
666
  "execute_cypher_query": self.execute_cypher_query_tool,
556
667
  "add_code_to_graph": self.add_code_to_graph_tool,
557
668
  "check_job_status": self.check_job_status_tool,
558
- "list_jobs": self.list_jobs_tool
669
+ "list_jobs": self.list_jobs_tool,
670
+ "calculate_cyclomatic_complexity": self.calculate_cyclomatic_complexity_tool,
671
+ "find_most_complex_functions": self.find_most_complex_functions_tool,
672
+ "list_indexed_repositories": self.list_indexed_repositories_tool,
673
+ "delete_repository": self.delete_repository_tool
559
674
  }
560
675
  handler = tool_map.get(tool_name)
561
676
  if handler:
@@ -522,6 +522,14 @@ class CodeFinder:
522
522
  "summary": f"Found {len(results['potentially_unused_functions'])} potentially unused functions"
523
523
  }
524
524
 
525
+ elif query_type == "find_complexity":
526
+ limit = int(context) if context and context.isdigit() else 10
527
+ results = self.find_most_complex_functions(limit)
528
+ return {
529
+ "query_type": "find_complexity", "limit": limit, "results": results,
530
+ "summary": f"Found the top {len(results)} most complex functions"
531
+ }
532
+
525
533
  elif query_type in ["call_chain", "path", "chain"]:
526
534
  if '->' in target:
527
535
  start_func, end_func = target.split('->', 1)
@@ -556,7 +564,7 @@ class CodeFinder:
556
564
  "supported_types": [
557
565
  "find_callers", "find_callees", "find_importers", "who_modifies",
558
566
  "class_hierarchy", "overrides", "dead_code", "call_chain",
559
- "module_deps", "variable_scope"
567
+ "module_deps", "variable_scope", "find_complexity"
560
568
  ]
561
569
  }
562
570
 
@@ -566,3 +574,46 @@ class CodeFinder:
566
574
  "query_type": query_type,
567
575
  "target": target
568
576
  }
577
+
578
+ def get_cyclomatic_complexity(self, function_name: str, file_path: str = None) -> List[Dict]:
579
+ """Get the cyclomatic complexity of a function."""
580
+ with self.driver.session() as session:
581
+ if file_path:
582
+ # Use ENDS WITH for flexible path matching
583
+ query = """
584
+ MATCH (f:Function {name: $function_name})
585
+ WHERE f.file_path ENDS WITH $file_path
586
+ RETURN f.name as function_name, f.file_path as file_path, f.cyclomatic_complexity as complexity
587
+ """
588
+ result = session.run(query, function_name=function_name, file_path=file_path)
589
+ else:
590
+ query = """
591
+ MATCH (f:Function {name: $function_name})
592
+ RETURN f.name as function_name, f.file_path as file_path, f.cyclomatic_complexity as complexity
593
+ """
594
+ result = session.run(query, function_name=function_name)
595
+
596
+ return [dict(record) for record in result]
597
+
598
+ def find_most_complex_functions(self, limit: int = 10) -> List[Dict]:
599
+ """Find the most complex functions based on cyclomatic complexity."""
600
+ with self.driver.session() as session:
601
+ query = """
602
+ MATCH (f:Function)
603
+ WHERE f.cyclomatic_complexity IS NOT NULL
604
+ RETURN f.name as function_name, f.file_path as file_path, f.cyclomatic_complexity as complexity, f.line_number as line_number
605
+ ORDER BY f.cyclomatic_complexity DESC
606
+ LIMIT $limit
607
+ """
608
+ result = session.run(query, limit=limit)
609
+ return [dict(record) for record in result]
610
+
611
+ def list_indexed_repositories(self) -> List[Dict]:
612
+ """List all indexed repositories."""
613
+ with self.driver.session() as session:
614
+ result = session.run("""
615
+ MATCH (r:Repository)
616
+ RETURN r.name as name, r.path as path, r.is_dependency as is_dependency
617
+ ORDER BY r.name
618
+ """)
619
+ return [dict(record) for record in result]
@@ -23,6 +23,68 @@ def debug_log(message):
23
23
  f.flush()
24
24
 
25
25
 
26
+ class CyclomaticComplexityVisitor(ast.NodeVisitor):
27
+ """Calculates cyclomatic complexity for a given AST node."""
28
+ def __init__(self):
29
+ self.complexity = 1
30
+
31
+ def visit_If(self, node):
32
+ self.complexity += 1
33
+ self.generic_visit(node)
34
+
35
+ def visit_For(self, node):
36
+ self.complexity += 1
37
+ self.generic_visit(node)
38
+
39
+ def visit_While(self, node):
40
+ self.complexity += 1
41
+ self.generic_visit(node)
42
+
43
+ def visit_With(self, node):
44
+ self.complexity += len(node.items)
45
+ self.generic_visit(node)
46
+
47
+ def visit_AsyncFor(self, node):
48
+ self.complexity += 1
49
+ self.generic_visit(node)
50
+
51
+ def visit_AsyncWith(self, node):
52
+ self.complexity += len(node.items)
53
+ self.generic_visit(node)
54
+
55
+ def visit_ExceptHandler(self, node):
56
+ self.complexity += 1
57
+ self.generic_visit(node)
58
+
59
+ def visit_BoolOp(self, node):
60
+ self.complexity += len(node.values) - 1
61
+ self.generic_visit(node)
62
+
63
+ def visit_ListComp(self, node):
64
+ self.complexity += len(node.generators)
65
+ self.generic_visit(node)
66
+
67
+ def visit_SetComp(self, node):
68
+ self.complexity += len(node.generators)
69
+ self.generic_visit(node)
70
+
71
+ def visit_DictComp(self, node):
72
+ self.complexity += len(node.generators)
73
+ self.generic_visit(node)
74
+
75
+ def visit_GeneratorExp(self, node):
76
+ self.complexity += len(node.generators)
77
+ self.generic_visit(node)
78
+
79
+ def visit_IfExp(self, node):
80
+ self.complexity += 1
81
+ self.generic_visit(node)
82
+
83
+ def visit_match_case(self, node):
84
+ self.complexity += 1
85
+ self.generic_visit(node)
86
+
87
+
26
88
  class CodeVisitor(ast.NodeVisitor):
27
89
  """Enhanced AST visitor to extract code elements with better function call detection"""
28
90
 
@@ -62,6 +124,9 @@ class CodeVisitor(ast.NodeVisitor):
62
124
 
63
125
  def visit_FunctionDef(self, node):
64
126
  """Visit function definitions"""
127
+ complexity_visitor = CyclomaticComplexityVisitor()
128
+ complexity_visitor.visit(node)
129
+
65
130
  func_data = {
66
131
  "name": node.name,
67
132
  "line_number": node.lineno,
@@ -76,6 +141,7 @@ class CodeVisitor(ast.NodeVisitor):
76
141
  ast.unparse(dec) if hasattr(ast, "unparse") else ""
77
142
  for dec in node.decorator_list
78
143
  ],
144
+ "cyclomatic_complexity": complexity_visitor.complexity,
79
145
  }
80
146
  self.functions.append(func_data)
81
147
  self._push_context(node.name, "function", node.lineno)
@@ -236,6 +302,7 @@ class GraphBuilder:
236
302
  try:
237
303
  session.run("CREATE CONSTRAINT repository_path IF NOT EXISTS FOR (r:Repository) REQUIRE r.path IS UNIQUE")
238
304
  session.run("CREATE CONSTRAINT file_path IF NOT EXISTS FOR (f:File) REQUIRE f.path IS UNIQUE")
305
+ session.run("CREATE CONSTRAINT directory_path IF NOT EXISTS FOR (d:Directory) REQUIRE d.path IS UNIQUE")
239
306
  session.run("CREATE CONSTRAINT function_unique IF NOT EXISTS FOR (f:Function) REQUIRE (f.name, f.file_path, f.line_number) IS UNIQUE")
240
307
  session.run("CREATE CONSTRAINT class_unique IF NOT EXISTS FOR (c:Class) REQUIRE (c.name, c.file_path, c.line_number) IS UNIQUE")
241
308
  session.run("CREATE CONSTRAINT variable_unique IF NOT EXISTS FOR (v:Variable) REQUIRE (v.name, v.file_path, v.line_number) IS UNIQUE")
@@ -280,12 +347,42 @@ class GraphBuilder:
280
347
  except ValueError:
281
348
  relative_path = file_name
282
349
 
350
+ # Create/Merge the file node
283
351
  session.run("""
284
- MATCH (r:Repository {name: $repo_name})
285
352
  MERGE (f:File {path: $path})
286
353
  SET f.name = $name, f.relative_path = $relative_path, f.is_dependency = $is_dependency
287
- MERGE (r)-[:CONTAINS]->(f)
288
- """, repo_name=repo_name, path=file_path_str, name=file_name, relative_path=relative_path, is_dependency=is_dependency)
354
+ """, path=file_path_str, name=file_name, relative_path=relative_path, is_dependency=is_dependency)
355
+
356
+ # Create directory structure and link it
357
+ file_path_obj = Path(file_path_str)
358
+ repo_path_obj = Path(repo_result['path'])
359
+
360
+ relative_path_to_file = file_path_obj.relative_to(repo_path_obj)
361
+
362
+ parent_path = str(repo_path_obj)
363
+ parent_label = 'Repository'
364
+
365
+ # Create nodes for each directory part of the path
366
+ for part in relative_path_to_file.parts[:-1]: # For each directory in the path
367
+ current_path = Path(parent_path) / part
368
+ current_path_str = str(current_path)
369
+
370
+ session.run(f"""
371
+ MATCH (p:{parent_label} {{path: $parent_path}})
372
+ MERGE (d:Directory {{path: $current_path}})
373
+ SET d.name = $part
374
+ MERGE (p)-[:CONTAINS]->(d)
375
+ """, parent_path=parent_path, current_path=current_path_str, part=part)
376
+
377
+ parent_path = current_path_str
378
+ parent_label = 'Directory'
379
+
380
+ # Link the last directory/repository to the file
381
+ session.run(f"""
382
+ MATCH (p:{parent_label} {{path: $parent_path}})
383
+ MATCH (f:File {{path: $file_path}})
384
+ MERGE (p)-[:CONTAINS]->(f)
385
+ """, parent_path=parent_path, file_path=file_path_str)
289
386
 
290
387
  for item_data, label in [(file_data['functions'], 'Function'), (file_data['classes'], 'Class'), (file_data['variables'], 'Variable')]:
291
388
  for item in item_data:
@@ -444,6 +541,14 @@ class GraphBuilder:
444
541
  """Deletes a file and all its contained elements and relationships."""
445
542
  file_path_str = str(Path(file_path).resolve())
446
543
  with self.driver.session() as session:
544
+ # Get parent directories
545
+ parents_res = session.run("""
546
+ MATCH (f:File {path: $path})<-[:CONTAINS*]-(d:Directory)
547
+ RETURN d.path as path ORDER BY length(d.path) DESC
548
+ """, path=file_path_str)
549
+ parent_paths = [record["path"] for record in parents_res]
550
+
551
+ # Delete the file and its contents
447
552
  session.run(
448
553
  """
449
554
  MATCH (f:File {path: $path})
@@ -454,6 +559,25 @@ class GraphBuilder:
454
559
  )
455
560
  logger.info(f"Deleted file and its elements from graph: {file_path_str}")
456
561
 
562
+ # Clean up empty parent directories, starting from the deepest
563
+ for path in parent_paths:
564
+ session.run("""
565
+ MATCH (d:Directory {path: $path})
566
+ WHERE NOT (d)-[:CONTAINS]->()
567
+ DETACH DELETE d
568
+ """, path=path)
569
+
570
+ def delete_repository_from_graph(self, repo_path: str):
571
+ """Deletes a repository and all its contents from the graph."""
572
+ repo_path_str = str(Path(repo_path).resolve())
573
+ with self.driver.session() as session:
574
+ session.run("""
575
+ MATCH (r:Repository {path: $path})
576
+ OPTIONAL MATCH (r)-[:CONTAINS*]->(e)
577
+ DETACH DELETE r, e
578
+ """, path=repo_path_str)
579
+ logger.info(f"Deleted repository and its contents from graph: {repo_path_str}")
580
+
457
581
  def update_file_in_graph(self, file_path: Path):
458
582
  """Updates a file by deleting and re-adding it."""
459
583
  file_path_str = str(file_path.resolve())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codegraphcontext
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: An MCP server that indexes local code into a graph database to provide context to AI assistants.
5
5
  Author-email: Shashank Shekhar Singh <shashankshekharsingh1205@gmail.com>
6
6
  License: MIT License
@@ -123,6 +123,12 @@ Once the server is running, you can interact with it through your AI assistant u
123
123
  OR
124
124
  - "Keep the code graph updated for the project I'm working on at `~/dev/main-app`."
125
125
 
126
+ When you ask to watch a directory, the system performs two actions at once:
127
+ 1. It kicks off a full scan to index all the code in that directory. This process runs in the background, and you'll receive a `job_id` to track its progress.
128
+ 2. It begins watching the directory for any file changes to keep the graph updated in real-time.
129
+
130
+ This means you can start by simply telling the system to watch a directory, and it will handle both the initial indexing and the continuous updates automatically.
131
+
126
132
  ### Querying and Understanding Code
127
133
 
128
134
  - **Finding where code is defined:**