codegraphcontext-rust 0.5.5__cp311-cp311-win_amd64.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 (101) hide show
  1. codegraphcontext/__init__.py +2 -0
  2. codegraphcontext/__main__.py +5 -0
  3. codegraphcontext/_cgc_rust.cp311-win_amd64.pyd +0 -0
  4. codegraphcontext/cli/__init__.py +1 -0
  5. codegraphcontext/cli/cli_helpers.py +774 -0
  6. codegraphcontext/cli/config_manager.py +1051 -0
  7. codegraphcontext/cli/main.py +2386 -0
  8. codegraphcontext/cli/registry_commands.py +404 -0
  9. codegraphcontext/cli/setup_macos.py +93 -0
  10. codegraphcontext/cli/setup_wizard.py +991 -0
  11. codegraphcontext/cli/visualizer.py +50 -0
  12. codegraphcontext/core/__init__.py +165 -0
  13. codegraphcontext/core/bundle_registry.py +181 -0
  14. codegraphcontext/core/cgc_bundle.py +858 -0
  15. codegraphcontext/core/cgcignore.py +118 -0
  16. codegraphcontext/core/database.py +273 -0
  17. codegraphcontext/core/database_falkordb.py +480 -0
  18. codegraphcontext/core/database_falkordb_remote.py +199 -0
  19. codegraphcontext/core/database_kuzu.py +636 -0
  20. codegraphcontext/core/falkor_worker.py +132 -0
  21. codegraphcontext/core/jobs.py +132 -0
  22. codegraphcontext/core/watcher.py +260 -0
  23. codegraphcontext/prompts.py +125 -0
  24. codegraphcontext/server.py +471 -0
  25. codegraphcontext/tool_definitions.py +220 -0
  26. codegraphcontext/tools/__init__.py +0 -0
  27. codegraphcontext/tools/advanced_language_query_tool.py +103 -0
  28. codegraphcontext/tools/code_finder.py +1160 -0
  29. codegraphcontext/tools/graph_builder.py +337 -0
  30. codegraphcontext/tools/handlers/analysis_handlers.py +114 -0
  31. codegraphcontext/tools/handlers/indexing_handlers.py +116 -0
  32. codegraphcontext/tools/handlers/management_handlers.py +339 -0
  33. codegraphcontext/tools/handlers/query_handlers.py +83 -0
  34. codegraphcontext/tools/handlers/watcher_handlers.py +83 -0
  35. codegraphcontext/tools/indexing/__init__.py +1 -0
  36. codegraphcontext/tools/indexing/constants.py +25 -0
  37. codegraphcontext/tools/indexing/discovery.py +64 -0
  38. codegraphcontext/tools/indexing/engine.py +109 -0
  39. codegraphcontext/tools/indexing/persistence/__init__.py +3 -0
  40. codegraphcontext/tools/indexing/persistence/parquet_loader.py +643 -0
  41. codegraphcontext/tools/indexing/persistence/writer.py +1114 -0
  42. codegraphcontext/tools/indexing/pipeline.py +171 -0
  43. codegraphcontext/tools/indexing/pre_scan.py +105 -0
  44. codegraphcontext/tools/indexing/resolution/__init__.py +8 -0
  45. codegraphcontext/tools/indexing/resolution/calls.py +253 -0
  46. codegraphcontext/tools/indexing/resolution/inheritance.py +91 -0
  47. codegraphcontext/tools/indexing/sanitize.py +41 -0
  48. codegraphcontext/tools/indexing/schema.py +79 -0
  49. codegraphcontext/tools/indexing/schema_contract.py +44 -0
  50. codegraphcontext/tools/indexing/scip_pipeline.py +140 -0
  51. codegraphcontext/tools/languages/c.py +563 -0
  52. codegraphcontext/tools/languages/cpp.py +616 -0
  53. codegraphcontext/tools/languages/csharp.py +551 -0
  54. codegraphcontext/tools/languages/dart.py +378 -0
  55. codegraphcontext/tools/languages/elixir.py +460 -0
  56. codegraphcontext/tools/languages/go.py +499 -0
  57. codegraphcontext/tools/languages/haskell.py +426 -0
  58. codegraphcontext/tools/languages/java.py +500 -0
  59. codegraphcontext/tools/languages/javascript.py +589 -0
  60. codegraphcontext/tools/languages/kotlin.py +640 -0
  61. codegraphcontext/tools/languages/perl.py +260 -0
  62. codegraphcontext/tools/languages/php.py +519 -0
  63. codegraphcontext/tools/languages/python.py +576 -0
  64. codegraphcontext/tools/languages/ruby.py +537 -0
  65. codegraphcontext/tools/languages/rust.py +295 -0
  66. codegraphcontext/tools/languages/scala.py +519 -0
  67. codegraphcontext/tools/languages/swift.py +491 -0
  68. codegraphcontext/tools/languages/typescript.py +575 -0
  69. codegraphcontext/tools/languages/typescriptjsx.py +151 -0
  70. codegraphcontext/tools/package_resolver.py +472 -0
  71. codegraphcontext/tools/query_tool_languages/c_toolkit.py +5 -0
  72. codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +87 -0
  73. codegraphcontext/tools/query_tool_languages/csharp_toolkit.py +5 -0
  74. codegraphcontext/tools/query_tool_languages/dart_toolkit.py +5 -0
  75. codegraphcontext/tools/query_tool_languages/go_toolkit.py +5 -0
  76. codegraphcontext/tools/query_tool_languages/haskell_toolkit.py +5 -0
  77. codegraphcontext/tools/query_tool_languages/java_toolkit.py +5 -0
  78. codegraphcontext/tools/query_tool_languages/javascript_toolkit.py +5 -0
  79. codegraphcontext/tools/query_tool_languages/perl_toolkit.py +5 -0
  80. codegraphcontext/tools/query_tool_languages/python_toolkit.py +5 -0
  81. codegraphcontext/tools/query_tool_languages/ruby_toolkit.py +5 -0
  82. codegraphcontext/tools/query_tool_languages/rust_toolkit.py +5 -0
  83. codegraphcontext/tools/query_tool_languages/scala_toolkit.py +5 -0
  84. codegraphcontext/tools/query_tool_languages/swift_toolkit.py +5 -0
  85. codegraphcontext/tools/query_tool_languages/typescript_toolkit.py +5 -0
  86. codegraphcontext/tools/scip_indexer.py +467 -0
  87. codegraphcontext/tools/scip_pb2.py +2455 -0
  88. codegraphcontext/tools/system.py +133 -0
  89. codegraphcontext/tools/tree_sitter_parser.py +103 -0
  90. codegraphcontext/utils/debug_log.py +90 -0
  91. codegraphcontext/utils/path_ignore.py +55 -0
  92. codegraphcontext/utils/repo_path.py +27 -0
  93. codegraphcontext/utils/tree_sitter_manager.py +285 -0
  94. codegraphcontext/utils/visualize_graph.py +0 -0
  95. codegraphcontext/viz/server.py +282 -0
  96. codegraphcontext_rust-0.5.5.dist-info/METADATA +537 -0
  97. codegraphcontext_rust-0.5.5.dist-info/RECORD +101 -0
  98. codegraphcontext_rust-0.5.5.dist-info/WHEEL +4 -0
  99. codegraphcontext_rust-0.5.5.dist-info/entry_points.txt +3 -0
  100. codegraphcontext_rust-0.5.5.dist-info/licenses/LICENSE +21 -0
  101. codegraphcontext_rust-0.5.5.dist-info/sboms/cgc-python.cyclonedx.json +2333 -0
