code-review-graph-codeblackwell 2.3.6.post1__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_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Multi-repo registry and connection pool.
|
|
2
|
+
|
|
3
|
+
Manages a registry of multiple repositories at ``~/.code-review-graph/registry.json``
|
|
4
|
+
and provides a connection pool for concurrent access to multiple graph databases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Default registry path
|
|
19
|
+
_REGISTRY_DIR = Path.home() / ".code-review-graph"
|
|
20
|
+
_REGISTRY_PATH = _REGISTRY_DIR / "registry.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Registry:
|
|
24
|
+
"""Manages a JSON-based registry of code-review-graph repositories.
|
|
25
|
+
|
|
26
|
+
Each entry stores the repo path and an optional alias.
|
|
27
|
+
The registry lives at ``~/.code-review-graph/registry.json``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
31
|
+
self._path = path or _REGISTRY_PATH
|
|
32
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._repos: list[dict[str, str]] = []
|
|
35
|
+
self._load()
|
|
36
|
+
|
|
37
|
+
def _load(self) -> None:
|
|
38
|
+
"""Load registry from disk."""
|
|
39
|
+
if self._path.exists():
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(self._path.read_text(encoding="utf-8", errors="replace"))
|
|
42
|
+
self._repos = data.get("repos", [])
|
|
43
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
44
|
+
logger.warning("Invalid registry file, starting fresh: %s", self._path)
|
|
45
|
+
self._repos = []
|
|
46
|
+
else:
|
|
47
|
+
self._repos = []
|
|
48
|
+
|
|
49
|
+
def _save(self) -> None:
|
|
50
|
+
"""Write registry to disk."""
|
|
51
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
data = {"repos": self._repos}
|
|
53
|
+
self._path.write_text(
|
|
54
|
+
json.dumps(data, indent=2) + "\n", encoding="utf-8"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def register(
|
|
58
|
+
self, path: str, alias: str | None = None, data_dir: str | None = None,
|
|
59
|
+
) -> dict[str, str]:
|
|
60
|
+
"""Register a repository path.
|
|
61
|
+
|
|
62
|
+
Validates that the path contains a ``.git`` or ``.code-review-graph``
|
|
63
|
+
directory.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Absolute or relative path to the repository root.
|
|
67
|
+
alias: Optional short alias for the repository.
|
|
68
|
+
data_dir: Optional external directory for graph database.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The registered entry dict.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the path is not a valid repository.
|
|
75
|
+
"""
|
|
76
|
+
resolved = Path(path).resolve()
|
|
77
|
+
if not resolved.is_dir():
|
|
78
|
+
raise ValueError(f"Path is not a directory: {resolved}")
|
|
79
|
+
has_repo_marker = (
|
|
80
|
+
(resolved / ".git").exists()
|
|
81
|
+
or (resolved / ".svn").exists()
|
|
82
|
+
or (resolved / ".code-review-graph").exists()
|
|
83
|
+
)
|
|
84
|
+
if not has_repo_marker:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Path does not look like a repository "
|
|
87
|
+
f"(no .git, .svn, or .code-review-graph): {resolved}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with self._lock:
|
|
91
|
+
# Check for duplicate path
|
|
92
|
+
str_path = str(resolved)
|
|
93
|
+
for entry in self._repos:
|
|
94
|
+
if entry["path"] == str_path:
|
|
95
|
+
# Update alias and/or data_dir if provided
|
|
96
|
+
if alias:
|
|
97
|
+
entry["alias"] = alias
|
|
98
|
+
if data_dir:
|
|
99
|
+
entry["data_dir"] = str(Path(data_dir).resolve())
|
|
100
|
+
self._save()
|
|
101
|
+
return entry
|
|
102
|
+
|
|
103
|
+
new_entry: dict[str, str] = {"path": str_path}
|
|
104
|
+
if alias:
|
|
105
|
+
new_entry["alias"] = alias
|
|
106
|
+
if data_dir:
|
|
107
|
+
new_entry["data_dir"] = str(Path(data_dir).resolve())
|
|
108
|
+
self._repos.append(new_entry)
|
|
109
|
+
self._save()
|
|
110
|
+
return new_entry
|
|
111
|
+
|
|
112
|
+
def unregister(self, path_or_alias: str) -> bool:
|
|
113
|
+
"""Remove a repository by path or alias.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
path_or_alias: Either the absolute path or the alias.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if an entry was removed, False otherwise.
|
|
120
|
+
"""
|
|
121
|
+
with self._lock:
|
|
122
|
+
resolved = str(Path(path_or_alias).resolve())
|
|
123
|
+
original_len = len(self._repos)
|
|
124
|
+
self._repos = [
|
|
125
|
+
entry for entry in self._repos
|
|
126
|
+
if entry["path"] != resolved
|
|
127
|
+
and entry.get("alias") != path_or_alias
|
|
128
|
+
]
|
|
129
|
+
if len(self._repos) < original_len:
|
|
130
|
+
self._save()
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def list_repos(self) -> list[dict[str, str]]:
|
|
135
|
+
"""Return list of all registered repositories.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of dicts with 'path' and optional 'alias' keys.
|
|
139
|
+
"""
|
|
140
|
+
with self._lock:
|
|
141
|
+
return list(self._repos)
|
|
142
|
+
|
|
143
|
+
def find_by_alias(self, alias: str) -> dict[str, str] | None:
|
|
144
|
+
"""Look up a repository by its alias.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
alias: The alias to search for.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The matching entry, or None.
|
|
151
|
+
"""
|
|
152
|
+
with self._lock:
|
|
153
|
+
for entry in self._repos:
|
|
154
|
+
if entry.get("alias") == alias:
|
|
155
|
+
return dict(entry)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def find_by_path(self, path: str) -> dict[str, str] | None:
|
|
159
|
+
"""Look up a repository by its path.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path: The path to search for.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The matching entry, or None.
|
|
166
|
+
"""
|
|
167
|
+
resolved = str(Path(path).resolve())
|
|
168
|
+
with self._lock:
|
|
169
|
+
for entry in self._repos:
|
|
170
|
+
if entry["path"] == resolved:
|
|
171
|
+
return dict(entry)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def set_data_dir(self, path: str, data_dir: str) -> dict[str, str]:
|
|
175
|
+
"""Set the external data directory for a repository.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
path: Repository path (absolute or relative).
|
|
179
|
+
data_dir: External directory path to store graph database.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The updated or created registry entry.
|
|
183
|
+
"""
|
|
184
|
+
resolved = str(Path(path).resolve())
|
|
185
|
+
data_resolved = str(Path(data_dir).resolve())
|
|
186
|
+
|
|
187
|
+
with self._lock:
|
|
188
|
+
# Check for existing entry
|
|
189
|
+
for entry in self._repos:
|
|
190
|
+
if entry["path"] == resolved:
|
|
191
|
+
entry["data_dir"] = data_resolved
|
|
192
|
+
self._save()
|
|
193
|
+
return dict(entry)
|
|
194
|
+
|
|
195
|
+
# Create new entry if not found
|
|
196
|
+
new_entry = {
|
|
197
|
+
"path": resolved,
|
|
198
|
+
"data_dir": data_resolved
|
|
199
|
+
}
|
|
200
|
+
self._repos.append(new_entry)
|
|
201
|
+
self._save()
|
|
202
|
+
return new_entry
|
|
203
|
+
|
|
204
|
+
def get_data_dir_for_repo(self, path: str) -> str | None:
|
|
205
|
+
"""Get the stored data directory for a repository.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
path: Repository path (absolute or relative).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The stored data_dir path, or None if not set.
|
|
212
|
+
"""
|
|
213
|
+
resolved = str(Path(path).resolve())
|
|
214
|
+
with self._lock:
|
|
215
|
+
for entry in self._repos:
|
|
216
|
+
if entry["path"] == resolved:
|
|
217
|
+
return entry.get("data_dir")
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ConnectionPool:
|
|
222
|
+
"""LRU connection pool for SQLite graph databases.
|
|
223
|
+
|
|
224
|
+
Caches open connections keyed by database path, evicting the least
|
|
225
|
+
recently used connection when the pool is full.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(self, max_size: int = 10) -> None:
|
|
229
|
+
self._max_size = max_size
|
|
230
|
+
self._pool: OrderedDict[str, sqlite3.Connection] = OrderedDict()
|
|
231
|
+
self._lock = threading.Lock()
|
|
232
|
+
|
|
233
|
+
def get(self, db_path: str) -> sqlite3.Connection:
|
|
234
|
+
"""Get or create a connection for the given database path.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
db_path: Path to the SQLite database file.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
An open SQLite connection.
|
|
241
|
+
"""
|
|
242
|
+
key = str(Path(db_path).resolve())
|
|
243
|
+
with self._lock:
|
|
244
|
+
if key in self._pool:
|
|
245
|
+
self._pool.move_to_end(key)
|
|
246
|
+
return self._pool[key]
|
|
247
|
+
|
|
248
|
+
# Evict LRU if full
|
|
249
|
+
while len(self._pool) >= self._max_size:
|
|
250
|
+
evict_key, evict_conn = self._pool.popitem(last=False)
|
|
251
|
+
try:
|
|
252
|
+
evict_conn.close()
|
|
253
|
+
except sqlite3.Error:
|
|
254
|
+
logger.debug("Failed to close evicted connection: %s", evict_key)
|
|
255
|
+
logger.debug("Evicted connection: %s", evict_key)
|
|
256
|
+
|
|
257
|
+
conn = sqlite3.connect(
|
|
258
|
+
key, timeout=30, check_same_thread=False,
|
|
259
|
+
isolation_level=None,
|
|
260
|
+
)
|
|
261
|
+
conn.row_factory = sqlite3.Row
|
|
262
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
263
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
264
|
+
self._pool[key] = conn
|
|
265
|
+
return conn
|
|
266
|
+
|
|
267
|
+
def close_all(self) -> None:
|
|
268
|
+
"""Close all connections in the pool."""
|
|
269
|
+
with self._lock:
|
|
270
|
+
for key, conn in self._pool.items():
|
|
271
|
+
try:
|
|
272
|
+
conn.close()
|
|
273
|
+
except sqlite3.Error:
|
|
274
|
+
logger.debug("Failed to close connection: %s", key)
|
|
275
|
+
self._pool.clear()
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def size(self) -> int:
|
|
279
|
+
"""Current number of open connections."""
|
|
280
|
+
with self._lock:
|
|
281
|
+
return len(self._pool)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def resolve_repo(
|
|
285
|
+
registry: Registry,
|
|
286
|
+
repo: str | None,
|
|
287
|
+
cwd: str | None = None,
|
|
288
|
+
) -> str | None:
|
|
289
|
+
"""Resolve a repo parameter to an absolute path.
|
|
290
|
+
|
|
291
|
+
Resolution order:
|
|
292
|
+
1. If repo is given, try as alias first.
|
|
293
|
+
2. If repo is given and not an alias, try as a direct path.
|
|
294
|
+
3. If repo is None, use cwd.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
registry: The Registry instance.
|
|
298
|
+
repo: Alias or path string, or None.
|
|
299
|
+
cwd: Current working directory fallback.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Resolved absolute path string, or None if unresolvable.
|
|
303
|
+
"""
|
|
304
|
+
if repo:
|
|
305
|
+
# Try alias first
|
|
306
|
+
entry = registry.find_by_alias(repo)
|
|
307
|
+
if entry:
|
|
308
|
+
return entry["path"]
|
|
309
|
+
|
|
310
|
+
# Try as direct path
|
|
311
|
+
path = Path(repo).resolve()
|
|
312
|
+
if path.is_dir():
|
|
313
|
+
return str(path)
|
|
314
|
+
|
|
315
|
+
# Fall back to CWD
|
|
316
|
+
if cwd:
|
|
317
|
+
return str(Path(cwd).resolve())
|
|
318
|
+
|
|
319
|
+
return None
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Post-build pass that resolves ReScript cross-module references.
|
|
2
|
+
|
|
3
|
+
The per-file parser emits CALLS/IMPORTS_FROM edges with bare targets like
|
|
4
|
+
``LogicUtils.safeParse`` because the parser only sees one file at a time.
|
|
5
|
+
This module runs after ``full_build`` / incremental updates and rewrites
|
|
6
|
+
those targets to canonical qualified names like
|
|
7
|
+
``<abs-path>/LogicUtils.res::safeParse`` so ``callers_of``,
|
|
8
|
+
``get_impact_radius`` and ``importers_of`` work correctly across files.
|
|
9
|
+
|
|
10
|
+
Resolutions performed:
|
|
11
|
+
1. ``Module.fn`` / ``Module.Sub.fn`` CALLS edges → canonical node
|
|
12
|
+
when a ``.res`` / ``.resi`` file with matching basename exists.
|
|
13
|
+
2. Bare ``fn(...)`` CALLS edges in a file that ``open`` / ``include``\\s
|
|
14
|
+
a module → canonical node in that module's file.
|
|
15
|
+
3. IMPORTS_FROM edges targeting a module name (open / include / jsx /
|
|
16
|
+
module_alias / external_module) → the target file path, so
|
|
17
|
+
``importers_of(<path>)`` finds every consuming file.
|
|
18
|
+
|
|
19
|
+
Only the ``target_qualified`` column is updated; source and edge kind are
|
|
20
|
+
preserved. Edges that cannot be resolved are left unchanged.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .graph import GraphStore
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_rescript_cross_module(store: GraphStore) -> dict:
|
|
37
|
+
"""Resolve ReScript cross-module targets in the graph store.
|
|
38
|
+
|
|
39
|
+
Safe to call multiple times: already-resolved edges (targets containing
|
|
40
|
+
``::``) are skipped.
|
|
41
|
+
|
|
42
|
+
Returns a dict with resolution counts for telemetry.
|
|
43
|
+
"""
|
|
44
|
+
conn = store._conn # intentional: post-build maintenance pass
|
|
45
|
+
|
|
46
|
+
# Basename (module name) → absolute file path, preferring .res over .resi.
|
|
47
|
+
basename_to_path: dict[str, str] = {}
|
|
48
|
+
rescript_files: set[str] = set()
|
|
49
|
+
for file_path in store.get_all_files():
|
|
50
|
+
p = Path(file_path)
|
|
51
|
+
suffix = p.suffix.lower()
|
|
52
|
+
if suffix not in (".res", ".resi"):
|
|
53
|
+
continue
|
|
54
|
+
rescript_files.add(file_path)
|
|
55
|
+
stem = p.stem
|
|
56
|
+
existing = basename_to_path.get(stem)
|
|
57
|
+
if existing is None or existing.lower().endswith(".resi"):
|
|
58
|
+
# Prefer implementation (.res) over interface (.resi).
|
|
59
|
+
basename_to_path[stem] = file_path
|
|
60
|
+
|
|
61
|
+
if not basename_to_path:
|
|
62
|
+
return {"files_indexed": 0, "calls_resolved": 0, "imports_resolved": 0}
|
|
63
|
+
|
|
64
|
+
# Per-file opens/includes so we can resolve bare calls.
|
|
65
|
+
opens_by_file: dict[str, list[str]] = {}
|
|
66
|
+
imports_rows = conn.execute(
|
|
67
|
+
"SELECT source_qualified, target_qualified, file_path, extra "
|
|
68
|
+
"FROM edges WHERE kind = 'IMPORTS_FROM'"
|
|
69
|
+
).fetchall()
|
|
70
|
+
for row in imports_rows:
|
|
71
|
+
fp = row["file_path"]
|
|
72
|
+
if fp not in rescript_files:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
extra = json.loads(row["extra"] or "{}")
|
|
76
|
+
except (json.JSONDecodeError, TypeError):
|
|
77
|
+
extra = {}
|
|
78
|
+
kind = extra.get("rescript_import_kind")
|
|
79
|
+
if kind in ("open", "include"):
|
|
80
|
+
# Strip nested submodule — root determines file.
|
|
81
|
+
root = row["target_qualified"].split(".", 1)[0]
|
|
82
|
+
opens_by_file.setdefault(fp, []).append(root)
|
|
83
|
+
|
|
84
|
+
# --- 1 + 2. Resolve CALLS edges ---
|
|
85
|
+
call_rows = conn.execute(
|
|
86
|
+
"SELECT id, source_qualified, target_qualified, file_path "
|
|
87
|
+
"FROM edges WHERE kind = 'CALLS'"
|
|
88
|
+
).fetchall()
|
|
89
|
+
|
|
90
|
+
call_updates: list[tuple[str, int]] = []
|
|
91
|
+
for row in call_rows:
|
|
92
|
+
target = row["target_qualified"]
|
|
93
|
+
if "::" in target:
|
|
94
|
+
continue # already resolved
|
|
95
|
+
|
|
96
|
+
resolved = _resolve_call_target(
|
|
97
|
+
target,
|
|
98
|
+
row["file_path"],
|
|
99
|
+
basename_to_path,
|
|
100
|
+
opens_by_file,
|
|
101
|
+
store,
|
|
102
|
+
)
|
|
103
|
+
if resolved and resolved != target:
|
|
104
|
+
call_updates.append((resolved, row["id"]))
|
|
105
|
+
|
|
106
|
+
# --- 3. Resolve IMPORTS_FROM edge targets to file paths ---
|
|
107
|
+
import_updates: list[tuple[str, int]] = []
|
|
108
|
+
import_rows_full = conn.execute(
|
|
109
|
+
"SELECT id, target_qualified, file_path FROM edges "
|
|
110
|
+
"WHERE kind = 'IMPORTS_FROM'"
|
|
111
|
+
).fetchall()
|
|
112
|
+
for row in import_rows_full:
|
|
113
|
+
target = row["target_qualified"]
|
|
114
|
+
if target in rescript_files:
|
|
115
|
+
continue # already a file path
|
|
116
|
+
if "/" in target or "\\" in target:
|
|
117
|
+
continue # looks like a path already (e.g. relative JS import)
|
|
118
|
+
root = target.split(".", 1)[0]
|
|
119
|
+
file_target = basename_to_path.get(root)
|
|
120
|
+
if file_target and file_target != target:
|
|
121
|
+
import_updates.append((file_target, row["id"]))
|
|
122
|
+
|
|
123
|
+
cur = conn.cursor()
|
|
124
|
+
for new_target, edge_id in call_updates:
|
|
125
|
+
cur.execute(
|
|
126
|
+
"UPDATE edges SET target_qualified = ? WHERE id = ?",
|
|
127
|
+
(new_target, edge_id),
|
|
128
|
+
)
|
|
129
|
+
for new_target, edge_id in import_updates:
|
|
130
|
+
cur.execute(
|
|
131
|
+
"UPDATE edges SET target_qualified = ? WHERE id = ?",
|
|
132
|
+
(new_target, edge_id),
|
|
133
|
+
)
|
|
134
|
+
conn.commit()
|
|
135
|
+
store._invalidate_cache()
|
|
136
|
+
|
|
137
|
+
result = {
|
|
138
|
+
"files_indexed": len(basename_to_path),
|
|
139
|
+
"calls_resolved": len(call_updates),
|
|
140
|
+
"imports_resolved": len(import_updates),
|
|
141
|
+
}
|
|
142
|
+
logger.info("ReScript cross-module resolution: %s", result)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_call_target(
|
|
147
|
+
target: str,
|
|
148
|
+
file_path: str,
|
|
149
|
+
basename_to_path: dict[str, str],
|
|
150
|
+
opens_by_file: dict[str, list[str]],
|
|
151
|
+
store: GraphStore,
|
|
152
|
+
) -> str | None:
|
|
153
|
+
"""Resolve a CALLS edge's ``target_qualified`` to a canonical qualified
|
|
154
|
+
node name. Returns None when no resolution is possible.
|
|
155
|
+
"""
|
|
156
|
+
# Dotted: `Module.fn` or `Module.Sub.fn`.
|
|
157
|
+
if "." in target:
|
|
158
|
+
head, _, rest = target.partition(".")
|
|
159
|
+
target_file = basename_to_path.get(head)
|
|
160
|
+
if target_file is None:
|
|
161
|
+
return None
|
|
162
|
+
candidate = _pick_existing_qualified(target_file, rest, store)
|
|
163
|
+
return candidate
|
|
164
|
+
|
|
165
|
+
# Bare: `fn` — only resolvable via an open/include in the calling file.
|
|
166
|
+
for opened in opens_by_file.get(file_path, []):
|
|
167
|
+
target_file = basename_to_path.get(opened)
|
|
168
|
+
if target_file is None:
|
|
169
|
+
continue
|
|
170
|
+
candidate = f"{target_file}::{target}"
|
|
171
|
+
if store.get_node(candidate) is not None:
|
|
172
|
+
return candidate
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _pick_existing_qualified(
|
|
177
|
+
target_file: str, rest: str, store: GraphStore,
|
|
178
|
+
) -> str | None:
|
|
179
|
+
"""Given ``LogicUtils.foo.bar``, try ``file::foo.bar`` then
|
|
180
|
+
``file::Foo.bar`` then ``file::foo``. Return the first one that
|
|
181
|
+
corresponds to an existing node.
|
|
182
|
+
"""
|
|
183
|
+
# Direct: rest as the qualified name tail.
|
|
184
|
+
direct = f"{target_file}::{rest}"
|
|
185
|
+
if store.get_node(direct) is not None:
|
|
186
|
+
return direct
|
|
187
|
+
|
|
188
|
+
# Dotted rest like `Sub.fn`: parent_name = Sub, name = fn.
|
|
189
|
+
# _qualify formats it the same way, so `direct` would already match if
|
|
190
|
+
# the node was stored with that exact qualified name.
|
|
191
|
+
|
|
192
|
+
# Some targets include a trailing member-access that isn't part of
|
|
193
|
+
# the qualified node (e.g. `LogicUtils.safeParse.resp` — property on
|
|
194
|
+
# the result). Try peeling from the right.
|
|
195
|
+
parts = rest.split(".")
|
|
196
|
+
while len(parts) > 1:
|
|
197
|
+
parts.pop()
|
|
198
|
+
candidate = f"{target_file}::{'.'.join(parts)}"
|
|
199
|
+
if store.get_node(candidate) is not None:
|
|
200
|
+
return candidate
|
|
201
|
+
# Last resort: top-level `file::name` (first part only).
|
|
202
|
+
first = rest.split(".", 1)[0]
|
|
203
|
+
candidate = f"{target_file}::{first}"
|
|
204
|
+
if store.get_node(candidate) is not None:
|
|
205
|
+
return candidate
|
|
206
|
+
return None
|