From b0dd5af4a60d46141ea843797769a08a38c8ddf6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 18:39:23 +0200 Subject: [PATCH] fix(pipeline): restrict PHP imports to matching project namespaces Filter non-PHP candidates before selection and distinguish an empty namespace map from missing namespace information. Refs #1186. Signed-off-by: Martin Vogel --- src/pipeline/pass_pkgmap.c | 268 ++++++++++++++++++++++++++------- tests/test_edge_imports.c | 299 +++++++++++++++++++++++++++++++++++-- 2 files changed, 499 insertions(+), 68 deletions(-) diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index f2a451f89..32b242b75 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -25,6 +25,7 @@ #include +#include #include #include #include @@ -1826,6 +1827,10 @@ static const char *path_leaf(const char *path) { return leaf; } +static bool node_in_php_file(const cbm_gbuf_node_t *n) { + return n && n->file_path && cbm_language_for_filename(path_leaf(n->file_path)) == CBM_LANG_PHP; +} + static bool is_header_include(const char *path) { if (!path || !path[0]) { return false; @@ -2014,7 +2019,7 @@ static const cbm_gbuf_node_t *resolve_header_include(const cbm_pipeline_ctx_t *c static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, - const char *module_path) { + const char *module_path, bool php_only) { if (!module_path || !module_path[0]) { return NULL; } @@ -2065,7 +2070,7 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx } const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qn); free(qn); - if (n && import_targetable_label(n->label) && + if (n && (!php_only || node_in_php_file(n)) && import_targetable_label(n->label) && (!source_file_qn || !n->qualified_name || strcmp(n->qualified_name, source_file_qn) != 0)) { found = n; @@ -2271,47 +2276,14 @@ static const cbm_gbuf_node_t *resolve_kustomize_entry(const cbm_pipeline_ctx_t * return found; } -const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, - const char *source_rel, - const char *source_file_qn, - const CBMImport *imp, - CBMHashTable *namespace_map) { - if (!ctx || !imp || !imp->module_path) { - return NULL; - } - - /* The importing file's language gates the strategies below. */ - const char *src_base = source_rel ? source_rel : ""; - for (const char *pb = src_base; *pb; pb++) { - if (*pb == '/' || *pb == '\\') { - src_base = pb + SKIP_ONE; - } - } - const CBMLanguage src_lang = cbm_language_for_filename(src_base); - if (src_lang == CBM_LANG_KUSTOMIZE) { - return resolve_kustomize_entry(ctx, source_rel, imp->module_path); - } - - /* Prefer exact header-file nodes for C/C++ includes so same-stem source or - * module nodes do not steal the edge target. */ - const cbm_gbuf_node_t *header_target = - resolve_header_include(ctx, source_rel, source_file_qn, imp->module_path); - if (header_target) { - return header_target; - } - - /* PHP class imports covered by a composer psr-4 prefix name exactly one - * file; when it is absent the import stays unresolved (#1186). */ - const cbm_gbuf_node_t *psr4_target = NULL; - switch (resolve_php_psr4_class(ctx->gbuf, source_file_qn, imp, &psr4_target)) { - case PSR4_RESOLVED: - return psr4_target; - case PSR4_UNRESOLVED: - return NULL; - case PSR4_NOT_APPLICABLE: - break; - } - +/* Language-generic strategies retain their ordering. PHP name imports with + * no namespace information (and global uses) restrict candidates before any + * winner is selected, so a non-PHP collision cannot hide a later PHP hit. + * src_lang is the importing file's language; it gates the name-guess + * fallbacks (#1934). */ +static const cbm_gbuf_node_t *resolve_import_strategies( + const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, + const CBMImport *imp, CBMHashTable *namespace_map, CBMLanguage src_lang, bool php_only) { /* Strategy 1: module-path resolution → existing node (Python/TS/Go). * No label filter here: directory-module languages (Go/Java packages) * legitimately resolve straight to a Folder node -- that's the intended, @@ -2322,7 +2294,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t char *target_qn = cbm_pipeline_resolve_module(ctx, source_rel, imp->module_path); const cbm_gbuf_node_t *target = target_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, target_qn) : NULL; free(target_qn); - if (target) { + if (target && (!php_only || node_in_php_file(target))) { /* Python/TS from-import of a member: module_path is often * "pkg.mod.symbol" while resolve_module lands on the Module node * "pkg.mod". Prefer the member Function/Class when it exists — @@ -2339,7 +2311,8 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t char member_qn[CBM_SZ_512]; snprintf(member_qn, sizeof(member_qn), "%s.%s", target->qualified_name, sym); const cbm_gbuf_node_t *member = cbm_gbuf_find_by_qn(ctx->gbuf, member_qn); - if (member && import_targetable_label(member->label) && + if (member && (!php_only || node_in_php_file(member)) && + import_targetable_label(member->label) && strcmp(member->label, "Module") != 0 && strcmp(member->label, "File") != 0) { return member; @@ -2362,7 +2335,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t * closer (`os/exec` → a same-package exec() method) — gated the same. */ if (symbol_fallback_allowed) { const cbm_gbuf_node_t *sib = - resolve_sibling_file(ctx, source_rel, source_file_qn, imp->module_path); + resolve_sibling_file(ctx, source_rel, source_file_qn, imp->module_path, php_only); if (sib) { return sib; } @@ -2421,7 +2394,8 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t memcpy(qbuf, seg, len); qbuf[len] = '\0'; const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qbuf); - if (n && (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0)) { + if (n && (!php_only || node_in_php_file(n)) && + (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0)) { return n; } } @@ -2522,7 +2496,8 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t const cbm_gbuf_node_t *best = NULL; for (int i = 0; i < n; i++) { const cbm_gbuf_node_t *cand = hits[i]; - if (!cand || !import_targetable_label(cand->label)) { + if (!cand || (php_only && !node_in_php_file(cand)) || + !import_targetable_label(cand->label)) { continue; } if (source_file_qn && cand->qualified_name && @@ -2596,7 +2571,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t char *rqn = cbm_pipeline_resolve_module(ctx, source_rel, work); const cbm_gbuf_node_t *n = rqn ? cbm_gbuf_find_by_qn(ctx->gbuf, rqn) : NULL; free(rqn); - if (n && import_targetable_label(n->label) && + if (n && (!php_only || node_in_php_file(n)) && import_targetable_label(n->label) && (!source_file_qn || !n->qualified_name || strcmp(n->qualified_name, source_file_qn) != 0)) { return n; @@ -2613,6 +2588,188 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t return NULL; } +/* ── PHP `use` of a namespaced name (#1186 follow-up) ─────────────── */ + +/* The shape of a PHP `use` import. The importing file is PHP and the path is + * a name: identifier segments, joined by '\' when qualified. require/include + * paths ("lib/x.php", 'a\b.php') contain '/', '.' or quotes: PHP_USE_NONE. */ +typedef enum { + PHP_USE_NONE = 0, /* not a PHP `use` (other language, require/include) */ + PHP_USE_GLOBAL, /* `use Closure;` names the global namespace */ + PHP_USE_NAMESPACED, /* `use A\B\C;` */ +} php_use_shape_t; + +enum { PHP_NON_ASCII_MIN = 0x80 }; /* PHP identifiers admit any byte >= 0x80 */ + +static php_use_shape_t php_use_shape(const char *source_rel, const CBMImport *imp) { + if (!source_rel || cbm_language_for_filename(path_leaf(source_rel)) != CBM_LANG_PHP) { + return PHP_USE_NONE; + } + const char *p = imp->module_path; + if (p[0] == '\\') { + p++; + } + if (!p[0]) { + return PHP_USE_NONE; + } + bool qualified = false; + for (; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '\\') { + qualified = true; + } else if (c != '_' && !isalnum(c) && c < PHP_NON_ASCII_MIN) { + return PHP_USE_NONE; + } + } + return qualified ? PHP_USE_NAMESPACED : PHP_USE_GLOBAL; +} + +/* The next usable file of a namespace-map bucket (a '\n'-delimited list of + * __file__ QNs): a PHP File node that is not the importing file. Advances + * *cursor past the entry. */ +static const cbm_gbuf_node_t *php_bucket_next(const cbm_pipeline_ctx_t *ctx, const char **cursor, + const char *source_file_qn) { + while (*cursor && **cursor) { + const char *seg = *cursor; + const char *eol = strchr(seg, '\n'); + size_t len = eol ? (size_t)(eol - seg) : strlen(seg); + *cursor = eol ? eol + SKIP_ONE : NULL; + char qbuf[CBM_SZ_1K]; + if (len == 0 || len >= sizeof(qbuf)) { + continue; + } + memcpy(qbuf, seg, len); + qbuf[len] = '\0'; + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qbuf); + if (n && n->label && strcmp(n->label, "File") == 0 && node_in_php_file(n) && + (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0)) { + return n; + } + } + return NULL; +} + +/* The file of `bucket` that defines a symbol named `leaf`, or NULL. Exact: + * the definition must live in that very file (same file_path). */ +static const cbm_gbuf_node_t *php_bucket_declaring(const cbm_pipeline_ctx_t *ctx, + const char *bucket, const char *source_file_qn, + const char *leaf) { + const cbm_gbuf_node_t **hits = NULL; + int hit_count = 0; + if (!bucket || cbm_gbuf_find_by_name(ctx->gbuf, leaf, &hits, &hit_count) != 0 || !hits) { + return NULL; + } + const char *cursor = bucket; + for (const cbm_gbuf_node_t *f = php_bucket_next(ctx, &cursor, source_file_qn); f; + f = php_bucket_next(ctx, &cursor, source_file_qn)) { + for (int i = 0; i < hit_count; i++) { + const cbm_gbuf_node_t *h = hits[i]; + if (h && h->label && strcmp(h->label, "File") != 0 && h->file_path && f->file_path && + strcmp(h->file_path, f->file_path) == 0) { + return f; + } + } + } + return NULL; +} + +/* Resolve a PHP namespaced `use` that no psr-4 prefix covers, through the + * namespaces project files DECLARE. `use A\B\C` names C in namespace A\B (or + * the namespace A\B\C itself, as an alias): the target is the A\B file that + * defines C, else a file declaring namespace A\B\C, else the first A\B file. + * A namespace no project PHP file declares (Illuminate\, Spatie\, ...) is + * outside the project: the import stays unresolved instead of binding by name + * to a same-named project symbol, possibly in another language. */ +static const cbm_gbuf_node_t *resolve_php_declared_use(const cbm_pipeline_ctx_t *ctx, + const char *source_file_qn, + const CBMImport *imp, + CBMHashTable *namespace_map) { + const char *name = imp->module_path[0] == '\\' ? imp->module_path + SKIP_ONE : imp->module_path; + char key[CBM_SZ_1K]; + if (strlen(name) >= sizeof(key)) { + return NULL; + } + snprintf(key, sizeof(key), "%s", name); + for (char *p = key; *p; p++) { + if (*p == '\\') { + *p = '.'; + } + } + const char *full_bucket = (const char *)cbm_ht_get(namespace_map, key); + char *dot = strrchr(key, '.'); + *dot = '\0'; /* PHP_USE_NAMESPACED guarantees a separator */ + const char *parent_bucket = (const char *)cbm_ht_get(namespace_map, key); + const char *leaf = dot + SKIP_ONE; + + const cbm_gbuf_node_t *hit = php_bucket_declaring(ctx, parent_bucket, source_file_qn, leaf); + if (!hit) { + const char *cursor = full_bucket; + hit = php_bucket_next(ctx, &cursor, source_file_qn); + } + if (!hit) { + const char *cursor = parent_bucket; + hit = php_bucket_next(ctx, &cursor, source_file_qn); + } + return hit; +} + +const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, + const char *source_rel, + const char *source_file_qn, + const CBMImport *imp, + CBMHashTable *namespace_map) { + if (!ctx || !imp || !imp->module_path) { + return NULL; + } + + /* The importing file's language gates the strategies below. */ + const char *src_base = source_rel ? source_rel : ""; + for (const char *pb = src_base; *pb; pb++) { + if (*pb == '/' || *pb == '\\') { + src_base = pb + SKIP_ONE; + } + } + const CBMLanguage src_lang = cbm_language_for_filename(src_base); + if (src_lang == CBM_LANG_KUSTOMIZE) { + return resolve_kustomize_entry(ctx, source_rel, imp->module_path); + } + + /* Prefer exact header-file nodes for C/C++ includes so same-stem source or + * module nodes do not steal the edge target. */ + const cbm_gbuf_node_t *header_target = + resolve_header_include(ctx, source_rel, source_file_qn, imp->module_path); + if (header_target) { + return header_target; + } + + /* PHP class imports covered by a composer psr-4 prefix name exactly one + * file; when it is absent the import stays unresolved (#1186). */ + const cbm_gbuf_node_t *psr4_target = NULL; + switch (resolve_php_psr4_class(ctx->gbuf, source_file_qn, imp, &psr4_target)) { + case PSR4_RESOLVED: + return psr4_target; + case PSR4_UNRESOLVED: + return NULL; + case PSR4_NOT_APPLICABLE: + break; + } + + const php_use_shape_t php_use = php_use_shape(source_rel, imp); + if (php_use == PHP_USE_NONE) { + return resolve_import_strategies(ctx, source_rel, source_file_qn, imp, namespace_map, + src_lang, false); + } + /* Any other PHP namespaced `use` resolves only through declared namespaces. + * A global-namespace `use Closure;` and the CALLS import map (which passes + * no namespace map) keep the generic strategies, but a PHP `use` never + * binds to a non-PHP file. */ + if (php_use == PHP_USE_NAMESPACED && namespace_map) { + return resolve_php_declared_use(ctx, source_file_qn, imp, namespace_map); + } + return resolve_import_strategies(ctx, source_rel, source_file_qn, imp, namespace_map, src_lang, + true); +} + /* ── Namespace map ───────────────────────────────────────────────── */ /* The namespace names themselves, so a caller that has parked some results on @@ -2624,18 +2781,17 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, const char *const *namespaces, const char *const *rels, int count) { - CBMHashTable *map = NULL; + /* NULL means no namespace information (e.g. the CALLS import map), while + * an empty map means no project file declares a namespace. */ + CBMHashTable *map = cbm_ht_create(CBM_SZ_64); + if (!map) { + return NULL; + } for (int i = 0; i < count; i++) { const char *namespace_name = namespaces[i]; if (!namespace_name || !namespace_name[0] || !rels[i]) { continue; } - if (!map) { - map = cbm_ht_create(CBM_SZ_64); - if (!map) { - return NULL; - } - } char *file_qn = cbm_pipeline_fqn_compute(project_name, rels[i], "__file__"); if (!file_qn) { continue; diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 685cad0fb..807767713 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -50,6 +50,7 @@ #include #include #include +#include #include #include @@ -1427,18 +1428,32 @@ typedef struct { const char *want_path; /* expected target file_path; NULL = no edge */ } EIPhpImportExpect; -/* Find the IMPORTS edge of `importer` whose local_name is `local` and write - * its target's file_path into `out` ("" when there is no such edge). */ -static void ei_import_target_path(cbm_store_t *store, const char *project, const char *importer, - const char *local, char *out, size_t outsz) { +/* Return the number of matching IMPORTS edges, or -1 on a setup/query error. + * A failed query or missing importer must never look like a valid no-edge result. */ +static int ei_import_target_path(cbm_store_t *store, const char *project, const char *importer, + const char *local, char *out, size_t outsz) { out[0] = '\0'; - int64_t src_id = ei_node_id_for_file_label(store, project, importer, "File"); + cbm_node_t *sources = NULL; + int source_count = 0; + if (cbm_store_find_nodes_by_file(store, project, importer, &sources, &source_count) != + CBM_STORE_OK) { + return -1; + } + int64_t src_id = 0; + for (int i = 0; i < source_count; i++) { + if (sources[i].label && strcmp(sources[i].label, "File") == 0) { + src_id = sources[i].id; + break; + } + } + cbm_store_free_nodes(sources, source_count); cbm_edge_t *edges = NULL; int n = 0; if (src_id <= 0 || cbm_store_find_edges_by_source_type(store, src_id, "IMPORTS", &edges, &n) != CBM_STORE_OK) { - return; + return -1; } + int matches = 0; char needle[256]; snprintf(needle, sizeof(needle), "\"local_name\":\"%s\"", local); for (int i = 0; i < n; i++) { @@ -1447,36 +1462,65 @@ static void ei_import_target_path(cbm_store_t *store, const char *project, const } cbm_node_t target; memset(&target, 0, sizeof(target)); - if (cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK) { - snprintf(out, outsz, "%s", target.file_path ? target.file_path : "?"); - cbm_node_free_fields(&target); + if (cbm_store_find_node_by_id(store, edges[i].target_id, &target) != CBM_STORE_OK) { + matches = -1; + break; } - break; + snprintf(out, outsz, "%s", target.file_path ? target.file_path : "?"); + cbm_node_free_fields(&target); + matches++; } cbm_store_free_edges(edges, n); + return matches; } /* Index `files` and check every expectation for `importer`. Returns 1 when * all hold; prints each mismatch so a RED names the fabricated target. */ static int ei_php_imports_match(const EILangFile *files, int nfiles, const char *importer, const EIPhpImportExpect *want, int nwant) { + const char *source = NULL; + for (int i = 0; i < nfiles; i++) { + if (strcmp(files[i].name, importer) == 0) { + source = files[i].content; + break; + } + } + CBMFileResult *extracted = source ? cbm_extract_file(source, (int)strlen(source), CBM_LANG_PHP, + "php_import_test", importer, 0, NULL, NULL) + : NULL; + if (!extracted) { + return 0; + } EILangProj lp; cbm_store_t *store = ei_index_files(&lp, files, nfiles); if (!store) { + cbm_free_result(extracted); ei_cleanup(&lp, store); return 0; } int ok = 1; for (int i = 0; i < nwant; i++) { + int import_count = 0; + for (int j = 0; j < extracted->imports.count; j++) { + const CBMImport *imp = &extracted->imports.items[j]; + if (imp->local_name && strcmp(imp->local_name, want[i].local_name) == 0 && + imp->module_path && imp->module_path[0] && !imp->is_default) { + import_count++; + } + } char got[512]; - ei_import_target_path(store, lp.project, importer, want[i].local_name, got, sizeof(got)); + int edge_count = ei_import_target_path(store, lp.project, importer, want[i].local_name, got, + sizeof(got)); const char *expect = want[i].want_path ? want[i].want_path : ""; - if (strcmp(got, expect) != 0) { + if (import_count != 1 || edge_count != (want[i].want_path ? 1 : 0) || + strcmp(got, expect) != 0) { fprintf(stderr, " [IMPORTS %s] %s -> got \"%s\", want \"%s\"\n", importer, want[i].local_name, got, expect); + fprintf(stderr, " extracted=%d, matching edges=%d\n", import_count, edge_count); ok = 0; } } + cbm_free_result(extracted); ei_cleanup(&lp, store); return ok; } @@ -1599,6 +1643,228 @@ TEST(ei_php_psr4_missing_class_file_unresolved_issue1186) { PASS(); } +/* ═══════════════════════════════════════════════════════════════════════════ + * PHP vendor / unmapped namespaces — #1186 follow-up + * + * A `use` of a namespace that no composer psr-4 prefix covers and no project + * file declares (Illuminate\, Spatie\, ...) names a class outside the project. + * The name-guess fallbacks bound it to any same-named project symbol, even in + * another language (`use Illuminate\Http\JsonResponse` -> a TypeScript class + * Http). Such an import must produce no edge. Namespaces that project files + * do declare keep resolving, to the file that declares the imported class. + * ═══════════════════════════════════════════════════════════════════════════ */ + +/* A vendor class import whose short name matches a project class binds to + * nothing; the project's own PSR-4 import of that class still resolves. */ +TEST(ei_php_vendor_use_same_name_no_edge_issue1186) { + static const EILangFile f[] = { + {"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"}, + {"app/Enums/Acl/Role.php", " a TS class Http) nor through the class name + * (`Request` -> a TS class Request). Nor does a global-namespace `use Closure;` + * (-> a TS type Closure). */ +TEST(ei_php_vendor_use_cross_language_no_edge_issue1186) { + static const EILangFile f[] = { + {"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\"}}}\n"}, + {"resources/js/services/http.ts", "export class Http {\n get(url: string) {}\n}\n"}, + {"resources/js/services/request.ts", "export class Request {\n send() {}\n}\n"}, + {"resources/js/types.d.ts", "type Closure = () => void\n"}, + {"app/Http/Controllers/SongController.php", + "imports.count; i++) { + const CBMImport *imp = &r->imports.items[i]; + if (!imp->local_name || !imp->module_path || imp->is_default) { + continue; + } + if ((strcmp(imp->local_name, "fmt") == 0 && + strcmp(imp->module_path, "Lib\\Util\\render") == 0 && + imp->kind == CBM_IMPORT_KIND_FUNCTION) || + (strcmp(imp->local_name, "enc") == 0 && + strcmp(imp->module_path, "Lib\\Util\\encode") == 0 && + imp->kind == CBM_IMPORT_KIND_FUNCTION) || + (strcmp(imp->local_name, "H") == 0 && + strcmp(imp->module_path, "Lib\\Util\\Helper") == 0 && + imp->kind == CBM_IMPORT_KIND_DEFAULT)) { + kinds_ok++; + } + } + cbm_free_result(r); + ASSERT_EQ(kinds_ok, 3); + ASSERT_TRUE(ei_php_imports_match(f, 7, "main.php", want, 5)); + PASS(); +} + +/* Exercise the NULL namespace-map contract used by CALLS directly. Try both + * a direct module-path collision and an earlier-sorting symbol-name collision. */ +static int ei_php_null_map_candidate(bool direct_collision) { + cbm_gbuf_t *gbuf = cbm_gbuf_new("phpimports", "/tmp"); + if (!gbuf) { + return 0; + } + int64_t decoy = cbm_gbuf_upsert_node( + gbuf, "Function", "render", + direct_collision ? "phpimports.Lib.render" : "phpimports.a.render", "a.ts", 1, 1, "{}"); + int64_t valid = cbm_gbuf_upsert_node(gbuf, "Function", "render", "phpimports.z.render", "z.php", + 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.project_name = "phpimports", .gbuf = gbuf}; + CBMImport imp = { + .local_name = "fmt", .module_path = "Lib\\render", .kind = CBM_IMPORT_KIND_FUNCTION}; + CBMHashTable *saved_pkgmap = cbm_pipeline_get_pkgmap(); + cbm_pipeline_set_pkgmap(NULL); + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "main.php", "phpimports.main.__file__", &imp, NULL); + int ok = decoy > 0 && valid > 0 && target && target->id == valid; + cbm_pipeline_set_pkgmap(saved_pkgmap); + cbm_gbuf_free(gbuf); + return ok; +} + +TEST(ei_php_null_namespace_map_keeps_php_candidate_issue1186) { + ASSERT_TRUE(ei_php_null_map_candidate(false)); + ASSERT_TRUE(ei_php_null_map_candidate(true)); + PASS(); +} + +/* A require/include path is not a namespace use. Even with an empty namespace + * map it retains generic path resolution to the named file. */ +TEST(ei_php_require_path_keeps_generic_resolution_issue1186) { + cbm_gbuf_t *gbuf = cbm_gbuf_new("phpimports", "/tmp"); + ASSERT_NOT_NULL(gbuf); + int64_t valid = cbm_gbuf_upsert_node(gbuf, "File", "helper.php", "phpimports.lib.helper", + "lib/helper.php", 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.project_name = "phpimports", .gbuf = gbuf}; + CBMImport imp = {.local_name = "helper", .module_path = "lib/helper.php"}; + CBMHashTable *map = cbm_pipeline_namespace_map_build_names("phpimports", NULL, NULL, 0); + CBMHashTable *saved_pkgmap = cbm_pipeline_get_pkgmap(); + cbm_pipeline_set_pkgmap(NULL); + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "main.php", "phpimports.main.__file__", &imp, map); + int ok = map && valid > 0 && target && target->id == valid; + cbm_pipeline_set_pkgmap(saved_pkgmap); + cbm_pipeline_namespace_map_free(map); + cbm_gbuf_free(gbuf); + ASSERT_TRUE(ok); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * SUITE registration * ═══════════════════════════════════════════════════════════════════════════ */ @@ -1688,4 +1954,13 @@ SUITE(edge_imports) { RUN_TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186); RUN_TEST(ei_php_psr4_use_function_not_class_mapped_issue1186); RUN_TEST(ei_php_psr4_missing_class_file_unresolved_issue1186); + RUN_TEST(ei_php_vendor_use_same_name_no_edge_issue1186); + RUN_TEST(ei_php_vendor_use_cross_language_no_edge_issue1186); + RUN_TEST(ei_php_undeclared_namespace_no_composer_no_edge_issue1186); + RUN_TEST(ei_php_declared_namespace_per_class_file_issue1186); + RUN_TEST(ei_php_vendor_use_empty_namespace_map_issue1186); + RUN_TEST(ei_php_global_use_keeps_php_candidate_issue1186); + RUN_TEST(ei_php_declared_functions_and_alias_issue1186); + RUN_TEST(ei_php_null_namespace_map_keeps_php_candidate_issue1186); + RUN_TEST(ei_php_require_path_keeps_generic_resolution_issue1186); }