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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-12 20:20:14 +02:00
parent 2cd0956560
commit b0dd5af4a6
2 changed files with 499 additions and 68 deletions
+212 -56
View File
@@ -25,6 +25,7 @@
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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;
+287 -12
View File
@@ -50,6 +50,7 @@
#include <mcp/mcp.h>
#include <store/store.h>
#include <pipeline/pipeline.h>
#include <pipeline/pipeline_internal.h>
#include <foundation/log.h>
#include <string.h>
@@ -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", "<?php\nnamespace App\\Enums\\Acl;\n\nenum Role: string {\n"
" case ADMIN = 'admin';\n}\n"},
{"app/Models/Permission.php", EI_PHP_CLASS("App\\Models", "Permission")},
{"app/Models/User.php", "<?php\nnamespace App\\Models;\n\n"
"use Spatie\\Permission\\Models\\Role;\n"
"use Spatie\\Permission\\Models\\Permission as SpatiePermission;\n"
"use App\\Enums\\Acl\\Role as AclRole;\n\n"
"class User {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Role", NULL},
{"SpatiePermission", NULL},
{"AclRole", "app/Enums/Acl/Role.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 4, "app/Models/User.php", want, 3));
PASS();
}
/* A vendor class import never binds to a non-PHP file: neither through a
* namespace segment (`Http` -> 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",
"<?php\nnamespace App\\Http\\Controllers;\n\n"
"use Closure;\nuse Illuminate\\Http\\JsonResponse;\nuse Illuminate\\Http\\Request;\n\n"
"class SongController {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"JsonResponse", NULL},
{"Request", NULL},
{"Closure", NULL},
};
ASSERT_TRUE(ei_php_imports_match(f, 5, "app/Http/Controllers/SongController.php", want, 3));
PASS();
}
/* Without composer.json the rule is the same: a namespace no project file
* declares binds to nothing, while a declared one still resolves. */
TEST(ei_php_undeclared_namespace_no_composer_no_edge_issue1186) {
static const EILangFile f[] = {
{"lib/Util/Helper.php", EI_PHP_CLASS("Lib\\Util", "Helper")},
{"main.php", "<?php\nnamespace Main;\n\n"
"use Vendor\\Pkg\\Helper as VendorHelper;\nuse Lib\\Util\\Helper;\n\n"
"class Main {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"VendorHelper", NULL},
{"Helper", "lib/Util/Helper.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 2, "main.php", want, 2));
PASS();
}
/* Control (no composer.json): a namespace declared by several project files
* resolves each class import to the file that declares that class, not to the
* first file of the namespace. */
TEST(ei_php_declared_namespace_per_class_file_issue1186) {
static const EILangFile f[] = {
{"lib/Util/Aaa.php", EI_PHP_CLASS("Lib\\Util", "Aaa")},
{"lib/Util/Helper.php", EI_PHP_CLASS("Lib\\Util", "Helper")},
{"lib/Util/Zed.php", EI_PHP_CLASS("Lib\\Util", "Zed")},
{"main.php", "<?php\nnamespace Main;\n\n"
"use Lib\\Util\\Helper;\nuse Lib\\Util\\Zed as Z;\nuse Lib\\Util\\Aaa;\n\n"
"class Main {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"Helper", "lib/Util/Helper.php"},
{"Z", "lib/Util/Zed.php"},
{"Aaa", "lib/Util/Aaa.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 4, "main.php", want, 3));
PASS();
}
/* No namespace declarations anywhere: an empty map must still block vendor
* name guessing, while an imported global project class remains valid. */
TEST(ei_php_vendor_use_empty_namespace_map_issue1186) {
static const EILangFile f[] = {{"Helper.php", "<?php\nclass Helper {\n}\n"},
{"Legacy.php", "<?php\nclass LocalHelper {\n}\n"},
{"main.php",
"<?php\nuse Vendor\\Pkg\\Helper;\nuse LocalHelper as Local;\n"
"function run() {}\n"}};
static const EIPhpImportExpect want[] = {
{"Helper", NULL},
{"Local", "Legacy.php"},
};
ASSERT_TRUE(ei_php_imports_match(f, 3, "main.php", want, 2));
PASS();
}
/* Filtering one generic winner would discard Helper when a.ts sorts first.
* Filtering candidates must instead preserve the valid global PHP class. */
TEST(ei_php_global_use_keeps_php_candidate_issue1186) {
static const EILangFile f[] = {
{"a.ts", "export class Helper {\n}\n"},
{"z.php", "<?php\nclass Helper {\n}\n"},
{"main.php", "<?php\nnamespace Client;\nuse Helper;\nclass Main {\n}\n"}};
static const EIPhpImportExpect want[] = {{"Helper", "z.php"}};
ASSERT_TRUE(ei_php_imports_match(f, 3, "main.php", want, 1));
PASS();
}
/* Unmapped project namespaces retain exact member-file targets, mixed grouped
* use kinds, aliases, fully qualified names, and imports of a namespace itself. */
TEST(ei_php_declared_functions_and_alias_issue1186) {
static const EILangFile f[] = {
{"lib/Util/Aaa.php", EI_PHP_CLASS("Lib\\Util", "Aaa")},
{"lib/Util/render.php", "<?php\nnamespace Lib\\Util;\nfunction render() {}\n"},
{"lib/Util/encode.php", "<?php\nnamespace Lib\\Util;\nfunction encode() {}\n"},
{"lib/Util/Helper.php", EI_PHP_CLASS("Lib\\Util", "Helper")},
{"lib/Extra/Extra.php", EI_PHP_CLASS("Lib\\Extra", "Extra")},
{"lib/Other/Other.php", EI_PHP_CLASS("Lib\\Other", "Other")},
{"main.php",
"<?php\nnamespace Main;\n"
"use Lib\\Util\\{function render as fmt, function encode as enc, Helper as H};\n"
"use Lib\\Extra as Tools;\nuse \\Lib\\Other\\Other as FullyQualified;\n"
"class Main {\n}\n"}};
static const EIPhpImportExpect want[] = {
{"fmt", "lib/Util/render.php"},
{"enc", "lib/Util/encode.php"},
{"H", "lib/Util/Helper.php"},
{"Tools", "lib/Extra/Extra.php"},
{"FullyQualified", "lib/Other/Other.php"},
};
CBMFileResult *r = cbm_extract_file(f[6].content, (int)strlen(f[6].content), CBM_LANG_PHP,
"php_import_test", "main.php", 0, NULL, NULL);
ASSERT_NOT_NULL(r);
int kinds_ok = 0;
for (int i = 0; i < r->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);
}