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.
- ctrlcode/__init__.py +8 -0
- ctrlcode/agents/__init__.py +29 -0
- ctrlcode/agents/cleanup.py +388 -0
- ctrlcode/agents/communication.py +439 -0
- ctrlcode/agents/observability.py +421 -0
- ctrlcode/agents/react_loop.py +297 -0
- ctrlcode/agents/registry.py +211 -0
- ctrlcode/agents/result_parser.py +242 -0
- ctrlcode/agents/workflow.py +723 -0
- ctrlcode/analysis/__init__.py +28 -0
- ctrlcode/analysis/ast_diff.py +163 -0
- ctrlcode/analysis/bug_detector.py +149 -0
- ctrlcode/analysis/code_graphs.py +329 -0
- ctrlcode/analysis/semantic.py +205 -0
- ctrlcode/analysis/static.py +183 -0
- ctrlcode/analysis/synthesizer.py +281 -0
- ctrlcode/analysis/tests.py +189 -0
- ctrlcode/cleanup/__init__.py +16 -0
- ctrlcode/cleanup/auto_merge.py +350 -0
- ctrlcode/cleanup/doc_gardening.py +388 -0
- ctrlcode/cleanup/pr_automation.py +330 -0
- ctrlcode/cleanup/scheduler.py +356 -0
- ctrlcode/config.py +380 -0
- ctrlcode/embeddings/__init__.py +6 -0
- ctrlcode/embeddings/embedder.py +192 -0
- ctrlcode/embeddings/vector_store.py +213 -0
- ctrlcode/fuzzing/__init__.py +24 -0
- ctrlcode/fuzzing/analyzer.py +280 -0
- ctrlcode/fuzzing/budget.py +112 -0
- ctrlcode/fuzzing/context.py +665 -0
- ctrlcode/fuzzing/context_fuzzer.py +506 -0
- ctrlcode/fuzzing/derived_orchestrator.py +732 -0
- ctrlcode/fuzzing/oracle_adapter.py +135 -0
- ctrlcode/linters/__init__.py +11 -0
- ctrlcode/linters/hand_rolled_utils.py +221 -0
- ctrlcode/linters/yolo_parsing.py +217 -0
- ctrlcode/metrics/__init__.py +6 -0
- ctrlcode/metrics/dashboard.py +283 -0
- ctrlcode/metrics/tech_debt.py +663 -0
- ctrlcode/paths.py +68 -0
- ctrlcode/permissions.py +179 -0
- ctrlcode/providers/__init__.py +15 -0
- ctrlcode/providers/anthropic.py +138 -0
- ctrlcode/providers/base.py +77 -0
- ctrlcode/providers/openai.py +197 -0
- ctrlcode/providers/parallel.py +104 -0
- ctrlcode/server.py +871 -0
- ctrlcode/session/__init__.py +6 -0
- ctrlcode/session/baseline.py +57 -0
- ctrlcode/session/manager.py +967 -0
- ctrlcode/skills/__init__.py +10 -0
- ctrlcode/skills/builtin/commit.toml +29 -0
- ctrlcode/skills/builtin/docs.toml +25 -0
- ctrlcode/skills/builtin/refactor.toml +33 -0
- ctrlcode/skills/builtin/review.toml +28 -0
- ctrlcode/skills/builtin/test.toml +28 -0
- ctrlcode/skills/loader.py +111 -0
- ctrlcode/skills/registry.py +139 -0
- ctrlcode/storage/__init__.py +19 -0
- ctrlcode/storage/history_db.py +708 -0
- ctrlcode/tools/__init__.py +220 -0
- ctrlcode/tools/bash.py +112 -0
- ctrlcode/tools/browser.py +352 -0
- ctrlcode/tools/executor.py +153 -0
- ctrlcode/tools/explore.py +486 -0
- ctrlcode/tools/mcp.py +108 -0
- ctrlcode/tools/observability.py +561 -0
- ctrlcode/tools/registry.py +193 -0
- ctrlcode/tools/todo.py +291 -0
- ctrlcode/tools/update.py +266 -0
- ctrlcode/tools/webfetch.py +147 -0
- ctrlcode-0.1.0.dist-info/METADATA +93 -0
- ctrlcode-0.1.0.dist-info/RECORD +75 -0
- ctrlcode-0.1.0.dist-info/WHEEL +4 -0
- 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
|