codebase-brain 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (26) hide show
  1. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/PKG-INFO +1 -1
  2. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_cli.py +128 -35
  3. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/bug_detector.py +15 -14
  4. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/codebase_walker.py +11 -6
  5. codebase_brain-0.3.0/brain_parser/graph_builder.py +304 -0
  6. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/llm_router.py +23 -5
  7. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/PKG-INFO +1 -1
  8. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/setup.py +1 -1
  9. codebase_brain-0.1.0/brain_parser/graph_builder.py +0 -190
  10. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/README.md +0 -0
  11. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/__init__.py +0 -0
  12. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/ast_parser.py +0 -0
  13. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/file_watcher.py +0 -0
  14. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/query_engine.py +0 -0
  15. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/root_cause.py +0 -0
  16. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/brain_parser/universal_parser.py +0 -0
  17. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/SOURCES.txt +0 -0
  18. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/dependency_links.txt +0 -0
  19. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/entry_points.txt +0 -0
  20. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/requires.txt +0 -0
  21. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/codebase_brain.egg-info/top_level.txt +0 -0
  22. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/setup.cfg +0 -0
  23. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/tests/test_bugs.py +0 -0
  24. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/tests/test_classes.py +0 -0
  25. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/tests/test_parser.py +0 -0
  26. {codebase_brain-0.1.0 → codebase_brain-0.3.0}/tests/test_treesitter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-brain
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: An AI brain that lives inside your codebase
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: watchdog
@@ -65,6 +65,75 @@ def cmd_start(path):
65
65
  print(f"\nBrain: {answer}\n")
66
66
 
67
67
 
68
+ def _explain_risk(staged_files, G, risk, brain):
69
+ from brain_parser.llm_router import call_llm
70
+
71
+ context = ""
72
+ for f in staged_files:
73
+ target_norm = os.path.abspath(f).replace('\\', '/')
74
+ matched = None
75
+ for node in G.nodes():
76
+ node_norm = os.path.abspath(node).replace('\\', '/')
77
+ if node_norm == target_norm:
78
+ matched = node
79
+ break
80
+ if not matched:
81
+ continue
82
+
83
+ file_risk = risk.get(matched, 'LOW')
84
+ if file_risk != 'HIGH':
85
+ continue
86
+
87
+ direct = list(G.predecessors(matched))
88
+ file_data = brain.get(matched, {})
89
+ functions = [fn['name'] for fn in file_data.get('functions', [])]
90
+
91
+ context += f"\nFile: {f}\n"
92
+ context += f"Risk: {file_risk}\n"
93
+ context += f"Functions in this file: {functions}\n"
94
+ context += f"Files that depend on it: {direct}\n"
95
+
96
+ prompt = f"""A developer is about to commit changes to a HIGH risk file.
97
+ Given this context:
98
+ {context}
99
+
100
+ In 2-3 sentences, explain in plain English:
101
+ 1. Why this file is risky to change
102
+ 2. What could realistically break if the change is wrong
103
+ 3. What the developer should double check before committing
104
+
105
+ Be direct and specific. No generic advice."""
106
+
107
+ try:
108
+ return call_llm(prompt, max_tokens=200)
109
+ except Exception as e:
110
+ return f"Could not generate explanation: {str(e)}"
111
+
112
+
113
+ def _print_impact(G, risk, filepath, clean):
114
+ from brain_parser.graph_builder import get_impact
115
+
116
+ result = get_impact(G, filepath)
117
+
118
+ if not result:
119
+ print(f"File not found in brain: {filepath}")
120
+ return None
121
+
122
+ file_risk = risk.get(result['target'], 'LOW')
123
+
124
+ print(f"\n Change Impact: {filepath}")
125
+ print(f" Risk Level: {file_risk}\n")
126
+ print(f"DIRECT IMPACT ({len(result['direct'])} files):")
127
+ for f in result['direct']:
128
+ print(f" → {clean(f)}")
129
+ print(f"\nINDIRECT IMPACT ({len(result['indirect'])} files):")
130
+ for f in result['indirect']:
131
+ print(f" → {clean(f)}")
132
+ print(f"\nTotal files affected: {result['total_affected']}\n")
133
+
134
+ return {'risk': file_risk, 'total': result['total_affected']}
135
+
136
+
68
137
  def cmd_impact(filepath=None, staged=False, block=False):
