diff --git a/Makefile.cbm b/Makefile.cbm index b4b8c8827..f4d85d03d 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -845,7 +845,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 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 cf221f06e..430611123 100644 --- a/internal/cbm/lsp/java_lsp.c +++ b/internal/cbm/lsp/java_lsp.c @@ -969,6 +969,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 +1803,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 +2275,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 +3353,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 +3882,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 +4030,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 +4108,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 ea304c64b..18d064235 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 d67918735..c98bab3a2 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -580,6 +580,19 @@ 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 1eb3bddff..f82fdc576 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -870,14 +870,21 @@ 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 @@ -966,7 +973,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) { res = cbm_registry_resolve_call_lang(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count, lang); /* Cross-language veto: a name-only guess never binds another diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index cf94926c0..00856973d 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2020,6 +2020,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/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 2c0a9023a..1d2dff142 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3223,13 +3223,19 @@ 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, @@ -3266,7 +3272,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) { res = cbm_registry_resolve_call_lang(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 000000000..5996584cb --- /dev/null +++ b/tests/test_java_typed_calls.c @@ -0,0 +1,319 @@ +/* + * 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; + * - 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. + * 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" +#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 Filters number(String n) { return this; }\n" + " public void setPaid(boolean p) {}\n" + " public Object getCustomer() { return null; }\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 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); + 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; +} + +/* 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) { + 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"); + /* An unknown type in a project package is not proven external. */ + ok &= jt_expect_some_edge(ix, "branchCity", "getCity"); + 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_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); +} diff --git a/tests/test_main.c b/tests/test_main.c index 5ff6bdb9f..e0394f209 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_rust_lsp(void); extern void suite_store_arch(void); @@ -2312,6 +2313,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 a8c0618df..10e101db7 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3645,6 +3645,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 @@ -5332,6 +5362,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);