From f4c897110cb1f02edee37db7c8849e63894ed8a4 Mon Sep 17 00:00:00 2001 From: MopicMP Date: Wed, 23 Sep 2026 22:44:16 +0200 Subject: [PATCH 1/6] 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 93da084be..fd051c482 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 84958954c..c702f7efd 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 e85ad015f..380679c13 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 e5a672cb0..416c5b25a 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 c99c72d8d..2a9fb19fb 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 3a7b3acd1..d7aa27939 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 5f751a899..126400da6 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); From ee6e78b7142d51f849acaaa34cf010492568a28c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 23:22:14 +0200 Subject: [PATCH 2/6] fix(java): ignore comment extras in record accessor parameters Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 14 +++++- tests/test_call_reference_contract.c | 70 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b88d77928..2525f0a15 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9590,7 +9590,19 @@ static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char } /* 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) { + if (ts_node_is_null(params)) { + return true; + } + /* Comments are named extras, not parameters. */ + bool has_parameters = false; + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + if (!ts_node_is_extra(ts_node_named_child(params, pi))) { + has_parameters = true; + break; + } + } + if (!has_parameters) { return true; } } diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 67e30132a..ec5ce4abe 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -578,6 +578,74 @@ TEST(call_java_record_varargs_accessor_has_array_return) { PASS(); } +/* Comment extras in an otherwise empty parameter list must not create a + * second, implicit accessor at the record component's source location. */ +TEST(call_java_record_commented_accessor_has_only_explicit_span) { + static const char *sources[] = {"record Value(int x) {\n" + " public int x(/* explanation */) {\n" + " return x;\n" + " }\n" + "}\n", + "record Value(int x) {\n" + " public int x(// explanation\n" + " ) {\n" + " return x;\n" + " }\n" + "}\n"}; + static const int end_lines[] = {4, 5}; + for (int fixture = 0; fixture < 2; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + int explicit_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + if (def->start_line == 2 && def->end_line == end_lines[fixture]) { + explicit_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_EQ(explicit_spans, 1); + } + PASS(); +} + +TEST(call_java_record_commented_overload_keeps_implicit_accessor) { + static const char source[] = + "record Value(int x) {\n" + " public int x(/* explanation */ int scale) { return x * scale; }\n" + "}\n"; + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_JAVA, "repro", + "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int methods = 0; + int implicit_spans = 0; + int overload_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + methods++; + if (def->start_line == 1 && def->end_line == 1) { + implicit_spans++; + } + if (def->start_line == 2 && def->end_line == 2) { + overload_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(methods, 2); + ASSERT_EQ(implicit_spans, 1); + ASSERT_EQ(overload_spans, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -596,6 +664,8 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); RUN_TEST(call_java_record_accessor_binds_the_record); RUN_TEST(call_java_record_varargs_accessor_has_array_return); + RUN_TEST(call_java_record_commented_accessor_has_only_explicit_span); + RUN_TEST(call_java_record_commented_overload_keeps_implicit_accessor); RUN_TEST(call_java_static_import_does_not_call_field_sequential); RUN_TEST(call_java_static_import_does_not_call_field_parallel); } From 729ce339bd130e44d317d7ecf5c296bdfe016350 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 02:56:12 +0100 Subject: [PATCH 3/6] fix(java): register generated record accessors by qualified name only A record's implicit accessors are members the compiler writes and the source never spells. Their nodes now carry the property generated=record, and every registry registration path files them in the exact (qualified-name) map only, never in the short-name index. The Java type-aware resolver still binds p.y() on a Point to Point.y; a call such as v.y() on a type from outside the project no longer binds the accessor through a unique_name / suffix_match / field_type_hint / fuzzy guess. - extract_defs.c: each implicit accessor carries extra_props generated=record (key CBM_PROP_GENERATED, cbm.h). - registry.c: cbm_registry_add_qn_only, and the pure predicates cbm_def_props_generated / cbm_node_props_generated. - pass_definitions.c, pass_parallel.c, pipeline_incremental.c: the three registration sites route a generated member to the exact map only. - pipeline_delta.c: the incremental preseed proxy keeps the mark, so an incremental re-index registers the member exactly as a full one does (the proxy carried only a few properties and the accessor came back into the short-name index). Tests: pipeline_java_record_accessors_generated_qn_only (sequential, parallel and incremental venues) and two registry unit tests. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 14 +- internal/cbm/extract_defs.c | 10 ++ src/pipeline/pass_definitions.c | 11 +- src/pipeline/pass_parallel.c | 11 +- src/pipeline/pipeline.h | 18 +++ src/pipeline/pipeline_delta.c | 54 ++++++- src/pipeline/pipeline_incremental.c | 6 + src/pipeline/registry.c | 26 +++ tests/test_pipeline.c | 236 ++++++++++++++++++++++++++++ tests/test_registry.c | 47 ++++++ 10 files changed, 419 insertions(+), 14 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index ade41fea5..ef5fad51f 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -298,9 +298,10 @@ typedef struct { * are bound to the owning result's source identity before cross-file use. */ uint32_t test_name_start_byte, test_name_end_byte; uint32_t test_body_start_byte, test_body_end_byte; - /* Documents only: facts a document definition adds to its node's - * properties, as a NULL-terminated key, value, key, value, ... list (an - * ADR's id, status, date, deciders, title). Keys are fixed literals; the + /* Facts a definition adds to its node's properties, as a NULL-terminated + * key, value, key, value, ... list: a document's (an ADR's id, status, + * date, deciders, title), or CBM_PROP_GENERATED on a member the compiler + * writes and the source never spells. Keys are fixed literals; the * values are escaped where the properties are written. Tail field: * zero-init stays valid. */ const char **extra_props; @@ -315,6 +316,13 @@ typedef struct { const char *variant_key; } CBMDefinition; +/* extra_props key of a generated member, one the compiler writes and the + * source never spells: a Java record's implicit accessor carries + * `generated: record`. The node keeps the property, and the pipeline registers + * such a definition by qualified name only, so no short-name guess can land on + * it (pipeline.h cbm_def_props_generated). */ +#define CBM_PROP_GENERATED "generated" + /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2525f0a15..65829e9b8 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9668,6 +9668,16 @@ static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char const char *type = cbm_node_text(a, ctype, ctx->source); adef.return_type = spread && type ? cbm_arena_sprintf(a, "%s[]", type) : type; } + /* The compiler writes this member and the source never spells it: its + * node says so, and the pipeline registers it by qualified name only, + * so no short-name guess can land on it. */ + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_3 * sizeof(char *)); + if (kv) { + kv[0] = CBM_PROP_GENERATED; + kv[SKIP_ONE] = "record"; + kv[PAIR_LEN] = NULL; + } + adef.extra_props = kv; cbm_defs_push(&ctx->result->defs, a, adef); } } diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index ed60d3f9a..45bb5d3a7 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -509,10 +509,15 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed - * through the same predicate, so the three registries cannot diverge. */ + * through the same predicate, so the three registries cannot diverge. + * A generated member is registered by qualified name only, at all three. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 23f4934ce..dd04e3ad9 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1811,10 +1811,15 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d return 0; } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol - * (helpers.c) — see pass_definitions.c for the per-label rationale. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, and + * for the generated member registered by qualified name only. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } (*reg_entries)++; } char marked[CBM_SZ_4K]; diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index fca310004..b564b4575 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -277,6 +277,24 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang, size_t module_prefix_len); +/* Register a symbol for exact (qualified-name) lookups only: it is never + * offered to the short-name strategies (unique_name, suffix_match, + * field_type_hint, fuzzy). For a generated member (cbm_def_props_generated), + * which the source never spells, so only a type-aware resolver that knows its + * owner may bind it. The three graph registration sites route such a symbol + * here instead of cbm_registry_add_lang_in_module. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label); + +/* True when a definition's extra_props (key, value, ..., NULL) carry the + * generated-member key (cbm.h CBM_PROP_GENERATED), e.g. a Java record's + * implicit accessor. Pure; unit-tested in test_registry.c. */ +bool cbm_def_props_generated(const char *const *extra_props); + +/* The stored-node twin of cbm_def_props_generated, for the incremental + * registry seed: true when a node's properties JSON carries that key as a + * string property. Pure; unit-tested in test_registry.c. */ +bool cbm_node_props_generated(const char *properties_json); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 895cded93..ca1dbab46 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -590,6 +590,39 @@ static char *delta_owner_props(const char *project, const char *name, const char return out; } +/* `props` (a JSON object) with a generated member's mark (cbm.h + * CBM_PROP_GENERATED) added, or NULL when `generated` is NULL/empty + * (CBM_MEM_CLASS_OTHER; the caller frees). */ +static char *delta_generated_props(const char *props, const char *generated) { + if (!generated || !generated[0]) { + return NULL; + } + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + PAIR_LEN * strlen(generated) + CBM_SZ_32; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + size_t k = 0; + if (has_members) { + memcpy(out, props, pl - SKIP_ONE); + k = pl - SKIP_ONE; + out[k++] = ','; + } else { + out[k++] = '{'; + } + k += (size_t)snprintf(out + k, cap - k, "\"%s\":\"", CBM_PROP_GENERATED); + for (const char *p = generated; *p && k + CBM_SZ_3 < cap; p++) { + if (*p == '"' || *p == '\\') { + out[k++] = '\\'; + } + out[k++] = *p; + } + snprintf(out + k, cap - k, "\"}"); + return out; +} + int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf) { sqlite3 *db = cbm_store_get_db(store); if (!db) { @@ -634,7 +667,12 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * goes by -- a Go method's QN has no receiver, a class-body variable's QN * no class. A document naming `Order.Reset` binds such a node through * its owner (doc_links_pdf.c), so a re-resolved document needs it too. - * Only where owner + name differs from the QN (delta_owner_props). */ + * Only where owner + name differs from the QN (delta_owner_props). + * + * And a generated member's mark (a Java record's implicit accessor, a + * Method node): the registry seed reads it to register the member by + * qualified name only, as the full build does (cbm_node_props_generated); + * without it the proxy came back into the short-name index. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -646,7 +684,10 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " start_line, end_line," " CASE WHEN label IN ('Method','Variable')" " AND instr(properties, '\"parent_class\"') > 0" - " THEN json_extract(properties, '$.parent_class') END" + " THEN json_extract(properties, '$.parent_class') END," + " CASE WHEN label = 'Method'" + " AND instr(properties, '\"" CBM_PROP_GENERATED "\"') > 0" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -670,13 +711,16 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g int start_line = sqlite3_column_int(stmt, 6); int end_line = sqlite3_column_int(stmt, 7); const char *parent = (const char *)sqlite3_column_text(stmt, 8); + const char *generated = (const char *)sqlite3_column_text(stmt, 9); char *owner_props = parent ? delta_owner_props(project, name, qn, parent, client_props) : NULL; + const char *props = owner_props ? owner_props : (client_props ? client_props : "{}"); + char *generated_props = delta_generated_props(props, generated); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = - cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, end_line, - owner_props ? owner_props : (client_props ? client_props : "{}")); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, + end_line, generated_props ? generated_props : props); + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { /* A QN collision inside the preseed set would silently split diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 6e6740573..3d44cc696 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1348,6 +1348,12 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } + /* A generated member stays out of the short-name index here as in the + * full index (pass_definitions.c); its node carries the mark. */ + if (cbm_node_props_generated(node->properties_json)) { + cbm_registry_add_qn_only(seed->registry, node->qualified_name, node->label); + return; + } CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { void *v = cbm_ht_get(seed->lang_by_path, node->file_path); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 209691ea2..51beb1eaf 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1406,6 +1406,32 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, } } +/* A generated member (a Java record's implicit accessor) is a definition the + * source never spells: no call names it except through its owner's type. The + * exact map holds it for the LSP targets and the import/same-module lookups, + * as it holds a Python function's locals; the by-name index does not, so no + * unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) { + if (!r || !qualified_name || !label) { + return; + } + (void)registry_add_exact(r, qualified_name, label); +} + +bool cbm_def_props_generated(const char *const *extra_props) { + for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { + if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) { + return true; + } + } + return false; +} + +bool cbm_node_props_generated(const char *properties_json) { + /* The key as the definition passes write it: "generated":"". */ + return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL; +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f3..6dbbdad39 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17366,6 +17366,239 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { PASS(); } +/* A Java record's implicit accessors are GENERATED members (#2295): Method + * nodes marked `generated: record`, registered by qualified name only, as a + * Python function's locals are. The type-aware resolver binds `p.y()` on a + * Point to Point.y; no short-name guess may land on a member the source never + * spells. `far` calls y() on a type outside the project, so the project-wide + * name registry is its only route to Point.y. `data` calls x(), whose other + * spelling is Spring's private field: neither the accessor (a name guess) nor + * the field (a Java call never names data) is its target. The record's + * hand-written z() and norm() are source members: no `generated` mark. */ +static const char jgen_point_java[] = + "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double z() { return z; }\n" + " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" + "}\n"; +static const char jgen_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"; +static const char jgen_caller_java[] = "package com.example;\n" + "import org.external.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + "}\n"; + +/* The id of the one `label` node called `name` in `file_path`; 0 when there is + * none or more than one. `generated` (optional) receives whether its + * properties carry the `generated: record` mark. */ +static int64_t jgen_node(cbm_store_t *s, const char *project, const char *name, const char *label, + const char *file_path, bool *generated) { + cbm_node_t *nodes = NULL; + int count = 0; + int64_t id = 0; + int hits = 0; + bool marked = false; + if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0 && nodes[i].file_path && + strcmp(nodes[i].file_path, file_path) == 0) { + hits++; + id = nodes[i].id; + marked = nodes[i].properties_json && + strstr(nodes[i].properties_json, "\"generated\":\"record\"") != NULL; + } + } + cbm_store_free_nodes(nodes, count); + if (generated) { + *generated = marked; + } + return hits == 1 ? id : 0; +} + +/* CALLS edges out of `source_id` into `target_id`, or (target_id 0) into any + * node called `target_name`. */ +static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, + const char *target_name) { + cbm_edge_t *edges = NULL; + int ec = 0; + int n = 0; + if (cbm_store_find_edges_by_source_type(s, source_id, "CALLS", &edges, &ec) != CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < ec; i++) { + if (target_id) { + n += edges[i].target_id == target_id; + continue; + } + cbm_node_t t = {0}; + if (cbm_store_find_node_by_id(s, edges[i].target_id, &t) == CBM_STORE_OK && t.name && + strcmp(t.name, target_name) == 0) { + n++; + } + cbm_node_free_fields(&t); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return n; +} + +/* One fact of jgen_check: reported, counted, and the check goes on, so a run + * shows every fact that does not hold. */ +#define JGEN_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failed++; \ + } \ + } while (0) + +/* Every fact the fixture pins, on one indexed store; the number that fail. */ +static int jgen_check(cbm_store_t *s, const char *project) { + int failed = 0; + bool gen_x = false; + bool gen_y = false; + bool gen_z = true; + bool gen_norm = true; + int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); + int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); + int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); + int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); + int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0); + /* The compiler's members are marked; the hand-written ones are not. */ + JGEN_EXPECT(gen_x); + JGEN_EXPECT(gen_y); + JGEN_EXPECT(!gen_z); + JGEN_EXPECT(!gen_norm); + /* Bound by qualified name through the receiver's type ... */ + JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + /* ... and never by a short-name guess. */ + JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + /* Neither the accessor nor Spring's field `x`. */ + JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + return failed; +} +#undef JGEN_EXPECT + +static int jgen_write_fixture(const char *tmp) { + write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Spring.java", jgen_spring_java); + write_temp_file(tmp, "Caller.java", jgen_caller_java); + return 0; +} + +TEST(pipeline_java_record_accessors_generated_qn_only) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* The same fixture through pass_parallel.c: >= 50 files and CBM_WORKERS=4. */ +TEST(pipeline_java_record_accessors_generated_qn_only_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "Filler%d.java", i); + snprintf(body, sizeof(body), + "package com.example;\nclass Filler%d { int f() { return %d; } }\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run = p ? cbm_pipeline_run(p) : -1; + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(run, 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph: the + * untouched Point.java's accessors come back from their nodes, and must stay + * out of the short-name index exactly as in the full index while the touched + * Caller.java is re-resolved. */ +TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char caller_path[512]; + snprintf(caller_path, sizeof(caller_path), "%s/Caller.java", tmp); + ASSERT_EQ(th_append_file(caller_path, "\n// touched: re-resolve Caller.java incrementally\n"), + 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p2)); + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + /* As pyn_edge, and the edge's properties also contain `fragment`. */ static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, const char *type, const char *fragment) { @@ -27777,6 +28010,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_parallel); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); diff --git a/tests/test_registry.c b/tests/test_registry.c index d7e062bfc..7eb012679 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1436,6 +1436,51 @@ TEST(java_call_never_binds_data_member) { PASS(); } +TEST(generated_member_registers_by_qn_only) { + /* A generated member (a Java record's implicit accessor) is found by its + * qualified name, never by a short-name guess. The same member registered + * the ordinary way is exactly what the guess binds. */ + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.y", "Method"); + ASSERT_TRUE(cbm_registry_exists(r, "proj.geo.Point.Point.y")); + ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.geo.Point.Point.y"), "Method"); + const char **qns = NULL; + int n = -1; + ASSERT_EQ(cbm_registry_find_by_name(r, "y", &qns, &n), 0); + ASSERT_EQ(n, 0); + cbm_resolution_t res = cbm_registry_resolve(r, "v.y", "proj.app.Caller", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_add(r, "x", "proj.geo.Point.Point.x", "Method"); + res = cbm_registry_resolve(r, "v.x", "proj.app.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.geo.Point.Point.x"); + /* Degenerate input registers nothing. */ + cbm_registry_add_qn_only(r, NULL, "Method"); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.z", NULL); + cbm_registry_add_qn_only(NULL, "proj.geo.Point.Point.z", "Method"); + ASSERT_EQ(cbm_registry_size(r), 2); + cbm_registry_free(r); + PASS(); +} + +TEST(generated_member_mark_on_defs_and_nodes) { + static const char *const record[] = {"generated", "record", NULL}; + static const char *const later[] = {"source", "openapi", "generated", "lombok", NULL}; + static const char *const doc[] = {"id", "ADR-7", "status", "accepted", NULL}; + static const char *const keyless[] = {"generated", NULL}; + ASSERT_TRUE(cbm_def_props_generated(record)); + ASSERT_TRUE(cbm_def_props_generated(later)); + ASSERT_FALSE(cbm_def_props_generated(doc)); + ASSERT_FALSE(cbm_def_props_generated(keyless)); /* a key without a value is no fact */ + ASSERT_FALSE(cbm_def_props_generated(NULL)); + ASSERT_TRUE( + cbm_node_props_generated("{\"parent_class\":\"p.Point\",\"generated\":\"record\"}")); + ASSERT_FALSE(cbm_node_props_generated("{\"parent_class\":\"p.Point\"}")); + /* The word inside a value is not the key. */ + ASSERT_FALSE(cbm_node_props_generated("{\"docstring\":\"code generated by protoc\"}")); + ASSERT_FALSE(cbm_node_props_generated(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. @@ -2298,6 +2343,8 @@ SUITE(registry) { RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); RUN_TEST(java_call_never_binds_data_member); + RUN_TEST(generated_member_registers_by_qn_only); + RUN_TEST(generated_member_mark_on_defs_and_nodes); 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); From 6c52e8260cc01e7cb5470c16b106b28815c426de Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:12:42 +0100 Subject: [PATCH 4/6] fix(java): no import resolves to a generated record accessor The import resolver's symbol-name fallback tries the trailing segments of an import path that names no project symbol, and a package segment that merely spells a record accessor bound the import to it. On elasticsearch, `import javax.crypto.spec.PBEKeySpec` bound a record's spec() accessor, and `new PBEKeySpec(...)`, annotations and type uses then followed the import onto it: 6,302 IMPORTS edges into accessors, plus CALLS, USAGE, DECORATES and THROWS through the import map. A generated member is registered by qualified name only, and no Java import can name an instance accessor, so the fallback now skips it exactly as it skips a Python def-local (#1548). The record pipeline fixture gains an import whose package segment spells an accessor. Without the change it fails in the sequential, parallel and incremental venues: an IMPORTS edge into Point.y, and make() -> Point.y. Signed-off-by: Martin Vogel --- src/pipeline/pass_pkgmap.c | 9 +++++++++ tests/test_pipeline.c | 29 ++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index ef3ba0f10..128ba5fc3 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2592,6 +2592,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies( if (import_candidate_is_python_local(ctx->gbuf, cand)) { continue; } + /* Nor is a generated member (a Java record's implicit + * accessor, an instance method no import can name). It is + * registered by qualified name only, and a package segment + * that merely spells it (`javax.crypto.spec` -> spec()) + * must not bind the import -- or every use of the + * imported name -- to it. */ + if (cbm_node_props_generated(cand->properties_json)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6dbbdad39..940169b77 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17374,7 +17374,12 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * name registry is its only route to Point.y. `data` calls x(), whose other * spelling is Spring's private field: neither the accessor (a name guess) nor * the field (a Java call never names data) is its target. The record's - * hand-written z() and norm() are source members: no `generated` mark. */ + * hand-written z() and norm() are source members: no `generated` mark. + * `org.external.y.Vector` is not in the project, and its package segment `y` + * spells the accessor: the import's symbol-name fallback must not bind it + * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import + * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` + * bound a record's `spec()`). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" @@ -17387,11 +17392,12 @@ static const char jgen_spring_java[] = "package com.example;\n" " public void put(double v) { this.x = v; }\n" "}\n"; static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.Vector;\n" + "import org.external.y.Vector;\n" "public final class Caller {\n" " public double near(Point p) { return p.y(); }\n" " public double far(Vector v) { return v.y(); }\n" " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is @@ -17451,6 +17457,19 @@ static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, return n; } +/* Edges of `type` into `target_id`. */ +static int jgen_edges_into(cbm_store_t *s, int64_t target_id, const char *type) { + cbm_edge_t *edges = NULL; + int ec = 0; + if (cbm_store_find_edges_by_target_type(s, target_id, type, &edges, &ec) != CBM_STORE_OK) { + return -1; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return ec; +} + /* One fact of jgen_check: reported, counted, and the check goes on, so a run * shows every fact that does not hold. */ #define JGEN_EXPECT(cond) \ @@ -17475,8 +17494,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -17489,6 +17509,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + /* No import names a generated member, so nothing follows one onto it. */ + JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); + JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); return failed; } #undef JGEN_EXPECT From 679d1e1af432b0bb1c0873c60668d46ed6736172 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:45:08 +0100 Subject: [PATCH 5/6] fix(java): a JVM leaf-name fallback never picks a generated accessor When a JVM LSP callee has no exact node and no Class.method tail match, cbm_pipeline_lsp_target_node takes the one project callable with that leaf name. A record accessor became such a callable: on elasticsearch `Objects.nonNull(x)` (37 CALLS) and `filter(Objects::nonNull)` (230 CALL_REFERENCE) bound a test record's nonNull() accessor. A generated member is registered by qualified name only. It still joins through its owner's Class.method tail, but the leaf-name fallback no longer considers it, either as the unique callable or as a rival that makes a same-named project callable ambiguous, so every other bind is decided exactly as before the accessors existed. The record pipeline fixture gains a record Seen(boolean nonNull) with `Objects.nonNull(o)` and `filter(Objects::nonNull)` callers. Without the change it fails in the sequential, parallel and incremental venues. Signed-off-by: Martin Vogel --- src/pipeline/lsp_resolve.h | 16 ++++++++++---- tests/test_pipeline.c | 45 ++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e26..371cb5536 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1281,10 +1281,18 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node_policy( if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) { continue; } - if (!unique_callable) { - unique_callable = cand; - } else if (unique_callable->id != cand->id) { - callable_ambiguous = true; + /* A generated member (a Java record's implicit accessor) is registered + * by qualified name only: it joins through its owner's Class.method + * tail below, never on its leaf name alone -- `Objects.nonNull(x)` is + * no call of some record's nonNull(). Nor does it make a same-named + * callable ambiguous: the leaf fallback sees the graph as if it were + * not there. */ + if (!cbm_node_props_generated(cand->properties_json)) { + if (!unique_callable) { + unique_callable = cand; + } else if (unique_callable->id != cand->id) { + callable_ambiguous = true; + } } if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) { continue; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 940169b77..b9bfbb057 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17379,26 +17379,39 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * spells the accessor: the import's symbol-name fallback must not bind it * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` - * bound a record's `spec()`). */ + * bound a record's `spec()`). `stat` calls, and `refs` passes, the JDK's + * Objects.nonNull: no project callable is its target, and Seen's accessor -- + * the only project callable called nonNull -- must not be taken for it on its + * leaf name alone (elasticsearch: `Objects.nonNull(x)` and + * `filter(Objects::nonNull)` bound a test record's nonNull()). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" " public double z() { return z; }\n" " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" "}\n"; +static const char jgen_seen_java[] = "package com.example;\n" + "record Seen(boolean nonNull) {}\n"; static const char jgen_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"; -static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.y.Vector;\n" - "public final class Caller {\n" - " public double near(Point p) { return p.y(); }\n" - " public double far(Vector v) { return v.y(); }\n" - " public double data(Vector v) { return v.x(); }\n" - " public Object make() { return new Vector(); }\n" - "}\n"; +static const char jgen_caller_java[] = + "package com.example;\n" + "import java.util.List;\n" + "import java.util.Objects;\n" + "import org.external.y.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" + " public boolean stat(Object o) { return Objects.nonNull(o); }\n" + " public long refs(List xs) {\n" + " return xs.stream().filter(Objects::nonNull).count();\n" + " }\n" + "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is * none or more than one. `generated` (optional) receives whether its @@ -17487,6 +17500,8 @@ static int jgen_check(cbm_store_t *s, const char *project) { bool gen_y = false; bool gen_z = true; bool gen_norm = true; + bool gen_nn = false; + int64_t acc_nn = jgen_node(s, project, "nonNull", "Method", "Seen.java", &gen_nn); int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); @@ -17495,11 +17510,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); - JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); + int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); + int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); + JGEN_EXPECT(gen_nn); JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ @@ -17512,12 +17530,17 @@ static int jgen_check(cbm_store_t *s, const char *project) { /* No import names a generated member, so nothing follows one onto it. */ JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); + /* The JDK's Objects.nonNull is not Seen's accessor, called or referenced. */ + JGEN_EXPECT(jgen_calls(s, stat, acc_nn, NULL) == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "CALL_REFERENCE") == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "USAGE") == 0); return failed; } #undef JGEN_EXPECT static int jgen_write_fixture(const char *tmp) { write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Seen.java", jgen_seen_java); write_temp_file(tmp, "Spring.java", jgen_spring_java); write_temp_file(tmp, "Caller.java", jgen_caller_java); return 0; From 8f36ea6c0b15a1060296718e93bb62aa45b5a2d7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:49:16 +0100 Subject: [PATCH 6/6] test(pipeline): rename the near/far locals in the record accessor test Windows headers define near and far as empty macros, so the locals named near and far in the generated-accessor pipeline test did not compile on the Windows leg. Rename them to near_m and far_m; the node names stay. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 355ebbcf9..82755022c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18059,14 +18059,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); - int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); - int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t near_m = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far_m = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); + JGEN_EXPECT(near_m > 0 && far_m > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -18074,10 +18074,10 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ - JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + JGEN_EXPECT(jgen_calls(s, near_m, acc_y, NULL) == 1); /* ... and never by a short-name guess. */ - JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); - JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + JGEN_EXPECT(jgen_calls(s, far_m, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far_m, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); /* No import names a generated member, so nothing follows one onto it. */