envfix 0.4.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.
- envfix/__init__.py +3 -0
- envfix/ai.py +261 -0
- envfix/cache.py +134 -0
- envfix/config.py +36 -0
- envfix/context.py +122 -0
- envfix/logger.py +147 -0
- envfix/main.py +485 -0
- envfix/preview.py +77 -0
- envfix/runner.py +26 -0
- envfix-0.4.0.dist-info/METADATA +450 -0
- envfix-0.4.0.dist-info/RECORD +15 -0
- envfix-0.4.0.dist-info/WHEEL +5 -0
- envfix-0.4.0.dist-info/entry_points.txt +2 -0
- envfix-0.4.0.dist-info/licenses/LICENSE +91 -0
- envfix-0.4.0.dist-info/top_level.txt +1 -0
envfix/__init__.py
ADDED
envfix/ai.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""ai.py — Calls local or cloud AI models and parses their responses."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from envfix.context import CodeContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import ollama
|
|
14
|
+
except ImportError: # pragma: no cover
|
|
15
|
+
ollama = None # type: ignore[assignment]
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import groq
|
|
19
|
+
except ImportError:
|
|
20
|
+
groq = None
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from google import genai
|
|
24
|
+
except ImportError:
|
|
25
|
+
genai = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROMPT_TEMPLATE = (
|
|
29
|
+
"You are diagnosing a development environment error on a Windows machine. "
|
|
30
|
+
"The user has specified this error is related to the '{category}' ecosystem "
|
|
31
|
+
"(it could be Python, Node.js, package managers, build tools, permissions, or general shell errors).\n"
|
|
32
|
+
"Here is the error output:\n{stderr}\n\n"
|
|
33
|
+
"Give a short diagnosis (1-2 sentences) of the root cause, "
|
|
34
|
+
"then give exactly ONE shell command that would likely fix it. "
|
|
35
|
+
"Respond in this exact format:\n"
|
|
36
|
+
"DIAGNOSIS: <text>\n"
|
|
37
|
+
"FIX: <command>\n\n"
|
|
38
|
+
"IMPORTANT rules for the FIX command:\n"
|
|
39
|
+
"- If it's a Python pip error, use 'python -m pip install ...' instead of 'pip install ...'\n"
|
|
40
|
+
"- NO backticks, NO markdown formatting, NO surrounding quotes around the command\n"
|
|
41
|
+
"- Give a single runnable shell command only\n"
|
|
42
|
+
"Example correct format:\n"
|
|
43
|
+
"DIAGNOSIS: The torch package is not installed.\n"
|
|
44
|
+
"FIX: python -m pip install torch"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Used when a code snippet from the failing file is available.
|
|
48
|
+
# Gives the model much richer context than the raw error text alone.
|
|
49
|
+
PROMPT_TEMPLATE_WITH_CONTEXT = (
|
|
50
|
+
"You are diagnosing a development environment error on a Windows machine. "
|
|
51
|
+
"The user has specified this error is related to the '{category}' ecosystem.\n\n"
|
|
52
|
+
"Here is the error output:\n{stderr}\n\n"
|
|
53
|
+
"Here is the relevant code from {filepath}, lines {start}-{end}:\n"
|
|
54
|
+
"```\n{snippet}\n```\n\n"
|
|
55
|
+
"Using both the error and the code above, give a precise diagnosis (1-2 sentences) "
|
|
56
|
+
"that references the actual code where possible, "
|
|
57
|
+
"then give exactly ONE shell command that would likely fix it. "
|
|
58
|
+
"Respond in this exact format:\n"
|
|
59
|
+
"DIAGNOSIS: <text>\n"
|
|
60
|
+
"FIX: <command>\n\n"
|
|
61
|
+
"IMPORTANT rules for the FIX command:\n"
|
|
62
|
+
"- If it's a Python pip error, use 'python -m pip install ...' instead of 'pip install ...'\n"
|
|
63
|
+
"- NO backticks, NO markdown formatting, NO surrounding quotes around the command\n"
|
|
64
|
+
"- Give a single runnable shell command only\n"
|
|
65
|
+
"Example correct format:\n"
|
|
66
|
+
"DIAGNOSIS: Line 42 calls `train()` before the model is loaded.\n"
|
|
67
|
+
"FIX: python -m pip install torch"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
DEFAULT_MODEL = "llama3.1:8b"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_actual_model(model: str, provider: str) -> str:
|
|
74
|
+
"""Resolve the actual model string if the user left it as the Ollama default."""
|
|
75
|
+
if model != DEFAULT_MODEL:
|
|
76
|
+
return model
|
|
77
|
+
|
|
78
|
+
if provider == "groq":
|
|
79
|
+
return "llama-3.3-70b-versatile"
|
|
80
|
+
elif provider == "gemini":
|
|
81
|
+
return "gemini-3.5-flash"
|
|
82
|
+
|
|
83
|
+
return DEFAULT_MODEL
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DiagnosisResult:
|
|
88
|
+
"""Structured result from the AI model."""
|
|
89
|
+
|
|
90
|
+
diagnosis: str
|
|
91
|
+
fix: str
|
|
92
|
+
raw_response: str
|
|
93
|
+
parsed_ok: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_diagnosis(
|
|
97
|
+
stderr: str,
|
|
98
|
+
model: str = DEFAULT_MODEL,
|
|
99
|
+
category: str = "general",
|
|
100
|
+
code_context: "Optional[CodeContext]" = None,
|
|
101
|
+
provider: str = "ollama",
|
|
102
|
+
) -> DiagnosisResult:
|
|
103
|
+
"""
|
|
104
|
+
Send stderr (and optional code context) to the chosen AI provider.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
stderr: The captured error text from the failed command.
|
|
108
|
+
model: Model tag to use (default: llama3.1:8b for ollama).
|
|
109
|
+
category: The ecosystem category.
|
|
110
|
+
code_context: Optional in-project code snippet extracted from the traceback.
|
|
111
|
+
provider: Which AI service to use ("ollama", "groq", or "gemini").
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A DiagnosisResult with diagnosis, fix, raw_response, and parsed_ok.
|
|
115
|
+
"""
|
|
116
|
+
if code_context is not None:
|
|
117
|
+
prompt = PROMPT_TEMPLATE_WITH_CONTEXT.format(
|
|
118
|
+
stderr=stderr,
|
|
119
|
+
category=category,
|
|
120
|
+
filepath=code_context.filepath,
|
|
121
|
+
start=code_context.start_line,
|
|
122
|
+
end=code_context.end_line,
|
|
123
|
+
snippet=code_context.snippet,
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
prompt = PROMPT_TEMPLATE.format(stderr=stderr, category=category)
|
|
127
|
+
|
|
128
|
+
if provider == "ollama":
|
|
129
|
+
if ollama is None:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
"The 'ollama' Python package is not installed. "
|
|
132
|
+
"Run: pip install ollama"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
response = ollama.chat(
|
|
136
|
+
model=model,
|
|
137
|
+
messages=[{"role": "user", "content": prompt}],
|
|
138
|
+
)
|
|
139
|
+
raw = response["message"]["content"].strip()
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"Ollama request failed: {exc}\n"
|
|
143
|
+
"Make sure the Ollama service is running (`ollama serve`) "
|
|
144
|
+
f"and the model '{model}' is pulled (`ollama pull {model}`)."
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
elif provider == "groq":
|
|
148
|
+
if groq is None:
|
|
149
|
+
raise RuntimeError("The 'groq' Python package is not installed.")
|
|
150
|
+
api_key = os.getenv("GROQ_API_KEY")
|
|
151
|
+
if not api_key:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"GROQ_API_KEY environment variable is not set. "
|
|
154
|
+
"Please set it to your Groq API key."
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
client = groq.Groq(api_key=api_key)
|
|
158
|
+
actual_model = get_actual_model(model, provider)
|
|
159
|
+
chat_completion = client.chat.completions.create(
|
|
160
|
+
messages=[{"role": "user", "content": prompt}],
|
|
161
|
+
model=actual_model,
|
|
162
|
+
)
|
|
163
|
+
raw = chat_completion.choices[0].message.content.strip()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise RuntimeError(f"Groq request failed: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
elif provider == "gemini":
|
|
168
|
+
if genai is None:
|
|
169
|
+
raise RuntimeError("The 'google-genai' Python package is not installed.")
|
|
170
|
+
api_key = os.getenv("GEMINI_API_KEY")
|
|
171
|
+
if not api_key:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
"GEMINI_API_KEY environment variable is not set. "
|
|
174
|
+
"Please set it to your Gemini API key."
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
client = genai.Client(api_key=api_key)
|
|
178
|
+
actual_model = get_actual_model(model, provider)
|
|
179
|
+
response = client.models.generate_content(
|
|
180
|
+
model=actual_model,
|
|
181
|
+
contents=prompt
|
|
182
|
+
)
|
|
183
|
+
raw = response.text.strip()
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
raise RuntimeError(f"Gemini API call failed: {exc}") from exc
|
|
186
|
+
|
|
187
|
+
else:
|
|
188
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
189
|
+
|
|
190
|
+
return _parse_response(raw)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_response(raw: str) -> DiagnosisResult:
|
|
194
|
+
"""
|
|
195
|
+
Extract DIAGNOSIS and FIX from the model's raw text.
|
|
196
|
+
|
|
197
|
+
Tries a strict regex first, then falls back to a lenient line scan.
|
|
198
|
+
If neither works, marks parsed_ok=False and stuffs the raw text into
|
|
199
|
+
both fields so the UI can still display something useful.
|
|
200
|
+
"""
|
|
201
|
+
# --- Strict parse: both keys on their own lines (case-insensitive) ---
|
|
202
|
+
strict = re.search(
|
|
203
|
+
r"DIAGNOSIS\s*:\s*(.+?)\s*FIX\s*:\s*(.+)",
|
|
204
|
+
raw,
|
|
205
|
+
re.IGNORECASE | re.DOTALL,
|
|
206
|
+
)
|
|
207
|
+
if strict:
|
|
208
|
+
diagnosis = strict.group(1).strip()
|
|
209
|
+
fix = _clean_fix(strict.group(2).strip().splitlines()[0].strip())
|
|
210
|
+
return DiagnosisResult(
|
|
211
|
+
diagnosis=diagnosis, fix=fix, raw_response=raw, parsed_ok=True
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# --- Lenient parse: scan line-by-line ---
|
|
215
|
+
diagnosis: Optional[str] = None
|
|
216
|
+
fix: Optional[str] = None
|
|
217
|
+
for line in raw.splitlines():
|
|
218
|
+
stripped = line.strip()
|
|
219
|
+
if diagnosis is None and re.match(r"DIAGNOSIS\s*:", stripped, re.IGNORECASE):
|
|
220
|
+
diagnosis = re.sub(r"^DIAGNOSIS\s*:\s*", "", stripped, flags=re.IGNORECASE)
|
|
221
|
+
elif fix is None and re.match(r"FIX\s*:", stripped, re.IGNORECASE):
|
|
222
|
+
fix = re.sub(r"^FIX\s*:\s*", "", stripped, flags=re.IGNORECASE)
|
|
223
|
+
|
|
224
|
+
if diagnosis and fix:
|
|
225
|
+
return DiagnosisResult(
|
|
226
|
+
diagnosis=diagnosis, fix=_clean_fix(fix), raw_response=raw, parsed_ok=True
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# --- Fallback: show raw output, don't crash ---
|
|
230
|
+
return DiagnosisResult(
|
|
231
|
+
diagnosis=raw,
|
|
232
|
+
fix="(could not parse a fix command — see diagnosis above)",
|
|
233
|
+
raw_response=raw,
|
|
234
|
+
parsed_ok=False,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _clean_fix(fix: str) -> str:
|
|
239
|
+
"""
|
|
240
|
+
Strip markdown/shell formatting artefacts from the fix command and
|
|
241
|
+
normalise Windows-incompatible patterns.
|
|
242
|
+
|
|
243
|
+
LLMs often wrap commands in backticks (`` `cmd` ``) or fenced code blocks.
|
|
244
|
+
Running `` `pip install torch` `` on Windows will fail because the backtick
|
|
245
|
+
is not a valid shell character there.
|
|
246
|
+
|
|
247
|
+
Also replaces bare 'pip install' with 'python -m pip install' because pip
|
|
248
|
+
is often not on the Windows PATH even when python is.
|
|
249
|
+
"""
|
|
250
|
+
# Strip surrounding backticks: `cmd` → cmd or ```cmd``` → cmd
|
|
251
|
+
fix = fix.strip()
|
|
252
|
+
fix = re.sub(r'^`+|`+$', '', fix).strip()
|
|
253
|
+
# Strip surrounding single or double quotes added by the model
|
|
254
|
+
if (fix.startswith('"') and fix.endswith('"')) or \
|
|
255
|
+
(fix.startswith("'") and fix.endswith("'")):
|
|
256
|
+
fix = fix[1:-1].strip()
|
|
257
|
+
# Strip markdown emphasis like *cmd* or **cmd**
|
|
258
|
+
fix = re.sub(r'^\*+|\*+$', '', fix).strip()
|
|
259
|
+
# Replace bare 'pip' with 'python -m pip' so it works when pip is not on PATH
|
|
260
|
+
fix = re.sub(r'^pip\b', 'python -m pip', fix)
|
|
261
|
+
return fix
|
envfix/cache.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""cache.py — Known-fix cache: fuzzy-match current errors against past verified fixes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from difflib import SequenceMatcher
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from envfix.logger import get_log_file
|
|
10
|
+
|
|
11
|
+
# Minimum similarity ratio (0–1) to consider a log entry a match.
|
|
12
|
+
# 0.92 is deliberately conservative: we only surface cache hits when
|
|
13
|
+
# the error text is nearly identical (same module name, same error type).
|
|
14
|
+
# Too low and structurally-similar errors (e.g. two different ModuleNotFoundErrors)
|
|
15
|
+
# get matched to each other even when the missing package differs.
|
|
16
|
+
SIMILARITY_THRESHOLD = 0.92
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CacheHit:
|
|
21
|
+
"""A previously verified fix that closely matches the current error."""
|
|
22
|
+
|
|
23
|
+
fix: str
|
|
24
|
+
diagnosis: str
|
|
25
|
+
score: float # 0.0 – 1.0 similarity ratio
|
|
26
|
+
original_command: str # the command that originally triggered the error
|
|
27
|
+
previously_worked: bool # True if fix_worked=True in the log entry
|
|
28
|
+
category: str # The ecosystem category of the error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_cached_fix(
|
|
32
|
+
error_text: str,
|
|
33
|
+
log_file: str = "", # empty string → resolved at call-time
|
|
34
|
+
threshold: float = SIMILARITY_THRESHOLD,
|
|
35
|
+
category: str = "general",
|
|
36
|
+
) -> Optional[CacheHit]:
|
|
37
|
+
"""
|
|
38
|
+
Search the user's log for a previously attempted fix for a similar error.
|
|
39
|
+
|
|
40
|
+
log_file defaults to the current user's personal log file if not supplied.
|
|
41
|
+
Passing an explicit path is still supported (used in tests).
|
|
42
|
+
|
|
43
|
+
Two tiers:
|
|
44
|
+
- Prefers entries where the fix is CONFIRMED to have worked (fix_worked=True).
|
|
45
|
+
- Falls back to entries where the user APPROVED the fix (user_approved=True)
|
|
46
|
+
even if fix_worked=False/None — this surfaces "we've already tried this"
|
|
47
|
+
so Ollama is not called again for the same problem.
|
|
48
|
+
|
|
49
|
+
Uses difflib.SequenceMatcher for lightweight fuzzy string similarity.
|
|
50
|
+
Supports both Phase 1 (worked/fix/stderr) and Phase 2 schemas transparently.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
error_text: The current stderr text to match against.
|
|
54
|
+
log_file: Path to the JSON log file (defaults to cwd).
|
|
55
|
+
threshold: Minimum SequenceMatcher ratio to accept as a hit.
|
|
56
|
+
category: The ecosystem category. Only matches logs with the same category.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A CacheHit with the best-matching fix, or None if no good match found.
|
|
60
|
+
"""
|
|
61
|
+
if not log_file:
|
|
62
|
+
log_file = get_log_file()
|
|
63
|
+
|
|
64
|
+
if not os.path.exists(log_file):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with open(log_file, "r", encoding="utf-8") as f:
|
|
69
|
+
log_data = json.load(f)
|
|
70
|
+
except (json.JSONDecodeError, OSError):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
if not isinstance(log_data, list) or not log_data:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# We keep two best candidates: one that worked, one that was merely approved.
|
|
77
|
+
best_worked: Optional[tuple[float, CacheHit]] = None
|
|
78
|
+
best_approved: Optional[tuple[float, CacheHit]] = None
|
|
79
|
+
|
|
80
|
+
for entry in log_data:
|
|
81
|
+
# ── Support both Phase 1 and Phase 2 schemas ─────────────────────
|
|
82
|
+
fix_worked = (
|
|
83
|
+
entry.get("fix_worked")
|
|
84
|
+
if "fix_worked" in entry
|
|
85
|
+
else entry.get("worked")
|
|
86
|
+
)
|
|
87
|
+
user_approved = (
|
|
88
|
+
entry.get("user_approved")
|
|
89
|
+
if "user_approved" in entry
|
|
90
|
+
else entry.get("approved", False)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Only use entries the user actually approved (ignore "n" declines)
|
|
94
|
+
if not user_approved:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
entry_category = entry.get("category", "general")
|
|
98
|
+
if entry_category != category:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
stored_error: str = entry.get("error_text") or entry.get("stderr", "")
|
|
102
|
+
fix: str = entry.get("fix_command") or entry.get("fix", "")
|
|
103
|
+
diagnosis: str = entry.get("diagnosis", "")
|
|
104
|
+
original_cmd: str = entry.get("original_command") or entry.get("command", "")
|
|
105
|
+
|
|
106
|
+
if not stored_error or not fix:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
score = SequenceMatcher(None, error_text, stored_error).ratio()
|
|
110
|
+
if score < threshold:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
hit = CacheHit(
|
|
114
|
+
fix=fix,
|
|
115
|
+
diagnosis=diagnosis,
|
|
116
|
+
score=score,
|
|
117
|
+
original_command=original_cmd,
|
|
118
|
+
previously_worked=bool(fix_worked),
|
|
119
|
+
category=entry_category,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if fix_worked:
|
|
123
|
+
if best_worked is None or score > best_worked[0]:
|
|
124
|
+
best_worked = (score, hit)
|
|
125
|
+
else:
|
|
126
|
+
if best_approved is None or score > best_approved[0]:
|
|
127
|
+
best_approved = (score, hit)
|
|
128
|
+
|
|
129
|
+
# Prefer a confirmed-working hit over a merely-approved one
|
|
130
|
+
if best_worked:
|
|
131
|
+
return best_worked[1]
|
|
132
|
+
if best_approved:
|
|
133
|
+
return best_approved[1]
|
|
134
|
+
return None
|
envfix/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import tomllib
|
|
8
|
+
except ImportError:
|
|
9
|
+
import tomli as tomllib
|
|
10
|
+
|
|
11
|
+
import tomli_w
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".envfix"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
15
|
+
|
|
16
|
+
def load_config() -> Dict[str, Any]:
|
|
17
|
+
"""Load the persistent TOML configuration file."""
|
|
18
|
+
if not CONFIG_FILE.exists():
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
23
|
+
return tomllib.load(f)
|
|
24
|
+
except Exception:
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
def save_config(data: Dict[str, Any]) -> None:
|
|
28
|
+
"""Save the configuration dict to TOML."""
|
|
29
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with open(CONFIG_FILE, "wb") as f:
|
|
31
|
+
tomli_w.dump(data, f)
|
|
32
|
+
|
|
33
|
+
def reset_config() -> None:
|
|
34
|
+
"""Delete the configuration file."""
|
|
35
|
+
if CONFIG_FILE.exists():
|
|
36
|
+
CONFIG_FILE.unlink()
|
envfix/context.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""context.py — Extract code snippets from stack traces for richer Ollama prompts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
# Lines to include above and below the failing line
|
|
10
|
+
CONTEXT_WINDOW = 10
|
|
11
|
+
|
|
12
|
+
# ── Stack-trace patterns ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
# Python: File "main.py", line 42
|
|
15
|
+
_PYTHON_RE = re.compile(r'File "([^"]+)",\s*line (\d+)')
|
|
16
|
+
|
|
17
|
+
# Node.js: at Object.<anonymous> (main.js:42:10) OR at main.js:42:10
|
|
18
|
+
_NODE_RE = re.compile(
|
|
19
|
+
r'at\s+(?:[^\s(]+\s+\()?([^()]+?\.(?:js|ts|jsx|tsx|mjs|cjs)):(\d+)'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CodeContext:
|
|
25
|
+
"""A code snippet extracted from the failing file."""
|
|
26
|
+
|
|
27
|
+
filepath: str # path relative to cwd — safe to show the user
|
|
28
|
+
line_number: int # the line the stack trace pointed at
|
|
29
|
+
start_line: int # first line of the snippet (1-indexed, clamped)
|
|
30
|
+
end_line: int # last line of the snippet (1-indexed, clamped)
|
|
31
|
+
snippet: str # numbered source lines ready to embed in a prompt
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_context(stderr: str, cwd: Optional[str] = None) -> Optional[CodeContext]:
|
|
35
|
+
"""
|
|
36
|
+
Parse *stderr* for file paths and line numbers in common stack trace formats.
|
|
37
|
+
|
|
38
|
+
Only files that live inside *cwd* (or its subdirectories) are read.
|
|
39
|
+
System paths, site-packages, and anything outside the project root are
|
|
40
|
+
silently skipped.
|
|
41
|
+
|
|
42
|
+
For Python tracebacks the *last* in-project file is used (that's where
|
|
43
|
+
the user's own code triggered the error). For Node.js the first match
|
|
44
|
+
is used (the top of the call stack).
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
stderr: The captured error output from the failed command.
|
|
48
|
+
cwd: Project root to restrict file access to. Defaults to os.getcwd().
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A CodeContext if a safe, readable file is found; None otherwise.
|
|
52
|
+
"""
|
|
53
|
+
root = Path(cwd or os.getcwd()).resolve()
|
|
54
|
+
|
|
55
|
+
python_hits: list[tuple[str, int]] = [
|
|
56
|
+
(m.group(1), int(m.group(2))) for m in _PYTHON_RE.finditer(stderr)
|
|
57
|
+
]
|
|
58
|
+
node_hits: list[tuple[str, int]] = [
|
|
59
|
+
(m.group(1), int(m.group(2))) for m in _NODE_RE.finditer(stderr)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Python: try last in-project file first (closest to the actual error)
|
|
63
|
+
for filepath, lineno in reversed(python_hits):
|
|
64
|
+
ctx = _read_safe(filepath, lineno, root)
|
|
65
|
+
if ctx:
|
|
66
|
+
return ctx
|
|
67
|
+
|
|
68
|
+
# Node.js: try first match first (top of call stack = user's code)
|
|
69
|
+
for filepath, lineno in node_hits:
|
|
70
|
+
ctx = _read_safe(filepath, lineno, root)
|
|
71
|
+
if ctx:
|
|
72
|
+
return ctx
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_safe(filepath: str, lineno: int, root: Path) -> Optional[CodeContext]:
|
|
78
|
+
"""
|
|
79
|
+
Read a ±CONTEXT_WINDOW line snippet from *filepath* around *lineno*.
|
|
80
|
+
|
|
81
|
+
Returns None if:
|
|
82
|
+
- the path resolves outside *root* (safety boundary)
|
|
83
|
+
- the file doesn't exist or can't be read
|
|
84
|
+
- the line number is out of range
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
if os.path.isabs(filepath):
|
|
88
|
+
resolved = Path(filepath).resolve()
|
|
89
|
+
else:
|
|
90
|
+
resolved = (root / filepath).resolve()
|
|
91
|
+
|
|
92
|
+
# ── Safety check: must be inside the project root ─────────────────
|
|
93
|
+
resolved.relative_to(root) # raises ValueError if outside
|
|
94
|
+
|
|
95
|
+
if not resolved.is_file():
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
99
|
+
total = len(lines)
|
|
100
|
+
|
|
101
|
+
if lineno < 1 or lineno > total:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Clamp snippet window to file bounds (1-indexed)
|
|
105
|
+
start = max(1, lineno - CONTEXT_WINDOW)
|
|
106
|
+
end = min(total, lineno + CONTEXT_WINDOW)
|
|
107
|
+
|
|
108
|
+
# Build a numbered snippet so the model can reference line numbers
|
|
109
|
+
snippet = "\n".join(
|
|
110
|
+
f"{start + i:4d} | {line}"
|
|
111
|
+
for i, line in enumerate(lines[start - 1 : end])
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return CodeContext(
|
|
115
|
+
filepath=str(resolved.relative_to(root)),
|
|
116
|
+
line_number=lineno,
|
|
117
|
+
start_line=start,
|
|
118
|
+
end_line=end,
|
|
119
|
+
snippet=snippet,
|
|
120
|
+
)
|
|
121
|
+
except (ValueError, OSError, UnicodeDecodeError):
|
|
122
|
+
return None
|