ctrlcode 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.
Files changed (75) hide show
  1. ctrlcode/__init__.py +8 -0
  2. ctrlcode/agents/__init__.py +29 -0
  3. ctrlcode/agents/cleanup.py +388 -0
  4. ctrlcode/agents/communication.py +439 -0
  5. ctrlcode/agents/observability.py +421 -0
  6. ctrlcode/agents/react_loop.py +297 -0
  7. ctrlcode/agents/registry.py +211 -0
  8. ctrlcode/agents/result_parser.py +242 -0
  9. ctrlcode/agents/workflow.py +723 -0
  10. ctrlcode/analysis/__init__.py +28 -0
  11. ctrlcode/analysis/ast_diff.py +163 -0
  12. ctrlcode/analysis/bug_detector.py +149 -0
  13. ctrlcode/analysis/code_graphs.py +329 -0
  14. ctrlcode/analysis/semantic.py +205 -0
  15. ctrlcode/analysis/static.py +183 -0
  16. ctrlcode/analysis/synthesizer.py +281 -0
  17. ctrlcode/analysis/tests.py +189 -0
  18. ctrlcode/cleanup/__init__.py +16 -0
  19. ctrlcode/cleanup/auto_merge.py +350 -0
  20. ctrlcode/cleanup/doc_gardening.py +388 -0
  21. ctrlcode/cleanup/pr_automation.py +330 -0
  22. ctrlcode/cleanup/scheduler.py +356 -0
  23. ctrlcode/config.py +380 -0
  24. ctrlcode/embeddings/__init__.py +6 -0
  25. ctrlcode/embeddings/embedder.py +192 -0
  26. ctrlcode/embeddings/vector_store.py +213 -0
  27. ctrlcode/fuzzing/__init__.py +24 -0
  28. ctrlcode/fuzzing/analyzer.py +280 -0
  29. ctrlcode/fuzzing/budget.py +112 -0
  30. ctrlcode/fuzzing/context.py +665 -0
  31. ctrlcode/fuzzing/context_fuzzer.py +506 -0
  32. ctrlcode/fuzzing/derived_orchestrator.py +732 -0
  33. ctrlcode/fuzzing/oracle_adapter.py +135 -0
  34. ctrlcode/linters/__init__.py +11 -0
  35. ctrlcode/linters/hand_rolled_utils.py +221 -0
  36. ctrlcode/linters/yolo_parsing.py +217 -0
  37. ctrlcode/metrics/__init__.py +6 -0
  38. ctrlcode/metrics/dashboard.py +283 -0
  39. ctrlcode/metrics/tech_debt.py +663 -0
  40. ctrlcode/paths.py +68 -0
  41. ctrlcode/permissions.py +179 -0
  42. ctrlcode/providers/__init__.py +15 -0
  43. ctrlcode/providers/anthropic.py +138 -0
  44. ctrlcode/providers/base.py +77 -0
  45. ctrlcode/providers/openai.py +197 -0
  46. ctrlcode/providers/parallel.py +104 -0
  47. ctrlcode/server.py +871 -0
  48. ctrlcode/session/__init__.py +6 -0
  49. ctrlcode/session/baseline.py +57 -0
  50. ctrlcode/session/manager.py +967 -0
  51. ctrlcode/skills/__init__.py +10 -0
  52. ctrlcode/skills/builtin/commit.toml +29 -0
  53. ctrlcode/skills/builtin/docs.toml +25 -0
  54. ctrlcode/skills/builtin/refactor.toml +33 -0
  55. ctrlcode/skills/builtin/review.toml +28 -0
  56. ctrlcode/skills/builtin/test.toml +28 -0
  57. ctrlcode/skills/loader.py +111 -0
  58. ctrlcode/skills/registry.py +139 -0
  59. ctrlcode/storage/__init__.py +19 -0
  60. ctrlcode/storage/history_db.py +708 -0
  61. ctrlcode/tools/__init__.py +220 -0
  62. ctrlcode/tools/bash.py +112 -0
  63. ctrlcode/tools/browser.py +352 -0
  64. ctrlcode/tools/executor.py +153 -0
  65. ctrlcode/tools/explore.py +486 -0
  66. ctrlcode/tools/mcp.py +108 -0
  67. ctrlcode/tools/observability.py +561 -0
  68. ctrlcode/tools/registry.py +193 -0
  69. ctrlcode/tools/todo.py +291 -0
  70. ctrlcode/tools/update.py +266 -0
  71. ctrlcode/tools/webfetch.py +147 -0
  72. ctrlcode-0.1.0.dist-info/METADATA +93 -0
  73. ctrlcode-0.1.0.dist-info/RECORD +75 -0
  74. ctrlcode-0.1.0.dist-info/WHEEL +4 -0
  75. ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,28 @@
1
+ """Code analysis components for ctrl-code."""
2
+
3
+ from .ast_diff import ASTAnalyzer, ASTDiff
4
+ from .bug_detector import BugPatternDetector, DetectedPattern
5
+ from .code_graphs import CodeGraphBuilder, CodeGraphs, SymbolInfo
6
+ from .semantic import SemanticAnalyzer, SemanticDiff
7
+ from .static import StaticAnalyzer, StaticAnalysisResult
8
+ from .synthesizer import AnalysisResult, Feedback, FeedbackSynthesizer
9
+ from .tests import TestExecutor, TestResult
10
+
11
+ __all__ = [
12
+ "ASTAnalyzer",
13
+ "ASTDiff",
14
+ "BugPatternDetector",
15
+ "DetectedPattern",
16
+ "CodeGraphBuilder",
17
+ "CodeGraphs",
18
+ "SymbolInfo",
19
+ "SemanticAnalyzer",
20
+ "SemanticDiff",
21
+ "TestExecutor",
22
+ "TestResult",
23
+ "StaticAnalyzer",
24
+ "StaticAnalysisResult",
25
+ "FeedbackSynthesizer",
26
+ "Feedback",
27
+ "AnalysisResult",
28
+ ]
@@ -0,0 +1,163 @@
1
+ """AST-based structural code comparison."""
2
+
3
+ import ast
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class ASTDiff:
10
+ """Result of AST comparison."""
11
+
12
+ variant_id: str
13
+ has_syntax_error: bool = False
14
+ error_message: Optional[str] = None
15
+ function_count: int = 0
16
+ class_count: int = 0
17
+ import_count: int = 0
18
+ complexity_estimate: int = 0
19
+ structural_similarity: float = 0.0
20
+ differences: list[str] = field(default_factory=list)
21
+
22
+
23
+ class ASTAnalyzer:
24
+ """Analyzes code structure using Python AST."""
25
+
26
+ def compare(self, baseline: str, variant: str, variant_id: str) -> ASTDiff:
27
+ """
28
+ Compare AST structures of baseline and variant.
29
+
30
+ Args:
31
+ baseline: Baseline code
32
+ variant: Variant code
33
+ variant_id: Identifier for variant
34
+
35
+ Returns:
36
+ ASTDiff with comparison results
37
+ """
38
+ # Parse baseline
39
+ try:
40
+ baseline_ast = ast.parse(baseline)
41
+ baseline_stats = self._analyze_ast(baseline_ast)
42
+ except SyntaxError as e:
43
+ return ASTDiff(
44
+ variant_id=variant_id,
45
+ has_syntax_error=True,
46
+ error_message=f"Baseline syntax error: {e}"
47
+ )
48
+
49
+ # Parse variant
50
+ try:
51
+ variant_ast = ast.parse(variant)
52
+ variant_stats = self._analyze_ast(variant_ast)
53
+ except SyntaxError as e:
54
+ return ASTDiff(
55
+ variant_id=variant_id,
56
+ has_syntax_error=True,
57
+ error_message=f"Variant syntax error: {e}"
58
+ )
59
+
60
+ # Compare structures
61
+ differences = self._find_differences(baseline_stats, variant_stats)
62
+ similarity = self._calculate_similarity(baseline_stats, variant_stats)
63
+
64
+ return ASTDiff(
65
+ variant_id=variant_id,
66
+ has_syntax_error=False,
67
+ function_count=variant_stats["functions"],
68
+ class_count=variant_stats["classes"],
69
+ import_count=variant_stats["imports"],
70
+ complexity_estimate=variant_stats["complexity"],
71
+ structural_similarity=similarity,
72
+ differences=differences,
73
+ )
74
+
75
+ def _analyze_ast(self, tree: ast.AST) -> dict[str, int]:
76
+ """
77
+ Analyze AST and extract statistics.
78
+
79
+ Args:
80
+ tree: AST tree
81
+
82
+ Returns:
83
+ Dict with structural stats
84
+ """
85
+ stats = {
86
+ "functions": 0,
87
+ "classes": 0,
88
+ "imports": 0,
89
+ "complexity": 0,
90
+ "loops": 0,
91
+ "conditionals": 0,
92
+ "try_blocks": 0,
93
+ }
94
+
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, ast.FunctionDef):
97
+ stats["functions"] += 1
98
+ elif isinstance(node, ast.ClassDef):
99
+ stats["classes"] += 1
100
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
101
+ stats["imports"] += 1
102
+ elif isinstance(node, (ast.For, ast.While)):
103
+ stats["loops"] += 1
104
+ stats["complexity"] += 1
105
+ elif isinstance(node, ast.If):
106
+ stats["conditionals"] += 1
107
+ stats["complexity"] += 1
108
+ elif isinstance(node, ast.Try):
109
+ stats["try_blocks"] += 1
110
+ stats["complexity"] += 1
111
+
112
+ return stats
113
+
114
+ def _find_differences(
115
+ self,
116
+ baseline_stats: dict[str, int],
117
+ variant_stats: dict[str, int]
118
+ ) -> list[str]:
119
+ """Find structural differences between baseline and variant."""
120
+ differences = []
121
+
122
+ for key in baseline_stats:
123
+ baseline_val = baseline_stats[key]
124
+ variant_val = variant_stats[key]
125
+
126
+ if baseline_val != variant_val:
127
+ diff = variant_val - baseline_val
128
+ sign = "+" if diff > 0 else ""
129
+ differences.append(f"{key}: {baseline_val} → {variant_val} ({sign}{diff})")
130
+
131
+ return differences
132
+
133
+ def _calculate_similarity(
134
+ self,
135
+ baseline_stats: dict[str, int],
136
+ variant_stats: dict[str, int]
137
+ ) -> float:
138
+ """
139
+ Calculate structural similarity score.
140
+
141
+ Returns:
142
+ Similarity score between 0.0 (completely different) and 1.0 (identical)
143
+ """
144
+ total_diff = 0
145
+ total_magnitude = 0
146
+
147
+ for key in baseline_stats:
148
+ baseline_val = baseline_stats[key]
149
+ variant_val = variant_stats[key]
150
+
151
+ # Absolute difference
152
+ diff = abs(variant_val - baseline_val)
153
+ total_diff += diff
154
+
155
+ # Maximum of the two values
156
+ total_magnitude += max(baseline_val, variant_val)
157
+
158
+ if total_magnitude == 0:
159
+ return 1.0 # Both empty
160
+
161
+ # Similarity = 1 - (normalized difference)
162
+ similarity = 1.0 - (total_diff / max(total_magnitude, 1))
163
+ return max(0.0, min(1.0, similarity))
@@ -0,0 +1,149 @@
1
+ """Bug pattern detection using historical fuzzing results."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+
9
+ from ..embeddings.embedder import CodeEmbedder
10
+ from ..embeddings.vector_store import VectorStore
11
+ from ..storage.history_db import BugPattern, HistoryDB
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class DetectedPattern:
18
+ """A similar bug pattern detected in historical data."""
19
+
20
+ bug_id: str
21
+ description: str
22
+ code_snippet: str
23
+ severity: str
24
+ similarity: float
25
+ session_id: str
26
+
27
+ @property
28
+ def confidence(self) -> str:
29
+ """Confidence level based on similarity."""
30
+ if self.similarity >= 0.9:
31
+ return "HIGH"
32
+ elif self.similarity >= 0.8:
33
+ return "MEDIUM"
34
+ else:
35
+ return "LOW"
36
+
37
+
38
+ class BugPatternDetector:
39
+ """Detects similar bug patterns in code using historical fuzzing data.
40
+
41
+ Before fuzzing new code, search for similar bugs in history and warn
42
+ the user if risky patterns are detected. This enables proactive bug
43
+ prevention based on past failures.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ history_db: HistoryDB,
49
+ embedder: Optional[CodeEmbedder] = None,
50
+ similarity_threshold: float = 0.75,
51
+ ):
52
+ """Initialize bug pattern detector.
53
+
54
+ Args:
55
+ history_db: History database with bug patterns
56
+ embedder: Optional code embedder (created if not provided)
57
+ similarity_threshold: Minimum similarity for pattern matching (default: 0.75)
58
+ """
59
+ self.history_db = history_db
60
+ self.embedder = embedder or CodeEmbedder()
61
+ self.similarity_threshold = similarity_threshold
62
+
63
+ def check_patterns(self, code: str) -> list[DetectedPattern]:
64
+ """Check code for similar historical bug patterns.
65
+
66
+ Args:
67
+ code: Code to check for risky patterns
68
+
69
+ Returns:
70
+ List of similar bug patterns found, sorted by similarity (descending)
71
+ """
72
+ # Embed the code
73
+ code_embedding = self.embedder.embed_code(code)
74
+
75
+ # Get all historical bug patterns
76
+ bug_records = self.history_db.get_all_bug_embeddings(limit=1000)
77
+
78
+ if not bug_records:
79
+ logger.debug("No historical bug patterns in database")
80
+ return []
81
+
82
+ # Build vector store from bug embeddings
83
+ vector_store = VectorStore(dimension=code_embedding.shape[0])
84
+
85
+ embeddings = np.array([bug.embedding for bug in bug_records])
86
+ ids = [bug.bug_id for bug in bug_records]
87
+
88
+ vector_store.add(embeddings, ids)
89
+
90
+ # Search for similar bugs
91
+ results = vector_store.search(
92
+ code_embedding,
93
+ k=10,
94
+ min_similarity=self.similarity_threshold,
95
+ )
96
+
97
+ if not results:
98
+ logger.debug(f"No similar bugs found above threshold {self.similarity_threshold}")
99
+ return []
100
+
101
+ # Convert to DetectedPattern instances
102
+ detected_patterns = []
103
+
104
+ for bug_id, similarity in results:
105
+ # Find the bug record
106
+ bug_record = next((b for b in bug_records if b.bug_id == bug_id), None)
107
+ if not bug_record:
108
+ continue
109
+
110
+ pattern = DetectedPattern(
111
+ bug_id=bug_record.bug_id,
112
+ description=bug_record.bug_description,
113
+ code_snippet=bug_record.code_snippet,
114
+ severity=bug_record.severity,
115
+ similarity=similarity,
116
+ session_id=bug_record.session_id,
117
+ )
118
+ detected_patterns.append(pattern)
119
+
120
+ logger.info(f"Detected {len(detected_patterns)} similar bug patterns")
121
+ return detected_patterns
122
+
123
+ def format_warnings(self, patterns: list[DetectedPattern]) -> str:
124
+ """Format detected patterns as human-readable warnings.
125
+
126
+ Args:
127
+ patterns: List of detected patterns
128
+
129
+ Returns:
130
+ Formatted warning message
131
+ """
132
+ if not patterns:
133
+ return ""
134
+
135
+ lines = ["⚠️ SIMILAR BUG PATTERNS DETECTED", ""]
136
+
137
+ for i, pattern in enumerate(patterns[:5], 1): # Limit to top 5
138
+ lines.append(f"{i}. {pattern.description}")
139
+ lines.append(f" Severity: {pattern.severity}")
140
+ lines.append(f" Confidence: {pattern.confidence} ({pattern.similarity:.1%} similar)")
141
+ lines.append(f" Code snippet:")
142
+ lines.append(f" ```")
143
+ for line in pattern.code_snippet.split("\n")[:5]: # First 5 lines
144
+ lines.append(f" {line}")
145
+ lines.append(f" ```")
146
+ lines.append("")
147
+
148
+ lines.append("Consider reviewing these patterns before proceeding with fuzzing.")
149
+ return "\n".join(lines)
@@ -0,0 +1,329 @@
1
+ """Code relationship graphs using AST analysis and NetworkX."""
2
+
3
+ import ast
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import networkx as nx
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class SymbolInfo:
16
+ """Information about a code symbol (function, class, variable)."""
17
+
18
+ name: str
19
+ symbol_type: str # 'function', 'class', 'import', 'variable'
20
+ file: str
21
+ line: int
22
+ scope: str # 'module', 'class', 'function'
23
+ parent: Optional[str] = None # Parent class/function name
24
+
25
+
26
+ @dataclass
27
+ class CodeGraphs:
28
+ """Collection of code relationship graphs and symbol maps."""
29
+
30
+ # Symbol resolution maps (O(1) lookup)
31
+ file_map: dict[str, list[str]] = field(default_factory=dict) # file -> symbols
32
+ export_map: dict[str, SymbolInfo] = field(default_factory=dict) # symbol -> info
33
+ import_map: dict[str, list[str]] = field(default_factory=dict) # file -> imported modules
34
+
35
+ # Relationship graphs (NetworkX directed graphs)
36
+ call_graph: nx.DiGraph = field(default_factory=nx.DiGraph) # function -> called functions
37
+ dependency_graph: nx.DiGraph = field(default_factory=nx.DiGraph) # file -> imported files
38
+
39
+ @property
40
+ def function_count(self) -> int:
41
+ """Total number of functions."""
42
+ return sum(1 for s in self.export_map.values() if s.symbol_type == "function")
43
+
44
+ @property
45
+ def class_count(self) -> int:
46
+ """Total number of classes."""
47
+ return sum(1 for s in self.export_map.values() if s.symbol_type == "class")
48
+
49
+ @property
50
+ def file_count(self) -> int:
51
+ """Total number of files analyzed."""
52
+ return len(self.file_map)
53
+
54
+ def get_functions_in_file(self, file: str) -> list[str]:
55
+ """Get all function names defined in a file."""
56
+ symbols = self.file_map.get(file, [])
57
+ return [s for s in symbols if self.export_map[s].symbol_type == "function"]
58
+
59
+ def get_callers(self, function: str) -> list[str]:
60
+ """Get all functions that call the given function."""
61
+ if function not in self.call_graph:
62
+ return []
63
+ return list(self.call_graph.predecessors(function))
64
+
65
+ def get_callees(self, function: str) -> list[str]:
66
+ """Get all functions called by the given function."""
67
+ if function not in self.call_graph:
68
+ return []
69
+ return list(self.call_graph.successors(function))
70
+
71
+ def get_dependencies(self, file: str) -> list[str]:
72
+ """Get all files imported by the given file."""
73
+ if file not in self.dependency_graph:
74
+ return []
75
+ return list(self.dependency_graph.successors(file))
76
+
77
+ def get_dependents(self, file: str) -> list[str]:
78
+ """Get all files that import the given file."""
79
+ if file not in self.dependency_graph:
80
+ return []
81
+ return list(self.dependency_graph.predecessors(file))
82
+
83
+
84
+ class CodeGraphBuilder:
85
+ """Build code graphs from Python source code using AST analysis."""
86
+
87
+ def build_from_code(self, code: str, file_path: str = "<string>") -> CodeGraphs:
88
+ """Build graphs from a single Python source file.
89
+
90
+ Args:
91
+ code: Python source code
92
+ file_path: Path to the file (for tracking)
93
+
94
+ Returns:
95
+ CodeGraphs with symbol maps and relationship graphs
96
+ """
97
+ graphs = CodeGraphs()
98
+
99
+ try:
100
+ tree = ast.parse(code, filename=file_path)
101
+ except SyntaxError as e:
102
+ logger.warning(f"Syntax error in {file_path}: {e}")
103
+ return graphs
104
+
105
+ # Build symbol maps and graphs
106
+ visitor = _SymbolVisitor(file_path, graphs)
107
+ visitor.visit(tree)
108
+
109
+ return graphs
110
+
111
+ def build_from_files(self, file_paths: list[str | Path]) -> CodeGraphs:
112
+ """Build graphs from multiple Python files.
113
+
114
+ Args:
115
+ file_paths: List of file paths to analyze
116
+
117
+ Returns:
118
+ CodeGraphs combining all files
119
+ """
120
+ graphs = CodeGraphs()
121
+
122
+ for path in file_paths:
123
+ path = Path(path)
124
+ if not path.exists():
125
+ logger.warning(f"File not found: {path}")
126
+ continue
127
+
128
+ try:
129
+ code = path.read_text()
130
+ file_graphs = self.build_from_code(code, str(path))
131
+
132
+ # Merge into combined graphs
133
+ self._merge_graphs(graphs, file_graphs)
134
+
135
+ except Exception as e:
136
+ logger.error(f"Error processing {path}: {e}")
137
+
138
+ return graphs
139
+
140
+ def _merge_graphs(self, target: CodeGraphs, source: CodeGraphs) -> None:
141
+ """Merge source graphs into target graphs.
142
+
143
+ Args:
144
+ target: Target graphs to merge into
145
+ source: Source graphs to merge from
146
+ """
147
+ # Merge symbol maps
148
+ for file, symbols in source.file_map.items():
149
+ target.file_map.setdefault(file, []).extend(symbols)
150
+
151
+ target.export_map.update(source.export_map)
152
+
153
+ for file, imports in source.import_map.items():
154
+ target.import_map.setdefault(file, []).extend(imports)
155
+
156
+ # Merge graphs
157
+ target.call_graph = nx.compose(target.call_graph, source.call_graph)
158
+ target.dependency_graph = nx.compose(target.dependency_graph, source.dependency_graph)
159
+
160
+
161
+ class _SymbolVisitor(ast.NodeVisitor):
162
+ """AST visitor that extracts symbols and builds relationship graphs."""
163
+
164
+ def __init__(self, file_path: str, graphs: CodeGraphs):
165
+ self.file_path = file_path
166
+ self.graphs = graphs
167
+ self.current_scope = "module"
168
+ self.current_function: Optional[str] = None
169
+ self.current_class: Optional[str] = None
170
+ self.scope_stack: list[str] = []
171
+
172
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
173
+ """Visit function definition."""
174
+ # Determine full qualified name
175
+ if self.current_class:
176
+ full_name = f"{self.current_class}.{node.name}"
177
+ parent = self.current_class
178
+ scope = "class"
179
+ else:
180
+ full_name = node.name
181
+ parent = None
182
+ scope = "module"
183
+
184
+ # Record symbol
185
+ symbol = SymbolInfo(
186
+ name=full_name,
187
+ symbol_type="function",
188
+ file=self.file_path,
189
+ line=node.lineno,
190
+ scope=scope,
191
+ parent=parent,
192
+ )
193
+ self.graphs.export_map[full_name] = symbol
194
+ self.graphs.file_map.setdefault(self.file_path, []).append(full_name)
195
+
196
+ # Add node to call graph
197
+ self.graphs.call_graph.add_node(full_name)
198
+
199
+ # Visit function body to find calls
200
+ prev_function = self.current_function
201
+ self.current_function = full_name
202
+ self.scope_stack.append(scope)
203
+
204
+ self.generic_visit(node)
205
+
206
+ self.current_function = prev_function
207
+ self.scope_stack.pop()
208
+
209
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
210
+ """Visit async function definition."""
211
+ # Treat same as regular function
212
+ self.visit_FunctionDef(node) # type: ignore
213
+
214
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
215
+ """Visit class definition."""
216
+ full_name = node.name
217
+
218
+ # Record symbol
219
+ symbol = SymbolInfo(
220
+ name=full_name,
221
+ symbol_type="class",
222
+ file=self.file_path,
223
+ line=node.lineno,
224
+ scope="module",
225
+ parent=None,
226
+ )
227
+ self.graphs.export_map[full_name] = symbol
228
+ self.graphs.file_map.setdefault(self.file_path, []).append(full_name)
229
+
230
+ # Visit class body
231
+ prev_class = self.current_class
232
+ self.current_class = full_name
233
+ self.scope_stack.append("class")
234
+
235
+ self.generic_visit(node)
236
+
237
+ self.current_class = prev_class
238
+ self.scope_stack.pop()
239
+
240
+ def visit_Import(self, node: ast.Import) -> None:
241
+ """Visit import statement."""
242
+ for alias in node.names:
243
+ module = alias.name
244
+ self.graphs.import_map.setdefault(self.file_path, []).append(module)
245
+
246
+ # Record import symbol
247
+ import_name = alias.asname if alias.asname else alias.name
248
+ symbol = SymbolInfo(
249
+ name=import_name,
250
+ symbol_type="import",
251
+ file=self.file_path,
252
+ line=node.lineno,
253
+ scope=self.current_scope,
254
+ )
255
+ self.graphs.export_map[import_name] = symbol
256
+
257
+ # Add to dependency graph
258
+ self.graphs.dependency_graph.add_edge(self.file_path, module)
259
+
260
+ self.generic_visit(node)
261
+
262
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
263
+ """Visit from...import statement."""
264
+ if node.module:
265
+ self.graphs.import_map.setdefault(self.file_path, []).append(node.module)
266
+
267
+ # Add to dependency graph
268
+ self.graphs.dependency_graph.add_edge(self.file_path, node.module)
269
+
270
+ # Record imported symbols
271
+ for alias in node.names:
272
+ import_name = alias.asname if alias.asname else alias.name
273
+ symbol = SymbolInfo(
274
+ name=import_name,
275
+ symbol_type="import",
276
+ file=self.file_path,
277
+ line=node.lineno,
278
+ scope=self.current_scope,
279
+ )
280
+ self.graphs.export_map[import_name] = symbol
281
+
282
+ self.generic_visit(node)
283
+
284
+ def visit_Call(self, node: ast.Call) -> None:
285
+ """Visit function call."""
286
+ if self.current_function is None:
287
+ # Call outside function (module-level)
288
+ self.generic_visit(node)
289
+ return
290
+
291
+ # Extract called function name
292
+ called_name = self._extract_call_name(node)
293
+
294
+ if called_name:
295
+ # Add edge in call graph
296
+ self.graphs.call_graph.add_edge(self.current_function, called_name)
297
+
298
+ self.generic_visit(node)
299
+
300
+ def _extract_call_name(self, node: ast.Call) -> Optional[str]:
301
+ """Extract function name from call node.
302
+
303
+ Args:
304
+ node: Call AST node
305
+
306
+ Returns:
307
+ Function name or None
308
+ """
309
+ func = node.func
310
+
311
+ if isinstance(func, ast.Name):
312
+ # Simple call: foo()
313
+ return func.id
314
+ elif isinstance(func, ast.Attribute):
315
+ # Method call: obj.foo() or module.foo()
316
+ # Try to resolve the full name
317
+ parts = []
318
+ current = func
319
+
320
+ while isinstance(current, ast.Attribute):
321
+ parts.append(current.attr)
322
+ current = current.value
323
+
324
+ if isinstance(current, ast.Name):
325
+ parts.append(current.id)
326
+
327
+ return ".".join(reversed(parts))
328
+
329
+ return None