forgeoptimizer 0.1.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
forgecli/__init__.py
ADDED
|
@@ -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
|
+
]
|
forgecli/build/apply.py
ADDED
|
@@ -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"]
|