diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 05db1bac1..deee8a8e7 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1317,7 +1317,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; @@ -1626,35 +1627,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, @@ -1723,10 +1774,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; } @@ -5901,8 +5960,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/mcp/mcp.c b/src/mcp/mcp.c index af95e139e..b1e653a01 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1793,10 +1793,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/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/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index e027bc30c..8b5766978 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -885,8 +885,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/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); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 5cae85015..11946613e 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. @@ -1488,7 +1513,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); @@ -1497,6 +1522,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_laravel_facade_routes_issue952); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 04431382e..fb2b6c795 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2738,6 +2738,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++) { @@ -4625,6 +4657,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); 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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7ca8ccc64..98ad0bafb 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4780,6 +4780,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) * @@ -6439,6 +6514,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);