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
project_brain/cli_ui.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def section(title: str):
|
|
11
|
+
console.rule(f"[bold cyan]{title}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def success(message: str, next_step: str | None = None):
|
|
15
|
+
console.print(f"[bold green]✅ {message}[/bold green]")
|
|
16
|
+
|
|
17
|
+
if next_step:
|
|
18
|
+
console.print(
|
|
19
|
+
Panel(
|
|
20
|
+
f"[bold]Next:[/bold]\n{next_step}",
|
|
21
|
+
border_style="cyan",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def error(message: str, suggestion: str | None = None):
|
|
27
|
+
console.print(f"[bold red]❌ {message}[/bold red]")
|
|
28
|
+
|
|
29
|
+
if suggestion:
|
|
30
|
+
console.print(
|
|
31
|
+
Panel(
|
|
32
|
+
suggestion,
|
|
33
|
+
title="Try",
|
|
34
|
+
border_style="yellow",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def info(message: str):
|
|
40
|
+
console.print(f"[cyan]ℹ {message}[/cyan]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def key_value_table(title: str, rows: list[tuple[str, str]]):
|
|
44
|
+
table = Table(title=title)
|
|
45
|
+
|
|
46
|
+
table.add_column("Field", style="cyan")
|
|
47
|
+
table.add_column("Value", style="white")
|
|
48
|
+
|
|
49
|
+
for k, v in rows:
|
|
50
|
+
table.add_row(k, v)
|
|
51
|
+
|
|
52
|
+
console.print(table)
|
|
53
|
+
|
|
54
|
+
def doctor_panel(title: str, rows: list[tuple[str, str, str]]):
|
|
55
|
+
table = Table(title=title)
|
|
56
|
+
|
|
57
|
+
table.add_column("Check", style="cyan")
|
|
58
|
+
table.add_column("Status")
|
|
59
|
+
table.add_column("Details", style="white")
|
|
60
|
+
|
|
61
|
+
for check, status, detail in rows:
|
|
62
|
+
table.add_row(check, status, detail)
|
|
63
|
+
|
|
64
|
+
console.print(
|
|
65
|
+
Panel(
|
|
66
|
+
table,
|
|
67
|
+
border_style="cyan",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import hashlib
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from project_brain.core.logger import log_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sha256_file(path: Path) -> str:
|
|
10
|
+
hasher = hashlib.sha256()
|
|
11
|
+
try:
|
|
12
|
+
with path.open("rb") as f:
|
|
13
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
14
|
+
hasher.update(chunk)
|
|
15
|
+
return hasher.hexdigest()
|
|
16
|
+
except Exception as e:
|
|
17
|
+
log_error(f"Function failed: {str(e)}")
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def analyze_python_file(path: Path, rel_path: str):
|
|
22
|
+
functions = []
|
|
23
|
+
classes = []
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
source = path.read_text(encoding="utf-8")
|
|
27
|
+
tree = ast.parse(source)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
log_error(f"Function failed: {str(e)}")
|
|
30
|
+
return functions, classes
|
|
31
|
+
|
|
32
|
+
for node in ast.walk(tree):
|
|
33
|
+
if isinstance(node, ast.FunctionDef):
|
|
34
|
+
functions.append({
|
|
35
|
+
"name": node.name,
|
|
36
|
+
"arguments": [arg.arg for arg in node.args.args],
|
|
37
|
+
"line": node.lineno,
|
|
38
|
+
"file": rel_path
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
elif isinstance(node, ast.ClassDef):
|
|
42
|
+
classes.append({
|
|
43
|
+
"name": node.name,
|
|
44
|
+
"line": node.lineno,
|
|
45
|
+
"file": rel_path
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return functions, classes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def should_skip(path: Path, ignore_patterns):
|
|
52
|
+
if not ignore_patterns:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
path_parts = set(path.parts)
|
|
56
|
+
|
|
57
|
+
for pattern in ignore_patterns:
|
|
58
|
+
pattern = pattern.strip().rstrip("/")
|
|
59
|
+
|
|
60
|
+
# 1. Directory match (exact)
|
|
61
|
+
if pattern in path_parts:
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
# 2. Extension match (*.pyc etc.)
|
|
65
|
+
if pattern.startswith("*."):
|
|
66
|
+
if path.name.endswith(pattern[1:]):
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_binary(path: Path):
|
|
73
|
+
try:
|
|
74
|
+
with open(path, 'rb') as f:
|
|
75
|
+
return b'\0' in f.read(1024)
|
|
76
|
+
except:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def analyze_project(root_path: Path, ignore_patterns=None, include_tests=False):
|
|
81
|
+
if ignore_patterns is None:
|
|
82
|
+
ignore_patterns = []
|
|
83
|
+
root_path = root_path.resolve()
|
|
84
|
+
|
|
85
|
+
files_data = []
|
|
86
|
+
functions = []
|
|
87
|
+
classes = []
|
|
88
|
+
files_path = []
|
|
89
|
+
|
|
90
|
+
for path in root_path.rglob("*"):
|
|
91
|
+
if path.is_dir():
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if should_skip(path, ignore_patterns):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if is_binary(path):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if not include_tests and "test" in path.name.lower():
|
|
101
|
+
continue
|
|
102
|
+
# files_path.append(path)
|
|
103
|
+
try:
|
|
104
|
+
rel_path = str(path.relative_to(root_path))
|
|
105
|
+
stat = path.stat()
|
|
106
|
+
|
|
107
|
+
file_info = {
|
|
108
|
+
"path": rel_path,
|
|
109
|
+
"size": stat.st_size,
|
|
110
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
111
|
+
"sha256": sha256_file(path)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
files_data.append(file_info)
|
|
115
|
+
files_path.append(rel_path)
|
|
116
|
+
|
|
117
|
+
if path.suffix == ".py":
|
|
118
|
+
fn, cls = analyze_python_file(path, rel_path)
|
|
119
|
+
functions.extend(fn)
|
|
120
|
+
classes.extend(cls)
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
log_error(f"Function failed: {str(e)}")
|
|
124
|
+
continue # skip unreadable files safely
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"project": {
|
|
128
|
+
"root": str(root_path),
|
|
129
|
+
"total_files": len(files_data)
|
|
130
|
+
},
|
|
131
|
+
"files": files_data,
|
|
132
|
+
"functions": functions,
|
|
133
|
+
"classes": classes,
|
|
134
|
+
}, files_path
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from project_brain.core.logger import log_warning
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG = {
|
|
9
|
+
"version": "1.0",
|
|
10
|
+
"llm": {
|
|
11
|
+
"provider": "none",
|
|
12
|
+
"model": "",
|
|
13
|
+
"timeout_sec": 60,
|
|
14
|
+
},
|
|
15
|
+
"analysis": {
|
|
16
|
+
"depth": "fast",
|
|
17
|
+
"include_tests": False,
|
|
18
|
+
"ignore": [
|
|
19
|
+
".brain/",
|
|
20
|
+
".git/",
|
|
21
|
+
"node_modules/",
|
|
22
|
+
"venv/",
|
|
23
|
+
".venv/",
|
|
24
|
+
"__pycache__/",
|
|
25
|
+
"env/",
|
|
26
|
+
".env/",
|
|
27
|
+
"*.egg-info/",
|
|
28
|
+
"tests/",
|
|
29
|
+
"test/",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
"diff": {
|
|
33
|
+
"mode": "function",
|
|
34
|
+
},
|
|
35
|
+
"export": {
|
|
36
|
+
"full_code": {
|
|
37
|
+
"include_tests": False,
|
|
38
|
+
"max_file_size_kb": 200,
|
|
39
|
+
},
|
|
40
|
+
"manual_add": {
|
|
41
|
+
"allow_duplicates": True,
|
|
42
|
+
},
|
|
43
|
+
"changes": {
|
|
44
|
+
"mode": "function",
|
|
45
|
+
"include_context": True,
|
|
46
|
+
"output_path": ".brain/exports/code_changes.txt",
|
|
47
|
+
},
|
|
48
|
+
"ignore": [
|
|
49
|
+
".brain/",
|
|
50
|
+
".git/",
|
|
51
|
+
"node_modules/",
|
|
52
|
+
"venv/",
|
|
53
|
+
".venv/",
|
|
54
|
+
"__pycache__/",
|
|
55
|
+
"env/",
|
|
56
|
+
".env/",
|
|
57
|
+
"*.egg-info/",
|
|
58
|
+
"tests/",
|
|
59
|
+
"test/",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
"explain": {
|
|
63
|
+
"level": "detailed",
|
|
64
|
+
"include_risks": True,
|
|
65
|
+
},
|
|
66
|
+
"output": {
|
|
67
|
+
"format": "text",
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def merge(default, user):
|
|
73
|
+
result = copy.deepcopy(default)
|
|
74
|
+
|
|
75
|
+
for k, v in (user or {}).items():
|
|
76
|
+
if isinstance(v, dict) and k in result:
|
|
77
|
+
result[k] = merge(result[k], v)
|
|
78
|
+
else:
|
|
79
|
+
result[k] = v
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# -----------------------------
|
|
85
|
+
# Validation Helpers
|
|
86
|
+
# -----------------------------
|
|
87
|
+
def _warn(path: str, value, default):
|
|
88
|
+
log_warning(f"Invalid value for {path}='{value}', using default='{default}'")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_enum(config, path, allowed, default):
|
|
92
|
+
keys = path.split(".")
|
|
93
|
+
node = config
|
|
94
|
+
for k in keys[:-1]:
|
|
95
|
+
node = node.get(k, {})
|
|
96
|
+
last = keys[-1]
|
|
97
|
+
|
|
98
|
+
value = node.get(last)
|
|
99
|
+
|
|
100
|
+
if value not in allowed:
|
|
101
|
+
_warn(path, value, default)
|
|
102
|
+
node[last] = default
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_int_positive(config, path, default):
|
|
106
|
+
keys = path.split(".")
|
|
107
|
+
node = config
|
|
108
|
+
for k in keys[:-1]:
|
|
109
|
+
node = node.get(k, {})
|
|
110
|
+
last = keys[-1]
|
|
111
|
+
|
|
112
|
+
value = node.get(last)
|
|
113
|
+
|
|
114
|
+
if not isinstance(value, int) or value <= 0:
|
|
115
|
+
_warn(path, value, default)
|
|
116
|
+
node[last] = default
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# -----------------------------
|
|
120
|
+
# Main Validation
|
|
121
|
+
# -----------------------------
|
|
122
|
+
def validate_config(config: dict) -> dict:
|
|
123
|
+
safe = merge(DEFAULT_CONFIG, config or {})
|
|
124
|
+
|
|
125
|
+
_validate_enum(
|
|
126
|
+
safe,
|
|
127
|
+
"llm.provider",
|
|
128
|
+
["none", "openai", "ollama", "gemini", "huggingface"],
|
|
129
|
+
DEFAULT_CONFIG["llm"]["provider"],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
_validate_enum(
|
|
133
|
+
safe,
|
|
134
|
+
"diff.mode",
|
|
135
|
+
["function", "file"],
|
|
136
|
+
DEFAULT_CONFIG["diff"]["mode"],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
_validate_enum(
|
|
140
|
+
safe,
|
|
141
|
+
"export.changes.mode",
|
|
142
|
+
["function", "file"],
|
|
143
|
+
DEFAULT_CONFIG["export"]["changes"]["mode"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
_validate_enum(
|
|
147
|
+
safe,
|
|
148
|
+
"analysis.depth",
|
|
149
|
+
["fast", "full"],
|
|
150
|
+
DEFAULT_CONFIG["analysis"]["depth"],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
_validate_enum(
|
|
154
|
+
safe,
|
|
155
|
+
"output.format",
|
|
156
|
+
["text", "json", "markdown"],
|
|
157
|
+
DEFAULT_CONFIG["output"]["format"],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
_validate_int_positive(
|
|
161
|
+
safe,
|
|
162
|
+
"llm.timeout_sec",
|
|
163
|
+
DEFAULT_CONFIG["llm"]["timeout_sec"],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return safe
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# -----------------------------
|
|
170
|
+
# Load Config
|
|
171
|
+
# -----------------------------
|
|
172
|
+
def load_config(root: Path) -> dict:
|
|
173
|
+
path = root / "brain.yaml"
|
|
174
|
+
|
|
175
|
+
if not path.exists():
|
|
176
|
+
return DEFAULT_CONFIG
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
180
|
+
except Exception as e:
|
|
181
|
+
log_warning(f"Failed to parse YAML: {str(e)}")
|
|
182
|
+
return DEFAULT_CONFIG
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
merged = merge(DEFAULT_CONFIG, raw)
|
|
186
|
+
return validate_config(merged)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
log_warning(f"Validation failed: {str(e)}")
|
|
189
|
+
return DEFAULT_CONFIG
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def dump_config(config: dict) -> str:
|
|
193
|
+
return yaml.dump(config, sort_keys=False)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from project_brain.core.logger import log_error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# -----------------------------
|
|
9
|
+
# Git Utilities (SAFE)
|
|
10
|
+
# -----------------------------
|
|
11
|
+
def run_git_command(args: list[str], cwd: Path) -> str | None:
|
|
12
|
+
try:
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["git"] + args,
|
|
15
|
+
cwd=cwd,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
encoding="utf-8", # 🔥 CRITICAL FIX
|
|
19
|
+
errors="ignore", # 🔥 PREVENT CRASH
|
|
20
|
+
check=True,
|
|
21
|
+
)
|
|
22
|
+
return result.stdout.strip()
|
|
23
|
+
except subprocess.CalledProcessError:
|
|
24
|
+
return None
|
|
25
|
+
except Exception as e:
|
|
26
|
+
log_error(f"Function failed: {str(e)}")
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_git_repo(path: Path) -> bool:
|
|
31
|
+
result = run_git_command(["rev-parse", "--is-inside-work-tree"], path)
|
|
32
|
+
return result == "true"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -----------------------------
|
|
36
|
+
# Diff Parsing
|
|
37
|
+
# -----------------------------
|
|
38
|
+
def parse_name_status(output: str):
|
|
39
|
+
added, modified, deleted = [], [], []
|
|
40
|
+
|
|
41
|
+
for line in output.splitlines():
|
|
42
|
+
if not line.strip():
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
parts = line.split(maxsplit=1)
|
|
46
|
+
if len(parts) != 2:
|
|
47
|
+
continue # 🔥 safe guard
|
|
48
|
+
|
|
49
|
+
status, file = parts
|
|
50
|
+
|
|
51
|
+
if status == "A":
|
|
52
|
+
added.append(file)
|
|
53
|
+
elif status == "M":
|
|
54
|
+
modified.append(file)
|
|
55
|
+
elif status == "D":
|
|
56
|
+
deleted.append(file)
|
|
57
|
+
|
|
58
|
+
return added, modified, deleted
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -----------------------------
|
|
62
|
+
# File Content Retrieval
|
|
63
|
+
# -----------------------------
|
|
64
|
+
def get_file_from_ref(ref: str, file: str, cwd: Path) -> str | None:
|
|
65
|
+
return run_git_command(["show", f"{ref}:{file}"], cwd)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -----------------------------
|
|
69
|
+
# AST Function Extraction
|
|
70
|
+
# -----------------------------
|
|
71
|
+
def extract_functions(source: str):
|
|
72
|
+
"""
|
|
73
|
+
Returns dict:
|
|
74
|
+
{
|
|
75
|
+
"function_name": "function_source_code"
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
functions = {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
tree = ast.parse(source)
|
|
82
|
+
except Exception:
|
|
83
|
+
return functions # 🔥 invalid python safety
|
|
84
|
+
|
|
85
|
+
for node in ast.walk(tree):
|
|
86
|
+
if isinstance(node, ast.FunctionDef):
|
|
87
|
+
try:
|
|
88
|
+
code = ast.get_source_segment(source, node) or ""
|
|
89
|
+
except Exception:
|
|
90
|
+
code = ""
|
|
91
|
+
|
|
92
|
+
functions[node.name] = code
|
|
93
|
+
|
|
94
|
+
return functions
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -----------------------------
|
|
98
|
+
# Function Diff Logic
|
|
99
|
+
# -----------------------------
|
|
100
|
+
def diff_functions(old_src: str, new_src: str):
|
|
101
|
+
old_funcs = extract_functions(old_src or "")
|
|
102
|
+
new_funcs = extract_functions(new_src or "")
|
|
103
|
+
|
|
104
|
+
old_set = set(old_funcs.keys())
|
|
105
|
+
new_set = set(new_funcs.keys())
|
|
106
|
+
|
|
107
|
+
added = sorted(new_set - old_set)
|
|
108
|
+
removed = sorted(old_set - new_set)
|
|
109
|
+
modified = sorted(
|
|
110
|
+
[fn for fn in old_set & new_set if old_funcs.get(fn) != new_funcs.get(fn)]
|
|
111
|
+
) # simple heuristic
|
|
112
|
+
|
|
113
|
+
return added, removed, modified
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# -----------------------------
|
|
117
|
+
# Main Diff Engine
|
|
118
|
+
# -----------------------------
|
|
119
|
+
def compute_diff(from_ref: str, to_ref: str, root: Path):
|
|
120
|
+
diff_output = run_git_command(
|
|
121
|
+
["log", "--name-status", "--pretty=format:", f"{from_ref}..{to_ref}"], root
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if diff_output is None:
|
|
125
|
+
raise RuntimeError("Git command failed")
|
|
126
|
+
|
|
127
|
+
if diff_output.strip() == "":
|
|
128
|
+
return {"added": [], "modified": [], "deleted": [], "function_diffs": []}
|
|
129
|
+
|
|
130
|
+
added, modified, deleted = parse_name_status(diff_output)
|
|
131
|
+
|
|
132
|
+
function_diffs = []
|
|
133
|
+
|
|
134
|
+
for file in modified:
|
|
135
|
+
if not file.endswith(".py"):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
old_src = get_file_from_ref(from_ref, file, root)
|
|
139
|
+
new_src = get_file_from_ref(to_ref, file, root)
|
|
140
|
+
|
|
141
|
+
if old_src is None or new_src is None:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
fn_added, fn_removed, fn_modified = diff_functions(old_src, new_src)
|
|
145
|
+
|
|
146
|
+
function_diffs.append(
|
|
147
|
+
{
|
|
148
|
+
"file": file,
|
|
149
|
+
"added": fn_added,
|
|
150
|
+
"removed": fn_removed,
|
|
151
|
+
"modified": fn_modified,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"added": added,
|
|
157
|
+
"modified": modified,
|
|
158
|
+
"deleted": deleted,
|
|
159
|
+
"function_diffs": function_diffs,
|
|
160
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from project_brain.core.doctor_checks.analysis import run_analysis_checks
|
|
4
|
+
from project_brain.core.doctor_checks.environment import run_environment_checks
|
|
5
|
+
from project_brain.core.doctor_checks.exports import run_export_checks
|
|
6
|
+
from project_brain.core.doctor_checks.llm import run_llm_checks
|
|
7
|
+
from project_brain.core.doctor_checks.repository import run_repository_checks
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_doctor(root: Path):
|
|
11
|
+
checks = []
|
|
12
|
+
|
|
13
|
+
checks.extend(run_environment_checks(root))
|
|
14
|
+
checks.extend(run_repository_checks(root))
|
|
15
|
+
checks.extend(run_analysis_checks(root))
|
|
16
|
+
checks.extend(run_export_checks(root))
|
|
17
|
+
checks.extend(run_llm_checks(root))
|
|
18
|
+
|
|
19
|
+
failures = [c for c in checks if c.status == "fail"]
|
|
20
|
+
warnings = [c for c in checks if c.status == "warn"]
|
|
21
|
+
|
|
22
|
+
if failures:
|
|
23
|
+
status = "NOT READY"
|
|
24
|
+
elif warnings:
|
|
25
|
+
status = "PARTIAL"
|
|
26
|
+
else:
|
|
27
|
+
status = "READY"
|
|
28
|
+
|
|
29
|
+
return checks, status
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from project_brain.core.doctor_checks.models import DoctorCheck
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_analysis_checks(root: Path):
|
|
9
|
+
checks = []
|
|
10
|
+
|
|
11
|
+
brain_dir = root / ".brain"
|
|
12
|
+
|
|
13
|
+
if brain_dir.exists():
|
|
14
|
+
checks.append(
|
|
15
|
+
DoctorCheck(
|
|
16
|
+
"Analysis",
|
|
17
|
+
"Initialization",
|
|
18
|
+
"pass",
|
|
19
|
+
".brain directory exists",
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
else:
|
|
23
|
+
checks.append(
|
|
24
|
+
DoctorCheck(
|
|
25
|
+
"Analysis",
|
|
26
|
+
"Initialization",
|
|
27
|
+
"fail",
|
|
28
|
+
"Project not initialized",
|
|
29
|
+
"Run:\nbrain project init",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return checks
|
|
33
|
+
|
|
34
|
+
data_path = brain_dir / "data.json"
|
|
35
|
+
|
|
36
|
+
if not data_path.exists():
|
|
37
|
+
checks.append(
|
|
38
|
+
DoctorCheck(
|
|
39
|
+
"Analysis",
|
|
40
|
+
"Analysis",
|
|
41
|
+
"fail",
|
|
42
|
+
"Analysis missing",
|
|
43
|
+
"Run:\nbrain project analyze .",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return checks
|
|
47
|
+
|
|
48
|
+
checks.append(
|
|
49
|
+
DoctorCheck(
|
|
50
|
+
"Analysis",
|
|
51
|
+
"Analysis",
|
|
52
|
+
"pass",
|
|
53
|
+
"data.json exists",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(data_path.read_text())
|
|
59
|
+
|
|
60
|
+
total_files = data.get("project", {}).get("total_files", 0)
|
|
61
|
+
|
|
62
|
+
checks.append(
|
|
63
|
+
DoctorCheck(
|
|
64
|
+
"Analysis",
|
|
65
|
+
"Analyzed Files",
|
|
66
|
+
"info",
|
|
67
|
+
str(total_files),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
modified = datetime.fromtimestamp(
|
|
72
|
+
data_path.stat().st_mtime
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
age_hours = (
|
|
76
|
+
datetime.now() - modified
|
|
77
|
+
).total_seconds() / 3600
|
|
78
|
+
|
|
79
|
+
if age_hours > 24:
|
|
80
|
+
checks.append(
|
|
81
|
+
DoctorCheck(
|
|
82
|
+
"Analysis",
|
|
83
|
+
"Freshness",
|
|
84
|
+
"warn",
|
|
85
|
+
"Analysis may be stale",
|
|
86
|
+
"Run:\nbrain project analyze .",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
checks.append(
|
|
91
|
+
DoctorCheck(
|
|
92
|
+
"Analysis",
|
|
93
|
+
"Freshness",
|
|
94
|
+
"pass",
|
|
95
|
+
"Analysis is recent",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
except Exception:
|
|
100
|
+
checks.append(
|
|
101
|
+
DoctorCheck(
|
|
102
|
+
"Analysis",
|
|
103
|
+
"Integrity",
|
|
104
|
+
"fail",
|
|
105
|
+
"Invalid data.json",
|
|
106
|
+
"Re-run analysis",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return checks
|