Files
graphify/graphify/resolver_registry.py
T
vamsi pavan mahesh gunturuandClaude Opus 4.8 86ecb769b6 feat(ruby): type-aware member-call resolution via a resolver framework (#1499)
Resolve Ruby `obj.method()` calls by the inferred type of the receiver
instead of by globally-unique method name. `p = Processor.new; p.run`
now emits a `calls` edge to `Processor#run` and survives name collisions
with unrelated `Worker#run` definitions, where the old name-based match
either resolved by luck or dropped the edge as ambiguous.

Introduces graphify/resolver_registry.py, a behavior-identical
formalization of the existing tail-of-extract() language resolution
passes (Swift #1356, Python #1446 become registered entries), and
graphify/ruby_resolution.py, its first new consumer. Receiver type is
inferred only from unambiguous local `var = ClassName.new` bindings;
ambiguous or unknown receivers resolve to nothing (no false positives).

Note: Ruby member calls are now excluded from name-based cross-file
resolution and resolved by inferred type only. This is an intentional
precision-over-recall change scoped to Ruby: a cross-file `var.method`
whose receiver type cannot be proven from a local `X.new` binding no
longer resolves by name-luck (it produces no edge rather than a possibly
wrong one), matching the project's confidence model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:40:22 +01:00

86 lines
3.1 KiB
Python

"""Registry for cross-file, language-specific resolution passes.
Some call/reference edges can only be resolved with language-specific knowledge
(receiver typing, qualified member calls, framework conventions). Historically
these ran as a hand-wired sequence of suffix-gated
``if <lang>_paths: try: _resolve_<lang>(...)`` blocks at the tail of
``extract.extract()``. That pattern is the de-facto extension point for
per-language resolution; this module formalizes it so a new language plugs in by
registering one ``LanguageResolver`` instead of editing ``extract()``'s body.
This module deliberately knows nothing about any specific language — languages
register themselves (see ``extract.py``), keeping the dependency direction one
way (``extract`` → ``resolver_registry``) and this seam small and reviewable on
its own, separate from the multi-thousand-line ``extract`` module.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class LanguageResolver:
"""One cross-file, language-specific resolution pass.
``resolve`` has the signature ``(per_file, all_nodes, all_edges) -> None`` and
mutates ``all_nodes`` / ``all_edges`` in place, matching the existing
member-call resolvers. ``suffixes`` gates activation: the pass runs only when
the corpus contains at least one file with one of these extensions.
"""
name: str
suffixes: frozenset
resolve: Callable
# Module-level registry, populated by callers via register(). Ordered: resolvers
# run in registration order, preserving any required sequencing between passes.
_REGISTRY: list[LanguageResolver] = []
def register(resolver: LanguageResolver) -> LanguageResolver:
"""Append a resolver to the global registry and return it (for inline use)."""
_REGISTRY.append(resolver)
return resolver
def registered_resolvers() -> list[LanguageResolver]:
"""Return a copy of the registered resolvers, in registration order."""
return list(_REGISTRY)
def run_language_resolvers(
paths: Sequence[Path],
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
*,
resolvers: Sequence[LanguageResolver] | None = None,
) -> None:
"""Run every resolver whose suffix appears in ``paths``.
Behaviorally identical to the prior hand-wired sequence of suffix-gated,
try/except-wrapped passes: same activation rule (suffix present), same
failure handling (log a warning and continue to the next pass), same
execution order (registration order).
``resolvers`` defaults to the global registry; tests pass an explicit list to
exercise the driver in isolation.
"""
active = _REGISTRY if resolvers is None else resolvers
suffixes_present = {p.suffix for p in paths}
for resolver in active:
if not (resolver.suffixes & suffixes_present):
continue
try:
resolver.resolve(per_file, all_nodes, all_edges)
except Exception as exc:
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)