codexa 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. codexa-0.4.0.dist-info/METADATA +650 -0
  2. codexa-0.4.0.dist-info/RECORD +189 -0
  3. codexa-0.4.0.dist-info/WHEEL +5 -0
  4. codexa-0.4.0.dist-info/entry_points.txt +2 -0
  5. codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. codexa-0.4.0.dist-info/top_level.txt +1 -0
  7. semantic_code_intelligence/__init__.py +5 -0
  8. semantic_code_intelligence/analysis/__init__.py +21 -0
  9. semantic_code_intelligence/analysis/ai_features.py +351 -0
  10. semantic_code_intelligence/bridge/__init__.py +28 -0
  11. semantic_code_intelligence/bridge/context_provider.py +245 -0
  12. semantic_code_intelligence/bridge/protocol.py +167 -0
  13. semantic_code_intelligence/bridge/server.py +348 -0
  14. semantic_code_intelligence/bridge/vscode.py +271 -0
  15. semantic_code_intelligence/ci/__init__.py +13 -0
  16. semantic_code_intelligence/ci/hooks.py +98 -0
  17. semantic_code_intelligence/ci/hotspots.py +272 -0
  18. semantic_code_intelligence/ci/impact.py +246 -0
  19. semantic_code_intelligence/ci/metrics.py +591 -0
  20. semantic_code_intelligence/ci/pr.py +412 -0
  21. semantic_code_intelligence/ci/quality.py +557 -0
  22. semantic_code_intelligence/ci/templates.py +164 -0
  23. semantic_code_intelligence/ci/trace.py +224 -0
  24. semantic_code_intelligence/cli/__init__.py +0 -0
  25. semantic_code_intelligence/cli/commands/__init__.py +0 -0
  26. semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
  27. semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
  28. semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
  29. semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
  30. semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
  31. semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
  32. semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
  33. semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
  34. semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
  35. semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
  36. semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
  37. semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
  38. semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
  39. semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
  40. semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
  41. semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
  42. semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
  43. semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
  44. semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
  45. semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
  46. semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
  47. semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
  48. semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
  49. semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
  50. semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
  51. semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
  52. semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
  53. semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
  54. semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
  55. semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
  56. semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
  57. semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
  58. semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
  59. semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
  60. semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
  61. semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
  62. semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
  63. semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
  64. semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
  65. semantic_code_intelligence/cli/main.py +65 -0
  66. semantic_code_intelligence/cli/router.py +92 -0
  67. semantic_code_intelligence/config/__init__.py +0 -0
  68. semantic_code_intelligence/config/settings.py +260 -0
  69. semantic_code_intelligence/context/__init__.py +19 -0
  70. semantic_code_intelligence/context/engine.py +429 -0
  71. semantic_code_intelligence/context/memory.py +253 -0
  72. semantic_code_intelligence/daemon/__init__.py +1 -0
  73. semantic_code_intelligence/daemon/watcher.py +515 -0
  74. semantic_code_intelligence/docs/__init__.py +1080 -0
  75. semantic_code_intelligence/embeddings/__init__.py +0 -0
  76. semantic_code_intelligence/embeddings/enhanced.py +131 -0
  77. semantic_code_intelligence/embeddings/generator.py +149 -0
  78. semantic_code_intelligence/embeddings/model_registry.py +100 -0
  79. semantic_code_intelligence/evolution/__init__.py +1 -0
  80. semantic_code_intelligence/evolution/budget_guard.py +111 -0
  81. semantic_code_intelligence/evolution/commit_manager.py +88 -0
  82. semantic_code_intelligence/evolution/context_builder.py +131 -0
  83. semantic_code_intelligence/evolution/engine.py +249 -0
  84. semantic_code_intelligence/evolution/patch_generator.py +229 -0
  85. semantic_code_intelligence/evolution/task_selector.py +214 -0
  86. semantic_code_intelligence/evolution/test_runner.py +111 -0
  87. semantic_code_intelligence/indexing/__init__.py +0 -0
  88. semantic_code_intelligence/indexing/chunker.py +174 -0
  89. semantic_code_intelligence/indexing/parallel.py +86 -0
  90. semantic_code_intelligence/indexing/scanner.py +146 -0
  91. semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
  92. semantic_code_intelligence/llm/__init__.py +62 -0
  93. semantic_code_intelligence/llm/cache.py +219 -0
  94. semantic_code_intelligence/llm/cached_provider.py +145 -0
  95. semantic_code_intelligence/llm/conversation.py +190 -0
  96. semantic_code_intelligence/llm/cross_refactor.py +272 -0
  97. semantic_code_intelligence/llm/investigation.py +274 -0
  98. semantic_code_intelligence/llm/mock_provider.py +77 -0
  99. semantic_code_intelligence/llm/ollama_provider.py +122 -0
  100. semantic_code_intelligence/llm/openai_provider.py +100 -0
  101. semantic_code_intelligence/llm/provider.py +92 -0
  102. semantic_code_intelligence/llm/rate_limiter.py +164 -0
  103. semantic_code_intelligence/llm/reasoning.py +438 -0
  104. semantic_code_intelligence/llm/safety.py +110 -0
  105. semantic_code_intelligence/llm/streaming.py +251 -0
  106. semantic_code_intelligence/lsp/__init__.py +609 -0
  107. semantic_code_intelligence/mcp/__init__.py +393 -0
  108. semantic_code_intelligence/parsing/__init__.py +19 -0
  109. semantic_code_intelligence/parsing/parser.py +375 -0
  110. semantic_code_intelligence/plugins/__init__.py +255 -0
  111. semantic_code_intelligence/plugins/examples/__init__.py +1 -0
  112. semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
  113. semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
  114. semantic_code_intelligence/scalability/__init__.py +205 -0
  115. semantic_code_intelligence/search/__init__.py +0 -0
  116. semantic_code_intelligence/search/formatter.py +123 -0
  117. semantic_code_intelligence/search/grep.py +361 -0
  118. semantic_code_intelligence/search/hybrid_search.py +170 -0
  119. semantic_code_intelligence/search/keyword_search.py +311 -0
  120. semantic_code_intelligence/search/section_expander.py +103 -0
  121. semantic_code_intelligence/services/__init__.py +0 -0
  122. semantic_code_intelligence/services/indexing_service.py +630 -0
  123. semantic_code_intelligence/services/search_service.py +269 -0
  124. semantic_code_intelligence/storage/__init__.py +0 -0
  125. semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
  126. semantic_code_intelligence/storage/hash_store.py +66 -0
  127. semantic_code_intelligence/storage/index_manifest.py +85 -0
  128. semantic_code_intelligence/storage/index_stats.py +138 -0
  129. semantic_code_intelligence/storage/query_history.py +160 -0
  130. semantic_code_intelligence/storage/symbol_registry.py +209 -0
  131. semantic_code_intelligence/storage/vector_store.py +297 -0
  132. semantic_code_intelligence/tests/__init__.py +0 -0
  133. semantic_code_intelligence/tests/test_ai_features.py +351 -0
  134. semantic_code_intelligence/tests/test_chunker.py +119 -0
  135. semantic_code_intelligence/tests/test_cli.py +188 -0
  136. semantic_code_intelligence/tests/test_config.py +154 -0
  137. semantic_code_intelligence/tests/test_context.py +381 -0
  138. semantic_code_intelligence/tests/test_embeddings.py +73 -0
  139. semantic_code_intelligence/tests/test_endtoend.py +1142 -0
  140. semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
  141. semantic_code_intelligence/tests/test_hash_store.py +79 -0
  142. semantic_code_intelligence/tests/test_logging.py +55 -0
  143. semantic_code_intelligence/tests/test_new_cli.py +138 -0
  144. semantic_code_intelligence/tests/test_parser.py +495 -0
  145. semantic_code_intelligence/tests/test_phase10.py +355 -0
  146. semantic_code_intelligence/tests/test_phase11.py +593 -0
  147. semantic_code_intelligence/tests/test_phase12.py +375 -0
  148. semantic_code_intelligence/tests/test_phase13.py +663 -0
  149. semantic_code_intelligence/tests/test_phase14.py +568 -0
  150. semantic_code_intelligence/tests/test_phase15.py +814 -0
  151. semantic_code_intelligence/tests/test_phase16.py +792 -0
  152. semantic_code_intelligence/tests/test_phase17.py +815 -0
  153. semantic_code_intelligence/tests/test_phase18.py +934 -0
  154. semantic_code_intelligence/tests/test_phase19.py +986 -0
  155. semantic_code_intelligence/tests/test_phase20.py +2753 -0
  156. semantic_code_intelligence/tests/test_phase20b.py +2058 -0
  157. semantic_code_intelligence/tests/test_phase20c.py +962 -0
  158. semantic_code_intelligence/tests/test_phase21.py +428 -0
  159. semantic_code_intelligence/tests/test_phase22.py +799 -0
  160. semantic_code_intelligence/tests/test_phase23.py +783 -0
  161. semantic_code_intelligence/tests/test_phase24.py +715 -0
  162. semantic_code_intelligence/tests/test_phase25.py +496 -0
  163. semantic_code_intelligence/tests/test_phase26.py +251 -0
  164. semantic_code_intelligence/tests/test_phase27.py +531 -0
  165. semantic_code_intelligence/tests/test_phase8.py +592 -0
  166. semantic_code_intelligence/tests/test_phase9.py +643 -0
  167. semantic_code_intelligence/tests/test_plugins.py +293 -0
  168. semantic_code_intelligence/tests/test_priority_features.py +727 -0
  169. semantic_code_intelligence/tests/test_router.py +41 -0
  170. semantic_code_intelligence/tests/test_scalability.py +138 -0
  171. semantic_code_intelligence/tests/test_scanner.py +125 -0
  172. semantic_code_intelligence/tests/test_search.py +160 -0
  173. semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
  174. semantic_code_intelligence/tests/test_tools.py +182 -0
  175. semantic_code_intelligence/tests/test_vector_store.py +151 -0
  176. semantic_code_intelligence/tests/test_watcher.py +211 -0
  177. semantic_code_intelligence/tools/__init__.py +442 -0
  178. semantic_code_intelligence/tools/executor.py +232 -0
  179. semantic_code_intelligence/tools/protocol.py +200 -0
  180. semantic_code_intelligence/tui/__init__.py +454 -0
  181. semantic_code_intelligence/utils/__init__.py +0 -0
  182. semantic_code_intelligence/utils/logging.py +112 -0
  183. semantic_code_intelligence/version.py +3 -0
  184. semantic_code_intelligence/web/__init__.py +11 -0
  185. semantic_code_intelligence/web/api.py +289 -0
  186. semantic_code_intelligence/web/server.py +397 -0
  187. semantic_code_intelligence/web/ui.py +659 -0
  188. semantic_code_intelligence/web/visualize.py +226 -0
  189. semantic_code_intelligence/workspace/__init__.py +427 -0
@@ -0,0 +1,131 @@
1
+ """Context builder — constructs minimal LLM prompt context.
2
+
3
+ The builder gathers *only* the information the LLM needs to generate a
4
+ patch: the task description, the target file contents (or relevant
5
+ excerpts), the git diff, and failing test output. The assembled context
6
+ is capped at a configurable token target (default 2 000 tokens ≈ 8 000
7
+ chars) to keep LLM costs low.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from semantic_code_intelligence.evolution.budget_guard import BudgetGuard
15
+ from semantic_code_intelligence.evolution.commit_manager import CommitManager
16
+ from semantic_code_intelligence.evolution.task_selector import EvolutionTask
17
+ from semantic_code_intelligence.utils.logging import get_logger
18
+
19
+ logger = get_logger("evolution.context_builder")
20
+
21
+ # Rough approximation: 1 token ≈ 4 characters
22
+ _CHARS_PER_TOKEN = 4
23
+ _DEFAULT_MAX_TOKENS = 2000
24
+
25
+ # System prompt used for every evolution call
26
+ SYSTEM_PROMPT = (
27
+ "You are a senior software engineer improving the CodexA codebase. "
28
+ "Your job is to make a SMALL, SAFE improvement. Rules:\n"
29
+ "- Change at most 3 files and 200 lines.\n"
30
+ "- Output ONLY a unified diff (no explanation before or after).\n"
31
+ "- Do NOT add new dependencies.\n"
32
+ "- Do NOT rewrite subsystems or change architecture.\n"
33
+ "- Preserve all existing tests.\n"
34
+ "- Use full type hints.\n"
35
+ "- Keep it simple."
36
+ )
37
+
38
+
39
+ class ContextBuilder:
40
+ """Assembles a minimal prompt context for the patch generator."""
41
+
42
+ def __init__(
43
+ self,
44
+ project_root: Path,
45
+ commit_manager: CommitManager,
46
+ max_context_tokens: int = _DEFAULT_MAX_TOKENS,
47
+ ) -> None:
48
+ self._root = project_root.resolve()
49
+ self._git = commit_manager
50
+ self._max_chars = max_context_tokens * _CHARS_PER_TOKEN
51
+
52
+ def build(self, task: EvolutionTask) -> str:
53
+ """Build the user-message portion of the LLM prompt.
54
+
55
+ Sections (included in priority order until budget exhausted):
56
+ 1. Task description (always included)
57
+ 2. Target file contents (trimmed to budget)
58
+ 3. Git diff (if any)
59
+ 4. Failing test output (if present in task context_hint)
60
+ """
61
+ parts: list[str] = []
62
+ budget = self._max_chars
63
+
64
+ # 1. Task description
65
+ header = self._task_section(task)
66
+ parts.append(header)
67
+ budget -= len(header)
68
+
69
+ # 2. Target file contents
70
+ for rel_path in task.target_files[:3]:
71
+ if budget <= 200:
72
+ break
73
+ section = self._file_section(rel_path, budget)
74
+ if section:
75
+ parts.append(section)
76
+ budget -= len(section)
77
+
78
+ # 3. Git diff
79
+ if budget > 200:
80
+ diff = self._git.git_diff()
81
+ if diff.strip():
82
+ diff_section = f"### Current git diff\n```diff\n{_truncate(diff, budget - 60)}\n```"
83
+ parts.append(diff_section)
84
+ budget -= len(diff_section)
85
+
86
+ # 4. Context hint (failing test output, etc.)
87
+ if budget > 200 and task.context_hint:
88
+ hint = f"### Additional context\n```\n{_truncate(task.context_hint, budget - 60)}\n```"
89
+ parts.append(hint)
90
+
91
+ return "\n\n".join(parts)
92
+
93
+ def estimate_tokens(self, text: str) -> int:
94
+ """Rough token estimate for a text string."""
95
+ return max(1, len(text) // _CHARS_PER_TOKEN)
96
+
97
+ # ------------------------------------------------------------------ #
98
+ # Section builders
99
+ # ------------------------------------------------------------------ #
100
+
101
+ @staticmethod
102
+ def _task_section(task: EvolutionTask) -> str:
103
+ lines = [
104
+ f"### Task: {task.category}",
105
+ task.description,
106
+ ]
107
+ if task.target_files:
108
+ lines.append(f"Target files: {', '.join(task.target_files)}")
109
+ lines.append(
110
+ "\nProduce a unified diff that implements this improvement."
111
+ )
112
+ return "\n".join(lines)
113
+
114
+ def _file_section(self, rel_path: str, budget: int) -> str | None:
115
+ """Read a target file and return a fenced code block."""
116
+ full = self._root / rel_path
117
+ if not full.exists():
118
+ return None
119
+ try:
120
+ content = full.read_text(encoding="utf-8", errors="replace")
121
+ except OSError:
122
+ return None
123
+ content = _truncate(content, budget - 80)
124
+ return f"### File: {rel_path}\n```python\n{content}\n```"
125
+
126
+
127
+ def _truncate(text: str, max_chars: int) -> str:
128
+ """Truncate *text* to at most *max_chars*, appending '…' if trimmed."""
129
+ if len(text) <= max_chars:
130
+ return text
131
+ return text[:max_chars] + "\n... (truncated)"
@@ -0,0 +1,249 @@
1
+ """Evolution engine — orchestrates the self-improving development loop.
2
+
3
+ Each iteration:
4
+ 1. Run tests to get baseline.
5
+ 2. Select a task (fix tests → type hints → error handling → dedup → optimise).
6
+ 3. Build minimal LLM context.
7
+ 4. Generate + apply patch via LLM.
8
+ 5. Run tests again.
9
+ 6. If tests pass → commit. If tests fail → revert.
10
+ 7. Repeat until budget exhausted.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from semantic_code_intelligence.evolution.budget_guard import BudgetGuard
22
+ from semantic_code_intelligence.evolution.commit_manager import CommitManager
23
+ from semantic_code_intelligence.evolution.context_builder import ContextBuilder
24
+ from semantic_code_intelligence.evolution.patch_generator import PatchGenerator, PatchResult
25
+ from semantic_code_intelligence.evolution.task_selector import EvolutionTask, TaskSelector
26
+ from semantic_code_intelligence.evolution.test_runner import TestResult, TestRunner
27
+ from semantic_code_intelligence.llm.provider import LLMProvider
28
+ from semantic_code_intelligence.utils.logging import get_logger
29
+
30
+ logger = get_logger("evolution.engine")
31
+
32
+
33
+ # ------------------------------------------------------------------ #
34
+ # Result dataclasses
35
+ # ------------------------------------------------------------------ #
36
+
37
+
38
+ @dataclass
39
+ class IterationRecord:
40
+ """Record of a single evolution iteration."""
41
+
42
+ iteration: int = 0
43
+ task_category: str = ""
44
+ task_description: str = ""
45
+ target_files: list[str] = field(default_factory=list)
46
+ patch_lines_changed: int = 0
47
+ tests_before: int = 0
48
+ tests_after: int = 0
49
+ committed: bool = False
50
+ commit_sha: str = ""
51
+ reverted: bool = False
52
+ error: str = ""
53
+ duration_seconds: float = 0.0
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Serialise the iteration record to a plain dictionary."""
57
+ return {
58
+ "iteration": self.iteration,
59
+ "task_category": self.task_category,
60
+ "task_description": self.task_description,
61
+ "target_files": self.target_files,
62
+ "patch_lines_changed": self.patch_lines_changed,
63
+ "tests_before": self.tests_before,
64
+ "tests_after": self.tests_after,
65
+ "committed": self.committed,
66
+ "commit_sha": self.commit_sha,
67
+ "reverted": self.reverted,
68
+ "error": self.error,
69
+ "duration_seconds": round(self.duration_seconds, 2),
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class EvolutionResult:
75
+ """Aggregate result of an evolution run."""
76
+
77
+ iterations_completed: int = 0
78
+ commits: list[str] = field(default_factory=list)
79
+ reverts: int = 0
80
+ stop_reason: str = ""
81
+ budget_summary: dict[str, object] = field(default_factory=dict)
82
+ history: list[IterationRecord] = field(default_factory=list)
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ """Serialise the evolution result to a plain dictionary."""
86
+ return {
87
+ "iterations_completed": self.iterations_completed,
88
+ "commits": self.commits,
89
+ "reverts": self.reverts,
90
+ "stop_reason": self.stop_reason,
91
+ "budget_summary": self.budget_summary,
92
+ "history": [r.to_dict() for r in self.history],
93
+ }
94
+
95
+
96
+ # ------------------------------------------------------------------ #
97
+ # Engine
98
+ # ------------------------------------------------------------------ #
99
+
100
+
101
+ class EvolutionEngine:
102
+ """Runs the self-improving development loop."""
103
+
104
+ def __init__(
105
+ self,
106
+ project_root: Path,
107
+ provider: LLMProvider,
108
+ budget: BudgetGuard,
109
+ ) -> None:
110
+ self._root = project_root.resolve()
111
+ self._provider = provider
112
+ self._budget = budget
113
+
114
+ # Build sub-components
115
+ self._test_runner = TestRunner(project_root=self._root)
116
+ self._commit_mgr = CommitManager(project_root=self._root)
117
+ self._ctx_builder = ContextBuilder(
118
+ project_root=self._root,
119
+ commit_manager=self._commit_mgr,
120
+ )
121
+ self._task_selector = TaskSelector(
122
+ project_root=self._root,
123
+ test_runner=self._test_runner,
124
+ commit_manager=self._commit_mgr,
125
+ )
126
+ self._patch_gen = PatchGenerator(
127
+ project_root=self._root,
128
+ provider=self._provider,
129
+ context_builder=self._ctx_builder,
130
+ budget=self._budget,
131
+ )
132
+
133
+ # ------------------------------------------------------------------ #
134
+ # Main loop
135
+ # ------------------------------------------------------------------ #
136
+
137
+ def run(self) -> EvolutionResult:
138
+ """Execute the evolution loop until the budget is exhausted."""
139
+ result = EvolutionResult()
140
+ self._budget.start()
141
+
142
+ # Baseline test run
143
+ last_test = self._test_runner.run()
144
+ logger.info("baseline tests: %s", last_test.summary_line())
145
+
146
+ iteration = 0
147
+ while self._budget.can_continue():
148
+ iteration += 1
149
+ iter_start = time.time()
150
+ record = IterationRecord(iteration=iteration, tests_before=last_test.passed)
151
+
152
+ try:
153
+ last_test = self._run_iteration(
154
+ iteration, last_test, record, result,
155
+ )
156
+ except Exception as exc:
157
+ record.error = f"unexpected error: {exc}"
158
+ logger.error("iter %d: %s", iteration, record.error)
159
+
160
+ record.duration_seconds = time.time() - iter_start
161
+ result.history.append(record)
162
+ self._budget.record_iteration()
163
+
164
+ result.iterations_completed = iteration
165
+ result.stop_reason = self._budget.stop_reason() or "completed"
166
+ result.budget_summary = self._budget.summary()
167
+
168
+ # Persist history
169
+ self._write_history(result)
170
+ return result
171
+
172
+ # ------------------------------------------------------------------ #
173
+ # Single iteration
174
+ # ------------------------------------------------------------------ #
175
+
176
+ def _run_iteration(
177
+ self,
178
+ iteration: int,
179
+ last_test: TestResult,
180
+ record: IterationRecord,
181
+ result: EvolutionResult,
182
+ ) -> TestResult:
183
+ """Execute one evolution iteration. Returns the latest TestResult."""
184
+ # 1. Select task
185
+ task = self._task_selector.select(last_test)
186
+ record.task_category = task.category
187
+ record.task_description = task.description
188
+ record.target_files = list(task.target_files)
189
+ logger.info("iter %d: %s — %s", iteration, task.category, task.description)
190
+
191
+ # 2. Generate + apply patch
192
+ patch_result = self._patch_gen.generate_and_apply(task)
193
+ record.patch_lines_changed = patch_result.lines_changed
194
+
195
+ if not patch_result.success:
196
+ record.error = patch_result.error
197
+ logger.warning("iter %d: patch failed — %s", iteration, patch_result.error)
198
+ return last_test
199
+
200
+ # 3. Re-run tests
201
+ new_test = self._test_runner.run()
202
+ record.tests_after = new_test.passed
203
+ logger.info(
204
+ "iter %d: tests %d -> %d",
205
+ iteration, last_test.passed, new_test.passed,
206
+ )
207
+
208
+ # 4. Decide: commit or revert
209
+ if new_test.return_code == 0 and new_test.passed >= last_test.passed:
210
+ self._commit_mgr.stage_files(patch_result.files_changed)
211
+ sha = self._commit_mgr.commit(
212
+ f"evolve: {task.category} — {task.description[:60]}"
213
+ )
214
+ record.committed = True
215
+ record.commit_sha = sha
216
+ result.commits.append(sha)
217
+ logger.info("iter %d: committed %s", iteration, sha)
218
+ return new_test
219
+
220
+ # Revert
221
+ self._commit_mgr.revert_files(patch_result.files_changed)
222
+ record.reverted = True
223
+ result.reverts += 1
224
+ logger.info("iter %d: reverted — tests regressed", iteration)
225
+ return last_test
226
+
227
+ # ------------------------------------------------------------------ #
228
+ # Persistence
229
+ # ------------------------------------------------------------------ #
230
+
231
+ def _write_history(self, result: EvolutionResult) -> None:
232
+ """Append the evolution run to ``.codexa/evolution_history.json``."""
233
+ history_dir = self._root / ".codexa"
234
+ history_dir.mkdir(parents=True, exist_ok=True)
235
+ history_file = history_dir / "evolution_history.json"
236
+
237
+ runs: list[dict[str, Any]] = []
238
+ if history_file.exists():
239
+ try:
240
+ runs = json.loads(history_file.read_text(encoding="utf-8"))
241
+ except (json.JSONDecodeError, OSError):
242
+ runs = []
243
+
244
+ runs.append(result.to_dict())
245
+ history_file.write_text(
246
+ json.dumps(runs, indent=2, default=str) + "\n",
247
+ encoding="utf-8",
248
+ )
249
+ logger.info("evolution history written to %s", history_file)
@@ -0,0 +1,229 @@
1
+ """Patch generator — asks the LLM for a unified diff and applies it.
2
+
3
+ The generator sends a minimal prompt (system + context) to the configured
4
+ LLM provider and parses the response as a unified diff. It then applies
5
+ the diff to the working tree using ``git apply``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from semantic_code_intelligence.evolution.budget_guard import BudgetGuard
18
+ from semantic_code_intelligence.evolution.context_builder import SYSTEM_PROMPT, ContextBuilder
19
+ from semantic_code_intelligence.evolution.task_selector import EvolutionTask
20
+ from semantic_code_intelligence.llm.provider import LLMMessage, LLMProvider, LLMResponse, MessageRole
21
+ from semantic_code_intelligence.utils.logging import get_logger
22
+
23
+ logger = get_logger("evolution.patch_generator")
24
+
25
+ # Safety limits
26
+ _MAX_FILES_CHANGED = 3
27
+ _MAX_LINES_CHANGED = 200
28
+
29
+
30
+ @dataclass
31
+ class PatchResult:
32
+ """Result of a patch generation + apply attempt."""
33
+
34
+ success: bool = False
35
+ diff_text: str = ""
36
+ files_changed: list[str] = field(default_factory=list)
37
+ lines_changed: int = 0
38
+ llm_response: LLMResponse | None = None
39
+ error: str = ""
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Serialise the patch result to a plain dictionary."""
43
+ return {
44
+ "success": self.success,
45
+ "files_changed": self.files_changed,
46
+ "lines_changed": self.lines_changed,
47
+ "error": self.error,
48
+ }
49
+
50
+
51
+ class PatchGenerator:
52
+ """Generates and applies patches via LLM."""
53
+
54
+ def __init__(
55
+ self,
56
+ project_root: Path,
57
+ provider: LLMProvider,
58
+ context_builder: ContextBuilder,
59
+ budget: BudgetGuard,
60
+ ) -> None:
61
+ self._root = project_root.resolve()
62
+ self._provider = provider
63
+ self._ctx = context_builder
64
+ self._budget = budget
65
+
66
+ def generate_and_apply(self, task: EvolutionTask) -> PatchResult:
67
+ """Generate a patch and apply it to the working tree.
68
+
69
+ Steps:
70
+ 1. Build minimal context prompt.
71
+ 2. Call LLM.
72
+ 3. Parse unified diff from response.
73
+ 4. Validate safety limits.
74
+ 5. Apply via ``git apply``.
75
+ """
76
+ # 1. Build context
77
+ user_msg = self._ctx.build(task)
78
+ prompt_tokens = self._ctx.estimate_tokens(SYSTEM_PROMPT + user_msg)
79
+
80
+ if prompt_tokens > self._budget.tokens_remaining:
81
+ return PatchResult(
82
+ error=f"Prompt ({prompt_tokens} tokens) exceeds remaining budget "
83
+ f"({self._budget.tokens_remaining} tokens)."
84
+ )
85
+
86
+ # 2. Call LLM
87
+ messages = [
88
+ LLMMessage(role=MessageRole.SYSTEM, content=SYSTEM_PROMPT),
89
+ LLMMessage(role=MessageRole.USER, content=user_msg),
90
+ ]
91
+ try:
92
+ response = self._provider.chat(messages, temperature=0.2, max_tokens=2048)
93
+ except Exception as exc:
94
+ return PatchResult(error=f"LLM call failed: {exc}")
95
+
96
+ total_tokens = response.usage.get("total_tokens", prompt_tokens + len(response.content) // 4)
97
+ self._budget.record_tokens(total_tokens)
98
+
99
+ # 3. Parse diff
100
+ diff_text = _extract_diff(response.content)
101
+ if not diff_text:
102
+ return PatchResult(
103
+ llm_response=response,
104
+ error="LLM response did not contain a valid unified diff.",
105
+ )
106
+
107
+ # 4. Validate safety limits
108
+ files = _diff_files(diff_text)
109
+ lines = _diff_line_count(diff_text)
110
+
111
+ if len(files) > _MAX_FILES_CHANGED:
112
+ return PatchResult(
113
+ diff_text=diff_text,
114
+ files_changed=files,
115
+ lines_changed=lines,
116
+ error=f"Patch touches {len(files)} files (max {_MAX_FILES_CHANGED}).",
117
+ )
118
+ if lines > _MAX_LINES_CHANGED:
119
+ return PatchResult(
120
+ diff_text=diff_text,
121
+ files_changed=files,
122
+ lines_changed=lines,
123
+ error=f"Patch changes {lines} lines (max {_MAX_LINES_CHANGED}).",
124
+ )
125
+
126
+ # 5. Apply
127
+ ok, apply_err = _apply_diff(diff_text, self._root)
128
+ if not ok:
129
+ return PatchResult(
130
+ diff_text=diff_text,
131
+ files_changed=files,
132
+ lines_changed=lines,
133
+ error=f"git apply failed: {apply_err}",
134
+ )
135
+
136
+ return PatchResult(
137
+ success=True,
138
+ diff_text=diff_text,
139
+ files_changed=files,
140
+ lines_changed=lines,
141
+ llm_response=response,
142
+ )
143
+
144
+
145
+ # ------------------------------------------------------------------ #
146
+ # Diff parsing helpers
147
+ # ------------------------------------------------------------------ #
148
+
149
+ def _extract_diff(text: str) -> str:
150
+ """Extract a unified diff block from LLM output.
151
+
152
+ Looks for ``--- a/`` or a fenced code block containing diff content.
153
+ """
154
+ # Try fenced code block first
155
+ m = re.search(r"```(?:diff)?\s*\n(.*?)```", text, re.DOTALL)
156
+ if m:
157
+ candidate = m.group(1).strip()
158
+ if "--- " in candidate or "+++ " in candidate:
159
+ return candidate
160
+
161
+ # Try raw diff
162
+ lines = text.splitlines()
163
+ diff_lines: list[str] = []
164
+ in_diff = False
165
+ for line in lines:
166
+ if line.startswith("--- ") or line.startswith("+++ ") or line.startswith("@@ "):
167
+ in_diff = True
168
+ if in_diff:
169
+ diff_lines.append(line)
170
+ # Stop on blank line after diff section
171
+ if not line.strip() and diff_lines and diff_lines[-2].strip():
172
+ continue
173
+ return "\n".join(diff_lines).strip()
174
+
175
+
176
+ def _diff_files(diff_text: str) -> list[str]:
177
+ """Extract file paths changed in a unified diff."""
178
+ files: list[str] = []
179
+ for line in diff_text.splitlines():
180
+ if line.startswith("+++ "):
181
+ path = line[4:].strip()
182
+ # Strip "b/" prefix
183
+ if path.startswith("b/"):
184
+ path = path[2:]
185
+ if path and path != "/dev/null" and path not in files:
186
+ files.append(path)
187
+ return files
188
+
189
+
190
+ def _diff_line_count(diff_text: str) -> int:
191
+ """Count the number of added + removed lines in a diff."""
192
+ count = 0
193
+ for line in diff_text.splitlines():
194
+ if line.startswith("+") and not line.startswith("+++"):
195
+ count += 1
196
+ elif line.startswith("-") and not line.startswith("---"):
197
+ count += 1
198
+ return count
199
+
200
+
201
+ def _apply_diff(diff_text: str, cwd: Path) -> tuple[bool, str]:
202
+ """Apply a unified diff via ``git apply``. Returns (success, error)."""
203
+ with tempfile.NamedTemporaryFile(
204
+ mode="w", suffix=".patch", delete=False, encoding="utf-8"
205
+ ) as f:
206
+ f.write(diff_text)
207
+ patch_path = f.name
208
+
209
+ try:
210
+ proc = subprocess.run(
211
+ ["git", "apply", "--check", patch_path],
212
+ capture_output=True,
213
+ text=True,
214
+ cwd=str(cwd),
215
+ )
216
+ if proc.returncode != 0:
217
+ return False, proc.stderr.strip()
218
+
219
+ proc = subprocess.run(
220
+ ["git", "apply", patch_path],
221
+ capture_output=True,
222
+ text=True,
223
+ cwd=str(cwd),
224
+ )
225
+ if proc.returncode != 0:
226
+ return False, proc.stderr.strip()
227
+ return True, ""
228
+ finally:
229
+ Path(patch_path).unlink(missing_ok=True)