code-oracle 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.
Files changed (40) hide show
  1. code_oracle/__init__.py +30 -0
  2. code_oracle/cli.py +795 -0
  3. code_oracle/config.py +145 -0
  4. code_oracle/dataset.py +5325 -0
  5. code_oracle/dead_code/__init__.py +32 -0
  6. code_oracle/dead_code/detector.py +379 -0
  7. code_oracle/dead_code/entrypoints.py +333 -0
  8. code_oracle/dead_code/models.py +255 -0
  9. code_oracle/dead_code/semantics.py +416 -0
  10. code_oracle/decision.py +906 -0
  11. code_oracle/engine.py +430 -0
  12. code_oracle/export_onnx.py +436 -0
  13. code_oracle/hook.py +531 -0
  14. code_oracle/indexer.py +894 -0
  15. code_oracle/languages/__init__.py +114 -0
  16. code_oracle/languages/common.py +127 -0
  17. code_oracle/languages/go.py +395 -0
  18. code_oracle/languages/python.py +336 -0
  19. code_oracle/languages/rust.py +474 -0
  20. code_oracle/languages/typescript.py +775 -0
  21. code_oracle/linearizer.py +166 -0
  22. code_oracle/locator.py +301 -0
  23. code_oracle/models.py +237 -0
  24. code_oracle/perf_lint/__init__.py +38 -0
  25. code_oracle/perf_lint/engine.py +234 -0
  26. code_oracle/perf_lint/models.py +229 -0
  27. code_oracle/perf_lint/rules/__init__.py +31 -0
  28. code_oracle/perf_lint/rules/async_blocking.py +143 -0
  29. code_oracle/perf_lint/rules/n_plus_one.py +232 -0
  30. code_oracle/perf_lint/rules/nested_loops.py +137 -0
  31. code_oracle/perf_lint/rules/unclosed_res.py +494 -0
  32. code_oracle/perf_lint/visitor.py +299 -0
  33. code_oracle/server.py +184 -0
  34. code_oracle/slicer.py +225 -0
  35. code_oracle/symbolic.py +459 -0
  36. code_oracle-0.1.0.dist-info/METADATA +225 -0
  37. code_oracle-0.1.0.dist-info/RECORD +40 -0
  38. code_oracle-0.1.0.dist-info/WHEEL +4 -0
  39. code_oracle-0.1.0.dist-info/entry_points.txt +2 -0
  40. code_oracle-0.1.0.dist-info/licenses/LICENSE +190 -0
