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/history_cmd.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.history_cmd
|
|
3
|
+
~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Implements `pisces history` — browse and inspect past repair attempts.
|
|
5
|
+
|
|
6
|
+
Storage layout:
|
|
7
|
+
.pisces/history/
|
|
8
|
+
<repair_id>/
|
|
9
|
+
metadata.json ← state, finding, judge result, timestamps
|
|
10
|
+
original.py ← file content BEFORE the patch
|
|
11
|
+
patch.py ← the proposed replacement code block
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
pisces history — list all repairs, newest first
|
|
15
|
+
pisces history <id> — show full detail + diff for one repair
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import difflib
|
|
21
|
+
import json
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from rich import box
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
from pisces.tui import (
|
|
30
|
+
_sev_badge,
|
|
31
|
+
console,
|
|
32
|
+
print_diff,
|
|
33
|
+
print_error,
|
|
34
|
+
print_section,
|
|
35
|
+
print_warning,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_HISTORY_DIR = ".pisces/history"
|
|
39
|
+
|
|
40
|
+
# State → display colour
|
|
41
|
+
_STATE_COLORS = {
|
|
42
|
+
"SUCCEEDED": "bold green",
|
|
43
|
+
"ROLLED_BACK": "bold red",
|
|
44
|
+
"REJECTED": "red",
|
|
45
|
+
"FAILED": "dim red",
|
|
46
|
+
"HEALING": "yellow",
|
|
47
|
+
"APPLIED": "cyan",
|
|
48
|
+
"DETECTED": "dim",
|
|
49
|
+
"PROPOSED": "dim",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ─────────────────────────────────────────────────────────────
|
|
54
|
+
# Entry point
|
|
55
|
+
# ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_history(
|
|
59
|
+
project_root: Path,
|
|
60
|
+
repair_id: str | None = None,
|
|
61
|
+
limit: int = 20,
|
|
62
|
+
) -> int:
|
|
63
|
+
"""
|
|
64
|
+
Entry point for `pisces history`.
|
|
65
|
+
|
|
66
|
+
If `repair_id` is given, show full detail for that repair.
|
|
67
|
+
Otherwise list all repairs, newest first.
|
|
68
|
+
|
|
69
|
+
Returns 0 on success, 1 on error.
|
|
70
|
+
"""
|
|
71
|
+
print_section("pisces history")
|
|
72
|
+
|
|
73
|
+
history_root = project_root / _HISTORY_DIR
|
|
74
|
+
|
|
75
|
+
if not history_root.exists():
|
|
76
|
+
console.print("[muted]No repair history found. Run 'pisces fix <file:line>' first.[/]")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
records = _load_all_records(history_root)
|
|
80
|
+
|
|
81
|
+
if not records:
|
|
82
|
+
console.print("[muted]No repair records found.[/]")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
if repair_id:
|
|
86
|
+
# Detail view for one repair
|
|
87
|
+
record = next((r for r in records if r["repair_id"] == repair_id), None)
|
|
88
|
+
if record is None:
|
|
89
|
+
print_error(f"Repair ID '{repair_id}' not found.")
|
|
90
|
+
return 1
|
|
91
|
+
_show_detail(record, history_root)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
# List view
|
|
95
|
+
_show_list(records, limit=limit)
|
|
96
|
+
console.print(
|
|
97
|
+
" [dim]Tip:[/] [cyan]pisces history <repair_id>[/] "
|
|
98
|
+
"[dim]to see diff + full details for any repair.[/]"
|
|
99
|
+
)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────
|
|
104
|
+
# List view
|
|
105
|
+
# ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _show_list(records: list[dict], limit: int) -> None:
|
|
109
|
+
"""Render a table of all repair records."""
|
|
110
|
+
sorted_records = sorted(
|
|
111
|
+
records,
|
|
112
|
+
key=lambda r: r.get("created_at", ""),
|
|
113
|
+
reverse=True,
|
|
114
|
+
)[:limit]
|
|
115
|
+
|
|
116
|
+
table = Table(
|
|
117
|
+
title=f"[bold]Repair History[/] [dim]({len(records)} total, showing {len(sorted_records)})[/]",
|
|
118
|
+
title_justify="left",
|
|
119
|
+
box=box.ROUNDED,
|
|
120
|
+
border_style="blue",
|
|
121
|
+
header_style="bold cyan",
|
|
122
|
+
show_lines=False,
|
|
123
|
+
padding=(0, 1),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
table.add_column("Repair ID", style="code", width=10, no_wrap=True)
|
|
127
|
+
table.add_column("State", style="bold", min_width=14)
|
|
128
|
+
table.add_column("Severity", style="bold", width=12)
|
|
129
|
+
table.add_column("Rule", style="code", min_width=25)
|
|
130
|
+
table.add_column("File", style="cyan", min_width=25, max_width=40)
|
|
131
|
+
table.add_column("When", style="dim", min_width=16)
|
|
132
|
+
|
|
133
|
+
for r in sorted_records:
|
|
134
|
+
state = r.get("state", "?")
|
|
135
|
+
finding = r.get("finding", {})
|
|
136
|
+
sev = finding.get("severity", "INFO").upper()
|
|
137
|
+
ts = _fmt_time(r.get("created_at", ""))
|
|
138
|
+
color = _STATE_COLORS.get(state, "white")
|
|
139
|
+
|
|
140
|
+
table.add_row(
|
|
141
|
+
r.get("repair_id", "?"),
|
|
142
|
+
f"[{color}]{state}[/]",
|
|
143
|
+
_sev_badge(sev),
|
|
144
|
+
finding.get("rule", "?"),
|
|
145
|
+
f"{finding.get('file', '?')}:{finding.get('line', '?')}",
|
|
146
|
+
ts,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
console.print(table)
|
|
150
|
+
console.print()
|
|
151
|
+
|
|
152
|
+
# Summary counts
|
|
153
|
+
states = [r.get("state", "?") for r in records]
|
|
154
|
+
succeeded = states.count("SUCCEEDED")
|
|
155
|
+
failed = states.count("ROLLED_BACK") + states.count("FAILED")
|
|
156
|
+
rejected = states.count("REJECTED")
|
|
157
|
+
|
|
158
|
+
console.print(
|
|
159
|
+
f" [success]✓ {succeeded} succeeded[/] "
|
|
160
|
+
f"[high]✗ {failed} failed/rolled back[/] "
|
|
161
|
+
f"[dim]⊘ {rejected} rejected[/]"
|
|
162
|
+
)
|
|
163
|
+
console.print()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ─────────────────────────────────────────────────────────────
|
|
167
|
+
# Detail view
|
|
168
|
+
# ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _show_detail(record: dict, history_root: Path) -> None:
|
|
172
|
+
"""Render the full detail panel for one repair record."""
|
|
173
|
+
from rich.panel import Panel
|
|
174
|
+
from rich.table import Table
|
|
175
|
+
|
|
176
|
+
repair_id = record.get("repair_id", "?")
|
|
177
|
+
state = record.get("state", "?")
|
|
178
|
+
finding = record.get("finding", {})
|
|
179
|
+
judge = record.get("judge_result") or {}
|
|
180
|
+
color = _STATE_COLORS.get(state, "white")
|
|
181
|
+
|
|
182
|
+
# ── Metadata grid ─────────────────────────────────────────
|
|
183
|
+
grid = Table.grid(padding=(0, 2))
|
|
184
|
+
grid.add_column(style="dim", min_width=18)
|
|
185
|
+
grid.add_column(style="white", min_width=30)
|
|
186
|
+
|
|
187
|
+
grid.add_row("Repair ID", f"[code]{repair_id}[/]")
|
|
188
|
+
grid.add_row("State", f"[{color}]{state}[/]")
|
|
189
|
+
grid.add_row("Rule", f"[code]{finding.get('rule', '?')}[/]")
|
|
190
|
+
grid.add_row("Severity", _sev_badge(finding.get("severity", "INFO")))
|
|
191
|
+
grid.add_row("File", f"[cyan]{finding.get('file', '?')}:{finding.get('line', '?')}[/]")
|
|
192
|
+
grid.add_row("Description", finding.get("description", ""))
|
|
193
|
+
grid.add_row("Created", _fmt_time(record.get("created_at", "")))
|
|
194
|
+
grid.add_row(
|
|
195
|
+
"Human approved",
|
|
196
|
+
"[success]Yes[/]"
|
|
197
|
+
if record.get("human_approved") is True
|
|
198
|
+
else "[high]No[/]"
|
|
199
|
+
if record.get("human_approved") is False
|
|
200
|
+
else "[dim]N/A[/]",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
console.print(
|
|
204
|
+
Panel(
|
|
205
|
+
grid,
|
|
206
|
+
title=f"[bold]Repair Detail — {repair_id}[/]",
|
|
207
|
+
border_style="blue",
|
|
208
|
+
padding=(0, 2),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
console.print()
|
|
212
|
+
|
|
213
|
+
# ── Judge result ──────────────────────────────────────────
|
|
214
|
+
if judge:
|
|
215
|
+
jt = Table.grid(padding=(0, 2))
|
|
216
|
+
jt.add_column(style="dim", min_width=20)
|
|
217
|
+
jt.add_column(style="white", min_width=20)
|
|
218
|
+
|
|
219
|
+
dec = judge.get("decision", "?")
|
|
220
|
+
dcol = "success" if dec == "APPROVED" else "high"
|
|
221
|
+
|
|
222
|
+
jt.add_row("Decision", f"[{dcol}]{dec}[/]")
|
|
223
|
+
jt.add_row("Security fixed", _badge(judge.get("security_ok")))
|
|
224
|
+
jt.add_row("Functionality ok", _badge(judge.get("functionality_ok")))
|
|
225
|
+
jt.add_row("Regression risk", judge.get("regression_risk", "?"))
|
|
226
|
+
jt.add_row("Quality", judge.get("patch_quality", "?"))
|
|
227
|
+
jt.add_row("Completeness", judge.get("completeness", "?"))
|
|
228
|
+
jt.add_row("Reasoning", f"[dim]{judge.get('reasoning', '')[:200]}[/]")
|
|
229
|
+
|
|
230
|
+
console.print(
|
|
231
|
+
Panel(
|
|
232
|
+
jt,
|
|
233
|
+
title="[bold]Judge Score[/]",
|
|
234
|
+
border_style="dim green",
|
|
235
|
+
padding=(0, 2),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
console.print()
|
|
239
|
+
|
|
240
|
+
# ── Diff ──────────────────────────────────────────────────
|
|
241
|
+
repair_dir = history_root / repair_id
|
|
242
|
+
original_file = repair_dir / "original.py"
|
|
243
|
+
patch_file = repair_dir / "patch.py"
|
|
244
|
+
|
|
245
|
+
if original_file.exists() and patch_file.exists():
|
|
246
|
+
original = original_file.read_text(encoding="utf-8")
|
|
247
|
+
patched = patch_file.read_text(encoding="utf-8")
|
|
248
|
+
console.print("[bold]Patch Diff:[/]")
|
|
249
|
+
|
|
250
|
+
diff_text = "".join(
|
|
251
|
+
difflib.unified_diff(
|
|
252
|
+
original.splitlines(keepends=True),
|
|
253
|
+
patched.splitlines(keepends=True),
|
|
254
|
+
fromfile=f"a/{finding.get('file', 'original.py')}",
|
|
255
|
+
tofile=f"b/{finding.get('file', 'patched.py')}",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
print_diff(
|
|
260
|
+
diff_text=diff_text or "No differences detected.\n",
|
|
261
|
+
filename=finding.get("file", ""),
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
console.print("[muted]No patch diff available (files not found).[/]")
|
|
265
|
+
console.print()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ─────────────────────────────────────────────────────────────
|
|
269
|
+
# Record loading
|
|
270
|
+
# ─────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _load_all_records(history_root: Path) -> list[dict]:
|
|
274
|
+
"""Load all metadata.json files from .pisces/history/*/."""
|
|
275
|
+
records: list[dict] = []
|
|
276
|
+
for meta_file in history_root.glob("*/metadata.json"):
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
279
|
+
records.append(data)
|
|
280
|
+
except (json.JSONDecodeError, OSError):
|
|
281
|
+
print_warning(f"Skipping corrupt record: {meta_file}")
|
|
282
|
+
return records
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ─────────────────────────────────────────────────────────────
|
|
286
|
+
# Formatting helpers
|
|
287
|
+
# ─────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _fmt_time(iso: str) -> str:
|
|
291
|
+
"""Convert an ISO timestamp to a human-readable relative string."""
|
|
292
|
+
try:
|
|
293
|
+
dt = datetime.fromisoformat(iso)
|
|
294
|
+
now = datetime.now(timezone.utc)
|
|
295
|
+
diff_s = (now - dt).total_seconds()
|
|
296
|
+
|
|
297
|
+
if diff_s < 60:
|
|
298
|
+
return "just now"
|
|
299
|
+
if diff_s < 3600:
|
|
300
|
+
return f"{int(diff_s // 60)}m ago"
|
|
301
|
+
if diff_s < 86400:
|
|
302
|
+
return f"{int(diff_s // 3600)}h ago"
|
|
303
|
+
if diff_s < 604800:
|
|
304
|
+
return f"{int(diff_s // 86400)}d ago"
|
|
305
|
+
return dt.strftime("%Y-%m-%d")
|
|
306
|
+
except (TypeError, ValueError, OverflowError):
|
|
307
|
+
return iso[:16] if len(iso) >= 16 else iso
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _badge(val: Any) -> str:
|
|
311
|
+
if val is True or str(val).lower() == "true":
|
|
312
|
+
return "[success]✓ yes[/]"
|
|
313
|
+
return "[high]✗ no[/]"
|
pisces/init_cmd.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""This implements the pisces init command for project initialization"""
|
|
2
|
+
|
|
3
|
+
# Can be run multiple times(idempotent).abs
|
|
4
|
+
# creates pisces.toml and the .pisces/ state directory
|
|
5
|
+
# NEVER destructively overwrites without --force
|
|
6
|
+
# Adds .pisces to .gitignore if it exists.abs
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pisces.tui import (
|
|
14
|
+
console,
|
|
15
|
+
print_section,
|
|
16
|
+
print_step_fail,
|
|
17
|
+
print_step_ok,
|
|
18
|
+
print_step_running,
|
|
19
|
+
print_success,
|
|
20
|
+
print_warning,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
## default pisces.toml template
|
|
24
|
+
|
|
25
|
+
_DEFAULT_PISCES_TOML = """\
|
|
26
|
+
# pisces project configuration
|
|
27
|
+
# API keys MUST come from environment variables — never put them here.
|
|
28
|
+
# See: https://github.com/richochetclementine1315/pisces#configuration
|
|
29
|
+
[llm]
|
|
30
|
+
# Supported providers: openai, anthropic, gemini, groq, ollama, lmstudio
|
|
31
|
+
provider = "groq"
|
|
32
|
+
model = "llama-3.3-70b-versatile"
|
|
33
|
+
[repair]
|
|
34
|
+
max_attempts = 3
|
|
35
|
+
require_approval = true # HITL — always ask before modifying source
|
|
36
|
+
[validation]
|
|
37
|
+
# Command used to run your project's test suite after patching.
|
|
38
|
+
# pisces will detect 'pytest' automatically if you leave this empty.
|
|
39
|
+
test_command = "pytest"
|
|
40
|
+
[scan]
|
|
41
|
+
# Paths to exclude from scanning (relative globs)
|
|
42
|
+
exclude = [
|
|
43
|
+
".git",
|
|
44
|
+
".venv",
|
|
45
|
+
"venv",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
"node_modules",
|
|
48
|
+
".pisces",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
]
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
"""Default context.json template"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _default_context(project_root: Path) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"schema_version": "1",
|
|
60
|
+
"project": {
|
|
61
|
+
"root": str(project_root),
|
|
62
|
+
"initialized_at": datetime.now(timezone.utc).isoformat(),
|
|
63
|
+
},
|
|
64
|
+
"files": {},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
"""Main init logic"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_init(project_root: Path, *, force: bool = False) -> int:
|
|
72
|
+
"""initialize pisces in *project_root*. , return 0 as success and 1 as errror."""
|
|
73
|
+
print_section("pisces init")
|
|
74
|
+
console.print(f" [dim]Project root:[/] [cyan]{project_root}[/]")
|
|
75
|
+
console.print()
|
|
76
|
+
|
|
77
|
+
# 1.Validation of the project root very important bro!
|
|
78
|
+
if not project_root.exists():
|
|
79
|
+
print_step_fail(f"Directory does not exists: {project_root}")
|
|
80
|
+
return 1
|
|
81
|
+
if not project_root.is_dir():
|
|
82
|
+
print_step_fail(f"Not a directory: {project_root}")
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
# 2.pisces.toml
|
|
86
|
+
toml_path = project_root / "pisces.toml"
|
|
87
|
+
_init_pisces_toml(toml_path, force=force)
|
|
88
|
+
|
|
89
|
+
# 3. .pisces/ state directories basically like cache, history context.json etc
|
|
90
|
+
pisces_dir = project_root / ".pisces"
|
|
91
|
+
history_dir = pisces_dir / "history"
|
|
92
|
+
cache_dir = pisces_dir / "cache"
|
|
93
|
+
context_file = pisces_dir / "context.json"
|
|
94
|
+
|
|
95
|
+
# Creating the files
|
|
96
|
+
ok = all(
|
|
97
|
+
[
|
|
98
|
+
_create_dir(pisces_dir),
|
|
99
|
+
_create_dir(history_dir),
|
|
100
|
+
_create_dir(cache_dir),
|
|
101
|
+
_init_context_json(context_file, project_root, force=force),
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
if not ok:
|
|
105
|
+
return 1 # error creating directories or context.json
|
|
106
|
+
# 4. .gitignore(means we are adding the .pisces in the .gitignore)
|
|
107
|
+
_update_gitignore(project_root)
|
|
108
|
+
|
|
109
|
+
# 5. Done
|
|
110
|
+
console.print()
|
|
111
|
+
print_success(
|
|
112
|
+
"✓ pisces initialized successfully!\n\n"
|
|
113
|
+
" Next steps:\n"
|
|
114
|
+
" 1. [cyan]pisces config[/] — set your LLM provider\n"
|
|
115
|
+
" 2. [cyan]pisces scan .[/] — detect security vulnerabilities\n"
|
|
116
|
+
" 3. [cyan]pisces fix <finding>[/] — generate and apply a patch"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return 0 # successfully created
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
"""Some Helper Functions"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _init_pisces_toml(path: Path, *, force: bool) -> None:
|
|
126
|
+
existed = path.exists()
|
|
127
|
+
if existed and not force:
|
|
128
|
+
print_step_ok("pisces.toml already exists - skipping (use --force)")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
action = "Overwriting" if existed else "creating"
|
|
132
|
+
print_step_running(f"{action} pisces.toml ...")
|
|
133
|
+
try:
|
|
134
|
+
path.write_text(_DEFAULT_PISCES_TOML, encoding="utf-8")
|
|
135
|
+
print_step_ok(f"pisces.toml {'overwritten' if existed else 'created'}")
|
|
136
|
+
except OSError as e:
|
|
137
|
+
print_step_fail(f"Could not write pisces.toml: {e}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _create_dir(path: Path) -> None:
|
|
141
|
+
if path.exists():
|
|
142
|
+
print_step_ok(f"{path.name}/ already exists - skipping")
|
|
143
|
+
return True
|
|
144
|
+
try:
|
|
145
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
print_step_ok(f"Created {path.name}/")
|
|
147
|
+
return True
|
|
148
|
+
except OSError as e:
|
|
149
|
+
print_step_fail(f"Could not create {path}: {e}")
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _init_context_json(path: Path, project_root: Path, *, force: bool) -> None:
|
|
154
|
+
if path.exists() and not force:
|
|
155
|
+
print_step_ok("context.json already exists - skipping")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
print_step_running("Writing context.json ...")
|
|
159
|
+
try:
|
|
160
|
+
path.write_text(
|
|
161
|
+
json.dumps(_default_context(project_root), indent=2),
|
|
162
|
+
encoding="utf-8",
|
|
163
|
+
)
|
|
164
|
+
print_step_ok("context.json initialized")
|
|
165
|
+
except OSError as e:
|
|
166
|
+
print_step_fail(f"Could not write context.json: {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _update_gitignore(project_root: Path) -> None:
|
|
170
|
+
"""Ensure .pisces/ is listed in .gitignore."""
|
|
171
|
+
gitignore = project_root / ".gitignore"
|
|
172
|
+
entry = ".pisces/"
|
|
173
|
+
|
|
174
|
+
if not gitignore.exists():
|
|
175
|
+
print_step_running("No .ignore found - creating one ...")
|
|
176
|
+
try:
|
|
177
|
+
gitignore.write_text(f"# pisces state directory\n{entry}\n", encoding="utf-8")
|
|
178
|
+
print_step_ok(".gitignore created with .pisces/entry")
|
|
179
|
+
except OSError as e:
|
|
180
|
+
print_warning(f"could not create .gitignore: {e}")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
184
|
+
if entry in content or ".pisces" in content:
|
|
185
|
+
print_step_ok(".gitignore already contains .pisces entry - skipping")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
with gitignore.open("a", encoding="utf-8") as f:
|
|
190
|
+
f.write(f"\n# pisces state directory\n{entry}\n")
|
|
191
|
+
print_step_ok("Added .pisces/ to .gitignore")
|
|
192
|
+
except OSError as e:
|
|
193
|
+
print_warning(f"Could not update .gitignore: {e}")
|
pisces/judge.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.judge
|
|
3
|
+
~~~~~~~~~~~~
|
|
4
|
+
LLM-based code review judge for proposed security patches.
|
|
5
|
+
|
|
6
|
+
The judge acts as an adversarial reviewer:
|
|
7
|
+
"Does this patch CORRECTLY and COMPLETELY fix the vulnerability
|
|
8
|
+
without introducing new problems or changing unrelated behaviour?"
|
|
9
|
+
|
|
10
|
+
Returns a structured JudgeResult the repair loop uses to decide
|
|
11
|
+
whether to accept the patch, reject it, or ask for another attempt.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from pisces.llm_client import call_llm
|
|
19
|
+
from pisces.models import Finding, JudgeResult, LLMRequest
|
|
20
|
+
|
|
21
|
+
_JUDGE_SYSTEM = """\
|
|
22
|
+
You are an adversarial security code reviewer. Your job is to judge
|
|
23
|
+
whether a proposed patch correctly fixes a security vulnerability.
|
|
24
|
+
Be strict. Reject patches that are incomplete, introduce new issues,
|
|
25
|
+
or change behaviour unrelated to the vulnerability.
|
|
26
|
+
|
|
27
|
+
Respond ONLY in this exact format:
|
|
28
|
+
|
|
29
|
+
DECISION: APPROVED
|
|
30
|
+
SECURITY_OK: true
|
|
31
|
+
FUNCTIONALITY_OK: true
|
|
32
|
+
SCOPE_OK: true
|
|
33
|
+
REGRESSION_RISK: LOW
|
|
34
|
+
PATCH_QUALITY: GOOD
|
|
35
|
+
COMPLETENESS: COMPLETE
|
|
36
|
+
REASONING: <one paragraph>
|
|
37
|
+
|
|
38
|
+
Valid values:
|
|
39
|
+
DECISION: APPROVED | REJECTED
|
|
40
|
+
SECURITY_OK: true | false (does it fix the vulnerability?)
|
|
41
|
+
FUNCTIONALITY_OK: true | false (does it preserve intended behaviour?)
|
|
42
|
+
SCOPE_OK: true | false (does it change ONLY what's needed?)
|
|
43
|
+
REGRESSION_RISK: LOW | MEDIUM | HIGH
|
|
44
|
+
PATCH_QUALITY: GOOD | ACCEPTABLE | POOR
|
|
45
|
+
COMPLETENESS: COMPLETE | PARTIAL | INCOMPLETE
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def judge_patch(
|
|
50
|
+
finding: Finding,
|
|
51
|
+
original_code: str,
|
|
52
|
+
patched_code: str,
|
|
53
|
+
provider: str,
|
|
54
|
+
model: str,
|
|
55
|
+
prior_failure: str | None = None,
|
|
56
|
+
) -> JudgeResult:
|
|
57
|
+
"""
|
|
58
|
+
Ask the LLM to judge whether *patched_code* correctly fixes *finding*.
|
|
59
|
+
|
|
60
|
+
*prior_failure* — if this is a re-attempt after test failure, include
|
|
61
|
+
the test output so the judge has extra context.
|
|
62
|
+
"""
|
|
63
|
+
prior_section = ""
|
|
64
|
+
if prior_failure:
|
|
65
|
+
prior_section = f"""
|
|
66
|
+
## Prior Attempt Failed
|
|
67
|
+
The patch was applied and tests were run. The test suite FAILED with:
|
|
68
|
+
{prior_failure[:1000]}
|
|
69
|
+
|
|
70
|
+
Consider whether the patch caused this failure.
|
|
71
|
+
"""
|
|
72
|
+
user_prompt = f"""\
|
|
73
|
+
## Security Finding to Fix
|
|
74
|
+
- Rule: {finding.rule}
|
|
75
|
+
- Severity: {finding.severity.value}
|
|
76
|
+
- File: {finding.file}
|
|
77
|
+
- Line: {finding.line}
|
|
78
|
+
- Issue: {finding.description}
|
|
79
|
+
- Code: `{finding.code}`
|
|
80
|
+
---
|
|
81
|
+
## Original Code (vulnerable)
|
|
82
|
+
```python
|
|
83
|
+
{original_code}
|
|
84
|
+
Proposed Patch
|
|
85
|
+
python
|
|
86
|
+
{patched_code}
|
|
87
|
+
{prior_section}
|
|
88
|
+
Judge this patch. Is it correct, complete, and safe to apply? """
|
|
89
|
+
|
|
90
|
+
request = LLMRequest(
|
|
91
|
+
system_prompt=_JUDGE_SYSTEM,
|
|
92
|
+
user_prompt=user_prompt,
|
|
93
|
+
temperature=0.1, # very low — we want consistent, strict judgment
|
|
94
|
+
max_tokens=512,
|
|
95
|
+
)
|
|
96
|
+
response = call_llm(request, provider=provider, model=model)
|
|
97
|
+
return _parse_judge_response(response.content)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_judge_response(content: str) -> JudgeResult:
|
|
101
|
+
"""Parse the structured judge response into a JudgeResult."""
|
|
102
|
+
|
|
103
|
+
def _field(name: str, default: str = "") -> str:
|
|
104
|
+
m = re.search(rf"^{name}:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE)
|
|
105
|
+
return m.group(1).strip() if m else default
|
|
106
|
+
|
|
107
|
+
def _bool_field(name: str, default: bool = True) -> bool:
|
|
108
|
+
val = _field(name, "true").lower()
|
|
109
|
+
return val == "true"
|
|
110
|
+
|
|
111
|
+
decision = _field("DECISION", "REJECTED").upper()
|
|
112
|
+
security_ok = _bool_field("SECURITY_OK", False)
|
|
113
|
+
functionality_ok = _bool_field("FUNCTIONALITY_OK", True)
|
|
114
|
+
scope_ok = _bool_field("SCOPE_OK", True)
|
|
115
|
+
regression_risk = _field("REGRESSION_RISK", "MEDIUM").upper()
|
|
116
|
+
patch_quality = _field("PATCH_QUALITY", "ACCEPTABLE").upper()
|
|
117
|
+
completeness = _field("COMPLETENESS", "PARTIAL").upper()
|
|
118
|
+
reasoning = _field("REASONING", content[:300])
|
|
119
|
+
# Safety override: if security not fixed, decision must be REJECTED
|
|
120
|
+
if not security_ok:
|
|
121
|
+
decision = "REJECTED"
|
|
122
|
+
return JudgeResult(
|
|
123
|
+
decision=decision,
|
|
124
|
+
security_ok=security_ok,
|
|
125
|
+
functionality_ok=functionality_ok,
|
|
126
|
+
scope_ok=scope_ok,
|
|
127
|
+
regression_risk=regression_risk,
|
|
128
|
+
patch_quality=patch_quality,
|
|
129
|
+
completeness=completeness,
|
|
130
|
+
reasoning=reasoning,
|
|
131
|
+
)
|