diff --git a/Makefile.cbm b/Makefile.cbm index bf51a5a8c..f1bc4991a 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -677,6 +677,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_issue787.c \ tests/repro/repro_issue842.c \ tests/repro/repro_issue964.c \ + tests/repro/repro_issue1692.c \ tests/repro/repro_invariant_calls.c \ tests/repro/repro_invariant_graph.c \ tests/repro/repro_invariant_breadth.c \ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index f9854b03e..611cb6906 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -23,6 +23,8 @@ #define MAX_PARAMS_MINUS_1 31 #define MAX_RETURN_TYPES 16 #define MAX_RETURN_TYPES_MINUS_1 15 +#define MAX_ATTR_WRAPPERS 16 // stacked C#/PHP attribute_list siblings per declaration (#1692) +#define MAX_ATTR_WRAPPERS_MINUS_1 15 // Tree traversal limits. enum { @@ -1287,7 +1289,7 @@ static const char *extract_docstring(CBMArena *a, TSNode node, const char *sourc return NULL; } -static TSNode find_jvm_modifiers(TSNode node, CBMLanguage lang); +static int find_jvm_modifiers(TSNode node, CBMLanguage lang, TSNode *out, int max); /* HTTP method names recognized in decorator calls (e.g., @router.post → "POST") */ static const char *decorator_method_name(const char *attr_text) { @@ -1673,12 +1675,9 @@ static void scan_route_annotations(CBMArena *a, TSNode owner, const char *source *out_method = NULL; *out_jax_path = NULL; - TSNode wrappers[2]; - int wn = 0; - TSNode modifiers = find_jvm_modifiers(owner, spec->language); - if (!ts_node_is_null(modifiers)) { - wrappers[wn++] = modifiers; - } + /* MINUS_1: the owner node itself is appended below, after the wrappers. */ + TSNode wrappers[MAX_ATTR_WRAPPERS]; + int wn = find_jvm_modifiers(owner, spec->language, wrappers, MAX_ATTR_WRAPPERS_MINUS_1); /* Direct-child annotations (some grammars attach the annotation as a child * of the method node rather than under `modifiers`). */ wrappers[wn++] = owner; @@ -1832,13 +1831,23 @@ static int count_modifier_annotations(TSNode modifiers, const CBMLangSpec *spec) return count; } -// Find the wrapper child that holds annotations/attributes for languages where -// they are nested under an intermediate node rather than being a prev-sibling: -// Java/Kotlin/C#/Swift → `modifiers` (contains annotation/attribute) -// PHP 8 → `attribute_list` (contains attribute_group) -// Returns a null node when the language has no such wrapper. -static TSNode find_jvm_modifiers(TSNode node, CBMLanguage lang) { - TSNode null_node = {0}; +// Find every wrapper child that holds annotations/attributes for languages +// where they are nested under an intermediate node rather than being a +// prev-sibling: +// Java/Kotlin/Swift → `modifiers` (one node, contains every annotation) +// C#/PHP 8 → `attribute_list` (contains attribute/attribute_group) +// +// C#/PHP attribute stacks are NOT a single wrapper: each bracketed group +// (`[Foo]`, `[Bar]`, ...) compiles to its own `attribute_list` node, so +// `[A] [B] [C]` above a declaration produces three separate `attribute_list` +// siblings among that declaration's children — not one `attribute_list` +// holding three entries. A field-name lookup (`ts_node_child_by_field_name`) +// only ever returns the first child registered under a given field, so using +// it here silently dropped every attribute after the first bracket group +// (#1692). Scanning all children by kind fixes that for C#/PHP and is a +// no-op change for Java/Kotlin/Swift, where `modifiers` never repeats. +// Writes up to `max` wrapper nodes into `out`; returns how many were found. +static int find_jvm_modifiers(TSNode node, CBMLanguage lang, TSNode *out, int max) { const char *wrapper = NULL; switch (lang) { case CBM_LANG_JAVA: @@ -1848,19 +1857,12 @@ static TSNode find_jvm_modifiers(TSNode node, CBMLanguage lang) { break; case CBM_LANG_CSHARP: case CBM_LANG_PHP: - /* C# attributes live in an `attribute_list` child (modifiers like - * `public` are separate `modifier` nodes); PHP 8 likewise nests - * `attribute_group` under `attribute_list`. */ wrapper = "attribute_list"; break; default: - return null_node; - } - TSNode w = ts_node_child_by_field_name(node, wrapper, (uint32_t)strlen(wrapper)); - if (ts_node_is_null(w)) { - w = cbm_find_child_by_kind(node, wrapper); + return 0; } - return w; + return cbm_find_children_by_kind(node, wrapper, out, max); } // Count direct children of `node` that are decorator/annotation nodes (used by @@ -1938,13 +1940,14 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou prev = ts_node_prev_sibling(prev); } - TSNode modifiers = {0}; + TSNode wrappers[MAX_ATTR_WRAPPERS]; + int wn = 0; int mod_count = 0; int child_count = 0; if (count == 0) { - modifiers = find_jvm_modifiers(node, lang); - if (!ts_node_is_null(modifiers)) { - mod_count = count_modifier_annotations(modifiers, spec); + wn = find_jvm_modifiers(node, lang, wrappers, MAX_ATTR_WRAPPERS); + for (int w = 0; w < wn; w++) { + mod_count += count_modifier_annotations(wrappers[w], spec); } /* Languages like Scala attach the annotation directly as a child of the * definition node (no wrapper, no prev-sibling). */ @@ -1974,8 +1977,8 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou } prev = ts_node_prev_sibling(prev); } - if (!ts_node_is_null(modifiers)) { - idx = collect_modifier_decorators(a, modifiers, source, spec, result, idx, total); + for (int w = 0; w < wn && mod_count > 0; w++) { + idx = collect_modifier_decorators(a, wrappers[w], source, spec, result, idx, total); } if (child_count > 0) { idx = collect_child_decorators(a, node, source, spec, result, idx, total); diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index f139ff61e..b9b9ab8cf 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -364,6 +364,18 @@ TSNode cbm_find_child_by_kind(TSNode parent, const char *kind) { return null_node; } +int cbm_find_children_by_kind(TSNode parent, const char *kind, TSNode *out, int max) { + int n = 0; + uint32_t count = ts_node_child_count(parent); + for (uint32_t i = 0; i < count && n < max; i++) { + TSNode child = ts_node_child(parent, i); + if (strcmp(ts_node_type(child), kind) == 0) { + out[n++] = child; + } + } + return n; +} + /* ── Node-type classification: TSSymbol bitset acceleration ─────────────── * cbm_kind_in_set is called for nearly every AST node (function/class/call/ * import/branching sets), so a linear strcmp over the type-name array is a hot diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index b03ef3743..2e7ffcf4e 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -122,6 +122,13 @@ char *cbm_cpp_out_of_line_parent_class(CBMArena *a, TSNode node, const char *sou // Find a child node by kind string. TSNode cbm_find_child_by_kind(TSNode parent, const char *kind); +// Find every child node matching `kind` (unlike cbm_find_child_by_kind, +// which stops at the first match). Writes up to `max` nodes into `out`; +// returns how many were found. Repeated sibling nodes of the same kind are +// common in some grammars (e.g. C#/PHP stack each `[Attr]` as its own +// attribute_list child) — see #1692. +int cbm_find_children_by_kind(TSNode parent, const char *kind, TSNode *out, int max); + // Check if node kind matches a set of types (NULL-terminated array of strings). bool cbm_kind_in_set(TSNode node, const char **types); diff --git a/tests/repro/repro_issue1692.c b/tests/repro/repro_issue1692.c new file mode 100644 index 000000000..f133c2a37 --- /dev/null +++ b/tests/repro/repro_issue1692.c @@ -0,0 +1,91 @@ +/* + * repro_issue1692.c — Reproduce-first case for OPEN bug #1692. + * + * Issue: #1692 — "C#: ... ASP.NET Core [Route]/[HttpGet] attribute routes + * not extracted (cross-repo-intelligence returns 0 edges)". The reporter's + * repro 2 shows a class carrying three stacked attributes + * ([ServiceFilter]/[Route]/[ApiController]) where DECORATES exists 13,964 + * times elsewhere in the same graph but zero times for that declaration. + * + * Root cause (traced further than the issue body): C# does not attach one + * `attribute_list` wrapper holding every bracket group above a declaration — + * each `[A]` / `[B]` / `[C]` line compiles to its OWN `attribute_list` node, + * so a stack of three produces three separate `attribute_list` siblings + * among the declaration's children. `find_jvm_modifiers()` + * (internal/cbm/extract_defs.c) located that wrapper with + * `ts_node_child_by_field_name(node, "attribute_list", ...)`, which only + * ever returns the FIRST child registered under a repeated field — so only + * the first bracket group's attribute(s) ever produced a DECORATES edge; + * every attribute after the first was silently dropped, with no warning and + * no staleness signal (confirmed indexed, not stale, via grafo_coverage in + * the downstream McpDevGrafo-bugs.md BUG-004 report that led here). + * + * Expected (correct) behaviour: + * A method carrying N stacked attributes gets exactly N outbound + * DECORATES edges — not just one for the first bracket group. + * + * Why RED on current code: only the first attribute's DECORATES edge + * exists, so the project-wide DECORATES count is 1, not 3. + * + * Scope — why this file is C#-only: + * find_jvm_modifiers() shares ONE switch across Java/Kotlin/Swift/C#/PHP, + * but only C# can reach this bug. Verified against the vendored grammars: + * `[A] [B] [C]` in C# parses as THREE sibling `attribute_list` nodes, while + * PHP's `#[A] #[B] #[C]` parses as ONE `attribute_list` holding three + * `attribute_group` children — so the first-child lookup already found them + * all and PHP never dropped an attribute. Java/Kotlin/Swift use the + * `modifiers` wrapper, a single node that never repeats. PHP's DECORATES + * path stays covered by mkc_c3_php8_attribute (test_matrix_known_classes.c); + * a stacked-PHP fixture here would pass with or without the fix and prove + * nothing. No other language reaches this function (`default` returns 0). + */ + +#include +#include "test_framework.h" +#include "repro_harness.h" + +#include +#include +#include + +/* ── Fixture: C# ─────────────────────────────────────────────────────────── + * + * One C# method, three stacked attributes, nothing else in the file that + * could carry a DECORATES edge — so a project-wide edge count is as precise + * as scoping to the method node, without the extra node lookup. + * ──────────────────────────────────────────────────────────────────────── */ +static const char *kStackedAttrsCs = "using System;\n" + "\n" + "namespace ReproNS\n" + "{\n" + " public class AttrController\n" + " {\n" + " [Foo]\n" + " [Bar(\"x\")]\n" + " [Baz]\n" + " public void StackedAttrsMethod() { }\n" + " }\n" + "}\n"; + +TEST(repro_issue1692_csharp_stacked_attributes_all_decorate) { + RProj lp; + cbm_store_t *store = rh_index(&lp, "StackedAttrs.cs", kStackedAttrsCs); + ASSERT_NOT_NULL(store); + + int decorates_count = rh_count_edges(store, lp.project, "DECORATES"); + if (decorates_count != 3) { + fprintf(stderr, + " [1692] FAIL decorates=%d (expected 3 — one per stacked [Foo][Bar][Baz]; " + "only the first attribute_list's attribute(s) survive find_jvm_modifiers' " + "single-field lookup)\n", + decorates_count); + } + ASSERT_EQ(3, decorates_count); /* one DECORATES per stacked attribute, not just the first */ + + rh_cleanup(&lp, store); + PASS(); +} + +SUITE(repro_issue1692) { + RUN_TEST(repro_issue1692_csharp_stacked_attributes_all_decorate); +} diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index 3c99a7d7b..4d7ef20f7 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -91,6 +91,7 @@ extern void suite_repro_issue581(void); extern void suite_repro_issue787(void); extern void suite_repro_issue842(void); extern void suite_repro_issue964(void); +extern void suite_repro_issue1692(void); /* NEW bugs found by the discovery sweep */ extern void suite_repro_new_ts_class_field_arrow(void); extern void suite_repro_new_py_tuple_unpack(void); @@ -192,6 +193,7 @@ int main(void) { RUN_SUITE(repro_issue787); RUN_SUITE(repro_issue842); RUN_SUITE(repro_issue964); + RUN_SUITE(repro_issue1692); RUN_SUITE(repro_invariant_calls); RUN_SUITE(repro_invariant_graph); RUN_SUITE(repro_invariant_breadth);