code_oracle/indexer.py ADDED
@@ -0,0 +1,894 @@
1
+ """
2
+ Stage 2: Workspace Indexer / Symbol Cache.
3
+ Maintains an incremental, mtime-hashed inverted symbol index in .code_oracle/index.json.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Set
13
+
14
+ from code_oracle.languages import SUPPORTED_EXTENSIONS
15
+ from code_oracle.locator import extract_imports_from_ast, extract_symbols_from_ast
16
+ from code_oracle.models import CallReference, ImportReference, Parameter, Symbol
17
+
18
+ IGNORE_DIRS = {
19
+ ".git",
20
+ ".code_oracle",
21
+ ".venv",
22
+ "venv",
23
+ "env",
24
+ "__pycache__",
25
+ ".pytest_cache",
26
+ ".mypy_cache",
27
+ "dist",
28
+ "build",
29
+ "node_modules",
30
+ }
31
+
32
+
33
+ def compute_file_hash(content: str) -> str:
34
+ """Compute SHA-256 hash of text content."""
35
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
36
+
37
+
38
+ class WorkspaceIndexer:
39
+ """
40
+ Incremental, mtime and content-hash cached symbol indexer.
41
+ Keeps an inverted index of symbol definitions, callers, importers, and inheritance.
42
+ """
43
+
44
+ def __init__(self, workspace_root: Optional[Path] = None, cache_dir: Optional[Path] = None):
45
+ self.workspace_root = Path(workspace_root or Path.cwd()).resolve()
46
+ self.cache_dir = Path(cache_dir or (self.workspace_root / ".code_oracle")).resolve()
47
+ self.index_file = self.cache_dir / "index.json"
48
+
49
+ # Internal state
50
+ self._file_cache: Dict[str, Dict[str, Any]] = {}
51
+ self._file_symbols: Dict[str, List[Symbol]] = {}
52
+ self._file_imports: Dict[str, List[ImportReference]] = {}
53
+ self._import_graph: Dict[str, List[str]] = {}
54
+ self._definitions: Dict[str, Symbol] = {}
55
+ self._name_to_symbols: Dict[str, List[Symbol]] = {}
56
+ self._callers: Dict[str, List[CallReference]] = {}
57
+ self._importers: Dict[str, List[ImportReference]] = {}
58
+ self._subclasses: Dict[str, List[Symbol]] = {}
59
+
60
+ self.load_cache()
61
+
62
+ def load_cache(self) -> bool:
63
+ """Load index from .code_oracle/index.json if present."""
64
+ if not self.index_file.exists():
65
+ return False
66
+
67
+ try:
68
+ with open(self.index_file, "r", encoding="utf-8") as f:
69
+ data = json.load(f)
70
+ if data.get("version") == 1 and isinstance(data.get("files"), dict):
71
+ self._file_cache = data["files"]
72
+ self._rebuild_indices()
73
+ return True
74
+ except Exception:
75
+ # Corrupted index, start fresh
76
+ self._file_cache = {}
77
+ return False
78
+
79
+ def save_cache(self) -> None:
80
+ """Atomically persist index cache to .code_oracle/index.json."""
81
+ try:
82
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
83
+ temp_file = self.cache_dir / "index.json.tmp"
84
+ data = {
85
+ "version": 1,
86
+ "timestamp": time.time(),
87
+ "workspace": str(self.workspace_root),
88
+ "files": self._file_cache,
89
+ }
90
+ with open(temp_file, "w", encoding="utf-8") as f:
91
+ json.dump(data, f, indent=2)
92
+ os.replace(temp_file, self.index_file)
93
+ except Exception:
94
+ # Non-fatal if cache write fails (e.g. read-only fs)
95
+ pass
96
+
97
+ def clean(self) -> bool:
98
+ """
99
+ Safely deletes .code_oracle/ cache directory (Rollback Resilience).
100
+ Restores workspace to default state with zero destructive impact.
101
+ """
102
+ self._file_cache.clear()
103
+ self._file_symbols.clear()
104
+ self._file_imports.clear()
105
+ self._import_graph.clear()
106
+ self._definitions.clear()
107
+ self._name_to_symbols.clear()
108
+ self._callers.clear()
109
+ self._importers.clear()
110
+ self._subclasses.clear()
111
+
112
+ if self.cache_dir.exists():
113
+ try:
114
+ shutil.rmtree(self.cache_dir)
115
+ return True
116
+ except Exception:
117
+ return False
118
+ return True
119
+
120
+ def scan_workspace(self, force: bool = False) -> Dict[str, Any]:
121
+ """
122
+ Incrementally scan all python files in workspace.
123
+ Re-indexes only files with changed mtime or content hash.
124
+ """
125
+ start_time = time.perf_counter()
126
+ scanned_count = 0
127
+ reindexed_count = 0
128
+
129
+ current_rel_files: Set[str] = set()
130
+
131
+ for root, dirs, files in os.walk(self.workspace_root):
132
+ # Prune ignored directories in-place
133
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
134
+
135
+ for file in files:
136
+ ext = Path(file).suffix.lower()
137
+ if ext not in SUPPORTED_EXTENSIONS:
138
+ continue
139
+
140
+ full_path = Path(root) / file
141
+ rel_path = str(full_path.relative_to(self.workspace_root)).replace("\\", "/")
142
+ current_rel_files.add(rel_path)
143
+ scanned_count += 1
144
+
145
+ try:
146
+ mtime = full_path.stat().st_mtime
147
+ except OSError:
148
+ continue
149
+
150
+ cached_entry = self._file_cache.get(rel_path)
151
+
152
+ if (
153
+ not force
154
+ and cached_entry
155
+ and cached_entry.get("mtime") == mtime
156
+ ):
157
+ # Cache hit by mtime
158
+ continue
159
+
160
+ # Read and check content hash
161
+ try:
162
+ content = full_path.read_text(encoding="utf-8")
163
+ except Exception:
164
+ continue
165
+
166
+ content_hash = compute_file_hash(content)
167
+ if (
168
+ not force
169
+ and cached_entry
170
+ and cached_entry.get("hash") == content_hash
171
+ ):
172
+ # Hash matches, update mtime only
173
+ cached_entry["mtime"] = mtime
174
+ continue
175
+
176
+ # Need re-index
177
+ reindexed_count += 1
178
+ symbols = extract_symbols_from_ast(content, file_path=rel_path)
179
+ imports = extract_imports_from_ast(content, file_path=rel_path)
180
+ self._file_symbols[rel_path] = symbols
181
+ self._file_imports[rel_path] = imports
182
+ self._file_cache[rel_path] = {
183
+ "mtime": mtime,
184
+ "hash": content_hash,
185
+ "symbols": [self._serialize_symbol(s) for s in symbols],
186
+ "imports": [self._serialize_import(imp) for imp in imports],
187
+ }
188
+
189
+ # Remove deleted files from cache
190
+ stale_files = set(self._file_cache.keys()) - current_rel_files
191
+ for sf in stale_files:
192
+ del self._file_cache[sf]
193
+ self._file_symbols.pop(sf, None)
194
+ self._file_imports.pop(sf, None)
195
+ self._import_graph.pop(sf, None)
196
+
197
+ if reindexed_count > 0 or stale_files or force:
198
+ self._rebuild_indices()
199
+ self.save_cache()
200
+
201
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
202
+ return {
203
+ "scanned": scanned_count,
204
+ "reindexed": reindexed_count,
205
+ "symbols_indexed": len(self._definitions),
206
+ "latency_ms": elapsed_ms,
207
+ }
208
+
209
+ def _serialize_import(self, imp: ImportReference) -> Dict[str, Any]:
210
+ """Serialize an ImportReference object."""
211
+ return {
212
+ "module": imp.module,
213
+ "name": imp.name,
214
+ "asname": imp.asname,
215
+ "lineno": imp.lineno,
216
+ "file_path": imp.file_path,
217
+ "level": imp.level,
218
+ }
219
+
220
+ def _deserialize_import(self, d: Dict[str, Any]) -> ImportReference:
221
+ """Deserialize a dict into an ImportReference object."""
222
+ return ImportReference(
223
+ module=d.get("module"),
224
+ name=d["name"],
225
+ asname=d.get("asname"),
226
+ lineno=d.get("lineno", 0),
227
+ file_path=d.get("file_path", ""),
228
+ level=d.get("level", 0),
229
+ )
230
+
231
+ def _serialize_symbol(self, s: Symbol) -> Dict[str, Any]:
232
+ """Serialize a Symbol object to dict for cache storage."""
233
+ return {
234
+ "name": s.name,
235
+ "qualname": s.qualname,
236
+ "file_path": s.file_path,
237
+ "kind": s.kind,
238
+ "lineno": s.lineno,
239
+ "end_lineno": s.end_lineno,
240
+ "signature": s.signature,
241
+ "min_args": s.min_args,
242
+ "max_args": s.max_args,
243
+ "accepted_kwargs": list(s.accepted_kwargs) if s.accepted_kwargs is not None else None,
244
+ "required_kwargs": list(s.required_kwargs),
245
+ "return_type": s.return_type,
246
+ "is_method": s.is_method,
247
+ "is_static": s.is_static,
248
+ "bases": s.bases,
249
+ "docstring": s.docstring,
250
+ "is_exported": s.is_exported,
251
+ "visibility": s.visibility,
252
+ "params": [
253
+ {
254
+ "name": p.name,
255
+ "annotation": p.annotation,
256
+ "default": p.default,
257
+ "has_default": p.has_default,
258
+ "is_vararg": p.is_vararg,
259
+ "is_kwarg": p.is_kwarg,
260
+ "is_kwonly": p.is_kwonly,
261
+ "is_posonly": p.is_posonly,
262
+ }
263
+ for p in s.params
264
+ ],
265
+ "calls": [
266
+ {
267
+ "callee": c.callee,
268
+ "args_count": c.args_count,
269
+ "kwargs": c.kwargs,
270
+ "lineno": c.lineno,
271
+ "caller": c.caller,
272
+ "has_vararg": c.has_vararg,
273
+ "has_kwarg": c.has_kwarg,
274
+ }
275
+ for c in s.calls
276
+ ],
277
+ }
278
+
279
+ def _deserialize_symbol(self, d: Dict[str, Any]) -> Symbol:
280
+ """Deserialize a dict into a Symbol object."""
281
+ params = [
282
+ Parameter(
283
+ name=p["name"],
284
+ annotation=p.get("annotation"),
285
+ default=p.get("default"),
286
+ has_default=p.get("has_default", False),
287
+ is_vararg=p.get("is_vararg", False),
288
+ is_kwarg=p.get("is_kwarg", False),
289
+ is_kwonly=p.get("is_kwonly", False),
290
+ is_posonly=p.get("is_posonly", False),
291
+ )
292
+ for p in d.get("params", [])
293
+ ]
294
+ calls = [
295
+ CallReference(
296
+ callee=c["callee"],
297
+ args_count=c["args_count"],
298
+ kwargs=c.get("kwargs", []),
299
+ lineno=c.get("lineno", 0),
300
+ caller=c.get("caller"),
301
+ has_vararg=c.get("has_vararg", False),
302
+ has_kwarg=c.get("has_kwarg", False),
303
+ )
304
+ for c in d.get("calls", [])
305
+ ]
306
+ accepted_kwargs = (
307
+ set(d["accepted_kwargs"]) if d.get("accepted_kwargs") is not None else None
308
+ )
309
+ required_kwargs = set(d.get("required_kwargs", []))
310
+
311
+ return Symbol(
312
+ name=d["name"],
313
+ qualname=d["qualname"],
314
+ file_path=d["file_path"],
315
+ kind=d["kind"],
316
+ lineno=d["lineno"],
317
+ end_lineno=d["end_lineno"],
318
+ signature=d.get("signature", ""),
319
+ params=params,
320
+ min_args=d.get("min_args", 0),
321
+ max_args=d.get("max_args"),
322
+ accepted_kwargs=accepted_kwargs,
323
+ required_kwargs=required_kwargs,
324
+ return_type=d.get("return_type"),
325
+ calls=calls,
326
+ is_method=d.get("is_method", False),
327
+ is_static=d.get("is_static", False),
328
+ bases=d.get("bases", []),
329
+ docstring=d.get("docstring"),
330
+ is_exported=d.get("is_exported", False),
331
+ visibility=d.get("visibility", "internal"),
332
+ )
333
+
334
+ def _remove_file_from_indices(self, file_path: str) -> None:
335
+ """Incrementally prune symbols, callers, importers, and inheritance for a single file."""
336
+ old_syms = self._file_symbols.get(file_path, [])
337
+ for sym in old_syms:
338
+ self._definitions.pop(sym.id, None)
339
+ if sym.name in self._name_to_symbols:
340
+ self._name_to_symbols[sym.name] = [s for s in self._name_to_symbols[sym.name] if s.file_path != file_path]
341
+ if not self._name_to_symbols[sym.name]:
342
+ del self._name_to_symbols[sym.name]
343
+ if sym.qualname != sym.name and sym.qualname in self._name_to_symbols:
344
+ self._name_to_symbols[sym.qualname] = [s for s in self._name_to_symbols[sym.qualname] if s.file_path != file_path]
345
+ if not self._name_to_symbols[sym.qualname]:
346
+ del self._name_to_symbols[sym.qualname]
347
+ for base_name in sym.bases:
348
+ if base_name in self._subclasses:
349
+ self._subclasses[base_name] = [s for s in self._subclasses[base_name] if s.file_path != file_path]
350
+ if not self._subclasses[base_name]:
351
+ del self._subclasses[base_name]
352
+ for call in sym.calls:
353
+ prefix = f"{file_path}::"
354
+ callee = call.callee
355
+ if callee in self._callers:
356
+ self._callers[callee] = [c for c in self._callers[callee] if not (c.caller and c.caller.startswith(prefix))]
357
+ if not self._callers[callee]:
358
+ del self._callers[callee]
359
+ simple = callee.split(".")[-1].split("::")[-1]
360
+ if simple in self._callers:
361
+ self._callers[simple] = [c for c in self._callers[simple] if not (c.caller and c.caller.startswith(prefix))]
362
+ if not self._callers[simple]:
363
+ del self._callers[simple]
364
+
365
+ old_imps = self._file_imports.get(file_path, [])
366
+ for imp in old_imps:
367
+ if imp.name in self._importers:
368
+ self._importers[imp.name] = [i for i in self._importers[imp.name] if i.file_path != file_path]
369
+ if not self._importers[imp.name]:
370
+ del self._importers[imp.name]
371
+ if imp.module:
372
+ key = f"{imp.module}.{imp.name}"
373
+ if key in self._importers:
374
+ self._importers[key] = [i for i in self._importers[key] if i.file_path != file_path]
375
+ if not self._importers[key]:
376
+ del self._importers[key]
377
+
378
+ self._import_graph.pop(file_path, None)
379
+ self._file_symbols.pop(file_path, None)
380
+ self._file_imports.pop(file_path, None)
381
+
382
+ def _add_file_to_indices(
383
+ self,
384
+ file_path: str,
385
+ symbols: List[Symbol],
386
+ imports: List[ImportReference],
387
+ ) -> None:
388
+ """Incrementally index symbols, callers, importers, and inheritance for a single file."""
389
+ self._file_symbols[file_path] = symbols
390
+ self._file_imports[file_path] = imports
391
+
392
+ for sym in symbols:
393
+ self._definitions[sym.id] = sym
394
+ self._name_to_symbols.setdefault(sym.name, []).append(sym)
395
+ if sym.qualname != sym.name:
396
+ self._name_to_symbols.setdefault(sym.qualname, []).append(sym)
397
+
398
+ for base_name in sym.bases:
399
+ self._subclasses.setdefault(base_name, []).append(sym)
400
+
401
+ for call in sym.calls:
402
+ callee = call.callee
403
+ self._callers.setdefault(callee, []).append(call)
404
+ simple = callee.split(".")[-1].split("::")[-1]
405
+ if simple != callee:
406
+ self._callers.setdefault(simple, []).append(call)
407
+
408
+ if sym.qualname and "." in sym.qualname:
409
+ class_qualname = sym.qualname.rsplit(".", 1)[0]
410
+ if callee.startswith(("self.", "cls.")):
411
+ self._callers.setdefault(f"{class_qualname}.{simple}", []).append(call)
412
+ self._callers.setdefault(
413
+ f"{sym.file_path}::{class_qualname}.{simple}", []
414
+ ).append(call)
415
+
416
+ for imp in imports:
417
+ self._importers.setdefault(imp.name, []).append(imp)
418
+ if imp.module:
419
+ self._importers.setdefault(f"{imp.module}.{imp.name}", []).append(imp)
420
+
421
+ # Update import graph edges for this file
422
+ targets: List[str] = []
423
+ for imp in imports:
424
+ target_f = self.resolve_import_to_file(imp, file_path)
425
+ if target_f and target_f != file_path and target_f not in targets:
426
+ targets.append(target_f)
427
+ self._import_graph[file_path] = targets
428
+
429
+ def _rebuild_indices(self) -> None:
430
+ """Rebuild definitions, name lookup, callers, importers, and inheritance indices."""
431
+ self._definitions.clear()
432
+ self._name_to_symbols.clear()
433
+ self._callers.clear()
434
+ self._importers.clear()
435
+ self._subclasses.clear()
436
+ self._import_graph.clear()
437
+
438
+ for rel_path, file_data in self._file_cache.items():
439
+ if rel_path not in self._file_symbols:
440
+ self._file_symbols[rel_path] = [self._deserialize_symbol(s) for s in file_data.get("symbols", [])]
441
+ if rel_path not in self._file_imports:
442
+ self._file_imports[rel_path] = [self._deserialize_import(i) for i in file_data.get("imports", [])]
443
+
444
+ self._add_file_to_indices(rel_path, self._file_symbols[rel_path], self._file_imports[rel_path])
445
+
446
+ def resolve_import_to_file(
447
+ self, imp: ImportReference, current_file: str
448
+ ) -> Optional[str]:
449
+ """
450
+ Resolve an ImportReference from current_file to a concrete file path in the workspace.
451
+ Handles relative imports (level > 0) and absolute workspace imports (level == 0).
452
+ """
453
+ available_files = set(self._file_cache.keys())
454
+ cur_p = Path(current_file)
455
+ cur_dir = cur_p.parent
456
+
457
+ cur_ext = cur_p.suffix.lower()
458
+
459
+ # TypeScript / JavaScript import resolution
460
+ if cur_ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
461
+ mod_str = imp.module or imp.name
462
+ if mod_str.startswith("node:"):
463
+ return None
464
+ first_pkg = mod_str.split("/")[0]
465
+ if first_pkg in {
466
+ "assert", "async_hooks", "buffer", "child_process", "cluster", "console",
467
+ "constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
468
+ "events", "fs", "http", "http2", "https", "inspector", "module", "net",
469
+ "os", "path", "perf_hooks", "process", "punycode", "querystring",
470
+ "readline", "repl", "stream", "string_decoder", "timers", "tls",
471
+ "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads",
472
+ "zlib",
473
+ }:
474
+ return None
475
+
476
+ rel_mod = mod_str.lstrip("./") if mod_str.startswith("./") else mod_str
477
+ base = (cur_dir / rel_mod).as_posix()
478
+ ts_cands = [
479
+ base,
480
+ f"{base}.ts",
481
+ f"{base}.tsx",
482
+ f"{base}.js",
483
+ f"{base}.jsx",
484
+ f"{base}/index.ts",
485
+ f"{base}/index.tsx",
486
+ f"{base}/index.js",
487
+ rel_mod,
488
+ f"{rel_mod}.ts",
489
+ f"{rel_mod}.tsx",
490
+ f"{rel_mod}.js",
491
+ f"src/{rel_mod}.ts",
492
+ f"src/{rel_mod}.tsx",
493
+ f"src/{rel_mod}.js",
494
+ ]
495
+ for cand in ts_cands:
496
+ norm = Path(cand).as_posix()
497
+ if norm.startswith("./"):
498
+ norm = norm[2:]
499
+ if norm in available_files:
500
+ return norm
501
+ return None
502
+
503
+ # Go import resolution
504
+ if cur_ext == ".go":
505
+ mod_path = imp.module or imp.name
506
+ first_segment = mod_path.split("/")[0]
507
+ # Standard library packages in Go should not resolve to workspace files
508
+ go_stdlib = {
509
+ "archive", "tar", "zip", "bufio", "builtin", "bytes", "compress",
510
+ "bzip2", "flate", "gzip", "lzw", "zlib", "container", "heap", "list",
511
+ "ring", "context", "crypto", "aes", "cipher", "des", "dsa", "ecdsa",
512
+ "ed25519", "elliptic", "hmac", "md5", "rand", "rc4", "rsa", "sha1",
513
+ "sha256", "sha512", "subtle", "tls", "x509", "database", "sql",
514
+ "debug", "dwarf", "elf", "gosym", "macho", "pe", "plan9obj", "embed",
515
+ "encoding", "ascii85", "asn1", "base32", "base64", "binary", "csv",
516
+ "gob", "hex", "json", "pem", "xml", "errors", "expvar", "flag",
517
+ "fmt", "go", "ast", "build", "constant", "doc", "format", "parser",
518
+ "printer", "scanner", "token", "types", "hash", "adler32", "crc32",
519
+ "crc64", "fnv", "maphash", "html", "template", "image", "color",
520
+ "draw", "gif", "jpeg", "png", "index", "suffixarray", "io", "fs",
521
+ "ioutil", "log", "slog", "syslog", "math", "big", "bits", "cmplx",
522
+ "mime", "multipart", "quotedprintable", "net", "http", "cgi",
523
+ "cookiejar", "fcgi", "httptest", "httptrace", "httputil", "pprof",
524
+ "mail", "rpc", "jsonrpc", "smtp", "textproto", "url", "os", "exec",
525
+ "signal", "user", "path", "filepath", "plugin", "reflect", "regexp",
526
+ "syntax", "runtime", "cgo", "coverage", "metrics", "msan",
527
+ "race", "trace", "sort", "strconv", "strings", "sync", "atomic",
528
+ "syscall", "testing", "fstest", "iotest", "quick", "text", "tabwriter",
529
+ "time", "tzdata", "unicode", "utf16", "utf8", "unsafe",
530
+ }
531
+ if first_segment in go_stdlib:
532
+ return None
533
+
534
+ # Relative Go imports
535
+ if mod_path.startswith("./") or mod_path.startswith("../"):
536
+ target_dir = (cur_dir / mod_path).resolve()
537
+ for af in available_files:
538
+ if af.endswith(".go") and (self.workspace_root / af).parent.resolve() == target_dir:
539
+ return af
540
+ return None
541
+
542
+ # Package path matching
543
+ pkg_target = mod_path.split("/")[-1]
544
+ for af in available_files:
545
+ if not af.endswith(".go"):
546
+ continue
547
+ af_dir = Path(af).parent
548
+ if af_dir == cur_dir:
549
+ continue
550
+ if af_dir.name == pkg_target or af_dir.as_posix().endswith(mod_path):
551
+ return af
552
+ return None
553
+
554
+ # Rust import resolution
555
+ if cur_ext == ".rs":
556
+ mod_target = (imp.module or imp.name)
557
+ first_crate = mod_target.split("::")[0]
558
+ if first_crate in {"std", "core", "alloc", "proc_macro", "test"}:
559
+ return None
560
+
561
+ cleaned_mod = mod_target.replace("crate::", "").replace("super::", "../").replace("self::", "").replace("::", "/")
562
+ rs_cands = [
563
+ f"{cur_dir / cleaned_mod}.rs",
564
+ f"{cur_dir / cleaned_mod}/mod.rs",
565
+ f"{cur_dir / imp.name}.rs",
566
+ f"{cur_dir / imp.name}/mod.rs",
567
+ f"src/{cleaned_mod}.rs",
568
+ f"src/{cleaned_mod}/mod.rs",
569
+ f"src/{imp.name}.rs",
570
+ f"src/{imp.name}/mod.rs",
571
+ f"{cleaned_mod}.rs",
572
+ f"{imp.name}.rs",
573
+ ]
574
+ for cand in rs_cands:
575
+ norm = Path(cand).as_posix()
576
+ if norm.startswith("./"):
577
+ norm = norm[2:]
578
+ if norm in available_files:
579
+ return norm
580
+ return None
581
+
582
+ # Python import resolution (default)
583
+ candidates = []
584
+
585
+ if imp.level > 0:
586
+ # Relative import
587
+ target_dir = cur_dir
588
+ for _ in range(imp.level - 1):
589
+ target_dir = target_dir.parent
590
+
591
+ if imp.module:
592
+ rel_mod = imp.module.replace(".", "/")
593
+ base = target_dir / rel_mod
594
+ candidates.extend([
595
+ f"{base}.py",
596
+ f"{base}/__init__.py",
597
+ f"{base / imp.name}.py",
598
+ f"{base / imp.name}/__init__.py",
599
+ ])
600
+ else:
601
+ candidates.extend([
602
+ f"{target_dir / imp.name}.py",
603
+ f"{target_dir / imp.name}/__init__.py",
604
+ f"{target_dir}/__init__.py",
605
+ ])
606
+ else:
607
+ # Absolute import
608
+ mods = []
609
+ if imp.module:
610
+ mods.append(imp.module)
611
+ mods.append(f"{imp.module}.{imp.name}")
612
+ else:
613
+ mods.append(imp.name)
614
+
615
+ for m in mods:
616
+ rel_m = m.replace(".", "/")
617
+ candidates.extend([
618
+ f"{rel_m}.py",
619
+ f"{rel_m}/__init__.py",
620
+ f"src/{rel_m}.py",
621
+ f"src/{rel_m}/__init__.py",
622
+ ])
623
+
624
+ for cand in candidates:
625
+ norm = Path(cand).as_posix()
626
+ if norm.startswith("./"):
627
+ norm = norm[2:]
628
+ if norm in available_files:
629
+ return norm
630
+ return None
631
+
632
+ def resolve_callee(
633
+ self, call: CallReference, caller_sym: Optional[Symbol] = None
634
+ ) -> Optional[Symbol]:
635
+ """
636
+ High-accuracy resolution of a call site to its Symbol definition.
637
+ Resolves direct calls, method self/cls invocations, and qualified accesses.
638
+ """
639
+ callee = call.callee
640
+
641
+ # 1. Exact ID or qualname match
642
+ if callee in self._definitions:
643
+ return self._definitions[callee]
644
+
645
+ # 2. Self or cls call inside a class
646
+ if callee.startswith(("self.", "cls.")) and caller_sym:
647
+ parts = callee.split(".")
648
+ if len(parts) == 2:
649
+ attr = parts[1]
650
+ if caller_sym.qualname and "." in caller_sym.qualname:
651
+ cls_qualname = caller_sym.qualname.rsplit(".", 1)[0]
652
+ full_id = f"{caller_sym.file_path}::{cls_qualname}.{attr}"
653
+ if full_id in self._definitions:
654
+ return self._definitions[full_id]
655
+ matches = self._name_to_symbols.get(f"{cls_qualname}.{attr}", [])
656
+ if matches:
657
+ return matches[0]
658
+
659
+ same_file = f"{caller_sym.file_path}::{attr}"
660
+ if same_file in self._definitions:
661
+ return self._definitions[same_file]
662
+ return self.get_definition(attr)
663
+ else:
664
+ # Chained call on attribute of self (e.g. self.indexer.restore_transient_symbols)
665
+ attr = parts[-1]
666
+ same_file = f"{caller_sym.file_path}::{attr}"
667
+ if same_file in self._definitions:
668
+ return self._definitions[same_file]
669
+ matches = self.get_symbols_by_name(attr)
670
+ if matches:
671
+ return matches[0]
672
+ return self.get_definition(attr)
673
+
674
+ # 3. Dotted or scoped callee: ClassName.method, mod.func, or mod::func
675
+ if "." in callee or "::" in callee:
676
+ sep = "::" if "::" in callee else "."
677
+ if caller_sym:
678
+ prefix, attr = callee.split(sep, 1)
679
+ caller_file = caller_sym.file_path
680
+ f_data = self._file_cache.get(caller_file, {})
681
+ for imp_data in f_data.get("imports", []):
682
+ imp = self._deserialize_import(imp_data)
683
+ match_alias = imp.asname and imp.asname == prefix
684
+ match_name = not imp.asname and (imp.name == prefix or (imp.module and imp.name == prefix))
685
+ if match_alias or match_name:
686
+ target_f = self.resolve_import_to_file(imp, caller_file)
687
+ if target_f:
688
+ target_id = f"{target_f}::{attr}"
689
+ if target_id in self._definitions:
690
+ return self._definitions[target_id]
691
+
692
+ match = self.get_definition(callee)
693
+ if match:
694
+ return match
695
+ attr = callee.split(sep)[-1]
696
+ if caller_sym:
697
+ same_file = f"{caller_sym.file_path}::{attr}"
698
+ if same_file in self._definitions:
699
+ return self._definitions[same_file]
700
+ return self.get_definition(attr)
701
+
702
+ # 4. Plain name call
703
+ if caller_sym:
704
+ # Check nested/local function under caller's qualname
705
+ nested_id = f"{caller_sym.file_path}::{caller_sym.qualname}.{callee}"
706
+ if nested_id in self._definitions:
707
+ return self._definitions[nested_id]
708
+
709
+ # Check sibling under parent qualname (if caller is itself nested or a method)
710
+ if "." in caller_sym.qualname:
711
+ parent_prefix = caller_sym.qualname.rsplit(".", 1)[0]
712
+ sibling_id = f"{caller_sym.file_path}::{parent_prefix}.{callee}"
713
+ if sibling_id in self._definitions:
714
+ return self._definitions[sibling_id]
715
+
716
+ # Check self-recursive call if callee == caller's short name
717
+ if callee == caller_sym.name and caller_sym.id in self._definitions:
718
+ return caller_sym
719
+
720
+ same_file = f"{caller_sym.file_path}::{callee}"
721
+ if same_file in self._definitions:
722
+ return self._definitions[same_file]
723
+
724
+ # Check file imports
725
+ caller_file = caller_sym.file_path
726
+ f_data = self._file_cache.get(caller_file, {})
727
+ for imp_data in f_data.get("imports", []):
728
+ imp = self._deserialize_import(imp_data)
729
+ if (imp.asname and imp.asname == callee) or (not imp.asname and imp.name == callee):
730
+ target_f = self.resolve_import_to_file(imp, caller_file)
731
+ if target_f:
732
+ target_id = f"{target_f}::{imp.name}"
733
+ if target_id in self._definitions:
734
+ return self._definitions[target_id]
735
+
736
+ return self.get_definition(callee)
737
+
738
+ def get_definition(self, symbol_id_or_name: str) -> Optional[Symbol]:
739
+ """Look up symbol by full ID or name."""
740
+ if symbol_id_or_name in self._definitions:
741
+ return self._definitions[symbol_id_or_name]
742
+ matches = self._name_to_symbols.get(symbol_id_or_name, [])
743
+ return matches[0] if matches else None
744
+
745
+ def get_symbols_by_name(self, name: str) -> List[Symbol]:
746
+ """Find all symbols matching a name across the workspace."""
747
+ return self._name_to_symbols.get(name, [])
748
+
749
+ def get_callers(self, symbol_name_or_qualname: str) -> List[CallReference]:
750
+ """Find all call references targeting this symbol name or qualname."""
751
+ results: List[CallReference] = []
752
+ seen = set()
753
+
754
+ def add_call(c: CallReference):
755
+ k = (c.caller, c.lineno, c.callee, c.args_count, tuple(c.kwargs))
756
+ if k not in seen:
757
+ seen.add(k)
758
+ results.append(c)
759
+
760
+ if symbol_name_or_qualname in self._callers:
761
+ for c in self._callers[symbol_name_or_qualname]:
762
+ add_call(c)
763
+
764
+ simple_name = symbol_name_or_qualname.split(".")[-1].split("::")[-1]
765
+ if simple_name != symbol_name_or_qualname and simple_name in self._callers:
766
+ for c in self._callers[simple_name]:
767
+ add_call(c)
768
+
769
+ return results
770
+
771
+ def get_importers(self, symbol_name_or_qualname: str) -> List[ImportReference]:
772
+ """Find all import references targeting this symbol name or qualname."""
773
+ results: List[ImportReference] = []
774
+ seen = set()
775
+
776
+ def add_imp(imp: ImportReference):
777
+ k = (imp.file_path, imp.lineno, imp.name, imp.module)
778
+ if k not in seen:
779
+ seen.add(k)
780
+ results.append(imp)
781
+
782
+ if symbol_name_or_qualname in self._importers:
783
+ for imp in self._importers[symbol_name_or_qualname]:
784
+ add_imp(imp)
785
+
786
+ simple_name = symbol_name_or_qualname.split(".")[-1]
787
+ if simple_name != symbol_name_or_qualname and simple_name in self._importers:
788
+ for imp in self._importers[simple_name]:
789
+ add_imp(imp)
790
+
791
+ return results
792
+
793
+ def get_subclasses(self, class_name: str) -> List[Symbol]:
794
+ """Find all known subclasses inheriting from class_name."""
795
+ return self._subclasses.get(class_name, [])
796
+
797
+ def resolve_class_init(self, class_sym: Symbol) -> Optional[Symbol]:
798
+ """Find constructor definition (__init__ for Python, constructor for TS/JS), checking inheritance."""
799
+ # 1. Direct constructor in class
800
+ init_names = ["__init__", "constructor"]
801
+ for init_name in init_names:
802
+ init_id = f"{class_sym.file_path}::{class_sym.qualname}.{init_name}"
803
+ if init_id in self._definitions:
804
+ return self._definitions[init_id]
805
+ direct_match = self.get_definition(f"{class_sym.qualname}.{init_name}")
806
+ if direct_match:
807
+ return direct_match
808
+
809
+ # 2. Check base classes
810
+ for base_name in getattr(class_sym, "bases", []):
811
+ base_sym = self.get_definition(base_name)
812
+ if base_sym and base_sym.kind == "class":
813
+ base_init = self.resolve_class_init(base_sym)
814
+ if base_init:
815
+ return base_init
816
+ return None
817
+
818
+ def get_file_symbols(self, file_path: str) -> List[Symbol]:
819
+ """Return all symbols in a given file."""
820
+ p = Path(file_path)
821
+ if p.is_absolute():
822
+ try:
823
+ clean_path = str(p.resolve().relative_to(self.workspace_root.resolve())).replace("\\", "/")
824
+ except ValueError:
825
+ clean_path = str(file_path).replace("\\", "/")
826
+ else:
827
+ full_p = (self.workspace_root / file_path).resolve()
828
+ try:
829
+ clean_path = str(full_p.relative_to(self.workspace_root.resolve())).replace("\\", "/")
830
+ except ValueError:
831
+ clean_path = str(file_path).replace("\\", "/")
832
+
833
+ cached = self._file_cache.get(clean_path)
834
+ if not cached:
835
+ return []
836
+ return [self._deserialize_symbol(s) for s in cached.get("symbols", [])]
837
+
838
+ def overlay_transient_symbols(
839
+ self,
840
+ file_path: str,
841
+ symbols: List[Symbol],
842
+ imports: Optional[List[ImportReference]] = None,
843
+ ) -> None:
844
+ """
845
+ In-memory overlay of modified symbols for a file without modifying disk.
846
+ Allows zero-side-effect evaluation of unverified patches.
847
+ """
848
+ try:
849
+ p = Path(file_path)
850
+ clean_path = (
851
+ str(p.resolve().relative_to(self.workspace_root.resolve())).replace("\\", "/")
852
+ if p.is_absolute()
853
+ else str(file_path).replace("\\", "/")
854
+ )
855
+ except ValueError:
856
+ clean_path = str(file_path).replace("\\", "/")
857
+
858
+ self._remove_file_from_indices(clean_path)
859
+ self._add_file_to_indices(clean_path, symbols, imports or [])
860
+ self._file_cache[clean_path] = {
861
+ "mtime": 0.0,
862
+ "hash": "transient",
863
+ "symbols": [self._serialize_symbol(s) for s in symbols],
864
+ "imports": [self._serialize_import(imp) for imp in (imports or [])],
865
+ }
866
+
867
+ def restore_transient_symbols(
868
+ self,
869
+ file_path: str,
870
+ backup_symbols: List[Symbol],
871
+ backup_imports: List[ImportReference],
872
+ cached_backup: Optional[Dict[str, Any]] = None,
873
+ ) -> None:
874
+ """
875
+ Fast incremental rollback of transient symbols and imports to restore default disk state.
876
+ """
877
+ try:
878
+ p = Path(file_path)
879
+ clean_path = (
880
+ str(p.resolve().relative_to(self.workspace_root.resolve())).replace("\\", "/")
881
+ if p.is_absolute()
882
+ else str(file_path).replace("\\", "/")
883
+ )
884
+ except ValueError:
885
+ clean_path = str(file_path).replace("\\", "/")
886
+
887
+ self._remove_file_from_indices(clean_path)
888
+ if backup_symbols or backup_imports:
889
+ self._add_file_to_indices(clean_path, backup_symbols, backup_imports)
890
+
891
+ if cached_backup is not None:
892
+ self._file_cache[clean_path] = cached_backup
893
+ else:
894
+ self._file_cache.pop(clean_path, None)