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,136 @@
1
+ import ast
2
+ from pathlib import Path
3
+ import os
4
+
5
+ from project_brain.core.config_loader import load_config
6
+ from project_brain.core.logger import log_error
7
+ from project_brain.llm.provider import call_llm
8
+
9
+
10
+ def extract_file_structure(source: str):
11
+ functions = []
12
+ classes = []
13
+
14
+ try:
15
+ tree = ast.parse(source)
16
+ except Exception:
17
+ return functions, classes
18
+
19
+ for node in ast.walk(tree):
20
+ if isinstance(node, ast.FunctionDef):
21
+ functions.append(node.name)
22
+ elif isinstance(node, ast.ClassDef):
23
+ classes.append(node.name)
24
+
25
+ return functions, classes
26
+
27
+
28
+ def extract_function(source: str, func_name: str):
29
+ try:
30
+ tree = ast.parse(source)
31
+ except Exception as e:
32
+ log_error(f"Function failed: {str(e)}")
33
+ return None
34
+
35
+ lines = source.splitlines()
36
+
37
+ for node in ast.walk(tree):
38
+ if isinstance(node, ast.FunctionDef) and node.name == func_name:
39
+ start = node.lineno - 1
40
+ end = getattr(node, "end_lineno", start + 1)
41
+ code = "\n".join(lines[start:end])
42
+
43
+ return {
44
+ "name": node.name,
45
+ "args": [arg.arg for arg in node.args.args],
46
+ "line": node.lineno,
47
+ "code": code
48
+ }
49
+
50
+ return None
51
+
52
+
53
+ def explain_file(root: Path, file_path: str):
54
+ config = load_config(root)
55
+ provider = config["llm"]["provider"]
56
+ model = config["llm"]["model"]
57
+ api_key = ""
58
+
59
+ path = root / file_path
60
+ if not path.exists():
61
+ log_error(f"File not found: {file_path}")
62
+ return "❌ File not found"
63
+
64
+ try:
65
+ source = path.read_text(encoding="utf-8", errors="ignore")
66
+ except Exception as e:
67
+ log_error(f"Unable to read file: {file_path} - {str(e)}")
68
+ return "❌ Unable to read file"
69
+
70
+ functions, classes = extract_file_structure(source)
71
+
72
+ if provider == "none":
73
+ lines = []
74
+ lines.append(f"File: {file_path}\n")
75
+ lines.append("Summary:")
76
+ lines.append(f"- Functions: {len(functions)}")
77
+ lines.append(f"- Classes: {len(classes)}\n")
78
+
79
+ lines.append("Functions:\n")
80
+ for fn in functions:
81
+ lines.append(f"* {fn}")
82
+
83
+ return "\n".join(lines)
84
+
85
+ prompt = f"""
86
+ Explain this file: purpose, main components, data flow, key risks.
87
+
88
+ {source}
89
+ """.strip()
90
+
91
+ response = call_llm(provider, model, prompt, api_key)
92
+
93
+ return f"File: {file_path}\n\nSummary:\n{response['output'].strip()}"
94
+
95
+
96
+ def explain_function(root: Path, file_path: str, func_name: str):
97
+ config = load_config(root)
98
+ provider = config["llm"]["provider"]
99
+ model = config["llm"]["model"]
100
+ api_key = ""
101
+ include_risks = config.get("explain", {}).get("include_risks", True)
102
+
103
+ path = root / file_path
104
+ if not path.exists():
105
+ log_error(f"File not found: {file_path}")
106
+ return "❌ File not found"
107
+
108
+ try:
109
+ source = path.read_text(encoding="utf-8", errors="ignore")
110
+ except Exception as e:
111
+ log_error(f"Unable to read file: {file_path} - {str(e)}")
112
+ return "❌ Unable to read file"
113
+
114
+ fn = extract_function(source, func_name)
115
+ if not fn:
116
+ log_error(f"Function not found: {func_name} in {file_path}")
117
+ return "❌ Function not found"
118
+
119
+ if provider == "none":
120
+ lines = []
121
+ lines.append(f"Function: {fn['name']}\n")
122
+ lines.append(f"Args: {', '.join(fn['args'])}")
123
+ lines.append(f"Line: {fn['line']}")
124
+ return "\n".join(lines)
125
+
126
+ risk_part = ", edge cases, risks" if include_risks else ""
127
+
128
+ prompt = f"""
129
+ Explain this function: purpose, inputs, outputs, logic{risk_part}.
130
+
131
+ {fn['code']}
132
+ """.strip()
133
+
134
+ response = call_llm(provider, model, prompt, api_key)
135
+
136
+ return f"Function: {fn['name']}\n\n{response['output'].strip()}"
@@ -0,0 +1,340 @@
1
+ import ast
2
+ import hashlib
3
+ from pathlib import Path
4
+
5
+ from project_brain.core.config_loader import load_config
6
+ from project_brain.core.differ import compute_diff, get_file_from_ref
7
+ from project_brain.core.logger import log_error
8
+
9
+
10
+ def should_skip(path: Path, ignore_patterns):
11
+ if not ignore_patterns:
12
+ return False
13
+
14
+ path_parts = set(path.parts)
15
+
16
+ for pattern in ignore_patterns:
17
+ pattern = pattern.strip().rstrip("/")
18
+
19
+ # 1. Directory match (exact)
20
+ if pattern in path_parts:
21
+ return True
22
+
23
+ # 2. Extension match (*.pyc etc.)
24
+ if pattern.startswith("*."):
25
+ if path.name.endswith(pattern[1:]):
26
+ return True
27
+
28
+ return False
29
+
30
+
31
+ def is_test_file(path: Path):
32
+ name = path.name.lower()
33
+ return (
34
+ name.startswith("test_") or
35
+ name.endswith("_test.py") or
36
+ "tests" in path.parts
37
+ )
38
+
39
+
40
+
41
+ def _read_existing_entries(output_path: Path):
42
+ if not output_path.exists():
43
+ return set()
44
+
45
+ existing = set()
46
+ try:
47
+ with output_path.open("r", encoding="utf-8", errors="ignore") as f:
48
+ for line in f:
49
+ if line.startswith("=== FILE:"):
50
+ parts = line.strip().split("=== FILE:")[-1].strip()
51
+ file_name = parts.replace("===", "").strip()
52
+ file_name = file_name.replace("(MANUAL ADD)", "").strip()
53
+ existing.add(file_name)
54
+ except Exception as e:
55
+ log_error(f"Function failed: {str(e)}")
56
+ pass
57
+
58
+ return existing
59
+
60
+
61
+ def _append_file(out, rel_path: str, content: str, manual: bool):
62
+ tag = " (MANUAL ADD)" if manual else ""
63
+ out.write(f"=== FILE: {rel_path}{tag} ===\n")
64
+ out.write(content)
65
+ out.write("\n\n")
66
+
67
+
68
+ def export_full_code(root: Path):
69
+ config = load_config(root)
70
+
71
+ export_cfg = config.get("export", {}).get("full_code", {})
72
+ include_tests = export_cfg.get("include_tests", False)
73
+ max_kb = export_cfg.get("max_file_size_kb", 200)
74
+ ignore_paths = config.get("export", {}).get("ignore", [])
75
+
76
+ export_dir = root / ".brain" / "exports"
77
+ export_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ output_path = export_dir / "full_code.txt"
80
+
81
+ files_exported = 0
82
+ file_paths = []
83
+
84
+ with output_path.open("w", encoding="utf-8") as out:
85
+
86
+ for path in root.rglob("*"):
87
+ if path.is_dir():
88
+ continue
89
+
90
+ if should_skip(path, ignore_paths):
91
+ continue
92
+
93
+ if not include_tests and is_test_file(path):
94
+ continue
95
+
96
+ try:
97
+ size_kb = path.stat().st_size / 1024
98
+ if size_kb > max_kb:
99
+ continue
100
+
101
+ rel_path = str(path.relative_to(root))
102
+
103
+ content = path.read_text(encoding="utf-8", errors="ignore")
104
+
105
+ out.write(f"=== FILE: {rel_path} ===\n")
106
+ out.write(content)
107
+ out.write("\n\n")
108
+
109
+ files_exported += 1
110
+ file_paths.append(rel_path)
111
+
112
+ except Exception:
113
+ continue # skip unreadable safely
114
+
115
+ return files_exported, output_path, file_paths
116
+
117
+
118
+ def add_code_file(root: Path, target: Path):
119
+ config = load_config(root)
120
+ max_kb = config["export"]["full_code"]["max_file_size_kb"]
121
+ allow_dup = config["export"]["manual_add"]["allow_duplicates"]
122
+ ingore_paths = config.get("export", {}).get("ignore", [])
123
+
124
+ export_dir = root / ".brain" / "exports"
125
+ export_dir.mkdir(parents=True, exist_ok=True)
126
+
127
+ output_path = export_dir / "full_code.txt"
128
+
129
+ if not target.exists():
130
+ return 0, output_path, "❌ File not found"
131
+
132
+ if should_skip(target, ingore_paths):
133
+ return 0, output_path, "⚠ Skipped (ignored path)"
134
+
135
+ existing = _read_existing_entries(output_path) if not allow_dup else set()
136
+
137
+ try:
138
+ size_kb = target.stat().st_size / 1024
139
+ if size_kb > max_kb:
140
+ return 0, output_path, "⚠ Skipped (file too large)"
141
+
142
+ rel_path = str(target.resolve().relative_to(root))
143
+
144
+ if not allow_dup and rel_path in existing:
145
+ return 0, output_path, "⚠ Skipped (duplicate)"
146
+
147
+ content = target.read_text(encoding="utf-8", errors="ignore")
148
+
149
+ with output_path.open("a", encoding="utf-8") as out:
150
+ _append_file(out, rel_path, content, manual=True)
151
+
152
+ return 1, output_path, None
153
+
154
+ except Exception:
155
+ return 0, output_path, "⚠ Skipped (unreadable)"
156
+
157
+
158
+ def add_code_dir(root: Path, target: Path):
159
+ config = load_config(root)
160
+ max_kb = config["export"]["full_code"]["max_file_size_kb"]
161
+ allow_dup = config["export"]["manual_add"]["allow_duplicates"]
162
+ ignore_paths = config.get("export", {}).get("ignore", [])
163
+
164
+ export_dir = root / ".brain" / "exports"
165
+ export_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ output_path = export_dir / "full_code.txt"
168
+
169
+ if not target.exists():
170
+ return 0, output_path, "❌ Directory not found"
171
+
172
+ existing = _read_existing_entries(output_path) if not allow_dup else set()
173
+
174
+ count = 0
175
+
176
+ with output_path.open("a", encoding="utf-8") as out:
177
+ for path in target.rglob("*"):
178
+ if path.is_dir():
179
+ continue
180
+
181
+ if should_skip(path, ignore_paths):
182
+ continue
183
+
184
+ try:
185
+ size_kb = path.stat().st_size / 1024
186
+ if size_kb > max_kb:
187
+ continue
188
+
189
+ rel_path = str(path.resolve().relative_to(root))
190
+
191
+ if not allow_dup and rel_path in existing:
192
+ continue
193
+
194
+ content = path.read_text(encoding="utf-8", errors="ignore")
195
+
196
+ _append_file(out, rel_path, content, manual=True)
197
+ count += 1
198
+
199
+ except Exception:
200
+ continue
201
+
202
+ return count, output_path, None
203
+
204
+
205
+ def _extract_functions_with_code(source: str):
206
+ result = {}
207
+
208
+ try:
209
+ tree = ast.parse(source)
210
+ except Exception:
211
+ return result
212
+
213
+ lines = source.splitlines()
214
+
215
+ for node in ast.walk(tree):
216
+ if isinstance(node, ast.FunctionDef):
217
+ start = node.lineno - 1
218
+ end = getattr(node, "end_lineno", start + 1)
219
+
220
+ code = "\n".join(lines[start:end])
221
+ body_hash = hashlib.sha256(code.encode()).hexdigest()
222
+
223
+ result[node.name] = {
224
+ "code": code,
225
+ "hash": body_hash,
226
+ "line": node.lineno
227
+ }
228
+
229
+ return result
230
+
231
+
232
+ def export_code_changes(root: Path, from_ref: str, to_ref: str):
233
+ config = load_config(root)
234
+ changes_cfg = config.get("export", {}).get("changes", {})
235
+
236
+ mode = changes_cfg.get("mode", "function")
237
+ include_context = changes_cfg.get("include_context", True)
238
+
239
+ export_dir = root / ".brain" / "exports"
240
+ export_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ output_path = export_dir / "code_changes.txt"
243
+
244
+ diff = compute_diff(from_ref, to_ref, root)
245
+ if not diff:
246
+ return 0, output_path
247
+
248
+ files_processed = 0
249
+
250
+ with output_path.open("w", encoding="utf-8") as out:
251
+
252
+ # ADDED FILES
253
+ for file in diff["added"]:
254
+ new_src = get_file_from_ref(to_ref, file, root)
255
+ if not new_src:
256
+ continue
257
+
258
+ out.write(f"=== FILE: {file} (ADDED) ===\n")
259
+ out.write(new_src)
260
+ out.write("\n\n")
261
+
262
+ files_processed += 1
263
+
264
+ # DELETED FILES
265
+ for file in diff["deleted"]:
266
+ old_src = get_file_from_ref(from_ref, file, root)
267
+ if not old_src:
268
+ continue
269
+
270
+ out.write(f"=== FILE: {file} (DELETED) ===\n")
271
+ out.write(old_src)
272
+ out.write("\n\n")
273
+
274
+ files_processed += 1
275
+
276
+ # MODIFIED FILES
277
+ for file in diff["modified"]:
278
+ if not file.endswith(".py"):
279
+ out.write(f"=== FILE: {file} (MODIFIED - NON PY) ===\n")
280
+ out.write("Changes not analyzed.\n\n")
281
+ files_processed += 1
282
+ continue
283
+
284
+ old_src = get_file_from_ref(from_ref, file, root)
285
+ new_src = get_file_from_ref(to_ref, file, root)
286
+
287
+ if not old_src or not new_src:
288
+ continue
289
+
290
+ if mode == "file":
291
+ out.write(f"=== FILE: {file} (MODIFIED) ===\n")
292
+ out.write("OLD:\n")
293
+ out.write(old_src)
294
+ out.write("\n\nNEW:\n")
295
+ out.write(new_src)
296
+ out.write("\n\n")
297
+ files_processed += 1
298
+ continue
299
+
300
+ old_funcs = _extract_functions_with_code(old_src)
301
+ new_funcs = _extract_functions_with_code(new_src)
302
+
303
+ all_funcs = set(old_funcs) | set(new_funcs)
304
+
305
+ out.write(f"=== FILE: {file} ===\n\n")
306
+
307
+ for fn in all_funcs:
308
+ old_f = old_funcs.get(fn)
309
+ new_f = new_funcs.get(fn)
310
+
311
+ if old_f and not new_f:
312
+ out.write(f"--- FUNCTION: {fn} (REMOVED) ---\n")
313
+ if include_context:
314
+ out.write(f"# line: {old_f['line']}\n")
315
+ out.write("OLD:\n")
316
+ out.write(old_f["code"])
317
+ out.write("\n\n")
318
+
319
+ elif new_f and not old_f:
320
+ out.write(f"--- FUNCTION: {fn} (ADDED) ---\n")
321
+ if include_context:
322
+ out.write(f"# line: {new_f['line']}\n")
323
+ out.write("NEW:\n")
324
+ out.write(new_f["code"])
325
+ out.write("\n\n")
326
+
327
+ elif old_f and new_f:
328
+ if old_f["hash"] != new_f["hash"]:
329
+ out.write(f"--- FUNCTION: {fn} (UPDATED) ---\n")
330
+ if include_context:
331
+ out.write(f"# old line: {old_f['line']}, new line: {new_f['line']}\n")
332
+ out.write("OLD:\n")
333
+ out.write(old_f["code"])
334
+ out.write("\n\nNEW:\n")
335
+ out.write(new_f["code"])
336
+ out.write("\n\n")
337
+
338
+ files_processed += 1
339
+
340
+ return files_processed, output_path
@@ -0,0 +1,31 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ LOG_FILE = Path(".brain") / "logs.txt"
5
+
6
+
7
+ def _write(level: str, message: str):
8
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
9
+ formatted = f"[{timestamp}] [{level}] {message}"
10
+
11
+ try:
12
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
13
+ with LOG_FILE.open("a", encoding="utf-8") as f:
14
+ f.write(formatted + "\n")
15
+ except Exception:
16
+ # fallback: never crash logging
17
+ print(formatted)
18
+
19
+
20
+ def log_info(message: str):
21
+ _write("INFO", message)
22
+
23
+
24
+ def log_warning(message: str):
25
+ _write("WARNING", message)
26
+ print(f"[WARNING] {message}") # keep visible
27
+
28
+
29
+ def log_error(message: str):
30
+ _write("ERROR", message)
31
+ print(f"[ERROR] {message}") # keep visible
@@ -0,0 +1,163 @@
1
+ from collections import defaultdict
2
+
3
+
4
+ def generate_html(results):
5
+
6
+ grouped = defaultdict(list)
7
+ for item in results:
8
+ grouped[item["file"]].append(item)
9
+
10
+ sections = ""
11
+
12
+ for file, items in grouped.items():
13
+ rows = ""
14
+
15
+ for item in items:
16
+ risk = item["risk"] or "unknown"
17
+
18
+ if "high" in risk.lower():
19
+ risk_class = "risk-high"
20
+ elif "medium" in risk.lower():
21
+ risk_class = "risk-medium"
22
+ else:
23
+ risk_class = "risk-low"
24
+
25
+ rows += f"""
26
+ <div class="function-card">
27
+ <div class="fn-header">
28
+ <span class="fn-name">{item['function']}</span>
29
+ <span class="badge {risk_class}">{risk}</span>
30
+ </div>
31
+
32
+ <div class="section">
33
+ <b>Change</b>
34
+ <p>{item['change']}</p>
35
+ </div>
36
+
37
+ <div class="section">
38
+ <b>Impact</b>
39
+ <p>{item['impact']}</p>
40
+ </div>
41
+
42
+ <div class="section">
43
+ <b>Risk</b>
44
+ <p>{item['risk']}</p>
45
+ </div>
46
+ </div>
47
+ """
48
+
49
+ sections += f"""
50
+ <div class="file-block">
51
+ <div class="file-header" onclick="toggle(this)">
52
+ 📂 {file}
53
+ </div>
54
+ <div class="file-content">
55
+ {rows}
56
+ </div>
57
+ </div>
58
+ """
59
+
60
+ return f"""
61
+ <html>
62
+ <head>
63
+ <meta charset="UTF-8">
64
+ <title>Project Brain Report</title>
65
+
66
+ <style>
67
+ body {{
68
+ font-family: Inter, Arial;
69
+ background: #0f172a;
70
+ color: #e2e8f0;
71
+ padding: 20px;
72
+ }}
73
+
74
+ h1 {{
75
+ color: #38bdf8;
76
+ margin-bottom: 20px;
77
+ }}
78
+
79
+ .file-block {{
80
+ margin-bottom: 20px;
81
+ border: 1px solid #334155;
82
+ border-radius: 10px;
83
+ overflow: hidden;
84
+ }}
85
+
86
+ .file-header {{
87
+ background: #1e293b;
88
+ padding: 12px;
89
+ cursor: pointer;
90
+ font-weight: bold;
91
+ }}
92
+
93
+ .file-content {{
94
+ display: none;
95
+ padding: 15px;
96
+ }}
97
+
98
+ .function-card {{
99
+ background: #020617;
100
+ border: 1px solid #334155;
101
+ border-radius: 8px;
102
+ padding: 12px;
103
+ margin-bottom: 12px;
104
+ }}
105
+
106
+ .fn-header {{
107
+ display: flex;
108
+ justify-content: space-between;
109
+ margin-bottom: 10px;
110
+ }}
111
+
112
+ .fn-name {{
113
+ font-weight: bold;
114
+ color: #22c55e;
115
+ }}
116
+
117
+ .section {{
118
+ margin-bottom: 8px;
119
+ }}
120
+
121
+ .section p {{
122
+ margin: 4px 0;
123
+ color: #cbd5f5;
124
+ }}
125
+
126
+ .badge {{
127
+ padding: 4px 8px;
128
+ border-radius: 6px;
129
+ font-size: 12px;
130
+ }}
131
+
132
+ .risk-high {{
133
+ background: #dc2626;
134
+ }}
135
+
136
+ .risk-medium {{
137
+ background: #f59e0b;
138
+ }}
139
+
140
+ .risk-low {{
141
+ background: #16a34a;
142
+ }}
143
+ </style>
144
+
145
+ <script>
146
+ function toggle(el) {{
147
+ let content = el.nextElementSibling;
148
+ content.style.display =
149
+ content.style.display === "block" ? "none" : "block";
150
+ }}
151
+ </script>
152
+
153
+ </head>
154
+
155
+ <body>
156
+
157
+ <h1>🧠 Project Brain - Diff Analysis</h1>
158
+
159
+ {sections}
160
+
161
+ </body>
162
+ </html>
163
+ """