From 012be57815a1c19b861250b42d2dbca42cb094e4 Mon Sep 17 00:00:00 2001 From: KolisCode Date: Mon, 13 Jul 2026 11:51:58 -0500 Subject: [PATCH] fix(extract): keep decorators separated by a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments are NAMED tree-sitter nodes, so the prev-sibling walk in extract_decorators() stopped at one — silently dropping every decorator ABOVE an interleaved comment: @Post('login') <-- dropped @HttpCode(HttpStatus.OK) <-- dropped // throttled per IP and account @Throttle({ ... }) <-- kept async login(...) The route then vanished from decorator/route queries, so documenting a decorator made the endpoint disappear from the graph. Real-world impact: on a NestJS backend, 1 of 95 HTTP endpoints was missing — the one whose throttle policy carried an explanatory comment. Comments are now transparent to the walk, the same way anonymous tokens (e.g. TS `export`) already were. Reuses the existing is_comment_node() helper. Signed-off-by: KolisCode --- internal/cbm/extract_defs.c | 19 ++++++++++++++++--- tests/test_extraction.c | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) 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(); }