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,66 @@
|
|
|
1
|
+
"""Phase 1 — Detect tech stack by reading build files."""
|
|
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.structured import extract
|
|
9
|
+
from forge_core.core.file_manager import FileManager
|
|
10
|
+
from forge_core.models.config import ForgeConfig
|
|
11
|
+
from forge_core.models.project import TechStack
|
|
12
|
+
|
|
13
|
+
# Build files to check for each language ecosystem
|
|
14
|
+
BUILD_FILES = [
|
|
15
|
+
"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts",
|
|
16
|
+
"pom.xml",
|
|
17
|
+
"package.json", "tsconfig.json",
|
|
18
|
+
"pyproject.toml", "setup.py", "requirements.txt", "Pipfile",
|
|
19
|
+
"go.mod", "go.sum",
|
|
20
|
+
"Cargo.toml",
|
|
21
|
+
"*.csproj", "*.sln",
|
|
22
|
+
"Gemfile",
|
|
23
|
+
"composer.json",
|
|
24
|
+
"Makefile", "CMakeLists.txt",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
config: ForgeConfig,
|
|
30
|
+
file_manager: FileManager,
|
|
31
|
+
prompts_dir: Path,
|
|
32
|
+
) -> TechStack:
|
|
33
|
+
"""Detect the project's technology stack."""
|
|
34
|
+
# Read build/config files
|
|
35
|
+
build_contents: dict[str, str] = {}
|
|
36
|
+
for pattern in BUILD_FILES:
|
|
37
|
+
if "*" in pattern:
|
|
38
|
+
found = file_manager.read_files(pattern)
|
|
39
|
+
build_contents.update(found)
|
|
40
|
+
else:
|
|
41
|
+
content = file_manager.read_file(pattern)
|
|
42
|
+
if content:
|
|
43
|
+
build_contents[pattern] = content
|
|
44
|
+
|
|
45
|
+
# Also check for common directory structures
|
|
46
|
+
project_path = config.project_path
|
|
47
|
+
dir_hints: list[str] = []
|
|
48
|
+
for d in ["src/main", "src/test", "src", "lib", "tests", "test", "spec", "app"]:
|
|
49
|
+
if (project_path / d).exists():
|
|
50
|
+
dir_hints.append(d)
|
|
51
|
+
|
|
52
|
+
# Use AI to analyze
|
|
53
|
+
prompt = load_prompt(prompts_dir, "detect-tech-stack")
|
|
54
|
+
file_context = build_file_context(build_contents)
|
|
55
|
+
user_prompt = (
|
|
56
|
+
f"Analyze this project's build files and detect the tech stack.\n\n"
|
|
57
|
+
f"Directory structure hints: {', '.join(dir_hints)}\n\n"
|
|
58
|
+
f"{file_context}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return extract(
|
|
62
|
+
config=config.ai,
|
|
63
|
+
system_prompt=prompt or "You are a build system analyst. Detect the tech stack.",
|
|
64
|
+
user_prompt=user_prompt,
|
|
65
|
+
response_model=TechStack,
|
|
66
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Phase 1.5 — Coverage exclusion scan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forge_core.core.file_manager import FileManager
|
|
6
|
+
from forge_core.models.config import ForgeConfig
|
|
7
|
+
from forge_core.models.project import TechStack
|
|
8
|
+
from forge_core.utils import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(
|
|
12
|
+
config: ForgeConfig,
|
|
13
|
+
file_manager: FileManager,
|
|
14
|
+
tech_stack: TechStack,
|
|
15
|
+
) -> list[str]:
|
|
16
|
+
"""Scan for coverage exclusions in build/config files.
|
|
17
|
+
|
|
18
|
+
Returns list of excluded package/path patterns.
|
|
19
|
+
"""
|
|
20
|
+
exclusions: list[str] = []
|
|
21
|
+
|
|
22
|
+
# Check common exclusion locations based on language
|
|
23
|
+
if tech_stack.language.lower() in ("kotlin", "java"):
|
|
24
|
+
_scan_gradle_exclusions(file_manager, exclusions)
|
|
25
|
+
_scan_jacoco_exclusions(file_manager, exclusions)
|
|
26
|
+
elif tech_stack.language.lower() == "python":
|
|
27
|
+
_scan_pytest_exclusions(file_manager, exclusions)
|
|
28
|
+
elif tech_stack.language.lower() == "go":
|
|
29
|
+
_scan_go_exclusions(file_manager, exclusions)
|
|
30
|
+
|
|
31
|
+
if exclusions:
|
|
32
|
+
logger.info(f"Exclusions: {', '.join(exclusions[:5])}")
|
|
33
|
+
|
|
34
|
+
return exclusions
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _scan_gradle_exclusions(fm: FileManager, exclusions: list[str]) -> None:
|
|
38
|
+
"""Scan build.gradle for jacocoTestReport exclusions."""
|
|
39
|
+
for f in ["build.gradle", "build.gradle.kts"]:
|
|
40
|
+
content = fm.read_file(f)
|
|
41
|
+
if "classDirectories" in content and "exclude" in content:
|
|
42
|
+
# Extract excluded patterns
|
|
43
|
+
import re
|
|
44
|
+
|
|
45
|
+
for m in re.finditer(r"""exclude\s*\(\s*["'](.+?)["']""", content):
|
|
46
|
+
exclusions.append(m.group(1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scan_jacoco_exclusions(fm: FileManager, exclusions: list[str]) -> None:
|
|
50
|
+
"""Scan jacoco config for exclusions."""
|
|
51
|
+
content = fm.read_file("jacoco.exec")
|
|
52
|
+
# JaCoCo exclusions are typically in build files, handled above
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scan_pytest_exclusions(fm: FileManager, exclusions: list[str]) -> None:
|
|
56
|
+
"""Scan pyproject.toml or .coveragerc for omit patterns."""
|
|
57
|
+
import re
|
|
58
|
+
|
|
59
|
+
for f in ["pyproject.toml", ".coveragerc", "setup.cfg"]:
|
|
60
|
+
content = fm.read_file(f)
|
|
61
|
+
if content:
|
|
62
|
+
for m in re.finditer(r"omit\s*=\s*(.+?)(?:\n\[|\n\n|\Z)", content, re.DOTALL):
|
|
63
|
+
paths = m.group(1).strip().split("\n")
|
|
64
|
+
exclusions.extend(p.strip() for p in paths if p.strip())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scan_go_exclusions(fm: FileManager, exclusions: list[str]) -> None:
|
|
68
|
+
"""Go doesn't have standard exclusion configs — check Makefile."""
|
|
69
|
+
content = fm.read_file("Makefile")
|
|
70
|
+
if content:
|
|
71
|
+
import re
|
|
72
|
+
|
|
73
|
+
for m in re.finditer(r"-coverprofile.*?(?:grep\s+-v\s+)(.+?)(?:\s|$)", content):
|
|
74
|
+
exclusions.append(m.group(1))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Phase 4 — Fix broken tests."""
|
|
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 ProjectGraph
|
|
12
|
+
from forge_core.models.test_result import CoverageReport
|
|
13
|
+
from forge_core.utils import logger
|
|
14
|
+
from forge_core.utils.shell import run as shell_run
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
config: ForgeConfig,
|
|
19
|
+
file_manager: FileManager,
|
|
20
|
+
prompts_dir: Path,
|
|
21
|
+
project_graph: ProjectGraph,
|
|
22
|
+
baseline: CoverageReport,
|
|
23
|
+
learnings: str = "",
|
|
24
|
+
) -> int:
|
|
25
|
+
"""Fix broken tests. Returns count of tests fixed."""
|
|
26
|
+
tech = project_graph.tech_stack
|
|
27
|
+
|
|
28
|
+
# Run tests to get failure output
|
|
29
|
+
result = shell_run(tech.test_command, cwd=config.project_path, timeout=300)
|
|
30
|
+
if result.success:
|
|
31
|
+
return 0 # No broken tests
|
|
32
|
+
|
|
33
|
+
# Read failing test files
|
|
34
|
+
test_root = tech.test_root or "src/test"
|
|
35
|
+
test_files = file_manager.read_files(f"{test_root}/**/*")
|
|
36
|
+
|
|
37
|
+
prompt = load_prompt(prompts_dir, "fix-broken-tests")
|
|
38
|
+
system_prompt = prompt or (
|
|
39
|
+
"You are a test repair specialist. Fix the broken tests based on the error output.\n"
|
|
40
|
+
"Rules: never modify production code, never delete passing tests.\n"
|
|
41
|
+
"Return JSON with fixed_files: [{path, content}]."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if learnings:
|
|
45
|
+
system_prompt += f"\n\nPast learnings:\n{learnings[:2000]}"
|
|
46
|
+
|
|
47
|
+
file_context = build_file_context(test_files, max_files=30)
|
|
48
|
+
|
|
49
|
+
response = complete(
|
|
50
|
+
config=config.ai,
|
|
51
|
+
system_prompt=system_prompt,
|
|
52
|
+
user_prompt=(
|
|
53
|
+
f"Fix the broken tests. Here is the error output:\n\n"
|
|
54
|
+
f"```\n{result.output[:5000]}\n```\n\n"
|
|
55
|
+
f"Test files:\n{file_context}"
|
|
56
|
+
),
|
|
57
|
+
json_mode=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Parse and write fixed files
|
|
61
|
+
import json
|
|
62
|
+
|
|
63
|
+
fixed_count = 0
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(response)
|
|
66
|
+
for fix in data.get("fixed_files", []):
|
|
67
|
+
path = fix.get("path", "")
|
|
68
|
+
content = fix.get("content", "")
|
|
69
|
+
if path and content:
|
|
70
|
+
file_manager.write_file(path, content)
|
|
71
|
+
fixed_count += 1
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
logger.warn("Failed to parse fix response")
|
|
74
|
+
|
|
75
|
+
# Verify fixes don't break more tests
|
|
76
|
+
verify = shell_run(tech.test_command, cwd=config.project_path, timeout=300)
|
|
77
|
+
if not verify.success:
|
|
78
|
+
logger.warn("Fixes introduced new failures — rolling back")
|
|
79
|
+
file_manager.rollback()
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
file_manager.checkpoint()
|
|
83
|
+
return fixed_count
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Phase 5 — Iterative test generation with journey-weighted prioritization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forge_core.ai.prompts import build_file_context, load_prompt
|
|
10
|
+
from forge_core.ai.provider import complete
|
|
11
|
+
from forge_core.core.agent_manager import AgentManager
|
|
12
|
+
from forge_core.core.coverage import run_coverage
|
|
13
|
+
from forge_core.core.file_manager import FileManager
|
|
14
|
+
from forge_core.models.config import ForgeConfig
|
|
15
|
+
from forge_core.models.dto import DTORegistry
|
|
16
|
+
from forge_core.models.project import Component, ProjectGraph
|
|
17
|
+
from forge_core.models.test_result import IterationResult, TestFileResult
|
|
18
|
+
from forge_core.utils import logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class GenerationResult:
|
|
23
|
+
"""Aggregate result of the generation loop."""
|
|
24
|
+
|
|
25
|
+
iterations: list[IterationResult] = field(default_factory=list)
|
|
26
|
+
total_tests_generated: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
config: ForgeConfig,
|
|
31
|
+
file_manager: FileManager,
|
|
32
|
+
prompts_dir: Path,
|
|
33
|
+
project_graph: ProjectGraph,
|
|
34
|
+
dto_registry: DTORegistry,
|
|
35
|
+
agent_manager: AgentManager,
|
|
36
|
+
baseline_coverage: float,
|
|
37
|
+
learnings: str = "",
|
|
38
|
+
) -> GenerationResult:
|
|
39
|
+
"""Run the iterative test generation loop."""
|
|
40
|
+
result = GenerationResult()
|
|
41
|
+
tech = project_graph.tech_stack
|
|
42
|
+
|
|
43
|
+
# Build prioritized target list (journey-weighted)
|
|
44
|
+
targets = _prioritize_targets(project_graph)
|
|
45
|
+
if not targets:
|
|
46
|
+
logger.warn("No generation targets found")
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
logger.info(f"Targets: {len(targets)} components to test")
|
|
50
|
+
|
|
51
|
+
# Load prompts
|
|
52
|
+
write_prompt = load_prompt(prompts_dir, "write-unit-tests")
|
|
53
|
+
system_prompt = write_prompt or (
|
|
54
|
+
"You are a backend test engineer. Write unit tests for the given source code.\n"
|
|
55
|
+
"Rules: idiomatic tests, proper mocking, no production code changes.\n"
|
|
56
|
+
"Return JSON with test_files: [{path, content}]."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if learnings:
|
|
60
|
+
system_prompt += f"\n\nPast learnings:\n{learnings[:2000]}"
|
|
61
|
+
|
|
62
|
+
# Add DTO registry context
|
|
63
|
+
if dto_registry.count > 0:
|
|
64
|
+
dto_context = _build_dto_context(dto_registry)
|
|
65
|
+
system_prompt += f"\n\nDTO Registry (use these exact constructors):\n{dto_context}"
|
|
66
|
+
|
|
67
|
+
# Iterative loop
|
|
68
|
+
current_coverage = baseline_coverage
|
|
69
|
+
best_coverage = baseline_coverage
|
|
70
|
+
stall_count = 0
|
|
71
|
+
batch_size = 5
|
|
72
|
+
|
|
73
|
+
for iteration in range(1, config.max_iterations + 1):
|
|
74
|
+
batch_start = (iteration - 1) * batch_size
|
|
75
|
+
batch_targets = targets[batch_start : batch_start + batch_size]
|
|
76
|
+
|
|
77
|
+
if not batch_targets:
|
|
78
|
+
logger.info(f"All targets covered after {iteration - 1} iterations")
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
logger.info(f"Iteration {iteration}/{config.max_iterations}: {len(batch_targets)} targets")
|
|
82
|
+
|
|
83
|
+
# Register agent scope
|
|
84
|
+
scope_id = f"gen-iter-{iteration}"
|
|
85
|
+
agent_manager.register(scope_id, [t.file_path for t in batch_targets])
|
|
86
|
+
|
|
87
|
+
# Read source files for this batch
|
|
88
|
+
batch_files: dict[str, str] = {}
|
|
89
|
+
for target in batch_targets:
|
|
90
|
+
content = file_manager.read_file(target.file_path)
|
|
91
|
+
if content:
|
|
92
|
+
batch_files[target.file_path] = content
|
|
93
|
+
|
|
94
|
+
file_context = build_file_context(batch_files)
|
|
95
|
+
|
|
96
|
+
# Generate tests
|
|
97
|
+
response = complete(
|
|
98
|
+
config=config.ai,
|
|
99
|
+
system_prompt=system_prompt,
|
|
100
|
+
user_prompt=(
|
|
101
|
+
f"Write unit tests for these source files.\n\n"
|
|
102
|
+
f"Language: {tech.language}\n"
|
|
103
|
+
f"Framework: {tech.framework}\n"
|
|
104
|
+
f"Test framework: {tech.test_framework}\n"
|
|
105
|
+
f"Mock library: {tech.mock_library}\n\n"
|
|
106
|
+
f"{file_context}"
|
|
107
|
+
),
|
|
108
|
+
json_mode=True,
|
|
109
|
+
max_tokens=8192,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Parse and write test files
|
|
113
|
+
iter_result = IterationResult(iteration=iteration, coverage_before=current_coverage)
|
|
114
|
+
tests_written = _write_generated_tests(response, file_manager, iter_result)
|
|
115
|
+
|
|
116
|
+
if tests_written == 0:
|
|
117
|
+
agent_manager.record_error(scope_id, "no_tests_generated")
|
|
118
|
+
action = agent_manager.heartbeat(scope_id)
|
|
119
|
+
if action in ("split", "terminate"):
|
|
120
|
+
logger.warn(f"Agent {scope_id}: {action} — moving to next batch")
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Run coverage check
|
|
124
|
+
coverage = run_coverage(config.project_path, tech.coverage_command)
|
|
125
|
+
iter_result.coverage_after = coverage.line_coverage
|
|
126
|
+
iter_result.coverage_delta = coverage.line_coverage - current_coverage
|
|
127
|
+
|
|
128
|
+
# Rollback protection
|
|
129
|
+
if coverage.line_coverage < best_coverage:
|
|
130
|
+
logger.warn(
|
|
131
|
+
f"Coverage dropped {best_coverage:.1f}% → {coverage.line_coverage:.1f}% — rolling back"
|
|
132
|
+
)
|
|
133
|
+
file_manager.rollback()
|
|
134
|
+
iter_result.rolled_back = True
|
|
135
|
+
else:
|
|
136
|
+
file_manager.checkpoint()
|
|
137
|
+
agent_manager.record_progress(scope_id)
|
|
138
|
+
if coverage.line_coverage > best_coverage:
|
|
139
|
+
best_coverage = coverage.line_coverage
|
|
140
|
+
current_coverage = coverage.line_coverage
|
|
141
|
+
|
|
142
|
+
agent_manager.complete(scope_id)
|
|
143
|
+
result.iterations.append(iter_result)
|
|
144
|
+
|
|
145
|
+
# Early exit if target reached
|
|
146
|
+
if current_coverage >= config.target_coverage:
|
|
147
|
+
logger.success(f"Target coverage {config.target_coverage}% reached!")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# Stall detection
|
|
151
|
+
if iter_result.coverage_delta < 0.5:
|
|
152
|
+
stall_count += 1
|
|
153
|
+
if stall_count >= 3:
|
|
154
|
+
logger.warn("Coverage stalled for 3 iterations — stopping")
|
|
155
|
+
break
|
|
156
|
+
else:
|
|
157
|
+
stall_count = 0
|
|
158
|
+
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _prioritize_targets(graph: ProjectGraph) -> list[Component]:
|
|
163
|
+
"""Build journey-weighted priority list of components to test."""
|
|
164
|
+
all_components: list[Component] = []
|
|
165
|
+
|
|
166
|
+
# Collect all components
|
|
167
|
+
for module in graph.modules:
|
|
168
|
+
# Components in journeys get highest priority
|
|
169
|
+
journey_components = set()
|
|
170
|
+
for journey in module.journeys:
|
|
171
|
+
journey_components.update(journey.components)
|
|
172
|
+
|
|
173
|
+
for layer in module.layers:
|
|
174
|
+
for comp in layer.components:
|
|
175
|
+
if not comp.is_tested:
|
|
176
|
+
# Boost priority for journey components
|
|
177
|
+
if comp.name in journey_components:
|
|
178
|
+
all_components.insert(0, comp)
|
|
179
|
+
else:
|
|
180
|
+
all_components.append(comp)
|
|
181
|
+
|
|
182
|
+
return all_components
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_dto_context(registry: DTORegistry) -> str:
|
|
186
|
+
"""Build a compact DTO reference for the system prompt."""
|
|
187
|
+
lines: list[str] = []
|
|
188
|
+
for entry in list(registry.entries.values())[:50]: # cap at 50 DTOs
|
|
189
|
+
params = ", ".join(f"{p.name}: {p.type}" for p in entry.params)
|
|
190
|
+
lines.append(f"- {entry.class_name}({params})")
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write_generated_tests(
|
|
195
|
+
response: str, file_manager: FileManager, iter_result: IterationResult
|
|
196
|
+
) -> int:
|
|
197
|
+
"""Parse AI response and write test files. Returns count written."""
|
|
198
|
+
count = 0
|
|
199
|
+
try:
|
|
200
|
+
data = json.loads(response)
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
logger.warn("Failed to parse test generation JSON")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
for test in data.get("test_files", []):
|
|
206
|
+
path = test.get("path", "")
|
|
207
|
+
content = test.get("content", "")
|
|
208
|
+
if path and content:
|
|
209
|
+
file_manager.write_file(path, content)
|
|
210
|
+
iter_result.tests_generated.append(
|
|
211
|
+
TestFileResult(
|
|
212
|
+
file_path=path,
|
|
213
|
+
test_count=content.count("@Test") + content.count("def test_") + content.count("func Test"),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
count += 1
|
|
217
|
+
|
|
218
|
+
return count
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Phase 2.5 — Journey mapping & DTO registry building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forge_core.ai.prompts import build_file_context, load_prompt
|
|
9
|
+
from forge_core.ai.provider import complete
|
|
10
|
+
from forge_core.core.file_manager import FileManager
|
|
11
|
+
from forge_core.models.config import ForgeConfig
|
|
12
|
+
from forge_core.models.dto import DTOEntry, DTOParam, DTORegistry
|
|
13
|
+
from forge_core.models.project import Journey, ProjectGraph
|
|
14
|
+
from forge_core.utils import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
config: ForgeConfig,
|
|
19
|
+
file_manager: FileManager,
|
|
20
|
+
prompts_dir: Path,
|
|
21
|
+
project_graph: ProjectGraph,
|
|
22
|
+
learnings: str = "",
|
|
23
|
+
) -> DTORegistry:
|
|
24
|
+
"""Map journeys and build the DTO registry."""
|
|
25
|
+
registry = DTORegistry()
|
|
26
|
+
tech = project_graph.tech_stack
|
|
27
|
+
|
|
28
|
+
# Read all source files for journey tracing
|
|
29
|
+
source_root = tech.source_root or "src"
|
|
30
|
+
source_files = file_manager.read_files(f"{source_root}/**/*")
|
|
31
|
+
code_exts = _code_exts(tech.language)
|
|
32
|
+
source_files = {k: v for k, v in source_files.items() if any(k.endswith(e) for e in code_exts)}
|
|
33
|
+
|
|
34
|
+
prompt = load_prompt(prompts_dir, "journey-mapping")
|
|
35
|
+
system_prompt = prompt or (
|
|
36
|
+
"You are a senior backend engineer. Trace all user journeys through this codebase.\n"
|
|
37
|
+
"For each journey: identify entry point, trace through all layers, catalog every DTO.\n"
|
|
38
|
+
"Return JSON with journeys and dto_registry."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if learnings:
|
|
42
|
+
system_prompt += f"\n\nPast learnings:\n{learnings[:2000]}"
|
|
43
|
+
|
|
44
|
+
file_context = build_file_context(source_files, max_files=80)
|
|
45
|
+
|
|
46
|
+
response = complete(
|
|
47
|
+
config=config.ai,
|
|
48
|
+
system_prompt=system_prompt,
|
|
49
|
+
user_prompt=(
|
|
50
|
+
f"Trace all user journeys and catalog all DTOs in this project.\n\n"
|
|
51
|
+
f"Project: {config.project_path.name}\n"
|
|
52
|
+
f"Language: {tech.language}\n"
|
|
53
|
+
f"Framework: {tech.framework}\n\n"
|
|
54
|
+
f"{file_context}"
|
|
55
|
+
),
|
|
56
|
+
json_mode=True,
|
|
57
|
+
max_tokens=8192,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_parse_journey_response(response, project_graph, registry)
|
|
61
|
+
|
|
62
|
+
return registry
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_journey_response(
|
|
66
|
+
response: str, graph: ProjectGraph, registry: DTORegistry
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Parse AI response into journeys and DTO registry."""
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(response)
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
logger.warn("Failed to parse journey mapping JSON")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Parse journeys
|
|
76
|
+
for j_data in data.get("journeys", []):
|
|
77
|
+
journey = Journey(
|
|
78
|
+
name=j_data.get("name", ""),
|
|
79
|
+
entry_point=j_data.get("entry_point", ""),
|
|
80
|
+
entry_type=j_data.get("entry_type", ""),
|
|
81
|
+
components=j_data.get("components", []),
|
|
82
|
+
priority=j_data.get("priority", 3),
|
|
83
|
+
description=j_data.get("description", ""),
|
|
84
|
+
)
|
|
85
|
+
# Add to first module (or create default)
|
|
86
|
+
if graph.modules:
|
|
87
|
+
graph.modules[0].journeys.append(journey)
|
|
88
|
+
|
|
89
|
+
# Parse DTOs
|
|
90
|
+
for dto_data in data.get("dto_registry", data.get("dtos", [])):
|
|
91
|
+
params = [
|
|
92
|
+
DTOParam(
|
|
93
|
+
name=p.get("name", ""),
|
|
94
|
+
type=p.get("type", ""),
|
|
95
|
+
default=p.get("default", ""),
|
|
96
|
+
nullable=p.get("nullable", False),
|
|
97
|
+
)
|
|
98
|
+
for p in dto_data.get("params", dto_data.get("constructor_params", []))
|
|
99
|
+
]
|
|
100
|
+
entry = DTOEntry(
|
|
101
|
+
class_name=dto_data.get("class_name", dto_data.get("name", "")),
|
|
102
|
+
package=dto_data.get("package", ""),
|
|
103
|
+
file_path=dto_data.get("file_path", ""),
|
|
104
|
+
params=params,
|
|
105
|
+
has_builder=dto_data.get("has_builder", False),
|
|
106
|
+
has_factory=dto_data.get("has_factory", False),
|
|
107
|
+
nested_dtos=dto_data.get("nested_dtos", []),
|
|
108
|
+
used_in_journeys=dto_data.get("used_in_journeys", []),
|
|
109
|
+
used_in_layers=dto_data.get("used_in_layers", []),
|
|
110
|
+
)
|
|
111
|
+
registry.register(entry)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _code_exts(language: str) -> list[str]:
|
|
115
|
+
ext_map = {
|
|
116
|
+
"kotlin": [".kt"], "java": [".java"], "python": [".py"],
|
|
117
|
+
"go": [".go"], "typescript": [".ts"], "javascript": [".js"],
|
|
118
|
+
"rust": [".rs"], "csharp": [".cs"], "ruby": [".rb"], "php": [".php"],
|
|
119
|
+
}
|
|
120
|
+
return ext_map.get(language.lower(), [".py", ".java", ".kt"])
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Phase 7 — Self-learning: record patterns to LEARNINGS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forge_core.ai.prompts import load_prompt
|
|
10
|
+
from forge_core.ai.provider import complete
|
|
11
|
+
from forge_core.core.file_manager import FileManager
|
|
12
|
+
from forge_core.models.config import ForgeConfig
|
|
13
|
+
from forge_core.models.project import ProjectGraph
|
|
14
|
+
from forge_core.models.test_result import RunReport
|
|
15
|
+
from forge_core.utils import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(
|
|
19
|
+
config: ForgeConfig,
|
|
20
|
+
file_manager: FileManager,
|
|
21
|
+
prompts_dir: Path,
|
|
22
|
+
project_graph: ProjectGraph,
|
|
23
|
+
report: RunReport,
|
|
24
|
+
learnings: str = "",
|
|
25
|
+
) -> int:
|
|
26
|
+
"""Record patterns learned from this run. Returns count of new patterns."""
|
|
27
|
+
prompt = load_prompt(prompts_dir, "self-learn")
|
|
28
|
+
system_prompt = prompt or (
|
|
29
|
+
"You are a learning engine. Analyze the test generation run and extract reusable patterns.\n"
|
|
30
|
+
"Focus on: mocking patterns, assertion styles, framework quirks, error patterns.\n"
|
|
31
|
+
"Return JSON with patterns: [{name, description, language, example}]."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Build run summary for AI
|
|
35
|
+
run_summary = (
|
|
36
|
+
f"Project: {report.project_name}\n"
|
|
37
|
+
f"Language: {report.language}, Framework: {report.framework}\n"
|
|
38
|
+
f"Coverage: {report.coverage_before:.1f}% → {report.coverage_after:.1f}%\n"
|
|
39
|
+
f"Tests generated: {report.tests_generated}, Fixed: {report.tests_fixed}\n"
|
|
40
|
+
f"Iterations: {report.total_iterations}, Rollbacks: {report.rollbacks}\n"
|
|
41
|
+
f"Journeys mapped: {report.journeys_mapped}, DTOs: {report.dtos_registered}\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if learnings:
|
|
45
|
+
run_summary += f"\nExisting learnings (don't duplicate):\n{learnings[:3000]}"
|
|
46
|
+
|
|
47
|
+
response = complete(
|
|
48
|
+
config=config.ai,
|
|
49
|
+
system_prompt=system_prompt,
|
|
50
|
+
user_prompt=f"Extract patterns from this run:\n\n{run_summary}",
|
|
51
|
+
json_mode=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Parse patterns
|
|
55
|
+
new_patterns: list[dict] = []
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(response)
|
|
58
|
+
new_patterns = data.get("patterns", [])
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
logger.warn("Failed to parse self-learn response")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
if not new_patterns:
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
# Append to LEARNINGS.md
|
|
67
|
+
learnings_path = config.project_path / "LEARNINGS.md"
|
|
68
|
+
_append_learnings(learnings_path, report, new_patterns)
|
|
69
|
+
|
|
70
|
+
# Sync to central hub if configured
|
|
71
|
+
if config.central_agent_path:
|
|
72
|
+
central_path = Path(config.central_agent_path) / "LEARNINGS.md"
|
|
73
|
+
_append_learnings(central_path, report, new_patterns)
|
|
74
|
+
|
|
75
|
+
return len(new_patterns)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _append_learnings(
|
|
79
|
+
path: Path, report: RunReport, patterns: list[dict]
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Append new patterns to a LEARNINGS.md file."""
|
|
82
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
83
|
+
|
|
84
|
+
section = f"\n\n## Run: {report.project_name} ({datetime.now().strftime('%Y-%m-%d %H:%M')})\n"
|
|
85
|
+
section += f"- Coverage: {report.coverage_before:.1f}% → {report.coverage_after:.1f}%\n"
|
|
86
|
+
section += f"- Language: {report.language}/{report.framework}\n\n"
|
|
87
|
+
|
|
88
|
+
for p in patterns:
|
|
89
|
+
name = p.get("name", "Pattern")
|
|
90
|
+
desc = p.get("description", "")
|
|
91
|
+
section += f"### {name}\n{desc}\n\n"
|
|
92
|
+
|
|
93
|
+
path.write_text(existing + section, encoding="utf-8")
|
|
File without changes
|