69
138
  from brain_parser.codebase_walker import load_brain
70
139
  from brain_parser.graph_builder import build_graph, get_impact, calculate_risk
@@ -106,10 +175,13 @@ def cmd_impact(filepath=None, staged=False, block=False):
106
175
  print("BRAIN WARNING: HIGH RISK commit detected.")
107
176
  print("This change affects critical files.")
108
177
  print("Production incidents have originated from files like these.")
178
+
179
+ print("\nAsking Brain to explain the risk...\n")
180
+ explanation = _explain_risk(staged_files, G, risk, brain)
181
+ print(explanation)
182
+
109
183
  print("\nType 'yes' to commit anyway, anything else to abort: ", end='', flush=True)
110
184
  try:
111
- # Windows: CON is the console device
112
- # Unix: /dev/tty is the terminal
113
185
  import platform
114
186
  if platform.system() == 'Windows':
115
187
  with open('CON', 'r') as tty:
@@ -134,30 +206,6 @@ def cmd_impact(filepath=None, staged=False, block=False):
134
206
  return
135
207
 
136
208
  _print_impact(G, risk, filepath, clean)
137
-
138
- def _print_impact(G, risk, filepath, clean):
139
- from brain_parser.graph_builder import get_impact
140
-
141
- result = get_impact(G, filepath)
142
-
143
- if not result:
144
- print(f"File not found in brain: {filepath}")
145
- return None
146
-
147
- file_risk = risk.get(result['target'], 'LOW')
148
-
149
- print(f"\n Change Impact: {filepath}")
150
- print(f" Risk Level: {file_risk}\n")
151
- print(f"DIRECT IMPACT ({len(result['direct'])} files):")
152
- for f in result['direct']:
153
- print(f" → {clean(f)}")
154
- print(f"\nINDIRECT IMPACT ({len(result['indirect'])} files):")
155
- for f in result['indirect']:
156
- print(f" → {clean(f)}")
157
- print(f"\nTotal files affected: {result['total_affected']}\n")
158
-
159
- return {'risk': file_risk, 'total': result['total_affected']}
160
-
161
209
  def install_hook(repo_path="."):
162
210
  hook_dir = os.path.join(repo_path, ".git", "hooks")
163
211
  hook_path = os.path.join(hook_dir, "pre-commit")
@@ -200,7 +248,14 @@ def install_hook(repo_path="."):
200
248
 
201
249
 
202
250
  def cmd_init():
251
+ import os
203
252
  print("Initializing Codebase Brain...\n")
253
+
254
+ # global config — stored once per machine
255
+ config_dir = os.path.expanduser("~/.codebase-brain")
256
+ os.makedirs(config_dir, exist_ok=True)
257
+ config_path = os.path.join(config_dir, "config.env")
258
+
204
259
  print("Choose your LLM provider:")
205
260
  print(" 1. Groq — Free, fast. llama-3.3-70b (recommended to start)")
206
261
  print(" 2. OpenAI — Reliable, trusted by enterprises. gpt-4o-mini")
@@ -237,7 +292,7 @@ def cmd_init():
237
292
  },
238
293
  "5": {
239
294
  "name": "ollama",
240
- "key_prompt": None, # no key needed
295
+ "key_prompt": None,
241
296
  "models": ["llama3.2", "codellama", "deepseek-coder"],
242
297
  "default_model": "llama3.2"
243
298
  }
@@ -248,14 +303,12 @@ def cmd_init():
248
303
 
249
304
  provider = providers[choice]
250
305
 
251
- # get API key
252
306
  if provider["key_prompt"]:
253
307
  api_key = input(provider["key_prompt"]).strip()
254
308
  else:
255
309
  api_key = "ollama"
256
310
  print("Ollama selected — make sure Ollama is running at http://localhost:11434")
257
311
 
258
- # pick model
259
312
  print(f"\nAvailable models for {provider['name']}:")
260
313
  for i, m in enumerate(provider["models"], 1):
261
314
  default_tag = " (default)" if m == provider["default_model"] else ""
@@ -271,16 +324,16 @@ def cmd_init():
271
324
  else:
272
325
  model = provider["default_model"]
273
326
 
