diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 9db45d62..8e0d9026 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1323,35 +1323,58 @@ static TSNode find_decorator_args(TSNode call_node) { return args; } +static bool is_route_string_kind(const char *kind) { + return strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 || + strcmp(kind, "interpreted_string_literal") == 0; +} + +static const char *route_path_from_string_node(CBMArena *a, TSNode node, const char *source) { + if (!is_route_string_kind(ts_node_type(node))) { + return NULL; + } + char *path = cbm_node_text(a, node, source); + if (!path) { + return NULL; + } + int plen = (int)strlen(path); + if (plen >= PAIR_CHARS && (path[0] == '"' || path[0] == '\'')) { + path = cbm_arena_strndup(a, path + SKIP_CHAR, (size_t)(plen - PAIR_CHARS)); + } + return (path && path[0] == '/') ? path : NULL; +} + +static const char *find_route_path_literal(CBMArena *a, TSNode node, const char *source, + int max_depth) { + if (ts_node_is_null(node) || max_depth < 0) { + return NULL; + } + const char *path = route_path_from_string_node(a, node, source); + if (path || max_depth == 0) { + return path; + } + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc && i < DECORATOR_SCAN_LIMIT; i++) { + path = find_route_path_literal(a, ts_node_named_child(node, i), source, max_depth - 1); + if (path) { + return path; + } + } + return NULL; +} + // Extract route path from decorator arguments (first string that starts with /). static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const char *source) { uint32_t nc = ts_node_named_child_count(args); for (uint32_t ai = 0; ai < nc && ai < DECORATOR_SCAN_LIMIT; ai++) { TSNode arg = ts_node_named_child(args, ai); - const char *ak = ts_node_type(arg); - /* Kotlin wraps each annotation argument in a `value_argument` node - * (and supports the named form `value = "/x"`); unwrap to the string. */ - if (strcmp(ak, "value_argument") == 0) { - TSNode s = cbm_find_child_by_kind(arg, "string_literal"); - if (ts_node_is_null(s)) { - continue; - } - arg = s; - ak = ts_node_type(arg); - } - if (strcmp(ak, "string") != 0 && strcmp(ak, "string_literal") != 0 && - strcmp(ak, "interpreted_string_literal") != 0) { - continue; - } - char *path = cbm_node_text(a, arg, source); + /* Spring/Kotlin frequently uses named or array-valued annotation args: + * @RequestMapping(value = ["/internal/v1"]) + * @GetMapping(path = {"/orders"}) + * Walk a bounded subtree and keep the first string literal that is + * path-shaped, while ignoring non-route literals such as media types. */ + const char *path = find_route_path_literal(a, arg, source, CBM_DESCENDANT_MAX_DEPTH); if (path) { - int plen = (int)strlen(path); - if (plen >= PAIR_CHARS && (path[0] == '"' || path[0] == '\'')) { - path = cbm_arena_strndup(a, path + SKIP_CHAR, (size_t)(plen - PAIR_CHARS)); - } - if (path && path[0] == '/') { - return path; - } + return path; } } return NULL; @@ -1655,6 +1678,38 @@ static void extract_route_from_decorators(CBMArena *a, TSNode func_node, const c extract_route_from_annotations(a, func_node, source, spec, out_path, out_method); } +static const char *join_route_paths(CBMArena *a, const char *prefix, const char *path) { + if (!path || !path[0]) { + return prefix; + } + if (!prefix || !prefix[0] || strcmp(prefix, "/") == 0) { + return path; + } + if (strcmp(path, "/") == 0) { + return prefix; + } + size_t plen = strlen(prefix); + bool prefix_slash = prefix[plen - 1] == '/'; + bool path_slash = path[0] == '/'; + if (prefix_slash && path_slash) { + return cbm_arena_sprintf(a, "%s%s", prefix, path + SKIP_CHAR); + } + if (!prefix_slash && !path_slash) { + return cbm_arena_sprintf(a, "%s/%s", prefix, path); + } + return cbm_arena_sprintf(a, "%s%s", prefix, path); +} + +static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, const char *source, + const CBMLangSpec *spec) { + const char *prefix = NULL; + const char *method = NULL; + if (extract_route_from_annotations(a, class_node, source, spec, &prefix, &method)) { + return prefix; + } + return NULL; +} + // Extract decorator names from preceding decorator/annotation nodes // Count annotations inside a Java/Kotlin/C# "modifiers" node. static int count_modifier_annotations(TSNode modifiers, const CBMLangSpec *spec) { @@ -4000,8 +4055,8 @@ static TSNode resolve_method_name(TSNode child, CBMLanguage lang) { } // Push a single method definition -static void push_method_def(CBMExtractCtx *ctx, TSNode child, const char *class_qn, - const CBMLangSpec *spec, TSNode name_node) { +static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, + const char *class_qn, const CBMLangSpec *spec, TSNode name_node) { CBMArena *a = ctx->arena; char *name = cbm_func_name_node_text(a, name_node, ctx->source); @@ -4049,6 +4104,10 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, const char *class_ def.decorators = extract_decorators(a, child, ctx->source, ctx->language, spec); extract_route_from_decorators(a, child, ctx->source, spec, &def.route_path, &def.route_method); + if (def.route_path && (ctx->language == CBM_LANG_JAVA || ctx->language == CBM_LANG_KOTLIN)) { + const char *prefix = spring_class_route_prefix(a, class_node, ctx->source, spec); + def.route_path = join_route_paths(a, prefix, def.route_path); + } def.docstring = extract_docstring(a, child, ctx->source, ctx->language); if (spec->branching_node_types && spec->branching_node_types[0]) { @@ -4073,7 +4132,7 @@ static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, cons if (cbm_kind_in_set(inner, spec->function_node_types)) { TSNode nm = resolve_method_name(inner, ctx->language); if (!ts_node_is_null(nm)) { - push_method_def(ctx, inner, class_qn, spec, nm); + push_method_def(ctx, inner, impl_node, class_qn, spec, nm); } } } @@ -4136,7 +4195,7 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c if (ts_node_is_null(fname)) { continue; } - push_method_def(ctx, value, class_qn, spec, fname); + push_method_def(ctx, value, class_node, class_qn, spec, fname); continue; } @@ -4149,7 +4208,7 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c continue; } - push_method_def(ctx, method_node, class_qn, spec, name_node); + push_method_def(ctx, method_node, class_node, class_qn, spec, name_node); } } diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 1bc0cf55..ca4f2441 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -142,6 +142,66 @@ static int et_edge_present(const EtFile *files, int nfiles, const char *edge, in return got >= floor; } +enum { ET_ROUTE_ASSERT_MAX = 16 }; + +/* Assert the exact Route node set. Edge-count smoke tests cannot catch partial + * Spring paths such as "/orders" when the real route is "/api/orders", and a + * presence-only assertion would still allow stale partial Route nodes to leak. */ +static int et_routes_exact(const EtFile *files, int nfiles, const char **routes) { + EtProj lp; + cbm_store_t *store = et_index_files(&lp, files, nfiles); + cbm_node_t *nodes = NULL; + int node_count = 0; + int wanted = 0; + int found[ET_ROUTE_ASSERT_MAX] = {0}; + int ok = store != NULL; + + while (routes[wanted] && wanted < ET_ROUTE_ASSERT_MAX) { + wanted++; + } + if (routes[wanted]) { + ok = 0; + } + + if (!store || cbm_store_find_nodes_by_label(store, lp.project, "Route", &nodes, &node_count) != + CBM_STORE_OK) { + ok = 0; + } else { + if (node_count != wanted) { + ok = 0; + } + for (int wi = 0; wi < wanted; wi++) { + for (int ni = 0; ni < node_count; ni++) { + if (nodes[ni].name && strcmp(nodes[ni].name, routes[wi]) == 0) { + found[wi] = 1; + break; + } + } + if (!found[wi]) { + ok = 0; + } + } + } + + if (!ok) { + fprintf(stderr, " [ET-ROUTE] FAIL expected=%d actual=%d missing:", wanted, node_count); + for (int wi = 0; wi < wanted; wi++) { + if (!found[wi]) { + fprintf(stderr, " %s", routes[wi]); + } + } + fprintf(stderr, " available:"); + for (int ni = 0; ni < node_count && ni < ET_ROUTE_ASSERT_MAX; ni++) { + fprintf(stderr, " %s", nodes[ni].name ? nodes[ni].name : ""); + } + fprintf(stderr, "\n"); + } + + cbm_store_free_nodes(nodes, node_count); + et_cleanup(&lp, store); + return ok; +} + /* Index meaningful[] plus PARALLEL_PAD_FILES trivial pad files to force the * parallel pipeline path (MIN_FILES_FOR_PARALLEL = 50). */ enum { ET_PARALLEL_PAD = 52, ET_PAD_MAX = 68 /* 52 pad + 16 meaningful */ }; @@ -277,13 +337,12 @@ TEST(handles_gin_go) { PASS(); } -/* Spring (Java) — @RequestMapping decorator sets route_path in extraction. - * REAL BUG: internal/cbm/extract_defs.c:extract_route_from_decorators only walks - * ts_node_prev_sibling(func) for decorator nodes of type "call". Java annotations - * (@GetMapping/@RequestMapping) live INSIDE the method's `modifiers` child (not a - * prev_sibling) and are `annotation`/`marker_annotation` nodes (not `call`), so - * route_path is never set → no Route/HANDLES for Spring controllers. */ +/* Spring (Java) — class-level @RequestMapping must prefix method mappings. + * Reproduce-first: a HANDLES count alone can pass with partial routes + * ("/orders"), but callers/search_graph need the actual endpoint names + * ("/api/orders"). */ TEST(handles_spring_java) { + static const char *routes[] = {"/api/orders", "/api/orders/{id}", NULL}; static const EtFile f[] = { {"OrderController.java", "package com.example;\n\n" @@ -296,31 +355,29 @@ TEST(handles_spring_java) { " @GetMapping(\"/orders/{id}\")\n" " public String getOrder(int id) {\n" " return \"order:\" + id;\n }\n}\n"}}; - ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 1)); + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 2)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); PASS(); } -/* Spring (Kotlin) — @RequestMapping/@GetMapping on a Kotlin @RestController. - * REAL BUG: tree-sitter-kotlin annotation nodes have no `name` field (the name - * lives in a nested user_type/type_identifier) and carry args under a - * constructor_invocation `value_arguments` node, so the Java-shaped - * ts_node_child_by_field_name(annotation, "name") / "arguments" lookups in - * try_route_from_annotation missed every Kotlin Spring route → route_path never - * set → no Route/HANDLES. Fixed by annotation_name_node/annotation_args_node. */ +/* Spring (Kotlin) — same prefix contract, including Kotlin's named array form + * for class-level RequestMapping values. */ TEST(handles_spring_kotlin) { + static const char *routes[] = {"/internal/v1/api/orders", "/internal/v1/api/orders/{id}", NULL}; static const EtFile f[] = { {"OrderController.kt", "package com.example\n\n" "import org.springframework.web.bind.annotation.RequestMapping\n" "import org.springframework.web.bind.annotation.GetMapping\n\n" - "@RequestMapping(\"/api\")\nclass OrderController {\n" + "@RequestMapping(value = [\"/internal/v1/api\"])\nclass OrderController {\n" " @GetMapping(\"/orders\")\n" " fun listOrders(): String {\n" " return \"orders\"\n }\n\n" " @GetMapping(\"/orders/{id}\")\n" " fun getOrder(id: Int): String {\n" " return \"order:\" + id\n }\n}\n"}}; - ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 1)); + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 2)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); PASS(); }