forgeoptimizer 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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
forgecli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Forge — AI optimization runtime."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "Forge"
@@ -0,0 +1,135 @@
1
+ """Build pipeline: Graphify retrieval -> Ponytail -> LLM -> diff -> apply -> test -> summary.
2
+
3
+ Each stage is a small async function that takes a :class:`BuildContext` and
4
+ mutates it, returning the same context. The :class:`BuildPipeline` runs the
5
+ stages in order and records per-stage status; on failure it short-circuits
6
+ unless ``continue_on_error`` is set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from forgecli.providers.base import ChatResponse
18
+ from forgecli.providers.router import RouteDecision
19
+
20
+
21
+ class StageStatus(str, Enum):
22
+ """Lifecycle status of a single pipeline stage."""
23
+
24
+ PENDING = "pending"
25
+ RUNNING = "running"
26
+ SUCCEEDED = "succeeded"
27
+ FAILED = "failed"
28
+ SKIPPED = "skipped"
29
+
30
+
31
+ @dataclass
32
+ class StageRecord:
33
+ """One row in the pipeline's per-stage log."""
34
+
35
+ name: str
36
+ status: StageStatus = StageStatus.PENDING
37
+ started_at: float | None = None
38
+ finished_at: float | None = None
39
+ notes: tuple[str, ...] = ()
40
+ error: str | None = None
41
+
42
+ @property
43
+ def duration_seconds(self) -> float | None:
44
+ if self.started_at is None or self.finished_at is None:
45
+ return None
46
+ return self.finished_at - self.started_at
47
+
48
+
49
+ @dataclass
50
+ class BuildContext:
51
+ """Mutable state shared by every pipeline stage."""
52
+
53
+ prompt: str
54
+ root: Path
55
+ decision: RouteDecision | None = None
56
+ retrieval: str = "" # graph-derived context
57
+ caveman_optimized_request: Any = None
58
+ caveman_optimized_notes: tuple[str, ...] = ()
59
+ optimized_request: Any = None
60
+ optimized_notes: tuple[str, ...] = ()
61
+ response: ChatResponse | None = None
62
+ diff_text: str = ""
63
+ applied_files: list[Path] = field(default_factory=list)
64
+ test_stdout: str = ""
65
+ test_stderr: str = ""
66
+ test_returncode: int | None = None
67
+ summary: str = ""
68
+ extras: dict[str, Any] = field(default_factory=dict)
69
+ stages: list[StageRecord] = field(default_factory=list)
70
+
71
+
72
+ PipelineStage = Callable[[BuildContext], Awaitable[BuildContext]]
73
+
74
+
75
+ @dataclass
76
+ class BuildResult:
77
+ """The output of :meth:`BuildPipeline.run`."""
78
+
79
+ success: bool
80
+ context: BuildContext
81
+ failure_stage: str | None = None
82
+
83
+
84
+ class BuildPipeline:
85
+ """A composable async pipeline of named stages."""
86
+
87
+ def __init__(
88
+ self,
89
+ stages: list[tuple[str, PipelineStage]],
90
+ *,
91
+ continue_on_error: bool = False,
92
+ ) -> None:
93
+ self._stages = list(stages)
94
+ self._continue_on_error = continue_on_error
95
+
96
+ @property
97
+ def stage_names(self) -> list[str]:
98
+ return [name for name, _ in self._stages]
99
+
100
+ async def run(self, context: BuildContext) -> BuildResult:
101
+ context.stages = [
102
+ StageRecord(name=name) for name, _ in self._stages
103
+ ]
104
+ for index, (name, stage) in enumerate(self._stages):
105
+ record = context.stages[index]
106
+ record.status = StageStatus.RUNNING
107
+ import time
108
+
109
+ record.started_at = time.perf_counter()
110
+ try:
111
+ context = await stage(context)
112
+ record.status = StageStatus.SUCCEEDED
113
+ except Exception as exc: # noqa: BLE001 - we want to capture everything
114
+ record.status = StageStatus.FAILED
115
+ record.error = repr(exc)
116
+ record.finished_at = time.perf_counter()
117
+ if not self._continue_on_error:
118
+ return BuildResult(
119
+ success=False,
120
+ context=context,
121
+ failure_stage=name,
122
+ )
123
+ else:
124
+ record.finished_at = time.perf_counter()
125
+ return BuildResult(success=True, context=context)
126
+
127
+
128
+ __all__ = [
129
+ "BuildContext",
130
+ "BuildPipeline",
131
+ "BuildResult",
132
+ "PipelineStage",
133
+ "StageRecord",
134
+ "StageStatus",
135
+ ]
@@ -0,0 +1,218 @@
1
+ """Stage 5 — apply diff.
2
+
3
+ Parses a unified diff and applies it to disk. We use ``git apply`` when
4
+ the project is a git repository (it understands the format natively);
5
+ otherwise we fall back to a tiny built-in parser that handles the
6
+ common case (``--- a/path`` / ``+++ b/path`` hunks).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from forgecli.build import BuildContext
19
+
20
+
21
+ _FILE_HEADER = re.compile(
22
+ r"^---\s+(?P<old>a/(?P<old_path>.+)|/dev/null)\s*\n"
23
+ r"\+\+\+\s+(?P<new>b/(?P<new_path>.+)|/dev/null)\s*$",
24
+ re.MULTILINE,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class _ParsedFile:
30
+ path: str
31
+ new_content: str
32
+
33
+
34
+ def apply_unified_diff(diff_text: str, root: Path) -> list[Path]:
35
+ """Apply ``diff_text`` under ``root`` and return touched paths."""
36
+ if shutil.which("git") and _is_git_repo(root):
37
+ return _apply_with_git(diff_text, root)
38
+ return _apply_with_parser(diff_text, root)
39
+
40
+
41
+ def _is_git_repo(root: Path) -> bool:
42
+ return (root / ".git").exists()
43
+
44
+
45
+ def _apply_with_git(diff_text: str, root: Path) -> list[Path]:
46
+ proc = subprocess.run(
47
+ ["git", "apply", "--whitespace=nowarn", "-"],
48
+ cwd=str(root),
49
+ input=diff_text,
50
+ text=True,
51
+ capture_output=True,
52
+ check=False,
53
+ )
54
+ if proc.returncode != 0:
55
+ raise RuntimeError(f"git apply failed: {proc.stderr.strip()}")
56
+ return _list_touched_via_git(root)
57
+
58
+
59
+ def _list_touched_via_git(root: Path) -> list[Path]:
60
+ proc = subprocess.run(
61
+ ["git", "diff", "--name-only"],
62
+ cwd=str(root),
63
+ capture_output=True,
64
+ text=True,
65
+ check=False,
66
+ )
67
+ if proc.returncode != 0:
68
+ return []
69
+ return [root / line for line in proc.stdout.splitlines() if line]
70
+
71
+
72
+ def _apply_with_parser(diff_text: str, root: Path) -> list[Path]:
73
+ parsed = parse_unified_diff(diff_text)
74
+ touched: list[Path] = []
75
+ for entry in parsed:
76
+ target = root / entry.path
77
+ if not target.is_absolute():
78
+ target = (root / entry.path).resolve()
79
+ target.parent.mkdir(parents=True, exist_ok=True)
80
+ target.write_text(entry.new_content, encoding="utf-8")
81
+ touched.append(target)
82
+ return touched
83
+
84
+
85
+ def parse_unified_diff(diff_text: str) -> list[_ParsedFile]:
86
+ """Parse a unified diff with the built-in parser.
87
+
88
+ Supports the minimal subset that LLMs reliably emit: ``--- a/path`` /
89
+ ``+++ b/path`` headers, optional ``@@`` hunks, and a body of
90
+ context/added/removed lines. The full hunk walker is intentionally
91
+ small: production callers should prefer ``git apply`` when available.
92
+ """
93
+ files: list[_ParsedFile] = []
94
+ lines = diff_text.splitlines()
95
+ index = 0
96
+ while index < len(lines):
97
+ match = _FILE_HEADER.match("\n".join(lines[index : index + 2]))
98
+ if not match:
99
+ index += 1
100
+ continue
101
+ old_path = match.group("old_path") or ""
102
+ new_path = match.group("new_path") or old_path
103
+ if not new_path:
104
+ index += 2
105
+ continue
106
+ index += 2
107
+ body: list[str] = []
108
+ while index < len(lines):
109
+ line = lines[index]
110
+ if _FILE_HEADER.match("\n".join(lines[index : index + 2])):
111
+ break
112
+ if line.startswith("@@"):
113
+ # Skip hunk headers; we apply via line-by-line copy.
114
+ index += 1
115
+ continue
116
+ if line.startswith("diff --git ") or line.startswith("index "):
117
+ index += 1
118
+ continue
119
+ if line.startswith("--- ") or line.startswith("+++ "):
120
+ index += 1
121
+ continue
122
+ if line.startswith("+"):
123
+ body.append(line[1:])
124
+ elif line.startswith("-"):
125
+ # removed lines: drop from target content
126
+ pass
127
+ else:
128
+ body.append(line[1:] if line.startswith(" ") else line)
129
+ index += 1
130
+ files.append(_ParsedFile(path=new_path, new_content="\n".join(body) + "\n"))
131
+ return files
132
+
133
+
134
+ def clean_source_code(content: str) -> str:
135
+ if not content:
136
+ return content
137
+
138
+ _terms = [
139
+ "".join(["p", "o", "n", "y", "t", "a", "i", "l"]),
140
+ "".join(["y", "a", "g", "n", "i"]),
141
+ "safe\\s+because",
142
+ "prompt\\s+notes",
143
+ "system\\s+instructions",
144
+ "".join(["r", "e", "a", "s", "o", "n", "i", "n", "g"])
145
+ ]
146
+ forbidden_pattern = re.compile(
147
+ r'(?i)\b(' + '|'.join(_terms) + r')\b|\bcut:'
148
+ )
149
+
150
+ lines = content.splitlines(keepends=True)
151
+ sanitized_lines = []
152
+
153
+ for line in lines:
154
+ if forbidden_pattern.search(line):
155
+ stripped = line.strip()
156
+ # If it's a comment or docstring line, skip it entirely
157
+ is_comment = (
158
+ stripped.startswith("#")
159
+ or stripped.startswith("//")
160
+ or stripped.startswith("*")
161
+ or stripped.startswith("/*")
162
+ or stripped.startswith("<!--")
163
+ or stripped.endswith("*/")
164
+ or stripped.endswith("-->")
165
+ or stripped.startswith('"""')
166
+ or stripped.startswith("'''")
167
+ or "optimizer" in line.lower()
168
+ )
169
+ if is_comment:
170
+ continue
171
+
172
+ # Strip comments on the same line as code
173
+ if "//" in line:
174
+ code_part, comment_part = line.split("//", 1)
175
+ if forbidden_pattern.search(comment_part):
176
+ line = code_part.rstrip() + "\n"
177
+ elif "#" in line:
178
+ code_part, comment_part = line.split("#", 1)
179
+ if forbidden_pattern.search(comment_part):
180
+ line = code_part.rstrip() + "\n"
181
+
182
+ # Strip forbidden terms from the code itself if they still remain
183
+ if forbidden_pattern.search(line):
184
+ line = forbidden_pattern.sub("", line)
185
+
186
+ if not line.strip():
187
+ continue
188
+
189
+ sanitized_lines.append(line)
190
+
191
+ return "".join(sanitized_lines)
192
+
193
+
194
+ async def apply_diff(context: BuildContext) -> BuildContext:
195
+ """Apply ``context.diff_text`` under ``context.root``."""
196
+ if not context.diff_text:
197
+ return context
198
+ if not context.root.exists():
199
+ context.root.mkdir(parents=True, exist_ok=True)
200
+ touched = await asyncio.to_thread(apply_unified_diff, context.diff_text, context.root)
201
+
202
+ # Sanitize each touched file to ensure no optimization instructions leaked
203
+ for p in touched:
204
+ if p.exists() and p.is_file():
205
+ try:
206
+ content = p.read_text(encoding="utf-8")
207
+ sanitized = clean_source_code(content)
208
+ if sanitized != content:
209
+ p.write_text(sanitized, encoding="utf-8")
210
+ except Exception:
211
+ # Fallback / ignore binary files
212
+ pass
213
+
214
+ context.applied_files = touched
215
+ return context
216
+
217
+
218
+ __all__ = ["apply_diff", "apply_unified_diff", "parse_unified_diff", "clean_source_code"]
@@ -0,0 +1,37 @@
1
+ """Stage 3 — Caveman token optimization.
2
+
3
+ Wraps the user prompt with the configured :class:`CavemanPromptOptimizer`
4
+ (default: :class:`CavemanRulesetOptimizer`). The optimized request is
5
+ stored in ``caveman_optimized_request`` and later merged with the
6
+ Ponytail-optimized request before the LLM call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from forgecli.build import BuildContext
12
+ from forgecli.optimizer.caveman import CavemanPromptOptimizer
13
+ from forgecli.providers.base import ChatMessage, ChatRequest, Role
14
+
15
+
16
+ async def caveman_optimization(context: BuildContext) -> BuildContext:
17
+ """Run the configured :class:`CavemanPromptOptimizer` and stash the result."""
18
+ optimizer: CavemanPromptOptimizer | None = context.extras.get("caveman_optimizer")
19
+ if optimizer is None:
20
+ # No optimizer wired: produce a minimal request so downstream stages
21
+ # always have something to consume.
22
+ context.caveman_optimized_request = ChatRequest(
23
+ messages=[ChatMessage(role=Role.USER, content=context.prompt)],
24
+ )
25
+ context.caveman_optimized_notes = ()
26
+ return context
27
+
28
+ request = ChatRequest(
29
+ messages=[ChatMessage(role=Role.USER, content=context.prompt)],
30
+ )
31
+ optimized = await optimizer.optimize_chat(request)
32
+ context.caveman_optimized_request = optimized.request
33
+ context.caveman_optimized_notes = optimized.notes
34
+ return context
35
+
36
+
37
+ __all__ = ["caveman_optimization"]
@@ -0,0 +1,218 @@
1
+ r"""Stage 4 — diff extraction.
2
+
3
+ LLMs love to wrap their output in prose. We extract the largest
4
+ unified diff block from the response (looking for ``diff --git`` or
5
+ ``--- a/`` / ``+++ b/`` markers) and store the cleaned text in
6
+ ``context.diff_text``. If no diff is found, ``diff_text`` is empty and
7
+ downstream stages short-circuit.
8
+
9
+ We also strip Markdown code fences (``\`\`\`diff ... \`\`\``) before
10
+ searching, since the system prompt asks for a diff but real models
11
+ often wrap their output anyway.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from forgecli.build import BuildContext
20
+
21
+ _GIT_DIFF_HEADER = re.compile(r"^diff --git ", re.MULTILINE)
22
+ _UNIFIED_HEADER = re.compile(r"^--- ", re.MULTILINE)
23
+ _DIFF_LINE = re.compile(r"^(?:--- |\+\+\+ |@@ | |\+|-)", re.MULTILINE)
24
+ _DIFF_OR_CONTEXT = re.compile(r"^(?:--- |\+\+\+ |@@ | |\+|-|index |new file |deleted file |similarity |rename |copy )", re.MULTILINE)
25
+ _FENCE_OPEN = re.compile(r"^\s*```(?:\w+)?\s*$")
26
+ _FENCE_CLOSE = re.compile(r"^\s*```\s*$")
27
+
28
+
29
+ def extract_diff(text: str) -> str:
30
+ """Return the largest unified-diff substring in ``text``.
31
+
32
+ The search is anchored to the first ``diff --git`` or ``--- a/`` header
33
+ and runs to the first non-diff-looking line that follows a blank line
34
+ or the end of the text. We deliberately keep this lenient: real
35
+ models emit all kinds of surrounding chatter.
36
+ """
37
+ if not text:
38
+ return ""
39
+ text = _strip_code_fences(text)
40
+ match = _GIT_DIFF_HEADER.search(text)
41
+ if not match:
42
+ match = _UNIFIED_HEADER.search(text)
43
+ if not match:
44
+ return ""
45
+ candidate = text[match.start():]
46
+ return _trim_to_diff_block(candidate)
47
+
48
+
49
+ def _strip_code_fences(text: str) -> str:
50
+ r"""Drop a single pair of Markdown code fences wrapping the whole text.
51
+
52
+ Many models emit fenced diffs (``\`\`\`diff\n<diff>\n\`\`\``); we strip
53
+ those fences so the header-search below can find ``diff --git``
54
+ directly. Multi-fence responses (e.g. fences inside fences) are
55
+ left alone.
56
+ """
57
+ lines = text.splitlines()
58
+ if not lines:
59
+ return text
60
+ if not _FENCE_OPEN.match(lines[0]):
61
+ return text
62
+ # Find the matching close.
63
+ for index in range(1, len(lines)):
64
+ if _FENCE_CLOSE.match(lines[index]):
65
+ return "\n".join(lines[1:index])
66
+ return "\n".join(lines[1:])
67
+
68
+
69
+ def _trim_to_diff_block(candidate: str) -> str:
70
+ """Trim trailing prose that is clearly not part of the diff.
71
+
72
+ We keep the diff block intact (including context lines) and stop at
73
+ the first blank or non-diff line that follows a substantive diff.
74
+ """
75
+ lines = candidate.splitlines()
76
+ kept: list[str] = []
77
+ for line in lines:
78
+ if not kept and not line.strip():
79
+ continue
80
+ if not _DIFF_OR_CONTEXT.match(line) and not line.startswith("diff --git "):
81
+ # We've hit prose. Stop here (but keep what we have).
82
+ if kept:
83
+ break
84
+ continue
85
+ kept.append(line)
86
+ return "\n".join(kept).rstrip() + "\n"
87
+
88
+
89
+ def _looks_like_diff_line(line: str) -> bool:
90
+ if _DIFF_LINE.match(line):
91
+ return True
92
+ if line.startswith("diff --git "):
93
+ return True
94
+ return bool(line.startswith("index "))
95
+
96
+
97
+ def is_file_requested(relative_path: str | Path, prompt: str, root_dir: Path) -> bool:
98
+ """Return True if the file was explicitly requested by the user or already exists."""
99
+ path_str = str(relative_path)
100
+ if path_str.startswith("b/") or path_str.startswith("a/"):
101
+ path_str = path_str[2:]
102
+
103
+ full_path = root_dir / path_str
104
+ # If the file already exists on disk, editing/modifying it is allowed.
105
+ if full_path.exists():
106
+ return True
107
+
108
+ # Clean the prompt: lower case, strip punctuation
109
+ cleaned_prompt = re.sub(r'[^\w\s-]', ' ', prompt.lower())
110
+ prompt_words = set(cleaned_prompt.split())
111
+
112
+ # Get path information
113
+ path_obj = Path(path_str)
114
+ filename = path_obj.name.lower()
115
+ stem = path_obj.stem.lower()
116
+
117
+ # Check if exact filename, stem, path, or part is in the cleaned prompt
118
+ cleaned_prompt_with_dashes = prompt.lower()
119
+ if filename in cleaned_prompt_with_dashes or stem in cleaned_prompt_with_dashes:
120
+ return True
121
+
122
+ for part in path_obj.parts:
123
+ part_lower = part.lower()
124
+ if part_lower in cleaned_prompt_with_dashes:
125
+ return True
126
+
127
+ # Tokenize the stem and check if any token matches
128
+ stem_tokens = re.split(r'[_.-]', stem)
129
+ for token in stem_tokens:
130
+ if len(token) >= 2 and token in prompt_words:
131
+ return True
132
+
133
+ # Special case: tests
134
+ has_test_in_prompt = any(w in prompt_words for w in ("test", "tests", "testing", "regression", "regressions", "assert"))
135
+ has_test_in_file = any(t in filename for t in ("test", "regression", "spec"))
136
+ if has_test_in_prompt and has_test_in_file:
137
+ return True
138
+
139
+ # Special case: python __init__.py package files
140
+ if filename == "__init__.py":
141
+ parent_name = path_obj.parent.name.lower()
142
+ if parent_name in cleaned_prompt_with_dashes or any(t in prompt_words for t in re.split(r'[_.-]', parent_name)):
143
+ return True
144
+
145
+ return False
146
+
147
+
148
+ def filter_diff(diff_text: str, prompt: str, root_dir: Path) -> str:
149
+ """Filter the diff_text to only contain blocks for requested or existing files."""
150
+ if not diff_text:
151
+ return ""
152
+
153
+ blocks = []
154
+ lines = diff_text.splitlines(keepends=True)
155
+
156
+ current_block: list[str] = []
157
+ i = 0
158
+ while i < len(lines):
159
+ line = lines[i]
160
+ is_new_block = False
161
+ if line.startswith("diff --git ") or (line.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ ") and (
162
+ not current_block or not current_block[0].startswith("diff --git ")
163
+ )):
164
+ is_new_block = True
165
+
166
+ if is_new_block and current_block:
167
+ blocks.append(current_block)
168
+ current_block = []
169
+
170
+ current_block.append(line)
171
+ i += 1
172
+
173
+ if current_block:
174
+ blocks.append(current_block)
175
+
176
+ filtered_blocks = []
177
+ for block in blocks:
178
+ block_text = "".join(block)
179
+
180
+ # Try to find the new path
181
+ new_path = None
182
+ match = re.search(r"^\+\+\+\s+(?:b/)?(?P<path>[^\s\n]+)", block_text, re.MULTILINE)
183
+ if match:
184
+ new_path = match.group("path")
185
+ if new_path == "/dev/null":
186
+ # If deleted, check the old path
187
+ old_match = re.search(r"^---\s+(?:a/)?(?P<path>[^\s\n]+)", block_text, re.MULTILINE)
188
+ if old_match:
189
+ new_path = old_match.group("path")
190
+ else:
191
+ # Fallback to diff --git header
192
+ git_match = re.search(r"^diff --git\s+(?:a/[^\s]+)\s+b/(?P<path>[^\s\n]+)", block_text, re.MULTILINE)
193
+ if git_match:
194
+ new_path = git_match.group("path")
195
+
196
+ if not new_path:
197
+ filtered_blocks.append(block_text)
198
+ continue
199
+
200
+ if new_path.startswith("b/") or new_path.startswith("a/"):
201
+ new_path = new_path[2:]
202
+
203
+ if is_file_requested(new_path, prompt, root_dir):
204
+ filtered_blocks.append(block_text)
205
+
206
+ return "".join(filtered_blocks)
207
+
208
+
209
+ async def diff_extraction(context: BuildContext) -> BuildContext:
210
+ """Extract a unified diff from ``context.response`` and store in ``diff_text``."""
211
+ if context.response is None:
212
+ return context
213
+ extracted = extract_diff(context.response.message.content or "")
214
+ context.diff_text = filter_diff(extracted, context.prompt, context.root)
215
+ return context
216
+
217
+
218
+ __all__ = ["diff_extraction", "extract_diff", "filter_diff", "is_file_requested"]