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/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
+ )