roma-debug 0.1.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.
@@ -0,0 +1,298 @@
1
+ """Dependency graph builder for project analysis.
2
+
3
+ Builds a graph of module dependencies for understanding code relationships.
4
+ """
5
+
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Optional, List, Dict, Set
10
+
11
+ from roma_debug.core.models import Language, Import, FileContext
12
+
13
+
14
+ @dataclass
15
+ class DependencyNode:
16
+ """A node in the dependency graph representing a file/module."""
17
+ filepath: str
18
+ language: Language
19
+ imports: List[Import] = field(default_factory=list)
20
+ imported_by: List[str] = field(default_factory=list) # Files that import this one
21
+ symbols: List[str] = field(default_factory=list) # Exported symbols
22
+
23
+ @property
24
+ def filename(self) -> str:
25
+ return Path(self.filepath).name
26
+
27
+ @property
28
+ def module_name(self) -> str:
29
+ """Derive module name from filepath."""
30
+ path = Path(self.filepath)
31
+ # Remove extension and convert to module format
32
+ stem = path.stem
33
+ if stem == '__init__':
34
+ return path.parent.name
35
+ return stem
36
+
37
+
38
+ class DependencyGraph:
39
+ """Graph of module dependencies in a project.
40
+
41
+ Tracks which files import which other files, allowing us to:
42
+ - Find all files that depend on a given file
43
+ - Find all dependencies of a file
44
+ - Identify potential root cause locations
45
+ """
46
+
47
+ def __init__(self, project_root: Optional[str] = None):
48
+ """Initialize the dependency graph.
49
+
50
+ Args:
51
+ project_root: Root directory of the project
52
+ """
53
+ self.project_root = Path(project_root) if project_root else Path.cwd()
54
+ self._nodes: Dict[str, DependencyNode] = {}
55
+ self._edges: Dict[str, Set[str]] = defaultdict(set) # from -> to
56
+ self._reverse_edges: Dict[str, Set[str]] = defaultdict(set) # to -> from
57
+
58
+ def add_file(self, filepath: str, language: Language, imports: List[Import]):
59
+ """Add a file to the dependency graph.
60
+
61
+ Args:
62
+ filepath: Path to the file
63
+ language: Language of the file
64
+ imports: List of imports from the file
65
+ """
66
+ filepath = str(Path(filepath).resolve())
67
+
68
+ if filepath not in self._nodes:
69
+ self._nodes[filepath] = DependencyNode(
70
+ filepath=filepath,
71
+ language=language,
72
+ )
73
+
74
+ node = self._nodes[filepath]
75
+ node.imports = imports
76
+
77
+ # Add edges for resolved imports
78
+ for imp in imports:
79
+ if imp.resolved_path:
80
+ resolved = str(Path(imp.resolved_path).resolve())
81
+ self._edges[filepath].add(resolved)
82
+ self._reverse_edges[resolved].add(filepath)
83
+
84
+ # Ensure the target node exists
85
+ if resolved not in self._nodes:
86
+ # Infer language from extension
87
+ target_lang = Language.from_extension(Path(resolved).suffix)
88
+ self._nodes[resolved] = DependencyNode(
89
+ filepath=resolved,
90
+ language=target_lang,
91
+ )
92
+
93
+ # Update imported_by
94
+ self._nodes[resolved].imported_by.append(filepath)
95
+
96
+ def add_file_context(self, context: FileContext):
97
+ """Add a FileContext to the graph.
98
+
99
+ Args:
100
+ context: FileContext object with imports
101
+ """
102
+ self.add_file(context.filepath, context.language, context.imports)
103
+
104
+ def get_dependencies(self, filepath: str) -> List[str]:
105
+ """Get all files that the given file imports.
106
+
107
+ Args:
108
+ filepath: Path to the file
109
+
110
+ Returns:
111
+ List of file paths that are imported
112
+ """
113
+ filepath = str(Path(filepath).resolve())
114
+ return list(self._edges.get(filepath, set()))
115
+
116
+ def get_dependents(self, filepath: str) -> List[str]:
117
+ """Get all files that import the given file.
118
+
119
+ Args:
120
+ filepath: Path to the file
121
+
122
+ Returns:
123
+ List of file paths that import this file
124
+ """
125
+ filepath = str(Path(filepath).resolve())
126
+ return list(self._reverse_edges.get(filepath, set()))
127
+
128
+ def get_transitive_dependencies(self, filepath: str, max_depth: int = 10) -> List[str]:
129
+ """Get all transitive dependencies of a file.
130
+
131
+ Args:
132
+ filepath: Path to the file
133
+ max_depth: Maximum recursion depth
134
+
135
+ Returns:
136
+ List of all files that the given file depends on (directly or indirectly)
137
+ """
138
+ filepath = str(Path(filepath).resolve())
139
+ visited = set()
140
+ result = []
141
+
142
+ def visit(path: str, depth: int):
143
+ if depth > max_depth or path in visited:
144
+ return
145
+ visited.add(path)
146
+
147
+ for dep in self._edges.get(path, set()):
148
+ if dep not in visited:
149
+ result.append(dep)
150
+ visit(dep, depth + 1)
151
+
152
+ visit(filepath, 0)
153
+ return result
154
+
155
+ def get_transitive_dependents(self, filepath: str, max_depth: int = 10) -> List[str]:
156
+ """Get all files that transitively depend on the given file.
157
+
158
+ Args:
159
+ filepath: Path to the file
160
+ max_depth: Maximum recursion depth
161
+
162
+ Returns:
163
+ List of all files that depend on this file (directly or indirectly)
164
+ """
165
+ filepath = str(Path(filepath).resolve())
166
+ visited = set()
167
+ result = []
168
+
169
+ def visit(path: str, depth: int):
170
+ if depth > max_depth or path in visited:
171
+ return
172
+ visited.add(path)
173
+
174
+ for dep in self._reverse_edges.get(path, set()):
175
+ if dep not in visited:
176
+ result.append(dep)
177
+ visit(dep, depth + 1)
178
+
179
+ visit(filepath, 0)
180
+ return result
181
+
182
+ def get_path_between(self, source: str, target: str) -> Optional[List[str]]:
183
+ """Find the import path between two files.
184
+
185
+ Args:
186
+ source: Starting file
187
+ target: Target file
188
+
189
+ Returns:
190
+ List of files forming the path, or None if no path exists
191
+ """
192
+ source = str(Path(source).resolve())
193
+ target = str(Path(target).resolve())
194
+
195
+ if source == target:
196
+ return [source]
197
+
198
+ # BFS to find shortest path
199
+ visited = {source}
200
+ queue = [(source, [source])]
201
+
202
+ while queue:
203
+ current, path = queue.pop(0)
204
+
205
+ for neighbor in self._edges.get(current, set()):
206
+ if neighbor == target:
207
+ return path + [neighbor]
208
+
209
+ if neighbor not in visited:
210
+ visited.add(neighbor)
211
+ queue.append((neighbor, path + [neighbor]))
212
+
213
+ return None
214
+
215
+ def find_common_dependencies(self, files: List[str]) -> List[str]:
216
+ """Find files that are imported by all given files.
217
+
218
+ Args:
219
+ files: List of file paths
220
+
221
+ Returns:
222
+ List of files that are common dependencies
223
+ """
224
+ if not files:
225
+ return []
226
+
227
+ # Get dependencies of first file
228
+ common = set(self.get_transitive_dependencies(files[0]))
229
+
230
+ # Intersect with dependencies of other files
231
+ for filepath in files[1:]:
232
+ deps = set(self.get_transitive_dependencies(filepath))
233
+ common &= deps
234
+
235
+ return list(common)
236
+
237
+ def get_node(self, filepath: str) -> Optional[DependencyNode]:
238
+ """Get the node for a file.
239
+
240
+ Args:
241
+ filepath: Path to the file
242
+
243
+ Returns:
244
+ DependencyNode or None
245
+ """
246
+ filepath = str(Path(filepath).resolve())
247
+ return self._nodes.get(filepath)
248
+
249
+ def get_all_files(self) -> List[str]:
250
+ """Get all files in the graph.
251
+
252
+ Returns:
253
+ List of all file paths
254
+ """
255
+ return list(self._nodes.keys())
256
+
257
+ def get_summary(self) -> str:
258
+ """Get a text summary of the dependency graph.
259
+
260
+ Returns:
261
+ Human-readable summary string
262
+ """
263
+ lines = [
264
+ f"Dependency Graph Summary:",
265
+ f" Files: {len(self._nodes)}",
266
+ f" Direct Dependencies: {sum(len(deps) for deps in self._edges.values())}",
267
+ ]
268
+
269
+ # Top imported files
270
+ import_counts = [(f, len(deps)) for f, deps in self._reverse_edges.items()]
271
+ import_counts.sort(key=lambda x: x[1], reverse=True)
272
+
273
+ if import_counts:
274
+ lines.append("\n Most Imported Files:")
275
+ for filepath, count in import_counts[:5]:
276
+ lines.append(f" {Path(filepath).name}: imported by {count} files")
277
+
278
+ return "\n".join(lines)
279
+
280
+ def to_dict(self) -> dict:
281
+ """Serialize the graph to a dictionary.
282
+
283
+ Returns:
284
+ Dictionary representation of the graph
285
+ """
286
+ return {
287
+ "project_root": str(self.project_root),
288
+ "nodes": {
289
+ path: {
290
+ "filepath": node.filepath,
291
+ "language": node.language.value,
292
+ "imports": [imp.module_name for imp in node.imports],
293
+ "imported_by": node.imported_by,
294
+ }
295
+ for path, node in self._nodes.items()
296
+ },
297
+ "edges": {k: list(v) for k, v in self._edges.items()},
298
+ }
@@ -0,0 +1,399 @@
1
+ """Error analyzer for understanding errors without explicit tracebacks.
2
+
3
+ Analyzes error messages to:
4
+ - Identify error type and category
5
+ - Find relevant files in the project
6
+ - Provide targeted context for AI fixing
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, List, Dict, Tuple
12
+
13
+ from roma_debug.core.models import Language, FileContext
14
+ from roma_debug.tracing.project_scanner import ProjectScanner, ProjectInfo, ProjectFile
15
+
16
+
17
+ @dataclass
18
+ class ErrorAnalysis:
19
+ """Result of analyzing an error message."""
20
+ error_type: str # 'http', 'runtime', 'syntax', 'import', 'config', 'unknown'
21
+ error_category: str # More specific: '404', 'type_error', 'module_not_found', etc.
22
+ error_message: str
23
+ suggested_language: Optional[Language] = None
24
+ relevant_files: List[ProjectFile] = field(default_factory=list)
25
+ affected_routes: List[str] = field(default_factory=list)
26
+ keywords: List[str] = field(default_factory=list)
27
+ confidence: float = 0.0 # 0-1 confidence in analysis
28
+
29
+ def to_context_string(self) -> str:
30
+ """Format analysis as context string for AI."""
31
+ lines = [
32
+ "## ERROR ANALYSIS",
33
+ f"Type: {self.error_type}",
34
+ f"Category: {self.error_category}",
35
+ f"Message: {self.error_message}",
36
+ ]
37
+
38
+ if self.suggested_language:
39
+ lines.append(f"Language: {self.suggested_language.value}")
40
+
41
+ if self.affected_routes:
42
+ lines.append(f"Affected Routes: {', '.join(self.affected_routes)}")
43
+
44
+ if self.relevant_files:
45
+ lines.append("\n### Relevant Files:")
46
+ for f in self.relevant_files:
47
+ lines.append(f"- {f.path}")
48
+
49
+ return "\n".join(lines)
50
+
51
+
52
+ # Error pattern matchers
53
+ ERROR_PATTERNS = {
54
+ # HTTP/Web errors
55
+ 'http_404': [
56
+ (r'cannot\s+(?:get|post|put|delete|patch)\s+[/\w]+', 0.9),
57
+ (r'404\s+(?:not\s+found|\(not\s+found\))', 0.95),
58
+ (r'get\s+http://.*\s+404', 0.95),
59
+ (r'route\s+not\s+found', 0.9),
60
+ (r'no\s+route\s+matches', 0.9),
61
+ ],
62
+ 'http_500': [
63
+ (r'500\s+internal\s+server\s+error', 0.95),
64
+ (r'internal\s+server\s+error', 0.8),
65
+ ],
66
+ 'http_400': [
67
+ (r'400\s+bad\s+request', 0.95),
68
+ (r'bad\s+request', 0.7),
69
+ ],
70
+ 'http_401': [
71
+ (r'401\s+unauthorized', 0.95),
72
+ (r'authentication\s+required', 0.85),
73
+ (r'not\s+authenticated', 0.8),
74
+ ],
75
+ 'http_403': [
76
+ (r'403\s+forbidden', 0.95),
77
+ (r'permission\s+denied', 0.8),
78
+ (r'access\s+denied', 0.8),
79
+ ],
80
+ 'static_file': [
81
+ (r'cannot\s+(?:get|find|serve)\s+.*\.(?:html|css|js|png|jpg|svg)', 0.9),
82
+ (r'static\s+file\s+not\s+found', 0.9),
83
+ (r'failed\s+to\s+load\s+resource', 0.85),
84
+ (r'enoent.*index\.html', 0.95),
85
+ (r'enoent.*public', 0.9),
86
+ (r'enoent.*static', 0.9),
87
+ (r'no\s+such\s+file.*\.html', 0.95),
88
+ (r'no\s+such\s+file.*public', 0.9),
89
+ ],
90
+ 'file_not_found': [
91
+ (r'enoent', 0.9),
92
+ (r'no\s+such\s+file\s+or\s+directory', 0.95),
93
+ (r'file\s+not\s+found', 0.9),
94
+ (r'cannot\s+find\s+(?:file|path)', 0.85),
95
+ ],
96
+
97
+ # Python errors
98
+ 'python_import': [
99
+ (r'modulenotfounderror', 0.95),
100
+ (r'importerror', 0.9),
101
+ (r'no\s+module\s+named', 0.95),
102
+ (r'cannot\s+import\s+name', 0.9),
103
+ ],
104
+ 'python_attribute': [
105
+ (r'attributeerror', 0.95),
106
+ (r'has\s+no\s+attribute', 0.9),
107
+ ],
108
+ 'python_type': [
109
+ (r'typeerror', 0.95),
110
+ (r'expected\s+\w+\s+got\s+\w+', 0.8),
111
+ ],
112
+ 'python_value': [
113
+ (r'valueerror', 0.95),
114
+ (r'invalid\s+value', 0.7),
115
+ ],
116
+ 'python_key': [
117
+ (r'keyerror', 0.95),
118
+ ],
119
+ 'python_index': [
120
+ (r'indexerror', 0.95),
121
+ (r'list\s+index\s+out\s+of\s+range', 0.95),
122
+ ],
123
+ 'python_name': [
124
+ (r'nameerror', 0.95),
125
+ (r'name\s+[\'"]?\w+[\'"]?\s+is\s+not\s+defined', 0.9),
126
+ ],
127
+ 'python_syntax': [
128
+ (r'syntaxerror', 0.95),
129
+ (r'invalid\s+syntax', 0.9),
130
+ ],
131
+
132
+ # JavaScript/Node errors
133
+ 'js_reference': [
134
+ (r'referenceerror', 0.95),
135
+ (r'is\s+not\s+defined', 0.8),
136
+ ],
137
+ 'js_type': [
138
+ (r'typeerror.*undefined', 0.9),
139
+ (r'cannot\s+read\s+propert', 0.9),
140
+ (r'is\s+not\s+a\s+function', 0.9),
141
+ ],
142
+ 'js_syntax': [
143
+ (r'syntaxerror.*javascript', 0.9),
144
+ (r'unexpected\s+token', 0.85),
145
+ ],
146
+ 'js_module': [
147
+ (r'cannot\s+find\s+module', 0.95),
148
+ (r'module\s+not\s+found', 0.9),
149
+ ],
150
+
151
+ # Go errors
152
+ 'go_panic': [
153
+ (r'panic:', 0.95),
154
+ (r'runtime\s+error:', 0.9),
155
+ ],
156
+ 'go_nil': [
157
+ (r'nil\s+pointer', 0.95),
158
+ (r'invalid\s+memory\s+address', 0.9),
159
+ ],
160
+
161
+ # Rust errors
162
+ 'rust_panic': [
163
+ (r'thread\s+.*\s+panicked', 0.95),
164
+ (r'called\s+`option::unwrap\(\)`', 0.9),
165
+ ],
166
+
167
+ # Database errors
168
+ 'database': [
169
+ (r'database\s+error', 0.85),
170
+ (r'sql\s+error', 0.9),
171
+ (r'connection\s+refused.*(?:5432|3306|27017)', 0.9), # Common DB ports
172
+ (r'operationalerror.*database', 0.9),
173
+ ],
174
+
175
+ # Config/Environment errors
176
+ 'config': [
177
+ (r'api\s*key\s+(?:not\s+(?:set|found|valid)|invalid)', 0.9),
178
+ (r'missing\s+(?:env|environment)\s+variable', 0.9),
179
+ (r'configuration\s+error', 0.85),
180
+ (r'\.env\s+(?:not\s+found|missing)', 0.9),
181
+ ],
182
+
183
+ # Connection errors
184
+ 'connection': [
185
+ (r'connection\s+refused', 0.9),
186
+ (r'econnrefused', 0.95),
187
+ (r'connection\s+timed?\s*out', 0.9),
188
+ (r'network\s+error', 0.8),
189
+ ],
190
+ }
191
+
192
+ # Map error categories to error types
193
+ CATEGORY_TO_TYPE = {
194
+ 'http_404': 'http',
195
+ 'http_500': 'http',
196
+ 'http_400': 'http',
197
+ 'http_401': 'http',
198
+ 'http_403': 'http',
199
+ 'static_file': 'static',
200
+ 'file_not_found': 'filesystem',
201
+ 'python_import': 'import',
202
+ 'python_attribute': 'runtime',
203
+ 'python_type': 'runtime',
204
+ 'python_value': 'runtime',
205
+ 'python_key': 'runtime',
206
+ 'python_index': 'runtime',
207
+ 'python_name': 'runtime',
208
+ 'python_syntax': 'syntax',
209
+ 'js_reference': 'runtime',
210
+ 'js_type': 'runtime',
211
+ 'js_syntax': 'syntax',
212
+ 'js_module': 'import',
213
+ 'go_panic': 'runtime',
214
+ 'go_nil': 'runtime',
215
+ 'rust_panic': 'runtime',
216
+ 'database': 'database',
217
+ 'config': 'config',
218
+ 'connection': 'connection',
219
+ }
220
+
221
+ # Language hints from error patterns
222
+ CATEGORY_TO_LANGUAGE = {
223
+ 'python_import': Language.PYTHON,
224
+ 'python_attribute': Language.PYTHON,
225
+ 'python_type': Language.PYTHON,
226
+ 'python_value': Language.PYTHON,
227
+ 'python_key': Language.PYTHON,
228
+ 'python_index': Language.PYTHON,
229
+ 'python_name': Language.PYTHON,
230
+ 'python_syntax': Language.PYTHON,
231
+ 'js_reference': Language.JAVASCRIPT,
232
+ 'js_type': Language.JAVASCRIPT,
233
+ 'js_syntax': Language.JAVASCRIPT,
234
+ 'js_module': Language.JAVASCRIPT,
235
+ 'go_panic': Language.GO,
236
+ 'go_nil': Language.GO,
237
+ 'rust_panic': Language.RUST,
238
+ }
239
+
240
+
241
+ class ErrorAnalyzer:
242
+ """Analyzes errors to extract meaning and find relevant files."""
243
+
244
+ def __init__(self, project_scanner: Optional[ProjectScanner] = None):
245
+ """Initialize the analyzer.
246
+
247
+ Args:
248
+ project_scanner: Optional project scanner for file discovery
249
+ """
250
+ self.scanner = project_scanner
251
+
252
+ def analyze(self, error_message: str) -> ErrorAnalysis:
253
+ """Analyze an error message.
254
+
255
+ Args:
256
+ error_message: The error message or log to analyze
257
+
258
+ Returns:
259
+ ErrorAnalysis with extracted information
260
+ """
261
+ error_lower = error_message.lower()
262
+
263
+ # Detect error category
264
+ category, confidence = self._detect_category(error_lower)
265
+ error_type = CATEGORY_TO_TYPE.get(category, 'unknown')
266
+ suggested_lang = CATEGORY_TO_LANGUAGE.get(category)
267
+
268
+ # Extract routes from HTTP errors
269
+ affected_routes = self._extract_routes(error_message)
270
+
271
+ # Extract keywords
272
+ keywords = self._extract_keywords(error_message)
273
+
274
+ # Find relevant files if scanner available
275
+ relevant_files = []
276
+ if self.scanner:
277
+ relevant_files = self.scanner.find_relevant_files(error_message, limit=5)
278
+
279
+ # If no language detected, use project's primary language
280
+ if not suggested_lang:
281
+ info = self.scanner.scan()
282
+ suggested_lang = info.primary_language
283
+
284
+ return ErrorAnalysis(
285
+ error_type=error_type,
286
+ error_category=category,
287
+ error_message=error_message[:500], # Truncate long messages
288
+ suggested_language=suggested_lang,
289
+ relevant_files=relevant_files,
290
+ affected_routes=affected_routes,
291
+ keywords=keywords,
292
+ confidence=confidence,
293
+ )
294
+
295
+ def _detect_category(self, error_lower: str) -> Tuple[str, float]:
296
+ """Detect error category from message.
297
+
298
+ Returns:
299
+ Tuple of (category, confidence)
300
+ """
301
+ best_category = 'unknown'
302
+ best_confidence = 0.0
303
+
304
+ for category, patterns in ERROR_PATTERNS.items():
305
+ for pattern, conf in patterns:
306
+ if re.search(pattern, error_lower):
307
+ if conf > best_confidence:
308
+ best_category = category
309
+ best_confidence = conf
310
+
311
+ return best_category, best_confidence
312
+
313
+ def _extract_routes(self, error_message: str) -> List[str]:
314
+ """Extract URL routes from error message."""
315
+ routes = []
316
+
317
+ # Pattern for routes in "Cannot GET /path" style
318
+ route_patterns = [
319
+ r'cannot\s+(?:get|post|put|delete|patch)\s+([/\w\-\.]+)',
320
+ r'(?:get|post|put|delete|patch)\s+([/\w\-\.]+)\s+(?:404|failed)',
321
+ r'route\s+[\'"]?([/\w\-\.]+)[\'"]?',
322
+ r'path\s+[\'"]?([/\w\-\.]+)[\'"]?',
323
+ ]
324
+
325
+ for pattern in route_patterns:
326
+ matches = re.findall(pattern, error_message, re.IGNORECASE)
327
+ routes.extend(matches)
328
+
329
+ # Deduplicate
330
+ return list(dict.fromkeys(routes))
331
+
332
+ def _extract_keywords(self, error_message: str) -> List[str]:
333
+ """Extract relevant keywords from error."""
334
+ keywords = []
335
+
336
+ # Extract quoted strings
337
+ quoted = re.findall(r'[\'"]([^\'"]{2,30})[\'"]', error_message)
338
+ keywords.extend(quoted)
339
+
340
+ # Extract identifiers
341
+ identifiers = re.findall(r'\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b', error_message)
342
+ keywords.extend(identifiers)
343
+
344
+ identifiers = re.findall(r'\b([a-z]+(?:_[a-z]+)+)\b', error_message)
345
+ keywords.extend(identifiers)
346
+
347
+ # Extract file references
348
+ files = re.findall(r'[\w\-]+\.(?:py|js|ts|go|rs|java)', error_message)
349
+ keywords.extend(files)
350
+
351
+ # Deduplicate and limit
352
+ return list(dict.fromkeys(keywords))[:20]
353
+
354
+ def get_fix_context(
355
+ self,
356
+ error_message: str,
357
+ include_project_structure: bool = True,
358
+ include_file_contents: bool = True,
359
+ max_files: int = 3,
360
+ ) -> str:
361
+ """Get comprehensive context for AI to fix the error.
362
+
363
+ Args:
364
+ error_message: The error message
365
+ include_project_structure: Include project structure info
366
+ include_file_contents: Include relevant file contents
367
+ max_files: Maximum file contents to include
368
+
369
+ Returns:
370
+ Formatted context string for AI
371
+ """
372
+ analysis = self.analyze(error_message)
373
+ parts = []
374
+
375
+ # Error analysis
376
+ parts.append(analysis.to_context_string())
377
+
378
+ # Project structure
379
+ if include_project_structure and self.scanner:
380
+ parts.append("")
381
+ parts.append(self.scanner.get_project_context(max_files=2))
382
+
383
+ # Relevant file contents
384
+ if include_file_contents and self.scanner and analysis.relevant_files:
385
+ parts.append("")
386
+ parts.append("## RELEVANT SOURCE FILES")
387
+
388
+ for pf in analysis.relevant_files[:max_files]:
389
+ content = self.scanner.get_file_content(pf.path)
390
+ if content:
391
+ parts.append(f"\n### {pf.path}")
392
+ parts.append(f"```{pf.language.value}")
393
+ # Truncate long files
394
+ if len(content) > 3000:
395
+ content = content[:3000] + "\n... (truncated)"
396
+ parts.append(content)
397
+ parts.append("```")
398
+
399
+ return "\n".join(parts)