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>
This commit is contained in:
vamsi pavan mahesh gunturu
2026-06-29 09:40:22 +01:00
committed by safishamsi
parent 12193a89f1
commit 86ecb769b6
5 changed files with 596 additions and 22 deletions
+117 -22
View File
@@ -14,6 +14,12 @@ from typing import Any, Callable
from .cache import load_cached, save_cached
from .mcp_ingest import extract_mcp_config, is_mcp_config_path
from .manifest_ingest import extract_package_manifest, is_package_manifest_path
from .resolver_registry import (
LanguageResolver,
register as register_language_resolver,
run_language_resolvers,
)
from .ruby_resolution import resolve_ruby_member_calls
# --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) ---
from graphify.extractors.base import ( # noqa: F401
@@ -2391,6 +2397,63 @@ _SWIFT_CONFIG = LanguageConfig(
import_handler=_import_swift,
)
# ── Ruby local type inference (for member-call resolution) ─────────────────────
def _ruby_new_class_name(node, source: bytes) -> str | None:
"""Return ``ClassName`` if ``node`` is a ``ClassName.new(...)`` call, else None.
Only a bare capitalized constant receiver counts (``Processor.new``);
namespaced (``A::B.new``) and dynamic receivers are intentionally ignored so
the binding stays unambiguous.
"""
if node is None or node.type != "call":
return None
recv = node.child_by_field_name("receiver")
meth = node.child_by_field_name("method")
if recv is None or meth is None:
return None
if recv.type != "constant" or _read_text(meth, source) != "new":
return None
return _read_text(recv, source)
def _ruby_local_class_bindings(body_node, source: bytes) -> dict[str, str | None]:
"""Map ``local_var -> ClassName`` for ``var = ClassName.new`` within one Ruby
method body, not descending into nested method definitions.
100%-confidence contract: a variable assigned more than once, or to anything
other than a single ``Constant.new``, maps to ``None`` (ambiguous) so callers
never resolve it. Only the certain single-binding case carries a type.
"""
bindings: dict[str, str | None] = {}
boundary = {"method", "singleton_method"}
def visit(n) -> None:
for child in n.children:
if child.type in boundary:
continue # nested method has its own scope
if child.type == "assignment":
left = child.child_by_field_name("left")
right = child.child_by_field_name("right")
if left is not None and left.type == "identifier":
var = _read_text(left, source)
cls = _ruby_new_class_name(right, source) if right is not None else None
if cls is None:
# assigned to something we can't type: poison if it was typed
if var in bindings:
bindings[var] = None
elif var in bindings:
if bindings[var] != cls:
bindings[var] = None # reassigned to a different class
else:
bindings[var] = cls
visit(child)
visit(body_node)
return bindings
# ── Generic extractor ─────────────────────────────────────────────────────────
def _extract_generic(
@@ -3567,6 +3630,10 @@ def _extract_generic(
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
seen_bind_pairs: set[tuple[str, str, str]] = set()
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
# populated before walk_calls runs. Lets member-call raw_calls carry a
# receiver_type so the cross-file pass resolves `var.method` by type (#ruby).
ruby_var_types: dict[str, dict[str, str | None]] = {}
def _php_class_const_scope(n) -> str | None:
scope = n.child_by_field_name("scope")
@@ -3701,6 +3768,20 @@ def _extract_generic(
raw = _read_text(type_node, source).split("<", 1)[0].strip()
if raw:
callee_name = raw.rsplit(".", 1)[-1]
elif config.ts_module == "tree_sitter_ruby":
# Ruby's `call` node carries `receiver` and `method` as direct
# fields (no intermediate accessor node), so the generic accessor
# model doesn't apply. Read them directly and capture a simple
# receiver (`p` in `p.run`, `Processor` in `Processor.new`) so the
# cross-file pass can resolve member calls by the receiver's type.
meth = node.child_by_field_name("method")
if meth is not None:
callee_name = _read_text(meth, source)
recv = node.child_by_field_name("receiver")
if recv is not None:
is_member_call = True
if recv.type in ("identifier", "constant"):
member_receiver = _read_text(recv, source)
else:
# Generic: get callee from call_function_field
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
@@ -3753,14 +3834,21 @@ def _extract_generic(
})
elif callee_name and not tgt_nid:
# Callee not in this file — save for cross-file resolution in extract()
raw_calls.append({
rc_entry = {
"caller_nid": caller_nid,
"callee": callee_name,
"is_member_call": is_member_call,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"receiver": swift_receiver or member_receiver,
})
}
# Ruby: attach the receiver's inferred type from the method's
# local `var = Const.new` bindings, when unambiguously known.
if member_receiver and config.ts_module == "tree_sitter_ruby":
rc_entry["receiver_type"] = ruby_var_types.get(
caller_nid, {}
).get(member_receiver)
raw_calls.append(rc_entry)
# Helper function calls: config('foo.bar') → uses_config edge to "foo"
if (callee_name and callee_name in config.helper_fn_names):
@@ -3889,6 +3977,10 @@ def _extract_generic(
for child in node.children:
walk_calls(child, caller_nid)
if config.ts_module == "tree_sitter_ruby":
for caller_nid, body_node in function_bodies:
ruby_var_types[caller_nid] = _ruby_local_class_bindings(body_node, source)
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
@@ -9494,6 +9586,23 @@ def _resolve_python_member_calls(
})
# Register the cross-file, language-specific member-call resolvers into the shared
# registry (framework lives in graphify.resolver_registry). A new language plugs in
# by adding one register() call below — no edits to extract()'s body. Order
# preserved from the prior inlined wiring: Swift (#1356) before Python (#1446).
register_language_resolver(
LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls)
)
register_language_resolver(
LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls)
)
# Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in
# graphify.ruby_resolution; registered here as a second consumer of the framework.
register_language_resolver(
LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls)
)
def extract_objc(path: Path) -> dict:
"""Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files."""
try:
@@ -13528,26 +13637,12 @@ def extract(
"weight": 1.0,
})
# Cross-file Swift member-call resolution (#1356). Runs after the shared call
# pass so node ids/caller_nids are final; additive (only receiver-typed calls
# the shared pass skipped), with a single-definition god-node guard.
swift_paths = [p for p in paths if p.suffix == ".swift"]
if swift_paths:
try:
_resolve_swift_member_calls(per_file, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Swift member-call resolution failed, skipping: %s", exc)
# Cross-file Python qualified class-method resolution (#1446). Same shape as the
# Swift pass: additive, runs after id-disambiguation, single-definition guard.
py_paths = [p for p in paths if p.suffix == ".py"]
if py_paths:
try:
_resolve_python_member_calls(per_file, all_nodes, all_edges)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Python member-call resolution failed, skipping: %s", exc)
# Cross-file, language-specific member-call resolution. Runs after the shared
# call pass so node ids/caller_nids are final; each pass is additive (only the
# receiver-typed/qualified calls the shared pass skipped) with its own
# single-definition god-node guard. Registered in graphify.resolver_registry so
# a new language plugs in without editing this body (#1356 Swift, #1446 Python).
run_language_resolvers(paths, per_file, all_nodes, all_edges)
# Relativize source_file fields so paths are portable across machines (#555)
for item in all_nodes + all_edges:
+85
View File
@@ -0,0 +1,85 @@
"""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)
+125
View File
@@ -0,0 +1,125 @@
"""Type-aware cross-file resolution for Ruby member calls.
Ruby has no type annotations and reuses method names heavily, so resolving
``obj.method()`` by globally-unique name is both lossy (drops on collision) and
unsafe (can attach to the wrong same-named method). This resolver instead uses
the receiver's *type*, inferred at extraction time from local
``var = ClassName.new`` bindings and carried on each member-call raw_call as
``receiver_type``.
It resolves two shapes, both at EXTRACTED (1.0) confidence and only when the
target is certain (single owning class, single owned method) — bail otherwise:
* ``Processor.new`` -> a ``calls`` edge to the ``Processor`` class
* ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to ``Processor#run``
Registered into graphify.resolver_registry and run by extract() after id
disambiguation, so node ids and raw_call caller_nids are final.
"""
from __future__ import annotations
import re
from typing import Any
def _key(label: str) -> str:
"""Normalize a class/method label to a comparison key (drop punctuation)."""
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
if not isinstance(result, dict):
continue
for rc in result.get("raw_calls", []):
if not isinstance(rc, dict):
continue
sf = str(rc.get("source_file", ""))
if sf.endswith(".rb"):
calls.append(rc)
return calls
def resolve_ruby_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Ruby ``Class.new`` and typed ``var.method`` calls by receiver type.
Purely additive: only emits edges the shared (name-based) call pass skips
because they are member calls. Each emission requires a single owning class
(god-node guard) so an ambiguous class name resolves to nothing rather than a
wrong edge.
"""
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
# class label key -> [class node ids]; (class_node_id, method_key) -> method id
class_def_nids: dict[str, list[str]] = {}
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
cnode = node_by_id.get(src)
if cnode is not None:
class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(str(src))
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(str(src), _key(tnode.get("label", "")))] = str(tgt)
for k in list(class_def_nids):
class_def_nids[k] = sorted(set(class_def_nids[k]))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
def _unique_class(name: str) -> str | None:
nids = class_def_nids.get(_key(name), [])
return nids[0] if len(nids) == 1 else None
def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
if not caller or not target or caller == target:
return
if (caller, target) in existing_pairs:
return
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
for rc in _ruby_raw_calls(per_file):
if not rc.get("is_member_call"):
continue
caller = str(rc.get("caller_nid", ""))
callee = rc.get("callee")
if not caller or not callee:
continue
# `Processor.new` -> instantiation edge to the class.
receiver = rc.get("receiver")
if callee == "new" and receiver and str(receiver)[:1].isupper():
class_nid = _unique_class(str(receiver))
if class_nid is not None:
_emit(caller, class_nid, rc)
continue
# `p.run` where p's type is known -> edge to that class's method.
receiver_type = rc.get("receiver_type")
if not receiver_type:
continue
class_nid = _unique_class(str(receiver_type))
if class_nid is None:
continue
method_nid = method_index.get((class_nid, _key(str(callee))))
if method_nid is None:
continue
_emit(caller, method_nid, rc)
+74
View File
@@ -0,0 +1,74 @@
"""Tests for the language resolver registry (graphify.resolver_registry).
The registry formalizes the previously hand-wired, suffix-gated cross-file
resolution passes. These tests pin its contract so future languages can be
registered with confidence: gating by suffix, in-order execution, in-place
mutation, and fault isolation (a failing pass logs and is skipped, never
aborting the build or blocking later passes).
"""
from __future__ import annotations
from pathlib import Path
from graphify.resolver_registry import (
LanguageResolver,
registered_resolvers,
run_language_resolvers,
)
def _make_resolver(name: str, suffix: str, log: list[str]) -> LanguageResolver:
def _resolve(per_file, all_nodes, all_edges):
log.append(name)
return LanguageResolver(name, frozenset({suffix}), _resolve)
def test_default_registry_contains_swift_then_python() -> None:
# Importing extract registers its resolvers into the shared registry. Order
# matters: it preserves the prior inlined wiring (Swift before Python).
import graphify.extract # noqa: F401 (registers resolvers on import)
names = [r.name for r in registered_resolvers()]
assert "swift_member_calls" in names
assert "python_member_calls" in names
assert names.index("swift_member_calls") < names.index("python_member_calls")
def test_resolver_runs_only_when_suffix_present() -> None:
log: list[str] = []
resolvers = [_make_resolver("ruby", ".rb", log), _make_resolver("go", ".go", log)]
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
assert log == ["ruby"] # go skipped: no .go file present
def test_resolvers_run_in_given_order() -> None:
log: list[str] = []
resolvers = [_make_resolver("first", ".rb", log), _make_resolver("second", ".rb", log)]
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
assert log == ["first", "second"]
def test_failing_resolver_is_isolated() -> None:
log: list[str] = []
def _boom(per_file, all_nodes, all_edges):
raise RuntimeError("resolver blew up")
resolvers = [
LanguageResolver("boom", frozenset({".rb"}), _boom),
_make_resolver("after", ".rb", log),
]
# Must not raise, and the later resolver still runs.
run_language_resolvers([Path("a.rb")], [], [], [], resolvers=resolvers)
assert log == ["after"]
def test_resolver_mutates_edges_in_place() -> None:
def _add_edge(per_file, all_nodes, all_edges):
all_edges.append({"source": "x", "target": "y", "relation": "calls"})
resolvers = [LanguageResolver("adder", frozenset({".rb"}), _add_edge)]
edges: list[dict] = []
run_language_resolvers([Path("a.rb")], [], [], edges, resolvers=resolvers)
assert edges == [{"source": "x", "target": "y", "relation": "calls"}]
+195
View File
@@ -0,0 +1,195 @@
"""TDD specs for type-aware Ruby call-graph resolution.
These drive the "improved Ruby graph" work:
* member calls capture their receiver (extraction)
* `var = ClassName.new` local bindings give the receiver a type (extraction)
* the cross-file resolver turns `var.method` into a precise edge BY TYPE,
not by globally-unique name — so it survives name collisions and never
emits a false positive when the type is unknown (resolution)
* `require_relative` links files (resolution)
Every resolved edge must be EXTRACTED (1.0) confidence: resolve only when
certain, bail otherwise.
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract, extract_ruby
# ── helpers ────────────────────────────────────────────────────────────────────
def _write(tmp_path: Path, name: str, body: str) -> Path:
p = tmp_path / name
p.write_text(body)
return p
def _raw_calls(result: dict) -> list[dict]:
return result.get("raw_calls", [])
def _find_raw_call(result: dict, callee: str) -> dict | None:
for rc in _raw_calls(result):
if rc.get("callee") == callee:
return rc
return None
def _labels(nodes: list[dict]) -> dict[str, str]:
return {n["id"]: str(n.get("label", "")) for n in nodes}
def _has_call_edge(graph: dict, src_label_sub: str, tgt_label_sub: str) -> dict | None:
"""Return the `calls` edge whose source/target labels contain the given
substrings, or None."""
labels = _labels(graph["nodes"])
for e in graph["edges"]:
if e.get("relation") != "calls":
continue
s = labels.get(e.get("source"), "")
t = labels.get(e.get("target"), "")
if src_label_sub in s and tgt_label_sub in t:
return e
return None
HELPER_RB = """\
def transform(data)
data.upcase
end
class Processor
def run(items)
items.map { |i| transform(i) }
end
end
"""
MAIN_RB = """\
require_relative "helper"
def handle(values)
transform(values)
end
def process_all(items)
p = Processor.new
p.run(items)
end
"""
WORKER_RB = """\
class Worker
def run(jobs)
jobs.each { |j| j }
end
end
"""
# ── extraction level ───────────────────────────────────────────────────────────
def test_member_call_captures_receiver(tmp_path: Path) -> None:
main = _write(tmp_path, "main.rb", MAIN_RB)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None, "p.run should produce a raw_call with callee 'run'"
assert rc["is_member_call"] is True
assert rc["receiver"] == "p"
def test_local_binding_gives_receiver_a_type(tmp_path: Path) -> None:
main = _write(tmp_path, "main.rb", MAIN_RB)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None
# `p = Processor.new` in the same method => p has type Processor.
assert rc.get("receiver_type") == "Processor"
def test_ambiguous_binding_yields_no_type(tmp_path: Path) -> None:
main = _write(
tmp_path,
"main.rb",
"""\
def process_all(items)
p = Processor.new
p = Worker.new
p.run(items)
end
""",
)
rc = _find_raw_call(extract_ruby(main), "run")
assert rc is not None
# reassigned to a different class => not certain => no type attached.
assert rc.get("receiver_type") is None
# ── resolution level ───────────────────────────────────────────────────────────
def test_resolves_member_call_by_type(tmp_path: Path) -> None:
_write(tmp_path, "helper.rb", HELPER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
edge = _has_call_edge(graph, "process_all", "run")
assert edge is not None, "process_all should resolve a call to Processor#run"
assert edge["confidence"] == "EXTRACTED"
def test_resolution_is_type_based_not_name_luck(tmp_path: Path) -> None:
"""The differentiator: adding an unrelated Worker#run must NOT break the edge.
Name-match resolvers drop this (two `run` definitions => ambiguous). A
type-based resolver keeps resolving p.run -> Processor#run, and never points
it at Worker#run.
"""
_write(tmp_path, "helper.rb", HELPER_RB)
_write(tmp_path, "worker.rb", WORKER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract(
[main, tmp_path / "helper.rb", tmp_path / "worker.rb"],
cache_root=tmp_path,
parallel=False,
)
to_processor_run = _has_call_edge(graph, "process_all", "run")
assert to_processor_run is not None, "edge must survive the name collision"
assert to_processor_run["confidence"] == "EXTRACTED"
# And it must be the RIGHT run: the target must be owned by Processor, not Worker.
labels = _labels(graph["nodes"])
tgt_id = to_processor_run["target"]
# the method node id is prefixed by its owning class (helper_processor_run)
assert "processor" in tgt_id.lower(), f"expected Processor#run, got {tgt_id}"
assert "worker" not in tgt_id.lower()
def test_no_false_positive_when_type_unknown(tmp_path: Path) -> None:
"""A member call on a receiver with no known type must NOT be resolved."""
_write(tmp_path, "helper.rb", HELPER_RB)
main = _write(
tmp_path,
"main.rb",
"""\
require_relative "helper"
def process_all(thing)
thing.run(1)
end
""",
)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
# `thing` is a parameter of unknown type => no precise target => no edge.
assert _has_call_edge(graph, "process_all", "run") is None
def test_class_new_creates_instantiation_edge(tmp_path: Path) -> None:
"""`p = Processor.new` should link the caller to the Processor class."""
_write(tmp_path, "helper.rb", HELPER_RB)
main = _write(tmp_path, "main.rb", MAIN_RB)
graph = extract([main, tmp_path / "helper.rb"], cache_root=tmp_path, parallel=False)
edge = _has_call_edge(graph, "process_all", "Processor")
assert edge is not None, "Processor.new should resolve a call to the Processor class"
assert edge["confidence"] == "EXTRACTED"