From 808100974cf89324bafdf03a3278fa83bd697ac1 Mon Sep 17 00:00:00 2001 From: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:06:43 +0200 Subject: [PATCH 1/4] feat(java): no name guess for calls on a proven-external receiver When the Java resolver resolves a call's receiver to a type outside the project (a JDK package, or a package no project file declares), the call gets strategy lsp_external. Both pipeline paths (pass_calls.c and pass_parallel.c) then look the target up strictly by QN through cbm_pipeline_java_external_target and skip the registry's name guess, so no edge is created to an unrelated project method of the same name. This is the rule the Rust path follows since #2053. Positive proof only: a receiver of unknown type keeps the existing guess, and a seeded JDK type that lacks the called method stays no_method_match. Type variables (T, ID) are never taken for package-qualified types. A package counts as the project's when any registry layer holds a project type in it (the layer above the shared registry is a per-file overlay), and a path-derived spelling counts as the project's also for a file at the repository root. java_receiver_is_external memoizes its answer per package. A type of the caller's own package, or of its own file, now shadows java.lang (JLS 6.4.1): java_resolve_type_name looks the JVM package up before the java.lang auto-import. Before the gate that wrong first answer was harmless, since the name guess still found the project class; with the gate a project `Enum` or `Process` would read as external. Only the parallel path builds the Java cross registry, so on the sequential path the gate does not fire yet; the next commit adds it. Tests: java_typed_calls_parallel (external receivers get no edge; a project receiver keeps its edge, also when its type is missing from a project package or shares its simple name with a java.lang type), parallel_java_external_target_contract. Signed-off-by: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> --- Makefile.cbm | 3 +- internal/cbm/lsp/java_lsp.c | 180 ++++++++++++++++++-- internal/cbm/lsp/java_lsp.h | 8 + src/pipeline/lsp_resolve.h | 12 ++ src/pipeline/pass_calls.c | 16 +- src/pipeline/pass_parallel.c | 9 +- tests/test_java_typed_calls.c | 300 ++++++++++++++++++++++++++++++++++ tests/test_main.c | 2 + tests/test_parallel.c | 31 ++++ 9 files changed, 540 insertions(+), 21 deletions(-) create mode 100644 tests/test_java_typed_calls.c diff --git a/Makefile.cbm b/Makefile.cbm index 3b93146f1f..84137eb6e1 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -846,7 +846,8 @@ TEST_PY_LSP_STRESS_SRCS = tests/test_py_lsp_stress.c TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c TEST_TS_LSP_SRCS = tests/test_ts_lsp.c -TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c +TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c \ + tests/test_java_typed_calls.c TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c diff --git a/internal/cbm/lsp/java_lsp.c b/internal/cbm/lsp/java_lsp.c index cf221f06e7..437dd82852 100644 --- a/internal/cbm/lsp/java_lsp.c +++ b/internal/cbm/lsp/java_lsp.c @@ -510,7 +510,19 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 3. java.lang auto-import. Try registered first, then the conventional QN. */ + /* 3. Java package prefix. A type of the own package shadows every + * on-demand import, java.lang's included (JLS 6.4.1). */ + if (ctx->package_name && ctx->package_name[0]) { + char buf[JAVA_LSP_BUF]; + int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name); + if (n > 0 && (size_t)n < sizeof(buf)) { + if (cbm_registry_lookup_type(ctx->registry, buf)) { + return cbm_arena_strdup(ctx->arena, buf); + } + } + } + + /* 4. java.lang auto-import. Try registered first, then the conventional QN. */ for (int i = 0; JAVA_LANG_TYPES[i]; i++) { if (strcmp(JAVA_LANG_TYPES[i], name) == 0) { char buf[JAVA_LSP_BUF]; @@ -521,7 +533,7 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 4. On-demand imports. */ + /* 5. On-demand imports. */ for (int i = 0; i < ctx->import_count; i++) { if (ctx->import_kinds[i] != CBM_JAVA_IMPORT_ON_DEMAND) continue; @@ -534,17 +546,6 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) { } } - /* 5. Java package prefix. */ - if (ctx->package_name && ctx->package_name[0]) { - char buf[JAVA_LSP_BUF]; - int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name); - if (n > 0 && (size_t)n < sizeof(buf)) { - if (cbm_registry_lookup_type(ctx->registry, buf)) { - return cbm_arena_strdup(ctx->arena, buf); - } - } - } - /* Exact already-qualified name. */ if (cbm_registry_lookup_type(ctx->registry, name)) { return cbm_arena_strdup(ctx->arena, name); @@ -969,6 +970,12 @@ const CBMRegisteredFunc *java_lookup_method(JavaLSPContext *ctx, const char *cla return fallback; } +/* A type declared in the project: the cross registrar and the own-file pass + * stamp namespace_qn on every project type; the JDK seed leaves it NULL. */ +static bool java_is_project_type(const CBMRegisteredType *rt) { + return rt && rt->namespace_qn && rt->namespace_qn[0]; +} + /* ── Method-invocation evaluation ─────────────────────────────────── */ static const CBMType *java_return_type_of(const CBMRegisteredFunc *f) { @@ -1797,11 +1804,135 @@ void java_lsp_process_file(JavaLSPContext *ctx, TSNode root) { /* ── Call-edge resolution ─────────────────────────────────────────── */ +/* True when `callee_qn` names a member (or a constructor) of a registered type + * the project does not declare — a JDK seed type. Such a target has no graph + * node, and the pipeline must not hand it to a same-named project method. */ +static bool java_callee_is_external(const JavaLSPContext *ctx, const char *callee_qn) { + if (!ctx->registry || !callee_qn) + return false; + const CBMRegisteredType *self = cbm_registry_lookup_type(ctx->registry, callee_qn); + if (self) + return !java_is_project_type(self); + const char *dot = strrchr(callee_qn, '.'); + if (!dot || dot == callee_qn) + return false; + char owner[JAVA_LSP_BUF]; + size_t n = (size_t)(dot - callee_qn); + if (n >= sizeof(owner)) + return false; + memcpy(owner, callee_qn, n); + owner[n] = '\0'; + const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, owner); + return rt && !java_is_project_type(rt); +} + +/* `T`, `ID`, `SELF`: a type-variable spelling — one or two characters, or + * capitals, digits and '_' only — that the file imports neither by name nor + * through its package (`import org.jooq.impl.*;` makes `DSL` a type). A type + * variable the resolver failed to bind can reach a receiver qualified by some + * package; it is unknown, not a library type. */ +static bool java_is_type_var_spelling(const JavaLSPContext *ctx, const char *pkg, size_t pkg_len, + const char *simple) { + size_t n = strlen(simple); + bool caps = n > 0; + for (size_t i = 0; i < n && caps; i++) { + caps = isupper((unsigned char)simple[i]) || isdigit((unsigned char)simple[i]) || + simple[i] == '_'; + } + if (!caps && n > 2) { + return false; + } + for (int i = 0; i < ctx->import_count; i++) { + const char *target = ctx->import_target_qns[i]; + if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_TYPE && ctx->import_local_names[i] && + strcmp(ctx->import_local_names[i], simple) == 0) { + return false; + } + if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_ON_DEMAND && target && + strlen(target) == pkg_len && strncmp(target, pkg, pkg_len) == 0) { + return false; + } + } + return true; +} + +/* Whether the shared registry holds no project type under `prefix` + * ("org.jooq."), memoised per package. */ +static bool java_package_is_external(JavaLSPContext *ctx, const CBMTypeRegistry *base, + const char *prefix) { + for (int i = 0; i < JAVA_EXT_MEMO; i++) { + if (ctx->ext_memo_pkg[i] && strcmp(ctx->ext_memo_pkg[i], prefix) == 0) { + return ctx->ext_memo_val[i]; + } + } + CBMArena scratch; + cbm_arena_init(&scratch); + CBMQNPrefixIter it; + bool project = false; + /* The prefix search reads one layer, and the layer above the shared + * registry is a per-file overlay that is often empty: walk the chain. */ + for (const CBMTypeRegistry *reg = base; reg && !project; reg = reg->fallback) { + if (!cbm_registry_types_with_qn_prefix(reg, prefix, true, &scratch, &it)) + continue; + for (int ti = cbm_qn_prefix_iter_next(&it); ti >= 0 && !project; + ti = cbm_qn_prefix_iter_next(&it)) { + project = java_is_project_type(®->types[ti]); + } + } + cbm_arena_destroy(&scratch); + int slot = ctx->ext_memo_next++ % JAVA_EXT_MEMO; + ctx->ext_memo_pkg[slot] = prefix; + ctx->ext_memo_val[slot] = !project; + return !project; +} + +/* A receiver type the registry does not know, spelled with a package the + * project declares no type in (`org.jooq.DSLContext` from an import): a + * library type. The package is the segments before the first capitalised + * one, so a nested type (`com.acme.Order.OrderBuilder`, registered flattened + * as `com.acme.OrderBuilder`) is judged by `com.acme`. Cross-file runs only — + * the shared registry is the one that knows every project package, and its + * type order makes the probe a binary search. A path-derived QN + * (project-prefixed) is an unresolved project spelling, never a library. */ +static bool java_receiver_is_external(JavaLSPContext *ctx, const char *recv_qn) { + const CBMTypeRegistry *base = ctx->registry ? ctx->registry->fallback : NULL; + if (!base || !recv_qn || !islower((unsigned char)recv_qn[0])) + return false; + const char *type = NULL; + for (const char *p = recv_qn; (p = strchr(p, '.')) != NULL; p++) { + if (isupper((unsigned char)p[1])) { + type = p + 1; + break; + } + } + if (!type) + return false; + size_t pkg_len = (size_t)(type - 1 - recv_qn); + const char *last = strrchr(recv_qn, '.'); + if (java_is_type_var_spelling(ctx, recv_qn, pkg_len, last + 1)) + return false; + /* The project segment; a file at the repository root has a module QN + * that is the project name alone. */ + const char *mod_dot = ctx->module_qn ? strchr(ctx->module_qn, '.') : NULL; + size_t mod_len = mod_dot ? (size_t)(mod_dot - ctx->module_qn) + : (ctx->module_qn ? strlen(ctx->module_qn) : 0); + if (mod_len && strncmp(recv_qn, ctx->module_qn, mod_len) == 0 && recv_qn[mod_len] == '.') + return false; + if (ctx->package_name && strlen(ctx->package_name) == pkg_len && + strncmp(ctx->package_name, recv_qn, pkg_len) == 0) + return false; + /* "org.jooq." */ + return java_package_is_external(ctx, base, cbm_arena_strndup(ctx->arena, recv_qn, pkg_len + 1)); +} + static void java_emit_resolved_orig_kind(JavaLSPContext *ctx, const char *callee_qn, const char *orig, const char *strategy, float confidence, CBMResolvedKind kind) { if (!ctx->resolved_calls || !ctx->enclosing_method_qn || !callee_qn) return; + if (kind == CBM_RESOLVED_INVOCATION && java_callee_is_external(ctx, callee_qn)) { + strategy = "lsp_external"; + } CBMResolvedCall rc = {0}; rc.caller_qn = ctx->enclosing_method_qn; rc.callee_qn = callee_qn; @@ -2145,6 +2276,15 @@ static void resolve_method_call(JavaLSPContext *ctx, TSNode call) { "lsp_interface_dispatch", 0.80f); return; } + /* A library type the registry does not hold: no method of it can be + * looked up, but no project method is its target either. A seeded + * JDK type with no such method stays a diagnostic below, as Rust's + * gate (#2053) needs a positively resolved std method. */ + if (!rt && java_receiver_is_external(ctx, recv_qn)) { + java_emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname), + "lsp_external", 0.90f); + return; + } java_emit_unresolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname), "no_method_match"); return; @@ -3214,6 +3354,9 @@ static void register_local_func_or_type_from_file(JavaLSPContext *ctx, CBMTypeRe stub.qualified_name = d->qualified_name; stub.short_name = d->name; stub.is_interface = (strcmp(d->label, "Interface") == 0); + stub.namespace_qn = (ctx->package_name && ctx->package_name[0]) ? ctx->package_name + : (module_qn && module_qn[0]) ? module_qn + : d->qualified_name; cbm_registry_add_type(reg, stub); } @@ -3740,6 +3883,11 @@ void cbm_java_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, const CBM rt.qualified_name = d->qualified_name; rt.short_name = d->short_name; rt.is_interface = (strcmp(d->label, "Interface") == 0) || d->is_interface; + /* Project marker (java_is_project_type) and the package that + * qualifies the type's own member signatures. */ + rt.namespace_qn = (d->namespace_name && d->namespace_name[0]) ? d->namespace_name + : (d->def_module_qn && d->def_module_qn[0]) ? d->def_module_qn + : d->qualified_name; /* Embedded types from "|"-separated text. */ if (d->embedded_types && d->embedded_types[0]) { int n = 1; @@ -3883,6 +4031,9 @@ CBMTypeRegistry *cbm_java_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, cbm_java_register_lsp_defs(arena, reg, jvm + type_count, total - type_count); } cbm_registry_finalize(reg); + /* java_receiver_is_external asks "does the project declare a type in + * this package?" per unresolved receiver: a binary search on this order. */ + cbm_registry_build_type_qn_order(reg, arena); reg->read_only = true; /* seal: shared Tier-2 registry is read-only during resolve */ return reg; } @@ -3958,6 +4109,9 @@ void cbm_run_java_lsp_cross_with_registry(CBMArena *arena, CBMFileResult *result } } +/* The per-file fallback when no shared cross registry was built. Its + * registry has no fallback layer, so java_receiver_is_external never fires + * here: library calls keep the textual registry fallback, as before. */ void cbm_run_java_lsp_cross(CBMArena *arena, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **import_names, const char **import_qns, int import_count, diff --git a/internal/cbm/lsp/java_lsp.h b/internal/cbm/lsp/java_lsp.h index ea304c64ba..18d0642358 100644 --- a/internal/cbm/lsp/java_lsp.h +++ b/internal/cbm/lsp/java_lsp.h @@ -35,6 +35,8 @@ #include "../cbm.h" #include "go_lsp.h" /* CBMLSPDef, CBMResolvedCallArray reused across languages */ +#define JAVA_EXT_MEMO 8 + /* Java `use`-style import kinds. Mirrors PHP's enum with Java semantics. */ enum { CBM_JAVA_IMPORT_TYPE = 0, /* import com.foo.Bar; — type import */ @@ -90,6 +92,12 @@ typedef struct { /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; + + /* java_receiver_is_external's answers for the packages probed so far + * (a few per file), so each costs one registry walk. */ + const char *ext_memo_pkg[JAVA_EXT_MEMO]; + bool ext_memo_val[JAVA_EXT_MEMO]; + int ext_memo_next; } JavaLSPContext; /* ── Initialization / configuration ───────────────────────────────── */ diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e266..10df40a803 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -635,6 +635,18 @@ static inline bool cbm_pipeline_kotlin_external_target(CBMLanguage lang, const c strncmp(callee_qn, "javax.", strlen("javax.")) == 0; } +/* Java: the LSP typed the receiver as a type OUTSIDE the project (a JDK or + * library class, `lsp_external`), so the callee is `java.util.Optional.map` + * and no project method. Without this gate the tail/unique-leaf node policy + * and then the textual registry bound such a call to whatever project method + * shares the leaf (`Optional.map` -> `Filters.map`). Such a call gets no + * CALLS edge, as #2053 does for Rust. Pure; unit-tested. */ +static inline bool cbm_pipeline_java_external_target(CBMLanguage lang, const CBMResolvedCall *lsp) { + return lang == CBM_LANG_JAVA && lsp && lsp->strategy && + strcmp(lsp->strategy, "lsp_external") == 0 && lsp->callee_qn && + strchr(lsp->callee_qn, '.') != NULL; +} + /* Rust (#2053): true when the Rust LSP positively resolved a call to a * registered symbol that lives OUTSIDE the project — a std/core/alloc seed * method (`root.join()` on `root: &Path` -> std.path.Path.join) or a seeded diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index f3570904db..e087b6073e 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -892,14 +892,20 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * Service/route classification below still runs. Mirrors pass_parallel.c. */ bool php_vendor_call = cbm_pipeline_php_vendor_call(php_vendor, result, call) || cbm_pipeline_lsp_external_receiver(lsp); + /* A Java call on a JDK/library type is never bound to a project method + * found by name (no tail match, no registry guess). MUST match + * pass_parallel.c. */ + bool java_external = cbm_pipeline_java_external_target(lang, lsp); if (lsp) { bool exact_external_target = call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); const cbm_gbuf_node_t *target_node = - exact_external_target ? cbm_pipeline_lsp_target_node_strict( - ctx->gbuf, ctx->project_name, lsp->callee_qn, allow_tail) - : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, - lsp->callee_qn, allow_tail); + java_external ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name, + lsp->callee_qn, false) + : exact_external_target ? cbm_pipeline_lsp_target_node_strict( + ctx->gbuf, ctx->project_name, lsp->callee_qn, allow_tail) + : cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name, + lsp->callee_qn, allow_tail); if (target_node && source_node->id != target_node->id) { cbm_resolution_t res = {0}; /* Use the gbuf node's QN so downstream edge props show the canonical @@ -988,7 +994,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM bool rust_external = lsp && cbm_pipeline_rust_external_target( lang, lsp->strategy, lsp->callee_qn, ctx->project_name); cbm_resolution_t res = {0}; - if (!rust_external && !php_vendor_call) { + if (!rust_external && !php_vendor_call && !java_external) { /* Python LEGB fallback first (MUST match pass_parallel.c). */ res = cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 73ce742ad9..fd8565c48d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3271,13 +3271,18 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB memory_order_relaxed); _rc_t0 = extract_now_ns(); const cbm_gbuf_node_t *lsp_target = NULL; + /* Mirrors pass_calls.c: a Java call on a JDK/library type is never + * bound to a project method found by name. */ + bool java_external = cbm_pipeline_java_external_target(lang, lsp); if (lsp) { /* Canonicalise to the gbuf node's QN so res.qualified_name matches * the gbuf even when the cross-file fallback had to prefix the * project name. */ bool exact_external_target = call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn); - lsp_target = exact_external_target + lsp_target = java_external ? cbm_pipeline_lsp_target_node_strict( + rc->main_gbuf, rc->project_name, lsp->callee_qn, false) + : exact_external_target ? cbm_pipeline_lsp_target_node_strict(rc->main_gbuf, rc->project_name, lsp->callee_qn, allow_tail) : cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name, @@ -3314,7 +3319,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool rust_external = lsp && cbm_pipeline_rust_external_target( lang, lsp->strategy, lsp->callee_qn, rc->project_name); if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution && - !rust_external && !php_vendor_call) { + !rust_external && !php_vendor_call && !java_external) { /* Python LEGB fallback first (MUST match pass_calls.c). */ res = cbm_registry_resolve_call_scoped(rc->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count, lang, diff --git a/tests/test_java_typed_calls.c b/tests/test_java_typed_calls.c new file mode 100644 index 0000000000..8f478a5ad9 --- /dev/null +++ b/tests/test_java_typed_calls.c @@ -0,0 +1,300 @@ +/* + * test_java_typed_calls.c — Java CALLS edges follow the receiver's type + * through the whole pipeline (definitions → cross-file LSP → calls pass). + * + * One small project is indexed with cbm_pipeline_run and the CALLS edges of + * InvoiceService are read back from the store. Each project method carries a + * same-named decoy in an unrelated class (Filters), so a name-only guess is + * visible as an edge to Filters. Covered: + * - JDK and library receivers (Stream, List, org.jooq.DSLContext), whose + * calls get no edge instead of a project method of the same name; + * - a project receiver, whose call keeps its edge, also when its type is + * spelled with its package; + * - a receiver whose type is missing from the graph but whose package is + * the project's (a generated class that was not indexed, spelled with + * its package): unknown, not external, so its call keeps the name + * fallback; + * - a project type in the caller's own package that shares its simple + * name with a java.lang type (Process), or such a type declared in the + * caller's own file (Record): the own package wins. + * The fixture is padded past MIN_FILES_FOR_PARALLEL: only the parallel path + * builds the Java cross registry that proves a receiver external. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "sqlite3.h" + +#include +#include +#include + +/* ── Fixture ─────────────────────────────────────────────────────── */ + +static const char *const JT_CUSTOMER = "package com.acme.model;\n\n" + "public class Customer {\n" + " public String getName() { return \"\"; }\n" + "}\n"; + +/* Every method here shares a name with a real target elsewhere. */ +static const char *const JT_FILTERS = "package com.acme.other;\n\n" + "public class Filters {\n" + " public Filters filter(Object p) { return this; }\n" + " public Filters map(Object f) { return this; }\n" + " public String getName() { return \"\"; }\n" + " public Object select(Object a) { return a; }\n" + " public Object build() { return null; }\n" + " public String getCity() { return \"\"; }\n" + " public int size() { return 0; }\n" + " public int start() { return 0; }\n" + " public Filters number(String n) { return this; }\n" + " public void setPaid(boolean p) {}\n" + " public Object getCustomer() { return null; }\n" + " public String label() { return \"\"; }\n" + "}\n"; + +/* Process and Record share their simple names with java.lang types: the own + * package and the file's own types win (JLS 6.4.1), so these are the types + * InvoiceService and Process.start mean. */ +static const char *const JT_PROCESS = "package com.acme.svc;\n\n" + "public class Process {\n" + " public Process(String name) {}\n" + " public int start() { return new Record().id(); }\n" + "}\n\n" + "class Record {\n" + " int id() { return 0; }\n" + "}\n"; + +static const char *const JT_SERVICE = + "package com.acme.svc;\n\n" + "import com.acme.model.Customer;\n" + "import java.util.List;\n" + "import java.util.stream.Collectors;\n" + "import org.jooq.DSLContext;\n\n" + "public class InvoiceService {\n" + " private final DSLContext dsl;\n\n" + " public InvoiceService(DSLContext dsl) {\n" + " this.dsl = dsl;\n" + " }\n\n" + " public List numbers(List names) {\n" + " return names.stream().filter(n -> n.isEmpty()).map(n -> n.trim())\n" + " .collect(Collectors.toList());\n" + " }\n\n" + " public int touch(List names) {\n" + " dsl.select(\"a\");\n" + " return names.size();\n" + " }\n\n" + " public String customerName(Customer c) {\n" + " return c.getName();\n" + " }\n\n" + " public int run() {\n" + " Process p = new Process(\"job\");\n" + " return p.start();\n" + " }\n\n" + " public String qualifiedName(com.acme.other.Filters f) {\n" + " return f.label();\n" + " }\n\n" + " public String label() {\n" + " return \"\";\n" + " }\n\n" + " public String branchCity(com.acme.model.Branch b) {\n" + " return b.getCity();\n" + " }\n" + "}\n"; + +static void jt_write_fixture(const char *tmp, int filler) { + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/model/Customer.java"), JT_CUSTOMER); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/other/Filters.java"), JT_FILTERS); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/InvoiceService.java"), JT_SERVICE); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/Process.java"), JT_PROCESS); + for (int i = 0; i < filler; i++) { + char rel[128]; + char body[256]; + snprintf(rel, sizeof(rel), "src/main/java/com/acme/fill/Fill%d.java", i); + snprintf(body, sizeof(body), + "package com.acme.fill;\n\npublic class Fill%d {\n" + " public int value%d() { return %d; }\n}\n", + i, i, i); + th_write_file(TH_PATH(tmp, rel), body); + } +} + +/* ── Store reads ─────────────────────────────────────────────────── */ + +typedef struct { + char tmp[256]; + char db_path[512]; + char project[512]; + sqlite3 *db; +} jt_index_t; + +static int jt_index(jt_index_t *ix, int filler) { + memset(ix, 0, sizeof(*ix)); + snprintf(ix->tmp, sizeof(ix->tmp), "/tmp/cbm_jtyped_XXXXXX"); + if (!cbm_mkdtemp(ix->tmp)) { + return -1; + } + jt_write_fixture(ix->tmp, filler); + snprintf(ix->db_path, sizeof(ix->db_path), "%s/jt.db", ix->tmp); + cbm_pipeline_t *p = cbm_pipeline_new(ix->tmp, ix->db_path, CBM_MODE_FULL); + if (!p) { + return -1; + } + int rc = cbm_pipeline_run(p); + snprintf(ix->project, sizeof(ix->project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + if (rc != 0 || sqlite3_open_v2(ix->db_path, &ix->db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + return -1; + } + return 0; +} + +static void jt_close(jt_index_t *ix) { + if (ix->db) { + sqlite3_close(ix->db); + } + th_cleanup(ix->tmp); +} + +/* Targets of the CALLS edges from method `caller` whose textual callee ends + * in `.leaf` or is `leaf`, as "|" lines. */ +static int jt_targets(jt_index_t *ix, const char *caller, const char *leaf, char *out, + size_t out_len) { + out[0] = '\0'; + const char *sql = "SELECT t.qualified_name, json_extract(e.properties,'$.strategy'), " + "json_extract(e.properties,'$.callee') FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.name = ?1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC); + int n = 0; + size_t plen = strlen(ix->project); + size_t llen = strlen(leaf); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *qn = (const char *)sqlite3_column_text(st, 0); + const char *strategy = (const char *)sqlite3_column_text(st, 1); + const char *callee = (const char *)sqlite3_column_text(st, 2); + if (!qn || !callee) { + continue; + } + size_t clen = strlen(callee); + bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) || + (clen > llen && callee[clen - llen - 1] == '.' && + strcmp(callee + clen - llen, leaf) == 0); + if (!leaf_match) { + continue; + } + if (strncmp(qn, ix->project, plen) == 0 && qn[plen] == '.') { + qn += plen + 1; + } + size_t used = strlen(out); + snprintf(out + used, out_len - used, "%s|%s\n", qn, strategy ? strategy : ""); + n++; + } + sqlite3_finalize(st); + return n; +} + +/* Exactly one CALLS edge for (caller, leaf), and its target QN ends in + * `want_suffix`. Prints what the graph has on failure. */ +static int jt_expect(jt_index_t *ix, const char *caller, const char *leaf, + const char *want_suffix) { + char got[4096]; + int n = jt_targets(ix, caller, leaf, got, sizeof(got)); + if (n == 1) { + const char *bar = strchr(got, '|'); + size_t qlen = bar ? (size_t)(bar - got) : strlen(got); + size_t wlen = strlen(want_suffix); + if (qlen >= wlen && strncmp(got + qlen - wlen, want_suffix, wlen) == 0) { + return 1; + } + } + printf("\n %s -> %s: want one edge to ...%s, have %d:\n%s", caller, leaf, want_suffix, n, got); + return 0; +} + +/* No CALLS edge for (caller, leaf) reaches a node whose QN contains `bad`. */ +static int jt_expect_none_to(jt_index_t *ix, const char *caller, const char *leaf, + const char *bad) { + char got[4096]; + (void)jt_targets(ix, caller, leaf, got, sizeof(got)); + if (!strstr(got, bad)) { + return 1; + } + printf("\n %s -> %s: an edge reaches %s:\n%s", caller, leaf, bad, got); + return 0; +} + +/* At least one CALLS edge for (caller, leaf). */ +static int jt_expect_some_edge(jt_index_t *ix, const char *caller, const char *leaf) { + char got[4096]; + if (jt_targets(ix, caller, leaf, got, sizeof(got)) > 0) { + return 1; + } + printf("\n %s -> %s: want an edge, have none\n", caller, leaf); + return 0; +} + +/* No CALLS edge at all for (caller, leaf). */ +static int jt_expect_no_edge(jt_index_t *ix, const char *caller, const char *leaf) { + char got[4096]; + int n = jt_targets(ix, caller, leaf, got, sizeof(got)); + if (n == 0) { + return 1; + } + printf("\n %s -> %s: want no edge, have %d:\n%s", caller, leaf, n, got); + return 0; +} + +/* ── Tests ───────────────────────────────────────────────────────── */ + +static int jt_check_external(jt_index_t *ix) { + int ok = 1; + /* JDK and library receivers never bind a project method by name... */ + ok &= jt_expect_none_to(ix, "numbers", "filter", "Filters"); + ok &= jt_expect_none_to(ix, "numbers", "map", "Filters"); + ok &= jt_expect_none_to(ix, "touch", "select", "Filters"); + ok &= jt_expect_none_to(ix, "touch", "size", "Filters"); + /* ...and get no edge: the graph holds no node for a library method. */ + ok &= jt_expect_no_edge(ix, "numbers", "filter"); + ok &= jt_expect_no_edge(ix, "touch", "select"); + ok &= jt_expect_no_edge(ix, "touch", "size"); + /* A project receiver keeps its edge. */ + ok &= jt_expect(ix, "customerName", "getName", "com.acme.model.Customer.getName"); + /* A project type spelled with its package and not imported binds by its + * type, where the name guess picks the caller's own label(). */ + ok &= jt_expect(ix, "qualifiedName", "label", "com.acme.other.Filters.label"); + /* An unknown type in a project package is not proven external. */ + ok &= jt_expect_some_edge(ix, "branchCity", "getCity"); + /* A project type named like a java.lang one, in the caller's package. */ + /* `new` on a project type binds its class node, as for any project type. */ + ok &= jt_expect(ix, "run", "Process", "com.acme.svc.Process"); + ok &= jt_expect(ix, "run", "start", "com.acme.svc.Process.start"); + ok &= jt_expect(ix, "start", "id", "Record.id"); + return ok; +} + +static int jt_run_all(int filler) { + jt_index_t ix; + if (jt_index(&ix, filler) != 0) { + printf("\n indexing the fixture failed\n"); + jt_close(&ix); + return 0; + } + int ok = jt_check_external(&ix); + jt_close(&ix); + return ok; +} + +TEST(java_typed_calls_parallel) { + ASSERT_TRUE(jt_run_all(60)); + PASS(); +} + +SUITE(java_typed_calls) { + RUN_TEST(java_typed_calls_parallel); +} diff --git a/tests/test_main.c b/tests/test_main.c index e0526cf858..e461ed45b1 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1811,6 +1811,7 @@ extern void suite_py_lsp_scale(void); extern void suite_ts_lsp(void); extern void suite_java_lsp(void); extern void suite_java_lsp_coverage(void); +extern void suite_java_typed_calls(void); extern void suite_kotlin_lsp(void); extern void suite_kotlin_typed_calls(void); extern void suite_rust_lsp(void); @@ -2403,6 +2404,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(ts_lsp); RUN_SELECTED_SUITE(java_lsp); RUN_SELECTED_SUITE(java_lsp_coverage); + RUN_SELECTED_SUITE(java_typed_calls); /* Architecture + ADR + Louvain */ RUN_SELECTED_SUITE(store_arch); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index a641e7e0e5..7206f72c13 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3730,6 +3730,36 @@ TEST(parallel_rust_external_target_contract) { PASS(); } +/* Contract for cbm_pipeline_java_external_target: only a Java row the + * resolver marked lsp_external, naming a qualified member, is external. The + * end-to-end probes live in test_java_typed_calls.c. */ +TEST(parallel_java_external_target_contract) { + CBMResolvedCall rc = {0}; + rc.callee_qn = "org.jooq.DSLContext.select"; + rc.strategy = "lsp_external"; + ASSERT_TRUE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* Every other strategy keeps the registry fallback. */ + rc.strategy = "lsp_type_dispatch"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + rc.strategy = "lsp_unresolved"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* A bare name is no member of any type. */ + rc.strategy = "lsp_external"; + rc.callee_qn = "select"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + /* Per-language: no other language is affected. */ + rc.callee_qn = "org.jooq.DSLContext.select"; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_KOTLIN, &rc)); + /* Defensive NULL inputs. */ + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, NULL)); + rc.callee_qn = NULL; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + rc.callee_qn = "org.jooq.DSLContext.select"; + rc.strategy = NULL; + ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc)); + PASS(); +} + /* Gate guard for the JVM-only unique-tail fallbacks (lsp_resolve.h). * * The tail fallbacks join LSP overrides across QN drift by unique @@ -5587,6 +5617,7 @@ SUITE(parallel) { RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges); RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges); RUN_TEST(parallel_rust_external_target_contract); + RUN_TEST(parallel_java_external_target_contract); RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm); RUN_TEST(parallel_calls_parity); RUN_TEST(parallel_defines_parity); From 8e9caf941adfffe8791a2dfee1640420f159ff20 Mon Sep 17 00:00:00 2001 From: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:06:43 +0200 Subject: [PATCH 2/4] feat(java): build the Java cross registry on the sequential path pass_lsp_cross.c built the shared Java registry only on the parallel path (more than 50 files). On the sequential path every Java file was resolved against its own definitions alone, so a call on a type from another file fell back to the name guess, and the external-receiver gate of the previous commit never fired. This is a behaviour change for every repository of 50 files or fewer: calls whose receiver type is declared in another file now resolve through the LSP, as they do on the parallel path, and calls on JDK and library types lose their guessed edge. It also fixes the package-qualified parameter case measured in #2382. Tests: java_typed_calls_sequential (the parallel fixture on the sequential path, plus a parameter typed with its package-qualified name), java_typed_calls_paths_agree (both paths produce the same edges). Signed-off-by: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> --- src/pipeline/pass_lsp_cross.c | 1 + tests/test_java_typed_calls.c | 58 +++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5aa9551123..e69c07c8fb 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2592,6 +2592,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * cross_registries.c = cbm_c_build_cross_registry(xa, all_defs, def_count); cross_registries.cs = cbm_cs_build_cross_registry(xa, all_defs, def_count); cross_registries.ts = cbm_ts_build_cross_registry(xa, all_defs, def_count); + cross_registries.java = cbm_java_build_cross_registry(xa, all_defs, def_count); } int processed = 0; diff --git a/tests/test_java_typed_calls.c b/tests/test_java_typed_calls.c index 8f478a5ad9..1fc5d6076e 100644 --- a/tests/test_java_typed_calls.c +++ b/tests/test_java_typed_calls.c @@ -17,8 +17,8 @@ * - a project type in the caller's own package that shares its simple * name with a java.lang type (Process), or such a type declared in the * caller's own file (Record): the own package wins. - * The fixture is padded past MIN_FILES_FOR_PARALLEL: only the parallel path - * builds the Java cross registry that proves a receiver external. + * The same fixture runs on the sequential path and, padded past + * MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree. */ #include "../src/foundation/compat.h" #include "test_framework.h" @@ -250,6 +250,33 @@ static int jt_expect_no_edge(jt_index_t *ix, const char *caller, const char *lea return 0; } +/* The CALLS edges out of the svc package, one sorted line each, with the + * project prefix dropped so two indexes compare. The Java resolver's + * strategies are kept; the textual registry's are one label, as its names + * already differ between the two paths before this resolver runs (a + * constructor is import_map on one and unique_name on the other). */ +static char *jt_edge_set(jt_index_t *ix) { + const char *sql = + "SELECT group_concat(x, char(10)) FROM (SELECT s.name || '|' || " + "coalesce(json_extract(e.properties,'$.callee'),'') || '|' || " + "substr(t.qualified_name, length(?1) + 2) || '|' || " + "CASE WHEN json_extract(e.properties,'$.strategy') LIKE 'lsp%' " + "THEN json_extract(e.properties,'$.strategy') ELSE 'registry' END AS x FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.file_path LIKE '%/svc/%' ORDER BY x)"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return NULL; + } + sqlite3_bind_text(st, 1, ix->project, -1, SQLITE_STATIC); + char *out = NULL; + if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0)) { + out = strdup((const char *)sqlite3_column_text(st, 0)); + } + sqlite3_finalize(st); + return out; +} + /* ── Tests ───────────────────────────────────────────────────────── */ static int jt_check_external(jt_index_t *ix) { @@ -290,11 +317,38 @@ static int jt_run_all(int filler) { return ok; } +TEST(java_typed_calls_sequential) { + ASSERT_TRUE(jt_run_all(0)); + PASS(); +} + TEST(java_typed_calls_parallel) { ASSERT_TRUE(jt_run_all(60)); PASS(); } +/* The sequential and the parallel path produce the same svc edges. */ +TEST(java_typed_calls_paths_agree) { + jt_index_t seq; + jt_index_t par; + int seq_rc = jt_index(&seq, 0); + int par_rc = jt_index(&par, 60); + char *a = seq_rc == 0 ? jt_edge_set(&seq) : NULL; + char *b = par_rc == 0 ? jt_edge_set(&par) : NULL; + int same = a && b && strcmp(a, b) == 0; + if (!same) { + printf("\n sequential:\n%s\n parallel:\n%s\n", a ? a : "(none)", b ? b : "(none)"); + } + free(a); + free(b); + jt_close(&seq); + jt_close(&par); + ASSERT_TRUE(same); + PASS(); +} + SUITE(java_typed_calls) { + RUN_TEST(java_typed_calls_sequential); RUN_TEST(java_typed_calls_parallel); + RUN_TEST(java_typed_calls_paths_agree); } From 7e0f021bf35b9d808ba43435de3984bf7b8c205d Mon Sep 17 00:00:00 2001 From: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:25 +0200 Subject: [PATCH 3/4] test(java): compare the two paths on edges found by type On Windows the parallel path binds `c.getName()` (Customer comes from an import) to Filters.getName: the textual registry's name guess picks another same-named method there. This PR does not change that guess, so the test no longer pins its target: - on the parallel path, customerName only has to keep an edge; the sequential path still checks Customer.getName; - java_typed_calls_paths_agree compares only the edges the Java resolver found by type (lsp_*), not the registry's. Signed-off-by: Timur Nizametdinov <7229951+tfactor2@users.noreply.github.com> --- tests/test_java_typed_calls.c | 43 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/test_java_typed_calls.c b/tests/test_java_typed_calls.c index 1fc5d6076e..0849f3acd3 100644 --- a/tests/test_java_typed_calls.c +++ b/tests/test_java_typed_calls.c @@ -18,7 +18,9 @@ * name with a java.lang type (Process), or such a type declared in the * caller's own file (Record): the own package wins. * The same fixture runs on the sequential path and, padded past - * MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree. + * MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree on every edge + * found by type. A name guess is left out of that comparison: on Windows the + * parallel path can pick another same-named method for it. */ #include "../src/foundation/compat.h" #include "test_framework.h" @@ -250,20 +252,20 @@ static int jt_expect_no_edge(jt_index_t *ix, const char *caller, const char *lea return 0; } -/* The CALLS edges out of the svc package, one sorted line each, with the - * project prefix dropped so two indexes compare. The Java resolver's - * strategies are kept; the textual registry's are one label, as its names - * already differ between the two paths before this resolver runs (a - * constructor is import_map on one and unique_name on the other). */ +/* The CALLS edges out of the svc package that the Java resolver found by + * type, one sorted line each, with the project prefix dropped so two indexes + * compare. Edges of the textual registry are left out: its strategy names + * already differ between the two paths (a constructor is import_map on one + * and unique_name on the other), and so can its pick among same-named + * methods. */ static char *jt_edge_set(jt_index_t *ix) { - const char *sql = - "SELECT group_concat(x, char(10)) FROM (SELECT s.name || '|' || " - "coalesce(json_extract(e.properties,'$.callee'),'') || '|' || " - "substr(t.qualified_name, length(?1) + 2) || '|' || " - "CASE WHEN json_extract(e.properties,'$.strategy') LIKE 'lsp%' " - "THEN json_extract(e.properties,'$.strategy') ELSE 'registry' END AS x FROM edges e " - "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " - "WHERE e.type = 'CALLS' AND s.file_path LIKE '%/svc/%' ORDER BY x)"; + const char *sql = "SELECT group_concat(x, char(10)) FROM (SELECT s.name || '|' || " + "coalesce(json_extract(e.properties,'$.callee'),'') || '|' || " + "substr(t.qualified_name, length(?1) + 2) || '|' || " + "json_extract(e.properties,'$.strategy') AS x FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.file_path LIKE '%/svc/%' " + "AND json_extract(e.properties,'$.strategy') LIKE 'lsp%' ORDER BY x)"; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { return NULL; @@ -279,7 +281,7 @@ static char *jt_edge_set(jt_index_t *ix) { /* ── Tests ───────────────────────────────────────────────────────── */ -static int jt_check_external(jt_index_t *ix) { +static int jt_check_external(jt_index_t *ix, int sequential) { int ok = 1; /* JDK and library receivers never bind a project method by name... */ ok &= jt_expect_none_to(ix, "numbers", "filter", "Filters"); @@ -290,8 +292,11 @@ static int jt_check_external(jt_index_t *ix) { ok &= jt_expect_no_edge(ix, "numbers", "filter"); ok &= jt_expect_no_edge(ix, "touch", "select"); ok &= jt_expect_no_edge(ix, "touch", "size"); - /* A project receiver keeps its edge. */ - ok &= jt_expect(ix, "customerName", "getName", "com.acme.model.Customer.getName"); + /* A project receiver keeps its edge. Its type comes from an import, which + * only the name guess reads here, as on main; on Windows the parallel + * path's guess can pick Filters.getName. */ + ok &= sequential ? jt_expect(ix, "customerName", "getName", "com.acme.model.Customer.getName") + : jt_expect_some_edge(ix, "customerName", "getName"); /* A project type spelled with its package and not imported binds by its * type, where the name guess picks the caller's own label(). */ ok &= jt_expect(ix, "qualifiedName", "label", "com.acme.other.Filters.label"); @@ -312,7 +317,7 @@ static int jt_run_all(int filler) { jt_close(&ix); return 0; } - int ok = jt_check_external(&ix); + int ok = jt_check_external(&ix, filler == 0); jt_close(&ix); return ok; } @@ -327,7 +332,7 @@ TEST(java_typed_calls_parallel) { PASS(); } -/* The sequential and the parallel path produce the same svc edges. */ +/* The sequential and the parallel path find the same svc edges by type. */ TEST(java_typed_calls_paths_agree) { jt_index_t seq; jt_index_t par; From 7fd658497715065b132c9e54fe1e16bd4fd8bdff Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 03:15:43 +0100 Subject: [PATCH 4/4] test(java): pin the own-package Enum case from the review The review of this change named a literal case: a project class Enum in package com.acme.protogen, and new Enum(cls, "parent") in a sibling class of that package (Netflix conductor has this shape: protogen.Enum). java.lang is an on-demand import, and an on-demand import never shadows a type of the own package (JLS 6.4.1), so the call means the project class. java_typed_calls_own_package_enum_sequential and _parallel index that pair on both pipeline paths and assert one CALLS edge from addNestedEnum to the Class node com.acme.protogen.Enum, as new does for every project type here. RED with only the type-name order of this branch reverted (java.lang before the own package): Enum reads as java.lang.Enum, the call counts as external, and both new tests find no edge. GREEN with this branch. Signed-off-by: Martin Vogel --- tests/test_java_typed_calls.c | 94 +++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/tests/test_java_typed_calls.c b/tests/test_java_typed_calls.c index 0849f3acd3..93ad9fbd41 100644 --- a/tests/test_java_typed_calls.c +++ b/tests/test_java_typed_calls.c @@ -16,7 +16,9 @@ * fallback; * - a project type in the caller's own package that shares its simple * name with a java.lang type (Process), or such a type declared in the - * caller's own file (Record): the own package wins. + * caller's own file (Record): the own package wins; + * - the same rule on a project class named Enum (com.acme.protogen): + * `new Enum(..)` there binds that class node, not java.lang.Enum. * The same fixture runs on the sequential path and, padded past * MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree on every edge * found by type. A name guess is left out of that comparison: on Windows the @@ -105,11 +107,30 @@ static const char *const JT_SERVICE = " }\n" "}\n"; +/* JLS 6.4.1: java.lang is an on-demand import, and an on-demand import never + * shadows a type of the compilation unit's own package. So `Enum` in + * Message means com.acme.protogen.Enum (the shape of Netflix conductor's + * protogen.Enum). Read as java.lang.Enum, the call counts as external and + * loses its edge. */ +static const char *const JT_PROTO_ENUM = "package com.acme.protogen;\n\n" + "public class Enum {\n" + " public Enum(Class cls, String parent) {}\n" + "}\n"; + +static const char *const JT_PROTO_MESSAGE = "package com.acme.protogen;\n\n" + "public class Message {\n" + " public void addNestedEnum(Class cls) {\n" + " Enum e = new Enum(cls, \"parent\");\n" + " }\n" + "}\n"; + static void jt_write_fixture(const char *tmp, int filler) { th_write_file(TH_PATH(tmp, "src/main/java/com/acme/model/Customer.java"), JT_CUSTOMER); th_write_file(TH_PATH(tmp, "src/main/java/com/acme/other/Filters.java"), JT_FILTERS); th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/InvoiceService.java"), JT_SERVICE); th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/Process.java"), JT_PROCESS); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Enum.java"), JT_PROTO_ENUM); + th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Message.java"), JT_PROTO_MESSAGE); for (int i = 0; i < filler; i++) { char rel[128]; char body[256]; @@ -310,18 +331,73 @@ static int jt_check_external(jt_index_t *ix, int sequential) { return ok; } -static int jt_run_all(int filler) { +/* The one CALLS edge for (caller, leaf) reaches a node labelled `label`. */ +static int jt_expect_target_label(jt_index_t *ix, const char *caller, const char *leaf, + const char *label) { + const char *sql = "SELECT t.label, json_extract(e.properties,'$.callee') FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.type = 'CALLS' AND s.name = ?1"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC); + int hits = 0; + int others = 0; + size_t llen = strlen(leaf); + while (sqlite3_step(st) == SQLITE_ROW) { + const char *have = (const char *)sqlite3_column_text(st, 0); + const char *callee = (const char *)sqlite3_column_text(st, 1); + if (!have || !callee) { + continue; + } + size_t clen = strlen(callee); + bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) || + (clen > llen && callee[clen - llen - 1] == '.' && + strcmp(callee + clen - llen, leaf) == 0); + if (!leaf_match) { + continue; + } + if (strcmp(have, label) == 0) { + hits++; + } else { + others++; + } + } + sqlite3_finalize(st); + if (hits == 1 && others == 0) { + return 1; + } + printf("\n %s -> %s: want one edge to a %s node, have %d to one and %d to others\n", caller, + leaf, label, hits, others); + return 0; +} + +/* `new Enum(..)` in com.acme.protogen binds the project class Enum (its + * class node, as `new` does for every project type), not java.lang.Enum. */ +static int jt_check_own_package_enum(jt_index_t *ix) { + int ok = 1; + ok &= jt_expect(ix, "addNestedEnum", "Enum", "com.acme.protogen.Enum"); + ok &= jt_expect_target_label(ix, "addNestedEnum", "Enum", "Class"); + return ok; +} + +static int jt_run_check(int filler, int enum_only) { jt_index_t ix; if (jt_index(&ix, filler) != 0) { printf("\n indexing the fixture failed\n"); jt_close(&ix); return 0; } - int ok = jt_check_external(&ix, filler == 0); + int ok = enum_only ? jt_check_own_package_enum(&ix) : jt_check_external(&ix, filler == 0); jt_close(&ix); return ok; } +static int jt_run_all(int filler) { + return jt_run_check(filler, 0); +} + TEST(java_typed_calls_sequential) { ASSERT_TRUE(jt_run_all(0)); PASS(); @@ -332,6 +408,16 @@ TEST(java_typed_calls_parallel) { PASS(); } +TEST(java_typed_calls_own_package_enum_sequential) { + ASSERT_TRUE(jt_run_check(0, 1)); + PASS(); +} + +TEST(java_typed_calls_own_package_enum_parallel) { + ASSERT_TRUE(jt_run_check(60, 1)); + PASS(); +} + /* The sequential and the parallel path find the same svc edges by type. */ TEST(java_typed_calls_paths_agree) { jt_index_t seq; @@ -355,5 +441,7 @@ TEST(java_typed_calls_paths_agree) { SUITE(java_typed_calls) { RUN_TEST(java_typed_calls_sequential); RUN_TEST(java_typed_calls_parallel); + RUN_TEST(java_typed_calls_own_package_enum_sequential); + RUN_TEST(java_typed_calls_own_package_enum_parallel); RUN_TEST(java_typed_calls_paths_agree); }