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,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