Skip to content
Open
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
26 changes: 23 additions & 3 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,8 @@ def _dropped(item: dict) -> bool:
# re-extracted its source. Hyperedges are semantic-tier (no _origin,
# null source_location), so an AST-only re-extract carries them.
# Deletion pruning below stays tier-blind.
if item.get("external"):
return False # external stub nodes/edges (#2873): never carried-forward-drop, never replace-drop
own = new_ast_sources if _is_ast_tier(item) else new_sem_sources
if sf in own or _norm_source_file(sf, _eff_root) in own:
return True # re-extracted this run — replaced by the new chunk
Expand Down Expand Up @@ -2014,7 +2016,11 @@ def prefix_graph_for_global(
collide and the aggregated community view fuses unrelated communities
into one meta-node (#3014). 0 (the default) leaves communities untouched.
"""
relabel = {n: f"{repo_tag}::{n}" for n in G.nodes}
relabel = {
n: f"{repo_tag}::{n}"
for n, data in G.nodes(data=True)
if not data.get("external")
}
H = nx.relabel_nodes(G, relabel, copy=True)
for node, data in H.nodes(data=True):
data["repo"] = repo_tag
Expand Down Expand Up @@ -2082,7 +2088,21 @@ def distinct_repo_tags(graph_paths: "list[Path]") -> "list[str]":


def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int:
"""Remove all nodes tagged with repo_tag from G in-place. Returns count removed."""
to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag]
"""Remove all nodes tagged with repo_tag from G in-place. Returns count removed.

External nodes (#2873) can be referenced by multiple repos via their
'repos' list -- pruning one repo only removes the tag; the node itself
is removed only once no repo still references it.
"""
to_remove = []
for n, d in G.nodes(data=True):
if d.get("repo") == repo_tag:
to_remove.append(n)
elif d.get("external") and repo_tag in d.get("repos", ()):
remaining = [r for r in d["repos"] if r != repo_tag]
if remaining:
d["repos"] = remaining
else:
to_remove.append(n)
G.remove_nodes_from(to_remove)
return len(to_remove)
39 changes: 39 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -7009,6 +7009,45 @@ def _canon(nid: str) -> str:
if e.get("target"):
e["target"] = _canon(e["target"])

# Every resolution/repoint/rewire/disambiguation pass above has now had its
# chance to land an edge endpoint on a real node id. Anything still not in
# the node set at this point is not a bug in one particular resolver — it
# is a genuine reference to something outside the graph (an unimported
# stdlib/third-party module, an unresolved dotted path, ...). Leaving it
# as a bare edge endpoint is not a neutral encoding of "outside the
# graph": every consumer that builds a graph object from nodes+edges
# (networkx included) materializes it as an attribute-less phantom node,
# so the file's declared node set and its produced node set disagree
# (#2873). Mint a minimal, explicitly-marked stub for each so they agree.
# `external: True` distinguishes these from ordinary file/symbol nodes —
# merge-graphs reads it to merge same-named external references across
# repos instead of namespacing them like repo-local ids (#2873).
_declared_ids = {n.get("id") for n in all_nodes if isinstance(n, dict)}
if resolution_context_nodes:
_declared_ids |= {
n.get("id") for n in resolution_context_nodes if isinstance(n, dict)
}
_external_stubs: dict[str, dict] = {}
for e in all_edges:
if not isinstance(e, dict):
continue
tgt = e.get("target")
if not tgt or tgt in _declared_ids or tgt in _external_stubs:
continue
if e.get("relation") in ("depends_on", "requires") or str(tgt).startswith("pkg_"):
continue # manifest/package deps: keep prior prune-not-fabricate behavior (#2873)
_external_stubs[tgt] = {
"id": tgt,
"label": tgt,
"file_type": "code",
"type": "module",
"confidence": "INFERRED",
"external": True,
"source_file": None,
}
if _external_stubs:
all_nodes.extend(_external_stubs.values())

# origin_file is an internal disambiguation hint (#1462): the colliding-id pass
# above reads it to keep same-named cross-file stubs distinct, after which nothing
# consumes it. Drop it from the returned nodes so it never ships into graph.json as
Expand Down
6 changes: 3 additions & 3 deletions tests/test_csharp_type_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,19 +252,19 @@ def test_csharp_import_edges_resolve_internal_namespace_and_alias(tmp_path: Path
(kind, fqn, target.get("type") if target else None)
for kind, fqn, target in imports
]
assert ("namespace", "UnityEngine", None) in [
assert ("namespace", "UnityEngine", "module") in [
(kind, fqn, target.get("type") if target else None)
for kind, fqn, target in imports
]
assert ("alias", "Game.Core.Damage", "Damage") in [
(kind, fqn, target.get("label") if target else None)
for kind, fqn, target in imports
]
assert ("alias", "System.Math", None) in [
assert ("alias", "System.Math", "system_math") in [
(kind, fqn, target.get("label") if target else None)
for kind, fqn, target in imports
]
assert ("static", "Game.Core.Damage", None) in [
assert ("static", "Game.Core.Damage", "game_core_damage") in [
(kind, fqn, target.get("label") if target else None)
for kind, fqn, target in imports
]
Expand Down
16 changes: 16 additions & 0 deletions tests/test_global_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ def test_prune_repo_returns_zero_if_not_present():
assert G.number_of_nodes() == 1


def test_prune_repo_preserves_shared_external_node_until_last_repo():
from graphify.build import prune_repo_from_graph
G = nx.Graph()
G.add_node("repoA::userservice", repo="repoA", label="UserService")
G.add_node("typing", external=True, repos=["repoA", "repoB"], label="typing")

removed = prune_repo_from_graph(G, "repoA")
assert removed == 1 # only repoA::userservice; typing still referenced by repoB
assert "typing" in G.nodes
assert G.nodes["typing"]["repos"] == ["repoB"]

removed = prune_repo_from_graph(G, "repoB")
assert removed == 1
assert "typing" not in G.nodes


# ── global_graph.py ───────────────────────────────────────────────────────────

def test_global_add_creates_global_graph(tmp_path):
Expand Down
Loading