fix(extract): keep decorators separated by a comment

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 <jhohantma@gmail.com>
This commit is contained in:
KolisCode
2026-07-15 21:01:29 -05:00
parent 90d7315cf9
commit 012be57815
2 changed files with 41 additions and 3 deletions
+16 -3
View File
@@ -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);
+25
View File
@@ -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();
}