diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 647b3f801..419f29e26 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -414,6 +414,43 @@ static inline bool cbm_pipeline_invocation_leaf_matches(const CBMResolvedCall *r call_leaf && strcmp(cbm_lsp_bare_segment(resolved->reason), call_leaf) == 0; } +/* #606: a sub-floor `php_method_typed_unindexed` row still proves the receiver + * is not a project class, so a weak short-name fallback must not bind it. A + * project class with the same simple name keeps the fallback, since the PHP + * LSP does not follow every cross-file type. */ +static inline bool cbm_pipeline_php_receiver_type_unindexed(const CBMResolvedCallArray *arr, + const CBMCall *call, + const cbm_registry_t *registry) { + if (!arr || !call || !call->enclosing_func_qn || !call->callee_name) { + return false; + } + for (int i = 0; i < arr->count; i++) { + const CBMResolvedCall *rc = &arr->items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !rc->strategy || strcmp(rc->strategy, "php_method_typed_unindexed") != 0 || + strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) { + continue; + } + int site_rank = cbm_pipeline_invocation_site_rank(rc, call); + if (site_rank == 0 || !cbm_pipeline_invocation_leaf_matches(rc, call, site_rank)) { + continue; + } + const char *method_dot = strrchr(rc->callee_qn, '.'); + if (!method_dot || method_dot == rc->callee_qn) { + continue; + } + char class_qn[CBM_SZ_512]; + size_t class_len = (size_t)(method_dot - rc->callee_qn); + if (class_len >= sizeof(class_qn)) { + continue; + } + memcpy(class_qn, rc->callee_qn, class_len); + class_qn[class_len] = '\0'; + return !cbm_registry_has_type_named(registry, cbm_lsp_bare_segment(class_qn)); + } + return false; +} + /* Look up the highest-confidence LSP-resolved call entry whose caller QN * matches the textual call's enclosing function and whose callee QN * short-name matches the textual callee. Returns a pointer into `arr` diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index fbbdabbe7..a4b930cb2 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1150,7 +1150,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM * paths are module chains) — MUST match pass_parallel.c exactly. */ (lang == CBM_LANG_PYTHON && cbm_suppress_weak_import_bound_call(true, true, res.strategy) && cbm_python_import_binding_contradicts(imports, call->callee_name, res.qualified_name, - ctx->gbuf, ctx->project_name, rel)); + ctx->gbuf, ctx->project_name, rel)) || + /* MUST match pass_parallel.c exactly. */ + (lang == CBM_LANG_PHP && cbm_suppress_weak_member_match(true, true, res.strategy) && + cbm_pipeline_php_receiver_type_unindexed(lsp_calls, call, ctx->registry)); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 89777fd26..3d4ec5d16 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3409,7 +3409,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_suppress_weak_import_bound_call(true, true, res.strategy) && cbm_python_import_binding_contradicts(&result->imports, call->callee_name, res.qualified_name, rc->main_gbuf, - rc->project_name, rel)); + rc->project_name, rel)) || + /* MUST match pass_calls.c exactly. */ + (lang == CBM_LANG_PHP && cbm_suppress_weak_member_match(true, true, res.strategy) && + cbm_pipeline_php_receiver_type_unindexed(&result->resolved_calls, call, rc->registry)); /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the * service signal lives in the callee_name. The registry can mis-resolve diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 072674d8d..bfc80b08e 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -496,6 +496,8 @@ const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn); int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out, int *count); +bool cbm_registry_has_type_named(const cbm_registry_t *r, const char *name); + /* Return total number of entries. */ int cbm_registry_size(const cbm_registry_t *r); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 3269b9676..b06ccb887 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1467,6 +1467,21 @@ const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *me return only; } +bool cbm_registry_has_type_named(const cbm_registry_t *r, const char *name) { + if (!r || !name || !name[0]) { + return false; + } + const char **qns = NULL; + int count = 0; + cbm_registry_find_by_name(r, name, &qns, &count); + for (int i = 0; i < count; i++) { + if (cbm_label_is_type_like(cbm_registry_label_of(r, qns[i]))) { + return true; + } + } + return false; +} + int cbm_registry_size(const cbm_registry_t *r) { return r ? (int)cbm_ht_count(r->exact) : 0; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2b80f51bd..e88ae355c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8644,6 +8644,163 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +/* #606: a receiver typed as an unindexed (vendor/) class must not weakly bind a + * same-named project method. */ +static void write_php_receiver_fixture(const char *tmp) { + write_temp_file(tmp, "src/Item.php", + "getUid();\n" + " }\n" + "\n" + " public function selfCall(): int\n" + " {\n" + " return $this->localHelper();\n" + " }\n" + "\n" + " private function localHelper(): int\n" + " {\n" + " return 1;\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/ChainListener.php", + "findFile()->getUid();\n" + " }\n" + "\n" + " public function projectCall(Repository $repository): Item\n" + " {\n" + " return $repository->findItem();\n" + " }\n" + "}\n"); +} + +/* Fewer than 50 files exercises pass_calls.c. */ +TEST(pipeline_php_receiver_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_php_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_php_receiver_fixture(tmp); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/php_recv.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); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_GTE(fixture_node_count(s, project, "src/Item.php", "getUid", "Method"), 1); + ASSERT_FALSE(cross_file_call_exists(s, project, "vendorCall", "getUid")); + ASSERT_FALSE(cross_file_call_exists(s, project, "chainedVendorCall", "getUid")); + ASSERT_TRUE(cross_file_call_exists(s, project, "projectCall", "findItem")); + ASSERT_GTE(named_edge_count(s, project, "CALLS", "selfCall", "localHelper"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* >= 50 files forces pass_parallel.c. */ +TEST(pipeline_php_receiver_parallel_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_php_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_php_receiver_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/Filler%d.php", i); + snprintf(body, sizeof(body), "