274
- with open(".env", "w") as f:
327
+ # save globally one time setup
328
+ with open(config_path, "w") as f:
275
329
  f.write(f"LLM_PROVIDER={provider['name']}\n")
276
330
  f.write(f"LLM_API_KEY={api_key}\n")
277
331
  f.write(f"LLM_MODEL={model}\n")
278
- # backwards compatibility
279
332
  f.write(f"GROQ_API_KEY={api_key}\n")
280
333
  f.write(f"GROQ_MODEL={model}\n")
281
334
 
282
- print(f"\nBrain initialized with {provider['name']} {model}")
283
- print("Run 'brain start' to begin.\n")
335
+ print(f"\nBrain initialized globally {provider['name']} / {model}")
336
+ print("Run 'brain start' in any project to begin.\n")
284
337
 
285
338
  def cmd_rootcause(error_text=None):
286
339
  from brain_parser.root_cause import find_root_cause
@@ -297,6 +350,41 @@ def cmd_rootcause(error_text=None):
297
350
  error_text = "\n".join(lines)
298
351
 
299
352
  print(find_root_cause(error_text))
353
+ def cmd_uninstall():
354
+ import shutil
355
+
356
+ print("Uninstalling Codebase Brain...\n")
357
+
358
+ # remove global config
359
+ config_dir = os.path.expanduser("~/.codebase-brain")
360
+ if os.path.exists(config_dir):
361
+ shutil.rmtree(config_dir)
362
+ print("Removed global config.")
363
+
364
+ # remove local brain.json
365
+ brain_json = os.path.join(os.getcwd(), 'brain.json')
366
+ if os.path.exists(brain_json):
367
+ os.remove(brain_json)
368
+ print("Removed brain.json.")
369
+
370
+ # remove local brain_map.html
371
+ brain_map = os.path.join(os.getcwd(), 'brain_map.html')
372
+ if os.path.exists(brain_map):
373
+ os.remove(brain_map)
374
+ print("Removed brain_map.html.")
375
+
376
+ # remove git hook
377
+ hook_path = os.path.join(os.getcwd(), '.git', 'hooks', 'pre-commit')
378
+ if os.path.exists(hook_path):
379
+ # only remove if it's a Brain hook
380
+ with open(hook_path, 'r') as f:
381
+ content = f.read()
382
+ if 'Codebase Brain' in content:
383
+ os.remove(hook_path)
384
+ print("Removed git hook.")
385
+
386
+ print("\nBrain removed from this project.")
387
+ print("To fully uninstall: pip uninstall codebase-brain")
300
388
 
301
389
 
302
390
  def main():
@@ -307,9 +395,10 @@ def main():
307
395
  parser.add_argument("--path", default=".", help="Path to codebase")
308
396
  parser.add_argument("--file", default="", help="File to analyze impact")
309
397
  parser.add_argument("--staged", action="store_true", help="Analyze all staged files")
310
- parser.add_argument("command", choices=["start", "init", "impact", "install-hook", "rootcause"])
398
+ parser.add_argument("command", choices=["start", "init", "impact", "install-hook", "rootcause", "uninstall"])
311
399
  parser.add_argument("--error", default="", help="Stacktrace to analyze")
312
400
  parser.add_argument("--block", action="store_true", help="Block HIGH risk commits")
401
+
313
402
  # ← new
314
403
  args = parser.parse_args()
315
404
 
@@ -324,8 +413,12 @@ def main():
324
413
  cmd_impact(filepath=args.file or None, staged=args.staged, block=args.block)
325
414
  elif args.command == "install-hook":
326
415
  install_hook()
416
+ elif args.command == "uninstall":
417
+ cmd_uninstall()
418
+
327
419
 
328
420
 
329
421
 
330
422
  if __name__ == "__main__":
331
- main()
423
+ main()
424
+
@@ -9,32 +9,33 @@ def detect_circular_dependencies(brain, temp_path=None):
9
9
  G = build_graph(brain)
10
10
  bugs = []
11
11
 
12
- def clean(path):
12
+ def rel(path):
13
13
  p = path.replace('\\', '/')
14
14
  idx = p.find('/temp/')
15
15
  if idx != -1:
16
16
  after_temp = p[idx + 6:]
17
17
  parts = after_temp.split('/', 1)
