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/llm_client.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.llm_client
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Multi-provider LLM client abstraction.
|
|
5
|
+
|
|
6
|
+
Supported providers:
|
|
7
|
+
openai — OpenAI API (GPT-4o, GPT-4o-mini, o1, ...)
|
|
8
|
+
anthropic — Anthropic API (Claude 3.5 Haiku/Sonnet, ...)
|
|
9
|
+
gemini — Google Gemini API (gemini-2.0-flash, ...)
|
|
10
|
+
groq — Groq API (llama-3.3-70b, mixtral, ...) [OpenAI-compatible]
|
|
11
|
+
ollama — Local Ollama server [OpenAI-compatible]
|
|
12
|
+
lmstudio — Local LM Studio server [OpenAI-compatible]
|
|
13
|
+
|
|
14
|
+
Security: credentials come from environment only (see config_manager.get_api_key).
|
|
15
|
+
|
|
16
|
+
Design:
|
|
17
|
+
- Lazy imports: only import a provider's SDK when it's actually used.
|
|
18
|
+
- Single unified LLMRequest / LLMResponse model.
|
|
19
|
+
- Retry with exponential backoff for rate-limit / transient errors.
|
|
20
|
+
- Timeout enforced on every call.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from pisces.config_manager import get_api_key
|
|
28
|
+
from pisces.models import LLMRequest, LLMResponse
|
|
29
|
+
|
|
30
|
+
# ─────────────────────────────────────────────────────────────
|
|
31
|
+
# Provider base URLs for OpenAI-compatible providers
|
|
32
|
+
# ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
_OPENAI_COMPAT_BASE_URLS: dict[str, str] = {
|
|
35
|
+
"groq": "https://api.groq.com/openai/v1",
|
|
36
|
+
"ollama": "http://localhost:11434/v1",
|
|
37
|
+
"lmstudio": "http://localhost:1234/v1",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ─────────────────────────────────────────────────────────────
|
|
41
|
+
# Retry settings
|
|
42
|
+
# ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
_MAX_RETRIES = 3
|
|
45
|
+
_RETRY_DELAY_S = 2.0 # seconds, doubled on each retry
|
|
46
|
+
_REQUEST_TIMEOUT = 120 # seconds
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ─────────────────────────────────────────────────────────────
|
|
50
|
+
# Dispatch
|
|
51
|
+
# ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def call_llm(
|
|
55
|
+
request: LLMRequest,
|
|
56
|
+
provider: str,
|
|
57
|
+
model: str,
|
|
58
|
+
) -> LLMResponse:
|
|
59
|
+
"""
|
|
60
|
+
Dispatch an LLM call to the correct provider function.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
EnvironmentError — API key not found
|
|
64
|
+
RuntimeError — SDK not installed, or all retries exhausted
|
|
65
|
+
"""
|
|
66
|
+
provider = provider.lower()
|
|
67
|
+
|
|
68
|
+
dispatch = {
|
|
69
|
+
"openai": _call_openai_compat,
|
|
70
|
+
"groq": _call_openai_compat,
|
|
71
|
+
"ollama": _call_openai_compat,
|
|
72
|
+
"lmstudio": _call_openai_compat,
|
|
73
|
+
"anthropic": _call_anthropic,
|
|
74
|
+
"gemini": _call_gemini,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn = dispatch.get(provider)
|
|
78
|
+
if fn is None:
|
|
79
|
+
raise RuntimeError(f"Unknown provider: '{provider}'")
|
|
80
|
+
|
|
81
|
+
return fn(request, provider=provider, model=model)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ─────────────────────────────────────────────────────────────
|
|
85
|
+
# OpenAI-compatible (openai, groq, ollama, lmstudio)
|
|
86
|
+
# ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _call_openai_compat(
|
|
90
|
+
request: LLMRequest,
|
|
91
|
+
provider: str,
|
|
92
|
+
model: str,
|
|
93
|
+
) -> LLMResponse:
|
|
94
|
+
try:
|
|
95
|
+
from openai import APIError, OpenAI, RateLimitError # lazy import
|
|
96
|
+
except ImportError:
|
|
97
|
+
raise RuntimeError("openai SDK not installed. Run: pip install openai")
|
|
98
|
+
|
|
99
|
+
api_key = get_api_key(provider) or "ollama" # ollama/lmstudio don't need a key
|
|
100
|
+
base_url = _OPENAI_COMPAT_BASE_URLS.get(provider)
|
|
101
|
+
|
|
102
|
+
client_kwargs: dict = {
|
|
103
|
+
"api_key": api_key,
|
|
104
|
+
"timeout": _REQUEST_TIMEOUT,
|
|
105
|
+
}
|
|
106
|
+
if base_url:
|
|
107
|
+
client_kwargs["base_url"] = base_url
|
|
108
|
+
|
|
109
|
+
client = OpenAI(**client_kwargs)
|
|
110
|
+
|
|
111
|
+
delay = _RETRY_DELAY_S
|
|
112
|
+
last_error: Exception | None = None
|
|
113
|
+
|
|
114
|
+
for attempt in range(1, _MAX_RETRIES + 1):
|
|
115
|
+
try:
|
|
116
|
+
response = client.chat.completions.create(
|
|
117
|
+
model=model,
|
|
118
|
+
messages=[
|
|
119
|
+
{"role": "system", "content": request.system_prompt},
|
|
120
|
+
{"role": "user", "content": request.user_prompt},
|
|
121
|
+
],
|
|
122
|
+
temperature=request.temperature,
|
|
123
|
+
max_tokens=request.max_tokens,
|
|
124
|
+
)
|
|
125
|
+
content = response.choices[0].message.content or ""
|
|
126
|
+
usage = response.usage
|
|
127
|
+
|
|
128
|
+
return LLMResponse(
|
|
129
|
+
content=content,
|
|
130
|
+
model=model,
|
|
131
|
+
provider=provider,
|
|
132
|
+
input_tokens=usage.prompt_tokens if usage else 0,
|
|
133
|
+
output_tokens=usage.completion_tokens if usage else 0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
except RateLimitError as e:
|
|
137
|
+
last_error = e
|
|
138
|
+
if attempt < _MAX_RETRIES:
|
|
139
|
+
time.sleep(delay)
|
|
140
|
+
delay *= 2
|
|
141
|
+
continue
|
|
142
|
+
except APIError as e:
|
|
143
|
+
raise RuntimeError(f"[{provider}] API error: {e}") from e
|
|
144
|
+
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"[{provider}] Rate limit hit — all {_MAX_RETRIES} retries exhausted. "
|
|
147
|
+
f"Last error: {last_error}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ─────────────────────────────────────────────────────────────
|
|
152
|
+
# Anthropic
|
|
153
|
+
# ─────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _call_anthropic(
|
|
157
|
+
request: LLMRequest,
|
|
158
|
+
provider: str,
|
|
159
|
+
model: str,
|
|
160
|
+
) -> LLMResponse:
|
|
161
|
+
try:
|
|
162
|
+
import anthropic as ant # lazy import
|
|
163
|
+
except ImportError:
|
|
164
|
+
raise RuntimeError("anthropic SDK not installed. Run: pip install anthropic")
|
|
165
|
+
|
|
166
|
+
api_key = get_api_key("anthropic")
|
|
167
|
+
client = ant.Anthropic(api_key=api_key, timeout=_REQUEST_TIMEOUT)
|
|
168
|
+
|
|
169
|
+
delay = _RETRY_DELAY_S
|
|
170
|
+
last_error: Exception | None = None
|
|
171
|
+
|
|
172
|
+
for attempt in range(1, _MAX_RETRIES + 1):
|
|
173
|
+
try:
|
|
174
|
+
msg = client.messages.create(
|
|
175
|
+
model=model,
|
|
176
|
+
max_tokens=request.max_tokens,
|
|
177
|
+
system=request.system_prompt,
|
|
178
|
+
messages=[{"role": "user", "content": request.user_prompt}],
|
|
179
|
+
temperature=request.temperature,
|
|
180
|
+
)
|
|
181
|
+
content = msg.content[0].text if msg.content else ""
|
|
182
|
+
return LLMResponse(
|
|
183
|
+
content=content,
|
|
184
|
+
model=model,
|
|
185
|
+
provider="anthropic",
|
|
186
|
+
input_tokens=msg.usage.input_tokens,
|
|
187
|
+
output_tokens=msg.usage.output_tokens,
|
|
188
|
+
)
|
|
189
|
+
except ant.RateLimitError as e:
|
|
190
|
+
last_error = e
|
|
191
|
+
if attempt < _MAX_RETRIES:
|
|
192
|
+
time.sleep(delay)
|
|
193
|
+
delay *= 2
|
|
194
|
+
continue
|
|
195
|
+
except ant.APIError as e:
|
|
196
|
+
raise RuntimeError(f"[anthropic] API error: {e}") from e
|
|
197
|
+
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"[anthropic] Rate limit hit — {_MAX_RETRIES} retries exhausted. Last: {last_error}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ─────────────────────────────────────────────────────────────
|
|
204
|
+
# Google Gemini
|
|
205
|
+
# ─────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _call_gemini(
|
|
209
|
+
request: LLMRequest,
|
|
210
|
+
provider: str,
|
|
211
|
+
model: str,
|
|
212
|
+
) -> LLMResponse:
|
|
213
|
+
try:
|
|
214
|
+
import google.generativeai as genai # lazy import
|
|
215
|
+
except ImportError:
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
"google-generativeai SDK not installed. Run: pip install google-generativeai"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
api_key = get_api_key("gemini")
|
|
221
|
+
genai.configure(api_key=api_key)
|
|
222
|
+
|
|
223
|
+
generation_config = genai.types.GenerationConfig(
|
|
224
|
+
temperature=request.temperature,
|
|
225
|
+
max_output_tokens=request.max_tokens,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
gemini_model = genai.GenerativeModel(
|
|
229
|
+
model_name=model,
|
|
230
|
+
system_instruction=request.system_prompt,
|
|
231
|
+
generation_config=generation_config,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
delay = _RETRY_DELAY_S
|
|
235
|
+
last_error: Exception | None = None
|
|
236
|
+
|
|
237
|
+
for attempt in range(1, _MAX_RETRIES + 1):
|
|
238
|
+
try:
|
|
239
|
+
response = gemini_model.generate_content(request.user_prompt)
|
|
240
|
+
content = response.text or ""
|
|
241
|
+
return LLMResponse(
|
|
242
|
+
content=content,
|
|
243
|
+
model=model,
|
|
244
|
+
provider="gemini",
|
|
245
|
+
input_tokens=response.usage_metadata.prompt_token_count
|
|
246
|
+
if response.usage_metadata
|
|
247
|
+
else 0,
|
|
248
|
+
output_tokens=response.usage_metadata.candidates_token_count
|
|
249
|
+
if response.usage_metadata
|
|
250
|
+
else 0,
|
|
251
|
+
)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
if "quota" in str(e).lower() or "rate" in str(e).lower():
|
|
254
|
+
last_error = e
|
|
255
|
+
if attempt < _MAX_RETRIES:
|
|
256
|
+
time.sleep(delay)
|
|
257
|
+
delay *= 2
|
|
258
|
+
continue
|
|
259
|
+
raise RuntimeError(f"[gemini] API error: {e}") from e
|
|
260
|
+
|
|
261
|
+
raise RuntimeError(
|
|
262
|
+
f"[gemini] Rate limit hit — {_MAX_RETRIES} retries exhausted. Last: {last_error}"
|
|
263
|
+
)
|
pisces/models.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.models
|
|
3
|
+
~~~~~~~~~~~~~
|
|
4
|
+
All shared data models for pisces.
|
|
5
|
+
|
|
6
|
+
Rule: every other module imports FROM here. Nothing in here imports
|
|
7
|
+
from other pisces modules. This prevents circular imports.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
# ─────────────────────────────────────────────────────────────
|
|
18
|
+
# Severity
|
|
19
|
+
# ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Severity(str, Enum):
|
|
23
|
+
"""
|
|
24
|
+
Security finding severity levels.
|
|
25
|
+
|
|
26
|
+
Inherits from str so Severity.HIGH == "HIGH" works,
|
|
27
|
+
and JSON serialisation is automatic.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
CRITICAL = "CRITICAL"
|
|
31
|
+
HIGH = "HIGH"
|
|
32
|
+
MEDIUM = "MEDIUM"
|
|
33
|
+
LOW = "LOW"
|
|
34
|
+
INFO = "INFO"
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_str(cls, value: str) -> Severity:
|
|
38
|
+
try:
|
|
39
|
+
return cls(value.upper())
|
|
40
|
+
except ValueError:
|
|
41
|
+
return cls.INFO
|
|
42
|
+
|
|
43
|
+
def __lt__(self, other: Severity) -> bool:
|
|
44
|
+
_order = {
|
|
45
|
+
Severity.CRITICAL: 0,
|
|
46
|
+
Severity.HIGH: 1,
|
|
47
|
+
Severity.MEDIUM: 2,
|
|
48
|
+
Severity.LOW: 3,
|
|
49
|
+
Severity.INFO: 4,
|
|
50
|
+
}
|
|
51
|
+
return _order[self] < _order[other]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ─────────────────────────────────────────────────────────────
|
|
55
|
+
# Repair state machine
|
|
56
|
+
# ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RepairState(str, Enum):
|
|
60
|
+
"""
|
|
61
|
+
Lifecycle states of a single repair attempt.
|
|
62
|
+
|
|
63
|
+
DETECTED → EXPLAINED → PROPOSED → JUDGED → AWAITING_APPROVAL
|
|
64
|
+
→ APPROVED → CHECKPOINTED → APPLIED → VALIDATING
|
|
65
|
+
→ SUCCEEDED (terminal ✓)
|
|
66
|
+
→ FAILED → HEALING → PROPOSED (retry loop)
|
|
67
|
+
→ ROLLED_BACK (terminal, all attempts exhausted)
|
|
68
|
+
→ REJECTED (terminal, human said N)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
DETECTED = "DETECTED"
|
|
72
|
+
EXPLAINED = "EXPLAINED"
|
|
73
|
+
PROPOSED = "PROPOSED"
|
|
74
|
+
JUDGED = "JUDGED"
|
|
75
|
+
AWAITING_APPROVAL = "AWAITING_APPROVAL"
|
|
76
|
+
APPROVED = "APPROVED"
|
|
77
|
+
CHECKPOINTED = "CHECKPOINTED"
|
|
78
|
+
APPLIED = "APPLIED"
|
|
79
|
+
VALIDATING = "VALIDATING"
|
|
80
|
+
SUCCEEDED = "SUCCEEDED"
|
|
81
|
+
FAILED = "FAILED"
|
|
82
|
+
HEALING = "HEALING"
|
|
83
|
+
REJECTED = "REJECTED"
|
|
84
|
+
ROLLED_BACK = "ROLLED_BACK"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ─────────────────────────────────────────────────────────────
|
|
88
|
+
# AST-extracted file intelligence
|
|
89
|
+
# ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class FunctionInfo:
|
|
94
|
+
"""Information extracted from a function definition."""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
line_start: int
|
|
98
|
+
line_end: int
|
|
99
|
+
is_async: bool = False
|
|
100
|
+
decorators: list[str] = field(default_factory=list)
|
|
101
|
+
calls: list[str] = field(default_factory=list) # function names called inside
|
|
102
|
+
args: list[str] = field(default_factory=list) # parameter names
|
|
103
|
+
docstring: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ClassInfo:
|
|
108
|
+
"""Information extracted from a class definition."""
|
|
109
|
+
|
|
110
|
+
name: str
|
|
111
|
+
line_start: int
|
|
112
|
+
line_end: int
|
|
113
|
+
bases: list[str] = field(default_factory=list)
|
|
114
|
+
methods: list[str] = field(default_factory=list)
|
|
115
|
+
decorators: list[str] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class ImportInfo:
|
|
120
|
+
"""A single import statement."""
|
|
121
|
+
|
|
122
|
+
module: str # e.g. "os.path" or "subprocess"
|
|
123
|
+
names: list[str] # e.g. ["run", "Popen"] or ["*"]
|
|
124
|
+
line: int
|
|
125
|
+
is_from: bool = False # True = "from X import Y"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class SecurityConstruct:
|
|
130
|
+
"""
|
|
131
|
+
A security-relevant code construct spotted during AST analysis.
|
|
132
|
+
Not a full finding — just a hint for the scanner.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
kind: str # e.g. "subprocess_call", "pickle_load", "eval_call"
|
|
136
|
+
line: int
|
|
137
|
+
code: str # source snippet (best-effort)
|
|
138
|
+
details: str = ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class FileContext:
|
|
143
|
+
"""
|
|
144
|
+
Serialised intelligence for a single Python file.
|
|
145
|
+
Stored as one entry in context.json["files"].
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
path: str # relative to project root
|
|
149
|
+
hash: str # SHA-256 hex digest
|
|
150
|
+
functions: list[FunctionInfo] = field(default_factory=list)
|
|
151
|
+
classes: list[ClassInfo] = field(default_factory=list)
|
|
152
|
+
imports: list[ImportInfo] = field(default_factory=list)
|
|
153
|
+
calls: list[str] = field(default_factory=list)
|
|
154
|
+
security_constructs: list[SecurityConstruct] = field(default_factory=list)
|
|
155
|
+
parse_error: str | None = None # set if AST failed
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict:
|
|
158
|
+
return {
|
|
159
|
+
"path": self.path,
|
|
160
|
+
"hash": self.hash,
|
|
161
|
+
"functions": [
|
|
162
|
+
{
|
|
163
|
+
"name": f.name,
|
|
164
|
+
"line_start": f.line_start,
|
|
165
|
+
"line_end": f.line_end,
|
|
166
|
+
"is_async": f.is_async,
|
|
167
|
+
"decorators": f.decorators,
|
|
168
|
+
"calls": f.calls,
|
|
169
|
+
"args": f.args,
|
|
170
|
+
"docstring": f.docstring,
|
|
171
|
+
}
|
|
172
|
+
for f in self.functions
|
|
173
|
+
],
|
|
174
|
+
"classes": [
|
|
175
|
+
{
|
|
176
|
+
"name": c.name,
|
|
177
|
+
"line_start": c.line_start,
|
|
178
|
+
"line_end": c.line_end,
|
|
179
|
+
"bases": c.bases,
|
|
180
|
+
"methods": c.methods,
|
|
181
|
+
"decorators": c.decorators,
|
|
182
|
+
}
|
|
183
|
+
for c in self.classes
|
|
184
|
+
],
|
|
185
|
+
"imports": [
|
|
186
|
+
{
|
|
187
|
+
"module": i.module,
|
|
188
|
+
"names": i.names,
|
|
189
|
+
"line": i.line,
|
|
190
|
+
"is_from": i.is_from,
|
|
191
|
+
}
|
|
192
|
+
for i in self.imports
|
|
193
|
+
],
|
|
194
|
+
"calls": self.calls,
|
|
195
|
+
"security_constructs": [
|
|
196
|
+
{
|
|
197
|
+
"kind": s.kind,
|
|
198
|
+
"line": s.line,
|
|
199
|
+
"code": s.code,
|
|
200
|
+
"details": s.details,
|
|
201
|
+
}
|
|
202
|
+
for s in self.security_constructs
|
|
203
|
+
],
|
|
204
|
+
"parse_error": self.parse_error,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_dict(cls, data: dict) -> FileContext:
|
|
209
|
+
fc = cls(path=data["path"], hash=data["hash"])
|
|
210
|
+
fc.functions = [
|
|
211
|
+
FunctionInfo(
|
|
212
|
+
name=f["name"],
|
|
213
|
+
line_start=f["line_start"],
|
|
214
|
+
line_end=f["line_end"],
|
|
215
|
+
is_async=f.get("is_async", False),
|
|
216
|
+
decorators=f.get("decorators", []),
|
|
217
|
+
calls=f.get("calls", []),
|
|
218
|
+
args=f.get("args", []),
|
|
219
|
+
docstring=f.get("docstring"),
|
|
220
|
+
)
|
|
221
|
+
for f in data.get("functions", [])
|
|
222
|
+
]
|
|
223
|
+
fc.classes = [
|
|
224
|
+
ClassInfo(
|
|
225
|
+
name=c["name"],
|
|
226
|
+
line_start=c["line_start"],
|
|
227
|
+
line_end=c["line_end"],
|
|
228
|
+
bases=c.get("bases", []),
|
|
229
|
+
methods=c.get("methods", []),
|
|
230
|
+
decorators=c.get("decorators", []),
|
|
231
|
+
)
|
|
232
|
+
for c in data.get("classes", [])
|
|
233
|
+
]
|
|
234
|
+
fc.imports = [
|
|
235
|
+
ImportInfo(
|
|
236
|
+
module=i["module"],
|
|
237
|
+
names=i["names"],
|
|
238
|
+
line=i["line"],
|
|
239
|
+
is_from=i.get("is_from", False),
|
|
240
|
+
)
|
|
241
|
+
for i in data.get("imports", [])
|
|
242
|
+
]
|
|
243
|
+
fc.calls = data.get("calls", [])
|
|
244
|
+
fc.security_constructs = [
|
|
245
|
+
SecurityConstruct(
|
|
246
|
+
kind=s["kind"],
|
|
247
|
+
line=s["line"],
|
|
248
|
+
code=s["code"],
|
|
249
|
+
details=s.get("details", ""),
|
|
250
|
+
)
|
|
251
|
+
for s in data.get("security_constructs", [])
|
|
252
|
+
]
|
|
253
|
+
fc.parse_error = data.get("parse_error")
|
|
254
|
+
return fc
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ─────────────────────────────────────────────────────────────
|
|
258
|
+
# Finding (security scanner output)
|
|
259
|
+
# ─────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class Finding:
|
|
264
|
+
"""
|
|
265
|
+
A single security finding produced by the scanner.
|
|
266
|
+
|
|
267
|
+
id — unique, stable identifier: "<rule>:<file>:<line>"
|
|
268
|
+
rule — rule name, e.g. "HARDCODED_SECRET"
|
|
269
|
+
severity — Severity enum value
|
|
270
|
+
file — relative path to the file
|
|
271
|
+
line — line number (1-indexed)
|
|
272
|
+
code — the offending source line(s)
|
|
273
|
+
description — short one-line description
|
|
274
|
+
impact — why this matters
|
|
275
|
+
remediation — what to do about it
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
rule: str
|
|
279
|
+
severity: Severity
|
|
280
|
+
file: str
|
|
281
|
+
line: int
|
|
282
|
+
code: str
|
|
283
|
+
description: str
|
|
284
|
+
impact: str = ""
|
|
285
|
+
remediation: str = ""
|
|
286
|
+
id: str = field(default_factory=lambda: "")
|
|
287
|
+
|
|
288
|
+
def __post_init__(self) -> None:
|
|
289
|
+
if not self.id:
|
|
290
|
+
self.id = f"{self.rule}:{self.file}:{self.line}"
|
|
291
|
+
|
|
292
|
+
def to_dict(self) -> dict:
|
|
293
|
+
return {
|
|
294
|
+
"id": self.id,
|
|
295
|
+
"rule": self.rule,
|
|
296
|
+
"severity": self.severity.value,
|
|
297
|
+
"file": self.file,
|
|
298
|
+
"line": self.line,
|
|
299
|
+
"code": self.code,
|
|
300
|
+
"description": self.description,
|
|
301
|
+
"impact": self.impact,
|
|
302
|
+
"remediation": self.remediation,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_dict(cls, data: dict) -> Finding:
|
|
307
|
+
return cls(
|
|
308
|
+
id=data.get("id", ""),
|
|
309
|
+
rule=data["rule"],
|
|
310
|
+
severity=Severity.from_str(data["severity"]),
|
|
311
|
+
file=data["file"],
|
|
312
|
+
line=data["line"],
|
|
313
|
+
code=data["code"],
|
|
314
|
+
description=data["description"],
|
|
315
|
+
impact=data.get("impact", ""),
|
|
316
|
+
remediation=data.get("remediation", ""),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ─────────────────────────────────────────────────────────────
|
|
321
|
+
# LLM request / response
|
|
322
|
+
# ─────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class LLMRequest:
|
|
327
|
+
system_prompt: str
|
|
328
|
+
user_prompt: str
|
|
329
|
+
temperature: float = 0.2 # low temp for deterministic patches
|
|
330
|
+
max_tokens: int = 4096
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class LLMResponse:
|
|
335
|
+
content: str
|
|
336
|
+
model: str
|
|
337
|
+
provider: str
|
|
338
|
+
input_tokens: int = 0
|
|
339
|
+
output_tokens: int = 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ─────────────────────────────────────────────────────────────
|
|
343
|
+
# Judge result
|
|
344
|
+
# ─────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class JudgeResult:
|
|
349
|
+
decision: str # "APPROVED" | "REJECTED"
|
|
350
|
+
security_ok: bool
|
|
351
|
+
functionality_ok: bool
|
|
352
|
+
scope_ok: bool
|
|
353
|
+
regression_risk: str # "LOW" | "MEDIUM" | "HIGH"
|
|
354
|
+
patch_quality: str # "GOOD" | "ACCEPTABLE" | "POOR"
|
|
355
|
+
completeness: str # "COMPLETE" | "PARTIAL" | "INCOMPLETE"
|
|
356
|
+
reasoning: str
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def approved(self) -> bool:
|
|
360
|
+
return self.decision.upper() == "APPROVED"
|
|
361
|
+
|
|
362
|
+
def to_dict(self) -> dict:
|
|
363
|
+
return {
|
|
364
|
+
"decision": self.decision,
|
|
365
|
+
"security_ok": self.security_ok,
|
|
366
|
+
"functionality_ok": self.functionality_ok,
|
|
367
|
+
"scope_ok": self.scope_ok,
|
|
368
|
+
"regression_risk": self.regression_risk,
|
|
369
|
+
"patch_quality": self.patch_quality,
|
|
370
|
+
"completeness": self.completeness,
|
|
371
|
+
"reasoning": self.reasoning,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ─────────────────────────────────────────────────────────────
|
|
376
|
+
# Repair record (history entry)
|
|
377
|
+
# ─────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class RepairRecord:
|
|
382
|
+
"""Stored in .pisces/history/<repair_id>/metadata.json"""
|
|
383
|
+
|
|
384
|
+
repair_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
|
385
|
+
state: RepairState = RepairState.DETECTED
|
|
386
|
+
finding_id: str = ""
|
|
387
|
+
file: str = ""
|
|
388
|
+
rule: str = ""
|
|
389
|
+
severity: str = Severity.HIGH.value
|
|
390
|
+
provider: str = ""
|
|
391
|
+
model: str = ""
|
|
392
|
+
judge_result: dict | None = None
|
|
393
|
+
human_approved: bool | None = None
|
|
394
|
+
attempts: int = 0
|
|
395
|
+
started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
396
|
+
finished_at: str | None = None
|
|
397
|
+
|
|
398
|
+
def to_dict(self) -> dict:
|
|
399
|
+
return {
|
|
400
|
+
"repair_id": self.repair_id,
|
|
401
|
+
"state": self.state.value,
|
|
402
|
+
"finding_id": self.finding_id,
|
|
403
|
+
"file": self.file,
|
|
404
|
+
"rule": self.rule,
|
|
405
|
+
"severity": self.severity,
|
|
406
|
+
"provider": self.provider,
|
|
407
|
+
"model": self.model,
|
|
408
|
+
"judge_result": self.judge_result,
|
|
409
|
+
"human_approved": self.human_approved,
|
|
410
|
+
"attempts": self.attempts,
|
|
411
|
+
"started_at": self.started_at,
|
|
412
|
+
"finished_at": self.finished_at,
|
|
413
|
+
}
|