srcodex 0.2.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.
Files changed (52) hide show
  1. srcodex/__init__.py +0 -0
  2. srcodex/backend/__init__.py +0 -0
  3. srcodex/backend/chat.py +79 -0
  4. srcodex/backend/main.py +98 -0
  5. srcodex/backend/services/__init__.py +0 -0
  6. srcodex/backend/services/claude_service.py +754 -0
  7. srcodex/backend/services/config_loader.py +113 -0
  8. srcodex/backend/services/file_access_tools.py +279 -0
  9. srcodex/backend/services/file_tree.py +480 -0
  10. srcodex/backend/services/graph_tools.py +874 -0
  11. srcodex/backend/services/logger_setup.py +91 -0
  12. srcodex/backend/services/session_manager.py +81 -0
  13. srcodex/backend/services/status_tracker.py +91 -0
  14. srcodex/cli.py +255 -0
  15. srcodex/core/__init__.py +0 -0
  16. srcodex/core/config.py +113 -0
  17. srcodex/core/logger.py +23 -0
  18. srcodex/indexer/__init__.py +0 -0
  19. srcodex/indexer/cscope_client.py +183 -0
  20. srcodex/indexer/ctags_compat.py +223 -0
  21. srcodex/indexer/ctags_parser.py +456 -0
  22. srcodex/indexer/explorer.py +135 -0
  23. srcodex/indexer/field_access_analyzer.py +436 -0
  24. srcodex/indexer/indexer.py +664 -0
  25. srcodex/indexer/reference_ingestor.py +293 -0
  26. srcodex/indexer/reference_resolver.py +544 -0
  27. srcodex/tui/__init__.py +0 -0
  28. srcodex/tui/app.py +103 -0
  29. srcodex/tui/app.tcss +24 -0
  30. srcodex/tui/components/__init__.py +0 -0
  31. srcodex/tui/components/bars/__init__.py +0 -0
  32. srcodex/tui/components/bars/chat_header.py +48 -0
  33. srcodex/tui/components/bars/code_tab_bar.py +157 -0
  34. srcodex/tui/components/bars/footer_bar.py +128 -0
  35. srcodex/tui/components/bars/left_tab.py +54 -0
  36. srcodex/tui/components/logger.py +57 -0
  37. srcodex/tui/components/panels/__init__.py +0 -0
  38. srcodex/tui/components/panels/chat_panel.py +523 -0
  39. srcodex/tui/components/panels/code_panel.py +229 -0
  40. srcodex/tui/components/panels/side_panel.py +128 -0
  41. srcodex/tui/components/views/__init__.py +0 -0
  42. srcodex/tui/components/views/explorer_view.py +20 -0
  43. srcodex/tui/components/views/search_view.py +148 -0
  44. srcodex/tui/components/widgets/__init__.py +0 -0
  45. srcodex/tui/components/widgets/file_browser.py +16 -0
  46. srcodex/tui/components/widgets/find_box.py +85 -0
  47. srcodex-0.2.0.dist-info/METADATA +170 -0
  48. srcodex-0.2.0.dist-info/RECORD +52 -0
  49. srcodex-0.2.0.dist-info/WHEEL +5 -0
  50. srcodex-0.2.0.dist-info/entry_points.txt +2 -0
  51. srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. srcodex-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,91 @@
