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.
- code_oracle/__init__.py +30 -0
- code_oracle/cli.py +795 -0
- code_oracle/config.py +145 -0
- code_oracle/dataset.py +5325 -0
- code_oracle/dead_code/__init__.py +32 -0
- code_oracle/dead_code/detector.py +379 -0
- code_oracle/dead_code/entrypoints.py +333 -0
- code_oracle/dead_code/models.py +255 -0
- code_oracle/dead_code/semantics.py +416 -0
- code_oracle/decision.py +906 -0
- code_oracle/engine.py +430 -0
- code_oracle/export_onnx.py +436 -0
- code_oracle/hook.py +531 -0
- code_oracle/indexer.py +894 -0
- code_oracle/languages/__init__.py +114 -0
- code_oracle/languages/common.py +127 -0
- code_oracle/languages/go.py +395 -0
- code_oracle/languages/python.py +336 -0
- code_oracle/languages/rust.py +474 -0
- code_oracle/languages/typescript.py +775 -0
- code_oracle/linearizer.py +166 -0
- code_oracle/locator.py +301 -0
- code_oracle/models.py +237 -0
- code_oracle/perf_lint/__init__.py +38 -0
- code_oracle/perf_lint/engine.py +234 -0
- code_oracle/perf_lint/models.py +229 -0
- code_oracle/perf_lint/rules/__init__.py +31 -0
- code_oracle/perf_lint/rules/async_blocking.py +143 -0
- code_oracle/perf_lint/rules/n_plus_one.py +232 -0
- code_oracle/perf_lint/rules/nested_loops.py +137 -0
- code_oracle/perf_lint/rules/unclosed_res.py +494 -0
- code_oracle/perf_lint/visitor.py +299 -0
- code_oracle/server.py +184 -0
- code_oracle/slicer.py +225 -0
- code_oracle/symbolic.py +459 -0
- code_oracle-0.1.0.dist-info/METADATA +225 -0
- code_oracle-0.1.0.dist-info/RECORD +40 -0
- code_oracle-0.1.0.dist-info/WHEEL +4 -0
- code_oracle-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|