fix(routes): resolve PSR-4 handler classes through the class-file resolver

Why: since 830f0ccf (#2509) the package map stores a composer.json PSR-4
prefix ("Acme\Blog\") as its repo-relative directory ("packages/blog/src"),
not as a dotted QN root. The route-handler resolver still formatted that
value as a QN root and built "packages/blog/src.Http.PostsController.
PostsController.index", which names no node. The PSR-4 step therefore
resolved nothing. Handlers whose folders mirror their namespace
(App\Http\Controllers -> app/Http/Controllers) still got their edge through
the namespace/folder fallback, but a package class under a root whose folder
does not mirror its namespace got no HANDLES edge. That is the case of
packages/blog/src in the test fixture and of krayin's
Webkul\Admin\ -> packages/Webkul/Admin/src.

Change: resolve the handler class the way the import pass resolves
`use Ns\Class;` for PHP.
- pass_pkgmap.c: psr4_file_node and resolve_php_psr4_class take the graph
  buffer instead of the pipeline ctx, since they only read ctx->gbuf. The
  new cbm_pipeline_psr4_member_qn runs resolve_php_psr4_class for the class
  FQN, takes the Class/Interface/Trait/Enum node of that name defined in the
  resolved file, and returns the QN of <class QN>.<member> when that node
  exists.
- registry.c: psr4_member_qn delegates to it and no longer builds a QN from
  the package-map value. cbm_registry_resolve_handler takes the graph buffer
  in place of the package map. The namespace/folder fallback is unchanged.
- pass_calls.c and pass_parallel.c pass the graph buffer they already use
  for the handler lookup: ctx->gbuf, and main_gbuf, which is read-only
  during the resolve phase.

Proof:
- handles_laravel_class_handlers_issue1146 and its _parallel twin
  (edge_types_probe): RED 3/3 before the change ("missing
  PostsController.index -> /blog", expected=4 actual=3; suite 75 passed,
  2 failed). GREEN 3/3 after (77 passed). RED again with the production
  change reverted. GREEN again once it was restored.
- Real input: no Laravel app with a non-mirroring PSR-4 root is available
  locally. koel/koel v9.11.1 (App\ -> app/) was indexed with the binary
  before and after, each under a private runtime and cache. The results
  are identical: 16,564 nodes, 60,177 edges, 15 Routes and 1 HANDLES
  (/demo/new-session -> NewSessionController.__invoke), with the same
  handler rows.
- Suites: edge_types_probe 77, edge_imports 68, pipeline 317 and
  route_canon 11 all pass (473). make lint-ci passes.

Refs #1146

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-04 21:11:49 +02:00
parent 4e490bef5a
commit 42118ad142
5 changed files with 77 additions and 50 deletions
+1 -1
View File
@@ -406,7 +406,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') {
cbm_resolution_t hres =
cbm_registry_resolve_handler(ctx->registry, call->second_arg_name, module_qn, imp_keys,
imp_vals, imp_count, cbm_pipeline_get_pkgmap());
imp_vals, imp_count, ctx->gbuf);
if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name);
if (handler != NULL) {
+2 -2
View File
@@ -2295,8 +2295,8 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
esc_rp);
cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props);
if (handler_ref && handler_ref[0] != '\0') {
cbm_resolution_t hres = cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik,
iv, ic, cbm_pipeline_get_pkgmap());
cbm_resolution_t hres =
cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik, iv, ic, main_gbuf);
if (hres.qualified_name && hres.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name);
if (h) {
+45 -7
View File
@@ -2060,10 +2060,10 @@ typedef enum {
/* 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) {
static const cbm_gbuf_node_t *psr4_file_node(const cbm_gbuf_t *gbuf, 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) {
if (cbm_gbuf_find_by_name(gbuf, path_leaf(rel_path), &hits, &hit_count) != 0 || !hits) {
return NULL;
}
for (int i = 0; i < hit_count; i++) {
@@ -2084,9 +2084,8 @@ static const cbm_gbuf_node_t *psr4_file_node(const cbm_pipeline_ctx_t *ctx, cons
* 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) {
static psr4_outcome_t resolve_php_psr4_class(const cbm_gbuf_t *gbuf, 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) {
@@ -2124,7 +2123,7 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx,
*c = '/';
}
}
const cbm_gbuf_node_t *file = psr4_file_node(ctx, rel);
const cbm_gbuf_node_t *file = psr4_file_node(gbuf, rel);
if (file && (!source_file_qn || !file->qualified_name ||
strcmp(file->qualified_name, source_file_qn) != 0)) {
*out = file;
@@ -2134,6 +2133,45 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx,
return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE;
}
static bool psr4_class_label(const char *label) {
return label && (strcmp(label, "Class") == 0 || strcmp(label, "Interface") == 0 ||
strcmp(label, "Trait") == 0 || strcmp(label, "Enum") == 0);
}
const char *cbm_pipeline_psr4_member_qn(const cbm_gbuf_t *gbuf, const char *class_fqn,
const char *member) {
if (!gbuf || !class_fqn || !member || !member[0]) {
return NULL;
}
const CBMImport imp = {.module_path = class_fqn, .kind = CBM_IMPORT_KIND_DEFAULT};
const cbm_gbuf_node_t *file = NULL;
if (resolve_php_psr4_class(gbuf, NULL, &imp, &file) != PSR4_RESOLVED || !file->file_path) {
return NULL;
}
const char *cls = strrchr(class_fqn, '\\');
cls = cls ? cls + SKIP_ONE : class_fqn;
const cbm_gbuf_node_t **hits = NULL;
int hit_count = 0;
if (cbm_gbuf_find_by_name(gbuf, cls, &hits, &hit_count) != 0 || !hits) {
return NULL;
}
for (int i = 0; i < hit_count; i++) {
const cbm_gbuf_node_t *c = hits[i];
if (!c || !psr4_class_label(c->label) || !c->qualified_name || !c->file_path ||
strcmp(c->file_path, file->file_path) != 0) {
continue;
}
char qn[CBM_SZ_1K];
int w = snprintf(qn, sizeof(qn), "%s.%s", c->qualified_name, member);
if (w <= 0 || (size_t)w >= sizeof(qn)) {
return NULL;
}
const cbm_gbuf_node_t *m = cbm_gbuf_find_by_qn(gbuf, qn);
return m ? m->qualified_name : NULL;
}
return NULL;
}
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,
@@ -2154,7 +2192,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t
/* 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)) {
switch (resolve_php_psr4_class(ctx->gbuf, source_file_qn, imp, &psr4_target)) {
case PSR4_RESOLVED:
return psr4_target;
case PSR4_UNRESOLVED:
+15 -5
View File
@@ -213,14 +213,24 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map);
* Laravel `[Class::class, 'method']` and invokable `Class::class` handlers
* (#1146), with the class already qualified through the file's `use` imports
* and namespace — is placed on a method QN only by what places the class file:
* a composer.json PSR-4 root in psr4_roots (the package map), else the one
* candidate whose directories end with the class's namespace. Never by short
* name alone, so a vendor controller that is not in the repo gets no handler.
* A reference without "::" resolves exactly as cbm_registry_resolve does. */
* the composer.json PSR-4 class file in gbuf (cbm_pipeline_psr4_member_qn),
* else the one candidate whose directories end with the class's namespace.
* Never by short name alone, so a vendor controller that is not in the repo
* gets no handler. A reference without "::" resolves exactly as
* cbm_registry_resolve does. */
cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref,
const char *module_qn, const char **import_map_keys,
const char **import_map_vals, int import_map_count,
const CBMHashTable *psr4_roots);
const cbm_gbuf_t *gbuf);
/* The QN of `member` on the PHP class `class_fqn` ("Ns\\Class", NUL-
* terminated), placed the way composer's PSR-4 autoloader places the class:
* the class file the import resolver picks for `use Ns\\Class;` (#1186), the
* class that file defines, then that class's member. NULL when no PSR-4
* prefix covers the class, its file is absent, or the class has no such
* member. The returned string is owned by gbuf. Read-only on gbuf. */
const char *cbm_pipeline_psr4_member_qn(const cbm_gbuf_t *gbuf, const char *class_fqn,
const char *member);
/* Unified module resolver: relative → pkgmap → fqn_module fallback.
* Handles bare specifiers via pkgmap lookup with prefix matching.
+14 -35
View File
@@ -1502,42 +1502,21 @@ static int qn_class_member_tail(const char *qn, const char *cls, size_t cls_len,
return (int)(c - SKIP_ONE - qn);
}
/* PSR-4: the longest composer.json autoload root "Prefix\\" that prefixes the
* FQN maps the remaining namespace onto directories below the root's folder
* and the class onto <Class>.php — the file Composer's autoloader loads.
* Returns the registered method QN, or NULL (no root prefixes the FQN, or the
* autoloaded file holds no such method). */
static const char *psr4_member_qn(const cbm_registry_t *r, const CBMHashTable *psr4_roots,
const char *fqn, size_t fqn_len, const char *cls, size_t cls_len,
/* PSR-4: the class file Composer's autoloader loads for the FQN, the class it
* defines, and that class's member — through the PSR-4 import resolver
* (cbm_pipeline_psr4_member_qn), so a root whose folder does not mirror its
* namespace (Acme\\Blog\\ -> packages/blog/src) places the class as well.
* Returns the member QN, or NULL (no root covers the FQN, its class file is
* absent, or the class holds no such member). */
static const char *psr4_member_qn(const cbm_gbuf_t *gbuf, const char *fqn, size_t fqn_len,
const char *member) {
if (!psr4_roots || fqn_len >= CBM_SZ_512) {
char cls_fqn[CBM_SZ_512];
if (!gbuf || fqn_len >= sizeof(cls_fqn)) {
return NULL;
}
char key[CBM_SZ_512];
for (size_t cut = fqn_len; cut > 0; cut--) {
if (fqn[cut - SKIP_ONE] != '\\') {
continue;
}
memcpy(key, fqn, cut); /* "App\\" / "App\\Http\\" — keeps the trailing '\\' */
key[cut] = '\0';
const char *root_qn = (const char *)cbm_ht_get(psr4_roots, key);
if (!root_qn) {
continue;
}
char qn[CBM_SZ_1K];
int n = snprintf(qn, sizeof(qn), "%s.%.*s.%.*s.%s", root_qn, (int)(fqn_len - cut),
fqn + cut, (int)cls_len, cls, member);
if (n <= 0 || (size_t)n >= sizeof(qn)) {
return NULL;
}
for (char *p = qn + strlen(root_qn); *p; p++) {
if (*p == '\\') {
*p = '.';
}
}
return cbm_ht_get_key(r->exact, qn);
}
return NULL;
memcpy(cls_fqn, fqn, fqn_len);
cls_fqn[fqn_len] = '\0';
return cbm_pipeline_psr4_member_qn(gbuf, cls_fqn, member);
}
/* Do the directory segments of qn (between the project root segment and the
@@ -1600,7 +1579,7 @@ static const char *aligned_member_qn(const cbm_registry_t *r, const char *fqn, s
cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref,
const char *module_qn, const char **import_map_keys,
const char **import_map_vals, int import_map_count,
const CBMHashTable *psr4_roots) {
const cbm_gbuf_t *gbuf) {
const char *sep = handler_ref ? strstr(handler_ref, "::") : NULL;
if (!r || !sep) {
return cbm_registry_resolve(r, handler_ref, module_qn, import_map_keys, import_map_vals,
@@ -1618,7 +1597,7 @@ cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const cha
if (cls_len == 0 || !member[0]) {
return empty_result();
}
const char *qn = psr4_member_qn(r, psr4_roots, handler_ref, fqn_len, cls, cls_len, member);
const char *qn = psr4_member_qn(gbuf, handler_ref, fqn_len, member);
if (qn) {
return (cbm_resolution_t){qn, "php_psr4", CONF_PSR4_MEMBER, REG_RESOLVED};
}