diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h
index fb5e446a5..12eb5572b 100644
--- a/internal/cbm/cbm.h
+++ b/internal/cbm/cbm.h
@@ -304,10 +304,20 @@ typedef struct {
// guard's unique-name exemption. Default false.
} CBMCall;
+// What an import statement names, when the syntax says so. Only PHP's
+// `use function` / `use const` set a non-default kind today: they name a
+// namespace MEMBER, so they must never be mapped to a class file (#1186).
+typedef enum {
+ CBM_IMPORT_KIND_DEFAULT = 0, // module / class / namespace (language default)
+ CBM_IMPORT_KIND_FUNCTION, // PHP `use function A\b`
+ CBM_IMPORT_KIND_CONST, // PHP `use const A\B`
+} CBMImportKind;
+
typedef struct {
const char *local_name; // local alias or name
const char *module_path; // resolved module path / QN
bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916)
+ CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind
} CBMImport;
typedef enum {
diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c
index 7d416923c..4aa0baa75 100644
--- a/internal/cbm/extract_imports.c
+++ b/internal/cbm/extract_imports.c
@@ -1205,7 +1205,29 @@ static void parse_wolfram_imports(CBMExtractCtx *ctx) {
// require/include forms remain `expression_statement`s and are still handled by
// the text fallback. Take the first qualified_name/name descendant of each
// clause as the module path.
-static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *group_prefix) {
+// `use function` / `use const` carry the keyword as an anonymous direct child
+// of the declaration (flat form) or of the clause (grouped form
+// `use A\{function f, const C, K}`). DEFAULT when neither is present.
+static CBMImportKind php_use_kind(TSNode node) {
+ uint32_t n = ts_node_child_count(node);
+ for (uint32_t i = 0; i < n; i++) {
+ TSNode c = ts_node_child(node, i);
+ if (ts_node_is_named(c)) {
+ continue;
+ }
+ const char *k = ts_node_type(c);
+ if (strcmp(k, "function") == 0) {
+ return CBM_IMPORT_KIND_FUNCTION;
+ }
+ if (strcmp(k, "const") == 0) {
+ return CBM_IMPORT_KIND_CONST;
+ }
+ }
+ return CBM_IMPORT_KIND_DEFAULT;
+}
+
+static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *group_prefix,
+ CBMImportKind decl_kind) {
CBMArena *a = ctx->arena;
// The path node is the qualified_name / namespace_name / name child.
TSNode path_node = clause;
@@ -1230,12 +1252,14 @@ static void emit_php_use_clause(CBMExtractCtx *ctx, TSNode clause, const char *g
TSNode alias = ts_node_child_by_field_name(clause, TS_FIELD("alias"));
const char *local =
!ts_node_is_null(alias) ? cbm_node_text(a, alias, ctx->source) : path_last(a, path);
- CBMImport imp = {.local_name = local, .module_path = path};
+ CBMImportKind kind = decl_kind != CBM_IMPORT_KIND_DEFAULT ? decl_kind : php_use_kind(clause);
+ CBMImport imp = {.local_name = local, .module_path = path, .kind = kind};
cbm_imports_push(&ctx->result->imports, a, imp);
}
static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) {
CBMArena *a = ctx->arena;
+ const CBMImportKind decl_kind = php_use_kind(decl);
// Grouped form: namespace_use_group with a leading prefix qualified_name.
TSNode group = decl;
if (find_first_descendant_of(decl, "namespace_use_group", &group)) {
@@ -1257,7 +1281,7 @@ static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) {
const char *ck = ts_node_type(clause);
if (strcmp(ck, "namespace_use_group_clause") == 0 ||
strcmp(ck, "namespace_use_clause") == 0) {
- emit_php_use_clause(ctx, clause, prefix);
+ emit_php_use_clause(ctx, clause, prefix, decl_kind);
}
}
return;
@@ -1268,13 +1292,13 @@ static void emit_php_use_decl(CBMExtractCtx *ctx, TSNode decl) {
for (uint32_t i = 0; i < dc; i++) {
TSNode clause = ts_node_named_child(decl, i);
if (strcmp(ts_node_type(clause), "namespace_use_clause") == 0) {
- emit_php_use_clause(ctx, clause, NULL);
+ emit_php_use_clause(ctx, clause, NULL, decl_kind);
any = true;
}
}
if (!any) {
// Some grammar versions inline the path directly under the declaration.
- emit_php_use_clause(ctx, decl, NULL);
+ emit_php_use_clause(ctx, decl, NULL, decl_kind);
}
}
diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c
index dc5e46db0..9c4ace14b 100644
--- a/src/pipeline/pass_pkgmap.c
+++ b/src/pipeline/pass_pkgmap.c
@@ -440,9 +440,12 @@ static void parse_pyproject_toml(const char *source, int source_len, const char
}
}
-/* Extract PSR-4 autoload entries from composer.json root. */
-static void extract_psr4(yyjson_val *root, const char *dir, cbm_pkg_entries_t *entries) {
- yyjson_val *autoload = yyjson_obj_get(root, "autoload");
+/* Extract PSR-4 entries from one composer.json autoload section ("autoload"
+ * or "autoload-dev": composer registers both, so test namespaces such as
+ * Tests\ map to their directory as well). */
+static void extract_psr4(yyjson_val *root, const char *section, const char *dir,
+ cbm_pkg_entries_t *entries) {
+ yyjson_val *autoload = yyjson_obj_get(root, section);
if (!yyjson_is_obj(autoload)) {
return;
}
@@ -459,6 +462,12 @@ static void extract_psr4(yyjson_val *root, const char *dir, cbm_pkg_entries_t *e
}
const char *ns_prefix = yyjson_get_str(key);
const char *ns_dir = yyjson_get_str(val);
+ while (ns_dir[0] == '.' && ns_dir[SKIP_ONE] == '/') {
+ ns_dir += PAIR_LEN; /* "./src/" names src/ */
+ }
+ if (strcmp(ns_dir, ".") == 0) {
+ ns_dir = ""; /* "." names the manifest's own directory */
+ }
char ns_entry[PKGMAP_PATH_BUF];
if (dir[0]) {
snprintf(ns_entry, sizeof(ns_entry), "%s/%s", dir, ns_dir);
@@ -497,7 +506,8 @@ static void parse_composer_json(const char *source, int source_len, const char *
}
}
- extract_psr4(root, dir, entries);
+ extract_psr4(root, "autoload", dir, entries);
+ extract_psr4(root, "autoload-dev", dir, entries);
free(dir);
yyjson_doc_free(doc);
@@ -987,6 +997,12 @@ bool cbm_pkgmap_try_parse(const char *basename, const char *rel_path, const char
/* ── Merge: per-worker entries → hash table ────────────────────── */
+/* A composer.json `autoload.psr-4` key: a namespace prefix ending in '\\'. */
+static bool pkgmap_is_psr4_prefix(const char *pkg_name) {
+ size_t n = pkg_name ? strlen(pkg_name) : 0;
+ return n > 0 && pkg_name[n - SKIP_ONE] == '\\';
+}
+
CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_count,
const char *project_name) {
/* Count total entries */
@@ -1004,8 +1020,14 @@ CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_cou
for (int w = 0; w < worker_count; w++) {
cbm_pkg_entries_t *we = &worker_entries[w];
for (int i = 0; i < we->count; i++) {
- /* Convert entry_rel to QN: project.dir.parts */
- char *qn = cbm_pipeline_fqn_module(project_name, we->items[i].entry_rel);
+ /* Convert entry_rel to QN: project.dir.parts. A PSR-4 namespace
+ * prefix (key ends in '\\') keeps its repo-relative DIRECTORY
+ * instead: its readers map `Prefix\Sub\Class` onto a file path
+ * (
/Sub/Class.php), which a dotted QN cannot express (#1186). */
+ const char *entry_rel = we->items[i].entry_rel;
+ char *qn = pkgmap_is_psr4_prefix(we->items[i].pkg_name)
+ ? cbm_strndup(entry_rel, strlen(entry_rel))
+ : cbm_pipeline_fqn_module(project_name, entry_rel);
if (!qn) {
continue;
}
@@ -1355,7 +1377,8 @@ static char *resolve_dot_prefix(CBMHashTable *map, const char *module_path,
}
/* Try backslash-based prefix matching (PHP PSR-4: App\\Controllers\\Foo).
- * Returns heap QN or NULL. */
+ * The map value of a PSR-4 prefix is its repo-relative directory (see
+ * cbm_pkgmap_build). Returns heap QN or NULL. */
static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path,
const char *project_name) {
char *buf = strdup(module_path);
@@ -1375,7 +1398,8 @@ static char *resolve_backslash_prefix(CBMHashTable *map, const char *module_path
}
const char *subpath = module_path + (size_t)(bs - buf) + SKIP_ONE;
char path_result[PKGMAP_PATH_BUF];
- snprintf(path_result, sizeof(path_result), "%s/%s", base_dir, subpath);
+ snprintf(path_result, sizeof(path_result), "%s%s%s", base_dir, base_dir[0] ? "/" : "",
+ subpath);
for (char *c = path_result; *c; c++) {
if (*c == '\\') {
*c = '/';
@@ -2025,6 +2049,91 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx
return found;
}
+/* ── PHP PSR-4 class imports (#1186) ──────────────────────────────── */
+
+typedef enum {
+ PSR4_NOT_APPLICABLE = 0, /* no composer psr-4 prefix covers the import */
+ PSR4_RESOLVED, /* the class file exists: *out is its File node */
+ PSR4_UNRESOLVED, /* a prefix covers it but no class file exists */
+} psr4_outcome_t;
+
+/* The File node at exactly `rel_path`, or NULL. Looked up through the name
+ * index (File nodes are named by basename) and matched on the full path, so
+ * a same-named file in another directory never qualifies. */
+static const cbm_gbuf_node_t *psr4_file_node(const cbm_pipeline_ctx_t *ctx, const char *rel_path) {
+ const cbm_gbuf_node_t **hits = NULL;
+ int hit_count = 0;
+ if (cbm_gbuf_find_by_name(ctx->gbuf, path_leaf(rel_path), &hits, &hit_count) != 0 || !hits) {
+ return NULL;
+ }
+ for (int i = 0; i < hit_count; i++) {
+ const cbm_gbuf_node_t *n = hits[i];
+ if (n && n->label && strcmp(n->label, "File") == 0 && n->file_path &&
+ strcmp(n->file_path, rel_path) == 0) {
+ return n;
+ }
+ }
+ return NULL;
+}
+
+/* Resolve a PHP class import `use A\B\C;` the way composer's PSR-4 autoloader
+ * does: for every autoload.psr-4 prefix covering the name, longest first, the
+ * class lives in exactly /.php with sub-namespaces as
+ * subdirectories; the first existing file wins. When a prefix covers the name
+ * but no such file exists the import is UNRESOLVED: it must never fall through
+ * to the namespace bucket, which binds it to whichever file of the namespace
+ * came first. `use function` / `use const` name namespace members, not class
+ * files, so they are not applicable. */
+static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx,
+ const char *source_file_qn, const CBMImport *imp,
+ const cbm_gbuf_node_t **out) {
+ *out = NULL;
+ CBMHashTable *pkgmap = cbm_pipeline_get_pkgmap();
+ if (!pkgmap || imp->kind != CBM_IMPORT_KIND_DEFAULT) {
+ return PSR4_NOT_APPLICABLE;
+ }
+ const char *name = imp->module_path;
+ if (name[0] == '\\') {
+ name++; /* fully qualified `use \App\X;` */
+ }
+ char key[PKGMAP_PATH_BUF];
+ size_t name_len = strlen(name);
+ if (name_len == 0 || name_len >= sizeof(key) || !strchr(name, '\\')) {
+ return PSR4_NOT_APPLICABLE;
+ }
+ bool covered = false;
+ /* Right to left: each '\' ends a candidate prefix, longest first. */
+ for (size_t cut = name_len - SKIP_ONE; cut > 0; cut--) {
+ if (name[cut - SKIP_ONE] != '\\') {
+ continue;
+ }
+ memcpy(key, name, cut);
+ key[cut] = '\0';
+ const char *dir = (const char *)cbm_ht_get(pkgmap, key);
+ if (!dir) {
+ continue;
+ }
+ covered = true;
+ char rel[PKGMAP_PATH_BUF];
+ int w = snprintf(rel, sizeof(rel), "%s%s%s.php", dir, dir[0] ? "/" : "", name + cut);
+ if (w <= 0 || (size_t)w >= sizeof(rel)) {
+ continue;
+ }
+ for (char *c = rel; *c; c++) {
+ if (*c == '\\') {
+ *c = '/';
+ }
+ }
+ const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel);
+ if (file && (!source_file_qn || !file->qualified_name ||
+ strcmp(file->qualified_name, source_file_qn) != 0)) {
+ *out = file;
+ return PSR4_RESOLVED;
+ }
+ }
+ return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE;
+}
+
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,
@@ -2042,6 +2151,18 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t
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, source_file_qn, imp, &psr4_target)) {
+ case PSR4_RESOLVED:
+ return psr4_target;
+ case PSR4_UNRESOLVED:
+ return NULL;
+ case PSR4_NOT_APPLICABLE:
+ break;
+ }
+
/* 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,
diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c
index 097cebafe..01d4419c1 100644
--- a/tests/test_edge_imports.c
+++ b/tests/test_edge_imports.c
@@ -1243,6 +1243,194 @@ TEST(ei_php_interface_use) {
PASS();
}
+/* ═══════════════════════════════════════════════════════════════════════════
+ * PHP PSR-4 — #1186
+ *
+ * With composer.json `autoload.psr-4`, `use App\Models\Agency;` names exactly
+ * one file: /Models/Agency.php. The resolver instead fell through
+ * to the namespace bucket and bound every class import of App\Models to the
+ * FIRST file declaring that namespace (User.php), fabricating a hub. These
+ * tests pin the exact target file per local name; a missing class file must
+ * leave the import unresolved rather than land on a sibling.
+ * ═══════════════════════════════════════════════════════════════════════════ */
+
+typedef struct {
+ const char *local_name; /* IMPORTS edge local_name */
+ 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) {
+ out[0] = '\0';
+ int64_t src_id = ei_node_id_for_file_label(store, project, importer, "File");
+ 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;
+ }
+ char needle[256];
+ snprintf(needle, sizeof(needle), "\"local_name\":\"%s\"", local);
+ for (int i = 0; i < n; i++) {
+ if (!edges[i].properties_json || !strstr(edges[i].properties_json, needle)) {
+ continue;
+ }
+ 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);
+ }
+ break;
+ }
+ cbm_store_free_edges(edges, n);
+}
+
+/* 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) {
+ EILangProj lp;
+ cbm_store_t *store = ei_index_files(&lp, files, nfiles);
+ if (!store) {
+ ei_cleanup(&lp, store);
+ return 0;
+ }
+ int ok = 1;
+ for (int i = 0; i < nwant; i++) {
+ char got[512];
+ 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) {
+ fprintf(stderr, " [IMPORTS %s] %s -> got \"%s\", want \"%s\"\n", importer,
+ want[i].local_name, got, expect);
+ ok = 0;
+ }
+ }
+ ei_cleanup(&lp, store);
+ return ok;
+}
+
+#define EI_PHP_CLASS(ns, cls) " src/Domain/ over
+ * App\ -> app/), a package's own root maps its namespace, and autoload-dev
+ * maps Tests\. The decoy app/Domain/Order.php declares the same namespace and
+ * sorts first. */
+TEST(ei_php_psr4_multiple_roots_longest_prefix_issue1186) {
+ static const EILangFile f[] = {
+ {"composer.json", "{\"autoload\":{\"psr-4\":{\"App\\\\\":\"app/\","
+ "\"App\\\\Domain\\\\\":\"src/Domain/\"}},"
+ "\"autoload-dev\":{\"psr-4\":{\"Tests\\\\\":\"tests/\"}}}\n"},
+ {"tests/Support/Assert.php", EI_PHP_CLASS("Tests\\Support", "Assert")},
+ {"tests/Support/Factory.php", EI_PHP_CLASS("Tests\\Support", "Factory")},
+ {"packages/billing/composer.json",
+ "{\"name\":\"acme/billing\",\"autoload\":{\"psr-4\":{\"Billing\\\\\":\"src/\"}}}\n"},
+ {"app/Domain/Order.php", EI_PHP_CLASS("App\\Domain", "Order")},
+ {"src/Domain/Customer.php", EI_PHP_CLASS("App\\Domain", "Customer")},
+ {"src/Domain/Order.php", EI_PHP_CLASS("App\\Domain", "Order")},
+ {"packages/billing/src/Account.php", EI_PHP_CLASS("Billing", "Account")},
+ {"packages/billing/src/Ledger.php", EI_PHP_CLASS("Billing", "Ledger")},
+ {"app/Http/Checkout.php", "