Cross-file #include import edges are silently dropped when a header has a paired .cpp
Description
For C/C++ corpora, an #include "foo.h" produces an imports edge only when foo.h is header-only. If a sibling foo.cpp exists, graphify mints the header's node id in a different (doubled, translation-unit-suffixed) form than the one its #include resolver targets. The two ids never match, so the import edge has no valid endpoint and is dropped at build time.
The result: any header that has a paired implementation file appears as an isolated island — no incoming edges from the files that #include it — even though those include relationships exist in the source. The drop is silent (no warning, no error), and the graph health check does not flag it because the edge is discarded during build rather than left dangling.
Minimal reproduction
Create this tree:
repro/
├── bar.h # header-only (no bar.cpp)
├── widget.h # has a paired widget.cpp
├── widget.cpp
└── consumer.cpp # includes BOTH headers
repro/bar.h
#ifndef BAR_H
#define BAR_H
struct Bar { int x; };
#endif
repro/widget.h
#ifndef WIDGET_H
#define WIDGET_H
class Widget {
public:
static int make();
};
#endif
repro/widget.cpp
#include "widget.h"
int Widget::make() { return 42; }
repro/consumer.cpp
#include "bar.h"
#include "widget.h"
int use() { return Widget::make(); }
Then build the graph:
Expected
consumer.cpp should have an imports edge to both bar.h and widget.h.
Actual
consumer.cpp has an imports edge to bar.h only. The edge to widget.h is missing; widget.h + widget.cpp form an island with no incoming edges.
Verification snippet
Run from the directory containing graphify-out/ (edges are stored under the links key):
import json, os, glob
g = json.load(open('graphify-out/graph.json'))
ids = set(n['id'] for n in g['nodes'])
# Reproduce the canonical id the #include resolver targets, and check it exists.
for h in sorted(glob.glob('repro/**/*.h', recursive=True)):
base = h[:-2] # strip ".h"
has_cpp = os.path.exists(base + '.cpp')
canon = base.replace('/', '_').replace('-', '_').lower() # e.g. repro/widget -> repro_widget
print(f"{'paired-cpp' if has_cpp else 'header-only':12} "
f"canon_node={'Y' if canon in ids else 'N'} {h}")
Output:
header-only canon_node=Y repro/bar.h <-- import edge lands
paired-cpp canon_node=N repro/widget.h <-- canonical id never minted -> island
Root cause
graphify generates a header's node id with two divergent code paths:
-
Header-only foo.h → one file/module node with the canonical id ..._foo. The #include "foo.h" resolver computes that same id → match → imports edge connects.
-
Header foo.h with sibling foo.cpp → two translation-unit nodes with a doubled, suffixed id form:
..._foo_h_..._foo
..._foo_cpp_..._foo
The #include "foo.h" resolver still computes the canonical ..._foo, which is never minted → the edge's target does not exist → the edge is dropped.
So the include-resolver and the file-node-minter disagree on the id scheme precisely when a header is paired with a .cpp. The doubled ..._foo_h_..._foo form (path component repeated) is itself a symptom of the divergence.
Suggested fix
Make the two paths agree on one canonical id for a header regardless of .cpp pairing — either:
- Always mint the canonical
..._foo node for a header (in addition to, or instead of, the _h_-suffixed translation-unit node), or
- Have the
#include resolver target the same _h_-suffixed id the minter actually produces for paired headers.
A regression test on the minimal reproduction above (assert consumer.cpp --imports--> widget.h) would guard it.
Impact
In a real Qt/C++ codebase (84 files), this dropped the import edges for all 19 headers that had a paired .cpp while the 2 header-only headers connected correctly — so the graph under-draws header→consumer structure across nearly every component. The affected utility class looked like a disconnected island despite being included and called by three other files.
Cross-file
#includeimport edges are silently dropped when a header has a paired.cppDescription
For C/C++ corpora, an
#include "foo.h"produces animportsedge only whenfoo.his header-only. If a siblingfoo.cppexists, graphify mints the header's node id in a different (doubled, translation-unit-suffixed) form than the one its#includeresolver targets. The two ids never match, so the import edge has no valid endpoint and is dropped at build time.The result: any header that has a paired implementation file appears as an isolated island — no incoming edges from the files that
#includeit — even though those include relationships exist in the source. The drop is silent (no warning, no error), and the graph health check does not flag it because the edge is discarded during build rather than left dangling.Minimal reproduction
Create this tree:
repro/bar.hrepro/widget.hrepro/widget.cpprepro/consumer.cppThen build the graph:
Expected
consumer.cppshould have animportsedge to bothbar.handwidget.h.Actual
consumer.cpphas animportsedge tobar.honly. The edge towidget.his missing;widget.h+widget.cppform an island with no incoming edges.Verification snippet
Run from the directory containing
graphify-out/(edges are stored under thelinkskey):Output:
Root cause
graphify generates a header's node id with two divergent code paths:
Header-only
foo.h→ one file/module node with the canonical id..._foo. The#include "foo.h"resolver computes that same id → match →importsedge connects.Header
foo.hwith siblingfoo.cpp→ two translation-unit nodes with a doubled, suffixed id form:..._foo_h_..._foo..._foo_cpp_..._fooThe
#include "foo.h"resolver still computes the canonical..._foo, which is never minted → the edge's target does not exist → the edge is dropped.So the include-resolver and the file-node-minter disagree on the id scheme precisely when a header is paired with a
.cpp. The doubled..._foo_h_..._fooform (path component repeated) is itself a symptom of the divergence.Suggested fix
Make the two paths agree on one canonical id for a header regardless of
.cpppairing — either:..._foonode for a header (in addition to, or instead of, the_h_-suffixed translation-unit node), or#includeresolver target the same_h_-suffixed id the minter actually produces for paired headers.A regression test on the minimal reproduction above (assert
consumer.cpp --imports--> widget.h) would guard it.Impact
In a real Qt/C++ codebase (84 files), this dropped the import edges for all 19 headers that had a paired
.cppwhile the 2 header-only headers connected correctly — so the graph under-draws header→consumer structure across nearly every component. The affected utility class looked like a disconnected island despite being included and called by three other files.