Skip to content

Bug: paired-cpp creates islands, never showing how they connect with other classes. #1547

Description

@c0dezer019

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:

graphify repro/

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:

  1. Always mint the canonical ..._foo node for a header (in addition to, or instead of, the _h_-suffixed translation-unit node), or
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions