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,326 @@
|
|
|
1
|
+
"""Fast file search and content grep using ripgrep with stdlib fallback.
|
|
2
|
+
|
|
3
|
+
Adapted from DeepSeek Harness @deepseek-ai/dsh-tool-fs-search.
|
|
4
|
+
Provides RipgrepMatch structured results, glob filtering, regex and literal search,
|
|
5
|
+
and resilient stdlib traversal fallback when `rg` is not available on PATH.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RipgrepMatch:
|
|
22
|
+
"""A single match line found in a search."""
|
|
23
|
+
|
|
24
|
+
file: str
|
|
25
|
+
line_number: int
|
|
26
|
+
line_content: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
IGNORED_DIRECTORIES: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
".git",
|
|
32
|
+
".hg",
|
|
33
|
+
".svn",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
".pytest_cache",
|
|
36
|
+
".venv",
|
|
37
|
+
"venv",
|
|
38
|
+
"env",
|
|
39
|
+
"node_modules",
|
|
40
|
+
".tox",
|
|
41
|
+
".local_agent",
|
|
42
|
+
".mypy_cache",
|
|
43
|
+
".ruff_cache",
|
|
44
|
+
"dist",
|
|
45
|
+
"build",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_binary_file(file_path: Path) -> bool:
|
|
51
|
+
"""Check whether a file appears to be binary by inspecting initial bytes."""
|
|
52
|
+
try:
|
|
53
|
+
with open(file_path, "rb") as f:
|
|
54
|
+
chunk = f.read(1024)
|
|
55
|
+
return b"\x00" in chunk
|
|
56
|
+
except OSError:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _match_globs(rel_path_str: str, file_name: str, globs: list[str] | None) -> bool:
|
|
61
|
+
"""Check whether relative path and filename satisfy glob inclusion/exclusion rules."""
|
|
62
|
+
if not globs:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
positive_globs = [g for g in globs if not g.startswith("!")]
|
|
66
|
+
negative_globs = [g[1:] for g in globs if g.startswith("!")]
|
|
67
|
+
|
|
68
|
+
# Check exclusions first
|
|
69
|
+
for neg in negative_globs:
|
|
70
|
+
if fnmatch.fnmatch(rel_path_str, neg) or fnmatch.fnmatch(file_name, neg):
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
# Check positive globs if any are specified
|
|
74
|
+
if positive_globs:
|
|
75
|
+
matched = any(
|
|
76
|
+
fnmatch.fnmatch(rel_path_str, pos) or fnmatch.fnmatch(file_name, pos)
|
|
77
|
+
for pos in positive_globs
|
|
78
|
+
)
|
|
79
|
+
if not matched:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _stdlib_search(
|
|
86
|
+
query: str,
|
|
87
|
+
root_path: Path,
|
|
88
|
+
globs: list[str] | None = None,
|
|
89
|
+
is_regex: bool = False,
|
|
90
|
+
case_sensitive: bool = False,
|
|
91
|
+
max_results: int = 100,
|
|
92
|
+
) -> list[RipgrepMatch]:
|
|
93
|
+
"""Pure-Python fallback for grep search across repository files."""
|
|
94
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
95
|
+
try:
|
|
96
|
+
pattern = re.compile(query if is_regex else re.escape(query), flags)
|
|
97
|
+
except re.error:
|
|
98
|
+
# Invalid regex fallback
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
results: list[RipgrepMatch] = []
|
|
102
|
+
|
|
103
|
+
if root_path.is_file():
|
|
104
|
+
candidates = [root_path]
|
|
105
|
+
else:
|
|
106
|
+
candidates = []
|
|
107
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
108
|
+
# Prune ignored directory trees in-place
|
|
109
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRECTORIES]
|
|
110
|
+
for fname in filenames:
|
|
111
|
+
file_p = Path(dirpath) / fname
|
|
112
|
+
candidates.append(file_p)
|
|
113
|
+
|
|
114
|
+
for file_path in candidates:
|
|
115
|
+
if not file_path.is_file():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
rel_path = file_path.relative_to(root_path)
|
|
120
|
+
rel_str = rel_path.as_posix()
|
|
121
|
+
except ValueError:
|
|
122
|
+
rel_str = file_path.name
|
|
123
|
+
|
|
124
|
+
if not _match_globs(rel_str, file_path.name, globs):
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if _is_binary_file(file_path):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
132
|
+
for line_idx, line in enumerate(f, start=1):
|
|
133
|
+
if pattern.search(line):
|
|
134
|
+
clean_line = line.rstrip("\r\n")
|
|
135
|
+
results.append(
|
|
136
|
+
RipgrepMatch(
|
|
137
|
+
file=rel_str,
|
|
138
|
+
line_number=line_idx,
|
|
139
|
+
line_content=clean_line,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if len(results) >= max_results:
|
|
143
|
+
return results
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _stdlib_files(
|
|
151
|
+
pattern: str,
|
|
152
|
+
root_path: Path,
|
|
153
|
+
max_results: int = 100,
|
|
154
|
+
) -> list[str]:
|
|
155
|
+
"""Pure-Python fallback for listing matching files."""
|
|
156
|
+
matched_files: list[str] = []
|
|
157
|
+
has_pattern = bool(pattern and pattern != "*")
|
|
158
|
+
|
|
159
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
160
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRECTORIES]
|
|
161
|
+
for fname in sorted(filenames):
|
|
162
|
+
full_path = Path(dirpath) / fname
|
|
163
|
+
try:
|
|
164
|
+
rel_path = full_path.relative_to(root_path)
|
|
165
|
+
rel_str = rel_path.as_posix()
|
|
166
|
+
except ValueError:
|
|
167
|
+
rel_str = full_path.name
|
|
168
|
+
|
|
169
|
+
if has_pattern:
|
|
170
|
+
if not (fnmatch.fnmatch(rel_str, pattern) or fnmatch.fnmatch(fname, pattern)):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
matched_files.append(rel_str)
|
|
174
|
+
if len(matched_files) >= max_results:
|
|
175
|
+
return matched_files
|
|
176
|
+
|
|
177
|
+
return matched_files
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ripgrep_search(
|
|
181
|
+
query: str,
|
|
182
|
+
root: Path | str,
|
|
183
|
+
globs: list[str] | None = None,
|
|
184
|
+
is_regex: bool = False,
|
|
185
|
+
case_sensitive: bool = False,
|
|
186
|
+
max_results: int = 100,
|
|
187
|
+
_force_fallback: bool = False,
|
|
188
|
+
) -> list[RipgrepMatch]:
|
|
189
|
+
"""Perform a fast text or regex search across files under root.
|
|
190
|
+
|
|
191
|
+
Uses `rg` when available on PATH, falling back to stdlib search seamlessly.
|
|
192
|
+
"""
|
|
193
|
+
root_path = Path(root).resolve()
|
|
194
|
+
if not root_path.exists():
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
rg_bin = None if _force_fallback else shutil.which("rg")
|
|
198
|
+
|
|
199
|
+
if rg_bin:
|
|
200
|
+
cmd: list[str] = [rg_bin, "--json"]
|
|
201
|
+
if not case_sensitive:
|
|
202
|
+
cmd.append("-i")
|
|
203
|
+
else:
|
|
204
|
+
cmd.append("-s")
|
|
205
|
+
|
|
206
|
+
if not is_regex:
|
|
207
|
+
cmd.append("-F")
|
|
208
|
+
|
|
209
|
+
if globs:
|
|
210
|
+
for g in globs:
|
|
211
|
+
cmd.extend(["-g", g])
|
|
212
|
+
|
|
213
|
+
cmd.extend(["--max-count", str(max_results)])
|
|
214
|
+
cmd.extend(["--", query, str(root_path)])
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
proc = subprocess.run(
|
|
218
|
+
cmd,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
encoding="utf-8",
|
|
222
|
+
errors="replace",
|
|
223
|
+
cwd=str(root_path if root_path.is_dir() else root_path.parent),
|
|
224
|
+
check=False,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if proc.returncode in (0, 1):
|
|
228
|
+
matches: list[RipgrepMatch] = []
|
|
229
|
+
for line in proc.stdout.splitlines():
|
|
230
|
+
if not line.strip():
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
entry = json.loads(line)
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if entry.get("type") == "match":
|
|
238
|
+
data = entry.get("data", {})
|
|
239
|
+
path_text = data.get("path", {}).get("text", "")
|
|
240
|
+
file_p = Path(path_text)
|
|
241
|
+
try:
|
|
242
|
+
rel_str = file_p.resolve().relative_to(root_path).as_posix()
|
|
243
|
+
except ValueError:
|
|
244
|
+
rel_str = file_p.name or path_text
|
|
245
|
+
|
|
246
|
+
line_num = data.get("line_number", 0)
|
|
247
|
+
line_text = data.get("lines", {}).get("text", "").rstrip("\r\n")
|
|
248
|
+
matches.append(
|
|
249
|
+
RipgrepMatch(
|
|
250
|
+
file=rel_str,
|
|
251
|
+
line_number=line_num,
|
|
252
|
+
line_content=line_text,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
if len(matches) >= max_results:
|
|
256
|
+
break
|
|
257
|
+
return matches
|
|
258
|
+
except (OSError, subprocess.SubprocessError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
return _stdlib_search(
|
|
262
|
+
query=query,
|
|
263
|
+
root_path=root_path,
|
|
264
|
+
globs=globs,
|
|
265
|
+
is_regex=is_regex,
|
|
266
|
+
case_sensitive=case_sensitive,
|
|
267
|
+
max_results=max_results,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def ripgrep_files(
|
|
272
|
+
pattern: str,
|
|
273
|
+
root: Path | str,
|
|
274
|
+
max_results: int = 100,
|
|
275
|
+
_force_fallback: bool = False,
|
|
276
|
+
) -> list[str]:
|
|
277
|
+
"""Find files matching a pattern under root.
|
|
278
|
+
|
|
279
|
+
Uses `rg --files` when available, falling back to stdlib search seamlessly.
|
|
280
|
+
"""
|
|
281
|
+
root_path = Path(root).resolve()
|
|
282
|
+
if not root_path.exists():
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
rg_bin = None if _force_fallback else shutil.which("rg")
|
|
286
|
+
|
|
287
|
+
if rg_bin:
|
|
288
|
+
cmd: list[str] = [rg_bin, "--files"]
|
|
289
|
+
if pattern and pattern != "*":
|
|
290
|
+
cmd.extend(["-g", pattern])
|
|
291
|
+
cmd.extend(["--", str(root_path)])
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
proc = subprocess.run(
|
|
295
|
+
cmd,
|
|
296
|
+
capture_output=True,
|
|
297
|
+
text=True,
|
|
298
|
+
encoding="utf-8",
|
|
299
|
+
errors="replace",
|
|
300
|
+
cwd=str(root_path if root_path.is_dir() else root_path.parent),
|
|
301
|
+
check=False,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if proc.returncode in (0, 1):
|
|
305
|
+
files: list[str] = []
|
|
306
|
+
for line in proc.stdout.splitlines():
|
|
307
|
+
cleaned = line.strip()
|
|
308
|
+
if not cleaned:
|
|
309
|
+
continue
|
|
310
|
+
file_p = Path(cleaned)
|
|
311
|
+
try:
|
|
312
|
+
rel_str = file_p.resolve().relative_to(root_path).as_posix()
|
|
313
|
+
except ValueError:
|
|
314
|
+
rel_str = file_p.name or cleaned
|
|
315
|
+
files.append(rel_str)
|
|
316
|
+
if len(files) >= max_results:
|
|
317
|
+
break
|
|
318
|
+
return files
|
|
319
|
+
except (OSError, subprocess.SubprocessError):
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
return _stdlib_files(
|
|
323
|
+
pattern=pattern,
|
|
324
|
+
root_path=root_path,
|
|
325
|
+
max_results=max_results,
|
|
326
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Semantic Linter & Fast Pre-Test Prescriptions Engine (R18).
|
|
2
|
+
|
|
3
|
+
Sub-50ms static analysis pre-gate catching syntax, indentation, and structural
|
|
4
|
+
errors before executing heavy unit test runners, translating diagnostics into
|
|
5
|
+
deterministic pinpointed prescriptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
from .validators import apply_patch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LinterDiagnostic:
|
|
21
|
+
file: str
|
|
22
|
+
line: int | None
|
|
23
|
+
message: str
|
|
24
|
+
rule: str = "syntax"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class LinterReport:
|
|
29
|
+
valid: bool
|
|
30
|
+
diagnostics: tuple[LinterDiagnostic, ...] = field(default_factory=tuple)
|
|
31
|
+
prescriptions: tuple[str, ...] = field(default_factory=tuple)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lint_source_code(filename: str, source_code: str) -> LinterReport:
|
|
35
|
+
"""Run fast in-memory static linting on a single file."""
|
|
36
|
+
diagnostics: list[LinterDiagnostic] = []
|
|
37
|
+
prescriptions: list[str] = []
|
|
38
|
+
|
|
39
|
+
path = Path(filename)
|
|
40
|
+
if path.suffix == ".py":
|
|
41
|
+
try:
|
|
42
|
+
ast.parse(source_code, filename=filename)
|
|
43
|
+
except SyntaxError as exc:
|
|
44
|
+
line_no = exc.lineno
|
|
45
|
+
msg = exc.msg or "Syntax error"
|
|
46
|
+
diag = LinterDiagnostic(file=filename, line=line_no, message=msg, rule="SyntaxError")
|
|
47
|
+
diagnostics.append(diag)
|
|
48
|
+
prescriptions.append(
|
|
49
|
+
f"Синтаксическая ошибка в {filename}:{line_no}: {msg}. Проверьте двоеточия, скобки и отступы."
|
|
50
|
+
)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
diag = LinterDiagnostic(file=filename, line=None, message=str(exc), rule="ParseError")
|
|
53
|
+
diagnostics.append(diag)
|
|
54
|
+
prescriptions.append(f"Ошибка синтаксического анализа в {filename}: {exc}")
|
|
55
|
+
|
|
56
|
+
return LinterReport(
|
|
57
|
+
valid=len(diagnostics) == 0,
|
|
58
|
+
diagnostics=tuple(diagnostics),
|
|
59
|
+
prescriptions=tuple(prescriptions),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lint_patch_in_memory(workspace_root: str, patch_content: str) -> LinterReport:
|
|
64
|
+
"""Simulate patch application and lint all modified files."""
|
|
65
|
+
if not patch_content.strip():
|
|
66
|
+
return LinterReport(valid=True)
|
|
67
|
+
|
|
68
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
69
|
+
tmp_root = Path(tmp_dir)
|
|
70
|
+
# Copy modified files from workspace_root to tmp_root
|
|
71
|
+
ws_root = Path(workspace_root).resolve()
|
|
72
|
+
|
|
73
|
+
# Parse target files from patch
|
|
74
|
+
target_files: set[str] = set()
|
|
75
|
+
for line in patch_content.splitlines():
|
|
76
|
+
if line.startswith("--- a/") or line.startswith("+++ b/"):
|
|
77
|
+
fname = line[6:].strip()
|
|
78
|
+
if fname and fname != "/dev/null":
|
|
79
|
+
p = Path(fname)
|
|
80
|
+
if ".." in p.parts or p.is_absolute():
|
|
81
|
+
diag = LinterDiagnostic(file=fname, line=None, message="Path escapes workspace", rule="PathTraversal")
|
|
82
|
+
return LinterReport(
|
|
83
|
+
valid=False,
|
|
84
|
+
diagnostics=(diag,),
|
|
85
|
+
prescriptions=(f"Патч содержит недопустимый путь '{fname}', выходящий за пределы рабочей области",),
|
|
86
|
+
)
|
|
87
|
+
target_files.add(fname)
|
|
88
|
+
|
|
89
|
+
for rel_file in target_files:
|
|
90
|
+
src_file = ws_root / rel_file
|
|
91
|
+
dst_file = tmp_root / rel_file
|
|
92
|
+
if src_file.exists():
|
|
93
|
+
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
dst_file.write_bytes(src_file.read_bytes())
|
|
95
|
+
|
|
96
|
+
# Apply patch to tmp_root
|
|
97
|
+
applied, detail = apply_patch(str(tmp_root), patch_content)
|
|
98
|
+
if not applied:
|
|
99
|
+
diag = LinterDiagnostic(file="patch", line=None, message=detail, rule="GitApplyFailed")
|
|
100
|
+
return LinterReport(
|
|
101
|
+
valid=False,
|
|
102
|
+
diagnostics=(diag,),
|
|
103
|
+
prescriptions=(f"Патч не может быть применён к рабочей области: {detail}",),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Lint all target files
|
|
107
|
+
all_diags: list[LinterDiagnostic] = []
|
|
108
|
+
all_prescriptions: list[str] = []
|
|
109
|
+
for rel_file in target_files:
|
|
110
|
+
target_path = tmp_root / rel_file
|
|
111
|
+
if target_path.exists() and target_path.is_file():
|
|
112
|
+
content = target_path.read_text(encoding="utf-8", errors="replace")
|
|
113
|
+
report = lint_source_code(rel_file, content)
|
|
114
|
+
if not report.valid:
|
|
115
|
+
all_diags.extend(report.diagnostics)
|
|
116
|
+
all_prescriptions.extend(report.prescriptions)
|
|
117
|
+
|
|
118
|
+
return LinterReport(
|
|
119
|
+
valid=len(all_diags) == 0,
|
|
120
|
+
diagnostics=tuple(all_diags),
|
|
121
|
+
prescriptions=tuple(all_prescriptions),
|
|
122
|
+
)
|