mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 08:46:43 +00:00
fix(go): resolve qualified symbols by import path
Prevent package-qualified calls and types from collapsing onto unrelated bare-name symbols. Preserve Go import evidence, resolve internal packages exactly, canonicalize external type stubs, and support incremental resolution context. Investigation, implementation, and regression fixtures prepared with OpenAI Codex.
This commit is contained in:
+33
-1
@@ -79,6 +79,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
|
||||
_decldef_class_stem,
|
||||
_disambiguate_colliding_node_ids,
|
||||
_find_workspace_root,
|
||||
_go_import_path_for_file,
|
||||
_is_type_like_definition,
|
||||
_js_call_identifier,
|
||||
_js_default_export_name,
|
||||
@@ -116,6 +117,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
|
||||
_resolve_cross_file_imports,
|
||||
_resolve_cross_file_java_imports,
|
||||
_resolve_export_target,
|
||||
_resolve_go_type_references,
|
||||
_resolve_java_type_references,
|
||||
_resolve_php_type_references,
|
||||
_resolve_js_import_path,
|
||||
@@ -5951,6 +5953,21 @@ def extract(
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Java type-reference resolution failed, skipping: %s", exc)
|
||||
# Resolve internal Go pkg.Type references exactly and park external ones
|
||||
# before the generic bare-label stub rewire can manufacture a collision.
|
||||
_go_sel = [(r, p) for r, p in zip(per_file, paths) if p.suffix == ".go"]
|
||||
if _go_sel:
|
||||
try:
|
||||
_resolve_go_type_references(
|
||||
[r for r, _ in _go_sel], [p for _, p in _go_sel],
|
||||
all_nodes, all_edges, root,
|
||||
resolution_context_nodes, resolution_context_edges,
|
||||
)
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Go type-reference resolution failed, skipping: %s", exc
|
||||
)
|
||||
_rewire_unique_stub_nodes(all_nodes, all_edges)
|
||||
|
||||
# Add cross-file class-level edges (Python only - uses Python parser internally)
|
||||
@@ -6168,6 +6185,7 @@ def extract(
|
||||
# file is real ONLY if the caller imported it. So a cross-file call from one
|
||||
# of these files with no import evidence is gated below (#1659).
|
||||
_JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
|
||||
_go_module_cache: dict[Path, str | None] = {}
|
||||
for rc in all_raw_calls:
|
||||
callee = rc.get("callee", "")
|
||||
if not callee:
|
||||
@@ -6225,6 +6243,20 @@ def extract(
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
# Imported Go selectors carry exact package evidence. External package
|
||||
# calls yield no internal candidate instead of binding by bare name.
|
||||
go_exact_import = False
|
||||
if rc.get("language") == "go" and rc.get("import_path"):
|
||||
import_path = str(rc["import_path"])
|
||||
candidates = [
|
||||
candidate for candidate in candidates
|
||||
if _go_import_path_for_file(
|
||||
nid_to_source_file.get(candidate, ""), root, _go_module_cache
|
||||
) == import_path
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
go_exact_import = True
|
||||
caller = rc["caller_nid"]
|
||||
# Resolve the caller's file via the raw_call's own source_file string,
|
||||
# which is stable regardless of any caller_nid remap. An indirect
|
||||
@@ -6251,7 +6283,7 @@ def extract(
|
||||
|
||||
if len(candidates) == 1:
|
||||
tgt = candidates[0]
|
||||
has_import_evidence = _has_import_evidence(tgt)
|
||||
has_import_evidence = go_exact_import or _has_import_evidence(tgt)
|
||||
else:
|
||||
# Ambiguous name (defined in 2+ files). Don't bail outright (#1219):
|
||||
# if the caller has explicit import evidence pointing at exactly one
|
||||
|
||||
@@ -52,8 +52,10 @@ def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[st
|
||||
out.append((text, "generic_arg" if generic else "type"))
|
||||
return
|
||||
if t == "qualified_type":
|
||||
text = _read_text(node, source).rsplit(".", 1)[-1]
|
||||
if text and text not in _GO_PREDECLARED_TYPES:
|
||||
# Keep the package qualifier so the generic stub rewire cannot attach
|
||||
# `testing.T` to an unrelated local type or function named T.
|
||||
text = _read_text(node, source)
|
||||
if text:
|
||||
out.append((text, "generic_arg" if generic else "type"))
|
||||
return
|
||||
if t == "generic_type":
|
||||
@@ -105,7 +107,8 @@ def extract_go(path: Path) -> dict:
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, object]] = []
|
||||
go_imported_pkgs: set[str] = set() # local names of imported packages
|
||||
# local package name (including aliases) -> written Go import path
|
||||
go_imported_pkgs: dict[str, str] = {}
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
@@ -338,7 +341,7 @@ def extract_go(path: Path) -> dict:
|
||||
alias = spec.child_by_field_name("name")
|
||||
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
|
||||
if local_name and local_name != "_" and local_name != ".":
|
||||
go_imported_pkgs.add(local_name)
|
||||
go_imported_pkgs[local_name] = raw
|
||||
elif child.type == "import_spec":
|
||||
path_node = child.child_by_field_name("path")
|
||||
if path_node:
|
||||
@@ -348,7 +351,7 @@ def extract_go(path: Path) -> dict:
|
||||
alias = child.child_by_field_name("name")
|
||||
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
|
||||
if local_name and local_name != "_" and local_name != ".":
|
||||
go_imported_pkgs.add(local_name)
|
||||
go_imported_pkgs[local_name] = raw
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
@@ -373,6 +376,8 @@ def extract_go(path: Path) -> dict:
|
||||
callee_name: str | None = None
|
||||
is_member_call: bool = False
|
||||
is_bare_identifier: bool = False
|
||||
package_receiver: str | None = None
|
||||
import_path: str | None = None
|
||||
if func_node:
|
||||
if func_node.type == "identifier":
|
||||
is_bare_identifier = True
|
||||
@@ -384,6 +389,9 @@ def extract_go(path: Path) -> dict:
|
||||
# Package-qualified call (e.g. fmt.Println) → allow cross-file resolution.
|
||||
# Receiver method call (e.g. s.logger.Log) → skip, no import evidence.
|
||||
is_member_call = receiver_name not in go_imported_pkgs
|
||||
if not is_member_call:
|
||||
package_receiver = receiver_name
|
||||
import_path = go_imported_pkgs[receiver_name]
|
||||
if field:
|
||||
callee_name = _read_text(field, source)
|
||||
if is_bare_identifier and callee_name in _GO_PREDECLARED_FUNCS:
|
||||
@@ -393,7 +401,8 @@ def extract_go(path: Path) -> dict:
|
||||
# of raw_calls, so the cross-file pass cannot bind it either.
|
||||
callee_name = None
|
||||
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
|
||||
tgt_nid = label_to_nid.get(callee_name)
|
||||
# Never resolve an imported selector through a bare local name.
|
||||
tgt_nid = None if import_path else label_to_nid.get(callee_name)
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
@@ -415,6 +424,8 @@ def extract_go(path: Path) -> dict:
|
||||
"callee": callee_name,
|
||||
"is_member_call": is_member_call,
|
||||
"language": "go",
|
||||
"receiver": package_receiver,
|
||||
"import_path": import_path,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
@@ -431,4 +442,9 @@ def extract_go(path: Path) -> dict:
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": clean_edges,
|
||||
"raw_calls": raw_calls,
|
||||
"go_imports": dict(go_imported_pkgs),
|
||||
}
|
||||
|
||||
@@ -2316,6 +2316,157 @@ def _resolve_cross_file_java_imports(
|
||||
|
||||
return new_edges
|
||||
|
||||
|
||||
def _go_import_path_for_file(
|
||||
source_file: str | Path,
|
||||
root: Path,
|
||||
module_cache: dict[Path, str | None] | None = None,
|
||||
) -> str | None:
|
||||
"""Return the canonical Go import path for a source file inside a module."""
|
||||
cache = module_cache if module_cache is not None else {}
|
||||
path = Path(source_file)
|
||||
if not path.is_absolute():
|
||||
path = root / path
|
||||
try:
|
||||
directory = path.resolve().parent
|
||||
except OSError:
|
||||
directory = path.absolute().parent
|
||||
|
||||
module_dir: Path | None = None
|
||||
module_path: str | None = None
|
||||
for candidate in (directory, *directory.parents):
|
||||
if candidate in cache:
|
||||
cached = cache[candidate]
|
||||
if cached:
|
||||
module_dir, module_path = candidate, cached
|
||||
break
|
||||
go_mod = candidate / "go.mod"
|
||||
if not go_mod.is_file():
|
||||
continue
|
||||
try:
|
||||
match = re.search(
|
||||
r"(?m)^\s*module\s+([^\s]+)",
|
||||
go_mod.read_text(encoding="utf-8"),
|
||||
)
|
||||
except (OSError, UnicodeError):
|
||||
match = None
|
||||
module_dir = candidate
|
||||
module_path = match.group(1) if match else None
|
||||
cache[candidate] = module_path
|
||||
break
|
||||
|
||||
if not module_dir or not module_path:
|
||||
return None
|
||||
try:
|
||||
relative_dir = directory.relative_to(module_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
suffix = relative_dir.as_posix()
|
||||
return module_path if suffix == "." else f"{module_path}/{suffix}"
|
||||
|
||||
|
||||
def _resolve_go_type_references(
|
||||
per_file: list[dict],
|
||||
paths: list[Path],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
root: Path,
|
||||
resolution_context_nodes: list[dict] | None = None,
|
||||
resolution_context_edges: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Resolve qualified Go types through aliases and exact module paths."""
|
||||
imports_by_file: dict[str, dict[str, str]] = {}
|
||||
actual_path_by_file: dict[str, Path] = {}
|
||||
for path, result in zip(paths, per_file):
|
||||
imports = result.get("go_imports") or {}
|
||||
for node in result.get("nodes", []):
|
||||
source_file = node.get("source_file")
|
||||
if source_file:
|
||||
imports_by_file[str(source_file)] = imports
|
||||
actual_path_by_file[str(source_file)] = path
|
||||
|
||||
if not imports_by_file:
|
||||
return
|
||||
|
||||
definition_nodes = all_nodes + (resolution_context_nodes or [])
|
||||
definition_edges = all_edges + (resolution_context_edges or [])
|
||||
contained = {edge.get("target") for edge in definition_edges
|
||||
if edge.get("relation") == "contains"}
|
||||
module_cache: dict[Path, str | None] = {}
|
||||
fqn_to_ids: dict[str, list[str]] = {}
|
||||
for node in definition_nodes:
|
||||
source_file = str(node.get("source_file") or "")
|
||||
label = str(node.get("label") or "")
|
||||
nid = node.get("id")
|
||||
if (not source_file or not label or not nid or nid not in contained
|
||||
or not _is_type_like_definition(node)):
|
||||
continue
|
||||
actual_path = actual_path_by_file.get(source_file, Path(source_file))
|
||||
package_path = _go_import_path_for_file(actual_path, root, module_cache)
|
||||
if package_path:
|
||||
fqn_to_ids.setdefault(f"{package_path}.{label}", []).append(nid)
|
||||
|
||||
qualified_stubs = {
|
||||
node["id"]: str(node.get("label") or "")
|
||||
for node in all_nodes
|
||||
if node.get("id") and not node.get("source_file")
|
||||
and "." in str(node.get("label") or "")
|
||||
}
|
||||
if not qualified_stubs:
|
||||
return
|
||||
|
||||
node_ids = {node.get("id") for node in all_nodes if node.get("id")}
|
||||
external_stub_ids: dict[str, str] = {}
|
||||
new_nodes: list[dict] = []
|
||||
|
||||
def external_stub(fqn: str) -> str:
|
||||
existing = external_stub_ids.get(fqn)
|
||||
if existing:
|
||||
return existing
|
||||
nid = _make_id("go", "type", fqn)
|
||||
if nid not in node_ids:
|
||||
new_nodes.append({
|
||||
"id": nid,
|
||||
"label": fqn,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
})
|
||||
node_ids.add(nid)
|
||||
external_stub_ids[fqn] = nid
|
||||
return nid
|
||||
|
||||
repointed_from: set[str] = set()
|
||||
for edge in all_edges:
|
||||
if edge.get("relation") not in {"references", "embeds"}:
|
||||
continue
|
||||
target = edge.get("target")
|
||||
qualified = qualified_stubs.get(target)
|
||||
if not qualified:
|
||||
continue
|
||||
alias, _, type_name = qualified.rpartition(".")
|
||||
import_path = imports_by_file.get(
|
||||
str(edge.get("source_file") or ""), {}
|
||||
).get(alias)
|
||||
if not import_path or not type_name:
|
||||
continue
|
||||
fqn = f"{import_path}.{type_name}"
|
||||
candidates = fqn_to_ids.get(fqn, [])
|
||||
edge["target"] = candidates[0] if len(candidates) == 1 else external_stub(fqn)
|
||||
repointed_from.add(str(target))
|
||||
|
||||
if new_nodes:
|
||||
all_nodes.extend(new_nodes)
|
||||
if not repointed_from:
|
||||
return
|
||||
referenced = {endpoint for edge in all_edges
|
||||
for endpoint in (edge.get("source"), edge.get("target"))}
|
||||
all_nodes[:] = [
|
||||
node for node in all_nodes
|
||||
if node.get("id") not in repointed_from or node.get("id") in referenced
|
||||
]
|
||||
|
||||
|
||||
def _resolve_java_type_references(
|
||||
per_file: list[dict],
|
||||
paths: list[Path],
|
||||
|
||||
+14
-9
@@ -238,12 +238,14 @@ def test_origin_file_is_not_serialized_into_extract_output(tmp_path):
|
||||
|
||||
|
||||
def test_go_imported_type_stubs_do_not_collide_across_source_files(tmp_path):
|
||||
"""#1462 (dedicated extractors): the imported-type-stub disambiguation (the
|
||||
``origin_file`` key) landed only in the generic extractor, so the six dedicated
|
||||
extractors (Go, Rust, Julia, Fortran, PowerShell, ObjC) still collapsed same-label
|
||||
cross-file stubs into one conflated bare-id node — a false cross-package link.
|
||||
They must stay distinct per file while keeping ``source_file`` empty so the #1402
|
||||
rewire still collapses them onto a real definition when one exists."""
|
||||
"""Go external types use their import path as canonical identity.
|
||||
|
||||
#1462 kept unresolved bare stubs distinct per source file because Graphify
|
||||
could not tell whether they named the same external package. The Go
|
||||
import-aware resolver now has that evidence: two ``ext.Widget`` references
|
||||
intentionally share one sourceless node without colliding with a local
|
||||
``Widget`` definition.
|
||||
"""
|
||||
first = tmp_path / "a/use_a.go"
|
||||
second = tmp_path / "b/use_b.go"
|
||||
first.parent.mkdir(parents=True)
|
||||
@@ -252,11 +254,14 @@ def test_go_imported_type_stubs_do_not_collide_across_source_files(tmp_path):
|
||||
second.write_text('package b\n\nimport "ext"\n\nfunc UseB(w ext.Widget) {}\n', encoding="utf-8")
|
||||
|
||||
result = extract([first, second], cache_root=tmp_path)
|
||||
widget_nodes = [node for node in result["nodes"] if node["label"] == "Widget"]
|
||||
widget_nodes = [node for node in result["nodes"] if node["label"] == "ext.Widget"]
|
||||
|
||||
assert len(widget_nodes) == 2
|
||||
assert len({node["id"] for node in widget_nodes}) == 2
|
||||
assert len(widget_nodes) == 1
|
||||
assert all(not node.get("source_file") for node in widget_nodes)
|
||||
target = widget_nodes[0]["id"]
|
||||
refs = [edge for edge in result["edges"] if edge.get("relation") == "references"]
|
||||
assert len(refs) == 2
|
||||
assert all(edge["target"] == target for edge in refs)
|
||||
|
||||
|
||||
def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path):
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Regression coverage for package-qualified Go calls and type references."""
|
||||
|
||||
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 _ids(result: dict, *, label: str, suffix: str) -> set[str]:
|
||||
return {
|
||||
node["id"]
|
||||
for node in result["nodes"]
|
||||
if str(node.get("source_file", "")).endswith(suffix)
|
||||
and str(node.get("label", "")).strip(".()") == label
|
||||
}
|
||||
|
||||
|
||||
def test_external_package_new_does_not_bind_to_local_new(tmp_path: Path) -> None:
|
||||
"""``errors.New`` must not become a call to an unrelated local ``New``."""
|
||||
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
|
||||
(tmp_path / "factory.go").write_text("package repro\n\nfunc New() int { return 1 }\n")
|
||||
(tmp_path / "worker.go").write_text(
|
||||
'package repro\n\nimport "errors"\n\nfunc Build() error { return errors.New("boom") }\n'
|
||||
)
|
||||
|
||||
result = _extract(tmp_path)
|
||||
build_ids = _ids(result, label="Build", suffix="worker.go")
|
||||
local_new_ids = _ids(result, label="New", suffix="factory.go")
|
||||
phantom = [
|
||||
edge
|
||||
for edge in result["edges"]
|
||||
if edge.get("relation") == "calls"
|
||||
and edge.get("source") in build_ids
|
||||
and edge.get("target") in local_new_ids
|
||||
]
|
||||
assert phantom == [], f"errors.New bound to the local New: {phantom}"
|
||||
|
||||
|
||||
def test_internal_aliased_package_new_resolves_exact_import(tmp_path: Path) -> None:
|
||||
"""An imported internal package selector resolves despite same-name decoys."""
|
||||
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
|
||||
factory = tmp_path / "factory"
|
||||
factory.mkdir()
|
||||
(factory / "factory.go").write_text("package factory\n\nfunc New() int { return 1 }\n")
|
||||
app = tmp_path / "app"
|
||||
app.mkdir()
|
||||
(app / "decoy.go").write_text("package app\n\nfunc New() int { return 2 }\n")
|
||||
(app / "worker.go").write_text(
|
||||
'package app\n\nimport maker "example.com/repro/factory"\n\n'
|
||||
"func Build() int { return maker.New() }\n"
|
||||
)
|
||||
|
||||
result = _extract(tmp_path)
|
||||
build_ids = _ids(result, label="Build", suffix="app/worker.go")
|
||||
factory_new_ids = _ids(result, label="New", suffix="factory/factory.go")
|
||||
decoy_new_ids = _ids(result, label="New", suffix="app/decoy.go")
|
||||
calls = [
|
||||
edge
|
||||
for edge in result["edges"]
|
||||
if edge.get("relation") == "calls" and edge.get("source") in build_ids
|
||||
]
|
||||
assert len(calls) == 1, calls
|
||||
assert calls[0]["target"] in factory_new_ids
|
||||
assert calls[0]["target"] not in decoy_new_ids
|
||||
assert calls[0]["confidence"] == "EXTRACTED"
|
||||
|
||||
|
||||
def test_external_qualified_type_does_not_bind_to_local_function(tmp_path: Path) -> None:
|
||||
"""``*testing.T`` must not reference an unrelated local function ``T``."""
|
||||
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
|
||||
(tmp_path / "translate.go").write_text(
|
||||
"package repro\n\nfunc T(message string) string { return message }\n"
|
||||
)
|
||||
(tmp_path / "worker_test.go").write_text(
|
||||
'package repro\n\nimport "testing"\n\nfunc TestBuild(t *testing.T) {}\n'
|
||||
)
|
||||
|
||||
result = _extract(tmp_path)
|
||||
test_ids = _ids(result, label="TestBuild", suffix="worker_test.go")
|
||||
local_t_ids = _ids(result, label="T", suffix="translate.go")
|
||||
refs = [
|
||||
edge
|
||||
for edge in result["edges"]
|
||||
if edge.get("relation") == "references" and edge.get("source") in test_ids
|
||||
]
|
||||
assert refs
|
||||
assert all(edge.get("target") not in local_t_ids for edge in refs), refs
|
||||
|
||||
nodes = {node["id"]: node for node in result["nodes"]}
|
||||
assert any(nodes[edge["target"]]["label"] == "testing.T" for edge in refs)
|
||||
|
||||
|
||||
def test_internal_qualified_type_resolves_exact_import(tmp_path: Path) -> None:
|
||||
"""An aliased internal qualified type points to its package definition."""
|
||||
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
|
||||
model = tmp_path / "model"
|
||||
model.mkdir()
|
||||
(model / "user.go").write_text("package model\n\ntype User struct{}\n")
|
||||
app = tmp_path / "app"
|
||||
app.mkdir()
|
||||
(app / "decoy.go").write_text("package app\n\ntype User struct{}\n")
|
||||
(app / "handler.go").write_text(
|
||||
'package app\n\nimport domain "example.com/repro/model"\n\n'
|
||||
"func Handle(user *domain.User) {}\n"
|
||||
)
|
||||
|
||||
result = _extract(tmp_path)
|
||||
handle_ids = _ids(result, label="Handle", suffix="app/handler.go")
|
||||
model_user_ids = _ids(result, label="User", suffix="model/user.go")
|
||||
decoy_user_ids = _ids(result, label="User", suffix="app/decoy.go")
|
||||
refs = [
|
||||
edge
|
||||
for edge in result["edges"]
|
||||
if edge.get("relation") == "references" and edge.get("source") in handle_ids
|
||||
]
|
||||
assert len(refs) == 1, refs
|
||||
assert refs[0]["target"] in model_user_ids
|
||||
assert refs[0]["target"] not in decoy_user_ids
|
||||
|
||||
|
||||
def test_incremental_qualified_resolution_uses_unchanged_context(tmp_path: Path) -> None:
|
||||
"""Changed callers still resolve calls and types defined in unchanged files."""
|
||||
(tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n")
|
||||
factory = tmp_path / "factory"
|
||||
factory.mkdir()
|
||||
target = factory / "factory.go"
|
||||
target.write_text(
|
||||
"package factory\n\ntype User struct{}\n\n"
|
||||
"func New() *User { return &User{} }\n"
|
||||
)
|
||||
app = tmp_path / "app"
|
||||
app.mkdir()
|
||||
caller = app / "worker.go"
|
||||
caller.write_text(
|
||||
'package app\n\nimport maker "example.com/repro/factory"\n\n'
|
||||
"func Build() *maker.User { return maker.New() }\n"
|
||||
)
|
||||
full = _extract(tmp_path)
|
||||
|
||||
caller.write_text(caller.read_text() + "\n")
|
||||
changed = extract(
|
||||
[caller],
|
||||
cache_root=tmp_path,
|
||||
root=tmp_path,
|
||||
parallel=False,
|
||||
resolution_context_nodes=full["nodes"],
|
||||
resolution_context_edges=full["edges"],
|
||||
)
|
||||
|
||||
build_ids = _ids(changed, label="Build", suffix="app/worker.go")
|
||||
full_new_ids = _ids(full, label="New", suffix="factory/factory.go")
|
||||
full_user_ids = _ids(full, label="User", suffix="factory/factory.go")
|
||||
assert any(
|
||||
edge.get("relation") == "calls"
|
||||
and edge.get("source") in build_ids
|
||||
and edge.get("target") in full_new_ids
|
||||
for edge in changed["edges"]
|
||||
)
|
||||
assert any(
|
||||
edge.get("relation") == "references"
|
||||
and edge.get("source") in build_ids
|
||||
and edge.get("target") in full_user_ids
|
||||
for edge in changed["edges"]
|
||||
)
|
||||
Reference in New Issue
Block a user