local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Independent validation of the model's structured candidate result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
from .task import TaskEnvelope
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fold_path(path: str) -> str:
|
|
18
|
+
# ponytail: treat only Windows as case-insensitive; macOS default is
|
|
19
|
+
# case-insensitive but this keeps the common Linux case strict.
|
|
20
|
+
return path.casefold() if os.name == "nt" else path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ValidationReport:
|
|
25
|
+
valid: bool
|
|
26
|
+
changed_files: tuple[str, ...]
|
|
27
|
+
issues: tuple[str, ...]
|
|
28
|
+
resolved_patch: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _split_edit_lines(text: str) -> list[str]:
|
|
32
|
+
if text == "":
|
|
33
|
+
return []
|
|
34
|
+
lines = text.split("\n")
|
|
35
|
+
if text.endswith("\n"):
|
|
36
|
+
lines.pop()
|
|
37
|
+
return lines
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_edit_diff(
|
|
41
|
+
path: str,
|
|
42
|
+
search: str,
|
|
43
|
+
replace: str,
|
|
44
|
+
start_line: int,
|
|
45
|
+
old_no_newline: bool,
|
|
46
|
+
new_no_newline: bool,
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Build a minimal single-file unified diff replacing ``search`` with ``replace``.
|
|
49
|
+
|
|
50
|
+
``search`` is a line-aligned block already located by the caller; the diff
|
|
51
|
+
is controller-owned, so the model never computes line numbers or hunk
|
|
52
|
+
headers. ``old_no_newline`` marks the removed block's last line as lacking
|
|
53
|
+
a trailing newline in the file (i.e. it reaches EOF without one).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
old_lines = _split_edit_lines(search)
|
|
57
|
+
new_lines = _split_edit_lines(replace)
|
|
58
|
+
header = f"@@ -{start_line},{len(old_lines)} +{start_line},{len(new_lines)} @@"
|
|
59
|
+
body: list[str] = []
|
|
60
|
+
for position, line in enumerate(old_lines):
|
|
61
|
+
body.append("-" + line)
|
|
62
|
+
if position == len(old_lines) - 1 and old_no_newline:
|
|
63
|
+
body.append("\")
|
|
64
|
+
for position, line in enumerate(new_lines):
|
|
65
|
+
body.append("+" + line)
|
|
66
|
+
if position == len(new_lines) - 1 and new_no_newline:
|
|
67
|
+
body.append("\")
|
|
68
|
+
hunk = "\n".join([header, *body]) if body else header
|
|
69
|
+
return (
|
|
70
|
+
f"diff --git a/{path} b/{path}\n"
|
|
71
|
+
f"--- a/{path}\n"
|
|
72
|
+
f"+++ b/{path}\n"
|
|
73
|
+
f"{hunk}\n"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_edits(
|
|
78
|
+
workspace_root: str | Path,
|
|
79
|
+
edits: Any,
|
|
80
|
+
*,
|
|
81
|
+
allowed_files: set[str],
|
|
82
|
+
max_files: int,
|
|
83
|
+
max_patch_bytes: int,
|
|
84
|
+
) -> tuple[str, tuple[str, ...], list[str]]:
|
|
85
|
+
"""Validate SEARCH/REPLACE edit blocks and synthesize one unified diff.
|
|
86
|
+
|
|
87
|
+
Returns ``(patch, changed_files, issues)``. An edit block is
|
|
88
|
+
``{"file": ..., "search": ..., "replace": ...}``. ``search`` must match the
|
|
89
|
+
current file content exactly once and be line-aligned, so a model only has
|
|
90
|
+
to copy existing code instead of computing line numbers.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
issues: list[str] = []
|
|
94
|
+
changed: list[str] = []
|
|
95
|
+
diff_parts: list[str] = []
|
|
96
|
+
root = Path(workspace_root)
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
edits_by_file: dict[str, list[dict[str, Any]]] = {}
|
|
99
|
+
|
|
100
|
+
for edit in edits:
|
|
101
|
+
if not isinstance(edit, Mapping):
|
|
102
|
+
issues.append("each edit must be an object")
|
|
103
|
+
continue
|
|
104
|
+
raw_path = edit.get("file")
|
|
105
|
+
search = edit.get("search")
|
|
106
|
+
replace = edit.get("replace")
|
|
107
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
108
|
+
issues.append("edit file must be a non-empty string")
|
|
109
|
+
continue
|
|
110
|
+
normalized, path_issue = _normalize_diff_path(raw_path)
|
|
111
|
+
if path_issue:
|
|
112
|
+
issues.append(path_issue)
|
|
113
|
+
continue
|
|
114
|
+
if normalized is None:
|
|
115
|
+
issues.append(f"edit file path is empty: {raw_path}")
|
|
116
|
+
continue
|
|
117
|
+
if _fold_path(normalized) not in allowed_files:
|
|
118
|
+
issues.append(f"edit file is outside task allowlist: {raw_path}")
|
|
119
|
+
continue
|
|
120
|
+
if not isinstance(search, str) or not search:
|
|
121
|
+
issues.append("edit search must be a non-empty string")
|
|
122
|
+
continue
|
|
123
|
+
if not isinstance(replace, str):
|
|
124
|
+
issues.append("edit replace must be a string")
|
|
125
|
+
continue
|
|
126
|
+
seen.add(_fold_path(normalized))
|
|
127
|
+
edits_by_file.setdefault(normalized, []).append(
|
|
128
|
+
{"raw_path": raw_path, "search": search, "replace": replace}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
for normalized, file_edits in edits_by_file.items():
|
|
132
|
+
target = root / Path(normalized)
|
|
133
|
+
if not target.is_file():
|
|
134
|
+
issues.append(f"edit file does not exist: {file_edits[0]['raw_path']}")
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
original_content = target.read_text(encoding="utf-8")
|
|
138
|
+
except UnicodeDecodeError:
|
|
139
|
+
issues.append(f"edit file is not UTF-8 text: {file_edits[0]['raw_path']}")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if len(file_edits) == 1:
|
|
143
|
+
e = file_edits[0]
|
|
144
|
+
search = e["search"]
|
|
145
|
+
replace = e["replace"]
|
|
146
|
+
raw_path = e["raw_path"]
|
|
147
|
+
count = original_content.count(search)
|
|
148
|
+
if count == 0 and "\\n" in search:
|
|
149
|
+
candidate_search = search.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
150
|
+
if original_content.count(candidate_search) > 0:
|
|
151
|
+
search = candidate_search
|
|
152
|
+
count = original_content.count(search)
|
|
153
|
+
if "\\n" in replace:
|
|
154
|
+
replace = replace.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
155
|
+
if count == 0:
|
|
156
|
+
issues.append(f"edit search block not found: {raw_path}")
|
|
157
|
+
continue
|
|
158
|
+
if count > 1:
|
|
159
|
+
issues.append(f"edit search block is ambiguous ({count} matches): {raw_path}")
|
|
160
|
+
continue
|
|
161
|
+
index = original_content.find(search)
|
|
162
|
+
end = index + len(search)
|
|
163
|
+
is_start_aligned = index == 0 or original_content[index - 1] == "\n"
|
|
164
|
+
is_end_aligned = (
|
|
165
|
+
end == len(original_content)
|
|
166
|
+
or search.endswith("\n")
|
|
167
|
+
or original_content[end] == "\n"
|
|
168
|
+
or (original_content[end] == "\r" and end + 1 < len(original_content) and original_content[end + 1] == "\n")
|
|
169
|
+
)
|
|
170
|
+
if not (is_start_aligned and is_end_aligned):
|
|
171
|
+
line_start = original_content.rfind("\n", 0, index) + 1 if index > 0 else 0
|
|
172
|
+
line_end = original_content.find("\n", end)
|
|
173
|
+
if line_end == -1:
|
|
174
|
+
line_end = len(original_content)
|
|
175
|
+
prefix = original_content[line_start:index]
|
|
176
|
+
suffix = original_content[end:line_end]
|
|
177
|
+
search = original_content[line_start:line_end]
|
|
178
|
+
replace = prefix + replace + suffix
|
|
179
|
+
index = line_start
|
|
180
|
+
end = line_end
|
|
181
|
+
|
|
182
|
+
start_line = original_content[:index].count("\n") + 1
|
|
183
|
+
old_no_newline = end == len(original_content) and not original_content.endswith("\n")
|
|
184
|
+
new_no_newline = end == len(original_content) and bool(replace) and not replace.endswith("\n")
|
|
185
|
+
try:
|
|
186
|
+
diff_parts.append(
|
|
187
|
+
_build_edit_diff(
|
|
188
|
+
normalized,
|
|
189
|
+
search,
|
|
190
|
+
replace,
|
|
191
|
+
start_line,
|
|
192
|
+
old_no_newline,
|
|
193
|
+
new_no_newline,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
changed.append(normalized)
|
|
197
|
+
except ValueError as error:
|
|
198
|
+
issues.append(str(error))
|
|
199
|
+
continue
|
|
200
|
+
else:
|
|
201
|
+
current_content = original_content
|
|
202
|
+
file_ok = True
|
|
203
|
+
for e in file_edits:
|
|
204
|
+
search = e["search"]
|
|
205
|
+
replace = e["replace"]
|
|
206
|
+
raw_path = e["raw_path"]
|
|
207
|
+
count = current_content.count(search)
|
|
208
|
+
if count == 0 and "\\n" in search:
|
|
209
|
+
candidate_search = search.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
210
|
+
if current_content.count(candidate_search) > 0:
|
|
211
|
+
search = candidate_search
|
|
212
|
+
count = current_content.count(search)
|
|
213
|
+
if "\\n" in replace:
|
|
214
|
+
replace = replace.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
215
|
+
if count == 0:
|
|
216
|
+
issues.append(f"edit search block not found: {raw_path}")
|
|
217
|
+
file_ok = False
|
|
218
|
+
break
|
|
219
|
+
if count > 1:
|
|
220
|
+
issues.append(f"edit search block is ambiguous ({count} matches): {raw_path}")
|
|
221
|
+
file_ok = False
|
|
222
|
+
break
|
|
223
|
+
current_content = current_content.replace(search, replace, 1)
|
|
224
|
+
if file_ok:
|
|
225
|
+
diff_lines = list(
|
|
226
|
+
difflib.unified_diff(
|
|
227
|
+
original_content.splitlines(keepends=True),
|
|
228
|
+
current_content.splitlines(keepends=True),
|
|
229
|
+
fromfile=f"a/{normalized}",
|
|
230
|
+
tofile=f"b/{normalized}",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
if diff_lines:
|
|
234
|
+
diff_parts.append(f"diff --git a/{normalized} b/{normalized}\n" + "".join(diff_lines))
|
|
235
|
+
changed.append(normalized)
|
|
236
|
+
|
|
237
|
+
if len(seen) > max_files:
|
|
238
|
+
issues.append(f"edits exceed max_patch_files={max_files}")
|
|
239
|
+
if issues:
|
|
240
|
+
return "", tuple(sorted(set(changed))), issues
|
|
241
|
+
patch = "\n".join(diff_parts)
|
|
242
|
+
if len(patch.encode("utf-8")) > max_patch_bytes:
|
|
243
|
+
issues.append(f"edits exceed max_patch_bytes={max_patch_bytes}")
|
|
244
|
+
return "", tuple(sorted(set(changed))), issues
|
|
245
|
+
return patch, tuple(sorted(set(changed))), []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _evidence_facts(evidence: str | None) -> dict[str, str | None]:
|
|
249
|
+
"""Extract security-relevant facts from an evidence string.
|
|
250
|
+
|
|
251
|
+
The evidence is a `key=value; ` segment list produced by
|
|
252
|
+
`_process_evidence` (e.g. `exit_code=0; passed=True; stdout_bytes=...`).
|
|
253
|
+
Only `exit_code` and `passed` matter for anti-fabrication; the byte-count
|
|
254
|
+
metadata may be rephrased by the model. Missing keys are parsed as None.
|
|
255
|
+
|
|
256
|
+
Fallback: if neither side parses an `exit_code`, the comparison falls back
|
|
257
|
+
to `passed` equality (already enforced separately as the core gate), so a
|
|
258
|
+
model that rephrases evidence without structural values is not falsely
|
|
259
|
+
rejected as long as `passed` matches.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
facts: dict[str, str | None] = {"exit_code": None, "passed": None}
|
|
263
|
+
if not evidence:
|
|
264
|
+
return facts
|
|
265
|
+
for segment in evidence.split(";"):
|
|
266
|
+
if "=" not in segment:
|
|
267
|
+
continue
|
|
268
|
+
key, _, value = segment.partition("=")
|
|
269
|
+
key = key.strip()
|
|
270
|
+
value = value.strip()
|
|
271
|
+
if key in facts and value:
|
|
272
|
+
facts[key] = value
|
|
273
|
+
return facts
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def validate_candidate(
|
|
277
|
+
candidate: Mapping[str, Any],
|
|
278
|
+
task: TaskEnvelope,
|
|
279
|
+
*,
|
|
280
|
+
max_patch_bytes: int = 32_000,
|
|
281
|
+
max_patch_files: int = 2,
|
|
282
|
+
observed_checks: Mapping[str, Mapping[str, Any]] | None = None,
|
|
283
|
+
workspace_root: str | Path | None = None,
|
|
284
|
+
) -> ValidationReport:
|
|
285
|
+
issues: list[str] = []
|
|
286
|
+
changed_files: tuple[str, ...] = ()
|
|
287
|
+
if not isinstance(candidate, Mapping):
|
|
288
|
+
return ValidationReport(False, (), ("candidate must be a JSON object",))
|
|
289
|
+
|
|
290
|
+
if candidate.get("status") != "candidate":
|
|
291
|
+
issues.append("status must be 'candidate'")
|
|
292
|
+
if not isinstance(candidate.get("summary"), str) or not candidate["summary"].strip():
|
|
293
|
+
issues.append("summary must be a non-empty string")
|
|
294
|
+
patch = candidate.get("patch")
|
|
295
|
+
edits = candidate.get("edits")
|
|
296
|
+
if patch is None:
|
|
297
|
+
patch = ""
|
|
298
|
+
if not isinstance(patch, str):
|
|
299
|
+
issues.append("patch must be a string")
|
|
300
|
+
patch = ""
|
|
301
|
+
if edits is not None and not isinstance(edits, list):
|
|
302
|
+
issues.append("edits must be a list")
|
|
303
|
+
edits = None
|
|
304
|
+
checks = candidate.get("checks")
|
|
305
|
+
if not isinstance(checks, list):
|
|
306
|
+
issues.append("checks must be a list")
|
|
307
|
+
checks = []
|
|
308
|
+
if not isinstance(candidate.get("risks"), list):
|
|
309
|
+
issues.append("risks must be a list")
|
|
310
|
+
|
|
311
|
+
resolved_patch = ""
|
|
312
|
+
has_patch = bool(patch.strip()) if isinstance(patch, str) else False
|
|
313
|
+
has_edits = bool(edits) if isinstance(edits, list) else False
|
|
314
|
+
if has_patch and has_edits:
|
|
315
|
+
issues.append("candidate must provide either patch or edits, not both")
|
|
316
|
+
elif has_edits:
|
|
317
|
+
if workspace_root is None:
|
|
318
|
+
issues.append("edits require a workspace to resolve search blocks")
|
|
319
|
+
else:
|
|
320
|
+
allowed = {_normalize_task_path(path) for path in task.files}
|
|
321
|
+
resolved_patch, changed_files, edit_issues = resolve_edits(
|
|
322
|
+
workspace_root,
|
|
323
|
+
edits,
|
|
324
|
+
allowed_files=allowed,
|
|
325
|
+
max_files=max_patch_files,
|
|
326
|
+
max_patch_bytes=max_patch_bytes,
|
|
327
|
+
)
|
|
328
|
+
issues.extend(edit_issues)
|
|
329
|
+
if not edit_issues:
|
|
330
|
+
patch = resolved_patch
|
|
331
|
+
|
|
332
|
+
if patch:
|
|
333
|
+
if max_patch_bytes <= 0 or len(patch.encode("utf-8")) > max_patch_bytes:
|
|
334
|
+
issues.append(f"patch exceeds max_patch_bytes={max_patch_bytes}")
|
|
335
|
+
changed_files, diff_issues = parse_unified_diff(patch)
|
|
336
|
+
issues.extend(diff_issues)
|
|
337
|
+
if len(changed_files) > max_patch_files:
|
|
338
|
+
issues.append(f"patch exceeds max_patch_files={max_patch_files}")
|
|
339
|
+
allowed = {_normalize_task_path(path) for path in task.files}
|
|
340
|
+
for path in changed_files:
|
|
341
|
+
if _fold_path(path) not in allowed:
|
|
342
|
+
issues.append(f"patch file is outside task allowlist: {path}")
|
|
343
|
+
if workspace_root is not None and not issues:
|
|
344
|
+
applies, detail = check_patch_applies(workspace_root, patch)
|
|
345
|
+
if not applies:
|
|
346
|
+
issues.append(f"patch does not apply cleanly: {detail}")
|
|
347
|
+
|
|
348
|
+
expected_checks = set(task.checks)
|
|
349
|
+
seen_checks: set[str] = set()
|
|
350
|
+
for check in checks:
|
|
351
|
+
if not isinstance(check, Mapping):
|
|
352
|
+
issues.append("each check must be an object")
|
|
353
|
+
continue
|
|
354
|
+
command = check.get("command")
|
|
355
|
+
if not isinstance(command, str) or not command:
|
|
356
|
+
issues.append("check command must be a non-empty string")
|
|
357
|
+
continue
|
|
358
|
+
if command not in expected_checks:
|
|
359
|
+
issues.append(f"check command is not allowlisted: {command}")
|
|
360
|
+
if command in seen_checks:
|
|
361
|
+
issues.append(f"duplicate check command: {command}")
|
|
362
|
+
seen_checks.add(command)
|
|
363
|
+
if not isinstance(check.get("passed"), bool):
|
|
364
|
+
issues.append(f"check passed must be boolean: {command}")
|
|
365
|
+
elif check["passed"] is False:
|
|
366
|
+
issues.append(f"check failed: {command}")
|
|
367
|
+
evidence = check.get("evidence")
|
|
368
|
+
if not isinstance(evidence, str) or not evidence.strip():
|
|
369
|
+
issues.append(f"check evidence is required: {command}")
|
|
370
|
+
if observed_checks is None or command not in observed_checks:
|
|
371
|
+
issues.append(f"check has no external runner evidence: {command}")
|
|
372
|
+
else:
|
|
373
|
+
observed = observed_checks[command]
|
|
374
|
+
if check.get("passed") != observed.get("passed"):
|
|
375
|
+
issues.append(f"check result disagrees with external runner: {command}")
|
|
376
|
+
candidate_facts = _evidence_facts(check.get("evidence"))
|
|
377
|
+
observed_facts = _evidence_facts(observed.get("evidence"))
|
|
378
|
+
if (
|
|
379
|
+
candidate_facts.get("exit_code") is not None
|
|
380
|
+
and candidate_facts.get("exit_code") != observed_facts.get("exit_code")
|
|
381
|
+
):
|
|
382
|
+
issues.append(f"check evidence disagrees with external runner: {command}")
|
|
383
|
+
elif (
|
|
384
|
+
candidate_facts.get("passed") is not None
|
|
385
|
+
and candidate_facts.get("passed") != observed_facts.get("passed")
|
|
386
|
+
):
|
|
387
|
+
issues.append(f"check evidence disagrees with external runner: {command}")
|
|
388
|
+
|
|
389
|
+
if not (patch or resolved_patch or has_edits):
|
|
390
|
+
missing_checks = expected_checks - seen_checks
|
|
391
|
+
for command in sorted(missing_checks):
|
|
392
|
+
issues.append(f"missing check evidence: {command}")
|
|
393
|
+
if observed_checks is None or command not in observed_checks:
|
|
394
|
+
issues.append(f"check has no external runner evidence: {command}")
|
|
395
|
+
|
|
396
|
+
return ValidationReport(not issues, changed_files, tuple(issues), resolved_patch)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def check_patch_applies(
|
|
400
|
+
workspace_root: str | Path,
|
|
401
|
+
patch: str,
|
|
402
|
+
*,
|
|
403
|
+
timeout_seconds: float = 10,
|
|
404
|
+
) -> tuple[bool, str]:
|
|
405
|
+
"""Check a patch with git without modifying the workspace."""
|
|
406
|
+
|
|
407
|
+
return _run_git_apply(workspace_root, patch, check=True, timeout_seconds=timeout_seconds)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def apply_patch(
|
|
411
|
+
workspace_root: str | Path,
|
|
412
|
+
patch: str,
|
|
413
|
+
*,
|
|
414
|
+
timeout_seconds: float = 10,
|
|
415
|
+
reverse: bool = False,
|
|
416
|
+
) -> tuple[bool, str]:
|
|
417
|
+
"""Apply an already-validated patch to the workspace.
|
|
418
|
+
|
|
419
|
+
This is the controller-only mediated-apply seam. The local model never
|
|
420
|
+
reaches it directly: it has no `apply_patch` tool, and the controller only
|
|
421
|
+
calls this after `validate_candidate` has accepted the proposal.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
return _run_git_apply(
|
|
425
|
+
workspace_root,
|
|
426
|
+
patch,
|
|
427
|
+
check=False,
|
|
428
|
+
reverse=reverse,
|
|
429
|
+
timeout_seconds=timeout_seconds,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _run_git_apply(
|
|
434
|
+
workspace_root: str | Path,
|
|
435
|
+
patch: str,
|
|
436
|
+
*,
|
|
437
|
+
check: bool,
|
|
438
|
+
reverse: bool = False,
|
|
439
|
+
timeout_seconds: float,
|
|
440
|
+
) -> tuple[bool, str]:
|
|
441
|
+
if timeout_seconds <= 0:
|
|
442
|
+
raise ValueError("timeout_seconds must be positive")
|
|
443
|
+
executable = shutil.which("git")
|
|
444
|
+
if executable is None:
|
|
445
|
+
return False, "git executable is unavailable"
|
|
446
|
+
root = Path(workspace_root).resolve()
|
|
447
|
+
if not root.is_dir():
|
|
448
|
+
return False, f"workspace directory does not exist: {root}"
|
|
449
|
+
command = [executable, "apply", "--unidiff-zero", "--whitespace=nowarn"]
|
|
450
|
+
if check:
|
|
451
|
+
command.append("--check")
|
|
452
|
+
if reverse:
|
|
453
|
+
command.append("--reverse")
|
|
454
|
+
command.append("-")
|
|
455
|
+
try:
|
|
456
|
+
completed = subprocess.run(
|
|
457
|
+
command,
|
|
458
|
+
cwd=root,
|
|
459
|
+
input=patch.encode("utf-8"),
|
|
460
|
+
stdout=subprocess.PIPE,
|
|
461
|
+
stderr=subprocess.PIPE,
|
|
462
|
+
timeout=timeout_seconds,
|
|
463
|
+
check=False,
|
|
464
|
+
)
|
|
465
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
466
|
+
return False, f"git apply{' check' if check else ''} failed: {error}"
|
|
467
|
+
if completed.returncode == 0:
|
|
468
|
+
return True, ""
|
|
469
|
+
detail = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
470
|
+
return False, detail or f"git apply exited with code {completed.returncode}"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def parse_unified_diff(patch: str) -> tuple[tuple[str, ...], list[str]]:
|
|
474
|
+
paths: list[str] = []
|
|
475
|
+
issues: list[str] = []
|
|
476
|
+
saw_old = False
|
|
477
|
+
saw_new = False
|
|
478
|
+
saw_hunk = False
|
|
479
|
+
hunk: dict[str, int] | None = None
|
|
480
|
+
|
|
481
|
+
def finish_hunk() -> None:
|
|
482
|
+
nonlocal hunk
|
|
483
|
+
if hunk is None:
|
|
484
|
+
return
|
|
485
|
+
# ponytail: hunk line-count mismatch is intentionally NOT flagged here.
|
|
486
|
+
# git apply --check already rejects malformed hunks as "corrupt patch",
|
|
487
|
+
# so a stricter parser-side count check is a duplicate gate that rejects
|
|
488
|
+
# otherwise-valid patches (real models miscount hunk headers ~19%).
|
|
489
|
+
hunk = None
|
|
490
|
+
|
|
491
|
+
for line in patch.splitlines():
|
|
492
|
+
if line.startswith("diff --git "):
|
|
493
|
+
finish_hunk()
|
|
494
|
+
match = re.fullmatch(r"diff --git a/(.+) b/(.+)", line)
|
|
495
|
+
if not match:
|
|
496
|
+
issues.append("patch has invalid diff header")
|
|
497
|
+
continue
|
|
498
|
+
for raw_path in match.groups():
|
|
499
|
+
normalized, path_issue = _normalize_diff_path(raw_path)
|
|
500
|
+
if path_issue:
|
|
501
|
+
issues.append(path_issue)
|
|
502
|
+
elif normalized is not None and normalized not in paths:
|
|
503
|
+
paths.append(normalized)
|
|
504
|
+
elif line.startswith("@@ "):
|
|
505
|
+
finish_hunk()
|
|
506
|
+
saw_hunk = True
|
|
507
|
+
match = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*", line)
|
|
508
|
+
if not match:
|
|
509
|
+
issues.append("patch has invalid hunk header")
|
|
510
|
+
continue
|
|
511
|
+
hunk = {
|
|
512
|
+
"old_expected": int(match.group(2) or "1"),
|
|
513
|
+
"new_expected": int(match.group(4) or "1"),
|
|
514
|
+
"old_seen": 0,
|
|
515
|
+
"new_seen": 0,
|
|
516
|
+
}
|
|
517
|
+
elif hunk is not None:
|
|
518
|
+
if line.startswith("\\"):
|
|
519
|
+
continue
|
|
520
|
+
if line.startswith(" "):
|
|
521
|
+
hunk["old_seen"] += 1
|
|
522
|
+
hunk["new_seen"] += 1
|
|
523
|
+
elif line.startswith("-"):
|
|
524
|
+
hunk["old_seen"] += 1
|
|
525
|
+
elif line.startswith("+"):
|
|
526
|
+
hunk["new_seen"] += 1
|
|
527
|
+
else:
|
|
528
|
+
issues.append("patch has invalid hunk line")
|
|
529
|
+
elif line.startswith("--- "):
|
|
530
|
+
saw_old = True
|
|
531
|
+
normalized, path_issue = _normalize_diff_path(line[4:].split("\t", 1)[0].strip())
|
|
532
|
+
if path_issue:
|
|
533
|
+
issues.append(path_issue)
|
|
534
|
+
elif normalized is not None and normalized not in paths:
|
|
535
|
+
paths.append(normalized)
|
|
536
|
+
elif line.startswith("+++ "):
|
|
537
|
+
saw_new = True
|
|
538
|
+
normalized, path_issue = _normalize_diff_path(line[4:].split("\t", 1)[0].strip())
|
|
539
|
+
if path_issue:
|
|
540
|
+
issues.append(path_issue)
|
|
541
|
+
elif normalized is not None and normalized not in paths:
|
|
542
|
+
paths.append(normalized)
|
|
543
|
+
finish_hunk()
|
|
544
|
+
if not paths or not saw_old or not saw_new or not saw_hunk:
|
|
545
|
+
issues.append("patch is not a unified diff")
|
|
546
|
+
return tuple(sorted(paths)), issues
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _normalize_diff_path(raw_path: str) -> tuple[str | None, str | None]:
|
|
550
|
+
if raw_path == "/dev/null":
|
|
551
|
+
return None, None
|
|
552
|
+
path = raw_path[2:] if raw_path[:2] in {"a/", "b/"} else raw_path
|
|
553
|
+
path = path.replace("\\", "/")
|
|
554
|
+
if not path or path.startswith("/") or re.match(r"^[A-Za-z]:", path):
|
|
555
|
+
return None, f"patch path is absolute or empty: {raw_path}"
|
|
556
|
+
parts = [part for part in path.split("/") if part not in {"", "."}]
|
|
557
|
+
if ".." in parts:
|
|
558
|
+
return None, f"patch path escapes workspace: {raw_path}"
|
|
559
|
+
return "/".join(parts), None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _normalize_task_path(path: str) -> str:
|
|
563
|
+
return _fold_path(path.replace("\\", "/").strip("/"))
|