18
- if len(parts) > 1:
19
- return parts[1]
20
- return parts[0]
21
- # fallback — just filename
22
- return p.split('/')[-1]
18
+ p = parts[1] if len(parts) > 1 else parts[0]
19
+ if '/src/' in p:
20
+ p = p[p.find('/src/') + 1:]
21
+ elif p.startswith('./'):
22
+ p = p[2:]
23
+ return p
23
24
 
24
25
  try:
25
- cycles = list(nx.simple_cycles(G))
26
- for cycle in cycles:
27
- clean_cycle = [clean(f) for f in cycle]
28
- # format as numbered list not one long line
29
- chain = '\n '.join([f"{i+1}. {f}" for i, f in enumerate(clean_cycle)])
30
- chain += f"\n → back to {clean_cycle[0]}"
26
+ sccs = [scc for scc in nx.strongly_connected_components(G) if len(scc) > 1]
27
+
28
+ for scc in sccs:
29
+ clean_files = sorted(list(set([rel(f).split('/')[-1] for f in scc])))
30
+ chain = '\n '.join([f"{i+1}. {f}" for i, f in enumerate(clean_files)])
31
31
  bugs.append({
32
32
  'type': 'circular_dependency',
33
33
  'severity': 'HIGH',
34
- 'files': clean_cycle,
35
- 'message': f"Circular dependency ({len(cycle)} files):\n {chain}",
34
+ 'files': clean_files,
35
+ 'message': f"Circular dependency group ({len(clean_files)} files):\n {chain}",
36
36
  'fix': 'Extract shared logic into a separate file that both can import from.'
37
37
  })
38
+
38
39
  except Exception as e:
39
40
  print(f"Cycle detection error: {e}")
40
41
 
@@ -53,18 +53,23 @@ def walk_codebase(root_path):
53
53
 
54
54
  return brain
55
55
 
56
- BRAIN_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'brain.json')
56
+ def get_brain_path():
57
+ """Always returns brain.json in the current working directory."""
58
+ return os.path.join(os.getcwd(), 'brain.json')
57
59
 
58
- def save_brain(brain, output_path=BRAIN_PATH):
60
+ def save_brain(brain, output_path=None):
61
+ if output_path is None:
62
+ output_path = get_brain_path()
59
63
  with open(output_path, "w") as f:
60
64
  json.dump(brain, f, indent=4)
61
65
  print(f"Brain saved to {output_path}")
62
66
 
63
- def load_brain(input_path=BRAIN_PATH):
64
- if not os.path.exists(input_path):
65
- print("No brain found. Run walker first.")
67
+ def load_brain(output_path=None):
68
+ if output_path is None:
69
+ output_path = get_brain_path()
70
+ if not os.path.exists(output_path):
66
71
  return None
67
- with open(input_path, "r") as f:
72
+ with open(output_path, 'r') as f:
68
73
  return json.load(f)
69
74
 
70
75
 
