Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/core/src/repowise/core/ingestion/call_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ def _merged_methods_for(self, file_path: str) -> dict[tuple[str, str], str]:
def resolve_file(self, file_path: str, calls: list[CallSite]) -> list[ResolvedCall]:
"""Resolve all calls in a single file to symbol-level edges."""
results: list[ResolvedCall] = []
orphaned_receivers: list[CallSite] = []

for call in calls:
if not call.caller_symbol_id:
Expand All @@ -1046,9 +1047,69 @@ def resolve_file(self, file_path: str, calls: list[CallSite]) -> list[ResolvedCa
if call.edge_type != "calls":
resolved = replace(resolved, edge_type=call.edge_type)
results.append(resolved)
elif call.receiver_name:
orphaned_receivers.append(call)

results.extend(self._credit_orphaned_receivers(file_path, orphaned_receivers, results))

return results

def _credit_orphaned_receivers(
self,
file_path: str,
orphaned: list[CallSite],
resolved: list[ResolvedCall],
) -> list[ResolvedCall]:
"""A method call whose member resolved to nothing still uses its receiver.

``STAGES.forEach(...)`` answers on ``forEach``, an unresolvable builtin,
and so produced no edge at all — leaving ``STAGES`` with zero inbound
edges, and the dead-code analyzer, which credits a symbol only on an
inbound edge, calling a constant used four times unused. The same shape
covers ``DEFAULTS.timeout``, ``ROUTES.map(...)`` and every module-level
table consumed through a builtin.

Two deliberate limits keep this from guessing:

* **Only when the member resolved to nothing.** ``Defaults.timeout()``
where ``timeout`` is a same-file method already puts an edge inside
the receiver, and the receiver is reachable from it. Crediting it
again would mint an edge for every qualified property read.
* **Only same-file receivers.** An imported name already carries an
``imports`` edge, and a bare receiver matching an unrelated symbol in
another file would be a guess, not a resolution.

Emitted as ``references`` rather than ``calls``: naming a value is not
executing it. ``references`` sits in ``SYMBOL_USE_EDGE_TYPES`` so dead
code counts it, and outside ``EXECUTION_EDGE_TYPES`` so call graphs,
flow analysis and the inferred test map are unchanged.
"""
file_symbols = self._file_symbols.get(file_path, {})
if not file_symbols:
return []

credited: list[ResolvedCall] = []
seen = {(rc.caller_id, rc.callee_id) for rc in resolved}
for call in orphaned:
callee_id = file_symbols.get(call.receiver_name or "")
caller_id = call.caller_symbol_id or f"{file_path}::__module__"
if not callee_id or callee_id == caller_id:
continue
if (caller_id, callee_id) in seen:
continue
seen.add((caller_id, callee_id))
credited.append(
ResolvedCall(
caller_id=caller_id,
callee_id=callee_id,
confidence=0.95,
line=call.line,
origin="same_file",
edge_type="references",
)
)
return credited

def _resolve_one(self, file_path: str, call: CallSite) -> ResolvedCall | None:
"""Resolve a single CallSite through the three-tier fallback."""
caller_id = call.caller_symbol_id
Expand Down
171 changes: 171 additions & 0 deletions tests/unit/ingestion/test_call_resolver_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,3 +720,174 @@
if get_external_receiver_types(spec.tag)
}
assert populated == {"rust"}


# ---------------------------------------------------------------------------
# A method call uses its receiver
# ---------------------------------------------------------------------------


def _typed_edges(
parsed: dict[str, ParsedFile],
tmp_path: Path,
) -> list[tuple[str, str, float, str, str]]:
"""Like ``_edges``, but carries the edge type as well."""
resolver = CallResolver(
parsed,
{p: set() for p in parsed},
repo_path=str(tmp_path),
)
return [
(rc.caller_id, rc.callee_id, rc.confidence, rc.origin, rc.edge_type)
for path, pf in parsed.items()
for rc in resolver.resolve_file(path, pf.calls)
]


class TestReceiverIsUsed:
"""``STAGES.forEach(...)`` uses ``STAGES``.

Resolution answers the member (``forEach``, an unresolvable builtin) and
used to emit nothing at all, so the receiver carried zero inbound edges and
the dead-code analyzer — which credits a symbol only on an inbound edge —
reported a symbol used four times as dead.
"""

_GATE = (
"javascript",
"const STAGES = ['unit', 'e2e'];\n"
"\n"
"export function run() {\n"
" STAGES.forEach((s) => console.log(s));\n"
" return STAGES.length;\n"
"}\n",
)

def test_a_same_file_receiver_is_credited(self, tmp_path: Path) -> None:
parsed = _parse_all(tmp_path, {"gate.js": self._GATE})
assert (
"gate.js::run",
"gate.js::STAGES",
0.95,
"same_file",
"references",
) in _typed_edges(parsed, tmp_path)

def test_the_edge_is_a_use_and_not_an_execution(self, tmp_path: Path) -> None:
"""Reading a value is not running it.

``references`` sits in ``SYMBOL_USE_EDGE_TYPES`` so dead code counts it,
and outside ``EXECUTION_EDGE_TYPES`` so call graphs, flow analysis and
the inferred test map are unaffected.
"""
from repowise.core.ingestion.models import (
EXECUTION_EDGE_TYPES,
SYMBOL_USE_EDGE_TYPES,
)

parsed = _parse_all(tmp_path, {"gate.js": self._GATE})
edge = next(e for e in _typed_edges(parsed, tmp_path) if e[1] == "gate.js::STAGES")
assert edge[4] in SYMBOL_USE_EDGE_TYPES
assert edge[4] not in EXECUTION_EDGE_TYPES

def test_the_receiver_is_credited_once_not_per_call_site(self, tmp_path: Path) -> None:
parsed = _parse_all(tmp_path, {"gate.js": self._GATE})
hits = [
e
for e in _typed_edges(parsed, tmp_path)
if e[0] == "gate.js::run" and e[1] == "gate.js::STAGES"
]
assert len(hits) == 1

def test_a_receiver_that_names_nothing_local_mints_nothing(self, tmp_path: Path) -> None:
"""``console`` is not a symbol in this file, and must not become one."""
parsed = _parse_all(tmp_path, {"gate.js": self._GATE})
assert not [e for e in _typed_edges(parsed, tmp_path) if not e[1].startswith("gate.js::")]

def test_a_symbol_nothing_reads_stays_uncredited(self, tmp_path: Path) -> None:
"""The control: a genuinely dead constant must not go quietly clean."""
parsed = _parse_all(
tmp_path,
{
"gate.js": (
"javascript",
"const STAGES = ['unit'];\nconst UNUSED = ['nothing reads this'];\n"
"\n"
"export function run() {\n"
" STAGES.forEach((s) => s);\n"
"}\n",
)
},
)
edges = _typed_edges(parsed, tmp_path)
assert any(e[1] == "gate.js::STAGES" for e in edges)
assert not [e for e in edges if e[1] == "gate.js::UNUSED"]

def test_a_symbol_does_not_credit_itself(self, tmp_path: Path) -> None:
"""``run.cache`` inside ``run`` is not an inbound edge for ``run``."""
parsed = _parse_all(
tmp_path,
{
"gate.js": (
"javascript",
"export function run() {\n return run.call(null);\n}\n",
)
},
)
assert not [
e
for e in _typed_edges(parsed, tmp_path)
if e[0] == "gate.js::run" and e[1] == "gate.js::run"
]

def test_it_is_not_javascript_only(self, tmp_path: Path) -> None:
parsed = _parse_all(
tmp_path,
{
"gate.py": (
"python",
"STAGES = ['unit', 'e2e']\n\n\ndef run():\n return STAGES.index('e2e')\n",
)
},
)
assert (
"gate.py::run",
"gate.py::STAGES",
0.95,
"same_file",
"references",
) in _typed_edges(parsed, tmp_path)

def test_a_resolved_member_does_not_credit_the_receiver_again(
self, tmp_path: Path
) -> None:
"""``Defaults.timeout()`` already puts an edge inside the receiver.

Crediting the receiver a second time would mint an edge for every
qualified property read, which is the noise the same-file limit exists
to avoid.
"""
parsed = _parse_all(
tmp_path,
{
"config.py": (
"python",
"class Defaults:\n"
" def timeout(self):\n"
" return 30\n"
"\n"
"\n"
"def read():\n"
" return Defaults.timeout()\n",
)
},
)
edges = _typed_edges(parsed, tmp_path)
assert (
"config.py::read",
"config.py::Defaults::timeout",
0.93,
"receiver_same_file",
"calls",
) in edges
assert not [e for e in edges if e[4] == "references"]

Check warning on line 893 in tests/unit/ingestion/test_call_resolver_strategies.py

View check run for this annotation

Repowise Bot / Repowise / code health

Introduced: low cohesion

TestReceiverIsUsed has low cohesion (LCOM4=5): its 8 methods split into 5 groups that share no fields or calls
Loading