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/patch_engine.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.patch_engine
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Handles patch parsing, application, checkpointing, and rollback.
|
|
5
|
+
|
|
6
|
+
The patch format pisces uses is a "block replacement":
|
|
7
|
+
The LLM outputs a function (or set of lines) to replace.
|
|
8
|
+
We locate the old function boundaries via AST, then do a line-range
|
|
9
|
+
swap. This is far more reliable than applying unified diffs.
|
|
10
|
+
|
|
11
|
+
Atomicity guarantee:
|
|
12
|
+
1. Checkpoint — copy file to .pisces/history/<id>/original.py
|
|
13
|
+
2. Write — write new content to file.py.tmp
|
|
14
|
+
3. Rename — file.py.tmp → file.py (atomic on most OSes)
|
|
15
|
+
4. Rollback — if anything fails, copy original.py → file.py
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import shutil
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pisces.tui import print_step_fail, print_step_ok, print_step_running, print_warning
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────────────────────────────────────────
|
|
27
|
+
# LLM patch output format
|
|
28
|
+
# ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
# The fix prompt asks the LLM to wrap patched code in these markers:
|
|
31
|
+
PATCH_START = "<<<PISCES_PATCH_START>>>"
|
|
32
|
+
PATCH_END = "<<<PISCES_PATCH_END>>>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────────────────────
|
|
36
|
+
# Patch extraction from LLM response
|
|
37
|
+
# ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_patch_code(llm_response: str) -> str | None:
|
|
41
|
+
"""
|
|
42
|
+
Extract the patched code block from the LLM response.
|
|
43
|
+
|
|
44
|
+
Looks for text between PATCH_START and PATCH_END markers.
|
|
45
|
+
Falls back to extracting the first ```python ... ``` code block.
|
|
46
|
+
|
|
47
|
+
Returns the code string, or None if no patch found.
|
|
48
|
+
"""
|
|
49
|
+
# Primary: marker-based extraction
|
|
50
|
+
if PATCH_START in llm_response and PATCH_END in llm_response:
|
|
51
|
+
start = llm_response.index(PATCH_START) + len(PATCH_START)
|
|
52
|
+
end = llm_response.index(PATCH_END)
|
|
53
|
+
code = llm_response[start:end].strip()
|
|
54
|
+
# Strip any accidental ```python fences inside the markers
|
|
55
|
+
code = _strip_fences(code)
|
|
56
|
+
return code if code else None
|
|
57
|
+
|
|
58
|
+
# Fallback: first ```python block
|
|
59
|
+
import re
|
|
60
|
+
|
|
61
|
+
pattern = re.compile(r"```python\s*\n(.*?)```", re.DOTALL)
|
|
62
|
+
match = pattern.search(llm_response)
|
|
63
|
+
if match:
|
|
64
|
+
return match.group(1).strip()
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strip_fences(code: str) -> str:
|
|
70
|
+
"""Remove leading/trailing ``` fences from a code block."""
|
|
71
|
+
lines = code.strip().splitlines()
|
|
72
|
+
if lines and lines[0].strip().startswith("```"):
|
|
73
|
+
lines = lines[1:]
|
|
74
|
+
if lines and lines[-1].strip() == "```":
|
|
75
|
+
lines = lines[:-1]
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ─────────────────────────────────────────────────────────────
|
|
80
|
+
# Patch validation — is the replacement valid Python?
|
|
81
|
+
# ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_patch_syntax(patch_code: str) -> tuple[bool, str]:
|
|
85
|
+
"""
|
|
86
|
+
Return (ok, error_message).
|
|
87
|
+
Tries to parse the patch_code as Python and reports any SyntaxError.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
ast.parse(patch_code)
|
|
91
|
+
return True, ""
|
|
92
|
+
except SyntaxError as e:
|
|
93
|
+
return False, f"SyntaxError at line {e.lineno}: {e.msg}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ─────────────────────────────────────────────────────────────
|
|
97
|
+
# Checkpoint — save original file before patching
|
|
98
|
+
# ─────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def checkpoint_file(
|
|
102
|
+
file_path: Path,
|
|
103
|
+
project_root: Path,
|
|
104
|
+
repair_id: str,
|
|
105
|
+
) -> Path:
|
|
106
|
+
"""
|
|
107
|
+
Copy *file_path* to .pisces/history/<repair_id>/original.py.
|
|
108
|
+
|
|
109
|
+
Returns the path of the checkpoint copy.
|
|
110
|
+
Raises OSError if the copy fails.
|
|
111
|
+
"""
|
|
112
|
+
history_dir = project_root / ".pisces" / "history" / repair_id
|
|
113
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
checkpoint = history_dir / "original.py"
|
|
116
|
+
shutil.copy2(file_path, checkpoint)
|
|
117
|
+
print_step_ok(f"Checkpoint saved: [dim]{checkpoint.relative_to(project_root)}[/]")
|
|
118
|
+
return checkpoint
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_patch_record(
|
|
122
|
+
project_root: Path,
|
|
123
|
+
repair_id: str,
|
|
124
|
+
finding_dict: dict,
|
|
125
|
+
patch_code: str,
|
|
126
|
+
judge_result_dict: dict | None,
|
|
127
|
+
human_approved: bool | None,
|
|
128
|
+
state: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Write metadata.json and patch.py to the repair history directory."""
|
|
131
|
+
import json
|
|
132
|
+
from datetime import datetime, timezone
|
|
133
|
+
|
|
134
|
+
history_dir = project_root / ".pisces" / "history" / repair_id
|
|
135
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
# patch.py — the actual replacement code
|
|
138
|
+
(history_dir / "patch.py").write_text(patch_code, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
# metadata.json
|
|
141
|
+
meta = {
|
|
142
|
+
"repair_id": repair_id,
|
|
143
|
+
"state": state,
|
|
144
|
+
"finding": finding_dict,
|
|
145
|
+
"judge_result": judge_result_dict,
|
|
146
|
+
"human_approved": human_approved,
|
|
147
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
148
|
+
}
|
|
149
|
+
(history_dir / "metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ─────────────────────────────────────────────────────────────
|
|
153
|
+
# Patch application — line-range swap
|
|
154
|
+
# ─────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def locate_function_lines(
|
|
158
|
+
file_path: Path,
|
|
159
|
+
finding_line: int,
|
|
160
|
+
) -> tuple[int, int]:
|
|
161
|
+
"""
|
|
162
|
+
Return (line_start, line_end) of the function that *contains* finding_line.
|
|
163
|
+
|
|
164
|
+
If no enclosing function is found, returns (finding_line, finding_line)
|
|
165
|
+
— we still patch just that one line.
|
|
166
|
+
|
|
167
|
+
Uses AST for accuracy (accounts for decorators, nested functions, etc.)
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
171
|
+
tree = ast.parse(source)
|
|
172
|
+
except (OSError, SyntaxError):
|
|
173
|
+
return finding_line, finding_line
|
|
174
|
+
|
|
175
|
+
best: tuple[int, int] | None = None
|
|
176
|
+
best_size = float("inf")
|
|
177
|
+
|
|
178
|
+
for node in ast.walk(tree):
|
|
179
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
180
|
+
continue
|
|
181
|
+
start = node.lineno
|
|
182
|
+
end = node.end_lineno or node.lineno
|
|
183
|
+
if start <= finding_line <= end:
|
|
184
|
+
size = end - start
|
|
185
|
+
if size < best_size:
|
|
186
|
+
best_size = size
|
|
187
|
+
best = (start, end)
|
|
188
|
+
|
|
189
|
+
return best if best else (finding_line, finding_line)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def apply_patch(
|
|
193
|
+
file_path: Path,
|
|
194
|
+
patch_code: str,
|
|
195
|
+
line_start: int,
|
|
196
|
+
line_end: int,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Replace lines [line_start, line_end] (1-indexed, inclusive) in *file_path*
|
|
200
|
+
with *patch_code*, atomically via a .tmp file.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
OSError — file read/write failure
|
|
204
|
+
ValueError — patch would produce invalid Python
|
|
205
|
+
"""
|
|
206
|
+
# Read current file
|
|
207
|
+
original_lines = file_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
208
|
+
|
|
209
|
+
# Preserve leading indentation of the original block
|
|
210
|
+
# (the replaced block may be inside a class — keep its indentation)
|
|
211
|
+
original_block_indent = ""
|
|
212
|
+
if line_start <= len(original_lines):
|
|
213
|
+
first_original = original_lines[line_start - 1]
|
|
214
|
+
original_block_indent = first_original[: len(first_original) - len(first_original.lstrip())]
|
|
215
|
+
|
|
216
|
+
# Re-indent the patch_code to match the original indentation
|
|
217
|
+
patch_lines = patch_code.splitlines(keepends=True)
|
|
218
|
+
first_patch = patch_lines[0] if patch_lines else ""
|
|
219
|
+
patch_indent = first_patch[: len(first_patch) - len(first_patch.lstrip())]
|
|
220
|
+
|
|
221
|
+
if patch_indent != original_block_indent:
|
|
222
|
+
# Adjust: strip patch's own leading indent, add original's
|
|
223
|
+
adjusted = []
|
|
224
|
+
for pl in patch_lines:
|
|
225
|
+
stripped = pl.removeprefix(patch_indent)
|
|
226
|
+
adjusted.append(original_block_indent + stripped)
|
|
227
|
+
patch_lines = adjusted
|
|
228
|
+
|
|
229
|
+
# Assemble new file content
|
|
230
|
+
before = original_lines[: line_start - 1]
|
|
231
|
+
after = original_lines[line_end:]
|
|
232
|
+
new_content = "".join(before + patch_lines + after)
|
|
233
|
+
|
|
234
|
+
# Validate the new file as a whole
|
|
235
|
+
valid, err = validate_patch_syntax(new_content)
|
|
236
|
+
if not valid:
|
|
237
|
+
raise ValueError(f"Patched file has a syntax error: {err}")
|
|
238
|
+
|
|
239
|
+
# Atomic write via .tmp
|
|
240
|
+
tmp_path = file_path.with_suffix(".py.tmp")
|
|
241
|
+
try:
|
|
242
|
+
tmp_path.write_text(new_content, encoding="utf-8")
|
|
243
|
+
tmp_path.replace(file_path)
|
|
244
|
+
except OSError:
|
|
245
|
+
tmp_path.unlink(missing_ok=True)
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
print_step_ok(
|
|
249
|
+
f"Patch applied to [cyan]{file_path.name}[/] (replaced lines {line_start}–{line_end})"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ─────────────────────────────────────────────────────────────
|
|
254
|
+
# Rollback
|
|
255
|
+
# ─────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def rollback(file_path: Path, checkpoint: Path) -> None:
|
|
259
|
+
"""Restore *file_path* from the checkpoint copy."""
|
|
260
|
+
print_step_running(f"Rolling back [cyan]{file_path.name}[/] ...")
|
|
261
|
+
try:
|
|
262
|
+
shutil.copy2(checkpoint, file_path)
|
|
263
|
+
print_step_ok("Rollback complete — original file restored")
|
|
264
|
+
except OSError as e:
|
|
265
|
+
print_step_fail(f"Rollback FAILED: {e} — manual recovery needed!")
|
|
266
|
+
print_warning(f"Checkpoint is at: {checkpoint}")
|
pisces/scan_cmd.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.scan_cmd
|
|
3
|
+
~~~~~~~~~~~~~~~
|
|
4
|
+
Implements `pisces scan` — discovery, context update, scanning, output.
|
|
5
|
+
|
|
6
|
+
After scanning, findings are cached in .pisces/cache/findings.json so
|
|
7
|
+
`pisces explain` and `pisces fix` can resolve them by file:line reference.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pisces.config_manager import load_config
|
|
16
|
+
from pisces.context_manager import ContextManager, discover_python_files
|
|
17
|
+
from pisces.models import Finding, Severity
|
|
18
|
+
from pisces.scanner import Scanner
|
|
19
|
+
from pisces.tui import (
|
|
20
|
+
console,
|
|
21
|
+
make_scan_progress,
|
|
22
|
+
print_findings_table,
|
|
23
|
+
print_section,
|
|
24
|
+
print_step_ok,
|
|
25
|
+
print_warning,
|
|
26
|
+
spinner,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Where we persist findings between commands
|
|
30
|
+
_FINDINGS_CACHE = ".pisces/cache/findings.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────────────────────
|
|
34
|
+
# Entry point
|
|
35
|
+
# ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_scan(
|
|
39
|
+
target: Path,
|
|
40
|
+
output: Path | None = None,
|
|
41
|
+
min_severity: str = "LOW",
|
|
42
|
+
) -> int:
|
|
43
|
+
"""
|
|
44
|
+
Entry point for `pisces scan`.
|
|
45
|
+
|
|
46
|
+
Returns 0 if no findings (or only below threshold), 1 if findings exist.
|
|
47
|
+
"""
|
|
48
|
+
print_section("pisces Security Scanner")
|
|
49
|
+
|
|
50
|
+
# ── Load config ──────────────────────────────────────────
|
|
51
|
+
try:
|
|
52
|
+
cfg = load_config(target)
|
|
53
|
+
excludes = cfg.scan.exclude
|
|
54
|
+
except RuntimeError:
|
|
55
|
+
# Might be scanning a single file without init — allow it
|
|
56
|
+
excludes = []
|
|
57
|
+
print_warning("No pisces.toml found — using default exclusions.")
|
|
58
|
+
|
|
59
|
+
# ── Discover Python files ────────────────────────────────
|
|
60
|
+
with spinner("Discovering Python files..."):
|
|
61
|
+
if target.is_file() and target.suffix == ".py":
|
|
62
|
+
py_files = [target]
|
|
63
|
+
project_root = target.parent
|
|
64
|
+
else:
|
|
65
|
+
project_root = target
|
|
66
|
+
py_files = discover_python_files(target, extra_excludes=excludes)
|
|
67
|
+
|
|
68
|
+
print_step_ok(f"Discovered [cyan]{len(py_files)}[/] Python file(s)")
|
|
69
|
+
|
|
70
|
+
if not py_files:
|
|
71
|
+
console.print()
|
|
72
|
+
console.print("[muted]No Python files found to scan.[/]")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
# ── Update context ───────────────────────────────────────
|
|
76
|
+
with spinner("Updating project context..."):
|
|
77
|
+
try:
|
|
78
|
+
cm = ContextManager(project_root, extra_excludes=excludes)
|
|
79
|
+
cm.update(verbose=False)
|
|
80
|
+
except (OSError, RuntimeError, TypeError, ValueError) as e:
|
|
81
|
+
print_warning(f"Context update skipped: {e}")
|
|
82
|
+
|
|
83
|
+
print_step_ok("Project context updated")
|
|
84
|
+
|
|
85
|
+
# ── Run security rules with progress bar ─────────────────
|
|
86
|
+
console.print()
|
|
87
|
+
scanner = Scanner()
|
|
88
|
+
all_findings: list[Finding] = []
|
|
89
|
+
|
|
90
|
+
_RULE_LABELS = [
|
|
91
|
+
"Checking hardcoded secrets",
|
|
92
|
+
"Checking command injection",
|
|
93
|
+
"Checking unsafe subprocess usage",
|
|
94
|
+
"Checking unsafe deserialization",
|
|
95
|
+
"Checking TLS verification",
|
|
96
|
+
"Checking code injection (eval/exec)",
|
|
97
|
+
"Checking filesystem operations",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
with make_scan_progress() as progress:
|
|
101
|
+
task = progress.add_task(
|
|
102
|
+
"Running security rules...", total=len(py_files) + len(_RULE_LABELS)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Show rule-level steps briefly
|
|
106
|
+
for label in _RULE_LABELS:
|
|
107
|
+
progress.update(task, description=label, advance=1)
|
|
108
|
+
|
|
109
|
+
# Scan files
|
|
110
|
+
for py_file in py_files:
|
|
111
|
+
progress.update(
|
|
112
|
+
task,
|
|
113
|
+
description=f"Scanning [cyan]{py_file.name}[/]...",
|
|
114
|
+
advance=1,
|
|
115
|
+
)
|
|
116
|
+
all_findings.extend(scanner.scan_file(py_file, project_root))
|
|
117
|
+
|
|
118
|
+
console.print()
|
|
119
|
+
print_step_ok("Scan complete")
|
|
120
|
+
console.print()
|
|
121
|
+
|
|
122
|
+
# ── Filter by minimum severity ───────────────────────────
|
|
123
|
+
try:
|
|
124
|
+
min_sev = Severity.from_str(min_severity)
|
|
125
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
126
|
+
min_sev = Severity.LOW
|
|
127
|
+
|
|
128
|
+
_sev_order = {
|
|
129
|
+
Severity.CRITICAL: 0,
|
|
130
|
+
Severity.HIGH: 1,
|
|
131
|
+
Severity.MEDIUM: 2,
|
|
132
|
+
Severity.LOW: 3,
|
|
133
|
+
Severity.INFO: 4,
|
|
134
|
+
}
|
|
135
|
+
threshold = _sev_order[min_sev]
|
|
136
|
+
filtered = [f for f in all_findings if _sev_order.get(f.severity, 99) <= threshold]
|
|
137
|
+
|
|
138
|
+
# ── Save findings cache ───────────────────────────────────
|
|
139
|
+
_save_findings_cache(project_root, all_findings)
|
|
140
|
+
|
|
141
|
+
# ── Display results ───────────────────────────────────────
|
|
142
|
+
findings_as_dicts = [
|
|
143
|
+
{
|
|
144
|
+
"severity": f.severity.value,
|
|
145
|
+
"file": f.file,
|
|
146
|
+
"line": f.line,
|
|
147
|
+
"rule": f.rule,
|
|
148
|
+
"description": f.description,
|
|
149
|
+
}
|
|
150
|
+
for f in filtered
|
|
151
|
+
]
|
|
152
|
+
print_findings_table(findings_as_dicts)
|
|
153
|
+
|
|
154
|
+
# ── Summary line ─────────────────────────────────────────
|
|
155
|
+
total = len(all_findings)
|
|
156
|
+
if total > 0:
|
|
157
|
+
above = len(filtered)
|
|
158
|
+
console.print(
|
|
159
|
+
f" Found [bold red]{total}[/] vulnerability/vulnerabilities total "
|
|
160
|
+
f"([cyan]{above}[/] at or above [bold]{min_severity}[/] severity).\n"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if filtered:
|
|
164
|
+
console.print(
|
|
165
|
+
" [dim]Run[/] [cyan]pisces explain <file>:<line>[/] "
|
|
166
|
+
"[dim]for detail on any finding.[/]"
|
|
167
|
+
)
|
|
168
|
+
console.print(
|
|
169
|
+
" [dim]Run[/] [cyan]pisces fix <file>:<line>[/] "
|
|
170
|
+
"[dim]to generate and apply a security patch.[/]"
|
|
171
|
+
)
|
|
172
|
+
console.print()
|
|
173
|
+
|
|
174
|
+
# ── Optional JSON output ──────────────────────────────────
|
|
175
|
+
if output:
|
|
176
|
+
_write_json_output(output, all_findings)
|
|
177
|
+
|
|
178
|
+
# Exit code: 1 if any findings at/above threshold, else 0
|
|
179
|
+
return 1 if filtered else 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ─────────────────────────────────────────────────────────────
|
|
183
|
+
# Findings cache (used by explain + fix commands)
|
|
184
|
+
# ─────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _save_findings_cache(project_root: Path, findings: list[Finding]) -> None:
|
|
188
|
+
"""Save findings to .pisces/cache/findings.json for lookup by other commands."""
|
|
189
|
+
cache_path = project_root / _FINDINGS_CACHE
|
|
190
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
try:
|
|
192
|
+
data = {
|
|
193
|
+
"findings": [f.to_dict() for f in findings],
|
|
194
|
+
# Index by "file:line" for O(1) lookup
|
|
195
|
+
"index": {f"{f.file}:{f.line}": f.to_dict() for f in findings},
|
|
196
|
+
}
|
|
197
|
+
cache_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
198
|
+
except OSError as e:
|
|
199
|
+
print_warning(f"Could not save findings cache: {e}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_finding_by_ref(project_root: Path, ref: str) -> Finding | None:
|
|
203
|
+
"""
|
|
204
|
+
Load a specific finding by its 'file:line' reference.
|
|
205
|
+
|
|
206
|
+
Called by `pisces explain` and `pisces fix`.
|
|
207
|
+
"""
|
|
208
|
+
cache_path = project_root / _FINDINGS_CACHE
|
|
209
|
+
if not cache_path.exists():
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
213
|
+
entry = data.get("index", {}).get(ref)
|
|
214
|
+
if entry:
|
|
215
|
+
return Finding.from_dict(entry)
|
|
216
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
217
|
+
pass
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _write_json_output(output: Path, findings: list[Finding]) -> None:
|
|
222
|
+
"""Write findings as JSON to a user-specified file."""
|
|
223
|
+
try:
|
|
224
|
+
output.write_text(
|
|
225
|
+
json.dumps([f.to_dict() for f in findings], indent=2),
|
|
226
|
+
encoding="utf-8",
|
|
227
|
+
)
|
|
228
|
+
print_step_ok(f"Findings saved to [cyan]{output}[/]")
|
|
229
|
+
except OSError as e:
|
|
230
|
+
print_warning(f"Could not write output file: {e}")
|