@@ -0,0 +1,304 @@
1
+ import networkx as nx
2
+ import matplotlib.pyplot as plt
3
+ from brain_parser.codebase_walker import load_brain
4
+ from pyvis.network import Network
5
+
6
+
7
+ def build_graph(brain):
8
+ G = nx.DiGraph()
9
+
10
+ for filepath, data in brain.items():
11
+ G.add_node(filepath)
12
+
13
+ for imp in data.get('imports', []):
14
+ raw = imp.get('name', '')
15
+
16
+ # extract module from "from x import y" or "import x"
17
+ if raw.startswith('from '):
18
+ module = raw.split('from ')[1].split(' import')[0].strip()
19
+ elif raw.startswith('import '):
20
+ module = raw.replace('import ', '').split(' as ')[0].strip()
21
+ else:
22
+ continue
23
+
24
+ # convert module path to file path format
25
+ # tests.circular_test.file_b → tests/circular_test/file_b
26
+ module_as_path = module.replace('.', '/').lower()
27
+
28
+ for other_file in brain.keys():
29
+ other_normalized = other_file.replace('\\', '/').lower()
30
+ # match whole filename not partial
31
+ filename = other_normalized.split('/')[-1].replace('.py', '').replace('.js', '').replace('.java',
32
+ '').replace(
33
+ '.ts', '')
34
+ if module_as_path == filename or other_normalized.endswith(module_as_path + '.py'):
35
+ G.add_edge(filepath, other_file)
36
+
37
+ return G
38
+
39
+
40
+ def visualize_graph(G):
41
+ risk = calculate_risk(G)
42
+
43
+ color_map = {
44
+ "HIGH": "red",
45
+ "MEDIUM": "orange",
46
+ "LOW": "lightgreen"
47
+ }
48
+
49
+ node_colors = [color_map[risk[node]] for node in G.nodes()]
50
+
51
+ plt.figure(figsize=(10, 7))
52
+ pos = nx.spring_layout(G)
53
+
54
+ nx.draw(G, pos,
55
+ with_labels=True,
56
+ node_color=node_colors,
57
+ node_size=2000,
58
+ font_size=8,
59
+ arrows=True,
60
+ edge_color='gray',
61
+ font_weight='bold'
62
+ )
63
+
64
+ plt.title("Live Codebase Brain - Risk Map")
65
+ plt.savefig("brain_graph.png")
66
+ plt.show()
67
+
68
+
69
+ def calculate_risk(G):
70
+ risk = {}
71
+
72
+ for node in G.nodes():
73
+ in_degree = G.in_degree(node)
74
+
75
+ if in_degree >= 2:
76
+ risk[node] = "HIGH"
77
+ elif in_degree == 1:
78
+ risk[node] = "MEDIUM"
79
+ else:
80
+ risk[node] = "LOW"
81
+
82
+ return risk
83
+
84
+ def visualize_interactive(G):
85
+ from pyvis.network import Network
86
+ import os
87
+
88
+ risk = calculate_risk(G)
89
+
90
+ net = Network(
91
+ height="100vh",
92
+ width="100%",
93
+ bgcolor="#0d0d0d",
94
+ font_color="#ffffff",
95
+ directed=True,
96
+ cdn_resources='in_line'
97
+ )
98
+
99
+ color_map = {
100
+ "HIGH": "#ff3860",
101
+ "MEDIUM": "#ffdd57",
102
+ "LOW": "#23d160"
103
+ }
104
+
105
+ # build node map first — ensures consistent keys for edges
106
+ node_map = {}
107
+ for node in G.nodes():
108
+ node_map[node] = str(node)
109
+ # demo test
110
+ for node, node_id in node_map.items():
111
+ risk_level = risk.get(node, 'LOW')
112
+ color = color_map[risk_level]
113
+ label = node.replace('\\', '/').split('/')[-1]
114
+ size = 18 + (G.in_degree(node) * 5)
115
+
116
+ net.add_node(
117
+ node_id,
118
+ label=label,
119
+ color={
120
+ 'background': color,
121
+ 'border': color,
122
+ 'highlight': {'background': '#ffffff', 'border': color}
123
+ },
124
+ size=size,
125
+ font={'size': 14, 'color': '#ffffff', 'face': 'monospace'},
126
+ borderWidth=2,
127
+ shadow=True,
128
+ title=f"File: {label}\nRisk: {risk_level}\nConnections: {G.in_degree(node)}"
129
+ )
130
+
131
+ for edge in G.edges():
132
+ src = node_map.get(edge[0], str(edge[0]))
133
+ dst = node_map.get(edge[1], str(edge[1]))
134
+ if src in net.get_nodes() and dst in net.get_nodes():
135
+ net.add_edge(
136
+ src, dst,
137
+ color={'color': '#ffffff33'},
138
+ width=1.5,
139
+ arrows={'to': {'enabled': True, 'scaleFactor': 0.6}}
140
+ )
141
+
142
+ net.set_options("""
143
+ {
144
+ "nodes": {
145
+ "borderWidth": 2,
146
+ "shadow": {
147
+ "enabled": true,
148
+ "color": "rgba(0,0,0,0.8)",
149
+ "size": 15,
150
+ "x": 0,
151
+ "y": 0
152
+ }
153
+ },
154
+ "edges": {
155
+ "smooth": {
156
+ "type": "curvedCW",
157
+ "roundness": 0.2
158
+ },
159
+ "shadow": false
160
+ },
161
+ "interaction": {
162
+ "hover": true,
163
+ "navigationButtons": false,
164
+ "hideEdgesOnDrag": true,
165
+ "tooltipDelay": 100
166
+ },
167
+ "physics": {
168
+ "stabilization": {
169
+ "enabled": true,
170
+ "iterations": 200
171
+ },
172
+ "barnesHut": {
173
+ "gravitationalConstant": -12000,
174
+ "centralGravity": 0.1,
175
+ "springLength": 250,
176
+ "springConstant": 0.02,
177
+ "damping": 0.09
178
+ }
179
+ }
180
+ }
181
+ """)
182
+
183
+ output_path = os.path.join(os.getcwd(), 'brain_map.html')
184
+
185
+ html_content = net.generate_html()
186
+ with open(output_path, 'w', encoding='utf-8') as f:
187
+ f.write(html_content)
188
+
189
+ with open(output_path, 'r', encoding='utf-8') as f:
190
+ html = f.read()
191
+
192
+ custom_style = """
193
+ <style>
194
+ * { margin: 0; padding: 0; box-sizing: border-box; }
195
+ body { background: #0d0d0d; font-family: 'Courier New', monospace; }
196
+ #mynetwork {
197
+ background: radial-gradient(ellipse at center, #1a1a2e 0%, #0d0d0d 100%);
198
+ border: none !important;
199
+ }
200
+ .legend {
201
+ position: fixed;
202
+ top: 20px;
203
+ left: 20px;
204
+ background: rgba(13,13,13,0.9);
205
+ border: 1px solid #333;
206
+ border-radius: 12px;
207
+ padding: 16px 20px;
208
+ color: #fff;
209
+ font-family: monospace;
210
+ font-size: 13px;
211
+ z-index: 1000;
212
+ backdrop-filter: blur(10px);
213
+ }
214
+ .legend-title {
215
+ color: #888;
216
+ font-size: 11px;
217
+ letter-spacing: 2px;
218
+ text-transform: uppercase;
219
+ margin-bottom: 12px;
220
+ }
221
+ .legend-item { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
222
+ .dot { width: 12px; height: 12px; border-radius: 50%; box-shadow: 0 0 8px currentColor; }
223
+ .high { background: #ff3860; color: #ff3860; }
224
+ .medium { background: #ffdd57; color: #ffdd57; }
225
+ .low { background: #23d160; color: #23d160; }
226
+ .brain-title {
227
+ position: fixed;
228
+ top: 20px;
229
+ right: 20px;
230
+ color: #444;
231
+ font-family: monospace;
232
+ font-size: 12px;
233
+ letter-spacing: 3px;
234
+ text-transform: uppercase;
235
+ z-index: 1000;
236
+ }
237
+ div.vis-tooltip {
238
+ background: #1a1a2e !important;
239
+ border: 1px solid #ff3860 !important;
240
+ border-radius: 8px !important;
241
+ color: #fff !important;
242
+ font-family: monospace !important;
243
+ font-size: 12px !important;
244
+ padding: 10px 14px !important;
245
+ }
246
+ </style>
247
+ <div class="legend">
248
+ <div class="legend-title">Risk Level</div>
249
+ <div class="legend-item"><div class="dot high"></div><span>High Risk</span></div>
250
+ <div class="legend-item"><div class="dot medium"></div><span>Medium Risk</span></div>
251
+ <div class="legend-item"><div class="dot low"></div><span>Low Risk</span></div>
252
+ </div>
253
+ <div class="brain-title">Codebase Brain</div>
254
+ """
255
+
256
+ html = html.replace('<body>', '<body>' + custom_style)
257
+
258
+ with open(output_path, 'w', encoding='utf-8') as f:
259
+ f.write(html)
260
+
261
+ print(f"Nodes in graph: {len(G.nodes())}")
262
+ print(f"Edges in graph: {len(G.edges())}")
263
+ print(f"Graph saved to {output_path}")
264
+
265
+
266
+ def get_impact(G, filepath):
267
+ import networkx as nx
268
+ import os
269
+
270
+ # Normalize incoming path
271
+ target_norm = os.path.abspath(filepath).replace('\\', '/')
272
+ target_rel = filepath.replace('\\', '/').lstrip('./')
273
+
274
+ # Find matching node — handles both absolute and relative keys
275
+ matched = None
276
+
277
+ for node in G.nodes():
278
+ node_norm = os.path.abspath(node).replace('\\', '/')
279
+ if node_norm == target_norm:
280
+ matched = node
281
+ break
282
+
283
+ if not matched:
284
+ return None
285
+
286
+ direct = list(G.predecessors(matched))
287
+ all_affected = nx.ancestors(G, matched)
288
+ indirect = [f for f in all_affected if f not in direct]
289
+
290
+ return {
291
+ 'target': matched,
292
+ 'direct': direct,
293
+ 'indirect': indirect,
294
+ 'total_affected': len(direct) + len(indirect)
295
+ }
296
+
297
+ if __name__ == "__main__":
298
+ brain = load_brain()
299
+ G = build_graph(brain)
300
+ visualize_interactive(G)
301
+
302
+ print("\nRisk Report:")
303
+ for node, level in calculate_risk(G).items():
304
+ print(f" {node.split(chr(92))[-1]}: {level}")
@@ -1,13 +1,31 @@
1
1
  import os
2
- from dotenv import load_dotenv
3
2
 
4
- load_dotenv()
3
+ def get_config():
4
+ """Load from local .env first, then global ~/.codebase-brain/config.env"""
5
+ from dotenv import load_dotenv
6
+
7
+ # try local .env first
8
+ local_env = os.path.join(os.getcwd(), '.env')
9
+ if os.path.exists(local_env):
10
+ load_dotenv(local_env)
11
+ else:
12
+ # fallback to global config
13
+ global_config = os.path.expanduser("~/.codebase-brain/config.env")
14
+ if os.path.exists(global_config):
15
+ load_dotenv(global_config)
16
+
17
+ return {
18
+ 'provider': os.getenv("LLM_PROVIDER", "groq").lower(),
19
+ 'api_key': os.getenv("LLM_API_KEY") or os.getenv("GROQ_API_KEY"),
20
+ 'model': os.getenv("LLM_MODEL") or os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
21
+ }
5
22
 
6
23
 
7
24
  def call_llm(prompt, max_tokens=500):
8
- provider = os.getenv("LLM_PROVIDER", "groq").lower()
9
- api_key = os.getenv("LLM_API_KEY") or os.getenv("GROQ_API_KEY")
10
- model = os.getenv("LLM_MODEL") or os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
25
+ config = get_config()
26
+ provider = config['provider']
27
+ api_key = config['api_key']
28
+ model = config['model']
11
29
 
