diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 7cc4177e..34808cdc 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1886,6 +1886,18 @@ static int collect_modifier_decorators(CBMArena *a, TSNode modifiers, const char return idx; } +/* Comments are NAMED nodes in tree-sitter, so a comment interleaved in a + * decorator run would end the walk and silently drop every decorator above it: + * + * @Post('login') <-- lost + * @HttpCode(HttpStatus.OK) <-- lost + * // why this route is throttled <-- walk stopped here + * @Throttle({ ... }) <-- kept + * async login(...) + * + * Documenting a decorator must not make it disappear from the graph, so treat + * comments as transparent — like the anonymous tokens already skipped below. + * (is_comment_node() is defined above, near the docstring helpers.) */ static const char **extract_decorators(CBMArena *a, TSNode node, const char *source, CBMLanguage lang, const CBMLangSpec *spec) { if (!spec->decorator_node_types || !spec->decorator_node_types[0]) { @@ -1897,10 +1909,11 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou while (!ts_node_is_null(prev)) { if (cbm_kind_in_set(prev, spec->decorator_node_types)) { count++; - } else if (ts_node_is_named(prev)) { + } else if (ts_node_is_named(prev) && !is_comment_node(ts_node_type(prev))) { /* A real preceding construct ends the decorator run. Anonymous * tokens (e.g. TS `export` between `@Decorator` and the - * `class_declaration`) are skipped so the decorator is still seen. */ + * `class_declaration`) and comments are skipped so the decorator + * is still seen. */ break; } prev = ts_node_prev_sibling(prev); @@ -1937,7 +1950,7 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou while (!ts_node_is_null(prev) && idx < count) { if (cbm_kind_in_set(prev, spec->decorator_node_types)) { result[idx++] = cbm_node_text(a, prev, source); - } else if (ts_node_is_named(prev)) { + } else if (ts_node_is_named(prev) && !is_comment_node(ts_node_type(prev))) { break; } prev = ts_node_prev_sibling(prev); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index a742aa7b..850691c0 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2790,6 +2790,30 @@ TEST(extract_java_jaxrs_path_composition_issue1005) { PASS(); } +/* A comment between decorators must not drop the decorators above it. + * Comments are NAMED tree-sitter nodes, so the prev-sibling walk used to stop + * at one — a documented route (@Post + @HttpCode above an explanatory comment) + * silently lost those decorators and disappeared from route/authz queries. */ +TEST(extract_ts_decorators_survive_interleaved_comment) { + CBMFileResult *r = extract("class AuthController {\n" + " @Post('login')\n" + " @HttpCode(HttpStatus.OK)\n" + " // throttled per IP and per account\n" + " @Throttle({ default: { ttl: 900_000, limit: 5 } })\n" + " async login(dto: LoginDto) { return 1; }\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "auth.controller.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *m = find_def_by_name(r, "login"); + ASSERT_NOT_NULL(m); + ASSERT(decorators_contain(m, "Throttle")); /* below the comment — always worked */ + ASSERT(decorators_contain(m, "HttpCode")); /* above the comment — was dropped */ + ASSERT(decorators_contain(m, "Post")); /* above the comment — was dropped */ + 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++) { @@ -4912,6 +4936,7 @@ SUITE(extraction) { RUN_TEST(walk_defs_no_truncation_over_4096_issue668); RUN_TEST(extract_rust_test_attr_marks_is_test_issue855); RUN_TEST(docstring_utf8_truncation_boundary_issue1017); + RUN_TEST(extract_ts_decorators_survive_interleaved_comment); cbm_shutdown(); }