Skip to content

[BUG] graph.json contains edges to undeclared nodes, so loaders materialise attribute-less phantom nodes (and merge-graphs fragments external modules per repo) #2873

Description

@fabio-rovai

Summary

graph.json contains edges whose endpoints are not in the nodes array. Every consumer that builds a graph from the file, including networkx, materialises those endpoints as nodes with no attributes, so the node set the file declares and the node set the file produces disagree.

Version

graphifyy 0.9.46, Python 3.13, graphify update . --no-cluster.

Reproduction

Any Python source that imports from the standard library or an uninstalled package. Running against a small package (8 modules):

import json, networkx as nx

g = json.load(open("graphify-out/graph.json"))
declared = {n["id"] for n in g["nodes"]}
G = nx.node_link_graph(g, edges="links")

print(len(g["nodes"]), G.number_of_nodes())
print(sorted(n for n in G.nodes if n not in declared))

Actual

125 131
['dataclasses', 'mcp_server_fastmcp', 'pathlib', 'pyoxigraph', 're', 'typing']

Ten imports / imports_from edges point at module names that were never emitted as nodes. The file says 125 nodes; loading it yields 131.

This surfaces in graphify's own output too:

$ graphify explain "typing"
  Type:
  Community:
  Degree:    1

Type and Community are blank because there is no node record to read them from, and graphify path will route through these endpoints as though they were ordinary nodes.

The cross-repo consequence

merge-graphs has to paper over this, and the way it does creates a second problem. Merging two repositories that both import typing and pathlib:

inputs: 5 + 6 nodes, 5 + 6 edges
merged: 15 nodes, 11 edges

The four extra nodes are the materialised endpoints, namespaced per repo:

id=repoA::typing    label=None  type=None
id=repoA::pathlib   label=None  type=None
id=repoB::typing    label=None  type=None
id=repoB::pathlib   label=None  type=None

So the same standard-library module becomes four distinct nodes across two repositories, and a merged graph cannot answer "which repos depend on typing", which is the question a cross-repo merge exists to answer. Namespacing is right for repo-local ids; these are not repo-local, they are external identifiers that happen to have arrived through a repo-local path.

What I am not claiming

Not emitting nodes for external modules may well be deliberate, to keep the graph to code you own. If so, the issue is the edges rather than the missing nodes: an edge to a node that does not exist is not a neutral encoding of "this import goes outside the graph", because no consumer can distinguish it from a node the extractor forgot to write.

Checked and clean, for what it is worth

Two things I expected to find and did not, both worth recording:

  • Builds are deterministic. Two independent runs over the same tree produced identical node ids in identical order, identical edges in identical order, and identical top-level keys. The local-deterministic-parsing claim holds.
  • merge-graphs does not collide repo-local ids. Two unrelated repositories that both contain src/app/engine.py produce the same bare id src_app_engine, and the merge correctly namespaces them to repoA:: / repoB:: while preserving local_id and repo. 7 nodes in, 7 nodes out, nothing silently unified.
  • diagnose multigraph works. It found the same 8 same-endpoint edge pairs my own check did, including the imports_from / re_exports pairs on __init__.py.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions