codegraph-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. codegraph_cli/__init__.py +4 -0
  2. codegraph_cli/agents.py +191 -0
  3. codegraph_cli/bug_detector.py +386 -0
  4. codegraph_cli/chat_agent.py +352 -0
  5. codegraph_cli/chat_session.py +220 -0
  6. codegraph_cli/cli.py +330 -0
  7. codegraph_cli/cli_chat.py +367 -0
  8. codegraph_cli/cli_diagnose.py +133 -0
  9. codegraph_cli/cli_refactor.py +230 -0
  10. codegraph_cli/cli_setup.py +470 -0
  11. codegraph_cli/cli_test.py +177 -0
  12. codegraph_cli/cli_v2.py +267 -0
  13. codegraph_cli/codegen_agent.py +265 -0
  14. codegraph_cli/config.py +31 -0
  15. codegraph_cli/config_manager.py +341 -0
  16. codegraph_cli/context_manager.py +500 -0
  17. codegraph_cli/crew_agents.py +123 -0
  18. codegraph_cli/crew_chat.py +159 -0
  19. codegraph_cli/crew_tools.py +497 -0
  20. codegraph_cli/diff_engine.py +265 -0
  21. codegraph_cli/embeddings.py +241 -0
  22. codegraph_cli/graph_export.py +144 -0
  23. codegraph_cli/llm.py +642 -0
  24. codegraph_cli/models.py +47 -0
  25. codegraph_cli/models_v2.py +185 -0
  26. codegraph_cli/orchestrator.py +49 -0
  27. codegraph_cli/parser.py +800 -0
  28. codegraph_cli/performance_analyzer.py +223 -0
  29. codegraph_cli/project_context.py +230 -0
  30. codegraph_cli/rag.py +200 -0
  31. codegraph_cli/refactor_agent.py +452 -0
  32. codegraph_cli/security_scanner.py +366 -0
  33. codegraph_cli/storage.py +390 -0
  34. codegraph_cli/templates/graph_interactive.html +257 -0
  35. codegraph_cli/testgen_agent.py +316 -0
  36. codegraph_cli/validation_engine.py +285 -0
  37. codegraph_cli/vector_store.py +293 -0
  38. codegraph_cli-2.0.0.dist-info/METADATA +318 -0
  39. codegraph_cli-2.0.0.dist-info/RECORD +43 -0
  40. codegraph_cli-2.0.0.dist-info/WHEEL +5 -0
  41. codegraph_cli-2.0.0.dist-info/entry_points.txt +2 -0
  42. codegraph_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
  43. codegraph_cli-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,452 @@
