switchforge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_core/__init__.py +3 -0
- forge_core/__main__.py +6 -0
- forge_core/ai/__init__.py +0 -0
- forge_core/ai/prompts.py +87 -0
- forge_core/ai/provider.py +121 -0
- forge_core/ai/structured.py +68 -0
- forge_core/auth.py +72 -0
- forge_core/cli.py +209 -0
- forge_core/config.py +118 -0
- forge_core/core/__init__.py +0 -0
- forge_core/core/agent_manager.py +111 -0
- forge_core/core/coverage.py +241 -0
- forge_core/core/file_manager.py +104 -0
- forge_core/models/__init__.py +0 -0
- forge_core/models/config.py +115 -0
- forge_core/models/dto.py +58 -0
- forge_core/models/project.py +72 -0
- forge_core/models/test_result.py +93 -0
- forge_core/orchestrator.py +233 -0
- forge_core/phases/__init__.py +0 -0
- forge_core/phases/analyze_project.py +149 -0
- forge_core/phases/audit_tests.py +35 -0
- forge_core/phases/compile_fix.py +79 -0
- forge_core/phases/coverage_report.py +19 -0
- forge_core/phases/detect_stack.py +66 -0
- forge_core/phases/exclusion_scan.py +74 -0
- forge_core/phases/fix_broken.py +83 -0
- forge_core/phases/generate_tests.py +218 -0
- forge_core/phases/journey_mapping.py +120 -0
- forge_core/phases/self_learn.py +93 -0
- forge_core/utils/__init__.py +0 -0
- forge_core/utils/logger.py +89 -0
- forge_core/utils/reporter.py +70 -0
- forge_core/utils/shell.py +93 -0
- forge_core/utils/tokens.py +67 -0
- switchforge-1.0.0.dist-info/METADATA +65 -0
- switchforge-1.0.0.dist-info/RECORD +39 -0
- switchforge-1.0.0.dist-info/WHEEL +4 -0
- switchforge-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pydantic models for project structure — 4-Level DAG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Component(BaseModel):
|
|
9
|
+
"""A single class/file within a layer."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
file_path: str
|
|
13
|
+
layer: str = ""
|
|
14
|
+
dependencies: list[str] = Field(default_factory=list)
|
|
15
|
+
is_tested: bool = False
|
|
16
|
+
existing_test_file: str = ""
|
|
17
|
+
coverage_pct: float = 0.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Journey(BaseModel):
|
|
21
|
+
"""A traced user journey across layers."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
entry_point: str
|
|
25
|
+
entry_type: str = "" # route, consumer, job, grpc, cli
|
|
26
|
+
components: list[str] = Field(default_factory=list) # ordered list of component names
|
|
27
|
+
priority: int = 1 # 1 = critical, 5 = low
|
|
28
|
+
description: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Layer(BaseModel):
|
|
32
|
+
"""A functional layer within a module (e.g., controllers, services, adapters)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
components: list[Component] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Module(BaseModel):
|
|
39
|
+
"""A module/package within the project."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
path: str
|
|
43
|
+
layers: list[Layer] = Field(default_factory=list)
|
|
44
|
+
journeys: list[Journey] = Field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TechStack(BaseModel):
|
|
48
|
+
"""Detected technology stack."""
|
|
49
|
+
|
|
50
|
+
language: str = ""
|
|
51
|
+
framework: str = ""
|
|
52
|
+
build_tool: str = ""
|
|
53
|
+
test_framework: str = ""
|
|
54
|
+
mock_library: str = ""
|
|
55
|
+
coverage_tool: str = ""
|
|
56
|
+
source_root: str = ""
|
|
57
|
+
test_root: str = ""
|
|
58
|
+
test_command: str = ""
|
|
59
|
+
coverage_command: str = ""
|
|
60
|
+
is_monorepo: bool = False
|
|
61
|
+
modules: list[str] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProjectGraph(BaseModel):
|
|
65
|
+
"""4-Level DAG: Project → Modules → Layers → Components."""
|
|
66
|
+
|
|
67
|
+
name: str = ""
|
|
68
|
+
root_path: str = ""
|
|
69
|
+
tech_stack: TechStack = Field(default_factory=TechStack)
|
|
70
|
+
modules: list[Module] = Field(default_factory=list)
|
|
71
|
+
total_source_files: int = 0
|
|
72
|
+
total_test_files: int = 0
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Pydantic models for test results and coverage reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestFileResult(BaseModel):
|
|
12
|
+
"""Result of a single generated test file."""
|
|
13
|
+
|
|
14
|
+
file_path: str
|
|
15
|
+
test_count: int = 0
|
|
16
|
+
passed: int = 0
|
|
17
|
+
failed: int = 0
|
|
18
|
+
errors: list[str] = Field(default_factory=list)
|
|
19
|
+
compile_errors: list[str] = Field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CoverageEntry(BaseModel):
|
|
23
|
+
"""Coverage data for a single source file."""
|
|
24
|
+
|
|
25
|
+
file_path: str
|
|
26
|
+
line_coverage: float = 0.0
|
|
27
|
+
branch_coverage: float = 0.0
|
|
28
|
+
lines_covered: int = 0
|
|
29
|
+
lines_total: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CoverageReport(BaseModel):
|
|
33
|
+
"""Aggregated coverage report."""
|
|
34
|
+
|
|
35
|
+
line_coverage: float = 0.0
|
|
36
|
+
branch_coverage: float = 0.0
|
|
37
|
+
files: list[CoverageEntry] = Field(default_factory=list)
|
|
38
|
+
total_lines_covered: int = 0
|
|
39
|
+
total_lines: int = 0
|
|
40
|
+
total_tests: int = 0
|
|
41
|
+
tests_passed: int = 0
|
|
42
|
+
tests_failed: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IterationResult(BaseModel):
|
|
46
|
+
"""Result of a single generation iteration."""
|
|
47
|
+
|
|
48
|
+
iteration: int
|
|
49
|
+
tests_generated: list[TestFileResult] = Field(default_factory=list)
|
|
50
|
+
coverage_before: float = 0.0
|
|
51
|
+
coverage_after: float = 0.0
|
|
52
|
+
coverage_delta: float = 0.0
|
|
53
|
+
rolled_back: bool = False
|
|
54
|
+
duration_seconds: float = 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RunReport(BaseModel):
|
|
58
|
+
"""Final report for a complete Forge Core run."""
|
|
59
|
+
|
|
60
|
+
project_name: str = ""
|
|
61
|
+
project_path: str = ""
|
|
62
|
+
language: str = ""
|
|
63
|
+
framework: str = ""
|
|
64
|
+
started_at: datetime = Field(default_factory=datetime.now)
|
|
65
|
+
completed_at: Optional[datetime] = None
|
|
66
|
+
duration_seconds: float = 0.0
|
|
67
|
+
|
|
68
|
+
# Coverage
|
|
69
|
+
coverage_before: float = 0.0
|
|
70
|
+
coverage_after: float = 0.0
|
|
71
|
+
coverage_delta: float = 0.0
|
|
72
|
+
|
|
73
|
+
# Tests
|
|
74
|
+
total_tests_before: int = 0
|
|
75
|
+
total_tests_after: int = 0
|
|
76
|
+
tests_generated: int = 0
|
|
77
|
+
tests_fixed: int = 0
|
|
78
|
+
test_files_created: list[str] = Field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
# Iterations
|
|
81
|
+
iterations: list[IterationResult] = Field(default_factory=list)
|
|
82
|
+
total_iterations: int = 0
|
|
83
|
+
rollbacks: int = 0
|
|
84
|
+
|
|
85
|
+
# Learning
|
|
86
|
+
patterns_discovered: int = 0
|
|
87
|
+
journeys_mapped: int = 0
|
|
88
|
+
dtos_registered: int = 0
|
|
89
|
+
|
|
90
|
+
# Metadata
|
|
91
|
+
mode: str = "full"
|
|
92
|
+
target_coverage: float = 90.0
|
|
93
|
+
production_files_changed: int = 0 # must always be 0
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""8-Phase pipeline orchestrator — the heart of Forge Core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forge_core.ai.prompts import load_learnings, load_prompt
|
|
9
|
+
from forge_core.config import load_config
|
|
10
|
+
from forge_core.core.agent_manager import AgentManager
|
|
11
|
+
from forge_core.core.coverage import run_coverage, run_tests
|
|
12
|
+
from forge_core.core.file_manager import FileManager
|
|
13
|
+
from forge_core.models.config import ForgeConfig, RunMode
|
|
14
|
+
from forge_core.models.dto import DTORegistry
|
|
15
|
+
from forge_core.models.project import ProjectGraph
|
|
16
|
+
from forge_core.models.test_result import CoverageReport, IterationResult, RunReport
|
|
17
|
+
from forge_core.phases import (
|
|
18
|
+
analyze_project,
|
|
19
|
+
audit_tests,
|
|
20
|
+
compile_fix,
|
|
21
|
+
coverage_report,
|
|
22
|
+
detect_stack,
|
|
23
|
+
exclusion_scan,
|
|
24
|
+
fix_broken,
|
|
25
|
+
generate_tests,
|
|
26
|
+
journey_mapping,
|
|
27
|
+
self_learn,
|
|
28
|
+
)
|
|
29
|
+
from forge_core.utils import logger
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Orchestrator:
|
|
33
|
+
"""Runs the full 8-phase Forge Core pipeline."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: ForgeConfig):
|
|
36
|
+
self.config = config
|
|
37
|
+
self.file_manager = FileManager(config.project_path)
|
|
38
|
+
self.agent_manager = AgentManager()
|
|
39
|
+
self.project_graph = ProjectGraph()
|
|
40
|
+
self.dto_registry = DTORegistry()
|
|
41
|
+
self.report = RunReport(
|
|
42
|
+
project_path=str(config.project_path),
|
|
43
|
+
mode=config.mode.value,
|
|
44
|
+
target_coverage=config.target_coverage,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def run(self) -> RunReport:
|
|
48
|
+
"""Execute the full pipeline and return the run report."""
|
|
49
|
+
start_time = time.time()
|
|
50
|
+
self.report.started_at = __import__("datetime").datetime.now()
|
|
51
|
+
|
|
52
|
+
logger.banner("Forge Core v2.0", "AI-Powered Backend Test Generation Engine")
|
|
53
|
+
|
|
54
|
+
prompts_dir = Path(self.config.prompts_dir)
|
|
55
|
+
|
|
56
|
+
# Phase -1: Load past learnings
|
|
57
|
+
logger.phase_start("-1", "Loading past learnings")
|
|
58
|
+
learnings = load_learnings(
|
|
59
|
+
self.config.central_agent_path or None,
|
|
60
|
+
self.config.project_path,
|
|
61
|
+
)
|
|
62
|
+
if learnings:
|
|
63
|
+
logger.phase_done("-1", f"Loaded {len(learnings)} chars of learnings")
|
|
64
|
+
else:
|
|
65
|
+
logger.phase_done("-1", "No prior learnings found")
|
|
66
|
+
|
|
67
|
+
# Phase 1: Detect tech stack
|
|
68
|
+
logger.phase_start("1/8", "Detecting tech stack")
|
|
69
|
+
tech_stack = detect_stack.run(
|
|
70
|
+
config=self.config,
|
|
71
|
+
file_manager=self.file_manager,
|
|
72
|
+
prompts_dir=prompts_dir,
|
|
73
|
+
)
|
|
74
|
+
self.project_graph.tech_stack = tech_stack
|
|
75
|
+
self.report.language = tech_stack.language
|
|
76
|
+
self.report.framework = tech_stack.framework
|
|
77
|
+
self.report.project_name = self.config.project_path.name
|
|
78
|
+
logger.phase_done("1/8", f"{tech_stack.language}/{tech_stack.framework} detected")
|
|
79
|
+
|
|
80
|
+
# Phase 1.5: Coverage exclusion scan
|
|
81
|
+
logger.phase_start("1.5/8", "Scanning coverage exclusions")
|
|
82
|
+
exclusions = exclusion_scan.run(
|
|
83
|
+
config=self.config,
|
|
84
|
+
file_manager=self.file_manager,
|
|
85
|
+
tech_stack=tech_stack,
|
|
86
|
+
)
|
|
87
|
+
logger.phase_done("1.5/8", f"{len(exclusions)} exclusions found")
|
|
88
|
+
|
|
89
|
+
# Phase 2: Analyze architecture
|
|
90
|
+
logger.phase_start("2/8", "Analyzing architecture & building project graph")
|
|
91
|
+
self.project_graph = analyze_project.run(
|
|
92
|
+
config=self.config,
|
|
93
|
+
file_manager=self.file_manager,
|
|
94
|
+
prompts_dir=prompts_dir,
|
|
95
|
+
tech_stack=tech_stack,
|
|
96
|
+
learnings=learnings,
|
|
97
|
+
)
|
|
98
|
+
logger.phase_done(
|
|
99
|
+
"2/8",
|
|
100
|
+
f"{len(self.project_graph.modules)} modules, "
|
|
101
|
+
f"{self.project_graph.total_source_files} source files",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Phase 2.5: Journey mapping & DTO registry
|
|
105
|
+
logger.phase_start("2.5/8", "Mapping journeys & building DTO registry")
|
|
106
|
+
self.dto_registry = journey_mapping.run(
|
|
107
|
+
config=self.config,
|
|
108
|
+
file_manager=self.file_manager,
|
|
109
|
+
prompts_dir=prompts_dir,
|
|
110
|
+
project_graph=self.project_graph,
|
|
111
|
+
learnings=learnings,
|
|
112
|
+
)
|
|
113
|
+
journey_count = sum(len(m.journeys) for m in self.project_graph.modules)
|
|
114
|
+
self.report.journeys_mapped = journey_count
|
|
115
|
+
self.report.dtos_registered = self.dto_registry.count
|
|
116
|
+
logger.phase_done(
|
|
117
|
+
"2.5/8",
|
|
118
|
+
f"{journey_count} journeys mapped, {self.dto_registry.count} DTOs registered",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Stop here for analyze-only modes
|
|
122
|
+
if self.config.mode == RunMode.ANALYZE_ONLY:
|
|
123
|
+
logger.success("Analysis complete (analyze-only mode)")
|
|
124
|
+
self.report.duration_seconds = time.time() - start_time
|
|
125
|
+
return self.report
|
|
126
|
+
|
|
127
|
+
# Phase 3: Audit existing tests
|
|
128
|
+
logger.phase_start("3/8", "Auditing existing tests")
|
|
129
|
+
baseline = audit_tests.run(
|
|
130
|
+
config=self.config,
|
|
131
|
+
file_manager=self.file_manager,
|
|
132
|
+
prompts_dir=prompts_dir,
|
|
133
|
+
project_graph=self.project_graph,
|
|
134
|
+
)
|
|
135
|
+
self.report.coverage_before = baseline.line_coverage
|
|
136
|
+
self.report.total_tests_before = baseline.total_tests
|
|
137
|
+
logger.phase_done(
|
|
138
|
+
"3/8",
|
|
139
|
+
f"Baseline: {baseline.line_coverage:.1f}% coverage, "
|
|
140
|
+
f"{baseline.total_tests} tests ({baseline.tests_failed} failing)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self.config.mode == RunMode.ANALYZE_REVIEW:
|
|
144
|
+
logger.success("Analysis + review complete")
|
|
145
|
+
self.report.duration_seconds = time.time() - start_time
|
|
146
|
+
return self.report
|
|
147
|
+
|
|
148
|
+
# Phase 4: Fix broken tests
|
|
149
|
+
if baseline.tests_failed > 0:
|
|
150
|
+
logger.phase_start("4/8", "Fixing broken tests")
|
|
151
|
+
fixed_count = fix_broken.run(
|
|
152
|
+
config=self.config,
|
|
153
|
+
file_manager=self.file_manager,
|
|
154
|
+
prompts_dir=prompts_dir,
|
|
155
|
+
project_graph=self.project_graph,
|
|
156
|
+
baseline=baseline,
|
|
157
|
+
learnings=learnings,
|
|
158
|
+
)
|
|
159
|
+
self.report.tests_fixed = fixed_count
|
|
160
|
+
logger.phase_done("4/8", f"Fixed {fixed_count} broken tests")
|
|
161
|
+
else:
|
|
162
|
+
logger.phase_skip("4/8", "No broken tests found")
|
|
163
|
+
|
|
164
|
+
# Get post-fix coverage as new baseline
|
|
165
|
+
post_fix = run_coverage(
|
|
166
|
+
self.config.project_path,
|
|
167
|
+
self.project_graph.tech_stack.coverage_command,
|
|
168
|
+
)
|
|
169
|
+
best_coverage = max(baseline.line_coverage, post_fix.line_coverage)
|
|
170
|
+
|
|
171
|
+
# Phase 5: Iterative test generation
|
|
172
|
+
logger.phase_start("5/8", "Generating tests (journey-weighted)")
|
|
173
|
+
generation_result = generate_tests.run(
|
|
174
|
+
config=self.config,
|
|
175
|
+
file_manager=self.file_manager,
|
|
176
|
+
prompts_dir=prompts_dir,
|
|
177
|
+
project_graph=self.project_graph,
|
|
178
|
+
dto_registry=self.dto_registry,
|
|
179
|
+
agent_manager=self.agent_manager,
|
|
180
|
+
baseline_coverage=best_coverage,
|
|
181
|
+
learnings=learnings,
|
|
182
|
+
)
|
|
183
|
+
self.report.iterations = generation_result.iterations
|
|
184
|
+
self.report.total_iterations = len(generation_result.iterations)
|
|
185
|
+
self.report.rollbacks = sum(1 for i in generation_result.iterations if i.rolled_back)
|
|
186
|
+
|
|
187
|
+
# Phase 6: Final coverage report
|
|
188
|
+
logger.phase_start("6/8", "Generating final coverage report")
|
|
189
|
+
final = coverage_report.run(
|
|
190
|
+
config=self.config,
|
|
191
|
+
project_graph=self.project_graph,
|
|
192
|
+
)
|
|
193
|
+
self.report.coverage_after = final.line_coverage
|
|
194
|
+
self.report.coverage_delta = final.line_coverage - self.report.coverage_before
|
|
195
|
+
self.report.total_tests_after = final.total_tests
|
|
196
|
+
self.report.tests_generated = final.total_tests - self.report.total_tests_before
|
|
197
|
+
logger.phase_done("6/8", f"Final coverage: {final.line_coverage:.1f}%")
|
|
198
|
+
|
|
199
|
+
logger.coverage_table(
|
|
200
|
+
self.report.coverage_before,
|
|
201
|
+
self.report.coverage_after,
|
|
202
|
+
self.report.total_tests_before,
|
|
203
|
+
self.report.total_tests_after,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Phase 7: Self-learn
|
|
207
|
+
logger.phase_start("7/8", "Self-learning")
|
|
208
|
+
patterns = self_learn.run(
|
|
209
|
+
config=self.config,
|
|
210
|
+
file_manager=self.file_manager,
|
|
211
|
+
prompts_dir=prompts_dir,
|
|
212
|
+
project_graph=self.project_graph,
|
|
213
|
+
report=self.report,
|
|
214
|
+
learnings=learnings,
|
|
215
|
+
)
|
|
216
|
+
self.report.patterns_discovered = patterns
|
|
217
|
+
logger.phase_done("7/8", f"{patterns} new patterns recorded")
|
|
218
|
+
|
|
219
|
+
# Finalize
|
|
220
|
+
self.report.duration_seconds = time.time() - start_time
|
|
221
|
+
self.report.completed_at = __import__("datetime").datetime.now()
|
|
222
|
+
self.report.production_files_changed = 0 # always 0
|
|
223
|
+
|
|
224
|
+
mins = self.report.duration_seconds / 60
|
|
225
|
+
logger.banner(
|
|
226
|
+
"✅ Forge Core Complete",
|
|
227
|
+
f"{self.report.coverage_before:.1f}% → {self.report.coverage_after:.1f}% "
|
|
228
|
+
f"in {mins:.1f} minutes | "
|
|
229
|
+
f"+{self.report.tests_generated} tests | "
|
|
230
|
+
f"{self.report.patterns_discovered} patterns learned",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return self.report
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Phase 2 — Analyze project architecture and build project graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from forge_core.ai.prompts import build_file_context, load_prompt
|
|
8
|
+
from forge_core.ai.provider import complete
|
|
9
|
+
from forge_core.core.file_manager import FileManager
|
|
10
|
+
from forge_core.models.config import ForgeConfig
|
|
11
|
+
from forge_core.models.project import Component, Layer, Module, ProjectGraph, TechStack
|
|
12
|
+
from forge_core.utils import logger
|
|
13
|
+
from forge_core.utils.tokens import split_for_context
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(
|
|
19
|
+
config: ForgeConfig,
|
|
20
|
+
file_manager: FileManager,
|
|
21
|
+
prompts_dir: Path,
|
|
22
|
+
tech_stack: TechStack,
|
|
23
|
+
learnings: str = "",
|
|
24
|
+
) -> ProjectGraph:
|
|
25
|
+
"""Analyze the project and build the 4-Level DAG."""
|
|
26
|
+
# Read all source files
|
|
27
|
+
source_root = tech_stack.source_root or "src"
|
|
28
|
+
source_files = file_manager.read_files(f"{source_root}/**/*")
|
|
29
|
+
test_files = file_manager.read_files(f"{tech_stack.test_root or 'src/test'}/**/*")
|
|
30
|
+
|
|
31
|
+
# Filter to code files only
|
|
32
|
+
code_extensions = _get_code_extensions(tech_stack.language)
|
|
33
|
+
source_files = {
|
|
34
|
+
k: v for k, v in source_files.items()
|
|
35
|
+
if any(k.endswith(ext) for ext in code_extensions)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
graph = ProjectGraph(
|
|
39
|
+
name=config.project_path.name,
|
|
40
|
+
root_path=str(config.project_path),
|
|
41
|
+
tech_stack=tech_stack,
|
|
42
|
+
total_source_files=len(source_files),
|
|
43
|
+
total_test_files=len(test_files),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Split files into batches that fit in context
|
|
47
|
+
batches = split_for_context(source_files, max_tokens=80_000, model=config.ai.model)
|
|
48
|
+
|
|
49
|
+
prompt = load_prompt(prompts_dir, "analyze-project")
|
|
50
|
+
system_prompt = prompt or (
|
|
51
|
+
"You are a software architect. Analyze this codebase and identify:\n"
|
|
52
|
+
"1. Modules/packages\n2. Layers (controllers, services, adapters, models)\n"
|
|
53
|
+
"3. Key components per layer\n4. Dependencies between components\n"
|
|
54
|
+
"Return JSON with modules, layers, and components."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if learnings:
|
|
58
|
+
system_prompt += f"\n\nPast learnings:\n{learnings[:2000]}"
|
|
59
|
+
|
|
60
|
+
# Process each batch
|
|
61
|
+
all_modules: dict[str, Module] = {}
|
|
62
|
+
for i, batch in enumerate(batches):
|
|
63
|
+
logger.info(f"Analyzing batch {i + 1}/{len(batches)} ({len(batch)} files)")
|
|
64
|
+
file_context = build_file_context(batch)
|
|
65
|
+
|
|
66
|
+
response = complete(
|
|
67
|
+
config=config.ai,
|
|
68
|
+
system_prompt=system_prompt,
|
|
69
|
+
user_prompt=(
|
|
70
|
+
f"Analyze these source files and return the architecture as JSON.\n\n"
|
|
71
|
+
f"Project: {config.project_path.name}\n"
|
|
72
|
+
f"Language: {tech_stack.language}\n"
|
|
73
|
+
f"Framework: {tech_stack.framework}\n\n"
|
|
74
|
+
f"{file_context}"
|
|
75
|
+
),
|
|
76
|
+
json_mode=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_parse_architecture_response(response, all_modules, batch)
|
|
80
|
+
|
|
81
|
+
graph.modules = list(all_modules.values())
|
|
82
|
+
return graph
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_architecture_response(
|
|
86
|
+
response: str, modules: dict[str, Module], files: dict[str, str]
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Parse AI's architecture analysis response and merge into modules dict."""
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(response)
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
logger.warn("Failed to parse architecture JSON — using file-based fallback")
|
|
93
|
+
_fallback_from_files(modules, files)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
for mod_data in data.get("modules", [data] if "layers" in data else []):
|
|
97
|
+
mod_name = mod_data.get("name", "main")
|
|
98
|
+
if mod_name not in modules:
|
|
99
|
+
modules[mod_name] = Module(name=mod_name, path=mod_data.get("path", ""))
|
|
100
|
+
|
|
101
|
+
mod = modules[mod_name]
|
|
102
|
+
for layer_data in mod_data.get("layers", []):
|
|
103
|
+
layer = Layer(name=layer_data.get("name", ""))
|
|
104
|
+
for comp_data in layer_data.get("components", []):
|
|
105
|
+
layer.components.append(
|
|
106
|
+
Component(
|
|
107
|
+
name=comp_data.get("name", ""),
|
|
108
|
+
file_path=comp_data.get("file_path", ""),
|
|
109
|
+
layer=layer.name,
|
|
110
|
+
dependencies=comp_data.get("dependencies", []),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
mod.layers.append(layer)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fallback_from_files(modules: dict[str, Module], files: dict[str, str]) -> None:
|
|
117
|
+
"""Build a basic module structure from file paths when AI parsing fails."""
|
|
118
|
+
mod = modules.setdefault("main", Module(name="main", path="src"))
|
|
119
|
+
layers: dict[str, Layer] = {}
|
|
120
|
+
|
|
121
|
+
for path in files:
|
|
122
|
+
parts = Path(path).parts
|
|
123
|
+
layer_name = parts[2] if len(parts) > 2 else "root"
|
|
124
|
+
if layer_name not in layers:
|
|
125
|
+
layers[layer_name] = Layer(name=layer_name)
|
|
126
|
+
layers[layer_name].components.append(
|
|
127
|
+
Component(name=Path(path).stem, file_path=path, layer=layer_name)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
mod.layers = list(layers.values())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_code_extensions(language: str) -> list[str]:
|
|
134
|
+
"""Get relevant file extensions for a language."""
|
|
135
|
+
ext_map = {
|
|
136
|
+
"kotlin": [".kt", ".kts"],
|
|
137
|
+
"java": [".java"],
|
|
138
|
+
"python": [".py"],
|
|
139
|
+
"go": [".go"],
|
|
140
|
+
"typescript": [".ts", ".tsx"],
|
|
141
|
+
"javascript": [".js", ".jsx"],
|
|
142
|
+
"rust": [".rs"],
|
|
143
|
+
"csharp": [".cs"],
|
|
144
|
+
"c#": [".cs"],
|
|
145
|
+
"ruby": [".rb"],
|
|
146
|
+
"php": [".php"],
|
|
147
|
+
"c++": [".cpp", ".hpp", ".cc", ".h"],
|
|
148
|
+
}
|
|
149
|
+
return ext_map.get(language.lower(), [".py", ".java", ".kt", ".go", ".ts", ".js"])
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Phase 3 — Audit existing tests and establish baseline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from forge_core.core.coverage import run_coverage
|
|
8
|
+
from forge_core.core.file_manager import FileManager
|
|
9
|
+
from forge_core.models.config import ForgeConfig
|
|
10
|
+
from forge_core.models.project import ProjectGraph
|
|
11
|
+
from forge_core.models.test_result import CoverageReport
|
|
12
|
+
from forge_core.utils import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
config: ForgeConfig,
|
|
17
|
+
file_manager: FileManager,
|
|
18
|
+
prompts_dir: Path,
|
|
19
|
+
project_graph: ProjectGraph,
|
|
20
|
+
) -> CoverageReport:
|
|
21
|
+
"""Audit existing tests and get baseline coverage."""
|
|
22
|
+
tech = project_graph.tech_stack
|
|
23
|
+
|
|
24
|
+
if not tech.coverage_command:
|
|
25
|
+
logger.warn("No coverage command detected — using test command")
|
|
26
|
+
tech.coverage_command = tech.test_command
|
|
27
|
+
|
|
28
|
+
if not tech.coverage_command:
|
|
29
|
+
logger.warn("No test/coverage command found")
|
|
30
|
+
return CoverageReport()
|
|
31
|
+
|
|
32
|
+
# Run existing tests and get baseline coverage
|
|
33
|
+
report = run_coverage(config.project_path, tech.coverage_command)
|
|
34
|
+
|
|
35
|
+
return report
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Phase 5b — Auto compile-fix loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from forge_core.ai.provider import complete
|
|
8
|
+
from forge_core.core.file_manager import FileManager
|
|
9
|
+
from forge_core.models.config import ForgeConfig
|
|
10
|
+
from forge_core.utils import logger
|
|
11
|
+
from forge_core.utils.shell import run as shell_run
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fix_compilation_errors(
|
|
17
|
+
config: ForgeConfig,
|
|
18
|
+
file_manager: FileManager,
|
|
19
|
+
test_command: str,
|
|
20
|
+
max_attempts: int = 3,
|
|
21
|
+
) -> bool:
|
|
22
|
+
"""Attempt to fix compilation errors in generated tests.
|
|
23
|
+
|
|
24
|
+
Returns True if tests compile after fixes.
|
|
25
|
+
"""
|
|
26
|
+
for attempt in range(1, max_attempts + 1):
|
|
27
|
+
result = shell_run(test_command, cwd=config.project_path, timeout=300)
|
|
28
|
+
|
|
29
|
+
if result.success:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
# Check if it's a compilation error (not a test failure)
|
|
33
|
+
if not _is_compile_error(result.output):
|
|
34
|
+
return True # Tests compiled but some failed — that's okay
|
|
35
|
+
|
|
36
|
+
logger.info(f"Compile-fix attempt {attempt}/{max_attempts}")
|
|
37
|
+
|
|
38
|
+
# Ask AI to fix
|
|
39
|
+
response = complete(
|
|
40
|
+
config=config.ai,
|
|
41
|
+
system_prompt=(
|
|
42
|
+
"Fix the compilation errors in these test files. "
|
|
43
|
+
"Return JSON with fixed_files: [{path, content}]. "
|
|
44
|
+
"Only fix imports, types, and syntax — don't change test logic."
|
|
45
|
+
),
|
|
46
|
+
user_prompt=f"Compilation errors:\n```\n{result.output[:4000]}\n```",
|
|
47
|
+
json_mode=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(response)
|
|
52
|
+
for fix in data.get("fixed_files", []):
|
|
53
|
+
path = fix.get("path", "")
|
|
54
|
+
content = fix.get("content", "")
|
|
55
|
+
if path and content:
|
|
56
|
+
file_manager.write_file(path, content)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
logger.warn("Failed to parse compile-fix response")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_compile_error(output: str) -> bool:
|
|
65
|
+
"""Detect if the output contains compilation errors (vs test failures)."""
|
|
66
|
+
compile_indicators = [
|
|
67
|
+
"Compilation failed",
|
|
68
|
+
"COMPILE ERROR",
|
|
69
|
+
"error: cannot find symbol",
|
|
70
|
+
"Unresolved reference",
|
|
71
|
+
"SyntaxError",
|
|
72
|
+
"IndentationError",
|
|
73
|
+
"ImportError",
|
|
74
|
+
"ModuleNotFoundError",
|
|
75
|
+
"error TS",
|
|
76
|
+
"cannot resolve",
|
|
77
|
+
"does not exist",
|
|
78
|
+
]
|
|
79
|
+
return any(indicator.lower() in output.lower() for indicator in compile_indicators)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Phase 6 — Generate final coverage report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forge_core.core.coverage import run_coverage
|
|
6
|
+
from forge_core.models.config import ForgeConfig
|
|
7
|
+
from forge_core.models.project import ProjectGraph
|
|
8
|
+
from forge_core.models.test_result import CoverageReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(config: ForgeConfig, project_graph: ProjectGraph) -> CoverageReport:
|
|
12
|
+
"""Run the full test suite and generate the final coverage report."""
|
|
13
|
+
tech = project_graph.tech_stack
|
|
14
|
+
command = tech.coverage_command or tech.test_command
|
|
15
|
+
|
|
16
|
+
if not command:
|
|
17
|
+
return CoverageReport()
|
|
18
|
+
|
|
19
|
+
return run_coverage(config.project_path, command)
|