pisces-cli 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.
- pisces/__init__.py +9 -0
- pisces/__main__.py +4 -0
- pisces/ast_visitor.py +347 -0
- pisces/cli.py +245 -0
- pisces/config_cmd.py +234 -0
- pisces/config_manager.py +208 -0
- pisces/context_manager.py +333 -0
- pisces/explain_cmd.py +608 -0
- pisces/fix_cmd.py +479 -0
- pisces/history_cmd.py +313 -0
- pisces/init_cmd.py +193 -0
- pisces/judge.py +131 -0
- pisces/llm_client.py +263 -0
- pisces/models.py +413 -0
- pisces/patch_engine.py +266 -0
- pisces/scan_cmd.py +230 -0
- pisces/scanner.py +677 -0
- pisces/tui.py +539 -0
- pisces/watcher.py +244 -0
- pisces_cli-0.1.0.dist-info/METADATA +448 -0
- pisces_cli-0.1.0.dist-info/RECORD +24 -0
- pisces_cli-0.1.0.dist-info/WHEEL +4 -0
- pisces_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pisces_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pisces/fix_cmd.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.fix_cmd
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
Implements `pisces fix <file:line>` — the full self-healing repair loop.
|
|
5
|
+
State machine:
|
|
6
|
+
DETECTED → PROPOSED → JUDGED → AWAITING_APPROVAL
|
|
7
|
+
→ APPROVED → CHECKPOINTED → APPLIED → VALIDATING
|
|
8
|
+
→ SUCCEEDED (terminal ✓)
|
|
9
|
+
→ FAILED → HEALING → PROPOSED (retry loop)
|
|
10
|
+
→ ROLLED_BACK (terminal, all attempts exhausted)
|
|
11
|
+
→ REJECTED (terminal, human said No)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import subprocess
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pisces.config_manager import load_config
|
|
22
|
+
from pisces.context_manager import ContextManager
|
|
23
|
+
from pisces.explain_cmd import (
|
|
24
|
+
_CONTEXT_LINES,
|
|
25
|
+
_get_file_context,
|
|
26
|
+
_get_source_snippet,
|
|
27
|
+
_parse_ref,
|
|
28
|
+
)
|
|
29
|
+
from pisces.judge import judge_patch
|
|
30
|
+
from pisces.llm_client import call_llm
|
|
31
|
+
from pisces.models import Finding, JudgeResult, LLMRequest, RepairState, Severity
|
|
32
|
+
from pisces.patch_engine import (
|
|
33
|
+
PATCH_END,
|
|
34
|
+
PATCH_START,
|
|
35
|
+
apply_patch,
|
|
36
|
+
checkpoint_file,
|
|
37
|
+
extract_patch_code,
|
|
38
|
+
locate_function_lines,
|
|
39
|
+
rollback,
|
|
40
|
+
save_patch_record,
|
|
41
|
+
validate_patch_syntax,
|
|
42
|
+
)
|
|
43
|
+
from pisces.scan_cmd import load_finding_by_ref
|
|
44
|
+
from pisces.tui import (
|
|
45
|
+
console,
|
|
46
|
+
print_diff,
|
|
47
|
+
print_error,
|
|
48
|
+
print_hitl_approval,
|
|
49
|
+
print_section,
|
|
50
|
+
print_step_fail,
|
|
51
|
+
print_step_ok,
|
|
52
|
+
print_step_running,
|
|
53
|
+
print_success,
|
|
54
|
+
print_warning,
|
|
55
|
+
spinner,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ─────────────────────────────────────────────────────────────
|
|
59
|
+
# Patch generation system prompt
|
|
60
|
+
# ─────────────────────────────────────────────────────────────
|
|
61
|
+
_FIX_SYSTEM = f"""\
|
|
62
|
+
You are an expert Python security engineer. Your task is to produce a
|
|
63
|
+
minimal, correct security patch for the vulnerability described.
|
|
64
|
+
Rules you MUST follow:
|
|
65
|
+
1. Fix ONLY the vulnerability — do not refactor or reformat other code.
|
|
66
|
+
2. Preserve the exact function signature and return type.
|
|
67
|
+
3. The fix must be correct Python 3 and syntactically valid.
|
|
68
|
+
4. Include a one-line comment explaining each security-relevant change.
|
|
69
|
+
5. Output the replacement code BETWEEN these exact markers and nowhere else:
|
|
70
|
+
{PATCH_START}
|
|
71
|
+
<replacement code here>
|
|
72
|
+
{PATCH_END}
|
|
73
|
+
Do NOT include anything outside the markers.
|
|
74
|
+
Do NOT include the markers themselves as part of the code.
|
|
75
|
+
Output ONLY the patched code block, nothing else.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ─────────────────────────────────────────────────────────────
|
|
80
|
+
# Entry point
|
|
81
|
+
# ─────────────────────────────────────────────────────────────
|
|
82
|
+
def run_fix(
|
|
83
|
+
ref: str,
|
|
84
|
+
project_root: Path,
|
|
85
|
+
dry_run: bool = False, # Generate and display a patch without modifying the source file.
|
|
86
|
+
skip_judge: bool = False, # Skips the LLM based patch evaluation
|
|
87
|
+
max_attempts: int
|
|
88
|
+
| None = None, # Overrides the configured retry limit for self-healing attempts.
|
|
89
|
+
auto_approve: bool = False, # Skips human-in-the-loop approval. This is not recommended for production use.
|
|
90
|
+
) -> int:
|
|
91
|
+
"""
|
|
92
|
+
Entry point for `pisces fix <ref>`.
|
|
93
|
+
Returns 0 on success (SUCCEEDED), 1 on failure/rejection.
|
|
94
|
+
"""
|
|
95
|
+
print_section("pisces fix — Self-Healing Repair")
|
|
96
|
+
# ── Parse reference ─────────────────────────────────────
|
|
97
|
+
file_path, line_no = _parse_ref(ref, project_root)
|
|
98
|
+
if file_path is None:
|
|
99
|
+
print_error(f"Invalid reference: '{ref}' (expected: path/to/file.py:42)")
|
|
100
|
+
return 1
|
|
101
|
+
rel_path = str(file_path.relative_to(project_root))
|
|
102
|
+
lookup = f"{rel_path}:{line_no}"
|
|
103
|
+
# ── Load finding ─────────────────────────────────────────
|
|
104
|
+
finding = load_finding_by_ref(project_root, lookup)
|
|
105
|
+
if finding is None:
|
|
106
|
+
print_error(f"No finding cached at '{lookup}'.\n Run 'pisces scan .' first.")
|
|
107
|
+
return 1
|
|
108
|
+
# ── Load config ──────────────────────────────────────────
|
|
109
|
+
try:
|
|
110
|
+
cfg = load_config(project_root)
|
|
111
|
+
except RuntimeError as e:
|
|
112
|
+
print_error(str(e))
|
|
113
|
+
return 1
|
|
114
|
+
provider = cfg.llm.provider
|
|
115
|
+
model = cfg.llm.model
|
|
116
|
+
max_attempts = max_attempts if max_attempts is not None else cfg.repair.max_attempts
|
|
117
|
+
require_hitl = cfg.repair.require_approval and not auto_approve
|
|
118
|
+
test_command = cfg.validation.test_command
|
|
119
|
+
repair_id = str(uuid.uuid4())[:8]
|
|
120
|
+
checkpoint_path: Path | None = None
|
|
121
|
+
state = RepairState.DETECTED
|
|
122
|
+
console.print(
|
|
123
|
+
f" [dim]Finding:[/] [cyan]{lookup}[/] [dim]Severity:[/] {_severity_badge(finding.severity)}"
|
|
124
|
+
)
|
|
125
|
+
console.print(f" [dim]Provider:[/] [magenta]{provider}[/] / [cyan]{model}[/]")
|
|
126
|
+
console.print(
|
|
127
|
+
f" [dim]Max attempts:[/] {max_attempts} | [dim]HITL:[/] {'yes' if require_hitl else 'no (--auto-approve)'}"
|
|
128
|
+
)
|
|
129
|
+
console.print()
|
|
130
|
+
# ── Locate function boundaries ───────────────────────────
|
|
131
|
+
fn_start, fn_end = locate_function_lines(file_path, line_no)
|
|
132
|
+
original_block = _read_lines(file_path, fn_start, fn_end)
|
|
133
|
+
source_snippet = _get_source_snippet(file_path, line_no, context=_CONTEXT_LINES)
|
|
134
|
+
file_ctx = _get_file_context(project_root, rel_path)
|
|
135
|
+
# The repair loop
|
|
136
|
+
state = RepairState.DETECTED
|
|
137
|
+
prior_fail: str | None = None
|
|
138
|
+
for attempt in range(1, max_attempts + 1):
|
|
139
|
+
console.rule(
|
|
140
|
+
f"[bold]Attempt {attempt} / {max_attempts}[/]",
|
|
141
|
+
style="dim blue",
|
|
142
|
+
)
|
|
143
|
+
# ── Step 1: Generate patch ────────────────────────────
|
|
144
|
+
state = RepairState.PROPOSED
|
|
145
|
+
print_step_running(f"[{attempt}] Generating patch with {provider}/{model} ...")
|
|
146
|
+
patch_code = _generate_patch(
|
|
147
|
+
finding=finding,
|
|
148
|
+
source_snippet=source_snippet,
|
|
149
|
+
file_ctx_str=_fmt_file_ctx(file_ctx),
|
|
150
|
+
fn_start=fn_start,
|
|
151
|
+
fn_end=fn_end,
|
|
152
|
+
original_block=original_block,
|
|
153
|
+
provider=provider,
|
|
154
|
+
model=model,
|
|
155
|
+
prior_failure=prior_fail,
|
|
156
|
+
)
|
|
157
|
+
if patch_code is None:
|
|
158
|
+
print_step_fail("LLM did not produce a valid patch block.")
|
|
159
|
+
state = RepairState.FAILED
|
|
160
|
+
prior_fail = "LLM failed to output a patch in the expected format."
|
|
161
|
+
continue
|
|
162
|
+
# Validate patch syntax before judging
|
|
163
|
+
valid, err = validate_patch_syntax(patch_code)
|
|
164
|
+
if not valid:
|
|
165
|
+
print_step_fail(f"Patch has syntax error: {err}")
|
|
166
|
+
state = RepairState.FAILED
|
|
167
|
+
prior_fail = f"Patch syntax error: {err}"
|
|
168
|
+
continue
|
|
169
|
+
print_step_ok(f"Patch generated ({patch_code.count(chr(10)) + 1} lines)")
|
|
170
|
+
# ── Step 2: Show diff ─────────────────────────────────
|
|
171
|
+
diff_text = "".join(
|
|
172
|
+
difflib.unified_diff(
|
|
173
|
+
original_block.splitlines(keepends=True),
|
|
174
|
+
patch_code.splitlines(keepends=True),
|
|
175
|
+
fromfile=f"a/{rel_path}",
|
|
176
|
+
tofile=f"b/{rel_path}",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
print_diff(
|
|
181
|
+
diff_text=diff_text or "No differences detected.\n",
|
|
182
|
+
filename=rel_path,
|
|
183
|
+
)
|
|
184
|
+
if dry_run:
|
|
185
|
+
console.print(
|
|
186
|
+
"[bold yellow]DRY RUN:[/] Proposed patch displayed;"
|
|
187
|
+
"the original file has NOT been modified."
|
|
188
|
+
)
|
|
189
|
+
return 0
|
|
190
|
+
# ── Step 3: Judge ─────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
state = RepairState.JUDGED
|
|
193
|
+
|
|
194
|
+
if skip_judge:
|
|
195
|
+
print_warning("LLM judge skipped by --no-judge.")
|
|
196
|
+
judge = _fallback_judge()
|
|
197
|
+
else:
|
|
198
|
+
print_step_running("Judge reviewing the patch ...")
|
|
199
|
+
try:
|
|
200
|
+
with spinner("Running judge LLM..."):
|
|
201
|
+
judge = judge_patch(
|
|
202
|
+
finding=finding,
|
|
203
|
+
original_code=original_block,
|
|
204
|
+
patched_code=patch_code,
|
|
205
|
+
provider=provider,
|
|
206
|
+
model=model,
|
|
207
|
+
prior_failure=prior_fail,
|
|
208
|
+
)
|
|
209
|
+
except (RuntimeError, OSError, ValueError, TypeError) as e:
|
|
210
|
+
print_warning(f"Judge failed: {e} — proceeding anyway.")
|
|
211
|
+
judge = _fallback_judge()
|
|
212
|
+
|
|
213
|
+
_print_judge_result(judge)
|
|
214
|
+
|
|
215
|
+
if not judge.approved:
|
|
216
|
+
print_warning(f"Judge REJECTED this patch: {judge.reasoning[:200]}")
|
|
217
|
+
state = RepairState.FAILED
|
|
218
|
+
prior_fail = f"Judge rejected: {judge.reasoning}"
|
|
219
|
+
if attempt < max_attempts:
|
|
220
|
+
console.print(" [dim]Retrying with judge feedback...[/]\n")
|
|
221
|
+
continue
|
|
222
|
+
else:
|
|
223
|
+
_save_and_exit(
|
|
224
|
+
project_root, repair_id, finding, patch_code, judge, None, state.value
|
|
225
|
+
)
|
|
226
|
+
return 1
|
|
227
|
+
# ── Step 4: HITL Approval ─────────────────────────────
|
|
228
|
+
state = RepairState.AWAITING_APPROVAL
|
|
229
|
+
if require_hitl:
|
|
230
|
+
approved = print_hitl_approval(
|
|
231
|
+
finding=finding.to_dict(),
|
|
232
|
+
patch_summary=f"{patch_code.count(chr(10)) + 1} lines, "
|
|
233
|
+
f"quality={judge.patch_quality}, risk={judge.regression_risk}",
|
|
234
|
+
judge_result=judge.to_dict(),
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
console.print(" [dim]--auto-approve active — skipping human review.[/]")
|
|
238
|
+
approved = True
|
|
239
|
+
if not approved:
|
|
240
|
+
state = RepairState.REJECTED
|
|
241
|
+
console.print()
|
|
242
|
+
console.print("[high]✗ Patch rejected by user.[/]")
|
|
243
|
+
_save_and_exit(project_root, repair_id, finding, patch_code, judge, False, state.value)
|
|
244
|
+
return 1
|
|
245
|
+
state = RepairState.APPROVED
|
|
246
|
+
# ── Step 5: Checkpoint ────────────────────────────────
|
|
247
|
+
state = RepairState.CHECKPOINTED
|
|
248
|
+
try:
|
|
249
|
+
checkpoint_path = checkpoint_file(file_path, project_root, repair_id)
|
|
250
|
+
except OSError as e:
|
|
251
|
+
print_step_fail(f"Checkpoint failed: {e}")
|
|
252
|
+
return 1
|
|
253
|
+
# ── Step 6: Apply patch ───────────────────────────────
|
|
254
|
+
state = RepairState.APPLIED
|
|
255
|
+
try:
|
|
256
|
+
apply_patch(file_path, patch_code, fn_start, fn_end)
|
|
257
|
+
except (OSError, ValueError) as e:
|
|
258
|
+
print_step_fail(f"Patch application failed: {e}")
|
|
259
|
+
if checkpoint_path:
|
|
260
|
+
rollback(file_path, checkpoint_path)
|
|
261
|
+
state = RepairState.ROLLED_BACK
|
|
262
|
+
_save_and_exit(project_root, repair_id, finding, patch_code, judge, True, state.value)
|
|
263
|
+
return 1
|
|
264
|
+
# ── Step 7: Validate (run tests) ──────────────────────
|
|
265
|
+
state = RepairState.VALIDATING
|
|
266
|
+
console.print()
|
|
267
|
+
print_step_running(f"Running validation: [cyan]{test_command}[/] ...")
|
|
268
|
+
test_ok, test_output = _run_tests(project_root, test_command)
|
|
269
|
+
if test_ok:
|
|
270
|
+
state = RepairState.SUCCEEDED
|
|
271
|
+
console.print()
|
|
272
|
+
print_success(
|
|
273
|
+
f"✓ Repair SUCCEEDED (attempt {attempt}/{max_attempts})\n\n"
|
|
274
|
+
f" The vulnerability in [cyan]{rel_path}:{line_no}[/] has been fixed.\n"
|
|
275
|
+
f" Run [cyan]pisces scan .[/] to confirm no remaining issues."
|
|
276
|
+
)
|
|
277
|
+
_save_and_exit(project_root, repair_id, finding, patch_code, judge, True, state.value)
|
|
278
|
+
# Invalidate context for the patched file
|
|
279
|
+
try:
|
|
280
|
+
cm = ContextManager(project_root)
|
|
281
|
+
cm.update_single_file(file_path)
|
|
282
|
+
except (OSError, RuntimeError, ValueError):
|
|
283
|
+
print_warning("Could not update project context after the repair.")
|
|
284
|
+
return 0
|
|
285
|
+
# ── Tests failed → healing loop ───────────────────────
|
|
286
|
+
state = RepairState.FAILED
|
|
287
|
+
print_step_fail("Test suite failed after patching.")
|
|
288
|
+
console.print(f" [dim]{test_output[:400]}[/]\n")
|
|
289
|
+
if checkpoint_path:
|
|
290
|
+
rollback(file_path, checkpoint_path)
|
|
291
|
+
state = RepairState.HEALING
|
|
292
|
+
prior_fail = f"Patch was applied but tests failed:\n{test_output[:600]}"
|
|
293
|
+
if attempt < max_attempts:
|
|
294
|
+
console.print(" [dim]Healing — will regenerate patch with failure context.[/]\n")
|
|
295
|
+
continue
|
|
296
|
+
# ── All attempts exhausted ────────────────────────────────
|
|
297
|
+
state = RepairState.ROLLED_BACK
|
|
298
|
+
print_step_fail(f"All {max_attempts} repair attempt(s) exhausted — original file restored.")
|
|
299
|
+
_save_and_exit(project_root, repair_id, finding, "", None, None, state.value)
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ─────────────────────────────────────────────────────────────
|
|
304
|
+
# Patch generation
|
|
305
|
+
# ─────────────────────────────────────────────────────────────
|
|
306
|
+
def _generate_patch(
|
|
307
|
+
finding: Finding,
|
|
308
|
+
source_snippet: str,
|
|
309
|
+
file_ctx_str: str,
|
|
310
|
+
fn_start: int,
|
|
311
|
+
fn_end: int,
|
|
312
|
+
original_block: str,
|
|
313
|
+
provider: str,
|
|
314
|
+
model: str,
|
|
315
|
+
prior_failure: str | None,
|
|
316
|
+
) -> str | None:
|
|
317
|
+
prior_section = ""
|
|
318
|
+
if prior_failure:
|
|
319
|
+
prior_section = f"""
|
|
320
|
+
## ⚠ Prior Attempt Failed
|
|
321
|
+
Your previous patch was tried and failed for this reason:
|
|
322
|
+
{prior_failure[:800]}
|
|
323
|
+
|
|
324
|
+
Study this carefully and produce an improved patch.
|
|
325
|
+
"""
|
|
326
|
+
user_prompt = f"""\
|
|
327
|
+
## Security Finding
|
|
328
|
+
- File: `{finding.file}`
|
|
329
|
+
- Line: {finding.line}
|
|
330
|
+
- Rule: `{finding.rule}`
|
|
331
|
+
- Severity: {finding.severity.value}
|
|
332
|
+
- Issue: {finding.description}
|
|
333
|
+
- Code: `{finding.code}`
|
|
334
|
+
- Impact: {finding.impact}
|
|
335
|
+
- Fix hint: {finding.remediation}
|
|
336
|
+
---
|
|
337
|
+
## Function to Patch (lines {fn_start}–{fn_end})
|
|
338
|
+
```python
|
|
339
|
+
{original_block}
|
|
340
|
+
Surrounding Context (for reference only — do NOT include in patch)
|
|
341
|
+
python
|
|
342
|
+
{source_snippet}
|
|
343
|
+
File-Level Context
|
|
344
|
+
{file_ctx_str} {prior_section}
|
|
345
|
+
Produce the fixed replacement for the function shown above. Wrap it EXACTLY in {PATCH_START} and {PATCH_END} markers. """
|
|
346
|
+
|
|
347
|
+
request = LLMRequest(
|
|
348
|
+
system_prompt=_FIX_SYSTEM,
|
|
349
|
+
user_prompt=user_prompt,
|
|
350
|
+
temperature=0.15,
|
|
351
|
+
max_tokens=2048,
|
|
352
|
+
)
|
|
353
|
+
try:
|
|
354
|
+
with spinner("Generating patch..."):
|
|
355
|
+
response = call_llm(request, provider=provider, model=model)
|
|
356
|
+
except (OSError, RuntimeError) as e:
|
|
357
|
+
print_step_fail(str(e))
|
|
358
|
+
return None
|
|
359
|
+
return extract_patch_code(response.content)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ─────────────────────────────────────────────────────────────
|
|
363
|
+
# Test runner
|
|
364
|
+
# ─────────────────────────────────────────────────────────────
|
|
365
|
+
def _run_tests(project_root: Path, test_command: str) -> tuple[bool, str]:
|
|
366
|
+
"""
|
|
367
|
+
Run test_command in project_root.
|
|
368
|
+
|
|
369
|
+
Returns (success, combined_output).
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
result = subprocess.run(
|
|
373
|
+
test_command.split(),
|
|
374
|
+
cwd=project_root,
|
|
375
|
+
capture_output=True,
|
|
376
|
+
text=True,
|
|
377
|
+
timeout=120,
|
|
378
|
+
shell=False, # deliberately NOT shell=True
|
|
379
|
+
check=False,
|
|
380
|
+
)
|
|
381
|
+
output = (result.stdout + "\n" + result.stderr).strip()
|
|
382
|
+
return result.returncode == 0, output
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
return True, f"Test command '{test_command}' not found — skipping validation."
|
|
385
|
+
except subprocess.TimeoutExpired:
|
|
386
|
+
return False, "Test command timed out after 120 seconds."
|
|
387
|
+
except (OSError, ValueError, subprocess.SubprocessError) as e:
|
|
388
|
+
return True, f"Test runner error (non-fatal): {e}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ─────────────────────────────────────────────────────────────
|
|
392
|
+
# Display helpers
|
|
393
|
+
# ─────────────────────────────────────────────────────────────
|
|
394
|
+
def _print_judge_result(judge: JudgeResult) -> None:
|
|
395
|
+
decision_color = "success" if judge.approved else "high"
|
|
396
|
+
|
|
397
|
+
console.print(
|
|
398
|
+
f" Judge: [{decision_color}]{judge.decision}[/] "
|
|
399
|
+
f"security_ok={judge.security_ok} "
|
|
400
|
+
f"risk=[bold]{judge.regression_risk}[/] "
|
|
401
|
+
f"quality={judge.patch_quality}"
|
|
402
|
+
)
|
|
403
|
+
console.print(f" [dim]{judge.reasoning[:180]}[/]")
|
|
404
|
+
console.print()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _severity_badge(sev: Severity) -> str:
|
|
408
|
+
colors = {
|
|
409
|
+
Severity.CRITICAL: "bold red",
|
|
410
|
+
Severity.HIGH: "red",
|
|
411
|
+
Severity.MEDIUM: "yellow",
|
|
412
|
+
Severity.LOW: "green",
|
|
413
|
+
Severity.INFO: "dim",
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
color = colors.get(sev, "white")
|
|
417
|
+
return f"[{color}]{sev.value}[/]"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _fmt_file_ctx(file_ctx) -> str:
|
|
421
|
+
if file_ctx is None:
|
|
422
|
+
return "Not available."
|
|
423
|
+
|
|
424
|
+
lines = []
|
|
425
|
+
|
|
426
|
+
if file_ctx.imports:
|
|
427
|
+
lines.append("Imports: " + ", ".join(i.module for i in file_ctx.imports[:12]))
|
|
428
|
+
|
|
429
|
+
if file_ctx.functions:
|
|
430
|
+
lines.append("Functions: " + ", ".join(f.name for f in file_ctx.functions[:8]))
|
|
431
|
+
|
|
432
|
+
return "\\n".join(lines) or "Not available."
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _read_lines(file_path: Path, start: int, end: int) -> str:
|
|
436
|
+
try:
|
|
437
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
438
|
+
return "\\n".join(lines[start - 1 : end])
|
|
439
|
+
except OSError:
|
|
440
|
+
return ""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _fallback_judge() -> JudgeResult:
|
|
444
|
+
"""When judge LLM itself fails, return a permissive default."""
|
|
445
|
+
from pisces.models import JudgeResult
|
|
446
|
+
|
|
447
|
+
return JudgeResult(
|
|
448
|
+
decision="APPROVED",
|
|
449
|
+
security_ok=True,
|
|
450
|
+
functionality_ok=True,
|
|
451
|
+
scope_ok=True,
|
|
452
|
+
regression_risk="MEDIUM",
|
|
453
|
+
patch_quality="ACCEPTABLE",
|
|
454
|
+
completeness="COMPLETE",
|
|
455
|
+
reasoning="Judge LLM unavailable — proceeding with HITL review.",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _save_and_exit(
|
|
460
|
+
project_root,
|
|
461
|
+
repair_id,
|
|
462
|
+
finding,
|
|
463
|
+
patch_code,
|
|
464
|
+
judge,
|
|
465
|
+
human_approved,
|
|
466
|
+
state,
|
|
467
|
+
):
|
|
468
|
+
try:
|
|
469
|
+
save_patch_record(
|
|
470
|
+
project_root=project_root,
|
|
471
|
+
repair_id=repair_id,
|
|
472
|
+
finding_dict=finding.to_dict(),
|
|
473
|
+
patch_code=patch_code,
|
|
474
|
+
judge_result_dict=judge.to_dict() if judge else None,
|
|
475
|
+
human_approved=human_approved,
|
|
476
|
+
state=state,
|
|
477
|
+
)
|
|
478
|
+
except (OSError, TypeError, ValueError):
|
|
479
|
+
print_warning("Could not save the repair history record.")
|