1
+ """RefactorAgent for safe, dependency-aware code refactoring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import List, Optional, Set
9
+
10
+ from .diff_engine import DiffEngine
11
+ from .models_v2 import FileChange, Location, Range, RefactorPlan
12
+ from .storage import GraphStore
13
+
14
+
15
+ class RefactorAgent:
16
+ """Performs safe refactoring with automatic dependency tracking."""
17
+
18
+ def __init__(self, store: GraphStore, diff_engine: Optional[DiffEngine] = None):
19
+ """Initialize RefactorAgent.
20
+
21
+ Args:
22
+ store: Graph store for dependency tracking
23
+ diff_engine: Engine for managing diffs (optional)
24
+ """
25
+ self.store = store
26
+ self.diff_engine = diff_engine or DiffEngine()
27
+
28
+ def rename_symbol(self, old_name: str, new_name: str) -> RefactorPlan:
29
+ """Rename a symbol and update all references.
30
+
31
+ Args:
32
+ old_name: Current symbol name
33
+ new_name: New symbol name
34
+
35
+ Returns:
36
+ RefactorPlan with all necessary changes
37
+ """
38
+ # Find the symbol in the graph
39
+ node = self.store.get_node(old_name)
40
+ if not node:
41
+ raise ValueError(f"Symbol '{old_name}' not found in project")
42
+
43
+ # Find all call sites
44
+ call_sites = self.find_call_sites(old_name)
45
+
46
+ # Create changes for renaming
47
+ changes = []
48
+ files_to_update = set()
49
+
50
+ # Add the definition file
51
+ files_to_update.add(node["file_path"])
52
+
53
+ # Add all call site files
54
+ for location in call_sites:
55
+ files_to_update.add(location.file_path)
56
+
57
+ # Generate changes for each file
58
+ # Get project root from metadata
59
+ metadata = self.store.get_metadata()
60
+ project_root = Path(metadata.get("project_root", "."))
61
+
62
+ for file_path in files_to_update:
63
+ # Make path absolute
64
+ abs_path = project_root / file_path if not Path(file_path).is_absolute() else Path(file_path)
65
+
66
+ if not abs_path.exists():
67
+ continue # Skip non-existent files
68
+
69
+ original_content = abs_path.read_text()
70
+ new_content = self._rename_in_file(original_content, old_name, new_name)
71
+
72
+ if original_content != new_content:
73
+ changes.append(FileChange(
74
+ file_path=str(abs_path),
75
+ change_type="modify",
76
+ original_content=original_content,
77
+ new_content=new_content,
78
+ diff=self.diff_engine.create_diff(original_content, new_content, str(abs_path))
79
+ ))
80
+
81
+ return RefactorPlan(
82
+ refactor_type="rename",
83
+ description=f"Rename '{old_name}' to '{new_name}'",
84
+ source_locations=[Location(node["file_path"], node["start_line"])],
85
+ target_location=Location(node["file_path"], node["start_line"]),
86
+ call_sites=call_sites,
87
+ changes=changes
88
+ )
89
+
90
+ def extract_function(
91
+ self,
92
+ file_path: str,
93
+ start_line: int,
94
+ end_line: int,
95
+ function_name: str
96
+ ) -> RefactorPlan:
97
+ """Extract code range into a new function.
98
+
99
+ Args:
100
+ file_path: File containing code to extract
101
+ start_line: Start line of code to extract
102
+ end_line: End line of code to extract
103
+ function_name: Name for the new function
104
+
105
+ Returns:
106
+ RefactorPlan with extraction changes
107
+ """
108
+ file_path_obj = Path(file_path)
109
+ if not file_path_obj.exists():
110
+ raise ValueError(f"File not found: {file_path}")
111
+
112
+ original_content = file_path_obj.read_text()
113
+ lines = original_content.splitlines(keepends=True)
114
+
115
+ # Extract the code block
116
+ extracted_lines = lines[start_line - 1:end_line]
117
+ extracted_code = "".join(extracted_lines)
118
+
119
+ # Analyze variables and detect parameters
120
+ indent = self._get_indent(extracted_lines[0]) if extracted_lines else " "
121
+ params = self._detect_parameters(extracted_code, original_content, start_line, end_line)
122
+ has_return = self._has_return_statement(extracted_code)
123
+
124
+ # Create new function with detected parameters
125
+ param_str = ", ".join(params) if params else ""
126
+ new_function = f"def {function_name}({param_str}):\n"
127
+ new_function += f"{indent}\"\"\"Extracted function.\"\"\"\n"
128
+ new_function += extracted_code
129
+
130
+ # Only add return None if no return statements found
131
+ if not has_return:
132
+ new_function += f"{indent}return None\n"
133
+ new_function += "\n\n"
134
+
135
+ # Replace extracted code with function call
136
+ call_args = ", ".join(params) if params else ""
137
+ if has_return:
138
+ replacement_line = f"{indent}result = {function_name}({call_args})\n"
139
+ replacement_line += f"{indent}if result:\n"
140
+ replacement_line += f"{indent} return result\n"
141
+ else:
142
+ replacement_line = f"{indent}{function_name}({call_args})\n"
143
+
144
+ # Find insertion point (before containing function)
145
+ insertion_line = self._find_function_start(lines, start_line)
146
+
147
+ # Build new content
148
+ new_lines = lines[:insertion_line]
149
+ new_lines.append(new_function)
150
+ new_lines.extend(lines[insertion_line:start_line - 1])
151
+ new_lines.append(replacement_line)
152
+ new_lines.extend(lines[end_line:])
153
+
154
+ new_content = "".join(new_lines)
155
+
156
+ changes = [FileChange(
157
+ file_path=file_path,
158
+ change_type="modify",
159
+ original_content=original_content,
160
+ new_content=new_content,
161
+ diff=self.diff_engine.create_diff(original_content, new_content, file_path)
162
+ )]
163
+
164
+ return RefactorPlan(
165
+ refactor_type="extract-function",
166
+ description=f"Extract lines {start_line}-{end_line} to function '{function_name}'",
167
+ source_locations=[Location(file_path, start_line)],
168
+ target_location=Location(file_path, start_line - 1),
169
+ call_sites=[],
170
+ changes=changes
171
+ )
172
+
173
+ def extract_service(
174
+ self,
175
+ symbols: List[str],
176
+ target_file: str
177
+ ) -> RefactorPlan:
178
+ """Extract multiple functions to a new service file.
179
+
180
+ Args:
181
+ symbols: List of function names to extract
182
+ target_file: Path to new service file
183
+
184
+ Returns:
185
+ RefactorPlan with extraction changes
186
+ """
187
+ changes = []
188
+ source_locations = []
189
+ all_call_sites = []
190
+
191
+ # Collect all functions to extract
192
+ functions_code = []
193
+ source_files = set()
194
+
195
+ for symbol in symbols:
196
+ node = self.store.get_node(symbol)
197
+ if not node:
198
+ raise ValueError(f"Symbol '{symbol}' not found")
199
+
200
+ source_locations.append(Location(node["file_path"], node["start_line"]))
201
+ source_files.add(node["file_path"])
202
+
203
+ # Get function code
204
+ functions_code.append(node["code"])
205
+
206
+ # Find call sites
207
+ call_sites = self.find_call_sites(symbol)
208
+ all_call_sites.extend(call_sites)
209
+
210
+ # Create new service file
211
+ new_service_content = '"""Extracted service module."""\n\n'
212
+ new_service_content += "\n\n".join(functions_code)
213
+
214
+ changes.append(FileChange(
215
+ file_path=target_file,
216
+ change_type="create",
217
+ new_content=new_service_content
218
+ ))
219
+
220
+ # Update source files to remove extracted functions and add imports
221
+ # Get project root from metadata
222
+ metadata = self.store.get_metadata()
223
+ project_root = Path(metadata.get("project_root", "."))
224
+
225
+ for source_file in source_files:
226
+ # Make path absolute
227
+ abs_path = project_root / source_file if not Path(source_file).is_absolute() else Path(source_file)
228
+
229
+ if not abs_path.exists():
230
+ continue
231
+
232
+ original_content = abs_path.read_text()
233
+ new_content = self._remove_functions_and_add_import(
234
+ original_content,
235
+ symbols,
236
+ target_file
237
+ )
238
+
239
+ if original_content != new_content:
240
+ changes.append(FileChange(
241
+ file_path=source_file,
242
+ change_type="modify",
243
+ original_content=original_content,
244
+ new_content=new_content,
245
+ diff=self.diff_engine.create_diff(original_content, new_content, source_file)
246
+ ))
247
+
248
+ # Update call sites to use new import
249
+ call_site_files = {loc.file_path for loc in all_call_sites}
250
+ for call_site_file in call_site_files:
251
+ if call_site_file not in source_files:
252
+ # Make path absolute
253
+ abs_call_path = project_root / call_site_file if not Path(call_site_file).is_absolute() else Path(call_site_file)
254
+
255
+ if not abs_call_path.exists():
256
+ continue
257
+
258
+ original_content = abs_call_path.read_text()
259
+ new_content = self._add_import(original_content, symbols, target_file)
260
+
261
+ if original_content != new_content:
262
+ changes.append(FileChange(
263
+ file_path=call_site_file,
264
+ change_type="modify",
265
+ original_content=original_content,
266
+ new_content=new_content,
267
+ diff=self.diff_engine.create_diff(original_content, new_content, call_site_file)
268
+ ))
269
+
270
+ return RefactorPlan(
271
+ refactor_type="extract-service",
272
+ description=f"Extract {len(symbols)} function(s) to {target_file}",
273
+ source_locations=source_locations,
274
+ target_location=Location(target_file, 1),
275
+ call_sites=all_call_sites,
276
+ changes=changes
277
+ )
278
+
279
+ def find_call_sites(self, symbol: str) -> List[Location]:
280
+ """Find all locations where a symbol is called.
281
+
282
+ Args:
283
+ symbol: Symbol name to find
284
+
285
+ Returns:
286
+ List of locations where symbol is used
287
+ """
288
+ call_sites = []
289
+
290
+ # Use graph to find reverse dependencies
291
+ node = self.store.get_node(symbol)
292
+ if not node:
293
+ return call_sites
294
+
295
+ node_id = node["node_id"]
296
+
297
+ # Find all edges pointing to this node
298
+ # (This is a simplified implementation - would need reverse edge lookup)
299
+ all_nodes = self.store.get_nodes()
300
+
301
+ for other_node in all_nodes:
302
+ # Check if this node has edges to our target
303
+ edges = self.store.neighbors(other_node["node_id"])
304
+ for edge in edges:
305
+ if edge["dst"] == node_id:
306
+ call_sites.append(Location(
307
+ other_node["file_path"],
308
+ other_node["start_line"]
309
+ ))
310
+
311
+ return call_sites
312
+
313
+ def _rename_in_file(self, content: str, old_name: str, new_name: str) -> str:
314
+ """Rename symbol in file content.
315
+
316
+ Args:
317
+ content: File content
318
+ old_name: Old symbol name
319
+ new_name: New symbol name
320
+
321
+ Returns:
322
+ Updated content
323
+ """
324
+ # Simple implementation: replace whole words only
325
+ # In production, would use AST-based renaming
326
+ import re
327
+ pattern = r'\b' + re.escape(old_name) + r'\b'
328
+ return re.sub(pattern, new_name, content)
329
+
330
+ def _get_indent(self, line: str) -> str:
331
+ """Get indentation from a line."""
332
+ return line[:len(line) - len(line.lstrip())]
333
+
334
+ def _remove_functions_and_add_import(
335
+ self,
336
+ content: str,
337
+ symbols: List[str],
338
+ target_file: str
339
+ ) -> str:
340
+ """Remove functions from content and add import."""
341
+ # Simple implementation
342
+ # In production, would use AST manipulation
343
+
344
+ # Add import at top
345
+ module_name = Path(target_file).stem
346
+ import_line = f"from .{module_name} import {', '.join(symbols)}\n"
347
+
348
+ lines = content.splitlines(keepends=True)
349
+
350
+ # Find first non-import line
351
+ insert_pos = 0
352
+ for i, line in enumerate(lines):
353
+ if not line.strip().startswith(('import ', 'from ', '#', '"""', "'''")):
354
+ insert_pos = i
355
+ break
356
+
357
+ lines.insert(insert_pos, import_line)
358
+
359
+ # Remove function definitions (simplified)
360
+ # Would need proper AST-based removal in production
361
+
362
+ return "".join(lines)
363
+
364
+
365
+ def _detect_parameters(self, extracted_code: str, full_content: str, start_line: int, end_line: int) -> List[str]:
366
+ """Detect variables that should be parameters for extracted function.
367
+
368
+ Args:
369
+ extracted_code: Code being extracted
370
+ full_content: Full file content
371
+ start_line: Start line of extraction
372
+ end_line: End line of extraction
373
+
374
+ Returns:
375
+ List of parameter names
376
+ """
377
+ try:
378
+ # Parse extracted code to find used variables
379
+ tree = ast.parse(extracted_code)
380
+ used_names = set()
381
+ defined_names = set()
382
+
383
+ for node in ast.walk(tree):
384
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
385
+ used_names.add(node.id)
386
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
387
+ defined_names.add(node.id)
388
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
389
+ defined_names.add(node.name)
390
+
391
+ # Parameters are variables used but not defined in extracted code
392
+ # Filter out built-ins and common globals
393
+ builtins = {'True', 'False', 'None', 'print', 'len', 'range', 'str', 'int', 'list', 'dict', 'set'}
394
+ params = used_names - defined_names - builtins
395
+
396
+ return sorted(list(params))
397
+ except SyntaxError:
398
+ # If parsing fails, return empty list
399
+ return []
400
+
401
+ def _has_return_statement(self, code: str) -> bool:
402
+ """Check if code contains return statements.
403
+
404
+ Args:
405
+ code: Code to check
406
+
407
+ Returns:
408
+ True if code has return statements
409
+ """
410
+ try:
411
+ tree = ast.parse(code)
412
+ for node in ast.walk(tree):
413
+ if isinstance(node, ast.Return):
414
+ return True
415
+ return False
416
+ except SyntaxError:
417
+ return False
418
+
419
+ def _find_function_start(self, lines: List[str], current_line: int) -> int:
420
+ """Find the start of the containing function.
421
+
422
+ Args:
423
+ lines: All lines in file
424
+ current_line: Current line number (1-indexed)
425
+
426
+ Returns:
427
+ Line number where containing function starts (0-indexed)
428
+ """
429
+ # Search backwards for function definition
430
+ for i in range(current_line - 2, -1, -1):
431
+ line = lines[i].strip()
432
+ if line.startswith('def ') or line.startswith('async def '):
433
+ return i
434
+
435
+ # If no function found, insert at beginning
436
+ return 0
437
+
438
+ def _add_import(self, content: str, symbols: List[str], target_file: str) -> str:
439
+ """Add import statement to content."""
440
+ module_name = Path(target_file).stem
441
+ import_line = f"from .{module_name} import {', '.join(symbols)}\n"
442
+
443
+ lines = content.splitlines(keepends=True)
444
+
445
+ # Find appropriate position for import
446
+ insert_pos = 0
447
+ for i, line in enumerate(lines):
448
+ if line.strip().startswith('from '):
449
+ insert_pos = i + 1
450
+
451
+ lines.insert(insert_pos, import_line)
452
+ return "".join(lines)