raggiecode 0.2.1__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 (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. skills/tool.py +121 -0
@@ -0,0 +1,832 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Code Index SDK for AI Agents
4
+ Provides a high-level API for querying and traversing code indices stored in SQLite.
5
+ """
6
+
7
+ import json
8
+ import sqlite3
9
+ from collections import deque
10
+ from pathlib import Path
11
+ from typing import List, Dict, Optional, Any, Union
12
+ from dataclasses import asdict
13
+
14
+ from indexing.models import (
15
+ Function, Class, File, Dependency,
16
+ )
17
+ from indexing.queries import QueryMixin, DescriptionMixin
18
+
19
+
20
+ class CodeIndexSDK(QueryMixin, DescriptionMixin):
21
+ """
22
+ SDK for querying and traversing code indices.
23
+
24
+ Example usage:
25
+ sdk = CodeIndexSDK("code_index.db")
26
+
27
+ # Get all files
28
+ files = sdk.get_files()
29
+
30
+ # Search for functions by name
31
+ functions = sdk.search_functions("render")
32
+
33
+ # Get a class and its methods
34
+ cls = sdk.get_class_by_name("UserService")
35
+ methods = sdk.get_class_methods(cls.id)
36
+
37
+ # Get all functions in a file
38
+ file_funcs = sdk.get_file_functions(file_id)
39
+ """
40
+
41
+ def __init__(self, db_path: str, root_dir: str = None):
42
+ """Initialize the SDK with a database path and optional root directory for indexing."""
43
+ self.db_path = db_path
44
+ self.root_dir = root_dir
45
+ self.conn = None
46
+ self._connect()
47
+
48
+ def index_directory(self, force_reindex: bool = False, verbose: bool = False):
49
+ """Index the codebase directory into the database.
50
+
51
+ Delegates to the internal CodeIndexer. This is the public API for
52
+ triggering a re-index; external consumers should not import CodeIndexer directly.
53
+ """
54
+ from indexing.code_indexer import CodeIndexer
55
+ indexer = CodeIndexer(
56
+ root_dir=self.root_dir,
57
+ db_path=self.db_path,
58
+ force_reindex=force_reindex,
59
+ verbose=verbose,
60
+ )
61
+ indexer.index_directory()
62
+ self._connect()
63
+
64
+ def _connect(self):
65
+ """Establish database connection. Does not raise if the database does not exist yet
66
+ (it will be created by index_directory)."""
67
+ if not Path(self.db_path).exists():
68
+ self.conn = None
69
+ return
70
+ self.conn = sqlite3.connect(self.db_path, timeout=30) # Increase timeout for concurrent access
71
+ self.conn.row_factory = sqlite3.Row
72
+
73
+ def close(self):
74
+ """Close the database connection."""
75
+ if self.conn:
76
+ self.conn.close()
77
+ self.conn = None
78
+
79
+ def __enter__(self):
80
+ """Context manager entry."""
81
+ return self
82
+
83
+ def __exit__(self, exc_type, exc_val, exc_tb):
84
+ """Context manager exit."""
85
+ self.close()
86
+
87
+ # ==================== Convenience Methods ====================
88
+
89
+ def get_file_summary(self, file_id: int) -> Dict[str, Any]:
90
+ """Get a summary of all code elements in a file."""
91
+ file = self.get_file_by_id(file_id)
92
+ if not file:
93
+ return {}
94
+
95
+ return {
96
+ 'file': asdict(file),
97
+ 'functions': [asdict(f) for f in self.get_file_functions(file_id)],
98
+ 'classes': [asdict(c) for c in self.get_file_classes(file_id)],
99
+ 'variables': [asdict(v) for v in self.get_file_variables(file_id)],
100
+ 'type_aliases': [asdict(t) for t in self.get_type_aliases(file_id)],
101
+ 'structs': [asdict(s) for s in self.get_structs(file_id)],
102
+ 'interfaces': [asdict(i) for i in self.get_interfaces(file_id)]
103
+ }
104
+
105
+ def get_class_summary(self, class_id: int) -> Dict[str, Any]:
106
+ """Get a summary of a class including its methods and variables."""
107
+ cls = self.get_class_by_id(class_id)
108
+ if not cls:
109
+ return {}
110
+
111
+ return {
112
+ 'class': asdict(cls),
113
+ 'methods': [asdict(m) for m in self.get_class_methods(class_id)],
114
+ 'variables': [asdict(v) for v in self.get_class_variables(class_id)],
115
+ 'nested_classes': [asdict(c) for c in self.get_nested_classes(class_id)]
116
+ }
117
+
118
+ def search_all(self, pattern: str) -> Dict[str, List[Dict]]:
119
+ """Search for pattern across all entity types."""
120
+ return {
121
+ 'functions': [asdict(f) for f in self.search_functions(pattern)],
122
+ 'classes': [asdict(c) for c in self.search_classes(pattern)],
123
+ 'variables': [asdict(v) for v in self.search_variables(pattern)],
124
+ 'type_aliases': [asdict(t) for t in self.search_type_aliases(pattern)]
125
+ }
126
+
127
+ def search_symbols(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
128
+ """Search for symbols across functions, classes, and variables by name and description.
129
+
130
+ Uses SQL LIKE for substring matching on both name and description fields.
131
+ Results are ranked: exact name matches first, then partial name matches,
132
+ then description matches.
133
+
134
+ Args:
135
+ query: Search query string
136
+ limit: Maximum number of results to return
137
+
138
+ Returns:
139
+ List of dicts with keys: type, name, file_path, description, match_reason
140
+ """
141
+ cursor = self.conn.cursor()
142
+ like_pattern = f"%{query}%"
143
+ results = []
144
+
145
+ # Exact name matches (highest priority)
146
+ for table, label in [('functions', 'function'), ('classes', 'class'), ('variables', 'variable')]:
147
+ cursor.execute(
148
+ f"SELECT name, file_id, description FROM {table} WHERE name = ? LIMIT ?",
149
+ (query, limit)
150
+ )
151
+ for row in cursor.fetchall():
152
+ file = self.get_file_by_id(row['file_id'])
153
+ if file:
154
+ results.append({
155
+ 'type': label,
156
+ 'name': row['name'],
157
+ 'file_path': file.path,
158
+ 'description': row['description'] if 'description' in row.keys() else None,
159
+ 'match_reason': 'exact name match',
160
+ 'rank': 0,
161
+ })
162
+
163
+ # Partial name matches
164
+ for table, label in [('functions', 'function'), ('classes', 'class'), ('variables', 'variable')]:
165
+ cursor.execute(
166
+ f"SELECT name, file_id, description FROM {table} WHERE name LIKE ? AND name != ? LIMIT ?",
167
+ (like_pattern, query, limit)
168
+ )
169
+ for row in cursor.fetchall():
170
+ file = self.get_file_by_id(row['file_id'])
171
+ if file:
172
+ results.append({
173
+ 'type': label,
174
+ 'name': row['name'],
175
+ 'file_path': file.path,
176
+ 'description': row['description'] if 'description' in row.keys() else None,
177
+ 'match_reason': 'partial name match',
178
+ 'rank': 1,
179
+ })
180
+
181
+ # Description matches (lower priority)
182
+ for table, label in [('functions', 'function'), ('classes', 'class'), ('variables', 'variable')]:
183
+ cursor.execute(
184
+ f"SELECT name, file_id, description FROM {table} WHERE description LIKE ? AND name NOT LIKE ? LIMIT ?",
185
+ (like_pattern, like_pattern, limit)
186
+ )
187
+ for row in cursor.fetchall():
188
+ file = self.get_file_by_id(row['file_id'])
189
+ if file:
190
+ results.append({
191
+ 'type': label,
192
+ 'name': row['name'],
193
+ 'file_path': file.path,
194
+ 'description': row['description'],
195
+ 'match_reason': 'description match',
196
+ 'rank': 2,
197
+ })
198
+
199
+ # Sort by rank, then by name
200
+ results.sort(key=lambda r: (r['rank'], r['name']))
201
+ return results[:limit]
202
+
203
+ def _render_callable_deps(self, lines: List[str], deps: Dict[str, List], indent: str) -> None:
204
+ """Append 'depending on:' lines for a function/method's grouped deps."""
205
+ lines.append(f'{indent}depending on:')
206
+ dep_indent = indent + ' '
207
+ seen_deps: set = set()
208
+
209
+ type_labels = [
210
+ ('function_call', 'function', '()'),
211
+ ('method_call', 'method', '()'),
212
+ ('class_reference', 'class', ''),
213
+ ('variable_reference', 'variable', ''),
214
+ ]
215
+ resolvers = {
216
+ 'function_call': self._resolve_function_definition,
217
+ 'method_call': self._resolve_method_definition,
218
+ 'class_reference': self._resolve_class_definition,
219
+ 'variable_reference': self._resolve_variable_definition,
220
+ }
221
+
222
+ for dep_type, label, suffix in type_labels:
223
+ for dep in deps[dep_type]:
224
+ if self._is_likely_literal(dep.name):
225
+ continue
226
+ dep_key = f"{label}:{dep.name}"
227
+ if dep_key in seen_deps:
228
+ continue
229
+ def_info = resolvers[dep_type](dep.name)
230
+ if def_info:
231
+ lines.append(f'{dep_indent}- {label} {dep.name}{suffix} in file "{def_info["file_path"]}"')
232
+ seen_deps.add(dep_key)
233
+
234
+ if not seen_deps:
235
+ lines.append(f'{dep_indent}NONE')
236
+
237
+ def _render_class_members(self, lines: List[str], cls: 'Class', method_indent: str) -> None:
238
+ """Append member lines (methods + variables) for a class."""
239
+ methods = self.get_class_methods(cls.id)
240
+ class_vars = self.get_class_variables(cls.id)
241
+
242
+ if not methods and not class_vars:
243
+ lines.append(f'{method_indent} NONE')
244
+ return
245
+
246
+ for method in methods:
247
+ params_str = self._format_parameters(method.parameters)
248
+ desc_annotation = f" # {method.description}" if method.description else ""
249
+ lines.append(f'{method_indent}- method {method.name}({params_str}){desc_annotation}:')
250
+ deps = self.get_function_dependencies_grouped(method.id)
251
+ self._render_callable_deps(lines, deps, method_indent + ' ')
252
+
253
+ for var in class_vars:
254
+ type_suffix = f" : {var.field_type}" if var.field_type else ""
255
+ desc_annotation = f" # {var.description}" if var.description else ""
256
+ lines.append(f'{method_indent}- variable {var.name}{type_suffix}{desc_annotation}')
257
+
258
+ def _render_nested_classes(self, lines: List[str], func: 'Function', file: 'File') -> None:
259
+ """Append 'contains: nested classes' block for a function if any."""
260
+ cursor = self.conn.cursor()
261
+ cursor.execute(
262
+ "SELECT * FROM classes WHERE parent_id = ? AND file_id = ?",
263
+ (func.id, file.id)
264
+ )
265
+ nested_rows = cursor.fetchall()
266
+ if not nested_rows:
267
+ return
268
+
269
+ lines.append(' contains:')
270
+ for row in nested_rows:
271
+ nested_cls = Class.from_row(row, file.path)
272
+ desc_annotation = f" # {nested_cls.description}" if nested_cls.description else ""
273
+ lines.append(f' - class {nested_cls.name}{desc_annotation}:')
274
+ lines.append(f' members:')
275
+ self._render_class_members(lines, nested_cls, ' ')
276
+
277
+ def get_dependency_graph(self, file_path: str) -> str:
278
+ """Get a YAML-like formatted dependency graph for a file.
279
+
280
+ Args:
281
+ file_path: Relative path to the file in the database
282
+
283
+ Returns:
284
+ YAML-like formatted string showing the dependency graph
285
+ """
286
+ normalized_path = file_path[2:] if file_path.startswith('./') else file_path
287
+ file = self.get_file_by_path(normalized_path)
288
+
289
+ if not file:
290
+ filename = Path(normalized_path).name
291
+ cursor = self.conn.cursor()
292
+ cursor.execute("SELECT * FROM files WHERE path LIKE ?", (f"%{filename}",))
293
+ row = cursor.fetchone()
294
+ if row:
295
+ file = File.from_row(row)
296
+
297
+ if not file:
298
+ return f"Error: File not found in database: {file_path}"
299
+
300
+ lines = [f'file "{file_path}":']
301
+
302
+ for func in self.get_file_functions(file.id):
303
+ params_str = self._format_parameters(func.parameters)
304
+ desc_annotation = f" # {func.description}" if func.description else ""
305
+ lines.append(f' - function {func.name}({params_str}) in file "{file_path}"{desc_annotation}')
306
+ deps = self.get_function_dependencies_grouped(func.id)
307
+ self._render_callable_deps(lines, deps, ' ')
308
+ self._render_nested_classes(lines, func, file)
309
+
310
+ seen_vars: set = set()
311
+ for var in self.get_file_variables(file.id):
312
+ if var.name in seen_vars:
313
+ continue
314
+ seen_vars.add(var.name)
315
+ type_suffix = f" : {var.field_type}" if var.field_type else ""
316
+ desc_annotation = f" # {var.description}" if var.description else ""
317
+ lines.append(f' - variable {var.name}{type_suffix} in file "{file_path}"{desc_annotation}')
318
+
319
+ for imp in self.get_file_imports(file.id):
320
+ lines.append(f' - imported {imp.name} in file "{file_path}"')
321
+
322
+ for ns in self.get_file_namespaces(file.id):
323
+ lines.append(f' - namespace {ns.name} in file "{file_path}"')
324
+
325
+ for cls in self.get_file_classes(file.id):
326
+ desc_annotation = f" # {cls.description}" if cls.description else ""
327
+ ns_annotation = f" # namespace: {cls.namespace}" if cls.namespace else ""
328
+ lines.append(f' - class {cls.name} in file "{file_path}"{desc_annotation}{ns_annotation}:')
329
+ lines.append(f' members:')
330
+ self._render_class_members(lines, cls, ' ')
331
+
332
+ return '\n'.join(lines)
333
+
334
+ def _resolve_function_definition(self, name: str) -> Optional[Dict]:
335
+ """Resolve a function name to its definition location."""
336
+ functions = self.get_function_by_name(name)
337
+ if functions:
338
+ # Return the first match
339
+ func = functions[0]
340
+ return {"file_path": func.file_path}
341
+ return None
342
+
343
+ def _resolve_method_definition(self, name: str) -> Optional[Dict]:
344
+ """Resolve a method name to its definition location."""
345
+ # Strip the object part (e.g., "self.init" -> "init", "context.SaveChangesAsync" -> "SaveChangesAsync")
346
+ method_name = name.rsplit('.', 1)[-1] if '.' in name else name
347
+ cursor = self.conn.cursor()
348
+ cursor.execute(
349
+ "SELECT f.*, file.path FROM functions f JOIN files file ON f.file_id = file.id WHERE f.name = ? AND f.parent_type = 'class' LIMIT 1",
350
+ (method_name,)
351
+ )
352
+ row = cursor.fetchone()
353
+ if row:
354
+ return {"file_path": row['path']}
355
+ return None
356
+
357
+ def _resolve_class_definition(self, name: str) -> Optional[Dict]:
358
+ """Resolve a class name to its definition location."""
359
+ classes = self.get_class_by_name(name)
360
+ if classes:
361
+ cls = classes[0]
362
+ return {"file_path": cls.file_path}
363
+ return None
364
+
365
+ def _resolve_variable_definition(self, name: str) -> Optional[Dict]:
366
+ """Resolve a variable name to its definition location."""
367
+ variables = self.get_variable_by_name(name)
368
+ if variables:
369
+ var = variables[0]
370
+ return {"file_path": var.file_path}
371
+ return None
372
+
373
+ def _is_likely_literal(self, name: str) -> bool:
374
+ """Check if a dependency name is likely a literal string or unimplemented function.
375
+
376
+ Returns True if the name appears to be:
377
+ - A string literal (contains quotes, spaces, or special characters)
378
+ - A temporary/inline expression
379
+ """
380
+ # Check for string literals with quotes
381
+ if '"' in name or "'" in name:
382
+ return True
383
+
384
+ # Check for spaces (indicates multi-word string literal)
385
+ if ' ' in name:
386
+ return True
387
+
388
+ # Check for common literal patterns (command-like)
389
+ if name.startswith(('echo ', 'print ', 'return ', 'cd ', 'ls ', 'cat ')):
390
+ return True
391
+
392
+ # Check for format patterns (string formatting)
393
+ if '.format(' in name or ('%' in name and '(' in name and name.count('%') > 1):
394
+ return True
395
+
396
+ # Check for obvious expressions (multiple operators without function-like structure)
397
+ # Allow single operators like "x++" or "x--" but filter complex expressions
398
+ operator_count = sum(1 for op in ['+', '-', '*', '/', '=', '==', '!=', '<', '>', '<=', '>='] if op in name)
399
+ if operator_count >= 2:
400
+ return True
401
+
402
+ return False
403
+
404
+ def _format_parameters(self, parameters: Union[List[Dict], str]) -> str:
405
+ """Format parameters list into a string."""
406
+ if not parameters:
407
+ return ""
408
+
409
+ # Handle case where parameters might be a string (JSON not parsed)
410
+ if isinstance(parameters, str):
411
+ try:
412
+ parameters = json.loads(parameters)
413
+ except:
414
+ return parameters
415
+
416
+ param_strs = []
417
+ for param in parameters:
418
+ if isinstance(param, str):
419
+ param_strs.append(param)
420
+ elif isinstance(param, dict):
421
+ name = param.get('name', '')
422
+ param_type = param.get('type', '')
423
+ if param_type:
424
+ param_strs.append(f"{name}: {param_type}")
425
+ else:
426
+ param_strs.append(name)
427
+
428
+ return ', '.join(param_strs)
429
+
430
+ # ==================== Source Body Reading ====================
431
+
432
+ def _read_source_lines(self, file_id: int, start_line: int, end_line: int) -> Optional[str]:
433
+ """Read lines [start_line, end_line] (1-indexed, inclusive) from a source file.
434
+
435
+ Uses File.absolute_path from the index; falls back to resolving the relative
436
+ path against cwd and cwd/src if the absolute path no longer exists.
437
+ Returns None if the file cannot be found.
438
+ """
439
+ db_file = self.get_file_by_id(file_id)
440
+ if not db_file:
441
+ return None
442
+
443
+ source = Path(db_file.absolute_path)
444
+ if not source.exists():
445
+ for base in (Path.cwd(), Path.cwd() / "src"):
446
+ candidate = base / db_file.path
447
+ if candidate.exists():
448
+ source = candidate
449
+ break
450
+
451
+ if not source.exists():
452
+ return None
453
+
454
+ with open(source, 'r', encoding='utf-8') as f:
455
+ lines = f.read().split('\n')
456
+
457
+ return '\n'.join(lines[start_line - 1:end_line])
458
+
459
+ def get_function_body(self, function_name: str, file_path: Optional[str] = None) -> Optional[str]:
460
+ """Get the source body of a function or method by name.
461
+
462
+ Args:
463
+ function_name: Name of the function/method.
464
+ file_path: Optional relative file path to disambiguate same-name functions.
465
+
466
+ Returns:
467
+ Source code string with description, or None if not found / file unresolvable.
468
+ """
469
+ file_id = None
470
+ if file_path:
471
+ f = self.get_file_by_path(file_path)
472
+ if f:
473
+ file_id = f.id
474
+
475
+ matches = self.get_function_by_name(function_name, file_id)
476
+ if not matches:
477
+ return None
478
+
479
+ # Prefer implementations (methods inside classes) over interface declarations
480
+ # Interface declarations are standalone functions with no body — implementations
481
+ # have parent_type='class' and contain actual code
482
+ func = matches[0]
483
+ if len(matches) > 1:
484
+ impls = [m for m in matches if m.parent_type == 'class']
485
+ if impls:
486
+ func = impls[0]
487
+ body = self._read_source_lines(func.file_id, func.location.start_line, func.location.end_line)
488
+
489
+ if func.description:
490
+ return f"# Description: {func.description}\n\n{body}"
491
+ return body
492
+
493
+ def walk_call_tree(self, symbol_name: str, file_path: Optional[str] = None,
494
+ max_depth: int = 5, include_external: bool = False,
495
+ exclude: Optional[List[str]] = None) -> str:
496
+ """Walk the call tree starting from a function/method, depth-limited with cycle detection.
497
+
498
+ Returns JSON lines (one JSON object per line), sorted by depth then name. Each node has:
499
+ id: unique ID for follow-up queries (e.g. "f:123")
500
+ name: symbol name
501
+ kind: "function" or "method"
502
+ file: relative file path
503
+ line: start line number
504
+ snippet: first line of the body (one-liner)
505
+ depth: distance from root
506
+ cycle: true if this node appeared earlier in the path
507
+ callees: list of child node IDs
508
+
509
+ Args:
510
+ symbol_name: Name of the starting function/method. Can be "ClassName.method" syntax.
511
+ file_path: Optional file path to disambiguate same-name symbols.
512
+ max_depth: Maximum depth to traverse (default 5).
513
+ include_external: If True, include external/third-party calls (unresolved).
514
+ exclude: Optional list of path prefixes to exclude (e.g. ["tests/"]).
515
+ """
516
+ file_id = None
517
+ if file_path:
518
+ f = self.get_file_by_path(file_path)
519
+ if f:
520
+ file_id = f.id
521
+
522
+ # First check if it's a class name (for walking all methods)
523
+ classes = self.get_class_by_name(symbol_name, file_id)
524
+ if classes:
525
+ # Class-based walk: walk all methods of the class
526
+ cls = classes[0]
527
+ methods = self.get_class_methods(cls.id)
528
+ if not methods:
529
+ return json.dumps({"error": f"Class '{symbol_name}' has no methods"})
530
+
531
+ # Return information about the class and its methods
532
+ result = {
533
+ "type": "class",
534
+ "name": cls.name,
535
+ "file": cls.file_path,
536
+ "line": cls.location.start_line,
537
+ "methods": [
538
+ {
539
+ "id": f"f:{m.id}",
540
+ "name": m.name,
541
+ "line": m.location.start_line,
542
+ "snippet": self._read_one_line_snippet(m.file_id, m.location.start_line)
543
+ }
544
+ for m in methods
545
+ ]
546
+ }
547
+ return json.dumps(result, ensure_ascii=False)
548
+
549
+ # Handle Class.method syntax
550
+ if '.' in symbol_name:
551
+ parts = symbol_name.split('.', 1)
552
+ class_name = parts[0]
553
+ method_name = parts[1]
554
+
555
+ # Find the class
556
+ classes = self.get_class_by_name(class_name, file_id)
557
+ if not classes:
558
+ return json.dumps({"error": f"Class not found: {class_name}"})
559
+
560
+ cls = classes[0]
561
+ # Find the method within the class
562
+ method = self.get_method_by_name(cls.id, method_name)
563
+ if not method:
564
+ return json.dumps({"error": f"Method '{method_name}' not found in class '{class_name}'"})
565
+
566
+ root_func = method
567
+ else:
568
+ # Handle plain function/method name
569
+ functions = self.get_function_by_name(symbol_name, file_id)
570
+ if not functions:
571
+ return json.dumps({"error": f"Symbol not found: {symbol_name}"})
572
+ root_func = functions[0]
573
+ # Prefer implementations (methods inside classes) over interface declarations
574
+ if len(functions) > 1:
575
+ impls = [f for f in functions if f.parent_type == 'class']
576
+ if impls:
577
+ root_func = impls[0]
578
+
579
+ root_id = f"f:{root_func.id}"
580
+ root_snippet = self._read_one_line_snippet(root_func.file_id, root_func.location.start_line)
581
+
582
+ nodes: Dict[str, Dict[str, Any]] = {}
583
+ nodes[root_id] = {
584
+ "id": root_id,
585
+ "name": root_func.name,
586
+ "kind": root_func.type,
587
+ "file": root_func.file_path,
588
+ "line": root_func.location.start_line,
589
+ "snippet": root_snippet,
590
+ "depth": 0,
591
+ "cycle": False,
592
+ "callees": [],
593
+ }
594
+
595
+ queue: deque = deque()
596
+ queue.append((root_func.id, root_id, 0, frozenset([root_func.id])))
597
+
598
+ cursor = self.conn.cursor()
599
+
600
+ while queue:
601
+ func_id, parent_node_id, depth, ancestors = queue.popleft()
602
+ if depth >= max_depth:
603
+ continue
604
+
605
+ # Get the file_id for this function to pass to resolver
606
+ cursor.execute("SELECT file_id FROM functions WHERE id = ?", (func_id,))
607
+ row = cursor.fetchone()
608
+ source_file_id = row['file_id'] if row else None
609
+
610
+ cursor.execute(
611
+ """SELECT * FROM dependencies
612
+ WHERE source_function_id = ?
613
+ AND dependency_type IN ('function_call', 'method_call', 'class_reference')
614
+ ORDER BY name""",
615
+ (func_id,)
616
+ )
617
+
618
+ seen_callees = set() # Track seen callees to avoid duplicates
619
+ for row in cursor.fetchall():
620
+ dep = Dependency.from_row(row, "")
621
+ if self._is_likely_literal(dep.name):
622
+ continue
623
+
624
+ # Create a unique key for deduplication (include dependency_type to distinguish class_reference from function_call)
625
+ callee_key = (dep.name, dep.target_function_id, dep.dependency_type)
626
+ if callee_key in seen_callees:
627
+ continue
628
+ seen_callees.add(callee_key)
629
+
630
+ callee_func = self._resolve_call_target(dep, source_file_id)
631
+ if callee_func is None:
632
+ if include_external and dep.name:
633
+ ext_id = f"ext:{dep.name}"
634
+ if ext_id not in nodes:
635
+ nodes[ext_id] = {
636
+ "id": ext_id,
637
+ "name": dep.name,
638
+ "kind": "external",
639
+ "file": "",
640
+ "line": 0,
641
+ "snippet": "",
642
+ "depth": depth + 1,
643
+ "cycle": False,
644
+ "callees": [],
645
+ }
646
+ # Only add to parent's callees if not already present
647
+ if ext_id not in nodes[parent_node_id]["callees"]:
648
+ nodes[parent_node_id]["callees"].append(ext_id)
649
+ continue
650
+
651
+ # Skip callees in excluded paths
652
+ if exclude and any(callee_func.file_path.startswith(p) for p in exclude):
653
+ continue
654
+
655
+ callee_id = f"f:{callee_func.id}"
656
+ is_cycle = callee_func.id in ancestors
657
+
658
+ if callee_id not in nodes:
659
+ snippet = self._read_one_line_snippet(
660
+ callee_func.file_id, callee_func.location.start_line
661
+ )
662
+ nodes[callee_id] = {
663
+ "id": callee_id,
664
+ "name": callee_func.name,
665
+ "kind": callee_func.type,
666
+ "file": callee_func.file_path,
667
+ "line": callee_func.location.start_line,
668
+ "snippet": snippet,
669
+ "depth": depth + 1,
670
+ "cycle": is_cycle,
671
+ "callees": [],
672
+ }
673
+ elif is_cycle:
674
+ nodes[callee_id]["cycle"] = True
675
+
676
+ # Only add to parent's callees if not already present
677
+ if callee_id not in nodes[parent_node_id]["callees"]:
678
+ nodes[parent_node_id]["callees"].append(callee_id)
679
+
680
+ if not is_cycle and depth + 1 < max_depth:
681
+ queue.append((callee_func.id, callee_id, depth + 1,
682
+ ancestors | {callee_func.id}))
683
+
684
+ sorted_nodes = sorted(nodes.values(), key=lambda n: (n["depth"], n["name"]))
685
+ lines = [json.dumps(node, ensure_ascii=False) for node in sorted_nodes]
686
+ return "\n".join(lines)
687
+
688
+ def _resolve_call_target(self, dep: Dependency, source_file_id: Optional[int] = None) -> Optional['Function']:
689
+ """Resolve a function_call, method_call, or class_reference dependency to its Function definition.
690
+
691
+ Args:
692
+ dep: The dependency to resolve
693
+ source_file_id: Optional file ID of the source making the call, used for disambiguation
694
+ """
695
+ if dep.dependency_type == 'function_call':
696
+ # Prefer functions in the same file if available
697
+ if source_file_id:
698
+ funcs = self.get_function_by_name(dep.name, source_file_id)
699
+ if funcs:
700
+ return funcs[0]
701
+ # Fall back to any function with that name
702
+ funcs = self.get_function_by_name(dep.name)
703
+ if funcs:
704
+ return funcs[0]
705
+ elif dep.dependency_type == 'method_call':
706
+ # For method calls, strip the object part (e.g., "self.init" -> "init")
707
+ method_name = dep.name.rsplit('.', 1)[-1] if '.' in dep.name else dep.name
708
+ cursor = self.conn.cursor()
709
+ # Prefer methods in the same file
710
+ if source_file_id:
711
+ cursor.execute(
712
+ "SELECT f.*, file.path FROM functions f JOIN files file ON f.file_id = file.id "
713
+ "WHERE f.name = ? AND f.parent_type = 'class' AND f.file_id = ? LIMIT 1",
714
+ (method_name, source_file_id)
715
+ )
716
+ row = cursor.fetchone()
717
+ if row:
718
+ return Function.from_row(row, row['path'])
719
+ # Fall back to any method with that name
720
+ cursor.execute(
721
+ "SELECT f.*, file.path FROM functions f JOIN files file ON f.file_id = file.id "
722
+ "WHERE f.name = ? AND f.parent_type = 'class' LIMIT 1",
723
+ (method_name,)
724
+ )
725
+ row = cursor.fetchone()
726
+ if row:
727
+ return Function.from_row(row, row['path'])
728
+ elif dep.dependency_type == 'class_reference':
729
+ # For class references (instantiations), try to find the class's __init__ method
730
+ cursor = self.conn.cursor()
731
+
732
+ # Use target_class_id if available (already resolved during indexing)
733
+ class_id = dep.target_class_id
734
+
735
+ # Otherwise, find the class by name
736
+ if class_id is None:
737
+ if source_file_id:
738
+ cursor.execute(
739
+ "SELECT id FROM classes WHERE name = ? AND file_id = ? LIMIT 1",
740
+ (dep.name, source_file_id)
741
+ )
742
+ row = cursor.fetchone()
743
+ if row:
744
+ class_id = row['id']
745
+
746
+ if class_id is None:
747
+ cursor.execute(
748
+ "SELECT id FROM classes WHERE name = ? LIMIT 1",
749
+ (dep.name,)
750
+ )
751
+ row = cursor.fetchone()
752
+ if row:
753
+ class_id = row['id']
754
+
755
+ # If we found the class, get its __init__ method
756
+ if class_id is not None:
757
+ cursor.execute(
758
+ "SELECT f.*, file.path FROM functions f JOIN files file ON f.file_id = file.id "
759
+ "WHERE f.name = '__init__' AND f.parent_id = ? AND f.parent_type = 'class' LIMIT 1",
760
+ (class_id,)
761
+ )
762
+ row = cursor.fetchone()
763
+ if row:
764
+ return Function.from_row(row, row['path'])
765
+ return None
766
+
767
+ def _read_one_line_snippet(self, file_id: int, start_line: int) -> str:
768
+ """Read the first actual code line after the function signature as a one-line snippet.
769
+
770
+ Skips blank lines, comments, docstrings, and nested function definitions to find the first executable line.
771
+ """
772
+ # Read up to 50 lines to find the first actual code line (increased for robustness)
773
+ max_lines = 50
774
+ for offset in range(1, max_lines + 1):
775
+ line = self._read_source_lines(file_id, start_line + offset, start_line + offset)
776
+ if not line:
777
+ break
778
+
779
+ stripped = line.strip()
780
+ # Skip empty lines
781
+ if not stripped:
782
+ continue
783
+ # Skip comment lines (Python #, C-style //, etc.)
784
+ if stripped.startswith('#') or stripped.startswith('//') or stripped.startswith('/*'):
785
+ continue
786
+ # Skip docstring-like lines (triple quotes)
787
+ if stripped.startswith('"""') or stripped.startswith("'''"):
788
+ continue
789
+ # Skip decorator lines
790
+ if stripped.startswith('@'):
791
+ continue
792
+ # Skip nested function/class definitions (def, class)
793
+ if stripped.startswith('def ') or stripped.startswith('class '):
794
+ continue
795
+ # Skip opening/closing braces (C#, Java, etc.)
796
+ if stripped in ('{', '}', '};'):
797
+ continue
798
+ # Skip print statements that might be debug output
799
+ if stripped.startswith('print('):
800
+ continue
801
+
802
+ # Found a code line - return it truncated
803
+ return stripped[:120]
804
+
805
+ return ""
806
+
807
+ def get_class_body(self, class_name: str, file_path: Optional[str] = None) -> Optional[str]:
808
+ """Get the source body of a class by name.
809
+
810
+ Args:
811
+ class_name: Name of the class.
812
+ file_path: Optional relative file path to disambiguate same-name classes.
813
+
814
+ Returns:
815
+ Source code string with description, or None if not found / file unresolvable.
816
+ """
817
+ file_id = None
818
+ if file_path:
819
+ f = self.get_file_by_path(file_path)
820
+ if f:
821
+ file_id = f.id
822
+
823
+ matches = self.get_class_by_name(class_name, file_id)
824
+ if not matches:
825
+ return None
826
+
827
+ cls = matches[0]
828
+ body = self._read_source_lines(cls.file_id, cls.location.start_line, cls.location.end_line)
829
+
830
+ if cls.description:
831
+ return f"# Description: {cls.description}\n\n{body}"
832
+ return body