project-brain-cli 1.0.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.
@@ -0,0 +1,92 @@
1
+ import platform
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from project_brain.core.doctor_checks.models import DoctorCheck
7
+
8
+
9
+ def run_environment_checks(root: Path):
10
+ checks = []
11
+
12
+ # Python version
13
+ version = sys.version_info
14
+
15
+ if version.major >= 3 and version.minor >= 10:
16
+ checks.append(
17
+ DoctorCheck(
18
+ "Environment",
19
+ "Python",
20
+ "pass",
21
+ f"Python {version.major}.{version.minor}",
22
+ )
23
+ )
24
+ else:
25
+ checks.append(
26
+ DoctorCheck(
27
+ "Environment",
28
+ "Python",
29
+ "fail",
30
+ "Python 3.10+ required",
31
+ "Upgrade Python to >=3.10",
32
+ )
33
+ )
34
+
35
+ # OS
36
+ checks.append(
37
+ DoctorCheck(
38
+ "Environment",
39
+ "OS",
40
+ "info",
41
+ platform.platform(),
42
+ )
43
+ )
44
+
45
+ # Virtual env
46
+ venv_active = (
47
+ hasattr(sys, "real_prefix")
48
+ or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
49
+ )
50
+
51
+ if venv_active:
52
+ checks.append(
53
+ DoctorCheck(
54
+ "Environment",
55
+ "Virtual Environment",
56
+ "pass",
57
+ "Virtual environment active",
58
+ )
59
+ )
60
+ else:
61
+ checks.append(
62
+ DoctorCheck(
63
+ "Environment",
64
+ "Virtual Environment",
65
+ "warn",
66
+ "No virtual environment detected",
67
+ "Create one:\npython -m venv env",
68
+ )
69
+ )
70
+
71
+ # Git installed
72
+ if shutil.which("git"):
73
+ checks.append(
74
+ DoctorCheck(
75
+ "Environment",
76
+ "Git",
77
+ "pass",
78
+ "Git installed",
79
+ )
80
+ )
81
+ else:
82
+ checks.append(
83
+ DoctorCheck(
84
+ "Environment",
85
+ "Git",
86
+ "fail",
87
+ "Git not installed",
88
+ "Install Git from https://git-scm.com/",
89
+ )
90
+ )
91
+
92
+ return checks
@@ -0,0 +1,70 @@
1
+ from pathlib import Path
2
+
3
+ from project_brain.core.doctor_checks.models import DoctorCheck
4
+
5
+
6
+ def check_export(path: Path, name: str):
7
+ if not path.exists():
8
+ return DoctorCheck(
9
+ "Exports",
10
+ name,
11
+ "warn",
12
+ "Export missing",
13
+ )
14
+
15
+ if path.stat().st_size == 0:
16
+ return DoctorCheck(
17
+ "Exports",
18
+ name,
19
+ "warn",
20
+ "Export empty",
21
+ )
22
+
23
+ return DoctorCheck(
24
+ "Exports",
25
+ name,
26
+ "pass",
27
+ "Export available",
28
+ )
29
+
30
+
31
+ def run_export_checks(root: Path):
32
+ checks = []
33
+
34
+ export_dir = root / ".brain" / "exports"
35
+
36
+ if not export_dir.exists():
37
+ checks.append(
38
+ DoctorCheck(
39
+ "Exports",
40
+ "Exports Directory",
41
+ "warn",
42
+ "Exports directory missing",
43
+ )
44
+ )
45
+ return checks
46
+
47
+ checks.append(
48
+ DoctorCheck(
49
+ "Exports",
50
+ "Exports Directory",
51
+ "pass",
52
+ "Exports directory exists",
53
+ )
54
+ )
55
+
56
+ checks.append(
57
+ check_export(
58
+ export_dir / "full_code.txt",
59
+ "Full Code Export",
60
+ )
61
+ )
62
+
63
+ checks.append(
64
+ check_export(
65
+ export_dir / "code_changes.txt",
66
+ "Code Changes Export",
67
+ )
68
+ )
69
+
70
+ return checks
@@ -0,0 +1,107 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from project_brain.core.config_loader import load_config
6
+ from project_brain.core.doctor_checks.models import DoctorCheck
7
+
8
+
9
+ def run_llm_checks(root: Path):
10
+ checks = []
11
+
12
+ config = load_config(root)
13
+
14
+ llm = config.get("llm", {})
15
+
16
+ provider = llm.get("provider", "none")
17
+ model = llm.get("model", "")
18
+
19
+ checks.append(
20
+ DoctorCheck(
21
+ "LLM",
22
+ "Provider",
23
+ "info",
24
+ provider,
25
+ )
26
+ )
27
+
28
+ if provider == "none":
29
+ checks.append(
30
+ DoctorCheck(
31
+ "LLM",
32
+ "Status",
33
+ "info",
34
+ "LLM disabled",
35
+ )
36
+ )
37
+ return checks
38
+
39
+ if not model:
40
+ checks.append(
41
+ DoctorCheck(
42
+ "LLM",
43
+ "Model",
44
+ "fail",
45
+ "No model configured",
46
+ )
47
+ )
48
+ else:
49
+ checks.append(
50
+ DoctorCheck(
51
+ "LLM",
52
+ "Model",
53
+ "pass",
54
+ model,
55
+ )
56
+ )
57
+
58
+ if provider == "ollama":
59
+ try:
60
+ subprocess.run(
61
+ ["ollama", "list"],
62
+ capture_output=True,
63
+ check=True,
64
+ )
65
+
66
+ checks.append(
67
+ DoctorCheck(
68
+ "LLM",
69
+ "Ollama",
70
+ "pass",
71
+ "Ollama available",
72
+ )
73
+ )
74
+
75
+ except Exception:
76
+ checks.append(
77
+ DoctorCheck(
78
+ "LLM",
79
+ "Ollama",
80
+ "fail",
81
+ "Ollama unavailable",
82
+ "Install Ollama",
83
+ )
84
+ )
85
+
86
+ elif provider == "openai":
87
+ if os.getenv("OPENAI_API_KEY"):
88
+ checks.append(
89
+ DoctorCheck(
90
+ "LLM",
91
+ "OPENAI_API_KEY",
92
+ "pass",
93
+ "API key configured",
94
+ )
95
+ )
96
+ else:
97
+ checks.append(
98
+ DoctorCheck(
99
+ "LLM",
100
+ "OPENAI_API_KEY",
101
+ "fail",
102
+ "Missing API key",
103
+ "Set OPENAI_API_KEY",
104
+ )
105
+ )
106
+
107
+ return checks
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class DoctorCheck:
6
+ category: str
7
+ name: str
8
+ status: str
9
+ message: str
10
+ fix: str = ""
@@ -0,0 +1,102 @@
1
+ from pathlib import Path
2
+
3
+ from project_brain.core.differ import is_git_repo, run_git_command
4
+ from project_brain.core.doctor_checks.models import DoctorCheck
5
+
6
+
7
+ def get_repo_size(root: Path):
8
+ total = 0
9
+
10
+ try:
11
+ for path in root.rglob("*"):
12
+ if path.is_file():
13
+ total += path.stat().st_size
14
+ except Exception:
15
+ return "Unknown"
16
+
17
+ return f"{round(total / (1024 * 1024), 2)} MB"
18
+
19
+
20
+ def run_repository_checks(root: Path):
21
+ checks = []
22
+
23
+ if not is_git_repo(root):
24
+ checks.append(
25
+ DoctorCheck(
26
+ "Repository",
27
+ "Git Repository",
28
+ "warn",
29
+ "Not a git repository",
30
+ "Run:\ngit init",
31
+ )
32
+ )
33
+ return checks
34
+
35
+ checks.append(
36
+ DoctorCheck(
37
+ "Repository",
38
+ "Git Repository",
39
+ "pass",
40
+ "Git repository detected",
41
+ )
42
+ )
43
+
44
+ branch = run_git_command(
45
+ ["branch", "--show-current"],
46
+ root,
47
+ )
48
+
49
+ if branch:
50
+ checks.append(
51
+ DoctorCheck(
52
+ "Repository",
53
+ "Branch",
54
+ "pass",
55
+ branch,
56
+ )
57
+ )
58
+ else:
59
+ checks.append(
60
+ DoctorCheck(
61
+ "Repository",
62
+ "Branch",
63
+ "warn",
64
+ "Detached HEAD state",
65
+ )
66
+ )
67
+
68
+ commit = run_git_command(
69
+ ["rev-parse", "--short", "HEAD"],
70
+ root,
71
+ )
72
+
73
+ if commit:
74
+ checks.append(
75
+ DoctorCheck(
76
+ "Repository",
77
+ "Latest Commit",
78
+ "pass",
79
+ commit,
80
+ )
81
+ )
82
+ else:
83
+ checks.append(
84
+ DoctorCheck(
85
+ "Repository",
86
+ "Latest Commit",
87
+ "warn",
88
+ "No commits found",
89
+ "Create initial commit",
90
+ )
91
+ )
92
+
93
+ checks.append(
94
+ DoctorCheck(
95
+ "Repository",
96
+ "Repository Size",
97
+ "info",
98
+ get_repo_size(root),
99
+ )
100
+ )
101
+
102
+ return checks
@@ -0,0 +1,334 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ import os
6
+
7
+ from project_brain.core.config_loader import load_config
8
+ from project_brain.core.differ import (compute_diff, extract_functions,
9
+ get_file_from_ref)
10
+ from project_brain.core.logger import log_error, log_warning
11
+ from project_brain.llm.provider import call_llm
12
+
13
+
14
+ def hash_pair(old: str, new: str, fn: str) -> str:
15
+ return hashlib.sha256((old + new + fn).encode()).hexdigest()
16
+
17
+
18
+ def load_cache(cache_dir: Path, key: str):
19
+ path = cache_dir / f"{key}.json"
20
+ if path.exists():
21
+ try:
22
+ return json.loads(path.read_text())
23
+ except Exception as e:
24
+ log_warning(f"⚠️ Corrupted cache ignored: {path}")
25
+ return None
26
+ return None
27
+
28
+
29
+ def cleanup_cache(cache_dir: Path, max_files=1000):
30
+ files = sorted(cache_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)
31
+
32
+ if len(files) > max_files:
33
+ for f in files[: len(files) - max_files]:
34
+ f.unlink()
35
+
36
+
37
+ def is_valid_cache(data: dict) -> bool:
38
+ if not isinstance(data, dict):
39
+ return False
40
+
41
+ if "fast" not in data or "detailed" not in data:
42
+ return False
43
+
44
+ for section in ["fast", "detailed"]:
45
+ part = data.get(section)
46
+ if not isinstance(part, dict):
47
+ return False
48
+ if not all(k in part for k in ["change", "impact", "risk"]):
49
+ return False
50
+ if part["risk"] not in ["low", "medium", "high"]:
51
+ return False
52
+
53
+ return True
54
+
55
+
56
+ def save_cache(cache_dir: Path, key: str, data: dict):
57
+ if not is_valid_cache(data):
58
+ log_warning(f"Invalid cache skipped: {key}")
59
+ return
60
+
61
+ cache_dir.mkdir(parents=True, exist_ok=True)
62
+ path = cache_dir / f"{key}.json"
63
+ path.write_text(json.dumps(data, indent=2))
64
+ cleanup_cache(cache_dir)
65
+
66
+
67
+ def build_prompt(old_code: str, new_code: str, fn: str) -> str:
68
+ return f"""
69
+ You are a senior software engineer performing a code review.
70
+
71
+ Analyze the function change and return STRICT JSON ONLY.
72
+
73
+ DO NOT use markdown.
74
+ DO NOT add text outside JSON.
75
+
76
+ Return EXACTLY:
77
+
78
+ {{
79
+ "fast": {{
80
+ "change": "(1-2 lines) short line summary",
81
+ "impact": "(1-2 lines) short line impact",
82
+ "risk": "low | medium | high"
83
+ }},
84
+ "detailed": {{
85
+ "change": "Explain what changed at logic level (4-6 lines)",
86
+ "impact": "Explain system-level impact and behavior changes (4-6 lines)",
87
+ "risk": "Explain actual risk reasoning + severity (2-3 lines, end with low|medium|high)"
88
+ }}
89
+ }}
90
+
91
+ Rules:
92
+ - fast = minimal summary only
93
+ - detailed MUST include reasoning, not just description
94
+ - DO NOT repeat fast output in detailed
95
+ - risk must still end with one of: low, medium, high
96
+
97
+ Function: {fn}
98
+
99
+ Old Code:
100
+ {old_code}
101
+
102
+ New Code:
103
+ {new_code}
104
+ """.strip()
105
+
106
+
107
+ def safe_extract(source: str):
108
+ try:
109
+ return extract_functions(source)
110
+ except Exception as e:
111
+ log_error(f"Function failed: {str(e)}")
112
+ return {}
113
+
114
+
115
+ def explain_diff(from_ref: str, to_ref: str, root: Path) -> list[dict] | None:
116
+ config = load_config(root)
117
+ llm_cfg = config.get("llm", {})
118
+
119
+ provider = llm_cfg.get("provider", "none")
120
+ model = llm_cfg.get("model", "")
121
+ api_key = ""
122
+
123
+ diff = compute_diff(from_ref, to_ref, root)
124
+ if not diff:
125
+ return None
126
+
127
+ results = []
128
+ cache_dir = root / ".brain" / "cache"
129
+
130
+ for fd in diff["function_diffs"]:
131
+ file = fd["file"]
132
+
133
+ old_src = get_file_from_ref(from_ref, file, root) or ""
134
+ new_src = get_file_from_ref(to_ref, file, root) or ""
135
+
136
+ old_funcs = safe_extract(old_src)
137
+ new_funcs = safe_extract(new_src)
138
+
139
+ changed_funcs = list(
140
+ set(fd["added"]) | set(fd["removed"]) | set(fd["modified"])
141
+ )
142
+
143
+ if not changed_funcs:
144
+ continue
145
+
146
+ for fn in changed_funcs:
147
+ old_code = old_funcs.get(fn, "")
148
+ new_code = new_funcs.get(fn, "")
149
+
150
+ key = hash_pair(old_code, new_code, fn)
151
+ cached = load_cache(cache_dir, key)
152
+
153
+ if cached and is_valid_cache(cached):
154
+ data = select_output(cached, config)
155
+ results.append({"file": file, "function": fn, **data})
156
+ continue
157
+
158
+ # No LLM configured
159
+ if provider == "none":
160
+ data = {
161
+ "change": f"Function '{fn}' has code-level changes",
162
+ "impact": "Behavior may differ depending on logic changes",
163
+ "risk": "medium",
164
+ }
165
+ results.append({"file": file, "function": fn, **data})
166
+ continue
167
+
168
+ prompt = build_prompt(old_code, new_code, fn)
169
+ response = call_llm(provider, model, prompt, api_key)
170
+
171
+ if response["error"]:
172
+ log_error(f"LLM error: {response['error']}")
173
+ parsed = None
174
+ else:
175
+ parsed = normalize_response(response["output"], provider, model, api_key)
176
+
177
+ # retry if parsing failed
178
+ if not parsed:
179
+ fix_prompt = f"""
180
+ Convert this into the required JSON structure:
181
+
182
+ {response["output"]}
183
+
184
+ Return ONLY:
185
+ {{
186
+ "fast": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }},
187
+ "detailed": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }}
188
+ }}
189
+ """
190
+ response = call_llm(provider, model, fix_prompt, api_key)
191
+ if response["error"]:
192
+ parsed = None
193
+ else:
194
+ parsed = parse_llm_json(response["output"])
195
+
196
+ if parsed:
197
+ save_cache(cache_dir, key, parsed)
198
+ data = select_output(parsed, config)
199
+ else:
200
+ data = {
201
+ "change": "Failed to generate structured output",
202
+ "impact": "Unknown",
203
+ "risk": "high"
204
+ }
205
+ results.append({"file": file, "function": fn, **data})
206
+ explain_cfg = config.get("explain", {})
207
+ include_risks = explain_cfg.get("include_risks", True)
208
+
209
+ if not include_risks:
210
+ for result in results:
211
+ result["risk"] = ""
212
+ return results
213
+
214
+
215
+ def parse_llm_json(response: str):
216
+ try:
217
+ data = json.loads(response)
218
+
219
+ if not all(k in data for k in ["fast", "detailed"]):
220
+ raise ValueError("Missing structure")
221
+
222
+ return data
223
+
224
+ except Exception as e:
225
+ log_error(f"Function failed: {str(e)}")
226
+ return None
227
+
228
+
229
+ def normalize_response(response_text: str, provider, model, api_key):
230
+ # Step 1: direct parse
231
+ parsed = parse_llm_json(response_text)
232
+ if parsed and is_valid_cache(parsed):
233
+ return parsed
234
+
235
+ # Step 2: try extracting legacy text
236
+ extracted = extract_from_old_text(response_text)
237
+ if extracted.get("change"):
238
+ structured = {
239
+ "fast": extracted,
240
+ "detailed": extracted
241
+ }
242
+ if is_valid_cache(structured):
243
+ return structured
244
+
245
+ # Step 3: LLM fix (last resort)
246
+ fix_prompt = f"""
247
+ Convert into STRICT JSON:
248
+
249
+ {response_text}
250
+
251
+ Return ONLY:
252
+ {{
253
+ "fast": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }},
254
+ "detailed": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }}
255
+ }}
256
+ """
257
+
258
+ response = call_llm(provider, model, fix_prompt, api_key)
259
+
260
+ if response["error"]:
261
+ return None
262
+
263
+ parsed = parse_llm_json(response["output"])
264
+ if parsed and is_valid_cache(parsed):
265
+ return parsed
266
+
267
+ return None
268
+
269
+
270
+ def extract_from_old_text(text: str):
271
+ sections = {"change": "", "impact": "", "risk": ""}
272
+
273
+ # Normalize
274
+ text = text.replace("\r", "").strip()
275
+
276
+ # Patterns
277
+ change_match = re.search(
278
+ r"(?:What changed|### 1\..*?changed)(.*?)(?:###|$)", text, re.S | re.I
279
+ )
280
+ impact_match = re.search(
281
+ r"(?:Why it matters|Impact|### 2\..*?|### 3\..*?)(.*?)(?:###|$)",
282
+ text,
283
+ re.S | re.I,
284
+ )
285
+ risk_match = re.search(r"(?:Risk|### 4\..*?risk)(.*?)(?:###|$)", text, re.S | re.I)
286
+
287
+ if change_match:
288
+ sections["change"] = change_match.group(1).strip()
289
+
290
+ if impact_match:
291
+ sections["impact"] = impact_match.group(1).strip()
292
+
293
+ if risk_match:
294
+ sections["risk"] = risk_match.group(1).strip()
295
+
296
+ # Normalize risk
297
+ r = sections["risk"].lower()
298
+ if "high" in r:
299
+ sections["risk"] = "high"
300
+ elif "medium" in r:
301
+ sections["risk"] = "medium"
302
+ else:
303
+ sections["risk"] = "low"
304
+
305
+ return sections
306
+
307
+ def select_output(parsed: dict, config: dict):
308
+ explain_cfg = config.get("explain", {})
309
+
310
+ level = explain_cfg.get("level", "detailed")
311
+ include_risks = explain_cfg.get("include_risks", True)
312
+
313
+ selected = parsed.get(level, {})
314
+
315
+ # ensure fields exist
316
+ change = selected.get("change", "")
317
+ impact = selected.get("impact", "")
318
+ risk = selected.get("risk", "medium")
319
+
320
+ if risk not in ["low", "medium", "high"]:
321
+ risk = "medium"
322
+
323
+ if not include_risks:
324
+ return {
325
+ "change": change,
326
+ "impact": impact,
327
+ "risk": ""
328
+ }
329
+
330
+ return {
331
+ "change": change,
332
+ "impact": impact,
333
+ "risk": risk
334
+ }