diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 9b7acc42a..d80f7dc44 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -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]) { @@ -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); @@ -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); diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 667029d5c..3c8fd2a28 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -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++; diff --git a/tests/test_cypher.c b/tests/test_cypher.c index bc627f5c5..85811f5fc 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -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"); @@ -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); } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 686691974..e2c214ad3 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -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++) { @@ -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(); }