@@ -5233,6 +5233,173 @@ TEST(parallel_php_vendor_types_never_bind_by_name_issue1186) {
52335233 PASS ();
52345234}
52355235
5236+ /* ── PHP LSP types receivers of imported project classes (#1186) ─── */
5237+
5238+ /* Foo.php calls methods on receivers typed with classes imported from OTHER
5239+ * project files: a promoted property (`$this->svc->go()`) and an aliased
5240+ * import (`R $r` -> `$r->find()`). The import map handed to the PHP LSP held
5241+ * the IMPORTS edge target -- the imported file's File node QN
5242+ * (`...Svc.php.__file__`) -- as the class, so no receiver ever typed and the
5243+ * calls fell to name-only strategies (the #1186 "useful calls at 0.38"). The
5244+ * decoy App\Other\Svc shares the short name and the method, so a name-only
5245+ * match cannot tell the two apart. Controls: the vendor-typed receiver
5246+ * (`Client $c`) must stay unresolved, and the same-file calls keep their LSP
5247+ * strategies. */
5248+ static const struct {
5249+ const char * rel ;
5250+ const char * src ;
5251+ } k_php_xfile_files [] = {
5252+ {"lib/Services/Svc.php" , "<?php\nnamespace App\\Services;\n\nclass Svc {\n"
5253+ " public function go() { return 1; }\n}\n" },
5254+ {"lib/Other/Svc.php" , "<?php\nnamespace App\\Other;\n\nclass Svc {\n"
5255+ " public function go() { return 2; }\n}\n" },
5256+ {"lib/Repo/Repo.php" , "<?php\nnamespace App\\Repo;\n\nclass Repo {\n"
5257+ " public function find() { return 3; }\n}\n" },
5258+ {"lib/Http/Foo.php" ,
5259+ "<?php\nnamespace App\\Http;\n\n"
5260+ "use App\\Services\\Svc;\nuse App\\Repo\\Repo as R;\nuse Vendor\\Pkg\\Client;\n\n"
5261+ "class Foo {\n"
5262+ " public function __construct(private Svc $svc) {}\n"
5263+ " public function run(R $r, Client $c) {\n"
5264+ " $this->svc->go();\n"
5265+ " $r->find();\n"
5266+ " $c->go();\n"
5267+ " $this->local();\n"
5268+ " (new Local())->ping();\n"
5269+ " }\n"
5270+ " private function local() { return 4; }\n}\n\n"
5271+ "class Local {\n"
5272+ " public function ping() { return 5; }\n}\n" },
5273+ };
5274+ enum { PHP_XFILE_FILE_COUNT = (int )(sizeof (k_php_xfile_files ) / sizeof (k_php_xfile_files [0 ])) };
5275+
5276+ typedef struct {
5277+ const cbm_gbuf_t * gbuf ;
5278+ const char * src_rel ;
5279+ const char * tgt_rel ;
5280+ const char * tgt_suffix ;
5281+ const char * strategy_prefix ;
5282+ int total ;
5283+ int with_strategy ;
5284+ } php_xfile_edge_count_t ;
5285+
5286+ static void php_xfile_count_edge (const cbm_gbuf_edge_t * edge , void * ud ) {
5287+ php_xfile_edge_count_t * c = ud ;
5288+ if (!edge -> type || strcmp (edge -> type , "CALLS" ) != 0 ) {
5289+ return ;
5290+ }
5291+ const cbm_gbuf_node_t * s = cbm_gbuf_find_by_id (c -> gbuf , edge -> source_id );
5292+ const cbm_gbuf_node_t * t = cbm_gbuf_find_by_id (c -> gbuf , edge -> target_id );
5293+ if (!s || !t || !s -> file_path || !t -> file_path || !t -> qualified_name ||
5294+ strcmp (s -> file_path , c -> src_rel ) != 0 || strcmp (t -> file_path , c -> tgt_rel ) != 0 ) {
5295+ return ;
5296+ }
5297+ size_t qlen = strlen (t -> qualified_name );
5298+ size_t slen = strlen (c -> tgt_suffix );
5299+ if (qlen < slen || strcmp (t -> qualified_name + qlen - slen , c -> tgt_suffix ) != 0 ) {
5300+ return ;
5301+ }
5302+ c -> total ++ ;
5303+ char needle [128 ];
5304+ snprintf (needle , sizeof (needle ), "\"strategy\":\"%s" , c -> strategy_prefix );
5305+ if (edge -> properties_json && strstr (edge -> properties_json , needle )) {
5306+ c -> with_strategy ++ ;
5307+ }
5308+ }
5309+
5310+ /* 1 when every cross-file receiver resolved through the PHP LSP, the decoy and
5311+ * the vendor receiver drew nothing, and the same-file controls held. */
5312+ static int php_xfile_graph_ok (const cbm_gbuf_t * gbuf , const char * driver ) {
5313+ static const char * const foo = "lib/Http/Foo.php" ;
5314+ struct {
5315+ const char * tgt ;
5316+ const char * suffix ;
5317+ int want_total ;
5318+ int want_lsp ;
5319+ } checks [] = {
5320+ {"lib/Services/Svc.php" , ".Svc.go" , 1 , 1 }, /* $this->svc->go(), not $c->go() */
5321+ {"lib/Other/Svc.php" , ".Svc.go" , 0 , 0 }, /* decoy: same short name + method */
5322+ {"lib/Repo/Repo.php" , ".Repo.find" , 1 , 1 }, /* aliased import */
5323+ {foo , ".Foo.local" , 1 , 1 }, /* same-file control */
5324+ {foo , ".Local.ping" , 1 , 1 }, /* same-file control */
5325+ };
5326+ int ok = 1 ;
5327+ for (size_t i = 0 ; i < sizeof (checks ) / sizeof (checks [0 ]); i ++ ) {
5328+ php_xfile_edge_count_t c = {gbuf , foo , checks [i ].tgt , checks [i ].suffix , "php_" , 0 , 0 };
5329+ cbm_gbuf_foreach_edge (gbuf , php_xfile_count_edge , & c );
5330+ if (c .total != checks [i ].want_total || c .with_strategy != checks [i ].want_lsp ) {
5331+ printf (" [%s] CALLS Foo.php -> %s%s: %d edge(s), %d via php_*; want %d, %d\n" , driver ,
5332+ checks [i ].tgt , checks [i ].suffix , c .total , c .with_strategy , checks [i ].want_total ,
5333+ checks [i ].want_lsp );
5334+ ok = 0 ;
5335+ }
5336+ }
5337+ return ok ;
5338+ }
5339+
5340+ /* Run the fixture on both venues. extra_use (nullable) is one more `use` line
5341+ * added to Foo.php. Returns 1 when both graphs pass, 0 when not, -1 on setup
5342+ * failure. */
5343+ static int php_xfile_run (const char * extra_use ) {
5344+ char tmpdir [256 ];
5345+ snprintf (tmpdir , sizeof (tmpdir ), "/tmp/cbm_par_phpxfile_XXXXXX" );
5346+ if (!cbm_mkdtemp (tmpdir )) {
5347+ return -1 ;
5348+ }
5349+ char paths [PHP_XFILE_FILE_COUNT ][512 ];
5350+ cbm_file_info_t files [PHP_XFILE_FILE_COUNT ];
5351+ memset (files , 0 , sizeof (files ));
5352+ static const char foo_use [] = "use Vendor\\Pkg\\Client;\n" ;
5353+ for (int i = 0 ; i < PHP_XFILE_FILE_COUNT ; i ++ ) {
5354+ snprintf (paths [i ], sizeof (paths [i ]), "%s/%s" , tmpdir , k_php_xfile_files [i ].rel );
5355+ char dir [512 ];
5356+ snprintf (dir , sizeof (dir ), "%s" , paths [i ]);
5357+ * strrchr (dir , '/' ) = '\0' ;
5358+ cbm_mkdir_p (dir , 0755 );
5359+ char src [2048 ];
5360+ const char * s = k_php_xfile_files [i ].src ;
5361+ const char * at = extra_use ? strstr (s , foo_use ) : NULL ;
5362+ if (at ) {
5363+ size_t head = (size_t )(at - s ) + sizeof (foo_use ) - 1 ;
5364+ snprintf (src , sizeof (src ), "%.*s%s%s" , (int )head , s , extra_use , s + head );
5365+ s = src ;
5366+ }
5367+ if (th_write_file (paths [i ], s ) != 0 ) {
5368+ th_rmtree (tmpdir );
5369+ return -1 ;
5370+ }
5371+ files [i ].path = paths [i ];
5372+ files [i ].rel_path = (char * )k_php_xfile_files [i ].rel ;
5373+ files [i ].language = CBM_LANG_PHP ;
5374+ }
5375+
5376+ cbm_gbuf_t * seq = run_sequential_with_lsp_cross_and_mutator (
5377+ "cbm_par_phpxfile" , tmpdir , files , PHP_XFILE_FILE_COUNT , NULL , NULL , true);
5378+ cbm_gbuf_t * par = run_parallel_with_extract_opts_and_mutator (
5379+ "cbm_par_phpxfile" , tmpdir , files , PHP_XFILE_FILE_COUNT , 2 , NULL , NULL , NULL , true);
5380+ int seq_ok = seq ? php_xfile_graph_ok (seq , "sequential" ) : 0 ;
5381+ int par_ok = par ? php_xfile_graph_ok (par , "parallel" ) : 0 ;
5382+ cbm_gbuf_free (seq );
5383+ cbm_gbuf_free (par );
5384+ th_rmtree (tmpdir );
5385+ return seq_ok && par_ok ;
5386+ }
5387+
5388+ TEST (parallel_php_lsp_types_imported_project_receivers_issue1186 ) {
5389+ ASSERT_EQ (php_xfile_run (NULL ), 1 );
5390+ PASS ();
5391+ }
5392+
5393+ /* The same file with one more import that no file in the project declares,
5394+ * the global class `use Exception;` written in most namespaced PHP files.
5395+ * The typed import evidence leaves such a file "unknown" as a whole (#2520),
5396+ * so the class-valued import mapping must not depend on that evidence. */
5397+ TEST (parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186 ) {
5398+ ASSERT_EQ (php_xfile_run ("use Exception;\n" ), 1 );
5399+ PASS ();
5400+ }
5401+
5402+
52365403/* A fresh PHP external row replaces stale evidence for its exact occurrence,
52375404 * including an older external row, without clearing a real project invocation
52385405 * of the same method at the next occurrence. Test both old-row orders. */
@@ -5346,6 +5513,8 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) {
53465513SUITE (parallel ) {
53475514 RUN_TEST (parallel_php_external_row_replaces_only_its_exact_occurrence );
53485515 RUN_TEST (parallel_php_vendor_types_never_bind_by_name_issue1186 );
5516+ RUN_TEST (parallel_php_lsp_types_imported_project_receivers_issue1186 );
5517+ RUN_TEST (parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186 );
53495518 RUN_TEST (usage_semantic_reference_candidate_trusts_marked_producer );
53505519 RUN_TEST (lsp_bare_segment_skips_preprocessor_spacing );
53515520 RUN_TEST (lsp_resolve_qualified_static_call_normalizes_colons );
0 commit comments