From fb40961f4a79bd6c25a57b1da7255adc9f4b56c4 Mon Sep 17 00:00:00 2001 From: carterko23 Date: Tue, 22 Sep 2026 14:51:44 +0300 Subject: [PATCH] fix(go): repoint intra-module imports_from edges from go_pkg_ sinks to the package's files extractors/go.py mints every import target as `go_pkg_` with no module lookup, so an intra-module import never reaches a file: the sink has no source_file and nothing links it onward. `affected` and any reachability walk miss every consumer that arrives through an import, while qualified calls (`b.F()`) resolve, because the call pass already filters callees by _go_import_path_for_file. This inverts that mapping once per run and repoints the intra-module `imports_from` edges onto the imported package's file nodes, the way the JS/TS resolver repoints module specifiers. Stdlib and external imports have no file in the corpus and stay sinks by design. Measured on a 744-file single-go.mod module, same commit, with and without this change: a 111-file handler package goes from 0/21 direct intra-module imports resolved to 21/21 (24/24 transitive); the 57 intra-module go_pkg_ nodes disappear; +16,616 imports_from edges; no other relation changes. Closes #3746 Co-Authored-By: Claude Fable 5.1 --- graphify/extract.py | 36 ++++++++++ tests/test_go_import_repoint.py | 113 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/test_go_import_repoint.py diff --git a/graphify/extract.py b/graphify/extract.py index da66f41317..882f6b86f3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -8016,6 +8016,42 @@ def _has_import_evidence(candidate_id: str) -> bool: "weight": 1.0, }) + # Go: repoint intra-module `imports_from` edges from the synthetic + # `go_pkg_` sink onto the imported package's file nodes. + # extractors/go.py mints the sink from the raw import string with no module + # lookup, so the edge never reaches a file and `affected` / reachability miss + # every consumer that arrives through an import. The inverse mapping is one + # dict away: _go_import_path_for_file (used above to bind qualified calls to + # the exact package) gives every Go file its canonical import path. Stdlib + # and external imports have no file here and stay sinks by design. (#3746) + go_pkg_files: dict[str, list[str]] = {} + for sf, fnid in sf_to_file_nid.items(): + if not sf.endswith(".go"): + continue + import_path = _go_import_path_for_file(sf, root, _go_module_cache) + if import_path: + go_pkg_files.setdefault(import_path, []).append(fnid) + if go_pkg_files: + go_pkg_sink_ids = {_make_id("go", "pkg", ip): ip for ip in go_pkg_files} + rewritten_edges: list[dict] = [] + for e in all_edges: + import_path = ( + go_pkg_sink_ids.get(e.get("target", "")) + if e.get("relation") == "imports_from" + else None + ) + if import_path is None: + rewritten_edges.append(e) + continue + for fnid in go_pkg_files[import_path]: + if fnid == e["source"] or (e["source"], fnid) in existing_pairs: + continue + repointed = dict(e) + repointed["target"] = fnid + rewritten_edges.append(repointed) + existing_pairs.add((e["source"], fnid)) + all_edges[:] = rewritten_edges + # 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 diff --git a/tests/test_go_import_repoint.py b/tests/test_go_import_repoint.py new file mode 100644 index 0000000000..2119978261 --- /dev/null +++ b/tests/test_go_import_repoint.py @@ -0,0 +1,113 @@ +"""Go intra-module imports must reach the imported package's files (#3746). + +`graphify/extractors/go.py` turns every `import "x/y"` into an `imports_from` +edge whose target id is minted from the raw string (`go_pkg_x_y`). That node +has no source file and nothing links it onward, so an importer never reaches +the package it depends on: `graphify affected ` misses every consumer +that arrives through an import, while qualified calls (`b.F()`) do resolve, +because the call pass filters callees by `_go_import_path_for_file`. + +Measured on a 744-file module with a single go.mod: 0 of 21 direct intra-module +imports of a handler package resolved to files; 57 intra-module `go_pkg_*` +nodes had 780 edges in and 0 out. + +The fix inverts the mapping the call pass already uses — every Go file's +canonical import path — and repoints the intra-module `imports_from` edges +onto that package's file nodes, the way the JS/TS resolver repoints module +specifiers. Stdlib and external imports have no file in the corpus and stay +sinks on purpose; the last test pins that. +""" +from pathlib import Path + +from graphify.extract import extract + + +def _extract(root: Path) -> dict: + return extract( + sorted(root.rglob("*.go")), + cache_root=root, + root=root, + parallel=False, + ) + + +def _file_node(result: dict, suffix: str) -> dict: + hits = [ + n for n in result["nodes"] + if str(n.get("source_file", "")).endswith(suffix) + and n.get("label") == Path(suffix).name + ] + assert len(hits) == 1, f"expected one file node for {suffix}, got {hits}" + return hits[0] + + +def _edges(result: dict, source_id: str, relation: str) -> list[dict]: + return [ + e for e in result["edges"] + if e.get("source") == source_id and e.get("relation") == relation + ] + + +def _write_module(root: Path) -> None: + (root / "go.mod").write_text("module example.com/m\n\ngo 1.22\n") + a = root / "a" + b = root / "b" + a.mkdir() + b.mkdir() + (b / "b.go").write_text("package b\n\nfunc F() int { return 1 }\n") + (b / "c.go").write_text("package b\n\nfunc G() int { return 2 }\n") + (a / "a.go").write_text( + "package a\n\n" + 'import (\n\t"fmt"\n\n\t"example.com/m/b"\n)\n\n' + "func Run() int {\n\tfmt.Println(b.G())\n\treturn b.F()\n}\n" + ) + + +def test_intra_module_import_reaches_every_file_of_the_package(tmp_path: Path) -> None: + _write_module(tmp_path) + result = _extract(tmp_path) + + a_go = _file_node(result, "a/a.go") + b_go = _file_node(result, "b/b.go") + c_go = _file_node(result, "b/c.go") + + imports = _edges(result, a_go["id"], "imports_from") + targets = {e["target"] for e in imports} + assert b_go["id"] in targets, imports + assert c_go["id"] in targets, imports + # The synthetic sink for an intra-module package is gone: nothing points at it. + assert "go_pkg_example_com_m_b" not in targets, imports + assert not any( + e["target"] == "go_pkg_example_com_m_b" for e in result["edges"] + ) + + +def test_repoint_keeps_the_call_edge_and_adds_no_self_import(tmp_path: Path) -> None: + _write_module(tmp_path) + result = _extract(tmp_path) + + a_go = _file_node(result, "a/a.go") + run_ids = {n["id"] for n in result["nodes"] if n.get("label") == "Run()"} + f_ids = {n["id"] for n in result["nodes"] if n.get("label") == "F()"} + assert run_ids and f_ids + assert any( + e["source"] in run_ids and e["target"] in f_ids and e["relation"] == "calls" + for e in result["edges"] + ), "the qualified call edge must survive the import repoint" + assert not any( + e["source"] == a_go["id"] and e["target"] == a_go["id"] + for e in result["edges"] + ) + + +def test_stdlib_and_external_imports_stay_sinks(tmp_path: Path) -> None: + _write_module(tmp_path) + result = _extract(tmp_path) + + a_go = _file_node(result, "a/a.go") + imports = _edges(result, a_go["id"], "imports_from") + fmt_targets = [e["target"] for e in imports if e["target"].startswith("go_pkg_fmt")] + assert fmt_targets == ["go_pkg_fmt"], imports + # No file in the corpus is "fmt", so the sink must not have been repointed. + fmt_node = [n for n in result["nodes"] if n.get("id") == "go_pkg_fmt"] + assert all(not n.get("source_file") for n in fmt_node)