12
30
  if provider == "groq":
13
31
  return _call_groq(api_key, model, prompt, max_tokens)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-brain
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: An AI brain that lives inside your codebase
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: watchdog
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="codebase-brain",
5
- version="0.1.0",
5
+ version="0.3.0",
6
6
  description="An AI brain that lives inside your codebase",
7
7
  package_dir={"": "."},
8
8
  packages=find_packages(where="."),
@@ -1,190 +0,0 @@
1
- import networkx as nx
2
- import matplotlib.pyplot as plt
3
- from brain_parser.codebase_walker import load_brain
4
- from pyvis.network import Network
5
-
6
-
7
- def build_graph(brain):
8
- G = nx.DiGraph()
9
-
10
- for filepath, data in brain.items():
11
- G.add_node(filepath)
12
-
13
- for imp in data.get('imports', []):
14
- raw = imp.get('name', '')
15
-
16
- # extract module from "from x import y" or "import x"
17
- if raw.startswith('from '):
18
- module = raw.split('from ')[1].split(' import')[0].strip()
19
- elif raw.startswith('import '):
20
- module = raw.replace('import ', '').split(' as ')[0].strip()
21
- else:
22
- continue
23
-
24
- # convert module path to file path format
25
- # tests.circular_test.file_b → tests/circular_test/file_b
26
- module_as_path = module.replace('.', '/').lower()
27
-
28
- for other_file in brain.keys():
29
- other_normalized = other_file.replace('\\', '/').lower()
30
- # match whole filename not partial
31
- filename = other_normalized.split('/')[-1].replace('.py', '').replace('.js', '').replace('.java',
32
- '').replace(
33
- '.ts', '')
34
- if module_as_path == filename or other_normalized.endswith(module_as_path + '.py'):
35
- G.add_edge(filepath, other_file)
36
-
37
- return G
38
-
39
-
40
- def visualize_graph(G):
41
- risk = calculate_risk(G)
42
-
43
- color_map = {
44
- "HIGH": "red",
45
- "MEDIUM": "orange",
46
- "LOW": "lightgreen"
47
- }
48
-
49
- node_colors = [color_map[risk[node]] for node in G.nodes()]
50
-
51
- plt.figure(figsize=(10, 7))
52
- pos = nx.spring_layout(G)
53
-
54
- nx.draw(G, pos,
55
- with_labels=True,
56
- node_color=node_colors,
57
- node_size=2000,
58
- font_size=8,
59
- arrows=True,
60
- edge_color='gray',
61
- font_weight='bold'
62
- )
63
-
64
- plt.title("Live Codebase Brain - Risk Map")
65
- plt.savefig("brain_graph.png")
66
- plt.show()
67
-
68
-
69
- def calculate_risk(G):
70
- risk = {}
71
-
72
- for node in G.nodes():
73
- in_degree = G.in_degree(node)
74
-
75
- if in_degree >= 2:
76
- risk[node] = "HIGH"
77
- elif in_degree == 1:
78
- risk[node] = "MEDIUM"
79
- else:
80
- risk[node] = "LOW"
81
-
82
- return risk
83
-
84
- def visualize_interactive(G):
85
- risk = calculate_risk(G)\
86
-
87
- color_map = {
88
- "HIGH": "#ff4444",
89
- "MEDIUM": "#ffa500",
90
- "LOW": "#44cc44"
91
- }
92
-
93
- net = Network(
94
- height="750px",
95
- width="100%",
96
- bgcolor="#1a1a2e",
97
- font_color="white",
98
- directed=True
99
- )
100
-
101
- for node in G.nodes():
102
- risk_level = risk[node]
103
- color = color_map[risk_level]
104
- label = node.split("\\")[-1].split("/")[-1]
105
-
106
- net.add_node(
107
- node,
108
- label=label,
109
- color=color,
110
- size=20 + (G.in_degree(node) * 3),
111
- title=f"File: {label}\nRisk: {risk_level}\nConnections: {G.in_degree(node)}"
112
- )
113
-
114
- for edge in G.edges():
115
- net.add_edge(edge[0], edge[1])
116
-
117
- net.set_options("""
118
- {
119
- "nodes": {
120
- "borderWidth": 2,
121
- "shadow": true
122
- },
123
- "edges": {
124
- "smooth": {
125
- "type": "dynamic"
126
- },
127
- "shadow": true
128
- },
129
- "interaction": {
130
- "hover": true,
131
- "navigationButtons": true,
132
- "hideEdgesOnDrag": true
133
- },
134
- "physics": {
135
- "stabilization": true,
136
- "barnesHut": {
137
- "gravitationalConstant": -8000,
138
- "springLength": 200,
139
- "springConstant": 0.02
140
- }
141
- }
142
- }
143
- """)
144
- net.save_graph("brain_map.html")
145
- print(f"Nodes in graph: {len(G.nodes())}")
146
- print(f"Edges in graph: {len(G.edges())}")
147
- import webbrowser
148
- webbrowser.open("brain_map.html")
149
- print("Interactive graph saved to brain_map.html")
150
-
151
-
152
- def get_impact(G, filepath):
153
- import networkx as nx
154
- import os
155
-
156
- # Normalize incoming path
157
- target_norm = os.path.abspath(filepath).replace('\\', '/')
158
- target_rel = filepath.replace('\\', '/').lstrip('./')
159
-
160
- # Find matching node — handles both absolute and relative keys
161
- matched = None
162
-
163
- for node in G.nodes():
164
- node_norm = os.path.abspath(node).replace('\\', '/')
165
- if node_norm == target_norm:
166
- matched = node
167
- break
168
-
169
- if not matched:
170
- return None
171
-
172
- direct = list(G.predecessors(matched))
173
- all_affected = nx.ancestors(G, matched)
174
- indirect = [f for f in all_affected if f not in direct]
175
-
176
- return {
177
- 'target': matched,
178
- 'direct': direct,
179
- 'indirect': indirect,
180
- 'total_affected': len(direct) + len(indirect)
181
- }
182
-
183
- if __name__ == "__main__":
184
- brain = load_brain()
185
- G = build_graph(brain)
186
- visualize_interactive(G)
187
-
188
- print("\nRisk Report:")
189
- for node, level in calculate_risk(G).items():
190
- print(f" {node.split(chr(92))[-1]}: {level}")
File without changes
File without changes