diff --git a/packages/core/src/repowise/core/ingestion/call_resolver.py b/packages/core/src/repowise/core/ingestion/call_resolver.py index bbbab1e08..a75c58de7 100644 --- a/packages/core/src/repowise/core/ingestion/call_resolver.py +++ b/packages/core/src/repowise/core/ingestion/call_resolver.py @@ -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: @@ -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 diff --git a/tests/unit/ingestion/test_call_resolver_strategies.py b/tests/unit/ingestion/test_call_resolver_strategies.py index 807e73d3c..52dd8276f 100644 --- a/tests/unit/ingestion/test_call_resolver_strategies.py +++ b/tests/unit/ingestion/test_call_resolver_strategies.py @@ -720,3 +720,174 @@ def test_no_other_language_carries_the_set(self) -> None: 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"]