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.
@@ -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