201 lines
7.9 KiB
Python
201 lines
7.9 KiB
Python
"""Post-build Spring DI call resolver.
|
|
|
|
After tree-sitter parsing, Java CALLS edges whose target is a bare method
|
|
name (e.g. ``calculate``) carry ``extra.receiver`` naming the local variable
|
|
that was called on (e.g. ``invoiceCalculationService``). This module
|
|
resolves those receivers through the INJECTS map to their declared type, then
|
|
optionally to the unique concrete implementation via INHERITS edges.
|
|
|
|
Resolution chain:
|
|
receiver variable name
|
|
→ injected interface/class (from INJECTS.extra.field_name)
|
|
→ concrete implementation (from INHERITS, when unique)
|
|
|
|
Only Java files are processed. Edges that are already qualified (contain
|
|
``::``) or have no ``receiver`` extra key are skipped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .graph import GraphStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def resolve_spring_di_calls(store: GraphStore) -> dict:
|
|
"""Resolve Java CALLS edges whose receiver is a Spring-injected field.
|
|
|
|
Safe to call multiple times — already-resolved edges (targets containing
|
|
``::``) are skipped.
|
|
|
|
Returns a dict with resolution counts for telemetry.
|
|
"""
|
|
conn = store._conn
|
|
|
|
# Only process Java files
|
|
java_files: set[str] = {
|
|
row["file_path"]
|
|
for row in conn.execute(
|
|
"SELECT DISTINCT file_path FROM nodes WHERE language = 'java'"
|
|
).fetchall()
|
|
}
|
|
if not java_files:
|
|
return {"files_indexed": 0, "calls_resolved": 0}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Build field_map: (source_qualified_class, field_name) → injected_type
|
|
# from INJECTS edges that carry extra.field_name
|
|
# -----------------------------------------------------------------------
|
|
field_map: dict[tuple[str, str], str] = {}
|
|
injects_rows = conn.execute(
|
|
"SELECT source_qualified, target_qualified, extra FROM edges WHERE kind = 'INJECTS'"
|
|
).fetchall()
|
|
for row in injects_rows:
|
|
try:
|
|
extra = json.loads(row["extra"] or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
extra = {}
|
|
fname = extra.get("field_name")
|
|
if not fname:
|
|
continue
|
|
# source_qualified is the full class qualified name
|
|
class_qual = row["source_qualified"]
|
|
field_map[(class_qual, fname)] = row["target_qualified"]
|
|
|
|
if not field_map:
|
|
logger.info("Spring resolver: no INJECTS edges with field_name found, skipping")
|
|
return {"files_indexed": len(java_files), "calls_resolved": 0}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Build class_name → qualified_name lookup from nodes.
|
|
# Keyed by bare class name; value is the full "file_path::ClassName" form
|
|
# that callers_of uses for its target_qualified exact-match lookup.
|
|
# When a name appears in multiple files (e.g. same interface in several
|
|
# services), we keep the entry with the shortest path as a tiebreaker —
|
|
# this is overridden by the concrete-implementation lookup below.
|
|
# -----------------------------------------------------------------------
|
|
name_to_qual: dict[str, str] = {}
|
|
for row in conn.execute(
|
|
"SELECT name, qualified_name FROM nodes WHERE kind = 'Class' AND language = 'java'"
|
|
).fetchall():
|
|
bare = row["name"]
|
|
qual = row["qualified_name"]
|
|
if bare not in name_to_qual or len(qual) < len(name_to_qual[bare]):
|
|
name_to_qual[bare] = qual
|
|
|
|
# Also index Function nodes so we can build "file::Class.method" targets.
|
|
# key: (class_name, method_name) → full qualified_name of the method node
|
|
method_to_qual: dict[tuple[str, str], str] = {}
|
|
for row in conn.execute(
|
|
"SELECT name, qualified_name, parent_name FROM nodes "
|
|
"WHERE kind IN ('Function', 'Test') AND language = 'java' AND parent_name IS NOT NULL"
|
|
).fetchall():
|
|
method_to_qual[(row["parent_name"], row["name"])] = row["qualified_name"]
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Build implementors: bare interface name → list of implementing class quals
|
|
# from INHERITS edges (Java uses INHERITS for both extends and implements)
|
|
# -----------------------------------------------------------------------
|
|
implementors: dict[str, list[str]] = {}
|
|
for row in conn.execute(
|
|
"SELECT source_qualified, target_qualified FROM edges WHERE kind = 'INHERITS'"
|
|
).fetchall():
|
|
iface = row["target_qualified"]
|
|
impl = row["source_qualified"]
|
|
if any(impl.startswith(f) for f in java_files) or "::" in impl:
|
|
implementors.setdefault(iface, []).append(impl)
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Resolve CALLS edges
|
|
# -----------------------------------------------------------------------
|
|
calls_rows = conn.execute(
|
|
"SELECT id, source_qualified, target_qualified, extra, file_path "
|
|
"FROM edges WHERE kind = 'CALLS'"
|
|
).fetchall()
|
|
|
|
resolved = 0
|
|
|
|
for row in calls_rows:
|
|
if row["file_path"] not in java_files:
|
|
continue
|
|
|
|
try:
|
|
extra = json.loads(row["extra"] or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
extra = {}
|
|
|
|
receiver = extra.get("receiver")
|
|
if not receiver:
|
|
continue
|
|
|
|
# Skip edges already spring-resolved in a previous pass
|
|
if extra.get("spring_resolved"):
|
|
continue
|
|
|
|
# Strip any prior (possibly wrong) qualification — we have a receiver so
|
|
# we can do a better resolution. E.g. "file::ClassName.method" → "method"
|
|
raw_target = row["target_qualified"]
|
|
if "::" in raw_target:
|
|
after = raw_target.split("::", 1)[1]
|
|
method_name = after.split(".")[-1] if "." in after else after
|
|
else:
|
|
method_name = raw_target
|
|
source_qual = row["source_qualified"]
|
|
|
|
# Derive the enclosing class qualified name from source
|
|
# source_qual format: "file_path::ClassName.method_name"
|
|
enclosing_class_qual: str | None = None
|
|
if "::" in source_qual:
|
|
after_sep = source_qual.split("::", 1)[1]
|
|
if "." in after_sep:
|
|
class_part = after_sep.split(".")[0]
|
|
prefix = source_qual.split("::")[0]
|
|
enclosing_class_qual = f"{prefix}::{class_part}"
|
|
else:
|
|
enclosing_class_qual = source_qual
|
|
|
|
if not enclosing_class_qual:
|
|
continue
|
|
|
|
# Look up receiver in field_map for this class
|
|
injected_type = field_map.get((enclosing_class_qual, receiver))
|
|
if not injected_type:
|
|
continue
|
|
|
|
# Resolve to concrete implementation if unique
|
|
impls = implementors.get(injected_type, [])
|
|
if len(impls) == 1:
|
|
concrete_class = impls[0].split("::")[-1]
|
|
fallback = f"{impls[0]}.{method_name}"
|
|
new_target = method_to_qual.get((concrete_class, method_name)) or fallback
|
|
else:
|
|
type_bare = injected_type.rsplit(".", 1)[-1]
|
|
fallback = f"{injected_type}.{method_name}"
|
|
new_target = method_to_qual.get((type_bare, method_name)) or fallback
|
|
|
|
extra["spring_resolved"] = True
|
|
extra["injected_type"] = injected_type
|
|
new_extra = json.dumps(extra)
|
|
|
|
conn.execute(
|
|
"UPDATE edges SET target_qualified = ?, extra = ? WHERE id = ?",
|
|
(new_target, new_extra, row["id"]),
|
|
)
|
|
resolved += 1
|
|
logger.debug(
|
|
"Spring resolved: %s → %s (was %s, receiver=%s)",
|
|
source_qual, new_target, method_name, receiver,
|
|
)
|
|
|
|
if resolved:
|
|
conn.commit()
|
|
|
|
logger.info("Spring DI resolver: resolved %d CALLS edges in %d Java files",
|
|
resolved, len(java_files))
|
|
return {"files_indexed": len(java_files), "calls_resolved": resolved}
|