From 632fd08cf1c299468bd1d55a936bbb9a41f8229d Mon Sep 17 00:00:00 2001 From: Sinjan-Debnath Date: Thu, 9 Jul 2026 17:21:58 +0000 Subject: [PATCH 1/4] fix: resolve C/C++ includes to header file targets by matching stems Closes #964 Signed-off-by: Sinjan-Debnath --- internal/cbm/extract_defs.c | 4 +- src/pipeline/pass_pkgmap.c | 161 +++++++++++++++++- tests/fixtures/cpp_include/NodeController.cpp | 5 + tests/fixtures/cpp_include/NodeController.h | 5 + .../fixtures/cpp_include/SystemController.cpp | 5 + tests/fixtures/cpp_include/SystemController.h | 5 + tests/fixtures/cpp_include/main.cpp | 6 + tests/test_edge_imports.c | 150 ++++++++++++++++ 8 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/cpp_include/NodeController.cpp create mode 100644 tests/fixtures/cpp_include/NodeController.h create mode 100644 tests/fixtures/cpp_include/SystemController.cpp create mode 100644 tests/fixtures/cpp_include/SystemController.h create mode 100644 tests/fixtures/cpp_include/main.cpp diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2de63ffbb..9fa24af94 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5540,8 +5540,8 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch * For the nested case, the child has no "type" field directly. Detect by * walking named children for a variable_declaration. */ TSNode type_node = ts_node_child_by_field_name(child, TS_FIELD("type")); - TSNode name_node = - ts_node_is_null(type_node) ? (TSNode){0} : resolve_field_name_node(child); + TSNode empty_node = {0}; + TSNode name_node = ts_node_is_null(type_node) ? empty_node : resolve_field_name_node(child); if (ts_node_is_null(type_node)) { uint32_t cnc = ts_node_named_child_count(child); diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 61803254b..511a84564 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -426,8 +426,12 @@ static void parse_pyproject_toml(const char *source, int source_len, const char pkg_entries_push(entries, name, entry); /* Also register /__init__ as alternative (no src/ prefix) */ - snprintf(suffix, sizeof(suffix), "%s/__init__", name_copy); - char *alt_entry = build_entry_path(rel_path, suffix); + char *alt_entry = NULL; + if (name_copy) { + snprintf(suffix, sizeof(suffix), "%s/__init__", name_copy); + alt_entry = build_entry_path(rel_path, suffix); + } + if (name_copy && alt_entry) { pkg_entries_push(entries, name_copy, alt_entry); } else { @@ -1265,6 +1269,151 @@ static bool import_targetable_label(const char *label) { return false; } +static const char *path_leaf(const char *path) { + const char *leaf = path; + for (const char *p = path; p && *p; p++) { + if (*p == '/' || *p == '\\') { + leaf = p + 1; + } + } + return leaf; +} + +static bool is_header_include(const char *path) { + if (!path || !path[0]) { + return false; + } + static const char *header_exts[] = {".h", ".hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", NULL}; + for (const char **ext = header_exts; *ext; ext++) { + size_t path_len = strlen(path); + size_t ext_len = strlen(*ext); + if (path_len > ext_len && strcmp(path + path_len - ext_len, *ext) == 0) { + return true; + } + } + return false; +} + +static bool is_c_family_source(const char *source_rel) { + if (!source_rel || !source_rel[0]) { + return false; + } + static const char *exts[] = {".c", ".cc", ".cpp", ".cxx", ".c++", + ".h", ".hh", ".hpp", ".hxx", NULL}; + for (const char **ext = exts; *ext; ext++) { + size_t path_len = strlen(source_rel); + size_t ext_len = strlen(*ext); + if (path_len > ext_len && strcmp(source_rel + path_len - ext_len, *ext) == 0) { + return true; + } + } + return false; +} + +static const cbm_gbuf_node_t *resolve_exact_file_node(const cbm_pipeline_ctx_t *ctx, + const char *file_path, + const char *source_file_qn) { + if (!ctx || !file_path || !file_path[0]) { + return NULL; + } + + const char *leaf = path_leaf(file_path); + if (!leaf || !leaf[0]) { + return NULL; + } + + char stem[PKGMAP_PATH_BUF]; + snprintf(stem, sizeof(stem), "%s", leaf); + char *last_dot = strrchr(stem, '.'); + if (last_dot && last_dot != stem) { + *last_dot = '\0'; + } + + const char *names[2] = {stem[0] ? stem : NULL, leaf}; + const cbm_gbuf_node_t *best = NULL; + for (int ni = 0; ni < 2; ni++) { + const char *name = names[ni]; + if (!name || !name[0]) { + continue; + } + + const cbm_gbuf_node_t **hits = NULL; + int hit_count = 0; + if (cbm_gbuf_find_by_name(ctx->gbuf, name, &hits, &hit_count) != 0 || !hits) { + continue; + } + + for (int i = 0; i < hit_count; i++) { + const cbm_gbuf_node_t *cand = hits[i]; + if (!cand || !cand->file_path) { + continue; + } + + /* Tie-breaker: Ensure the candidate's actual file_path ends with the + * specific include path requested (e.g., 'a/util.h' instead of just 'util.h'). */ + if (!ends_with(cand->file_path, file_path)) { + continue; + } + + if (!cand->label || !import_targetable_label(cand->label)) { + continue; + } + if (source_file_qn && cand->qualified_name && + strcmp(cand->qualified_name, source_file_qn) == 0) { + continue; + } + if (strcmp(cand->label, "File") == 0) { + return cand; + } + if (!best) { + best = cand; + } + } + if (best) { + return best; + } + } + return NULL; +} + +static const cbm_gbuf_node_t *resolve_header_include(const cbm_pipeline_ctx_t *ctx, + const char *source_rel, + const char *source_file_qn, + const char *module_path) { + if (!is_c_family_source(source_rel) || !is_header_include(module_path)) { + return NULL; + } + + const char *base = module_path; + if (base[0] == '.' && base[1] == '/') { + base += 2; + } + + const cbm_gbuf_node_t *exact = resolve_exact_file_node(ctx, base, source_file_qn); + if (exact) { + return exact; + } + + if (source_rel && source_rel[0]) { + char *dir = path_dirname(source_rel); + if (dir) { + char candidate[PKGMAP_PATH_BUF]; + if (dir[0]) { + snprintf(candidate, sizeof(candidate), "%s/%s", dir, base); + } else { + snprintf(candidate, sizeof(candidate), "%s", base); + } + free(dir); + exact = resolve_exact_file_node(ctx, candidate, source_file_qn); + if (exact) { + return exact; + } + } + } + + return NULL; +} + /* Resolve a sibling-file import: a bare path/name (no leading "./") that names * a file relative to the importer's directory. This covers build/markup * grammars whose import string is a sibling filename or directory rather than a @@ -1352,6 +1501,14 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t return NULL; } + /* Prefer exact header-file nodes for C/C++ includes so same-stem source or + * module nodes do not steal the edge target. */ + const cbm_gbuf_node_t *header_target = + resolve_header_include(ctx, source_rel, source_file_qn, imp->module_path); + if (header_target) { + return header_target; + } + /* Strategy 1: module-path resolution → existing node (Python/TS/Go). * No label filter here: directory-module languages (Go/Java packages) * legitimately resolve straight to a Folder node -- that's the intended, diff --git a/tests/fixtures/cpp_include/NodeController.cpp b/tests/fixtures/cpp_include/NodeController.cpp new file mode 100644 index 000000000..8f27fb91e --- /dev/null +++ b/tests/fixtures/cpp_include/NodeController.cpp @@ -0,0 +1,5 @@ +#include "NodeController.h" + +int node_controller_value() { + return 1; +} \ No newline at end of file diff --git a/tests/fixtures/cpp_include/NodeController.h b/tests/fixtures/cpp_include/NodeController.h new file mode 100644 index 000000000..72a8bafa8 --- /dev/null +++ b/tests/fixtures/cpp_include/NodeController.h @@ -0,0 +1,5 @@ +#pragma once + +struct NodeController { + int value; +}; \ No newline at end of file diff --git a/tests/fixtures/cpp_include/SystemController.cpp b/tests/fixtures/cpp_include/SystemController.cpp new file mode 100644 index 000000000..8937a1b67 --- /dev/null +++ b/tests/fixtures/cpp_include/SystemController.cpp @@ -0,0 +1,5 @@ +#include "SystemController.h" + +int system_controller_value() { + return 2; +} \ No newline at end of file diff --git a/tests/fixtures/cpp_include/SystemController.h b/tests/fixtures/cpp_include/SystemController.h new file mode 100644 index 000000000..521c84f5b --- /dev/null +++ b/tests/fixtures/cpp_include/SystemController.h @@ -0,0 +1,5 @@ +#pragma once + +struct SystemController { + int value; +}; \ No newline at end of file diff --git a/tests/fixtures/cpp_include/main.cpp b/tests/fixtures/cpp_include/main.cpp new file mode 100644 index 000000000..a6e46c126 --- /dev/null +++ b/tests/fixtures/cpp_include/main.cpp @@ -0,0 +1,6 @@ +#include "NodeController.h" +#include + +int main() { + return 0; +} \ No newline at end of file diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 8f4d20762..caf723267 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -72,6 +72,10 @@ typedef struct { const char *content; } EILangFile; +typedef struct { + const char *name; /* fixture filename relative to a checked-in fixture root */ +} EILangFixtureFile; + static void ei_to_fwd_slashes(char *p) { for (; *p; p++) { if (*p == '\\') *p = '/'; @@ -123,6 +127,89 @@ static cbm_store_t *ei_index_files(EILangProj *lp, const EILangFile *files, int return cbm_store_open_path(lp->dbpath); } +static char *ei_slurp_file(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) { + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + return NULL; + } + long size = ftell(f); + if (size < 0) { + fclose(f); + return NULL; + } + if (fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + return NULL; + } + char *buf = (char *)calloc((size_t)size + 1, 1); + if (!buf) { + fclose(f); + return NULL; + } + size_t nread = fread(buf, 1, (size_t)size, f); + fclose(f); + buf[nread] = '\0'; + return buf; +} + +static cbm_store_t *ei_index_fixture_files(EILangProj *lp, const char *fixture_root, + const EILangFixtureFile *files, int nfiles) { + EILangFile *loaded = (EILangFile *)calloc((size_t)nfiles, sizeof(EILangFile)); + char **contents = (char **)calloc((size_t)nfiles, sizeof(char *)); + if (!loaded || !contents) { + free(loaded); + free(contents); + return NULL; + } + + cbm_store_t *store = NULL; + for (int i = 0; i < nfiles; i++) { + char path[512]; + snprintf(path, sizeof(path), "%s/%s", fixture_root, files[i].name); + contents[i] = ei_slurp_file(path); + if (!contents[i]) { + goto done; + } + loaded[i].name = files[i].name; + loaded[i].content = contents[i]; + } + + store = ei_index_files(lp, loaded, nfiles); + +done: + for (int i = 0; i < nfiles; i++) { + free(contents[i]); + } + free(contents); + free(loaded); + return store; +} + +static int64_t ei_node_id_for_file_label(cbm_store_t *store, const char *project, + const char *file_path, const char *label) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_file(store, project, file_path, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + int64_t id = 0; + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0) { + id = nodes[i].id; + break; + } + } + if (id == 0 && count > 0) { + id = nodes[0].id; + } + cbm_store_free_nodes(nodes, count); + return id; +} + static void ei_cleanup(EILangProj *lp, cbm_store_t *store) { if (store) cbm_store_close(store); if (lp->srv) { cbm_mcp_server_free(lp->srv); lp->srv = NULL; } @@ -409,6 +496,68 @@ TEST(ei_go_two_consumers_same_package) { PASS(); } +/* C++: header include should resolve to the header file node, not the same-stem + * source node. Also exercises angle-bracket include resolution. */ +TEST(ei_cpp_header_include_targets_header_file) { + static const EILangFixtureFile fixture_files[] = { + {"main.cpp"}, + {"NodeController.h"}, + {"NodeController.cpp"}, + {"SystemController.h"}, + {"SystemController.cpp"}, + }; + + EILangProj lp; + cbm_store_t *store = ei_index_fixture_files(&lp, "tests/fixtures/cpp_include", + fixture_files, + (int)(sizeof(fixture_files) / sizeof(fixture_files[0]))); + ASSERT_NOT_NULL(store); + + int64_t main_id = ei_node_id_for_file_label(store, lp.project, "main.cpp", "File"); + int64_t node_source_id = ei_node_id_for_file_label(store, lp.project, "NodeController.cpp", "File"); + int64_t system_source_id = ei_node_id_for_file_label(store, lp.project, "SystemController.cpp", "File"); + + ASSERT_GT(main_id, 0); + ASSERT_GT(node_source_id, 0); + ASSERT_GT(system_source_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + int rc = cbm_store_find_edges_by_source_type(store, main_id, "IMPORTS", &edges, &edge_count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_TRUE(edge_count >= 2); + + bool saw_node_header = false; + bool saw_system_header = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + + /* Pass target directly (no &) because it is already a pointer */ + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK); + ASSERT_EQ(edges[i].source_id, main_id); + ASSERT_NEQ(edges[i].target_id, node_source_id); + ASSERT_NEQ(edges[i].target_id, system_source_id); + + /* Use -> instead of . to access fields on a pointer */ + if (target->file_path && strcmp(target->file_path, "NodeController.h") == 0) { + saw_node_header = true; + } + if (target->file_path && strcmp(target->file_path, "SystemController.h") == 0) { + saw_system_header = true; + } + + /* Free the node inside the loop */ + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + + ASSERT_TRUE(saw_node_header); + ASSERT_TRUE(saw_system_header); + + ei_cleanup(&lp, store); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * RED REPRODUCTION — Rust * @@ -868,6 +1017,7 @@ SUITE(edge_imports) { RUN_TEST(ei_go_subpackage_import); RUN_TEST(ei_go_blank_import); RUN_TEST(ei_go_two_consumers_same_package); + RUN_TEST(ei_cpp_header_include_targets_header_file); /* ── RED REPRODUCTIONS — Rust (expected to FAIL until pipeline fixed) ── */ RUN_TEST(ei_rust_mod_plus_use); From 3b1ea5826e5904ef43bdb3b257f518103ce70d15 Mon Sep 17 00:00:00 2001 From: metehanulusoy Date: Sun, 12 Jul 2026 14:55:22 +0300 Subject: [PATCH 2/4] fix(mcp): ignore ::missed shadow rows when resolving a db's project name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The miss-graph pass inserts a second `projects` row ("::missed") so its nodes satisfy the FK on nodes.project. Since #704, db_internal_project_name required the projects table to hold exactly one row, so any project with a miss graph vanished from list_projects and the graph UI, and the fallback-scan resolve path failed — the index looked empty while fully present. Filter out internal shadow rows (names containing "::") and require exactly one primary row instead. Adds a reproduce-first regression test modeled on the #704 fixture (red on the old code: 150 passed, 1 failed; green with the fix: 151 passed). Fixes #1044 Signed-off-by: metehanulusoy Co-Authored-By: Claude Fable 5 --- src/mcp/mcp.c | 21 ++++++++++--- tests/test_mcp.c | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 0d7aa14d6..59c1f94a0 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1335,10 +1335,23 @@ static bool db_internal_project_name(const char *full_path, char *name_out, size cbm_project_t *projs = NULL; int n = 0; bool ok = false; - if (cbm_store_list_projects(st, &projs, &n) == CBM_STORE_OK && n == 1 && projs[0].name && - projs[0].name[0]) { - snprintf(name_out, name_sz, "%s", projs[0].name); - ok = true; + if (cbm_store_list_projects(st, &projs, &n) == CBM_STORE_OK) { + /* Ignore internal shadow projects ("::missed" miss-graph rows): + * they share the db with the primary project and must not make it + * unresolvable — requiring n == 1 over ALL rows made every project + * with a miss graph vanish from list_projects and the UI (#1044). */ + int primary = -1; + int primary_count = 0; + for (int i = 0; i < n; i++) { + if (projs[i].name && projs[i].name[0] && !strstr(projs[i].name, "::")) { + primary = i; + primary_count++; + } + } + if (primary_count == 1) { + snprintf(name_out, name_sz, "%s", projs[primary].name); + ok = true; + } } cbm_store_free_projects(projs, n); if (ok && out_store) { diff --git a/tests/test_mcp.c b/tests/test_mcp.c index e24ec0d73..a5ae60ceb 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4134,6 +4134,81 @@ TEST(tool_resolve_store_by_internal_name_issue704) { PASS(); } +/* ── #1044: a "::missed" shadow row must not hide the project ── + * + * The miss-graph pass inserts a second `projects` row ("::missed") so + * its nodes satisfy the FK on nodes.project. db_internal_project_name + * required the projects table to hold EXACTLY ONE row, so any project with + * a miss graph vanished from list_projects and the graph UI, and the + * fallback-scan resolve path failed. + * + * RED on buggy code / GREEN on the fix: + * A. list_projects still advertises "delta1044" while the shadow row exists. + * B. the shadow name itself is never advertised. + * C. search_graph(project="delta1044") still resolves and returns the node. + */ +TEST(tool_list_projects_ignores_missed_shadow_issue1044) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-issue1044-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails — not a #1044 signal */ + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + ASSERT_TRUE(issue704_make_db(cache, "delta1044.db", "delta1044", "deltaFunc1044")); + + /* Add the shadow row exactly the way the miss-graph pass does. */ + char db_path[700]; + snprintf(db_path, sizeof(db_path), "%s/delta1044.db", cache); + cbm_store_t *st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + ASSERT_EQ(cbm_store_upsert_project(st, "delta1044::missed", ""), CBM_STORE_OK); + cbm_store_close(st); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* ── A + B: primary advertised, shadow hidden ─────────────────── */ + char *list = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(list); + ASSERT_NOT_NULL(strstr(list, "delta1044")); /* RED before: db skipped as ghost */ + ASSERT_NULL(strstr(list, "::missed")); /* shadow never advertised */ + free(list); + + /* ── C: the project still resolves and returns its node ───────── */ + char *q = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"delta1044\",\"name_pattern\":\"deltaFunc1044\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q); + ASSERT_NOT_NULL(strstr(q, "deltaFunc1044")); + ASSERT_NULL(strstr(q, "not found")); + free(q); + + cbm_mcp_server_free(srv); + + /* ── cleanup ───────────────────────────────────────────────────── */ + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_unlink(db_path); + char side1044[740]; + snprintf(side1044, sizeof(side1044), "%s-wal", db_path); + cbm_unlink(side1044); + snprintf(side1044, sizeof(side1044), "%s-shm", db_path); + cbm_unlink(side1044); + cbm_rmdir(cache); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * QUERY STORE READ-ONLY (data-integrity reproductions) * @@ -5763,6 +5838,7 @@ SUITE(mcp) { RUN_TEST(tool_bad_project_name_no_overflow_issue235); RUN_TEST(tool_bad_project_error_valid_json_issue235); RUN_TEST(tool_resolve_store_by_internal_name_issue704); + RUN_TEST(tool_list_projects_ignores_missed_shadow_issue1044); /* auto_watch gate (distilled from PR #625) */ RUN_TEST(mcp_auto_watch_default_registers_watcher_on_connect); From 2833ef55f7ca80a78771dfe8c3c3119d65e829d3 Mon Sep 17 00:00:00 2001 From: Tommy Corbett Date: Fri, 10 Jul 2026 11:12:18 +0200 Subject: [PATCH 3/4] fix(incremental): name File nodes by basename, matching the full pipeline The incremental path passed changed_files[i].rel_path as the File node NAME, while the full pipeline names File nodes by basename (pipeline.c "Create File node"). Node upserts match by qualified name, so an incremental re-index of a touched file RENAMED its File node in place ("applications.py" -> "fastapi/applications.py"). File nodes are USAGE-edge sources, so: - the same tree yields different graphs depending on whether it was indexed fresh or incrementally, and the divergence grows as files are edited (add AND modify both rename); - name-based traversal dead-ends at any touched file's node (e.g. walking inbound USAGE transitively misses everything that reaches a target through a recently added/modified class). Mirror the full pipeline's node shape at the incremental call site: basename as the name, and the same {"extension": ...} props (previously "{}"). Adds incr_file_node_name_stays_basename to the incremental suite: modify a file, incremental re-index, then assert no File node name contains '/'. Red without the fix, green with it; full incremental suite 162/162. Signed-off-by: Tommy Corbett --- src/pipeline/pipeline_incremental.c | 13 ++++++++++-- tests/test_incremental.c | 31 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index ee69fc9c2..3b223e2bc 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -848,8 +848,17 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil for (int i = 0; i < ci; i++) { char *file_qn = cbm_pipeline_fqn_compute(project, changed_files[i].rel_path, "__file__"); if (file_qn) { - cbm_gbuf_upsert_node(existing, "File", changed_files[i].rel_path, file_qn, - changed_files[i].rel_path, 0, 0, "{}"); + /* #994: the name must be the BASENAME with extension props, + * mirroring the full build's File node (pipeline.c) — upserts + * match by QN, so any other name here renames the node in place + * and the incremental graph diverges from a full build. */ + const char *rel = changed_files[i].rel_path; + const char *slash = strrchr(rel, '/'); + const char *basename = slash ? slash + SKIP_ONE : rel; + char props[CBM_SZ_256]; + const char *ext = strrchr(basename, '.'); + snprintf(props, sizeof(props), "{\"extension\":\"%s\"}", ext ? ext : ""); + cbm_gbuf_upsert_node(existing, "File", basename, file_qn, rel, 0, 0, props); free(file_qn); } } diff --git a/tests/test_incremental.c b/tests/test_incremental.c index ae425b936..00c2ea53a 100644 --- a/tests/test_incremental.c +++ b/tests/test_incremental.c @@ -964,6 +964,36 @@ static int resp_lacks_key(const char *resp, const char *key) { /* ── list_projects ─────────────────────────────────────────────── */ +TEST(incr_file_node_name_stays_basename) { + /* #994: File nodes must keep their basename NAME through an incremental + * re-index (the bug renamed touched files' nodes to rel_path, diverging + * from a full build). Modify a file, re-index, assert no File node name + * contains a path separator. */ + char path[512]; + snprintf(path, sizeof(path), "%s/fastapi/applications.py", g_repodir); + FILE *f = fopen(path, "a"); + ASSERT(f != NULL); + fprintf(f, "\n# incr_file_node_name_stays_basename touch\n"); + fclose(f); + + char *resp = index_repo(); + ASSERT(resp != NULL); + free(resp); + + double ms; + char *r = call_tool_timed("query_graph", &ms, + "{\"project\":\"%s\",\"format\":\"json\"," + "\"query\":\"MATCH (n:File) WHERE n.name CONTAINS '/'" + " RETURN n.name LIMIT 5\"}", + g_project); + TOOL_OK(r, ms); + /* No File node may carry a path separator in its name; the query engine + * returns an empty rows array when nothing matches. */ + ASSERT(strstr(r, "\"rows\":[]") != NULL || strstr(r, "\\\"rows\\\":[]") != NULL); + free(r); + PASS(); +} + TEST(tool_list_projects_basic) { double ms; char *r = call_tool_timed("list_projects", &ms, "{}"); @@ -2970,6 +3000,7 @@ SUITE(incremental) { /* Phase 3: Incremental deltas */ RUN_TEST(incr_modify_file); + RUN_TEST(incr_file_node_name_stays_basename); RUN_TEST(incr_formatter_run); RUN_TEST(incr_add_file); RUN_TEST(incr_delete_file); From 041eb99c11692b93d43ea72c884cc50c51285db5 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 13:03:43 -0300 Subject: [PATCH 4/4] fix: compose JAX-RS method-level @Path with class-level prefix (#1005) JAX-RS splits a route across two annotations: the verb comes from a bare @GET/@POST/... and the path from a sibling @Path. The annotation scan returned on the first mapping annotation, so the @GET matched, defaulted the path to "/" and the sibling @Path was never read. Class-level @Path was never recognized as a prefix either, because a lone @Path carries no verb and the scan reported no route. scan_route_annotations() now collects the whole annotation set (mapping verb + optional inline path, and a separate JAX-RS @Path), then: - method-level: verb required; path = inline mapping path, else @Path, else "/" - class-level prefix: inline mapping path, else @Path Also recognizes bare @HEAD/@OPTIONS verbs. Spring behavior is unchanged (mapping annotations keep their inline path); covered by the existing test suite plus a new regression test. Signed-off-by: Charles Queiroz --- internal/cbm/extract_defs.c | 113 ++++++++++++++++++++++++++-------- tests/test_edge_types_probe.c | 28 ++++++++- tests/test_extraction.c | 33 ++++++++++ 3 files changed, 146 insertions(+), 28 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2de63ffbb..b4c79c890 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1296,7 +1296,8 @@ static const char *annotation_route_method(const char *name) { } /* JAX-RS bare-verb annotations (@GET/@POST/...) — path comes from @Path. */ if (strcmp(name, "GET") == 0 || strcmp(name, "POST") == 0 || strcmp(name, "PUT") == 0 || - strcmp(name, "DELETE") == 0 || strcmp(name, "PATCH") == 0) { + strcmp(name, "DELETE") == 0 || strcmp(name, "PATCH") == 0 || strcmp(name, "HEAD") == 0 || + strcmp(name, "OPTIONS") == 0) { return name; } return NULL; @@ -1605,35 +1606,85 @@ static bool try_route_from_annotation(CBMArena *a, TSNode annotation, const char return true; } -/* Scan the annotation nodes nested in a JVM/C# `modifiers`/`attribute_list` - * wrapper (and direct children) for a route-mapping annotation. Java/Kotlin - * Spring annotations (@GetMapping, @RequestMapping, ...) live here rather than - * as prev-siblings, so the prev-sibling decorator walk never sees them. */ -static bool extract_route_from_annotations(CBMArena *a, TSNode func_node, const char *source, - const CBMLangSpec *spec, const char **out_path, - const char **out_method) { - TSNode modifiers = find_jvm_modifiers(func_node, spec->language); +/* Scan ALL annotation nodes nested in a JVM/C# `modifiers`/`attribute_list` + * wrapper (and direct children), collecting route information from the whole + * set instead of stopping at the first mapping annotation. Java/Kotlin Spring + * annotations (@GetMapping, @RequestMapping, ...) live here rather than as + * prev-siblings, so the prev-sibling decorator walk never sees them. + * + * JAX-RS splits the route across two annotations: the verb comes from a bare + * @GET/@POST/... and the path from a sibling @Path("..."). Returning on the + * first mapping annotation therefore dropped every method-level @Path + * (the @GET matched first, defaulted the path to "/", and @Path was never + * read), and class-level @Path prefixes were never recognized at all. */ +static void scan_route_annotations(CBMArena *a, TSNode owner, const char *source, + const CBMLangSpec *spec, const char **out_map_path, + const char **out_method, const char **out_jax_path) { + *out_map_path = NULL; + *out_method = NULL; + *out_jax_path = NULL; + + TSNode wrappers[2]; + int wn = 0; + TSNode modifiers = find_jvm_modifiers(owner, spec->language); if (!ts_node_is_null(modifiers)) { - uint32_t mc = ts_node_child_count(modifiers); - for (uint32_t mi = 0; mi < mc; mi++) { - TSNode mchild = ts_node_child(modifiers, mi); - if (cbm_kind_in_set(mchild, spec->decorator_node_types) && - try_route_from_annotation(a, mchild, source, out_path, out_method)) { - return true; - } - } + wrappers[wn++] = modifiers; } /* Direct-child annotations (some grammars attach the annotation as a child * of the method node rather than under `modifiers`). */ - uint32_t cc = ts_node_child_count(func_node); - for (uint32_t ci = 0; ci < cc; ci++) { - TSNode child = ts_node_child(func_node, ci); - if (cbm_kind_in_set(child, spec->decorator_node_types) && - try_route_from_annotation(a, child, source, out_path, out_method)) { - return true; + wrappers[wn++] = owner; + + for (int w = 0; w < wn; w++) { + uint32_t cc = ts_node_child_count(wrappers[w]); + for (uint32_t ci = 0; ci < cc; ci++) { + TSNode child = ts_node_child(wrappers[w], ci); + if (!cbm_kind_in_set(child, spec->decorator_node_types)) { + continue; + } + TSNode name_node = annotation_name_node(child); + if (ts_node_is_null(name_node)) { + continue; + } + char *name = cbm_node_text(a, name_node, source); + if (!name) { + continue; + } + if (!*out_jax_path && strcmp(name, "Path") == 0) { + TSNode args = annotation_args_node(child); + if (!ts_node_is_null(args)) { + *out_jax_path = extract_route_path_from_args(a, args, source); + } + continue; + } + if (!*out_method) { + const char *method = annotation_route_method(name); + if (method) { + *out_method = method; + TSNode args = annotation_args_node(child); + if (!ts_node_is_null(args)) { + *out_map_path = extract_route_path_from_args(a, args, source); + } + } + } } } - return false; +} + +static bool extract_route_from_annotations(CBMArena *a, TSNode func_node, const char *source, + const CBMLangSpec *spec, const char **out_path, + const char **out_method) { + const char *map_path = NULL; + const char *method = NULL; + const char *jax_path = NULL; + scan_route_annotations(a, func_node, source, spec, &map_path, &method, &jax_path); + /* Method-level routes still require a verb/mapping annotation; a lone + * @Path (JAX-RS sub-resource locator) is not an endpoint by itself. */ + if (!method) { + return false; + } + *out_method = method; + *out_path = map_path ? map_path : (jax_path ? jax_path : "/"); + return true; } static void extract_route_from_decorators(CBMArena *a, TSNode func_node, const char *source, @@ -1702,10 +1753,18 @@ static const char *join_route_paths(CBMArena *a, const char *prefix, const char static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, const char *source, const CBMLangSpec *spec) { - const char *prefix = NULL; + const char *map_path = NULL; const char *method = NULL; - if (extract_route_from_annotations(a, class_node, source, spec, &prefix, &method)) { - return prefix; + const char *jax_path = NULL; + scan_route_annotations(a, class_node, source, spec, &map_path, &method, &jax_path); + if (map_path) { + return map_path; /* @RequestMapping("/api") and friends */ + } + if (jax_path) { + return jax_path; /* JAX-RS class-level @Path("/api") carries no verb */ + } + if (method) { + return "/"; /* mapping annotation without a path argument */ } return NULL; } diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index ca4f24419..f38b4e9cd 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -381,6 +381,31 @@ TEST(handles_spring_kotlin) { PASS(); } +/* JAX-RS (Java): the verb (@GET) and the path (@Path) are sibling + * annotations, and the class-level @Path must prefix method-level paths. + * Reproduce-first (#1005): the first-mapping-annotation scan dropped every + * method-level @Path, collapsing all methods onto a single "/" Route node; + * only the exact Route-name set catches that through the emission dedup. */ +TEST(handles_jaxrs_java) { + static const char *routes[] = {"/api/v1/widgets", "/api/v1/widgets/count", NULL}; + static const EtFile f[] = { + {"WidgetResource.java", + "package com.example;\n\n" + "import jakarta.ws.rs.GET;\n" + "import jakarta.ws.rs.Path;\n\n" + "@Path(\"/api/v1/widgets\")\npublic class WidgetResource {\n" + " @GET\n" + " public String list() {\n" + " return \"widgets\";\n }\n\n" + " @GET\n" + " @Path(\"/count\")\n" + " public String count() {\n" + " return \"42\";\n }\n}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 2)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + /* ASP.NET Minimal API (C#) — route registration via static MapGet/MapPost calls * with identifier handlers, under a Microsoft/AspNetCore path so the resolved * callee QN carries the "MapGet"/"Microsoft.AspNetCore" route-reg substrings. @@ -1442,7 +1467,7 @@ TEST(override_go_interface) { * ══════════════════════════════════════════════════════════════════ */ SUITE(edge_types_probe) { - /* HANDLES — route→handler across web frameworks (8 frameworks) */ + /* HANDLES — route→handler across web frameworks */ RUN_TEST(handles_flask_python); RUN_TEST(handles_fastapi_python); RUN_TEST(handles_drf_action_python); @@ -1451,6 +1476,7 @@ SUITE(edge_types_probe) { RUN_TEST(handles_gin_go); RUN_TEST(handles_spring_java); RUN_TEST(handles_spring_kotlin); + RUN_TEST(handles_jaxrs_java); RUN_TEST(handles_aspnet_csharp); RUN_TEST(handles_laravel_php); RUN_TEST(handles_rails_ruby); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index f8228e46a..5904bbb31 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2728,6 +2728,38 @@ TEST(extract_java_method_annotations_issue382) { PASS(); } +/* Issue #1005: JAX-RS splits a route across two annotations (@GET carries the + * verb, a sibling @Path carries the path). Returning on the first mapping + * annotation dropped every method-level @Path, and the class-level @Path + * prefix was never recognized at all. */ +TEST(extract_java_jaxrs_path_composition_issue1005) { + CBMFileResult *r = extract("import jakarta.ws.rs.GET;\n" + "import jakarta.ws.rs.Path;\n" + "@Path(\"/api/v1/widgets\")\n" + "public class WidgetResource {\n" + " @GET\n" + " public String list() { return \"\"; }\n" + " @GET\n" + " @Path(\"/count\")\n" + " public String count() { return \"\"; }\n" + "}\n", + CBM_LANG_JAVA, "t", "WidgetResource.java"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *list = find_def_by_name(r, "list"); + ASSERT_NOT_NULL(list); + ASSERT_NOT_NULL(list->route_path); + ASSERT_STR_EQ(list->route_path, "/api/v1/widgets"); + ASSERT_STR_EQ(list->route_method, "GET"); + const CBMDefinition *count = find_def_by_name(r, "count"); + ASSERT_NOT_NULL(count); + ASSERT_NOT_NULL(count->route_path); + ASSERT_STR_EQ(count->route_path, "/api/v1/widgets/count"); + ASSERT_STR_EQ(count->route_method, "GET"); + cbm_free_result(r); + PASS(); +} + /* Find an in-body call by its raw callee text; returns the call or NULL. */ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee) { for (int i = 0; i < r->calls.count; i++) { @@ -3720,6 +3752,7 @@ SUITE(extraction) { RUN_TEST(js_index_module_qn_not_collide_with_folder); RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); + RUN_TEST(extract_java_jaxrs_path_composition_issue1005); RUN_TEST(extract_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); RUN_TEST(extract_large_ts_has_functions_issue213);