1
+ """
2
+ Logging configuration for srcodex
3
+ Sets up file logging to .srcodex/.debug/backend.log
4
+ """
5
+ import logging
6
+ from pathlib import Path
7
+ from .config_loader import get_config
8
+
9
+
10
+ class ColoredFormatter(logging.Formatter):
11
+ """Custom formatter with ANSI colors"""
12
+
13
+ COLORS = {
14
+ 'DEBUG': '\033[36m', # Cyan
15
+ 'INFO': '\033[37m', # White
16
+ 'WARNING': '\033[33m', # Yellow (entire line)
17
+ 'ERROR': '\033[31m', # Red (entire line)
18
+ 'CRITICAL': '\033[91m', # Bright Red (entire line)
19
+ }
20
+ RESET = '\033[0m'
21
+ BOLD = '\033[1m'
22
+
23
+ def format(self, record):
24
+ # Format the message first
25
+ formatted = super().format(record)
26
+
27
+ # Color entire line based on level
28
+ if record.levelname in ['WARNING', 'ERROR', 'CRITICAL']:
29
+ color = self.COLORS.get(record.levelname, self.RESET)
30
+ return f"{color}{formatted}{self.RESET}"
31
+
32
+ # Special handling for iteration markers (bold white)
33
+ if 'Iteration' in record.msg:
34
+ return f"{self.BOLD}{formatted}{self.RESET}"
35
+
36
+ # Tool calls in magenta
37
+ if 'Tool #' in record.msg:
38
+ return f"\033[35m{formatted}{self.RESET}"
39
+
40
+ return formatted
41
+
42
+
43
+ def setup_backend_logging():
44
+ """
45
+ Configure backend logging to write to .srcodex/.debug/backend.log
46
+ Creates the .debug directory if it doesn't exist
47
+ """
48
+ try:
49
+ config = get_config()
50
+ debug_dir = config.project_root / ".srcodex" / ".debug"
51
+ debug_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ log_file = debug_dir / "backend.log"
54
+
55
+ # Get root logger
56
+ root_logger = logging.getLogger()
57
+ root_logger.setLevel(logging.INFO)
58
+
59
+ # Clear any existing handlers to avoid duplicates
60
+ root_logger.handlers.clear()
61
+
62
+ # File handler - NO colors (plain text for file)
63
+ file_handler = logging.FileHandler(log_file, mode='a')
64
+ file_handler.setLevel(logging.INFO)
65
+ file_handler.setFormatter(
66
+ logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
67
+ )
68
+ root_logger.addHandler(file_handler)
69
+
70
+ # Console handler - WITH colors (for terminal output)
71
+ console_handler = logging.StreamHandler()
72
+ console_handler.setLevel(logging.INFO)
73
+ colored_formatter = ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
74
+ console_handler.setFormatter(colored_formatter)
75
+ root_logger.addHandler(console_handler)
76
+
77
+ logger = logging.getLogger(__name__)
78
+ logger.info(f"Backend logging initialized: {log_file}")
79
+
80
+ return log_file
81
+
82
+ except Exception as e:
83
+ # Fallback to console-only logging if config fails
84
+ logging.basicConfig(
85
+ level=logging.INFO,
86
+ format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
87
+ datefmt='%Y-%m-%d %H:%M:%S'
88
+ )
89
+ logger = logging.getLogger(__name__)
90
+ logger.warning(f"Could not set up file logging: {e}")
91
+ return None
@@ -0,0 +1,81 @@
1
+ """
2
+ Conversation Session Manager
3
+ Handles saving and loading conversation history across TUI restarts.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from typing import List, Dict, Optional
9
+
10
+
11
+ class SessionManager:
12
+ """Manages persistent conversation sessions"""
13
+
14
+ def __init__(self, project_root: str):
15
+ self.project_root = Path(project_root)
16
+ self.sessions_dir = self.project_root / ".srcodex" / "conversations"
17
+ self.current_session_file = self.sessions_dir / "latest.json"
18
+
19
+ # Create directories if they don't exist
20
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ def save_session(self, messages: List[Dict], metadata: Optional[Dict] = None):
23
+ """
24
+ Save conversation history to disk
25
+
26
+ Args:
27
+ messages: List of conversation messages [{"role": "user", "content": "..."}]
28
+ metadata: Optional metadata (token counts, timestamps, etc.)
29
+ """
30
+ session_data = {
31
+ "created_at": datetime.now().isoformat(),
32
+ "message_count": len(messages),
33
+ "metadata": metadata or {},
34
+ "messages": messages
35
+ }
36
+
37
+ with open(self.current_session_file, 'w') as f:
38
+ json.dump(session_data, f, indent=2)
39
+
40
+ def load_session(self) -> List[Dict]:
41
+ """
42
+ Load last conversation history from disk
43
+
44
+ Returns:
45
+ List of messages, or empty list if no session exists
46
+ """
47
+ if not self.current_session_file.exists():
48
+ return []
49
+
50
+ try:
51
+ with open(self.current_session_file, 'r') as f:
52
+ session_data = json.load(f)
53
+ return session_data.get("messages", [])
54
+ except (json.JSONDecodeError, FileNotFoundError):
55
+ return []
56
+
57
+ def load_metadata(self) -> Dict:
58
+ """
59
+ Load session metadata from disk
60
+
61
+ Returns:
62
+ Dict of metadata, or empty dict if no session exists
63
+ """
64
+ if not self.current_session_file.exists():
65
+ return {}
66
+
67
+ try:
68
+ with open(self.current_session_file, 'r') as f:
69
+ session_data = json.load(f)
70
+ return session_data.get("metadata", {})
71
+ except (json.JSONDecodeError, FileNotFoundError):
72
+ return {}
73
+
74
+ def clear_session(self):
75
+ """Delete current session file"""
76
+ if self.current_session_file.exists():
77
+ self.current_session_file.unlink()
78
+
79
+ def session_exists(self) -> bool:
80
+ """Check if a saved session exists"""
81
+ return self.current_session_file.exists()
@@ -0,0 +1,91 @@
1
+ """
2
+ Status tracker for Claude query execution
3
+
4
+ Tracks iteration progress, tool calls, and elapsed time for live UI updates.
5
+ """
6
+ import time
7
+ from typing import Optional, Dict, Any
8
+
9
+
10
+ class StatusTracker:
11
+ """Tracks query execution status for live UI updates"""
12
+
13
+ def __init__(self):
14
+ self.query_start_time: Optional[float] = None
15
+ self.current_iteration: int = 0
16
+ self.max_iterations: int = 5
17
+ self.current_status: str = ""
18
+ self.is_active: bool = False
19
+
20
+ def start_query(self):
21
+ """Start tracking a new query"""
22
+ self.query_start_time = time.time()
23
+ self.current_iteration = 0
24
+ self.current_status = ""
25
+ self.is_active = True
26
+
27
+ def end_query(self):
28
+ """Stop tracking the query"""
29
+ self.is_active = False
30
+ self.current_status = ""
31
+
32
+ def start_iteration(self, iteration: int):
33
+ """Start a new iteration"""
34
+ self.current_iteration = iteration
35
+
36
+ def set_tool_status(self, tool_name: str, total_tools: int = 1):
37
+ """
38
+ Update status with current tool being called
39
+
40
+ Args:
41
+ tool_name: Name of the tool (e.g., 'search_symbols')
42
+ total_tools: Total number of tools in this batch
43
+ """
44
+ if total_tools > 1:
45
+ self.current_status = f"Calling {tool_name}() +{total_tools-1} more"
46
+ else:
47
+ self.current_status = f"Calling {tool_name}()"
48
+
49
+ def set_processing_status(self, total_tools: int):
50
+ """Update status to show processing multiple tools"""
51
+ self.current_status = f"Processing {total_tools} tools..."
52
+
53
+ def set_preparing_answer(self):
54
+ """Update status to show Claude is preparing the answer"""
55
+ self.current_status = "Preparing answer..."
56
+
57
+ def set_complete(self):
58
+ """Mark query as complete"""
59
+ self.current_status = "Complete"
60
+
61
+ def get_elapsed_seconds(self) -> int:
62
+ """Get elapsed time in whole seconds"""
63
+ if not self.query_start_time:
64
+ return 0
65
+ return int(time.time() - self.query_start_time)
66
+
67
+ def get_status_message(self) -> Dict[str, Any]:
68
+ """
69
+ Get current status as a dict for streaming to frontend
70
+
71
+ Returns:
72
+ {"type": "status", "content": "Calling search_symbols() +34 more", "elapsed": 5}
73
+ """
74
+ return {
75
+ "type": "status",
76
+ "content": self.current_status,
77
+ "elapsed": self.get_elapsed_seconds()
78
+ }
79
+
80
+ def format_status_line(self) -> str:
81
+ """
82
+ Format status line for display
83
+
84
+ Returns:
85
+ "Calling search_symbols() +34 more | 5s"
86
+ """
87
+ if not self.is_active or not self.current_status:
88
+ return ""
89
+
90
+ elapsed = self.get_elapsed_seconds()
91
+ return f"{self.current_status} | {elapsed}s"
srcodex/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ srcodex CLI - Semantic code explorer with AI-powered analysis
4
+ """
5
+ import click
6
+ import sys
7
+ import os
8
+ import json
9
+ import time
10
+ import subprocess
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+
14
+ from srcodex.tui.app import SrcodexApp
15
+ from srcodex.core.config import get_config
16
+ from srcodex.indexer.indexer import Indexer
17
+ from srcodex.indexer.field_access_analyzer import FieldAccessAnalyzer
18
+ from srcodex.indexer.reference_ingestor import ReferenceIngestor
19
+ from srcodex.indexer.reference_resolver import ReferenceResolver
20
+
21
+ @click.command()
22
+ @click.argument('path', default='.', type=click.Path(exists=True))
23
+ @click.option('--reindex', is_flag=True, help='Force re-indexing')
24
+ @click.option('--debug', is_flag=True, help='Enable debug logging')
25
+ def main(path, reindex, debug):
26
+ """
27
+ Launch srcodex TUI
28
+
29
+ EXAMPLES:
30
+ srcodex # Index current directory and launch
31
+ srcodex /path/to/code # Index specific directory
32
+ srcodex --reindex # Force re-index and launch
33
+ """
34
+
35
+ click.echo("srcodex v0.1.0 - Semantic code explorer")
36
+ click.echo()
37
+
38
+ project_path = Path(path).resolve()
39
+ srcodex_dir = project_path / ".srcodex"
40
+
41
+ # Check if .srcodex exists
42
+ if not srcodex_dir.exists() or reindex:
43
+ if not reindex:
44
+ # Prompt user
45
+ if not click.confirm(f"No .srcodex/ found in {project_path}\nIndex this directory?"):
46
+ click.echo("Cancelled.")
47
+ return
48
+
49
+ click.echo(f"\nIndexing {project_path}...")
50
+ click.echo("This may take a few minutes for large codebases...\n")
51
+
52
+ # Run indexer
53
+ try:
54
+ run_indexer(project_path, debug)
55
+ click.echo("\nIndexing complete!")
56
+ except Exception as e:
57
+ click.echo(f"\nError during indexing: {e}", err=True)
58
+ sys.exit(1)
59
+
60
+ # Check for API key
61
+ if not check_api_key():
62
+ click.echo("\nError: No API key found!", err=True)
63
+ click.echo("Please set either:")
64
+ click.echo(" - ANTHROPIC_API_KEY (for public API)")
65
+ click.echo(" - AMD_LLM_API_KEY (for enterprise gateway)")
66
+ click.echo("\nSee .env.example for configuration details")
67
+ sys.exit(1)
68
+
69
+ # Start backend server
70
+ click.echo("\nStarting backend server...")
71
+ backend_process = start_backend(project_path, debug)
72
+
73
+ try:
74
+ # Launch TUI (blocking)
75
+ click.echo("Launching TUI...\n")
76
+ launch_tui(project_path)
77
+ except KeyboardInterrupt:
78
+ click.echo("\n\nShutting down...")
79
+ finally:
80
+ # Cleanup
81
+ if backend_process:
82
+ backend_process.terminate()
83
+ backend_process.wait()
84
+
85
+
86
+ def run_indexer(project_path: Path, debug: bool = False):
87
+ """Run indexer and generate .srcodex/"""
88
+ # Create .srcodex/ directory structure
89
+ srcodex_dir = project_path / ".srcodex"
90
+ data_dir = srcodex_dir / "data"
91
+ data_dir.mkdir(parents=True, exist_ok=True)
92
+
93
+ # Database path
94
+ project_name = project_path.name
95
+ db_path = data_dir / f"{project_name}.db"
96
+
97
+ indexer = Indexer(str(db_path), verbose=debug)
98
+
99
+ try:
100
+ # Connect to database
101
+ indexer.connect_db()
102
+
103
+ # Track timing
104
+ start_time = time.time()
105
+
106
+ # Stage 1: Index symbols (always force-clear for new indexing)
107
+ click.echo("Stage 1: Extracting symbols with CTags...")
108
+ indexer.index_directory(str(project_path), extensions=['.c', '.h', '.cpp', '.cc', '.py'], force_clear=True)
109
+
110
+ # Stage 1.5: Field access analysis
111
+ click.echo("Stage 1.5: Analyzing field access patterns...")
112
+ field_analyzer = FieldAccessAnalyzer(indexer.conn, str(project_path))
113
+ field_analyzer.analyze_all_functions_parallel(clear_existing=True)
114
+
115
+ # Stage 2-4: Build cscope and resolve references
116
+ click.echo("Stage 2: Building cscope database...")
117
+ cscope_dir = data_dir / "cscope"
118
+ cscope_dir.mkdir(exist_ok=True)
119
+ indexer.build_cscope_database(str(cscope_dir))
120
+
121
+ click.echo("Stage 3: Ingesting references...")
122
+ ingestor = ReferenceIngestor(indexer.conn, str(project_path), cscope_dir)
123
+ ingestor.ingest_callees(clear_existing=True)
124
+ ingestor.ingest_callers(clear_existing=True)
125
+ ingestor.ingest_includes(clear_existing=True)
126
+
127
+ click.echo("Stage 4: Resolving semantic edges...")
128
+
129
+ resolver = ReferenceResolver(indexer.conn)
130
+ resolver.resolve_callees(clear_existing=True)
131
+ resolver.resolve_includes(clear_existing=True)
132
+
133
+ # Collect statistics
134
+ cursor = indexer.conn.cursor()
135
+
136
+ files_count = cursor.execute("SELECT COUNT(*) FROM files").fetchone()[0]
137
+ symbols_count = cursor.execute("SELECT COUNT(*) FROM symbols").fetchone()[0]
138
+ calls_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'CALLS'").fetchone()[0]
139
+ includes_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'INCLUDES'").fetchone()[0]
140
+ accesses_count = cursor.execute("SELECT COUNT(*) FROM symbol_edges WHERE edge_type = 'ACCESSES'").fetchone()[0]
141
+ avg_lines = 500 # Estimate: average C/Python file size
142
+
143
+ duration = time.time() - start_time
144
+
145
+ # Generate metadata.json
146
+ metadata = {
147
+ "project": {
148
+ "name": project_name,
149
+ "source_root": str(project_path),
150
+ "indexed_at": datetime.now().isoformat(),
151
+ "indexer_version": "0.1.0"
152
+ },
153
+ "stats": {
154
+ "files_indexed": files_count,
155
+ "total_symbols": symbols_count,
156
+ "avg_lines_per_file": int(avg_lines),
157
+ "edges": {
158
+ "calls": calls_count,
159
+ "includes": includes_count,
160
+ "accesses": accesses_count
161
+ },
162
+ "indexing_duration_seconds": round(duration, 2)
163
+ },
164
+ "paths": {
165
+ "source_root": ".",
166
+ "database": f".srcodex/data/{project_name}.db"
167
+ }
168
+ }
169
+
170
+ metadata_path = srcodex_dir / "metadata.json"
171
+ with open(metadata_path, 'w') as f:
172
+ json.dump(metadata, f, indent=2)
173
+
174
+ # Summary
175
+ click.echo(f"\n✓ Indexed {files_count:,} files, {symbols_count:,} symbols")
176
+ click.echo(f"✓ Edges: {calls_count:,} CALLS, {includes_count:,} INCLUDES, {accesses_count:,} ACCESSES")
177
+ click.echo(f"✓ Generated .srcodex/metadata.json")
178
+ click.echo(f"✓ Duration: {duration:.1f} seconds")
179
+
180
+ finally:
181
+ indexer.close_db()
182
+
183
+
184
+ def start_backend(project_path: Path, debug: bool = False):
185
+ """Start FastAPI backend in background subprocess"""
186
+
187
+ # Set environment variable for project root
188
+ env = os.environ.copy()
189
+ env['SRCODEX_PROJECT_ROOT'] = str(project_path)
190
+
191
+ # Start uvicorn in background
192
+ stdout = None if debug else subprocess.DEVNULL
193
+ stderr = None if debug else subprocess.DEVNULL
194
+
195
+ proc = subprocess.Popen(
196
+ [sys.executable, '-m', 'uvicorn', 'srcodex.backend.chat:app', '--port', '8000'],
197
+ env=env,
198
+ stdout=stdout,
199
+ stderr=stderr,
200
+ cwd=str(project_path)
201
+ )
202
+
203
+ time.sleep(2)
204
+ return proc
205
+
206
+
207
+ def launch_tui(project_path: Path):
208
+ """Launch Textual TUI"""
209
+ os.chdir(project_path)
210
+ app = SrcodexApp()
211
+ app.run()
212
+
213
+
214
+ def check_api_key() -> bool:
215
+ """Check if any API key is configured"""
216
+ amd_key = os.getenv("AMD_LLM_API_KEY")
217
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY")
218
+
219
+ return (amd_key and amd_key != "dummy") or bool(anthropic_key)
220
+
221
+
222
+ @click.command()
223
+ @click.argument('path', type=click.Path(exists=True))
224
+ def index(path):
225
+ """Index a codebase without launching TUI"""
226
+ project_path = Path(path).resolve()
227
+ click.echo(f"Indexing {project_path}...")
228
+ run_indexer(project_path)
229
+ click.echo("Complete!")
230
+
231
+
232
+ @click.command()
233
+ def info():
234
+ """Show project stats from .srcodex/metadata.json"""
235
+ try:
236
+ config = get_config()
237
+ stats = config.stats
238
+
239
+ click.echo(f"\nProject: {config.project_name}")
240
+ click.echo(f"Source root: {config.source_root}")
241
+ click.echo(f"Indexed at: {config.indexed_at}")
242
+ click.echo(f"\nStats:")
243
+ click.echo(f" Files: {stats['files_indexed']:,}")
244
+ click.echo(f" Symbols: {stats['total_symbols']:,}")
245
+ click.echo(f" Call edges: {stats['edges']['calls']:,}")
246
+ click.echo(f" Include edges: {stats['edges']['includes']:,}")
247
+ click.echo(f" Field access edges: {stats['edges']['accesses']:,}")
248
+ except FileNotFoundError as e:
249
+ click.echo(f"Error: {e}", err=True)
250
+ click.echo("No .srcodex/ found in current directory")
251
+ sys.exit(1)
252
+
253
+
254
+ if __name__ == '__main__':
255
+ main()
File without changes
srcodex/core/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Configuration Loader - Reads .srcodex/metadata.json
3
+ Provides project paths and stats to all services
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Dict, Any
8
+
9
+
10
+ class ProjectConfig:
11
+ """Loads and provides access to .srcodex/metadata.json"""
12
+
13
+ def __init__(self, project_root: Path = None):
14
+ """
15
+ Initialize config loader
16
+
17
+ Args:
18
+ project_root: Path to project root (defaults to searching upwards from cwd)
19
+ """
20
+ if project_root is None:
21
+ # Search upwards from current directory for .srcodex/
22
+ project_root = self._find_project_root()
23
+
24
+ self.project_root = Path(project_root)
25
+ self.srcodex_dir = self.project_root / ".srcodex"
26
+ self.metadata_file = self.srcodex_dir / "metadata.json"
27
+
28
+ # Load metadata
29
+ if not self.metadata_file.exists():
30
+ raise FileNotFoundError(
31
+ f"No .srcodex/metadata.json found in {self.project_root}\n"
32
+ f"Run indexer first to generate .srcodex/ directory"
33
+ )
34
+
35
+ with open(self.metadata_file, 'r') as f:
36
+ self.metadata = json.load(f)
37
+
38
+ def _find_project_root(self) -> Path:
39
+ """
40
+ Search upwards from current directory for .srcodex/ directory
41
+
42
+ Returns:
43
+ Path to project root containing .srcodex/
44
+
45
+ Raises:
46
+ FileNotFoundError if .srcodex/ not found in any parent directory
47
+ """
48
+ current = Path.cwd()
49
+
50
+ # Search up to 10 levels (prevent infinite loop)
51
+ for _ in range(10):
52
+ srcodex_dir = current / ".srcodex"
53
+ if srcodex_dir.exists() and srcodex_dir.is_dir():
54
+ return current
55
+
56
+ # Move to parent
57
+ parent = current.parent
58
+ if parent == current:
59
+ # Reached filesystem root
60
+ break
61
+ current = parent
62
+
63
+ raise FileNotFoundError(
64
+ f"No .srcodex/ directory found in {Path.cwd()} or any parent directory.\n"
65
+ f"Run indexer first to generate .srcodex/ directory"
66
+ )
67
+
68
+ with open(self.metadata_file, 'r') as f:
69
+ self.metadata = json.load(f)
70
+
71
+ @property
72
+ def project_name(self) -> str:
73
+ """Get project name"""
74
+ return self.metadata["project"]["name"]
75
+
76
+ @property
77
+ def source_root(self) -> Path:
78
+ """Get absolute path to source root"""
79
+ return self.project_root / self.metadata["paths"]["source_root"]
80
+
81
+ @property
82
+ def database_path(self) -> Path:
83
+ """Get absolute path to database"""
84
+ return self.project_root / self.metadata["paths"]["database"]
85
+
86
+ @property
87
+ def stats(self) -> Dict[str, Any]:
88
+ """Get project statistics"""
89
+ return self.metadata["stats"]
90
+
91
+ @property
92
+ def indexed_at(self) -> str:
93
+ """Get indexing timestamp"""
94
+ return self.metadata["project"]["indexed_at"]
95
+
96
+
97
+ # Global config instance (singleton pattern)
98
+ _config = None
99
+
100
+ def get_config(project_root: Path = None) -> ProjectConfig:
101
+ """
102
+ Get global project configuration (singleton)
103
+
104
+ Args:
105
+ project_root: Path to project root (only used on first call)
106
+
107
+ Returns:
108
+ ProjectConfig instance
109
+ """
110
+ global _config
111
+ if _config is None:
112
+ _config = ProjectConfig(project_root)
113
+ return _config
srcodex/core/logger.py ADDED
@@ -0,0 +1,23 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ # Create logs directory
5
+ LOG_DIR = Path(__file__).parent / "logs"
6
+ LOG_DIR.mkdir(exist_ok=True)
7
+
8
+ # Single log file (overwrites on each run)
9
+ log_file = LOG_DIR / "srcodex.log"
10
+
11
+ # Setup logger
12
+ logger = logging.getLogger("srcodex")
13
+ logger.setLevel(logging.DEBUG)
14
+
15
+ # File handler (mode='w' overwrites the file)
16
+ handler = logging.FileHandler(log_file, mode='w')
17
+ handler.setLevel(logging.DEBUG)
18
+
19
+ # Format
20
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
21
+ handler.setFormatter(formatter)
22
+
23
+ logger.addHandler(handler)
File without changes