Files
tirth8205--code-review-graph/code_review_graph/rescript_resolver.py
T
2026-07-13 12:42:18 +08:00

207 lines
7.4 KiB
Python

"""Post-build pass that resolves ReScript cross-module references.
The per-file parser emits CALLS/IMPORTS_FROM edges with bare targets like
``LogicUtils.safeParse`` because the parser only sees one file at a time.
This module runs after ``full_build`` / incremental updates and rewrites
those targets to canonical qualified names like
``<abs-path>/LogicUtils.res::safeParse`` so ``callers_of``,
``get_impact_radius`` and ``importers_of`` work correctly across files.
Resolutions performed:
1. ``Module.fn`` / ``Module.Sub.fn`` CALLS edges → canonical node
when a ``.res`` / ``.resi`` file with matching basename exists.
2. Bare ``fn(...)`` CALLS edges in a file that ``open`` / ``include``\\s
a module → canonical node in that module's file.
3. IMPORTS_FROM edges targeting a module name (open / include / jsx /
module_alias / external_module) → the target file path, so
``importers_of(<path>)`` finds every consuming file.
Only the ``target_qualified`` column is updated; source and edge kind are
preserved. Edges that cannot be resolved are left unchanged.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .graph import GraphStore
logger = logging.getLogger(__name__)
def resolve_rescript_cross_module(store: GraphStore) -> dict:
"""Resolve ReScript cross-module targets in the graph store.
Safe to call multiple times: already-resolved edges (targets containing
``::``) are skipped.
Returns a dict with resolution counts for telemetry.
"""
conn = store._conn # intentional: post-build maintenance pass
# Basename (module name) → absolute file path, preferring .res over .resi.
basename_to_path: dict[str, str] = {}
rescript_files: set[str] = set()
for file_path in store.get_all_files():
p = Path(file_path)
suffix = p.suffix.lower()
if suffix not in (".res", ".resi"):
continue
rescript_files.add(file_path)
stem = p.stem
existing = basename_to_path.get(stem)
if existing is None or existing.lower().endswith(".resi"):
# Prefer implementation (.res) over interface (.resi).
basename_to_path[stem] = file_path
if not basename_to_path:
return {"files_indexed": 0, "calls_resolved": 0, "imports_resolved": 0}
# Per-file opens/includes so we can resolve bare calls.
opens_by_file: dict[str, list[str]] = {}
imports_rows = conn.execute(
"SELECT source_qualified, target_qualified, file_path, extra "
"FROM edges WHERE kind = 'IMPORTS_FROM'"
).fetchall()
for row in imports_rows:
fp = row["file_path"]
if fp not in rescript_files:
continue
try:
extra = json.loads(row["extra"] or "{}")
except (json.JSONDecodeError, TypeError):
extra = {}
kind = extra.get("rescript_import_kind")
if kind in ("open", "include"):
# Strip nested submodule — root determines file.
root = row["target_qualified"].split(".", 1)[0]
opens_by_file.setdefault(fp, []).append(root)
# --- 1 + 2. Resolve CALLS edges ---
call_rows = conn.execute(
"SELECT id, source_qualified, target_qualified, file_path "
"FROM edges WHERE kind = 'CALLS'"
).fetchall()
call_updates: list[tuple[str, int]] = []
for row in call_rows:
target = row["target_qualified"]
if "::" in target:
continue # already resolved
resolved = _resolve_call_target(
target,
row["file_path"],
basename_to_path,
opens_by_file,
store,
)
if resolved and resolved != target:
call_updates.append((resolved, row["id"]))
# --- 3. Resolve IMPORTS_FROM edge targets to file paths ---
import_updates: list[tuple[str, int]] = []
import_rows_full = conn.execute(
"SELECT id, target_qualified, file_path FROM edges "
"WHERE kind = 'IMPORTS_FROM'"
).fetchall()
for row in import_rows_full:
target = row["target_qualified"]
if target in rescript_files:
continue # already a file path
if "/" in target or "\\" in target:
continue # looks like a path already (e.g. relative JS import)
root = target.split(".", 1)[0]
file_target = basename_to_path.get(root)
if file_target and file_target != target:
import_updates.append((file_target, row["id"]))
cur = conn.cursor()
for new_target, edge_id in call_updates:
cur.execute(
"UPDATE edges SET target_qualified = ? WHERE id = ?",
(new_target, edge_id),
)
for new_target, edge_id in import_updates:
cur.execute(
"UPDATE edges SET target_qualified = ? WHERE id = ?",
(new_target, edge_id),
)
conn.commit()
store._invalidate_cache()
result = {
"files_indexed": len(basename_to_path),
"calls_resolved": len(call_updates),
"imports_resolved": len(import_updates),
}
logger.info("ReScript cross-module resolution: %s", result)
return result
def _resolve_call_target(
target: str,
file_path: str,
basename_to_path: dict[str, str],
opens_by_file: dict[str, list[str]],
store: GraphStore,
) -> str | None:
"""Resolve a CALLS edge's ``target_qualified`` to a canonical qualified
node name. Returns None when no resolution is possible.
"""
# Dotted: `Module.fn` or `Module.Sub.fn`.
if "." in target:
head, _, rest = target.partition(".")
target_file = basename_to_path.get(head)
if target_file is None:
return None
candidate = _pick_existing_qualified(target_file, rest, store)
return candidate
# Bare: `fn` — only resolvable via an open/include in the calling file.
for opened in opens_by_file.get(file_path, []):
target_file = basename_to_path.get(opened)
if target_file is None:
continue
candidate = f"{target_file}::{target}"
if store.get_node(candidate) is not None:
return candidate
return None
def _pick_existing_qualified(
target_file: str, rest: str, store: GraphStore,
) -> str | None:
"""Given ``LogicUtils.foo.bar``, try ``file::foo.bar`` then
``file::Foo.bar`` then ``file::foo``. Return the first one that
corresponds to an existing node.
"""
# Direct: rest as the qualified name tail.
direct = f"{target_file}::{rest}"
if store.get_node(direct) is not None:
return direct
# Dotted rest like `Sub.fn`: parent_name = Sub, name = fn.
# _qualify formats it the same way, so `direct` would already match if
# the node was stored with that exact qualified name.
# Some targets include a trailing member-access that isn't part of
# the qualified node (e.g. `LogicUtils.safeParse.resp` — property on
# the result). Try peeling from the right.
parts = rest.split(".")
while len(parts) > 1:
parts.pop()
candidate = f"{target_file}::{'.'.join(parts)}"
if store.get_node(candidate) is not None:
return candidate
# Last resort: top-level `file::name` (first part only).
first = rest.split(".", 1)[0]
candidate = f"{target_file}::{first}"
if store.get_node(candidate) is not None:
return candidate
return None