cortexcode 0.1.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.
cortexcode/indexer.py ADDED
@@ -0,0 +1,1860 @@
1
+ """AST Indexer - Parse code files and extract symbols, calls, and relationships."""
2
+
3
+ import json
4
+ import hashlib
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from tree_sitter import Language, Parser
10
+
11
+ from cortexcode.plugins import plugin_registry
12
+
13
+ import tree_sitter_python
14
+ import tree_sitter_javascript
15
+ import tree_sitter_typescript
16
+ import tree_sitter_go
17
+ import tree_sitter_rust
18
+ import tree_sitter_java
19
+ import tree_sitter_c_sharp
20
+
21
+ try:
22
+ import tree_sitter_kotlin
23
+ _HAS_KOTLIN = True
24
+ except ImportError:
25
+ _HAS_KOTLIN = False
26
+
27
+ try:
28
+ import tree_sitter_swift
29
+ _HAS_SWIFT = True
30
+ except ImportError:
31
+ _HAS_SWIFT = False
32
+
33
+
34
+ LANGUAGE_MAP = {
35
+ ".py": ("python", tree_sitter_python.language),
36
+ ".js": ("javascript", tree_sitter_javascript.language),
37
+ ".jsx": ("javascript", tree_sitter_javascript.language),
38
+ ".ts": ("typescript", tree_sitter_typescript.language_tsx),
39
+ ".tsx": ("typescript", tree_sitter_typescript.language_tsx),
40
+ ".go": ("go", tree_sitter_go.language),
41
+ ".rs": ("rust", tree_sitter_rust.language),
42
+ ".java": ("java", tree_sitter_java.language),
43
+ ".cs": ("csharp", tree_sitter_c_sharp.language),
44
+ }
45
+
46
+ if _HAS_KOTLIN:
47
+ LANGUAGE_MAP[".kt"] = ("kotlin", tree_sitter_kotlin.language)
48
+ LANGUAGE_MAP[".kts"] = ("kotlin", tree_sitter_kotlin.language)
49
+
50
+ if _HAS_SWIFT:
51
+ LANGUAGE_MAP[".swift"] = ("swift", tree_sitter_swift.language)
52
+
53
+ # Dart uses regex-based extraction (no tree-sitter pip package)
54
+ REGEX_LANGUAGES = {
55
+ ".dart": "dart",
56
+ }
57
+
58
+
59
+ class CodeIndexer:
60
+ """Parse source files and extract symbols, calls, and relationships."""
61
+
62
+ SUPPORTED_EXTENSIONS = set(LANGUAGE_MAP.keys()) | set(REGEX_LANGUAGES.keys())
63
+
64
+ @classmethod
65
+ def get_all_extensions(cls) -> set[str]:
66
+ """Get all supported extensions including plugin-registered ones."""
67
+ return cls.SUPPORTED_EXTENSIONS | plugin_registry.registered_extensions
68
+
69
+ def __init__(self):
70
+ self.parsers: dict[str, Parser] = {}
71
+ self.symbols: list[dict[str, Any]] = []
72
+ self.call_graph: dict[str, list[str]] = {}
73
+ self.file_symbols: dict[str, list[dict[str, Any]]] = {}
74
+ self.gitignore_patterns: list[tuple[str, bool]] = []
75
+ self.default_ignore_patterns = {
76
+ "__pycache__", ".git", ".venv", "venv", "node_modules",
77
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", ".cortexcode",
78
+ "dist", "build", "target", ".idea", ".vscode", ".next", ".nuxt",
79
+ ".svelte-kit", "coverage", ".cache", "*.log", ".env.local"
80
+ }
81
+
82
+ def _get_parser(self, ext: str) -> Parser | None:
83
+ """Get or create a parser for the given extension."""
84
+ if ext in self.parsers:
85
+ return self.parsers[ext]
86
+
87
+ if ext not in LANGUAGE_MAP:
88
+ return None
89
+
90
+ try:
91
+ lang_func = LANGUAGE_MAP[ext][1]
92
+ parser = Parser(Language(lang_func()))
93
+ self.parsers[ext] = parser
94
+ return parser
95
+ except Exception as e:
96
+ print(f"Failed to load parser for {ext}: {e}")
97
+ return None
98
+
99
+ def index_directory(self, root_path: Path, incremental: bool = False) -> dict[str, Any]:
100
+ """Index all supported files in a directory.
101
+
102
+ Args:
103
+ root_path: Path to index
104
+ incremental: If True, only re-index changed files based on hash
105
+ """
106
+ root_path = Path(root_path).resolve()
107
+ self.symbols = []
108
+ self.call_graph = {}
109
+ self.file_symbols = {}
110
+ self.parsers = {}
111
+
112
+ self._load_gitignore(root_path)
113
+
114
+ # Load plugins from config
115
+ plugin_config = root_path / ".cortexcode" / "plugins.json"
116
+ plugin_registry.load_from_config(plugin_config)
117
+
118
+ old_hashes = {}
119
+ if incremental:
120
+ index_path = root_path / ".cortexcode" / "index.json"
121
+ if index_path.exists():
122
+ try:
123
+ old_index = json.loads(index_path.read_text(encoding="utf-8"))
124
+ old_hashes = old_index.get("file_hashes", {})
125
+ except (json.JSONDecodeError, OSError):
126
+ pass
127
+
128
+ for ext in self.get_all_extensions():
129
+ for file_path in root_path.rglob(f"*{ext}"):
130
+ if self._should_ignore(file_path, root_path):
131
+ continue
132
+
133
+ if incremental and old_hashes:
134
+ try:
135
+ current_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
136
+ rel_path = str(file_path.relative_to(root_path))
137
+ if old_hashes.get(rel_path) == current_hash:
138
+ old_index_path = root_path / ".cortexcode" / "index.json"
139
+ if old_index_path.exists():
140
+ try:
141
+ old_data = json.loads(old_index_path.read_text(encoding="utf-8"))
142
+ if rel_path in old_data.get("files", {}):
143
+ self.file_symbols[rel_path] = old_data["files"][rel_path]
144
+ for sym in old_data["files"][rel_path].get("symbols", []):
145
+ self.symbols.append(sym)
146
+ name = sym.get("name")
147
+ if name:
148
+ if name not in self.call_graph:
149
+ self.call_graph[name] = []
150
+ self.call_graph[name].extend(sym.get("calls", []))
151
+ continue
152
+ except:
153
+ pass
154
+ except OSError:
155
+ pass
156
+
157
+ self._index_file(file_path, root_path)
158
+
159
+ return self._build_index(root_path)
160
+
161
+ def _load_gitignore(self, root: Path) -> None:
162
+ """Load all .gitignore files from root and subdirectories."""
163
+ self.gitignore_patterns = []
164
+
165
+ for gitignore_path in root.rglob(".gitignore"):
166
+ try:
167
+ gitignore_dir = gitignore_path.parent
168
+ rel_dir = gitignore_dir.relative_to(root) if gitignore_dir != root else Path(".")
169
+
170
+ for line in gitignore_path.read_text(encoding="utf-8").splitlines():
171
+ line = line.strip()
172
+ if not line or line.startswith("#"):
173
+ continue
174
+
175
+ is_negation = line.startswith("!")
176
+ pattern = line[1:].strip() if is_negation else line
177
+
178
+ if pattern:
179
+ full_pattern = str(rel_dir / pattern) if rel_dir != Path(".") else pattern
180
+ self.gitignore_patterns.append((full_pattern, is_negation))
181
+ except (OSError, UnicodeDecodeError):
182
+ continue
183
+
184
+ def _matches_gitignore(self, file_path: Path, root: Path) -> bool:
185
+ """Check if file matches gitignore patterns."""
186
+ try:
187
+ rel_path = file_path.relative_to(root)
188
+ rel_str = str(rel_path)
189
+ parts = rel_path.parts
190
+
191
+ for pattern, is_negation in self.gitignore_patterns:
192
+ if self._match_pattern(pattern, parts, rel_str):
193
+ return not is_negation
194
+
195
+ return False
196
+ except ValueError:
197
+ return True
198
+
199
+ def _match_pattern(self, pattern: str, parts: tuple, rel_str: str) -> bool:
200
+ """Match a single gitignore pattern."""
201
+ pattern = pattern.rstrip("/")
202
+
203
+ if "/" in pattern:
204
+ pattern_parts = pattern.split("/")
205
+ if pattern.startswith("/"):
206
+ pattern_parts[0] = pattern_parts[0][1:]
207
+ if parts[:len(pattern_parts)] == tuple(pattern_parts):
208
+ return True
209
+ else:
210
+ for i in range(len(parts) - len(pattern_parts) + 1):
211
+ if parts[i:i+len(pattern_parts)] == tuple(pattern_parts):
212
+ return True
213
+ else:
214
+ for part in parts:
215
+ if part == pattern or (pattern.startswith("*") and part.endswith(pattern[1:])):
216
+ return True
217
+
218
+ if rel_str == pattern:
219
+ return True
220
+
221
+ return False
222
+
223
+ def _should_ignore(self, file_path: Path, root: Path) -> bool:
224
+ """Check if file should be ignored based on gitignore or defaults."""
225
+ path_str = str(file_path)
226
+
227
+ for pattern in self.default_ignore_patterns:
228
+ if pattern in path_str:
229
+ return True
230
+
231
+ return self._matches_gitignore(file_path, root)
232
+
233
+ def _index_file(self, file_path: Path, root: Path) -> None:
234
+ """Index a single file."""
235
+ ext = file_path.suffix.lower()
236
+
237
+ try:
238
+ content = file_path.read_text(encoding="utf-8")
239
+ except (UnicodeDecodeError, OSError):
240
+ return
241
+
242
+ rel_path = str(file_path.relative_to(root))
243
+
244
+ # Plugin-based extraction (custom framework plugins)
245
+ plugin_symbols = plugin_registry.extract_symbols(content, ext, rel_path)
246
+ if plugin_symbols is not None:
247
+ plugin_imports = plugin_registry.extract_imports(content, ext) or []
248
+ file_data = {
249
+ "symbols": plugin_symbols,
250
+ "imports": plugin_imports,
251
+ "exports": [],
252
+ "api_routes": [],
253
+ "entities": [],
254
+ }
255
+ self.file_symbols[rel_path] = file_data
256
+ self.symbols.extend(plugin_symbols)
257
+ for sym in plugin_symbols:
258
+ name = sym.get("name", "")
259
+ if name:
260
+ if name not in self.call_graph:
261
+ self.call_graph[name] = []
262
+ self.call_graph[name].extend(sym.get("calls", []))
263
+ return
264
+
265
+ # Regex-based languages (Dart)
266
+ if ext in REGEX_LANGUAGES:
267
+ symbols = self._extract_regex(content, ext, rel_path)
268
+ imports = self._extract_imports_regex(content, ext)
269
+ file_data = {
270
+ "symbols": symbols,
271
+ "imports": imports,
272
+ "exports": [],
273
+ "api_routes": [],
274
+ "entities": [],
275
+ }
276
+ self.file_symbols[rel_path] = file_data
277
+ self.symbols.extend(symbols)
278
+ for sym in symbols:
279
+ name = sym["name"]
280
+ if name not in self.call_graph:
281
+ self.call_graph[name] = []
282
+ self.call_graph[name].extend(sym.get("calls", []))
283
+ return
284
+
285
+ parser = self._get_parser(ext)
286
+ if not parser:
287
+ return
288
+
289
+ try:
290
+ tree = parser.parse(bytes(content, "utf8"))
291
+ except Exception:
292
+ return
293
+
294
+ symbols = self._extract_symbols(content, tree.root_node, ext)
295
+
296
+ imports = self._extract_imports(content, tree.root_node, ext)
297
+ exports = self._extract_exports(content, tree.root_node, ext)
298
+ api_routes = self._extract_api_routes(content, tree.root_node, ext)
299
+ entities = self._extract_entities(content, tree.root_node, ext)
300
+
301
+ file_data = {
302
+ "symbols": symbols,
303
+ "imports": imports,
304
+ "exports": exports,
305
+ "api_routes": api_routes,
306
+ "entities": entities,
307
+ }
308
+
309
+ self.file_symbols[rel_path] = file_data
310
+ self.symbols.extend(symbols)
311
+
312
+ for sym in symbols:
313
+ name = sym["name"]
314
+ if name not in self.call_graph:
315
+ self.call_graph[name] = []
316
+ self.call_graph[name].extend(sym.get("calls", []))
317
+
318
+ def _extract_symbols(self, source: str, node, ext: str) -> list[dict[str, Any]]:
319
+ """Extract all symbols from AST based on language."""
320
+ symbols = []
321
+
322
+ if ext == ".py":
323
+ self._extract_python(source, node, symbols, None)
324
+ elif ext in (".js", ".jsx"):
325
+ self._extract_javascript(source, node, symbols, None)
326
+ elif ext in (".ts", ".tsx"):
327
+ self._extract_typescript(source, node, symbols, None)
328
+ elif ext == ".go":
329
+ self._extract_go(source, node, symbols, None)
330
+ elif ext == ".rs":
331
+ self._extract_rust(source, node, symbols, None)
332
+ elif ext == ".java":
333
+ self._extract_java(source, node, symbols, None)
334
+ elif ext == ".cs":
335
+ self._extract_csharp(source, node, symbols, None)
336
+ elif ext in (".kt", ".kts"):
337
+ self._extract_kotlin(source, node, symbols, None)
338
+ elif ext == ".swift":
339
+ self._extract_swift(source, node, symbols, None)
340
+
341
+ return symbols
342
+
343
+ def _extract_python(self, source: str, node, symbols: list, current_class: str | None) -> None:
344
+ """Extract Python symbols."""
345
+ self._extract_generic(source, node, symbols, current_class, "function_definition", "class_definition")
346
+
347
+ def _extract_javascript(self, source: str, node, symbols: list, current_class: str | None) -> None:
348
+ """Extract JavaScript/React symbols."""
349
+ self._extract_js_ts_generic(source, node, symbols, current_class, is_ts=False)
350
+
351
+ def _extract_typescript(self, source: str, node, symbols: list, current_class: str | None) -> None:
352
+ """Extract TypeScript/Angular/Next.js symbols."""
353
+ self._extract_js_ts_generic(source, node, symbols, current_class, is_ts=True)
354
+
355
+ def _extract_js_ts_generic(self, source: str, node, symbols: list, current_class: str | None, is_ts: bool) -> None:
356
+ """Extract JavaScript/TypeScript with framework support."""
357
+ node_type = node.type
358
+
359
+ if node_type == "function_declaration":
360
+ name = self._get_node_name(node, source)
361
+ if name:
362
+ params = self._extract_params(node, source, node_type)
363
+ calls = self._extract_calls(node, source)
364
+ doc = self._extract_jsdoc(node, source)
365
+
366
+ sym_type = "method" if current_class else "function"
367
+ framework = self._detect_framework(name, node, source)
368
+
369
+ sym = {
370
+ "name": name,
371
+ "type": sym_type,
372
+ "line": node.start_point.row + 1,
373
+ "params": params,
374
+ "calls": calls,
375
+ "class": current_class,
376
+ "framework": framework,
377
+ }
378
+ if doc:
379
+ sym["doc"] = doc
380
+ symbols.append(sym)
381
+
382
+ elif node_type in ("lexical_declaration", "variable_declaration"):
383
+ for child in node.children:
384
+ if child.type == "variable_declarator":
385
+ name_node = child.child_by_field_name("name")
386
+ value_node = child.child_by_field_name("value")
387
+
388
+ if name_node and value_node:
389
+ name = name_node.text.decode("utf-8")
390
+
391
+ if value_node.type in ("arrow_function", "function_expression", "function"):
392
+ params = self._extract_params(value_node, source, value_node.type)
393
+ calls = self._extract_calls(value_node, source)
394
+ return_type = self._extract_return_type(value_node, source) if is_ts else None
395
+ doc = self._extract_jsdoc(node, source)
396
+
397
+ sym_type = "method" if current_class else "function"
398
+ framework = self._detect_framework(name, value_node, source)
399
+
400
+ sym = {
401
+ "name": name,
402
+ "type": sym_type,
403
+ "line": node.start_point.row + 1,
404
+ "params": params,
405
+ "calls": calls,
406
+ "class": current_class,
407
+ "framework": framework,
408
+ }
409
+ if return_type:
410
+ sym["return_type"] = return_type
411
+ if doc:
412
+ sym["doc"] = doc
413
+ symbols.append(sym)
414
+
415
+ elif value_node.type == "call_expression":
416
+ # const router = express.Router() or similar
417
+ pass
418
+
419
+ elif node_type in ("export_statement", "export_default_declaration"):
420
+ for child in node.children:
421
+ self._extract_js_ts_generic(source, child, symbols, current_class, is_ts)
422
+ return
423
+
424
+ elif node_type in ("class_declaration", "class_expression"):
425
+ name = self._get_node_name(node, source)
426
+ if name:
427
+ methods = []
428
+ class_calls = []
429
+
430
+ for child in node.children:
431
+ if child.type == "class_body":
432
+ for member in child.children:
433
+ if member.type in ("method_definition", "public_field_definition", "field_definition"):
434
+ method_name = self._get_node_name(member, source)
435
+ if method_name:
436
+ params = self._extract_params(member, source, member.type)
437
+ method_calls = self._extract_calls(member, source)
438
+ methods.append({
439
+ "name": method_name,
440
+ "type": "method",
441
+ "line": member.start_point.row + 1,
442
+ "params": params,
443
+ "calls": method_calls,
444
+ })
445
+ class_calls.extend(method_calls)
446
+
447
+ framework = self._detect_class_framework(name, node, source)
448
+
449
+ symbols.append({
450
+ "name": name,
451
+ "type": "class",
452
+ "line": node.start_point.row + 1,
453
+ "methods": methods,
454
+ "calls": list(set(class_calls)),
455
+ "framework": framework,
456
+ })
457
+
458
+ current_class = name
459
+
460
+ elif is_ts and node_type == "interface_declaration":
461
+ name = self._get_node_name(node, source)
462
+ if name:
463
+ members = []
464
+ for child in node.children:
465
+ if child.type == "object_type" or child.type == "interface_body":
466
+ for member in child.children:
467
+ prop_name = self._get_node_name(member, source)
468
+ if prop_name:
469
+ members.append(prop_name)
470
+ symbols.append({
471
+ "name": name,
472
+ "type": "interface",
473
+ "line": node.start_point.row + 1,
474
+ "members": members if members else None,
475
+ "framework": "typescript",
476
+ })
477
+
478
+ elif is_ts and node_type == "type_alias_declaration":
479
+ name = self._get_node_name(node, source)
480
+ if name:
481
+ symbols.append({
482
+ "name": name,
483
+ "type": "type",
484
+ "line": node.start_point.row + 1,
485
+ "framework": "typescript",
486
+ })
487
+
488
+ elif is_ts and node_type == "enum_declaration":
489
+ name = self._get_node_name(node, source)
490
+ if name:
491
+ symbols.append({
492
+ "name": name,
493
+ "type": "enum",
494
+ "line": node.start_point.row + 1,
495
+ "framework": "typescript",
496
+ })
497
+
498
+ for child in node.children:
499
+ self._extract_js_ts_generic(source, child, symbols, current_class, is_ts)
500
+
501
+ def _extract_return_type(self, node, source: str) -> str | None:
502
+ """Extract return type annotation from a function node."""
503
+ type_ann = node.child_by_field_name("return_type")
504
+ if type_ann:
505
+ return type_ann.text.decode("utf-8").lstrip(": ").strip()
506
+ return None
507
+
508
+ def _detect_framework(self, name: str, node, source: str) -> str | None:
509
+ """Detect framework: React, React Native, Expo, Next.js, NestJS, Express, FastAPI, Django, Flask."""
510
+ source_bytes = node.text if hasattr(node, 'text') else b''
511
+ source_str = source_bytes.decode("utf-8", errors="ignore")
512
+
513
+ # React Native specific hooks/APIs
514
+ if any(rn in source_str for rn in ("useNavigation", "useRoute", "useAnimatedStyle", "useSharedValue")):
515
+ return "react-native-hook"
516
+ if any(rn in source_str for rn in ("StyleSheet.create", "Dimensions.get", "PixelRatio")):
517
+ return "react-native-util"
518
+
519
+ # React Native components (import check)
520
+ rn_components = ("View", "Text", "TouchableOpacity", "FlatList", "ScrollView", "SafeAreaView", "StatusBar", "Alert", "Modal")
521
+ if name and name[0].isupper() and any(f"<{c}" in source_str or f"{c}>" in source_str for c in rn_components):
522
+ return "react-native-component"
523
+
524
+ # Expo
525
+ if any(expo in source_str for expo in ("expo-", "usePermissions", "useCameraPermissions", "useAssets", "Notifications.schedule")):
526
+ return "expo"
527
+ if "expo-router" in source_str or "useLocalSearchParams" in source_str or "useGlobalSearchParams" in source_str:
528
+ return "expo-router"
529
+
530
+ # React hooks
531
+ if "useState" in source_str or "useEffect" in source_str or "useContext" in source_str or "useReducer" in source_str or "useMemo" in source_str:
532
+ if name and name[0].isupper():
533
+ return "react-component"
534
+ if name and name.startswith("use"):
535
+ return "react-hook"
536
+ return "react-hook"
537
+
538
+ # Next.js App Router
539
+ if name in ("generateMetadata", "generateStaticParams"):
540
+ return "nextjs-app-router"
541
+ if "'use server'" in source_str or '"use server"' in source_str:
542
+ return "nextjs-server-action"
543
+ if "'use client'" in source_str or '"use client"' in source_str:
544
+ return "nextjs-client"
545
+
546
+ # Next.js Pages Router
547
+ if "getServerSideProps" in name or "getStaticProps" in name or "getStaticPaths" in name:
548
+ return "nextjs-ssg"
549
+ if "getServerSideProps" in source_str or "getStaticProps" in source_str:
550
+ return "nextjs-page"
551
+
552
+ # NestJS
553
+ if "@Get(" in source_str or "@Post(" in source_str or "@Put(" in source_str or "@Delete(" in source_str or "@Patch(" in source_str:
554
+ return "nestjs-controller"
555
+ if "@Injectable" in source_str:
556
+ return "nestjs-service"
557
+ if "@Controller" in source_str and "nestjs" not in source_str.lower():
558
+ return "nestjs-controller"
559
+ if "@Guard" in source_str or "CanActivate" in source_str:
560
+ return "nestjs-guard"
561
+ if "@Pipe" in source_str or "PipeTransform" in source_str:
562
+ return "nestjs-pipe"
563
+
564
+ # Express
565
+ if "app.get(" in source_str or "app.post(" in source_str or "router.get(" in source_str or "router.post(" in source_str:
566
+ return "express-route"
567
+ if "app.use(" in source_str and "router" not in name:
568
+ return "express-middleware"
569
+
570
+ # FastAPI (Python)
571
+ if "@app.get(" in source_str or "@app.post(" in source_str or "@router.get(" in source_str or "@router.post(" in source_str:
572
+ return "fastapi-endpoint"
573
+ if "Depends(" in source_str and ("async def" in source_str or "def " in source_str):
574
+ return "fastapi-dependency"
575
+
576
+ # Django
577
+ if "request.method" in source_str or "HttpResponse" in source_str or "JsonResponse" in source_str:
578
+ return "django-view"
579
+ if "@api_view" in source_str or "APIView" in source_str:
580
+ return "django-rest"
581
+
582
+ # Flask
583
+ if "@app.route(" in source_str or "@blueprint.route(" in source_str:
584
+ return "flask-route"
585
+
586
+ # Remix
587
+ if name in ("loader", "action") and ("json(" in source_str or "redirect(" in source_str):
588
+ return "remix-loader"
589
+
590
+ # React component (PascalCase + return JSX) — must be last React check
591
+ if name and name[0].isupper() and ("return" in source_str or "=>" in source_str):
592
+ if "<" in source_str:
593
+ return "react-component"
594
+
595
+ return None
596
+
597
+ def _detect_class_framework(self, name: str, node, source: str) -> str | None:
598
+ """Detect class-level framework: Angular, React Native, NestJS, etc."""
599
+ source_bytes = node.text if hasattr(node, 'text') else b''
600
+ source_str = source_bytes.decode("utf-8", errors="ignore")
601
+
602
+ # Angular
603
+ if "@Component" in source_str:
604
+ return "angular-component"
605
+ elif "@Injectable" in source_str:
606
+ return "angular-service"
607
+ elif "@NgModule" in source_str:
608
+ return "angular-module"
609
+ elif "@Directive" in source_str:
610
+ return "angular-directive"
611
+ elif "@Pipe" in source_str and "PipeTransform" in source_str:
612
+ return "angular-pipe"
613
+
614
+ # React Native class component
615
+ elif "extends Component" in source_str or "extends PureComponent" in source_str:
616
+ rn_indicators = ("View", "Text", "TouchableOpacity", "FlatList", "StyleSheet")
617
+ if any(ind in source_str for ind in rn_indicators):
618
+ return "react-native-component"
619
+ return "react-class-component"
620
+
621
+ # NestJS
622
+ elif "@Controller" in source_str:
623
+ return "nestjs-controller"
624
+ elif "@Injectable" in source_str and "nestjs" not in source_str.lower():
625
+ return "nestjs-service"
626
+ elif "@Module" in source_str and "imports:" in source_str:
627
+ return "nestjs-module"
628
+
629
+ # Spring Boot
630
+ elif "@Controller" in source_str or "@RestController" in source_str:
631
+ return "spring-boot"
632
+
633
+ return None
634
+
635
+ def _extract_go(self, source: str, node, symbols: list, current_class: str | None) -> None:
636
+ """Extract Go symbols."""
637
+ self._extract_generic(source, node, symbols, current_class, "function_declaration", "method_declaration", "type_declaration")
638
+
639
+ def _extract_rust(self, source: str, node, symbols: list, current_class: str | None) -> None:
640
+ """Extract Rust symbols."""
641
+ self._extract_generic(source, node, symbols, current_class, "function_item", "struct_item", "impl_item", "enum_item")
642
+
643
+ def _extract_java(self, source: str, node, symbols: list, current_class: str | None) -> None:
644
+ """Extract Java symbols with Spring Boot detection."""
645
+ self._extract_java_with_framework(source, node, symbols, current_class)
646
+
647
+ def _extract_java_with_framework(self, source: str, node, symbols: list, current_class: str | None) -> None:
648
+ """Extract Java with Spring Boot framework detection."""
649
+ node_type = node.type
650
+
651
+ if node_type == "method_declaration":
652
+ name = self._get_node_name(node, source)
653
+ if name:
654
+ params = self._extract_params(node, source, node_type)
655
+ calls = self._extract_calls(node, source)
656
+
657
+ symbols.append({
658
+ "name": name,
659
+ "type": "method",
660
+ "line": node.start_point.row + 1,
661
+ "params": params,
662
+ "calls": calls,
663
+ "class": current_class,
664
+ })
665
+
666
+ elif node_type == "class_declaration":
667
+ name = self._get_node_name(node, source)
668
+ if name:
669
+ methods = []
670
+ class_calls = []
671
+
672
+ for child in node.children:
673
+ if child.type == "method_declaration":
674
+ method_name = self._get_node_name(child, source)
675
+ if method_name:
676
+ params = self._extract_params(child, source, child.type)
677
+ method_calls = self._extract_calls(child, source)
678
+ methods.append({
679
+ "name": method_name,
680
+ "type": "method",
681
+ "line": child.start_point.row + 1,
682
+ "params": params,
683
+ "calls": method_calls,
684
+ })
685
+ class_calls.extend(method_calls)
686
+
687
+ framework = self._detect_java_framework(name, node, source)
688
+
689
+ symbols.append({
690
+ "name": name,
691
+ "type": "class",
692
+ "line": node.start_point.row + 1,
693
+ "methods": methods,
694
+ "calls": list(set(class_calls)),
695
+ "framework": framework,
696
+ })
697
+
698
+ current_class = name
699
+
700
+ elif node_type == "interface_declaration":
701
+ name = self._get_node_name(node, source)
702
+ if name:
703
+ symbols.append({
704
+ "name": name,
705
+ "type": "interface",
706
+ "line": node.start_point.row + 1,
707
+ "framework": self._detect_java_framework(name, node, source),
708
+ })
709
+
710
+ for child in node.children:
711
+ self._extract_java_with_framework(source, child, symbols, current_class)
712
+
713
+ def _detect_java_framework(self, name: str, node, source: str) -> str | None:
714
+ """Detect Spring Boot and Android framework patterns."""
715
+ source_bytes = node.text if hasattr(node, 'text') else b''
716
+ source_str = source_bytes.decode("utf-8", errors="ignore")
717
+
718
+ # Android
719
+ if "extends AppCompatActivity" in source_str or "extends Activity" in source_str or "extends FragmentActivity" in source_str:
720
+ return "android-activity"
721
+ if "extends Fragment" in source_str or "extends DialogFragment" in source_str:
722
+ return "android-fragment"
723
+ if "extends ViewModel" in source_str or "extends AndroidViewModel" in source_str:
724
+ return "android-viewmodel"
725
+ if "extends Service" in source_str or "extends IntentService" in source_str:
726
+ return "android-service"
727
+ if "extends BroadcastReceiver" in source_str:
728
+ return "android-receiver"
729
+ if "extends ContentProvider" in source_str:
730
+ return "android-provider"
731
+ if "extends RecyclerView.Adapter" in source_str or "extends ArrayAdapter" in source_str:
732
+ return "android-adapter"
733
+ if "@Entity" in source_str and "@ColumnInfo" in source_str:
734
+ return "android-room"
735
+ if "@Dao" in source_str and ("@Query" in source_str or "@Insert" in source_str):
736
+ return "android-room"
737
+ if "@Database" in source_str and "RoomDatabase" in source_str:
738
+ return "android-room-db"
739
+ if "@HiltAndroidApp" in source_str or "@AndroidEntryPoint" in source_str:
740
+ return "android-hilt"
741
+
742
+ # Spring Boot
743
+ if "@Entity" in source_str or "@Table" in source_str:
744
+ return "spring-entity"
745
+ elif "@Repository" in source_str:
746
+ return "spring-repository"
747
+ elif "@Service" in source_str:
748
+ return "spring-service"
749
+ elif "@Controller" in source_str or "@RestController" in source_str:
750
+ return "spring-controller"
751
+ elif "@Component" in source_str:
752
+ return "spring-component"
753
+ elif "@Configuration" in source_str:
754
+ return "spring-config"
755
+
756
+ return None
757
+
758
+ def _extract_csharp(self, source: str, node, symbols: list, current_class: str | None) -> None:
759
+ """Extract C# with .NET framework detection."""
760
+ self._extract_csharp_with_framework(source, node, symbols, current_class)
761
+
762
+ def _extract_csharp_with_framework(self, source: str, node, symbols: list, current_class: str | None) -> None:
763
+ """Extract C# with .NET framework detection."""
764
+ node_type = node.type
765
+
766
+ if node_type in ("method_declaration", "local_function_statement"):
767
+ name = self._get_node_name(node, source)
768
+ if name:
769
+ params = self._extract_params(node, source, node_type)
770
+ calls = self._extract_calls(node, source)
771
+
772
+ symbols.append({
773
+ "name": name,
774
+ "type": "method",
775
+ "line": node.start_point.row + 1,
776
+ "params": params,
777
+ "calls": calls,
778
+ "class": current_class,
779
+ })
780
+
781
+ elif node_type == "class_declaration":
782
+ name = self._get_node_name(node, source)
783
+ if name:
784
+ methods = []
785
+ class_calls = []
786
+
787
+ for child in node.children:
788
+ if child.type == "method_declaration":
789
+ method_name = self._get_node_name(child, source)
790
+ if method_name:
791
+ params = self._extract_params(child, source, child.type)
792
+ method_calls = self._extract_calls(child, source)
793
+ methods.append({
794
+ "name": method_name,
795
+ "type": "method",
796
+ "line": child.start_point.row + 1,
797
+ "params": params,
798
+ "calls": method_calls,
799
+ })
800
+ class_calls.extend(method_calls)
801
+
802
+ framework = self._detect_csharp_framework(name, node, source)
803
+
804
+ symbols.append({
805
+ "name": name,
806
+ "type": "class",
807
+ "line": node.start_point.row + 1,
808
+ "methods": methods,
809
+ "calls": list(set(class_calls)),
810
+ "framework": framework,
811
+ })
812
+
813
+ current_class = name
814
+
815
+ elif node_type == "interface_declaration":
816
+ name = self._get_node_name(node, source)
817
+ if name:
818
+ symbols.append({
819
+ "name": name,
820
+ "type": "interface",
821
+ "line": node.start_point.row + 1,
822
+ })
823
+
824
+ for child in node.children:
825
+ self._extract_csharp_with_framework(source, child, symbols, current_class)
826
+
827
+ def _detect_csharp_framework(self, name: str, node, source: str) -> str | None:
828
+ """Detect .NET framework patterns."""
829
+ source_bytes = node.text if hasattr(node, 'text') else b''
830
+ source_str = source_bytes.decode("utf-8", errors="ignore")
831
+
832
+ if "[ApiController]" in source_str or "ControllerBase" in source_str:
833
+ return "aspnet-controller"
834
+ elif "[Route(" in source_str or "[HttpGet]" in source_str or "[HttpPost]" in source_str:
835
+ return "aspnet-webapi"
836
+ elif "[DataContract]" in source_str or "[DataMember]" in source_str:
837
+ return "wcf-service"
838
+ elif "DbContext" in source_str or "DbSet<" in source_str:
839
+ return "ef-entity"
840
+
841
+ return None
842
+
843
+ def _extract_kotlin(self, source: str, node, symbols: list, current_class: str | None) -> None:
844
+ """Extract Kotlin symbols with Android framework detection."""
845
+ self._extract_kotlin_recursive(source, node, symbols, current_class)
846
+
847
+ def _extract_kotlin_recursive(self, source: str, node, symbols: list, current_class: str | None) -> None:
848
+ """Recursively extract Kotlin symbols."""
849
+ node_type = node.type
850
+
851
+ if node_type == "function_declaration":
852
+ name = self._get_node_name(node, source)
853
+ if name:
854
+ params = self._extract_params(node, source, node_type)
855
+ calls = self._extract_calls(node, source)
856
+ framework = self._detect_kotlin_framework(name, node, source)
857
+ sym_type = "method" if current_class else "function"
858
+ symbols.append({
859
+ "name": name, "type": sym_type,
860
+ "line": node.start_point.row + 1,
861
+ "params": params, "calls": calls,
862
+ "class": current_class, "framework": framework,
863
+ })
864
+
865
+ elif node_type == "class_declaration":
866
+ name = self._get_node_name(node, source)
867
+ if name:
868
+ methods = []
869
+ class_calls = []
870
+ for child in node.children:
871
+ if child.type == "class_body":
872
+ for member in child.children:
873
+ if member.type == "function_declaration":
874
+ m_name = self._get_node_name(member, source)
875
+ if m_name:
876
+ m_params = self._extract_params(member, source, member.type)
877
+ m_calls = self._extract_calls(member, source)
878
+ methods.append({"name": m_name, "type": "method", "line": member.start_point.row + 1, "params": m_params, "calls": m_calls})
879
+ class_calls.extend(m_calls)
880
+
881
+ framework = self._detect_kotlin_framework(name, node, source)
882
+ symbols.append({
883
+ "name": name, "type": "class",
884
+ "line": node.start_point.row + 1,
885
+ "methods": methods, "calls": list(set(class_calls)),
886
+ "framework": framework,
887
+ })
888
+ current_class = name
889
+
890
+ elif node_type == "object_declaration":
891
+ name = self._get_node_name(node, source)
892
+ if name:
893
+ symbols.append({
894
+ "name": name, "type": "class",
895
+ "line": node.start_point.row + 1,
896
+ "framework": self._detect_kotlin_framework(name, node, source),
897
+ })
898
+
899
+ for child in node.children:
900
+ self._extract_kotlin_recursive(source, child, symbols, current_class)
901
+
902
+ def _detect_kotlin_framework(self, name: str, node, source: str) -> str | None:
903
+ """Detect Android/Compose/Ktor framework patterns in Kotlin."""
904
+ src = node.text.decode("utf-8", errors="ignore") if hasattr(node, 'text') else ""
905
+
906
+ # Jetpack Compose
907
+ if "@Composable" in src:
908
+ return "compose-ui"
909
+ if "@Preview" in src:
910
+ return "compose-preview"
911
+ # Android Activity/Fragment/ViewModel
912
+ if ": AppCompatActivity()" in src or ": Activity()" in src or ": ComponentActivity()" in src:
913
+ return "android-activity"
914
+ if ": Fragment()" in src or ": DialogFragment()" in src:
915
+ return "android-fragment"
916
+ if ": ViewModel()" in src or ": AndroidViewModel(" in src:
917
+ return "android-viewmodel"
918
+ if ": Service()" in src or ": IntentService(" in src:
919
+ return "android-service"
920
+ if ": BroadcastReceiver()" in src:
921
+ return "android-receiver"
922
+ if ": ContentProvider()" in src:
923
+ return "android-provider"
924
+ # Ktor
925
+ if "routing {" in src or "get(" in src and "call.respond" in src:
926
+ return "ktor-route"
927
+ # Room database
928
+ if "@Entity" in src or "@Dao" in src:
929
+ return "android-room"
930
+ if "@Database" in src:
931
+ return "android-room-db"
932
+ # Hilt/Dagger
933
+ if "@HiltViewModel" in src or "@HiltAndroidApp" in src:
934
+ return "android-hilt"
935
+ if "@Inject" in src or "@Module" in src:
936
+ return "android-di"
937
+
938
+ return None
939
+
940
+ def _extract_swift(self, source: str, node, symbols: list, current_class: str | None) -> None:
941
+ """Extract Swift symbols with iOS framework detection."""
942
+ self._extract_swift_recursive(source, node, symbols, current_class)
943
+
944
+ def _extract_swift_recursive(self, source: str, node, symbols: list, current_class: str | None) -> None:
945
+ """Recursively extract Swift symbols."""
946
+ node_type = node.type
947
+
948
+ if node_type == "function_declaration":
949
+ name = self._get_node_name(node, source)
950
+ if name:
951
+ params = self._extract_params(node, source, node_type)
952
+ calls = self._extract_calls(node, source)
953
+ framework = self._detect_swift_framework(name, node, source)
954
+ sym_type = "method" if current_class else "function"
955
+ symbols.append({
956
+ "name": name, "type": sym_type,
957
+ "line": node.start_point.row + 1,
958
+ "params": params, "calls": calls,
959
+ "class": current_class, "framework": framework,
960
+ })
961
+
962
+ elif node_type == "class_declaration":
963
+ name = self._get_node_name(node, source)
964
+ if name:
965
+ methods = []
966
+ class_calls = []
967
+ for child in node.children:
968
+ if child.type == "class_body":
969
+ for member in child.children:
970
+ if member.type == "function_declaration":
971
+ m_name = self._get_node_name(member, source)
972
+ if m_name:
973
+ m_params = self._extract_params(member, source, member.type)
974
+ m_calls = self._extract_calls(member, source)
975
+ methods.append({"name": m_name, "type": "method", "line": member.start_point.row + 1, "params": m_params, "calls": m_calls})
976
+ class_calls.extend(m_calls)
977
+
978
+ framework = self._detect_swift_framework(name, node, source)
979
+ symbols.append({
980
+ "name": name, "type": "class",
981
+ "line": node.start_point.row + 1,
982
+ "methods": methods, "calls": list(set(class_calls)),
983
+ "framework": framework,
984
+ })
985
+ current_class = name
986
+
987
+ elif node_type == "protocol_declaration":
988
+ name = self._get_node_name(node, source)
989
+ if name:
990
+ symbols.append({
991
+ "name": name, "type": "interface",
992
+ "line": node.start_point.row + 1,
993
+ "framework": "swift",
994
+ })
995
+
996
+ elif node_type in ("struct_declaration",):
997
+ name = self._get_node_name(node, source)
998
+ if name:
999
+ framework = self._detect_swift_framework(name, node, source)
1000
+ symbols.append({
1001
+ "name": name, "type": "class",
1002
+ "line": node.start_point.row + 1,
1003
+ "framework": framework,
1004
+ })
1005
+
1006
+ elif node_type == "enum_declaration":
1007
+ name = self._get_node_name(node, source)
1008
+ if name:
1009
+ symbols.append({
1010
+ "name": name, "type": "enum",
1011
+ "line": node.start_point.row + 1,
1012
+ })
1013
+
1014
+ for child in node.children:
1015
+ self._extract_swift_recursive(source, child, symbols, current_class)
1016
+
1017
+ def _detect_swift_framework(self, name: str, node, source: str) -> str | None:
1018
+ """Detect iOS/SwiftUI/UIKit framework patterns."""
1019
+ src = node.text.decode("utf-8", errors="ignore") if hasattr(node, 'text') else ""
1020
+
1021
+ # SwiftUI
1022
+ if ": View" in src and "var body:" in src:
1023
+ return "swiftui-view"
1024
+ if "@ObservedObject" in src or "@StateObject" in src or "@EnvironmentObject" in src:
1025
+ return "swiftui-view"
1026
+ if "ObservableObject" in src:
1027
+ return "swiftui-observable"
1028
+ if "@State " in src or "@Binding " in src:
1029
+ return "swiftui-state"
1030
+ if "@main" in src and "App" in name:
1031
+ return "swiftui-app"
1032
+ # UIKit
1033
+ if ": UIViewController" in src:
1034
+ return "uikit-viewcontroller"
1035
+ if ": UITableViewDelegate" in src or ": UITableViewDataSource" in src:
1036
+ return "uikit-tableview"
1037
+ if ": UICollectionViewDelegate" in src:
1038
+ return "uikit-collectionview"
1039
+ if ": UIView" in src and ": UIViewController" not in src:
1040
+ return "uikit-view"
1041
+ # Combine
1042
+ if "AnyPublisher" in src or "@Published" in src or "sink(" in src:
1043
+ return "combine"
1044
+ # Core Data
1045
+ if ": NSManagedObject" in src or "@NSManaged" in src:
1046
+ return "coredata-entity"
1047
+ if "NSPersistentContainer" in src:
1048
+ return "coredata"
1049
+ # Vapor (server-side Swift)
1050
+ if "req.content" in src or "app.get(" in src or "app.post(" in src:
1051
+ return "vapor-route"
1052
+
1053
+ return None
1054
+
1055
+ def _extract_regex(self, source: str, ext: str, rel_path: str) -> list[dict[str, Any]]:
1056
+ """Regex-based symbol extraction for languages without tree-sitter (Dart)."""
1057
+ if ext == ".dart":
1058
+ return self._extract_dart_regex(source, rel_path)
1059
+ return []
1060
+
1061
+ def _extract_dart_regex(self, source: str, rel_path: str) -> list[dict[str, Any]]:
1062
+ """Extract Dart/Flutter symbols using regex."""
1063
+ symbols = []
1064
+ lines = source.split("\n")
1065
+
1066
+ # Class pattern: class Name extends/implements/with ... {
1067
+ class_re = re.compile(r'^\s*(?:abstract\s+)?class\s+(\w+)')
1068
+ # Function pattern: ReturnType name(params) { or =>
1069
+ func_re = re.compile(r'^\s*(?:static\s+)?(?:Future<[^>]*>|void|int|double|String|bool|dynamic|List<[^>]*>|Map<[^>]*>|Widget|State<[^>]*>|\w+)\s+(\w+)\s*\(')
1070
+ # Top-level function: type name(
1071
+ top_func_re = re.compile(r'^(?:Future<[^>]*>|void|int|double|String|bool|dynamic|Widget|State<[^>]*>|\w+)\s+(\w+)\s*\(')
1072
+ # Enum
1073
+ enum_re = re.compile(r'^\s*enum\s+(\w+)')
1074
+ # Mixin
1075
+ mixin_re = re.compile(r'^\s*mixin\s+(\w+)')
1076
+ # Extension
1077
+ ext_re = re.compile(r'^\s*extension\s+(\w+)')
1078
+
1079
+ current_class = None
1080
+
1081
+ for i, line in enumerate(lines):
1082
+ stripped = line.strip()
1083
+
1084
+ # Class
1085
+ m = class_re.match(stripped)
1086
+ if m:
1087
+ name = m.group(1)
1088
+ framework = self._detect_dart_framework(name, stripped, source)
1089
+ symbols.append({
1090
+ "name": name, "type": "class",
1091
+ "line": i + 1,
1092
+ "framework": framework,
1093
+ "calls": self._extract_dart_calls(source, i),
1094
+ })
1095
+ current_class = name
1096
+ continue
1097
+
1098
+ # Enum
1099
+ m = enum_re.match(stripped)
1100
+ if m:
1101
+ symbols.append({"name": m.group(1), "type": "enum", "line": i + 1})
1102
+ continue
1103
+
1104
+ # Mixin
1105
+ m = mixin_re.match(stripped)
1106
+ if m:
1107
+ symbols.append({"name": m.group(1), "type": "class", "line": i + 1, "framework": "dart-mixin"})
1108
+ continue
1109
+
1110
+ # Extension
1111
+ m = ext_re.match(stripped)
1112
+ if m:
1113
+ symbols.append({"name": m.group(1), "type": "class", "line": i + 1, "framework": "dart-extension"})
1114
+ continue
1115
+
1116
+ # Function/method
1117
+ m = func_re.match(stripped)
1118
+ if m:
1119
+ name = m.group(1)
1120
+ if name not in ("if", "while", "for", "switch", "catch", "class", "return"):
1121
+ params = self._extract_dart_params(stripped)
1122
+ sym_type = "method" if current_class and line.startswith(" ") else "function"
1123
+ framework = self._detect_dart_framework(name, stripped, source)
1124
+ symbols.append({
1125
+ "name": name, "type": sym_type,
1126
+ "line": i + 1, "params": params,
1127
+ "class": current_class if sym_type == "method" else None,
1128
+ "framework": framework,
1129
+ "calls": self._extract_dart_calls(source, i),
1130
+ })
1131
+ elif not line.startswith(" "):
1132
+ # Top-level function
1133
+ m = top_func_re.match(stripped)
1134
+ if m:
1135
+ name = m.group(1)
1136
+ if name not in ("if", "while", "for", "switch", "catch", "class", "return", "import"):
1137
+ symbols.append({
1138
+ "name": name, "type": "function",
1139
+ "line": i + 1,
1140
+ "params": self._extract_dart_params(stripped),
1141
+ "calls": self._extract_dart_calls(source, i),
1142
+ "framework": self._detect_dart_framework(name, stripped, source),
1143
+ })
1144
+ current_class = None
1145
+
1146
+ return symbols
1147
+
1148
+ def _extract_dart_params(self, line: str) -> list[str]:
1149
+ """Extract parameters from a Dart function line."""
1150
+ m = re.search(r'\(([^)]*)\)', line)
1151
+ if not m:
1152
+ return []
1153
+ params_str = m.group(1).strip()
1154
+ if not params_str:
1155
+ return []
1156
+ params = []
1157
+ for p in params_str.split(","):
1158
+ p = p.strip().rstrip("?")
1159
+ parts = p.split()
1160
+ if len(parts) >= 2:
1161
+ params.append(parts[-1])
1162
+ elif parts:
1163
+ params.append(parts[0])
1164
+ return params[:8]
1165
+
1166
+ def _extract_dart_calls(self, source: str, line_idx: int) -> list[str]:
1167
+ """Extract function calls near a Dart function definition."""
1168
+ calls = set()
1169
+ lines = source.split("\n")
1170
+ # Scan next 30 lines for calls
1171
+ for i in range(line_idx + 1, min(line_idx + 30, len(lines))):
1172
+ line = lines[i].strip()
1173
+ if line.startswith("class ") or line.startswith("enum "):
1174
+ break
1175
+ for m in re.finditer(r'(\w+)\s*\(', line):
1176
+ name = m.group(1)
1177
+ if name not in ("if", "while", "for", "switch", "catch", "return", "print"):
1178
+ calls.add(name)
1179
+ return list(calls)[:10]
1180
+
1181
+ def _detect_dart_framework(self, name: str, line: str, source: str) -> str | None:
1182
+ """Detect Flutter/Dart framework patterns."""
1183
+ # Flutter widgets
1184
+ if "extends StatelessWidget" in line or "extends StatelessWidget" in source[max(0,source.find(name)-10):source.find(name)+200]:
1185
+ return "flutter-widget"
1186
+ if "extends StatefulWidget" in line:
1187
+ return "flutter-stateful"
1188
+ if "extends State<" in line:
1189
+ return "flutter-state"
1190
+ # Flutter specific
1191
+ if "Widget build(" in line:
1192
+ return "flutter-build"
1193
+ if "@override" in source[max(0,source.find(name)-30):source.find(name)+5]:
1194
+ pass # Could be any override
1195
+ # Check class body for Flutter patterns
1196
+ ctx = source[max(0,source.find(f"class {name}")):source.find(f"class {name}")+500] if f"class {name}" in source else ""
1197
+ if "extends ChangeNotifier" in ctx:
1198
+ return "flutter-provider"
1199
+ if "extends GetxController" in ctx or "extends GetxService" in ctx:
1200
+ return "flutter-getx"
1201
+ if "extends Bloc<" in ctx or "extends Cubit<" in ctx:
1202
+ return "flutter-bloc"
1203
+ if "extends Equatable" in ctx:
1204
+ return "dart-equatable"
1205
+ # Firebase
1206
+ if "FirebaseAuth" in ctx or "FirebaseFirestore" in ctx or "FirebaseMessaging" in ctx:
1207
+ return "flutter-firebase"
1208
+ # Dio/HTTP
1209
+ if "Dio()" in ctx or "http.get" in ctx or "http.post" in ctx:
1210
+ return "dart-http"
1211
+ # Riverpod
1212
+ if "extends StateNotifier" in ctx or "extends AsyncNotifier" in ctx:
1213
+ return "flutter-riverpod"
1214
+ if "extends ConsumerWidget" in ctx or "extends ConsumerStatefulWidget" in ctx:
1215
+ return "flutter-riverpod"
1216
+
1217
+ return None
1218
+
1219
+ def _extract_imports_regex(self, source: str, ext: str) -> list[dict]:
1220
+ """Extract imports using regex for non-tree-sitter languages."""
1221
+ imports = []
1222
+ if ext == ".dart":
1223
+ for m in re.finditer(r"import\s+'([^']+)'", source):
1224
+ module = m.group(1)
1225
+ imports.append({"module": module, "imported": []})
1226
+ return imports
1227
+
1228
+ def _extract_generic(self, source: str, node, symbols: list, current_class: str | None,
1229
+ func_types: str, class_types: str, *extra_types: str) -> None:
1230
+ """Generic symbol extraction for multiple node types."""
1231
+ node_type = node.type
1232
+
1233
+ func_type_set = {func_types} if isinstance(func_types, str) else set(func_types)
1234
+ class_type_set = {class_types} if isinstance(class_types, str) else set(class_types)
1235
+ all_type_set = func_type_set | class_type_set | set(extra_types)
1236
+
1237
+ if node_type in func_type_set:
1238
+ name = self._get_node_name(node, source)
1239
+ if name:
1240
+ params = self._extract_params(node, source, node_type)
1241
+ calls = self._extract_calls(node, source)
1242
+ doc = self._extract_docstring(node, source)
1243
+
1244
+ sym_type = "function"
1245
+ if current_class:
1246
+ sym_type = "method"
1247
+
1248
+ sym = {
1249
+ "name": name,
1250
+ "type": sym_type,
1251
+ "line": node.start_point.row + 1,
1252
+ "params": params,
1253
+ "calls": calls,
1254
+ "class": current_class,
1255
+ }
1256
+ if doc:
1257
+ sym["doc"] = doc
1258
+ symbols.append(sym)
1259
+
1260
+ elif node_type in class_type_set:
1261
+ name = self._get_node_name(node, source)
1262
+ if name:
1263
+ methods = []
1264
+ class_calls = []
1265
+ doc = self._extract_docstring(node, source)
1266
+
1267
+ for child in node.children:
1268
+ if child.type in func_type_set or (extra_types and child.type in extra_types):
1269
+ method_name = self._get_node_name(child, source)
1270
+ if method_name:
1271
+ params = self._extract_params(child, source, child.type)
1272
+ method_calls = self._extract_calls(child, source)
1273
+ method_doc = self._extract_docstring(child, source)
1274
+ m = {
1275
+ "name": method_name,
1276
+ "type": "method",
1277
+ "line": child.start_point.row + 1,
1278
+ "params": params,
1279
+ "calls": method_calls,
1280
+ }
1281
+ if method_doc:
1282
+ m["doc"] = method_doc
1283
+ methods.append(m)
1284
+ class_calls.extend(method_calls)
1285
+
1286
+ sym = {
1287
+ "name": name,
1288
+ "type": "class",
1289
+ "line": node.start_point.row + 1,
1290
+ "methods": methods,
1291
+ "calls": list(set(class_calls)),
1292
+ }
1293
+ if doc:
1294
+ sym["doc"] = doc
1295
+ symbols.append(sym)
1296
+
1297
+ current_class = name
1298
+
1299
+ for child in node.children:
1300
+ self._extract_generic(source, child, symbols, current_class, func_types, class_types, *extra_types)
1301
+
1302
+ def _get_node_name(self, node, source: str) -> str | None:
1303
+ """Get name from definition node."""
1304
+ for child in node.children:
1305
+ if child.type == "identifier":
1306
+ return child.text.decode("utf-8")
1307
+ return None
1308
+
1309
+ def _extract_params(self, func_node, source: str, node_type: str) -> list[str]:
1310
+ """Extract function parameters."""
1311
+ params = []
1312
+
1313
+ for child in func_node.children:
1314
+ if child.type == "parameters":
1315
+ for param in child.children:
1316
+ if param.type == "identifier":
1317
+ params.append(param.text.decode("utf-8"))
1318
+ elif param.type in ("optional_parameter", "rest_parameter", "spread_element",
1319
+ "typed_parameter", "default_parameter", "keyword_argument",
1320
+ "parameter", "receiver"):
1321
+ for p in param.children:
1322
+ if p.type == "identifier":
1323
+ params.append(p.text.decode("utf-8"))
1324
+ break
1325
+
1326
+ return params
1327
+
1328
+ def _extract_calls(self, func_node, source: str) -> list[str]:
1329
+ """Extract function calls within a function body."""
1330
+ calls = []
1331
+ self._find_calls_recursive(func_node, calls)
1332
+ return list(set(calls))
1333
+
1334
+ def _find_calls_recursive(self, node, calls: list) -> None:
1335
+ """Recursively find function calls."""
1336
+ if node.type in ("call", "call_expression"):
1337
+ # Get the function being called
1338
+ func = node.child_by_field_name("function") or (node.children[0] if node.children else None)
1339
+ if func:
1340
+ if func.type == "identifier":
1341
+ calls.append(func.text.decode("utf-8"))
1342
+ elif func.type in ("member_expression", "attribute"):
1343
+ # obj.method() — extract method name
1344
+ prop = func.child_by_field_name("property") or func.child_by_field_name("attribute")
1345
+ if prop:
1346
+ calls.append(prop.text.decode("utf-8"))
1347
+ else:
1348
+ # Fallback: last identifier child
1349
+ for child in reversed(func.children):
1350
+ if child.type == "identifier" or child.type == "property_identifier":
1351
+ calls.append(child.text.decode("utf-8"))
1352
+ break
1353
+ elif func.type == "attribute_expression":
1354
+ attr = func.child_by_field_name("attribute")
1355
+ if attr:
1356
+ calls.append(attr.text.decode("utf-8"))
1357
+
1358
+ for child in node.children:
1359
+ self._find_calls_recursive(child, calls)
1360
+
1361
+ def _extract_imports(self, source: str, node, ext: str) -> list[dict[str, Any]]:
1362
+ """Extract import statements."""
1363
+ imports = []
1364
+
1365
+ if ext in (".js", ".jsx", ".ts", ".tsx"):
1366
+ self._find_js_imports(node, imports)
1367
+ elif ext == ".py":
1368
+ self._find_python_imports(node, imports)
1369
+
1370
+ return imports
1371
+
1372
+ def _find_js_imports(self, node, imports: list) -> None:
1373
+ """Find JavaScript/TypeScript imports."""
1374
+ if node.type == "import_statement":
1375
+ module_name = None
1376
+ imported = []
1377
+
1378
+ for child in node.children:
1379
+ if child.type == "string":
1380
+ module_name = child.text.decode("utf-8").strip('"\'')
1381
+ elif child.type == "import_clause":
1382
+ for c in child.children:
1383
+ if c.type == "identifier":
1384
+ imported.append(c.text.decode("utf-8"))
1385
+ elif c.type == "named_imports":
1386
+ for ic in c.children:
1387
+ if ic.type == "import_specifier":
1388
+ name = ic.child_by_field_name("name")
1389
+ if name:
1390
+ imported.append(name.text.decode("utf-8"))
1391
+
1392
+ if module_name:
1393
+ imports.append({
1394
+ "module": module_name,
1395
+ "imported": imported,
1396
+ "default": imported[0] if imported else None,
1397
+ })
1398
+
1399
+ for child in node.children:
1400
+ self._find_js_imports(child, imports)
1401
+
1402
+ def _find_python_imports(self, node, imports: list) -> None:
1403
+ """Find Python imports."""
1404
+ if node.type in ("import_statement", "import_from_statement"):
1405
+ module_name = None
1406
+ imported = []
1407
+
1408
+ for child in node.children:
1409
+ if child.type == "dotted_name":
1410
+ module_name = child.text.decode("utf-8")
1411
+ elif child.type == "aliased_import":
1412
+ for c in child.children:
1413
+ if c.type == "identifier":
1414
+ imported.append(c.text.decode("utf-8"))
1415
+ elif child.type == "wildcard_import":
1416
+ imported.append("*")
1417
+ elif child.type == "dotted_name" and node.type == "import_from_statement":
1418
+ for c in child.children:
1419
+ if c.type == "identifier":
1420
+ imported.append(c.text.decode("utf-8"))
1421
+
1422
+ if module_name:
1423
+ imports.append({
1424
+ "module": module_name,
1425
+ "imported": imported,
1426
+ })
1427
+
1428
+ for child in node.children:
1429
+ self._find_python_imports(child, imports)
1430
+
1431
+ def _extract_exports(self, source: str, node, ext: str) -> list[dict[str, Any]]:
1432
+ """Extract export statements."""
1433
+ exports = []
1434
+
1435
+ if ext in (".js", ".jsx", ".ts", ".tsx"):
1436
+ self._find_js_exports(node, exports)
1437
+ elif ext == ".py":
1438
+ self._find_python_exports(node, exports)
1439
+
1440
+ return exports
1441
+
1442
+ def _find_js_exports(self, node, exports: list) -> None:
1443
+ """Find JavaScript/TypeScript exports."""
1444
+ if node.type == "export_statement":
1445
+ for child in node.children:
1446
+ if child.type == "named_export":
1447
+ for c in child.children:
1448
+ if c.type == "export_clause":
1449
+ for ec in c.children:
1450
+ if ec.type == "export_specifier":
1451
+ name = ec.child_by_field_name("name")
1452
+ if name:
1453
+ exports.append({"name": name.text.decode("utf-8"), "type": "named"})
1454
+ elif child.type == "variable_declaration":
1455
+ for c in child.children:
1456
+ if c.type == "variable_declarator":
1457
+ name_node = c.child_by_field_name("name")
1458
+ if name_node:
1459
+ exports.append({"name": name_node.text.decode("utf-8"), "type": "variable"})
1460
+ elif child.type == "class_declaration":
1461
+ name_node = self._get_node_name(child, "")
1462
+ if name_node:
1463
+ exports.append({"name": name_node, "type": "class"})
1464
+ elif child.type == "function_declaration":
1465
+ name_node = self._get_node_name(child, "")
1466
+ if name_node:
1467
+ exports.append({"name": name_node, "type": "function"})
1468
+
1469
+ for child in node.children:
1470
+ self._find_js_exports(child, exports)
1471
+
1472
+ def _find_python_exports(self, node, exports: list) -> None:
1473
+ """Find Python __all__ exports."""
1474
+ if node.type == "assignment_statement":
1475
+ for child in node.children:
1476
+ if child.type == "attribute" and child.text.decode("utf-8") == "__all__":
1477
+ for c in child.children:
1478
+ if c.type == "list":
1479
+ for lc in c.children:
1480
+ if lc.type == "string":
1481
+ exports.append({"name": lc.text.decode("utf-8").strip('"\''), "type": "explicit"})
1482
+
1483
+ for child in node.children:
1484
+ self._find_python_exports(child, exports)
1485
+
1486
+ def _extract_api_routes(self, source: str, node, ext: str) -> list[dict[str, Any]]:
1487
+ """Extract API routes/endpoints."""
1488
+ routes = []
1489
+
1490
+ if ext in (".js", ".jsx", ".ts", ".tsx"):
1491
+ self._find_js_routes(node, routes)
1492
+
1493
+ return routes
1494
+
1495
+ def _find_js_routes(self, node, routes: list) -> None:
1496
+ """Find JavaScript/TypeScript API routes."""
1497
+ source_bytes = node.text if hasattr(node, 'text') else b''
1498
+ source_str = source_bytes.decode("utf-8", errors="ignore")
1499
+
1500
+ import re
1501
+
1502
+ patterns = [
1503
+ (r'["\'](GET|POST|PUT|DELETE|PATCH)\s+["\']([^"\']+)["\']', 'express'),
1504
+ (r'@Get\(["\']([^"\']+)["\']\)', 'nestjs'),
1505
+ (r'@Post\(["\']([^"\']+)["\']\)', 'nestjs'),
1506
+ (r'@Put\(["\']([^"\']+)["\']\)', 'nestjs'),
1507
+ (r'@Delete\(["\']([^"\']+)["\']\)', 'nestjs'),
1508
+ (r'@RequestMapping\(["\']([^"\']+)["\']', 'spring'),
1509
+ (r'router\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']', 'express'),
1510
+ (r'app\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']', 'express'),
1511
+ ]
1512
+
1513
+ for pattern, framework in patterns:
1514
+ matches = re.findall(pattern, source_str)
1515
+ for match in matches:
1516
+ if len(match) == 2:
1517
+ method = match[0] if match[0] in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] else framework
1518
+ path = match[1] if match[0] in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] else match[1]
1519
+ routes.append({"method": method.upper(), "path": path, "framework": framework})
1520
+
1521
+ for child in node.children:
1522
+ self._find_js_routes(child, routes)
1523
+
1524
+ def _extract_entities(self, source: str, node, ext: str) -> list[dict[str, Any]]:
1525
+ """Extract database entities/models."""
1526
+ entities = []
1527
+
1528
+ if ext in (".js", ".jsx", ".ts", ".tsx"):
1529
+ self._find_js_entities(node, entities, source)
1530
+ elif ext == ".py":
1531
+ self._find_python_entities(node, entities, source)
1532
+
1533
+ return entities
1534
+
1535
+ def _find_js_entities(self, node, entities: list, source: str) -> None:
1536
+ """Find JavaScript/TypeScript entities/models."""
1537
+ source_bytes = node.text if hasattr(node, 'text') else b''
1538
+ source_str = source_bytes.decode("utf-8", errors="ignore")
1539
+
1540
+ if node.type == "class_declaration":
1541
+ name = self._get_node_name(node, source_str)
1542
+ if name:
1543
+ entity_type = "unknown"
1544
+ if "@Entity" in source_str:
1545
+ entity_type = "typeorm"
1546
+ elif "sequelize" in source_str.lower() or "Model" in name:
1547
+ entity_type = "sequelize"
1548
+ elif "prisma" in source_str.lower() or "@Model" in source_str:
1549
+ entity_type = "prisma"
1550
+
1551
+ fields = []
1552
+ for child in node.children:
1553
+ if child.type == "class_body":
1554
+ for cb in child.children:
1555
+ if cb.type == "field_definition":
1556
+ field_name = self._get_node_name(cb, source_str)
1557
+ if field_name:
1558
+ fields.append(field_name)
1559
+
1560
+ entities.append({
1561
+ "name": name,
1562
+ "type": entity_type,
1563
+ "fields": fields,
1564
+ })
1565
+
1566
+ for child in node.children:
1567
+ self._find_js_entities(child, entities, source_str)
1568
+
1569
+ def _find_python_entities(self, node, entities: list, source: str) -> None:
1570
+ """Find Python entities/models."""
1571
+ source_bytes = node.text if hasattr(node, 'text') else b''
1572
+ source_str = source_bytes.decode("utf-8", errors="ignore")
1573
+
1574
+ if node.type == "class_definition":
1575
+ name = self._get_node_name(node, source_str)
1576
+ if name:
1577
+ entity_type = "unknown"
1578
+ if "SQLModel" in source_str or "Base" in source_str:
1579
+ entity_type = "sqlmodel"
1580
+ elif "Flask" in source_str or "SQLAlchemy" in source_str:
1581
+ entity_type = "sqlalchemy"
1582
+ elif "Django" in source_str:
1583
+ entity_type = "django"
1584
+ elif "Pydantic" in source_str:
1585
+ entity_type = "pydantic"
1586
+
1587
+ fields = []
1588
+ for child in node.children:
1589
+ if child.type == "block":
1590
+ for bc in child.children:
1591
+ if bc.type == "expression_statement":
1592
+ for bcc in bc.children:
1593
+ if bcc.type == "assignment":
1594
+ for bcca in bcc.children:
1595
+ if bcca.type == "identifier":
1596
+ fields.append(bcca.text.decode("utf-8"))
1597
+
1598
+ entities.append({
1599
+ "name": name,
1600
+ "type": entity_type,
1601
+ "fields": fields,
1602
+ })
1603
+
1604
+ for child in node.children:
1605
+ self._find_python_entities(child, entities, source_str)
1606
+
1607
+ def _extract_docstring(self, node, source: str) -> str | None:
1608
+ """Extract Python docstring from a function/class body."""
1609
+ # Look for the first expression_statement in the body that is a string
1610
+ for child in node.children:
1611
+ if child.type == "block":
1612
+ for bc in child.children:
1613
+ if bc.type == "expression_statement":
1614
+ for bcc in bc.children:
1615
+ if bcc.type == "string":
1616
+ doc = bcc.text.decode("utf-8", errors="ignore")
1617
+ # Strip triple quotes
1618
+ doc = doc.strip('"').strip("'").strip()
1619
+ if doc:
1620
+ # Truncate long docstrings
1621
+ return doc[:200] + "..." if len(doc) > 200 else doc
1622
+ break # Only check first statement
1623
+ break
1624
+ return None
1625
+
1626
+ def _extract_jsdoc(self, node, source: str) -> str | None:
1627
+ """Extract JSDoc comment preceding a node."""
1628
+ # Check for comment node preceding this node
1629
+ start_line = node.start_point.row
1630
+ source_lines = source.split("\n")
1631
+
1632
+ # Walk backwards from the node to find a JSDoc comment
1633
+ doc_lines = []
1634
+ in_jsdoc = False
1635
+ for i in range(start_line - 1, max(start_line - 15, -1), -1):
1636
+ if i < 0 or i >= len(source_lines):
1637
+ break
1638
+ line = source_lines[i].strip()
1639
+
1640
+ if line.endswith("*/"):
1641
+ in_jsdoc = True
1642
+ line = line[:-2].strip()
1643
+ if line:
1644
+ doc_lines.insert(0, line.lstrip("* "))
1645
+ elif in_jsdoc:
1646
+ if line.startswith("/**"):
1647
+ line = line[3:].strip()
1648
+ if line:
1649
+ doc_lines.insert(0, line.lstrip("* "))
1650
+ break
1651
+ elif line.startswith("/*"):
1652
+ line = line[2:].strip()
1653
+ if line:
1654
+ doc_lines.insert(0, line.lstrip("* "))
1655
+ break
1656
+ elif line.startswith("*"):
1657
+ line = line[1:].strip()
1658
+ if line and not line.startswith("@"):
1659
+ doc_lines.insert(0, line)
1660
+ else:
1661
+ break
1662
+ elif line.startswith("//"):
1663
+ # Single-line comment
1664
+ doc_lines.insert(0, line[2:].strip())
1665
+ elif line == "":
1666
+ if doc_lines:
1667
+ break
1668
+ continue
1669
+ else:
1670
+ break
1671
+
1672
+ if doc_lines:
1673
+ doc = " ".join(doc_lines).strip()
1674
+ return doc[:200] + "..." if len(doc) > 200 else doc
1675
+ return None
1676
+
1677
+ def _build_index(self, root: Path) -> dict[str, Any]:
1678
+ """Build the final index structure."""
1679
+ languages = set()
1680
+ for file_path in self.file_symbols.keys():
1681
+ ext = Path(file_path).suffix.lower()
1682
+ lang_info = LANGUAGE_MAP.get(ext)
1683
+ if lang_info:
1684
+ languages.add(lang_info[0])
1685
+ elif ext in REGEX_LANGUAGES:
1686
+ languages.add(REGEX_LANGUAGES[ext])
1687
+ else:
1688
+ languages.add(ext.lstrip("."))
1689
+
1690
+ # Build file dependency graph from imports
1691
+ file_deps = self._build_file_dependencies()
1692
+
1693
+ # Build cross-file type map
1694
+ type_map = self._build_type_map()
1695
+
1696
+ result = {
1697
+ "project_root": str(root),
1698
+ "last_indexed": self._timestamp(),
1699
+ "files": self.file_symbols,
1700
+ "call_graph": self.call_graph,
1701
+ "file_dependencies": file_deps,
1702
+ "file_hashes": self._compute_hashes(root),
1703
+ "languages": list(languages),
1704
+ }
1705
+
1706
+ if type_map:
1707
+ result["type_map"] = type_map
1708
+
1709
+ # Run plugin post-processors
1710
+ result = plugin_registry.run_post_processors(result)
1711
+
1712
+ return result
1713
+
1714
+ def _build_type_map(self) -> dict[str, dict[str, Any]]:
1715
+ """Build a cross-file type map: symbol name -> definition location + type info.
1716
+
1717
+ This resolves imported symbols to their source definitions, so an AI agent
1718
+ can look up where a type/function is actually defined even when it's imported.
1719
+ """
1720
+ # Step 1: Build export registry — what each file exports
1721
+ export_registry: dict[str, dict[str, str]] = {} # symbol_name -> {file, type, line}
1722
+ for rel_path, file_data in self.file_symbols.items():
1723
+ if not isinstance(file_data, dict):
1724
+ continue
1725
+
1726
+ # Register all top-level symbols as potential exports
1727
+ for sym in file_data.get("symbols", []):
1728
+ name = sym.get("name")
1729
+ if name and not sym.get("class"): # Only top-level
1730
+ export_registry[name] = {
1731
+ "defined_in": rel_path,
1732
+ "type": sym.get("type"),
1733
+ "line": sym.get("line"),
1734
+ }
1735
+
1736
+ # Register explicit exports
1737
+ for exp in file_data.get("exports", []):
1738
+ name = exp.get("name")
1739
+ if name and name not in export_registry:
1740
+ export_registry[name] = {
1741
+ "defined_in": rel_path,
1742
+ "type": exp.get("type", "export"),
1743
+ }
1744
+
1745
+ # Step 2: Resolve imports to definitions
1746
+ type_map: dict[str, dict[str, Any]] = {}
1747
+ for rel_path, file_data in self.file_symbols.items():
1748
+ if not isinstance(file_data, dict):
1749
+ continue
1750
+
1751
+ for imp in file_data.get("imports", []):
1752
+ imported_names = imp.get("imported", [])
1753
+ for imported_name in imported_names:
1754
+ if imported_name in export_registry:
1755
+ defn = export_registry[imported_name]
1756
+ if defn["defined_in"] != rel_path:
1757
+ key = f"{rel_path}:{imported_name}"
1758
+ type_map[key] = {
1759
+ "imported_in": rel_path,
1760
+ "name": imported_name,
1761
+ "defined_in": defn["defined_in"],
1762
+ "type": defn.get("type"),
1763
+ "line": defn.get("line"),
1764
+ }
1765
+
1766
+ return type_map
1767
+
1768
+ def _build_file_dependencies(self) -> dict[str, list[str]]:
1769
+ """Build a graph of which files import from which other files."""
1770
+ deps = {}
1771
+
1772
+ # Map module names to files for resolution
1773
+ module_to_file = {}
1774
+ for rel_path in self.file_symbols:
1775
+ # Register by filename stem and path variants
1776
+ stem = Path(rel_path).stem
1777
+ module_to_file[stem] = rel_path
1778
+ # Also register without extension
1779
+ no_ext = str(Path(rel_path).with_suffix(""))
1780
+ module_to_file[no_ext] = rel_path
1781
+ module_to_file[no_ext.replace("\\", "/")] = rel_path
1782
+
1783
+ for rel_path, file_data in self.file_symbols.items():
1784
+ if not isinstance(file_data, dict):
1785
+ continue
1786
+ imports = file_data.get("imports", [])
1787
+ if not imports:
1788
+ continue
1789
+
1790
+ dep_files = []
1791
+ for imp in imports:
1792
+ module = imp.get("module", "")
1793
+ if not module:
1794
+ continue
1795
+
1796
+ # Skip external packages (no relative path prefix, no /)
1797
+ if module.startswith("."):
1798
+ # Resolve relative import
1799
+ base_dir = str(Path(rel_path).parent)
1800
+ resolved = module.lstrip("./")
1801
+ candidate = f"{base_dir}/{resolved}" if base_dir != "." else resolved
1802
+
1803
+ # Try to find matching file
1804
+ for ext in (".ts", ".tsx", ".js", ".jsx", ".py"):
1805
+ key = candidate + ext
1806
+ if key.replace("\\", "/") in {k.replace("\\", "/") for k in self.file_symbols}:
1807
+ dep_files.append(key.replace("\\", "/"))
1808
+ break
1809
+ else:
1810
+ # Try index file
1811
+ for ext in (".ts", ".tsx", ".js", ".jsx"):
1812
+ key = f"{candidate}/index{ext}"
1813
+ if key.replace("\\", "/") in {k.replace("\\", "/") for k in self.file_symbols}:
1814
+ dep_files.append(key.replace("\\", "/"))
1815
+ break
1816
+ else:
1817
+ # Absolute import — try to match against known files
1818
+ clean = module.replace("@/", "src/").replace("~/", "")
1819
+ if clean in module_to_file:
1820
+ dep_files.append(module_to_file[clean])
1821
+
1822
+ if dep_files:
1823
+ deps[rel_path] = list(set(dep_files))
1824
+
1825
+ return deps
1826
+
1827
+ def _timestamp(self) -> str:
1828
+ """Get current ISO timestamp."""
1829
+ from datetime import datetime, timezone
1830
+ return datetime.now(timezone.utc).isoformat()
1831
+
1832
+ def _compute_hashes(self, root: Path) -> dict[str, str]:
1833
+ """Compute SHA-256 hashes for all indexed files."""
1834
+ hashes = {}
1835
+ for rel_path in self.file_symbols:
1836
+ file_path = root / rel_path
1837
+ try:
1838
+ content = file_path.read_bytes()
1839
+ hashes[rel_path] = hashlib.sha256(content).hexdigest()
1840
+ except OSError:
1841
+ pass
1842
+ return hashes
1843
+
1844
+
1845
+ def index_directory(path: str | Path, incremental: bool = False) -> dict[str, Any]:
1846
+ """Convenience function to index a directory."""
1847
+ indexer = CodeIndexer()
1848
+ return indexer.index_directory(Path(path), incremental=incremental)
1849
+
1850
+
1851
+ def save_index(index: dict[str, Any], output_path: Path) -> None:
1852
+ """Save index to JSON file."""
1853
+ output_path = Path(output_path)
1854
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1855
+ output_path.write_text(json.dumps(index, indent=2), encoding="utf-8")
1856
+
1857
+
1858
+ def load_index(index_path: Path) -> dict[str, Any]:
1859
+ """Load index from JSON file."""
1860
+ return json.loads(index_path.read_text(encoding="utf-8"))