@@ -0,0 +1,858 @@
1
+ # src/codegraphcontext/core/cgc_bundle.py
2
+ """
3
+ This module handles the creation and loading of .cgc (CodeGraphContext Bundle) files.
4
+
5
+ A .cgc file is a portable, pre-indexed graph snapshot that can be distributed and loaded
6
+ instantly without re-indexing. This enables:
7
+ - Pre-indexing famous repositories once
8
+ - Distributing graph knowledge as artifacts
9
+ - Instant context loading for LLMs
10
+ - Version-controlled code knowledge
11
+
12
+ Bundle Structure:
13
+ .cgc (ZIP archive)
14
+ ├── metadata.json # Repository and indexing metadata
15
+ ├── schema.json # Graph schema definition
16
+ ├── nodes.jsonl # All nodes (one JSON object per line)
17
+ ├── edges.jsonl # All relationships (one JSON object per line)
18
+ ├── stats.json # Graph statistics
19
+ └── README.md # Human-readable description
20
+ """
21
+
22
+ import json
23
+ import zipfile
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Dict, List, Optional, Any, Tuple
27
+ from datetime import datetime, date
28
+ import subprocess
29
+
30
+ from codegraphcontext.utils.debug_log import debug_log, info_logger, error_logger, warning_logger
31
+
32
+
33
+ class _BundleEncoder(json.JSONEncoder):
34
+ """Handles Neo4j DateTime and other non-standard types for bundle serialization."""
35
+ def default(self, obj):
36
+ if isinstance(obj, (datetime, date)):
37
+ return obj.isoformat()
38
+ if hasattr(obj, 'isoformat'):
39
+ return obj.isoformat()
40
+ if hasattr(obj, 'iso_format'):
41
+ return obj.iso_format()
42
+ if isinstance(obj, Path):
43
+ return str(obj)
44
+ if isinstance(obj, set):
45
+ return list(obj)
46
+ if isinstance(obj, bytes):
47
+ return obj.decode('utf-8', errors='replace')
48
+ return super().default(obj)
49
+
50
+
51
+ class CGCBundle:
52
+ """Handles creation and loading of .cgc bundle files."""
53
+
54
+ VERSION = "0.1.0" # CGC bundle format version
55
+
56
+ def __init__(self, db_manager):
57
+ """
58
+ Initialize the CGC Bundle handler.
59
+
60
+ Args:
61
+ db_manager: DatabaseManager instance for graph queries
62
+ """
63
+ self.db_manager = db_manager
64
+
65
+ def _get_id_function(self) -> str:
66
+ """
67
+ Get the appropriate ID function based on the database backend.
68
+
69
+ Returns:
70
+ str: 'elementId' for Neo4j, 'id' for FalkorDB
71
+ """
72
+ # Check if we're using Neo4j or FalkorDB
73
+ backend = self.db_manager.get_backend_type()
74
+ if backend == 'neo4j':
75
+ return 'elementId'
76
+ else: # FalkorDB or other backends
77
+ return 'id'
78
+
79
+
80
+ def export_to_bundle(
81
+ self,
82
+ output_path: Path,
83
+ repo_path: Optional[Path] = None,
84
+ include_stats: bool = True
85
+ ) -> Tuple[bool, str]:
86
+ """
87
+ Export the current graph (or a specific repository) to a .cgc bundle.
88
+
89
+ Args:
90
+ output_path: Path where the .cgc file should be saved
91
+ repo_path: Optional specific repository path to export (None = export all)
92
+ include_stats: Whether to include detailed statistics
93
+
94
+ Returns:
95
+ Tuple[bool, str]: (success, message)
96
+ """
97
+ try:
98
+ info_logger(f"Starting export to {output_path}")
99
+
100
+ # Ensure output path has .cgc extension
101
+ if not str(output_path).endswith('.cgc'):
102
+ output_path = Path(str(output_path) + '.cgc')
103
+
104
+ # Create temporary directory for bundle contents
105
+ with tempfile.TemporaryDirectory() as temp_dir:
106
+ temp_path = Path(temp_dir)
107
+
108
+ # Step 1: Extract metadata
109
+ info_logger("Extracting metadata...")
110
+ metadata = self._extract_metadata(repo_path)
111
+ with open(temp_path / "metadata.json", 'w') as f:
112
+ json.dump(metadata, f, indent=2, cls=_BundleEncoder)
113
+
114
+ # Step 2: Extract schema
115
+ info_logger("Extracting schema...")
116
+ schema = self._extract_schema()
117
+ with open(temp_path / "schema.json", 'w') as f:
118
+ json.dump(schema, f, indent=2, cls=_BundleEncoder)
119
+
120
+ # Step 3: Extract nodes
121
+ info_logger("Extracting nodes...")
122
+ node_count = self._extract_nodes(temp_path / "nodes.jsonl", repo_path)
123
+
124
+ # Step 4: Extract edges
125
+ info_logger("Extracting edges...")
126
+ edge_count = self._extract_edges(temp_path / "edges.jsonl", repo_path)
127
+
128
+ # Step 5: Generate statistics
129
+ if include_stats:
130
+ info_logger("Generating statistics...")
131
+ stats = self._generate_stats(repo_path, node_count, edge_count)
132
+ with open(temp_path / "stats.json", 'w') as f:
133
+ json.dump(stats, f, indent=2, cls=_BundleEncoder)
134
+
135
+ # Step 6: Create README
136
+ self._create_readme(temp_path / "README.md", metadata, stats if include_stats else None)
137
+
138
+ # Step 7: Create ZIP archive
139
+ info_logger("Creating bundle archive...")
140
+ self._create_zip(temp_path, output_path)
141
+
142
+ success_msg = f"✅ Successfully exported to {output_path}\n"
143
+ success_msg += f" Nodes: {node_count:,} | Edges: {edge_count:,}"
144
+ info_logger(success_msg)
145
+ return True, success_msg
146
+
147
+ except Exception as e:
148
+ import traceback
149
+ error_msg = f"Failed to export bundle: {str(e)}"
150
+ error_logger(error_msg)
151
+ # Print full traceback for debugging
152
+ traceback.print_exc()
153
+ return False, error_msg
154
+
155
+ def import_from_bundle(
156
+ self,
157
+ bundle_path: Path,
158
+ clear_existing: bool = False,
159
+ readonly: bool = False
160
+ ) -> Tuple[bool, str]:
161
+ """
162
+ Import a .cgc bundle into the current database.
163
+
164
+ Args:
165
+ bundle_path: Path to the .cgc file
166
+ clear_existing: Whether to clear existing graph data first
167
+ readonly: If True, mount as read-only (future feature)
168
+
169
+ Returns:
170
+ Tuple[bool, str]: (success, message)
171
+ """
172
+ try:
173
+ info_logger(f"Starting import from {bundle_path}")
174
+
175
+ if not bundle_path.exists():
176
+ return False, f"Bundle file not found: {bundle_path}"
177
+
178
+ # Extract bundle to temporary directory
179
+ with tempfile.TemporaryDirectory() as temp_dir:
180
+ temp_path = Path(temp_dir)
181
+
182
+ # Step 1: Extract ZIP (with Zip Slip protection)
183
+ info_logger("Extracting bundle...")
184
+ with zipfile.ZipFile(bundle_path, 'r') as zip_ref:
185
+ for entry in zip_ref.namelist():
186
+ resolved = (temp_path / entry).resolve()
187
+ if not str(resolved).startswith(str(temp_path.resolve())):
188
+ return False, f"Zip Slip detected: entry '{entry}' escapes target directory"
189
+ zip_ref.extractall(temp_path)
190
+
191
+ # Step 2: Validate bundle
192
+ info_logger("Validating bundle...")
193
+ is_valid, validation_msg = self._validate_bundle(temp_path)
194
+ if not is_valid:
195
+ return False, f"Invalid bundle: {validation_msg}"
196
+
197
+ # Step 3: Load metadata
198
+ with open(temp_path / "metadata.json", 'r') as f:
199
+ metadata = json.load(f)
200
+
201
+ info_logger(f"Loading bundle: {metadata.get('repo', 'unknown')}")
202
+ info_logger(f"Bundle version: {metadata.get('cgc_version', 'unknown')}")
203
+
204
+ # Step 4: Handle existing data
205
+ repo_name = metadata.get('repo', 'unknown')
206
+ repo_path = metadata.get('repo_path')
207
+
208
+ if clear_existing:
209
+ # User explicitly wants to clear - remove everything
210
+ info_logger("Clearing all existing graph data...")
211
+ self._clear_graph()
212
+ else:
213
+ # Check if this repository already exists (only when NOT clearing)
214
+ existing_repo = self._check_existing_repository(repo_name, repo_path)
215
+
216
+ if existing_repo:
217
+ return False, f"Repository '{repo_name}' already exists in the database. Use clear_existing=True to replace it."
218
+
219
+
220
+ # Step 5: Create schema
221
+ info_logger("Creating schema...")
222
+ self._import_schema(temp_path / "schema.json")
223
+
224
+ # Step 6: Import nodes
225
+ info_logger("Importing nodes...")
226
+ node_count = self._import_nodes(temp_path / "nodes.jsonl")
227
+
228
+ # Step 7: Import edges
229
+ info_logger("Importing edges...")
230
+ edge_count = self._import_edges(temp_path / "edges.jsonl")
231
+
232
+ success_msg = f"✅ Successfully imported {bundle_path.name}\n"
233
+ success_msg += f" Repository: {metadata.get('repo', 'unknown')}\n"
234
+ success_msg += f" Nodes: {node_count:,} | Edges: {edge_count:,}"
235
+ info_logger(success_msg)
236
+ return True, success_msg
237
+
238
+ except Exception as e:
239
+ error_msg = f"Failed to import bundle: {str(e)}"
240
+ error_logger(error_msg)
241
+ return False, error_msg
242
+
243
+ # ========================================================================
244
+ # EXPORT HELPERS
245
+ # ========================================================================
246
+
247
+ def _extract_metadata(self, repo_path: Optional[Path]) -> Dict[str, Any]:
248
+ """Extract metadata about the repository and indexing process."""
249
+ metadata = {
250
+ "cgc_version": self.VERSION,
251
+ "exported_at": datetime.now().isoformat(),
252
+ "format_version": "1.0"
253
+ }
254
+
255
+ # Get repository information
256
+ with self.db_manager.get_driver().session() as session:
257
+ if repo_path:
258
+ # Specific repository
259
+ result = session.run(
260
+ "MATCH (r:Repository {path: $path}) RETURN r",
261
+ path=str(repo_path.resolve())
262
+ )
263
+ repo_node = result.single()
264
+ if repo_node:
265
+ node = repo_node['r']
266
+ # Convert Node to dict (handle both Neo4j and FalkorDB)
267
+ try:
268
+ repo = dict(node)
269
+ except TypeError:
270
+ # FalkorDB nodes - access properties directly
271
+ repo = {}
272
+ if hasattr(node, '_properties'):
273
+ repo = dict(node._properties)
274
+ elif hasattr(node, 'properties'):
275
+ repo = dict(node.properties)
276
+ else:
277
+ # Fallback: try to get individual properties
278
+ for attr in ['name', 'path', 'is_dependency']:
279
+ if hasattr(node, attr):
280
+ repo[attr] = getattr(node, attr)
281
+
282
+ metadata["repo"] = repo.get('name', str(repo_path))
283
+ metadata["repo_path"] = repo.get('path')
284
+ metadata["is_dependency"] = repo.get('is_dependency', False)
285
+ else:
286
+ # All repositories
287
+ result = session.run(
288
+ "MATCH (r:Repository) RETURN r.name as name, r.path as path"
289
+ )
290
+ repos = [{"name": record["name"], "path": record["path"]} for record in result]
291
+ metadata["repositories"] = repos
292
+ metadata["repo"] = "multiple" if len(repos) > 1 else repos[0]["name"] if repos else "unknown"
293
+
294
+ # Try to get git information if available
295
+ if repo_path and repo_path.exists():
296
+ try:
297
+ commit = subprocess.check_output(
298
+ ['git', 'rev-parse', 'HEAD'],
299
+ cwd=repo_path,
300
+ stderr=subprocess.DEVNULL
301
+ ).decode().strip()
302
+ metadata["commit"] = commit[:8]
303
+ except (subprocess.CalledProcessError, FileNotFoundError):
304
+ pass
305
+
306
+ try:
307
+ result = session.run("""
308
+ MATCH (f:File)
309
+ WHERE f.path STARTS WITH $repo_path
310
+ RETURN f.language as language, count(*) as count
311
+ ORDER BY count DESC
312
+ """, repo_path=str(repo_path.resolve()))
313
+ languages = {record["language"]: record["count"] for record in result if record["language"]}
314
+ metadata["languages"] = list(languages.keys())
315
+ except Exception:
316
+ pass
317
+
318
+ return metadata
319
+
320
+ def _extract_schema(self) -> Dict[str, Any]:
321
+ """Extract the graph schema (node labels, relationship types, constraints)."""
322
+ schema = {
323
+ "node_labels": [],
324
+ "relationship_types": [],
325
+ "constraints": [],
326
+ "indexes": []
327
+ }
328
+
329
+ with self.db_manager.get_driver().session() as session:
330
+ # Get node labels
331
+ try:
332
+ result = session.run("CALL db.labels()")
333
+ labels = []
334
+ for record in result:
335
+ try:
336
+ labels.append(record[0])
337
+ except (KeyError, TypeError):
338
+ if hasattr(record, 'values'):
339
+ vals = list(record.values())
340
+ if vals:
341
+ labels.append(vals[0])
342
+ schema["node_labels"] = labels
343
+ except Exception:
344
+ schema["node_labels"] = []
345
+
346
+ # Get relationship types
347
+ try:
348
+ result = session.run("CALL db.relationshipTypes()")
349
+ rel_types = []
350
+ for record in result:
351
+ try:
352
+ rel_types.append(record[0])
353
+ except (KeyError, TypeError):
354
+ if hasattr(record, 'values'):
355
+ vals = list(record.values())
356
+ if vals:
357
+ rel_types.append(vals[0])
358
+ schema["relationship_types"] = rel_types
359
+ except Exception:
360
+ schema["relationship_types"] = []
361
+
362
+ # Get constraints (Neo4j specific, may not work on all backends)
363
+ try:
364
+ result = session.run("SHOW CONSTRAINTS")
365
+ schema["constraints"] = [dict(record) for record in result]
366
+ except:
367
+ pass
368
+
369
+ # Get indexes
370
+ try:
371
+ result = session.run("SHOW INDEXES")
372
+ schema["indexes"] = [dict(record) for record in result]
373
+ except:
374
+ pass
375
+
376
+ return schema
377
+
378
+ def _extract_nodes(self, output_file: Path, repo_path: Optional[Path]) -> int:
379
+ """Extract all nodes to JSONL format."""
380
+ count = 0
381
+
382
+ with self.db_manager.get_driver().session() as session:
383
+ # Build query based on repo_path
384
+ if repo_path:
385
+ query = """
386
+ MATCH (n)
387
+ WHERE n.path STARTS WITH $repo_path
388
+ RETURN n, labels(n) as labels
389
+ """
390
+ params = {"repo_path": str(repo_path.resolve())}
391
+ else:
392
+ query = "MATCH (n) RETURN n, labels(n) as labels"
393
+ params = {}
394
+
395
+ # Run query with proper parameter handling for both Neo4j and FalkorDB
396
+ try:
397
+ result = session.run(query, **params)
398
+ except TypeError:
399
+ # FalkorDB might not support **params, try without
400
+ result = session.run(query)
401
+
402
+ with open(output_file, 'w') as f:
403
+ for record in result:
404
+ node = record['n']
405
+ labels = record['labels']
406
+
407
+ # Convert node to dict (handle both Neo4j and FalkorDB)
408
+ try:
409
+ node_dict = dict(node)
410
+ except TypeError:
411
+ # FalkorDB nodes might not be directly convertible
412
+ node_dict = {}
413
+ if hasattr(node, '_properties'):
414
+ node_dict = dict(node._properties)
415
+ elif hasattr(node, 'properties'):
416
+ node_dict = dict(node.properties)
417
+
418
+ node_dict['_labels'] = labels
419
+
420
+ # Store internal ID for reference
421
+ if hasattr(node, 'element_id'):
422
+ node_dict['_id'] = node.element_id
423
+ elif hasattr(node, 'id'):
424
+ node_dict['_id'] = str(node.id)
425
+
426
+ f.write(json.dumps(node_dict, cls=_BundleEncoder) + '\n')
427
+ count += 1
428
+
429
+ return count
430
+
431
+ def _extract_edges(self, output_file: Path, repo_path: Optional[Path]) -> int:
432
+ """Extract all relationships to JSONL format."""
433
+ count = 0
434
+
435
+ with self.db_manager.get_driver().session() as session:
436
+ # Build query based on repo_path
437
+ if repo_path:
438
+ query = """
439
+ MATCH (n)-[r]->(m)
440
+ WHERE (n.path STARTS WITH $repo_path)
441
+ OR (m.path STARTS WITH $repo_path)
442
+ RETURN n, r, m, type(r) as rel_type
443
+ """
444
+ params = {"repo_path": str(repo_path.resolve())}
445
+ else:
446
+ query = "MATCH (n)-[r]->(m) RETURN n, r, m, type(r) as rel_type"
447
+ params = {}
448
+
449
+ # Run query with proper parameter handling for both Neo4j and FalkorDB
450
+ try:
451
+ result = session.run(query, **params)
452
+ except TypeError:
453
+ # FalkorDB might not support **params, try without
454
+ result = session.run(query)
455
+
456
+ with open(output_file, 'w') as f:
457
+ for record in result:
458
+ source = record['n']
459
+ target = record['m']
460
+ rel = record['r']
461
+ rel_type = record['rel_type']
462
+
463
+ # Get source and target IDs (handle both Neo4j and FalkorDB)
464
+ if hasattr(source, 'element_id'):
465
+ from_id = source.element_id
466
+ elif hasattr(source, 'id'):
467
+ from_id = str(source.id)
468
+ else:
469
+ from_id = str(id(source)) # Fallback
470
+
471
+ if hasattr(target, 'element_id'):
472
+ to_id = target.element_id
473
+ elif hasattr(target, 'id'):
474
+ to_id = str(target.id)
475
+ else:
476
+ to_id = str(id(target)) # Fallback
477
+
478
+ # Get relationship properties
479
+ try:
480
+ rel_props = dict(rel)
481
+ except TypeError:
482
+ rel_props = {}
483
+ if hasattr(rel, '_properties'):
484
+ rel_props = dict(rel._properties)
485
+ elif hasattr(rel, 'properties'):
486
+ rel_props = dict(rel.properties)
487
+
488
+ # Create edge representation
489
+ edge_dict = {
490
+ 'from': from_id,
491
+ 'to': to_id,
492
+ 'type': rel_type,
493
+ 'properties': rel_props
494
+ }
495
+
496
+ f.write(json.dumps(edge_dict, cls=_BundleEncoder) + '\n')
497
+ count += 1
498
+
499
+ return count
500
+
501
+ def _generate_stats(self, repo_path: Optional[Path], node_count: int, edge_count: int) -> Dict[str, Any]:
502
+ """Generate statistics about the graph."""
503
+ stats = {
504
+ "total_nodes": node_count,
505
+ "total_edges": edge_count,
506
+ "generated_at": datetime.now().isoformat()
507
+ }
508
+
509
+ with self.db_manager.get_driver().session() as session:
510
+ # Count by node type
511
+ result = session.run("""
512
+ MATCH (n)
513
+ RETURN labels(n)[0] as label, count(*) as count
514
+ ORDER BY count DESC
515
+ """)
516
+ stats["nodes_by_type"] = {record["label"]: record["count"] for record in result if record["label"]}
517
+
518
+ # Count by relationship type
519
+ result = session.run("""
520
+ MATCH ()-[r]->()
521
+ RETURN type(r) as type, count(*) as count
522
+ ORDER BY count DESC
523
+ """)
524
+ stats["edges_by_type"] = {record["type"]: record["count"] for record in result}
525
+
526
+ # File count
527
+ if repo_path:
528
+ result = session.run(
529
+ "MATCH (f:File) WHERE f.path STARTS WITH $repo_path RETURN count(f) as count",
530
+ repo_path=str(repo_path.resolve())
531
+ )
532
+ else:
533
+ result = session.run("MATCH (f:File) RETURN count(f) as count")
534
+
535
+ file_count = result.single()
536
+ stats["files"] = file_count["count"] if file_count else 0
537
+
538
+ return stats
539
+
540
+ def _create_readme(self, output_file: Path, metadata: Dict, stats: Optional[Dict]):
541
+ """Create a human-readable README for the bundle."""
542
+ readme_content = f"""# CodeGraphContext Bundle
543
+
544
+ ## Repository Information
545
+ - **Repository**: {metadata.get('repo', 'Unknown')}
546
+ - **Exported**: {metadata.get('exported_at', 'Unknown')}
547
+ - **CGC Version**: {metadata.get('cgc_version', 'Unknown')}
548
+ """
549
+
550
+ if 'commit' in metadata:
551
+ readme_content += f"- **Commit**: {metadata['commit']}\n"
552
+
553
+ if 'languages' in metadata:
554
+ readme_content += f"- **Languages**: {', '.join(metadata['languages'])}\n"
555
+
556
+ if stats:
557
+ readme_content += f"""
558
+ ## Statistics
559
+ - **Total Nodes**: {stats.get('total_nodes', 0):,}
560
+ - **Total Edges**: {stats.get('total_edges', 0):,}
561
+ - **Files**: {stats.get('files', 0):,}
562
+
563
+ ### Nodes by Type
564
+ """
565
+ for label, count in stats.get('nodes_by_type', {}).items():
566
+ readme_content += f"- {label}: {count:,}\n"
567
+
568
+ readme_content += "\n### Edges by Type\n"
569
+ for rel_type, count in stats.get('edges_by_type', {}).items():
570
+ readme_content += f"- {rel_type}: {count:,}\n"
571
+
572
+ readme_content += """
573
+ ## Usage
574
+
575
+ Load this bundle with:
576
+ ```bash
577
+ cgc load <bundle-file>.cgc
578
+ ```
579
+
580
+ Or import into existing graph:
581
+ ```bash
582
+ cgc import <bundle-file>.cgc
583
+ ```
584
+ """
585
+
586
+ with open(output_file, 'w') as f:
587
+ f.write(readme_content)
588
+
589
+ def _create_zip(self, source_dir: Path, output_file: Path):
590
+ """Create a ZIP archive from the bundle directory."""
591
+ with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
592
+ for path in source_dir.rglob('*'):
593
+ if path.is_file():
594
+ arcname = path.relative_to(source_dir)
595
+ zipf.write(path, arcname)
596
+
597
+ # ========================================================================
598
+ # IMPORT HELPERS
599
+ # ========================================================================
600
+
601
+ def _validate_bundle(self, bundle_dir: Path) -> Tuple[bool, str]:
602
+ """Validate that the bundle contains all required files."""
603
+ required_files = ['metadata.json', 'schema.json', 'nodes.jsonl', 'edges.jsonl']
604
+
605
+ for file_name in required_files:
606
+ if not (bundle_dir / file_name).exists():
607
+ return False, f"Missing required file: {file_name}"
608
+
609
+ # Validate metadata
610
+ try:
611
+ with open(bundle_dir / "metadata.json", 'r') as f:
612
+ metadata = json.load(f)
613
+ if 'cgc_version' not in metadata:
614
+ return False, "Invalid metadata: missing cgc_version"
615
+ except json.JSONDecodeError as e:
616
+ return False, f"Invalid metadata.json: {e}"
617
+
618
+ return True, "Valid bundle"
619
+
620
+ def _check_existing_repository(self, repo_name: str, repo_path: Optional[str]) -> bool:
621
+ """Check if a repository already exists in the database."""
622
+ with self.db_manager.get_driver().session() as session:
623
+ # Try to find by name first
624
+ result = session.run(
625
+ "MATCH (r:Repository {name: $name}) RETURN r LIMIT 1",
626
+ name=repo_name
627
+ )
628
+ if result.single():
629
+ return True
630
+
631
+ # If repo_path is provided, also check by path
632
+ if repo_path:
633
+ result = session.run(
634
+ "MATCH (r:Repository {path: $path}) RETURN r LIMIT 1",
635
+ path=repo_path
636
+ )
637
+ if result.single():
638
+ return True
639
+
640
+ return False
641
+
642
+ def _delete_repository(self, repo_identifier: str):
643
+ """Delete a specific repository and all its related nodes from the graph."""
644
+ with self.db_manager.get_driver().session() as session:
645
+ # First, try to find the repository by name or path
646
+ result = session.run("""
647
+ MATCH (r:Repository)
648
+ WHERE r.name = $identifier OR r.path = $identifier
649
+ RETURN r.path as path
650
+ LIMIT 1
651
+ """, identifier=repo_identifier)
652
+
653
+ record = result.single()
654
+ if not record:
655
+ warning_logger(f"Repository '{repo_identifier}' not found for deletion")
656
+ return
657
+
658
+ repo_path = record['path']
659
+
660
+ # Delete all nodes that belong to this repository
661
+ # Files, Functions, Classes, Modules all have paths that start with repo_path
662
+ session.run("""
663
+ MATCH (n)
664
+ WHERE n.path STARTS WITH $repo_path
665
+ DETACH DELETE n
666
+ """, repo_path=repo_path)
667
+
668
+ # Delete the repository node itself
669
+ session.run("""
670
+ MATCH (r:Repository)
671
+ WHERE r.path = $repo_path
672
+ DELETE r
673
+ """, repo_path=repo_path)
674
+
675
+ info_logger(f"Deleted repository: {repo_identifier}")
676
+
677
+ def _clear_graph(self):
678
+ """Clear all nodes and relationships from the graph in batches."""
679
+ with self.db_manager.get_driver().session() as session:
680
+ while True:
681
+ result = session.run(
682
+ "MATCH (n) WITH n LIMIT 500 DETACH DELETE n RETURN count(n) as deleted"
683
+ )
684
+ record = result.single()
685
+ if not record or record["deleted"] == 0:
686
+ break
687
+
688
+ def _import_schema(self, schema_file: Path):
689
+ """Import schema (constraints and indexes)."""
690
+ with open(schema_file, 'r') as f:
691
+ schema = json.load(f)
692
+
693
+ # Note: Schema import is complex and database-specific
694
+ # For now, we'll rely on the application to create the schema
695
+ # This is a placeholder for future enhancement
696
+ debug_log("Schema import not yet implemented - relying on application schema")
697
+
698
+ def _import_nodes(self, nodes_file: Path) -> int:
699
+ """Import nodes from JSONL file."""
700
+ count = 0
701
+ batch_size = 1000
702
+ batch = []
703
+
704
+ # Create a mapping from old IDs to new IDs
705
+ id_mapping = {}
706
+
707
+ with self.db_manager.get_driver().session() as session:
708
+ with open(nodes_file, 'r') as f:
709
+ for line in f:
710
+ node_data = json.loads(line)
711
+
712
+ # Extract labels and old ID (handle both Neo4j and KuzuDB formats)
713
+ labels = node_data.pop('_labels', None) or node_data.pop('_label', None) or []
714
+ if isinstance(labels, str):
715
+ labels = [labels]
716
+ old_id = node_data.pop('_id', None)
717
+ # Convert dict IDs to hashable tuples for mapping
718
+ if isinstance(old_id, dict):
719
+ old_id = (old_id.get('table', 0), old_id.get('offset', 0))
720
+
721
+ # Remove internal properties
722
+ node_data.pop('_element_id', None)
723
+
724
+ batch.append((labels, node_data, old_id))
725
+
726
+ if len(batch) >= batch_size:
727
+ count += self._import_node_batch(session, batch, id_mapping)
728
+ batch = []
729
+
730
+ # Import remaining nodes
731
+ if batch:
732
+ count += self._import_node_batch(session, batch, id_mapping)
733
+
734
+ # Store ID mapping for edge import
735
+ self._id_mapping = id_mapping
736
+
737
+ return count
738
+
739
+ _PK_MAP = {
740
+ 'Repository': 'path', 'File': 'path', 'Directory': 'path',
741
+ 'Module': 'name',
742
+ 'Function': 'uid', 'Class': 'uid', 'Variable': 'uid',
743
+ 'Trait': 'uid', 'Interface': 'uid', 'Macro': 'uid',
744
+ 'Struct': 'uid', 'Enum': 'uid', 'Union': 'uid',
745
+ 'Annotation': 'uid', 'Record': 'uid', 'Property': 'uid',
746
+ 'Parameter': 'uid',
747
+ }
748
+ _UID_PARTS = {
749
+ 'Function': ['name', 'path', 'line_number'],
750
+ 'Class': ['name', 'path', 'line_number'],
751
+ 'Variable': ['name', 'path', 'line_number'],
752
+ 'Trait': ['name', 'path', 'line_number'],
753
+ 'Interface': ['name', 'path', 'line_number'],
754
+ 'Macro': ['name', 'path', 'line_number'],
755
+ 'Struct': ['name', 'path', 'line_number'],
756
+ 'Enum': ['name', 'path', 'line_number'],
757
+ 'Union': ['name', 'path', 'line_number'],
758
+ 'Annotation': ['name', 'path', 'line_number'],
759
+ 'Record': ['name', 'path', 'line_number'],
760
+ 'Property': ['name', 'path', 'line_number'],
761
+ 'Parameter': ['name', 'path', 'function_line_number'],
762
+ }
763
+
764
+ def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> int:
765
+ """Import a batch of nodes."""
766
+ id_function = self._get_id_function()
767
+
768
+ for labels, properties, old_id in batch:
769
+ if not labels:
770
+ continue
771
+
772
+ if isinstance(labels, str):
773
+ labels = [labels]
774
+ label_str = ':'.join(labels)
775
+ primary_label = labels[0]
776
+
777
+ pk_field = self._PK_MAP.get(primary_label)
778
+ if pk_field == 'uid' and 'uid' not in properties:
779
+ parts = self._UID_PARTS.get(primary_label, [])
780
+ properties['uid'] = ''.join(str(properties.get(p, '')) for p in parts)
781
+
782
+ if pk_field and pk_field in properties:
783
+ pk_val = properties[pk_field]
784
+ remaining = {k: v for k, v in properties.items() if k != pk_field}
785
+ query = (
786
+ f"MERGE (n:{label_str} {{{pk_field}: $pk_val}}) "
787
+ f"SET n += $props RETURN {id_function}(n) as new_id"
788
+ )
789
+ result = session.run(query, pk_val=pk_val, props=remaining)
790
+ else:
791
+ query = f"CREATE (n:{label_str}) SET n = $props RETURN {id_function}(n) as new_id"
792
+ result = session.run(query, props=properties)
793
+
794
+ record = result.single()
795
+ if record and old_id:
796
+ id_mapping[old_id] = record['new_id']
797
+
798
+ return len(batch)
799
+
800
+ def _import_edges(self, edges_file: Path) -> int:
801
+ """Import edges from JSONL file."""
802
+ count = 0
803
+ batch_size = 1000
804
+ batch = []
805
+
806
+ with self.db_manager.get_driver().session() as session:
807
+ with open(edges_file, 'r') as f:
808
+ for line in f:
809
+ edge_data = json.loads(line)
810
+ batch.append(edge_data)
811
+
812
+ if len(batch) >= batch_size:
813
+ count += self._import_edge_batch(session, batch)
814
+ batch = []
815
+
816
+ # Import remaining edges
817
+ if batch:
818
+ count += self._import_edge_batch(session, batch)
819
+
820
+ return count
821
+
822
+ def _import_edge_batch(self, session, batch: List[Dict]) -> int:
823
+ """Import a batch of edges."""
824
+ id_mapping = getattr(self, '_id_mapping', {})
825
+ # Detect database backend to use appropriate ID function
826
+ id_function = self._get_id_function()
827
+
828
+ for edge in batch:
829
+ old_from = edge.get('from')
830
+ old_to = edge.get('to')
831
+ # Convert dict IDs to hashable tuples (matches node import conversion)
832
+ if isinstance(old_from, dict):
833
+ old_from = (old_from.get('table', 0), old_from.get('offset', 0))
834
+ if isinstance(old_to, dict):
835
+ old_to = (old_to.get('table', 0), old_to.get('offset', 0))
836
+ rel_type = edge.get('type')
837
+ properties = edge.get('properties', {})
838
+
839
+ # Map old IDs to new IDs
840
+ new_from = id_mapping.get(old_from)
841
+ new_to = id_mapping.get(old_to)
842
+
843
+ if not new_from or not new_to:
844
+ warning_logger(f"Skipping edge: node IDs not found in mapping")
845
+ continue
846
+
847
+ # Create relationship
848
+ query = f"""
849
+ MATCH (a), (b)
850
+ WHERE {id_function}(a) = $from_id AND {id_function}(b) = $to_id
851
+ CREATE (a)-[r:{rel_type}]->(b)
852
+ SET r = $props
853
+ """
854
+
855
+ session.run(query, from_id=new_from, to_id=new_to, props=properties)
856
+
857
+ return len(batch)
858
+