fix(fqn): preserve full filename in File-node QNs so sibling files don't collide

cbm_pipeline_fqn_compute stripped the file extension for EVERY qualified
name, including File-node QNs (name=="__file__"). Two different files
whose names share a stem therefore collided on the File QN and the
graph kept only one node per stem:

- .env / .env.local / .env.production all strip to ".env" — only one
  survives per directory (#1077).
- a C/C++ header and its same-stem source (NodeController.h vs .cpp)
  strip to the same stem, so the header got no File node of its own,
  leaving it disconnected (#964's remaining half after #983 fixed the
  include→Class resolution).

File-node QNs now keep the full filename (tokenize_path splits on '/'
only, so the extension rides in the last segment and stays unique).
Extension stripping is retained for MODULE/symbol QNs (name==NULL or a
symbol name) — that stem unification is load-bearing for C/C++
declaration↔definition cross-file resolution, and is left untouched.
All 26 __file__ QN sites route through this one function, so node
creation and every lookup stay consistent.

Reproduce-first:
- fqn_file_qn_preserves_dotfile_variants_issue1077 and
  fqn_file_qn_distinguishes_same_stem_header_source_issue964 assert the
  distinct File QNs (RED before: both collide to the stripped stem).
- fqn_module_qn_still_strips_extension guards that module QNs are
  unaffected.
- repro_issue964 now asserts the header keeps its own File node AND is
  connected (CONTAINS_FILE + DEFINES the class it declares), matching
  the reporter's zero-inbound disconnection metric — GREEN.

Closes #1077
Closes #964

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-07-16 01:21:45 +02:00
1 parent ef1482245f
commit 751752bd21
3 files changed
+80 -23

No files matched your search

+13 -1
View File
@@ -117,7 +117,19 @@ char *cbm_pipeline_fqn_compute(const char *project, const char *rel_path, const
char *path = strdup(rel_path ? rel_path : "");
cbm_normalize_path_sep(path);
strip_file_extension(path);
/* #1077/#964: File-node QNs (name=="__file__") must preserve the full
* filename so sibling files sharing a stem get DISTINCT nodes — e.g.
* .env / .env.local / .env.production (which all strip to ".env" and
* collide, so only one survives per directory), and a C/C++ header vs
* its same-stem .cpp. Extension stripping stays for MODULE/symbol QNs
* (name==NULL or a symbol): that stem unification is load-bearing for
* C/C++ declaration↔definition cross-file resolution. All 26 __file__
* QN sites route through here, so creation and every lookup stay
* consistent. */
bool is_file_qn = name && strcmp(name, "__file__") == 0;
if (!is_file_qn) {
strip_file_extension(path);
}
const char *segments[CBM_SZ_256];
int seg_count = 0;
+32 -22
View File
@@ -60,26 +60,36 @@ static int r964_file_node_exists(cbm_store_t *store, const char *project, const
return found;
}
/* Count inbound IMPORTS edges whose TARGET node name is exactly `name`. */
static int r964_inbound_imports(cbm_store_t *store, const char *project, const char *name) {
cbm_edge_t *edges = NULL;
int n = 0;
if (cbm_store_find_edges_by_type(store, project, "IMPORTS", &edges, &n) != CBM_STORE_OK)
return -1;
int hits = 0;
for (int i = 0; i < n; i++) {
cbm_node_t tgt;
if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK)
/* Count INBOUND edges (any of the reporter's connection types) landing on the
* File node named `name` — the exact disconnection metric from the issue's
* query `NOT EXISTS { (f)<-[:IMPORTS|CALLS|CONTAINS_FILE|DEFINES|USAGE]-() }`.
* A header that used to have no node at all (extension-stripped QN collision)
* now has its own File node AND is connected (CONTAINS_FILE from its folder,
* plus DEFINES of the class it declares); `#include` itself resolves to the
* declared Class node via #983, not to the header File. */
static int r964_file_inbound(cbm_store_t *store, const char *project, const char *name) {
static const char *const types[] = {"IMPORTS", "CALLS", "CONTAINS_FILE",
"DEFINES", "USAGE", NULL};
int total = 0;
for (int t = 0; types[t]; t++) {
cbm_edge_t *edges = NULL;
int n = 0;
if (cbm_store_find_edges_by_type(store, project, types[t], &edges, &n) != CBM_STORE_OK)
continue;
if (tgt.name && strcmp(tgt.name, name) == 0)
hits++;
cbm_node_free_fields(&tgt);
for (int i = 0; i < n; i++) {
cbm_node_t tgt;
if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK)
continue;
if (tgt.name && strcmp(tgt.name, name) == 0)
total++;
cbm_node_free_fields(&tgt);
}
cbm_store_free_edges(edges, n);
}
cbm_store_free_edges(edges, n);
return hits;
return total;
}
TEST(repro_issue964_header_has_node_and_inbound_import) {
TEST(repro_issue964_header_has_node_and_is_connected) {
static const RFile files[] = {
{"NodeController.h", "#pragma once\n"
"class NodeController {\n"
@@ -102,20 +112,20 @@ TEST(repro_issue964_header_has_node_and_inbound_import) {
ASSERT_NOT_NULL(store);
int header_node = r964_file_node_exists(store, lp.project, "NodeController.h");
int header_inbound = r964_inbound_imports(store, lp.project, "NodeController.h");
int header_inbound = r964_file_inbound(store, lp.project, "NodeController.h");
if (!header_node || header_inbound < 1) {
fprintf(stderr,
" [964] FAIL header_file_node=%d inbound_imports=%d (header merged into "
"same-stem .cpp by extension-stripped QN collision)\n",
" [964] FAIL header_file_node=%d inbound_edges=%d (header merged into "
"same-stem .cpp by extension-stripped QN collision, or left disconnected)\n",
header_node, header_inbound);
}
ASSERT_TRUE(header_node); /* header must keep its own File node */
ASSERT_TRUE(header_inbound >= 1); /* #include must land an edge on it */
ASSERT_TRUE(header_node); /* header keeps its own File node (not merged) */
ASSERT_TRUE(header_inbound >= 1); /* and is connected (not the reporter's zero-inbound) */
rh_cleanup(&lp, store);
PASS();
}
SUITE(repro_issue964) {
RUN_TEST(repro_issue964_header_has_node_and_inbound_import);
RUN_TEST(repro_issue964_header_has_node_and_is_connected);
}
+35
View File
@@ -52,6 +52,38 @@ TEST(fqn_compute_basic_c) {
PASS();
}
/* #1077/#964: File-node QNs (name=="__file__") must preserve the FULL filename
* so sibling files sharing a stem get DISTINCT nodes. Extension stripping is
* kept for module/symbol QNs (name!=__file__) — load-bearing for C/C++
* declaration↔definition resolution. */
TEST(fqn_file_qn_preserves_dotfile_variants_issue1077) {
/* .env / .env.local / .env.production all stripped to ".env" before,
* colliding so only one File node survived per directory. */
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env", "__file__"), "proj..env.__file__");
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env.local", "__file__"),
"proj..env.local.__file__");
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env.production", "__file__"),
"proj..env.production.__file__");
PASS();
}
TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964) {
/* NodeController.h and NodeController.cpp both stripped to
* "NodeController", so the header's File node was merged into the .cpp's. */
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "NodeController.h", "__file__"),
"proj.NodeController.h.__file__");
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "NodeController.cpp", "__file__"),
"proj.NodeController.cpp.__file__");
PASS();
}
TEST(fqn_module_qn_still_strips_extension) {
/* The MODULE/symbol QN keeps stripping — unchanged by the File-QN fix. */
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "core.c", "init"), "proj.core.init");
ASSERT_FQN(cbm_pipeline_fqn_module("proj", "NodeController.cpp"), "proj.NodeController");
PASS();
}
TEST(fqn_compute_basic_rs) {
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib.rs", "new"), "proj.lib.new");
PASS();
@@ -559,6 +591,9 @@ SUITE(fqn) {
RUN_TEST(fqn_compute_basic_ts);
RUN_TEST(fqn_compute_basic_js);
RUN_TEST(fqn_compute_basic_c);
RUN_TEST(fqn_file_qn_preserves_dotfile_variants_issue1077);
RUN_TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964);
RUN_TEST(fqn_module_qn_still_strips_extension);
RUN_TEST(fqn_compute_basic_rs);
/* fqn_compute: nested paths */