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
80 changes: 80 additions & 0 deletions internal/cbm/extract_defs.c
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,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 ---
Expand Down Expand Up @@ -4809,6 +4810,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);

Expand Down Expand Up @@ -5697,6 +5702,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) {
Expand Down
6 changes: 6 additions & 0 deletions src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
24 changes: 24 additions & 0 deletions src/pipeline/registry.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions tests/test_call_reference_contract.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
22 changes: 22 additions & 0 deletions tests/test_registry.c
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading