diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 941a3ee56..189dd9f2d 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -635,6 +635,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (!target_node || source_node->id == target_node->id) { return 0; } + /* #725: suffix_match is language-agnostic and will attach a Python + * Store.commit() call to a JS function named commit (or a Bash main + * to a Python main). Drop that weak cross-language edge. */ + if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { + return 0; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, tsjs_drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a34b45eb9..890deab72 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2514,6 +2514,12 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } atomic_fetch_add_explicit(&rc->time_ns_rc_target, extract_now_ns() - _rc_t0, memory_order_relaxed); + if (target_node && source_node->id != target_node->id && + cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { + /* #725: same guard as pass_calls.c — do not emit a suffix_match + * CALLS edge across a language boundary. */ + 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 c01b07e02..5cde0c0f0 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -264,6 +264,14 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c * Pure; unit-tested in test_registry.c. */ bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy); +/* #725: drop a suffix_match CALLS edge when the caller language and the + * target file's language disagree. unique_name (candidates == 1) is #1572 + * and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX + * are one family so a .ts helper calling a .tsx function is not dropped. + * Pure; unit-tested in test_registry.c. */ +bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const char *target_file_path, + const char *strategy); + /* 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 cf1b3f008..dbb16d2d7 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -455,6 +455,49 @@ bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const cha strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0; } +static bool js_ts_family(CBMLanguage lang) { + return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX; +} + +static const char *path_basename(const char *path) { + if (!path || !path[0]) { + return path; + } + const char *slash = strrchr(path, '/'); +#ifdef _WIN32 + const char *bslash = strrchr(path, '\\'); + if (bslash && (!slash || bslash > slash)) { + slash = bslash; + } +#endif + return slash ? slash + 1 : path; +} + +bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const char *target_file_path, + const char *strategy) { + /* Two same-named symbols in different languages: suffix_match picks one + * winner by import-distance and attaches every bare-name call to it + * (#725, Bash/Python main, JS/Python commit). unique_name is the + * candidates==1 case (#1572) and is not this guard. */ + if (!strategy || strcmp(strategy, "suffix_match") != 0) { + return false; + } + if (caller_lang == CBM_LANG_COUNT || !target_file_path || !target_file_path[0]) { + return false; + } + CBMLanguage target_lang = cbm_language_for_filename(path_basename(target_file_path)); + if (target_lang == CBM_LANG_COUNT) { + return false; + } + if (caller_lang == target_lang) { + return false; + } + if (js_ts_family(caller_lang) && js_ts_family(target_lang)) { + return false; + } + return true; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 61d920430..4f9f0f361 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5893,6 +5893,91 @@ TEST(pipeline_python_cross_module_call) { PASS(); } +/* #725: two same-named symbols across languages must not share CALLS edges. + * Python Store.commit is the real callee of save(); the JS Editor.commit + * function is a distinct binding and must have no inbound CALLS from Python. + * unique_name (candidates==1) is #1572 and is not this claim. */ +TEST(pipeline_cross_language_same_name_does_not_share_calls_issue725) { + const char *files[] = {"store.py", "app.py", "web/src/pages/Editor.js"}; + const char *contents[] = { + "class Store:\n" + " def commit(self):\n" + " return True\n", + + "from store import Store\n" + "\n" + "def save():\n" + " return Store().commit()\n", + + "export function commit() {\n" + " return 1;\n" + "}\n"}; + + if (setup_lang_repo(files, contents, 3) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + cbm_node_t *commits = NULL; + int ncommit = 0; + cbm_store_find_nodes_by_name(s, proj, "commit", &commits, &ncommit); + ASSERT_GTE(ncommit, 2); + + int64_t js_id = 0; + int64_t py_id = 0; + for (int i = 0; i < ncommit; i++) { + if (commits[i].file_path && strstr(commits[i].file_path, "Editor.js")) + js_id = commits[i].id; + if (commits[i].file_path && strstr(commits[i].file_path, "store.py")) + py_id = commits[i].id; + } + ASSERT_TRUE(js_id != 0); + ASSERT_TRUE(py_id != 0); + + cbm_node_t *saves = NULL; + int nsave = 0; + cbm_store_find_nodes_by_name(s, proj, "save", &saves, &nsave); + ASSERT_GT(nsave, 0); + + cbm_edge_t *from_save = NULL; + int nfrom = 0; + cbm_store_find_edges_by_source_type(s, saves[0].id, "CALLS", &from_save, &nfrom); + bool save_calls_py = false; + bool save_calls_js = false; + for (int i = 0; i < nfrom; i++) { + if (from_save[i].target_id == py_id) + save_calls_py = true; + if (from_save[i].target_id == js_id) + save_calls_js = true; + } + ASSERT_TRUE(save_calls_py); + ASSERT_FALSE(save_calls_js); + + cbm_edge_t *into_js = NULL; + int njs = 0; + cbm_store_find_edges_by_target_type(s, js_id, "CALLS", &into_js, &njs); + ASSERT_EQ(njs, 0); + + if (from_save) + cbm_store_free_edges(from_save, nfrom); + if (into_js) + cbm_store_free_edges(into_js, njs); + cbm_store_free_nodes(commits, ncommit); + cbm_store_free_nodes(saves, nsave); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + TEST(pipeline_go_type_classification) { /* Port of TestGoTypeClassification */ const char *files[] = {"types.go"}; @@ -11668,6 +11753,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_cross_package_call); RUN_TEST(pipeline_swift_cross_package_import); RUN_TEST(pipeline_python_cross_module_call); + RUN_TEST(pipeline_cross_language_same_name_does_not_share_calls_issue725); RUN_TEST(pipeline_go_type_classification); RUN_TEST(pipeline_go_grouped_types); RUN_TEST(pipeline_kotlin_project); diff --git a/tests/test_registry.c b/tests/test_registry.c index 725a6f935..387bdc143 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -761,6 +761,34 @@ TEST(perl_suppress_keeps_high_confidence_and_genuine_calls) { PASS(); } +TEST(cross_language_suffix_match_drops_py_vs_js) { + /* #725: two same-named symbols in different languages. suffix_match is the + * strategy that collapses them; unique_name is #1572 and must stay. */ + ASSERT_TRUE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, "web/src/pages/Editor.js", + "suffix_match")); + ASSERT_TRUE(cbm_suppress_cross_language_suffix_match(CBM_LANG_JAVASCRIPT, "store.py", + "suffix_match")); + ASSERT_TRUE(cbm_suppress_cross_language_suffix_match(CBM_LANG_BASH, "cli/main.py", + "suffix_match")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, "store.py", + "suffix_match")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, "web/src/pages/Editor.js", + "unique_name")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, "web/src/pages/Editor.js", + "same_module")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, "web/src/pages/Editor.js", + "import_map")); + /* JS/TS/TSX are one family. */ + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_JAVASCRIPT, "lib/util.ts", + "suffix_match")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_TYPESCRIPT, "ui/Panel.tsx", + "suffix_match")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_PYTHON, NULL, "suffix_match")); + ASSERT_FALSE(cbm_suppress_cross_language_suffix_match(CBM_LANG_COUNT, "store.py", + "suffix_match")); + PASS(); +} + TEST(tsjs_suppress_drops_weak_method_matches) { /* #592/#606: a TS/JS member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -893,6 +921,7 @@ SUITE(registry) { RUN_TEST(perl_builtin_set_rejects_project_subs); RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches); RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); + RUN_TEST(cross_language_suffix_match_drops_py_vs_js); RUN_TEST(tsjs_suppress_drops_weak_method_matches); RUN_TEST(tsjs_suppress_keeps_high_confidence_and_non_methods); }