From f4c897110cb1f02edee37db7c8849e63894ed8a4 Mon Sep 17 00:00:00 2001 From: MopicMP Date: Wed, 23 Sep 2026 22:44:16 +0200 Subject: [PATCH] fix(java): resolve record accessor calls, and never bind a call to data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record's accessors are implicit, so `record Point(double x, double y, double z)` declares no `x()` and extraction emitted no node for one. The call `p.x()` had nothing to resolve to, fell through to the short-name registry, and bound to the first `x` in the project — typically an unrelated class's private field. Measured on two indexed Java trees, that was 278 of 2444 and 2649 of 29509 CALLS edges. Extraction now emits one Method definition per record component, skipping any accessor the record declares itself (JLS 8.10.3), so the accessor has a node and the call resolves to it. And a Java call may no longer bind to a Variable or a Field. A Java call expression never names data, so such an edge is a spelling collision, not a call. The guard follows cbm_go_suppress_bare_field_ref — a pure, label-keyed predicate — and is called from both pass_calls.c and pass_parallel.c so the sequential and parallel resolvers stay identical. Java-gated: Kotlin properties hold function values and C has function pointers, so both legitimately call through data. Signed-off-by: MopicMP Co-authored-by: Claude Opus 5 --- internal/cbm/extract_defs.c | 80 ++++++++++++++++++++++++++ src/pipeline/pass_calls.c | 6 ++ src/pipeline/pass_parallel.c | 6 ++ src/pipeline/pipeline.h | 7 +++ src/pipeline/registry.c | 24 ++++++++ tests/test_call_reference_contract.c | 85 ++++++++++++++++++++++++++++ tests/test_registry.c | 22 +++++++ 7 files changed, 230 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 93da084be1..fd051c482d 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -204,6 +204,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch const CBMLangSpec *spec); static TSNode find_class_body(TSNode class_node, CBMLanguage lang); static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *class_qn); +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn); static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); // --- Helpers --- @@ -4642,6 +4643,10 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec extract_enum_members(ctx, node, class_qn); } + if (ctx->language == CBM_LANG_JAVA && strcmp(kind, "record_declaration") == 0) { + extract_record_accessors(ctx, node, class_qn); + } + // Extract methods inside the class extract_class_methods(ctx, node, class_qn, spec); @@ -5529,6 +5534,81 @@ static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *cl } } +/* Whether the record body declares this accessor itself (JLS §8.10.3 lets a + * record override any of them), so the implicit one is not emitted twice. */ +static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char *comp_name) { + TSNode body = find_class_body(node, ctx->language); + if (ts_node_is_null(body)) { + return false; + } + uint32_t mc = ts_node_named_child_count(body); + for (uint32_t mi = 0; mi < mc; mi++) { + TSNode member = ts_node_named_child(body, mi); + if (strcmp(ts_node_type(member), "method_declaration") != 0) { + continue; + } + TSNode mname = ts_node_child_by_field_name(member, TS_FIELD("name")); + if (ts_node_is_null(mname)) { + continue; + } + char *name = cbm_node_text(ctx->arena, mname, ctx->source); + if (!name || strcmp(name, comp_name) != 0) { + continue; + } + /* Same name, no parameters — that is the accessor. */ + TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); + if (ts_node_is_null(params) || ts_node_named_child_count(params) == 0) { + return true; + } + } + return false; +} + +/* Java records: the compiler writes one accessor per component, so `p.x()` + * calls a method that appears nowhere in the source. Without a definition for + * it the call has nothing to resolve to and falls through to the project-wide + * name registry, which binds it to the first `x` in the tree — typically an + * unrelated class's private field. Emitting the accessors makes the record's + * own API visible, exactly as the language defines it (JLS §8.10.3). */ +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn) { + CBMArena *a = ctx->arena; + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + TSNode comp = ts_node_named_child(params, pi); + if (strcmp(ts_node_type(comp), "formal_parameter") != 0) { + continue; + } + TSNode cname = ts_node_child_by_field_name(comp, TS_FIELD("name")); + if (ts_node_is_null(cname)) { + continue; + } + char *comp_name = cbm_node_text(a, cname, ctx->source); + if (!comp_name || !comp_name[0] || record_declares_accessor(ctx, node, comp_name)) { + continue; + } + CBMDefinition adef; + memset(&adef, 0, sizeof(adef)); + adef.name = comp_name; + adef.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, comp_name); + adef.label = "Method"; + adef.file_path = ctx->rel_path; + adef.start_line = ts_node_start_point(comp).row + TS_LINE_OFFSET; + adef.end_line = ts_node_end_point(comp).row + TS_LINE_OFFSET; + adef.lines = 1; + adef.parent_class = class_qn; + adef.is_exported = true; + TSNode ctype = ts_node_child_by_field_name(comp, TS_FIELD("type")); + if (!ts_node_is_null(ctype)) { + adef.return_type = cbm_node_text(a, ctype, ctx->source); + } + cbm_defs_push(&ctx->result->defs, a, adef); + } +} + /* Resolve the identifier node from a destructure pattern child. * pair_pattern → value field; shorthand/identifier → itself; others → first named child. */ static TSNode destructure_ident(TSNode pat_child) { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954cf..c702f7efd7 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -682,6 +682,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + /* A Java call never targets data — see cbm_java_suppress_call_to_data_member. + * The language gate lives here, as with the guards above, and MUST match + * pass_parallel.c exactly or the two resolvers diverge. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return 0; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..380679c136 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3028,6 +3028,12 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * CALLS edge across a language boundary. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + /* Same guard as pass_calls.c — a Java call never targets a + * Variable or a Field. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0b..416c5b25a9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -344,6 +344,13 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target * unit-tested in test_registry.c. */ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label); +/* A Java call expression never names data: `p.x()` is a method invocation + * whatever `x` spells elsewhere in the project. Drops a CALLS bind whose + * target is a Variable or Field. Java only — Kotlin properties and C function + * pointers are callable names that are not methods. Pure; unit-tested in + * test_registry.c. */ +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d5..2a9fb19fbb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -811,6 +811,30 @@ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const cha return strcmp(target_label, "Field") == 0; } +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) { + /* A Java call expression never names data. `p.x()` is a method invocation + * whatever `x` spells elsewhere in the tree, so a CALLS edge into a + * Variable or a Field is a spelling collision and not a call. + * + * The bind is reached when the type-aware resolver finds no method to + * return: a record's accessors are implicit, so `record Point(double x, + * double y, double z)` declares no `x()` for java_lookup_method to find, + * and every `p.x()` falls through to the short-name registry — which + * matches the first `x` anywhere in the project, typically an unrelated + * class's private field. Measured on a 600-file Java tree (2026-09-22): + * 278 of 2444 CALLS edges landed on fields that way, and the callers of + * one four-field class absorbed every record read in the project. + * + * Java-gated, like the Go guard above, because the veto is only sound + * where no callable name can be anything but a method: Kotlin properties + * hold function values (`val f: () -> Unit; f()`) and C has function + * pointers, so both legitimately call through data. */ + if (!is_java || !target_label) { + return false; + } + return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 3a7b3acd14..d7aa27939f 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -35,6 +35,40 @@ static int crc_edge_count(cbm_store_t *store, const char *project, const char *t return matches; } +/* Edges of `type` out of `source_name` whose target's qualified name ends with + * `target_qn_suffix`. Two methods in one project can share a short name, and + * then only the qualified name tells them apart. */ +static int crc_edge_count_to_qn(cbm_store_t *store, const char *project, const char *type, + const char *source_name, const char *target_qn_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, type, &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + size_t suffix_len = strlen(target_qn_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + bool source_ok = + cbm_store_find_node_by_id(store, edges[i].source_id, &source) == CBM_STORE_OK; + bool target_ok = + cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.qualified_name && + strcmp(source.name, source_name) == 0) { + size_t qn_len = strlen(target.qualified_name); + if (qn_len >= suffix_len && + strcmp(target.qualified_name + qn_len - suffix_len, target_qn_suffix) == 0) { + matches++; + } + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + cbm_store_free_edges(edges, edge_count); + return matches; +} + static int crc_global_handler_edge_count(cbm_store_t *store, const char *project, const char *caller) { int references = @@ -423,6 +457,56 @@ TEST(call_reference_go_ambiguous_promoted_method_stays_usage) { PASS(); } +TEST(call_java_record_accessor_binds_the_record) { + /* A record's accessors are implicit, so `record Point(double x, ...)` + * declares no `x()` in its source. Extraction emits them, and `other.x()` + * binds to the record's own accessor. + * + * Two things must not happen instead. The call must not reach the + * project-wide short-name registry and land on the first `x` in the tree — + * here an unrelated class's private field. And the guard that refuses such + * data binds must not swallow a real method that merely shares the + * spelling: the builder's `x(int)`, declared beside its own `x` field, + * keeps its edge. */ + static const RFile files[] = { + {"Point.java", "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double first(Point other) {\n" + " return Maths.abs(other.x()) + new Builder().x(5).made();\n" + " }\n" + "}\n"}, + {"Spring.java", "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"}, + {"Builder.java", "package com.example;\n" + "public final class Builder {\n" + " private int x;\n" + " public Builder x(int v) { this.x = v; return this; }\n" + " public int made() { return x; }\n" + "}\n"}, + {"Maths.java", "package com.example;\n" + "public final class Maths {\n" + " public static double abs(double v) { return v < 0 ? -v : v; }\n" + "}\n"}}; + RProj project; + cbm_store_t *store = rh_index_files(&project, files, 4); + ASSERT_NOT_NULL(store); + int fabricated_field = crc_edge_count(store, project.project, "CALLS", "first", "Field", "x"); + int fabricated_var = crc_edge_count(store, project.project, "CALLS", "first", "Variable", "x"); + int genuine = crc_edge_count(store, project.project, "CALLS", "first", "Method", "abs"); + int accessor = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Point.x"); + int builder = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Builder.x"); + rh_cleanup(&project, store); + ASSERT_EQ(fabricated_field, 0); + ASSERT_EQ(fabricated_var, 0); + ASSERT_EQ(genuine, 1); + ASSERT_EQ(accessor, 1); + ASSERT_EQ(builder, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -439,4 +523,5 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_python_later_decorated_method_rebinding_stays_usage); RUN_TEST(call_reference_go_bound_method_argument_is_exact); RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); + RUN_TEST(call_java_record_accessor_binds_the_record); } diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a8993..126400da61 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -987,6 +987,27 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(java_call_never_binds_data_member) { + /* A Java call expression never names data, so a CALLS bind that landed on + * a Variable or a Field is a spelling collision. The shape that produces + * it: a record's accessors are implicit, `p.x()` finds no declared `x()`, + * and the short-name registry hands back the first `x` in the tree — an + * unrelated class's private field. */ + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Field")); + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Variable")); + /* Everything callable is kept, including the constructor's Class target. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Method")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Function")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Class")); + /* Other languages have callable data: a Kotlin property or a C function + * pointer is a name that is not a method and is called all the same. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Field")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Variable")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -1244,6 +1265,7 @@ SUITE(registry) { RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(java_call_never_binds_data_member); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods);