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.
- project_brain/__init__.py +1 -0
- project_brain/cli.py +644 -0
- project_brain/cli_help.py +52 -0
- project_brain/cli_ui.py +69 -0
- project_brain/config/__init__.py +0 -0
- project_brain/core/__init__.py +0 -0
- project_brain/core/analyzer.py +134 -0
- project_brain/core/config_loader.py +193 -0
- project_brain/core/differ.py +160 -0
- project_brain/core/doctor.py +29 -0
- project_brain/core/doctor_checks/analysis.py +110 -0
- project_brain/core/doctor_checks/environment.py +92 -0
- project_brain/core/doctor_checks/exports.py +70 -0
- project_brain/core/doctor_checks/llm.py +107 -0
- project_brain/core/doctor_checks/models.py +10 -0
- project_brain/core/doctor_checks/repository.py +102 -0
- project_brain/core/explainer.py +334 -0
- project_brain/core/explainer_file.py +136 -0
- project_brain/core/exporter.py +340 -0
- project_brain/core/logger.py +31 -0
- project_brain/core/results.py +163 -0
- project_brain/core/summarizer.py +108 -0
- project_brain/llm/__init__.py +0 -0
- project_brain/llm/provider.py +211 -0
- project_brain/storage/__init__.py +0 -0
- project_brain/storage/storage.py +12 -0
- project_brain_cli-1.0.0.dist-info/METADATA +1185 -0
- project_brain_cli-1.0.0.dist-info/RECORD +32 -0
- project_brain_cli-1.0.0.dist-info/WHEEL +5 -0
- project_brain_cli-1.0.0.dist-info/entry_points.txt +3 -0
- project_brain_cli-1.0.0.dist-info/licenses/LICENSE +11 -0
- project_brain_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
"""
|