Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions internal/cbm/extract_defs.c
Original file line number Diff line number Diff line change
Expand Up @@ -1797,6 +1797,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]) {
Expand All @@ -1808,10 +1820,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);
Expand Down Expand Up @@ -1848,7 +1861,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);
Expand Down
38 changes: 36 additions & 2 deletions src/cypher/cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -2230,15 +2230,49 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu
p++;
}
if (*p == '"') {
/* String value */
/* String value — honor backslash escapes: without this, an embedded \"
* cuts the value short at the first escaped quote. */
p++;
size_t i = 0;
while (*p && *p != '"' && i < buf_sz - SKIP_ONE) {
if (*p == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* keep the escape pair intact */
}
buf[i++] = *p++;
}
buf[i] = '\0';
} else if (*p == '[' || *p == '{') {
/* Array/object value — copy the whole balanced construct. A scan-to-comma
* truncates at the first comma INSIDE the value: e.g. a decorators array
* ["@Roles('OWNER', 'ADMIN')","@Get()"] came back as ["@Roles('OWNER'. */
char open = *p;
char close = (open == '[') ? ']' : '}';
int depth = 0;
int in_str = 0;
size_t i = 0;
while (*p && i < buf_sz - SKIP_ONE) {
char c = *p;
if (in_str) {
if (c == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) {
buf[i++] = *p++; /* escape pair stays intact */
} else if (c == '"') {
in_str = 0;
}
} else if (c == '"') {
in_str = 1;
} else if (c == open) {
depth++;
} else if (c == close) {
depth--;
}
buf[i++] = *p++;
if (!in_str && depth == 0) {
break; /* outer bracket closed */
}
}
buf[i] = '\0';
} else {
/* Numeric or other value */
/* Numeric or other scalar value */
size_t i = 0;
while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) {
buf[i++] = *p++;
Expand Down
56 changes: 56 additions & 0 deletions tests/test_cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -2794,6 +2794,59 @@ TEST(cypher_multi_prop_projection_no_alias) {
* forked child so a stack overwrite (ASan abort, or a raw segfault) shows up
* as a killing signal instead of taking down the whole runner; the bounded
* path returns an ordinary error and the child exits cleanly. */
/* Property projection must return the WHOLE value of composite properties.
* json_extract_prop() scanned a non-string value up to the first ',' — so an
* array/object property was truncated at its first INTERNAL comma. Real-world
* hit: a NestJS handler's decorators
* ["@Roles('OWNER', 'ADMIN')","@Get()"]
* projected as ["@Roles('OWNER' — unusable for route/authz queries. */
TEST(cypher_exec_prop_array_with_internal_commas) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Method",
.name = "findAll",
.qualified_name = "test.PacienteController.findAll",
.file_path = "paciente.controller.ts",
.properties_json =
"{\"decorators\":[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"],\"lines\":3}"};
cbm_store_upsert_node(s, &n);

cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (m:Method) RETURN m.decorators, m.lines", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
/* whole array, commas and all — was ["@Roles('OWNER' before the fix */
ASSERT_STR_EQ(r.rows[0][0], "[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"]");
ASSERT_STR_EQ(r.rows[0][1], "3"); /* scalar sibling still parses */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}

/* A string property must not end at an ESCAPED quote: the scan stopped at the
* first '"' regardless of a preceding backslash, cutting the value short. */
TEST(cypher_exec_prop_string_with_escaped_quote) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "parse",
.qualified_name = "test.parse",
.file_path = "p.ts",
.properties_json = "{\"signature\":\"(sep: \\\"a,b\\\") => void\"}"};
cbm_store_upsert_node(s, &n);

cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.signature", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "(sep: \\\"a,b\\\") => void"); /* was: (sep: \ */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}

TEST(cypher_wide_return_projection_bounded) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic");
Expand Down Expand Up @@ -2994,4 +3047,7 @@ SUITE(cypher) {
RUN_TEST(cypher_parse_unwind);
RUN_TEST(cypher_parse_unwind_var);
RUN_TEST(cypher_wide_return_projection_bounded);
/* Composite property projection (arrays/objects, escaped quotes) */
RUN_TEST(cypher_exec_prop_array_with_internal_commas);
RUN_TEST(cypher_exec_prop_string_with_escaped_quote);
}
25 changes: 25 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -2728,6 +2728,30 @@ TEST(extract_java_method_annotations_issue382) {
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++) {
Expand Down Expand Up @@ -3868,6 +3892,7 @@ SUITE(extraction) {
RUN_TEST(extract_c_clean_file_no_recovery_duplicates_issue961);
RUN_TEST(walk_defs_no_truncation_over_4096_issue668);
RUN_TEST(extract_rust_test_attr_marks_is_test_issue855);
RUN_TEST(extract_ts_decorators_survive_interleaved_comment);

cbm_shutdown();
}
Loading