Merge pull request #2447 from DeusData/fix/issue-1146-laravel-withrouting-prefix

fix(routes): compose the Laravel 11+ withRouting api prefix into Route paths (#1146)
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-04 16:22:05 +02:00
commit 6644fc433d
6 files changed
+646 -24

No files matched your search

+1
View File
@@ -437,6 +437,7 @@ PIPELINE_SRCS = \
src/pipeline/lsp_surface.c \
src/pipeline/pipeline_delta.c \
src/pipeline/path_alias.c \
src/pipeline/laravel_routing.c \
src/pipeline/registry.c \
src/pipeline/pipeline.c \
src/pipeline/pipeline_incremental.c \
+403
View File
@@ -0,0 +1,403 @@
/*
* laravel_routing.c — Laravel 11+ framework route mounts (#1146).
*
* See laravel_routing.h for the contract. bootstrap/app.php is parsed with
* the same PHP grammar the extractor uses; only named `withRouting(...)`
* arguments whose values are plain literals (or `__DIR__`/`dirname(__DIR__)`
* concatenations and `base_path()` calls over literals) are trusted.
*/
#include "pipeline/laravel_routing.h"
#include "cbm.h"
#include "lang_specs.h"
#include "service_patterns.h"
#include "tree_sitter/api.h"
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/mem_core.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
enum {
LR_PATH_MAX = CBM_SZ_512, /* repo-relative path buffer */
LR_KEY_MAX = CBM_SZ_32, /* named-argument key */
LR_PREFIX_MAX = CBM_SZ_128, /* apiPrefix literal */
LR_SEG_MAX = CBM_SZ_64, /* path segments tracked while normalising */
LR_SOURCE_MAX = CBM_SZ_256 * CBM_SZ_1K, /* bootstrap/app.php read cap */
};
static const char LR_BOOTSTRAP_REL[] = "bootstrap/app.php";
/* Copy a node's source text into buf. False when it does not fit. */
static bool lr_node_text(const char *src, TSNode node, char *buf, size_t buf_sz) {
if (ts_node_is_null(node)) {
return false;
}
uint32_t start = ts_node_start_byte(node);
uint32_t end = ts_node_end_byte(node);
size_t len = end > start ? (size_t)(end - start) : 0;
if (len >= buf_sz) {
return false;
}
memcpy(buf, src + start, len);
buf[len] = '\0';
return true;
}
static bool lr_node_is(TSNode node, const char *type) {
return !ts_node_is_null(node) && strcmp(ts_node_type(node), type) == 0;
}
static bool lr_node_text_is(const char *src, TSNode node, const char *text) {
char buf[LR_KEY_MAX];
return lr_node_text(src, node, buf, sizeof(buf)) && strcmp(buf, text) == 0;
}
/* A plain string literal: '...' or "..." whose only children are
* string_content (no interpolation, no escapes). Writes the unquoted value. */
static bool lr_string_literal(const char *src, TSNode node, char *buf, size_t buf_sz) {
if (!lr_node_is(node, "string") && !lr_node_is(node, "encapsed_string")) {
return false;
}
size_t pos = 0;
buf[0] = '\0';
uint32_t nc = ts_node_named_child_count(node);
for (uint32_t i = 0; i < nc; i++) {
TSNode part = ts_node_named_child(node, i);
if (!lr_node_is(part, "string_content") ||
!lr_node_text(src, part, buf + pos, buf_sz - pos)) {
return false;
}
pos += strlen(buf + pos);
}
return true;
}
/* The value expression of a call argument: its first named child that is
* neither the `name:` label nor a comment. */
static TSNode lr_arg_value(TSNode arg, TSNode label) {
uint32_t nc = ts_node_named_child_count(arg);
for (uint32_t i = 0; i < nc; i++) {
TSNode child = ts_node_named_child(arg, i);
if ((ts_node_is_null(label) || !ts_node_eq(child, label)) &&
!lr_node_is(child, "comment")) {
return child;
}
}
TSNode none = {0};
return none;
}
/* The single literal argument of `fn('...')` when the callee is `fn`. */
static bool lr_call_literal_arg(const char *src, TSNode call, const char *fn, char *buf,
size_t buf_sz) {
if (!lr_node_is(call, "function_call_expression") ||
!lr_node_text_is(src, ts_node_child_by_field_name(call, TS_FIELD("function")), fn)) {
return false;
}
TSNode args = ts_node_child_by_field_name(call, TS_FIELD("arguments"));
if (ts_node_is_null(args) || ts_node_named_child_count(args) != SKIP_ONE) {
return false;
}
TSNode arg = ts_node_named_child(args, 0);
TSNode val = lr_arg_value(arg, ts_node_child_by_field_name(arg, TS_FIELD("name")));
return lr_string_literal(src, val, buf, buf_sz);
}
/* `dirname(__DIR__)` — the application base directory. */
static bool lr_is_dirname_dir(const char *src, TSNode node) {
if (!lr_node_is(node, "function_call_expression") ||
!lr_node_text_is(src, ts_node_child_by_field_name(node, TS_FIELD("function")), "dirname")) {
return false;
}
TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments"));
if (ts_node_is_null(args) || ts_node_named_child_count(args) != SKIP_ONE) {
return false;
}
TSNode arg = ts_node_named_child(args, 0);
TSNode val = lr_arg_value(arg, ts_node_child_by_field_name(arg, TS_FIELD("name")));
return lr_node_is(val, "name") && lr_node_text_is(src, val, "__DIR__");
}
/* Resolve "." and ".." segments of a '/'-joined path in place. False when the
* path climbs above the repository root. */
static bool lr_normalize(char *path) {
size_t seg_start[LR_SEG_MAX];
int depth = 0;
size_t out = 0;
const char *p = path;
while (*p) {
const char *slash = strchr(p, '/');
size_t len = slash ? (size_t)(slash - p) : strlen(p);
if (len == PAIR_LEN && p[0] == '.' && p[SKIP_ONE] == '.') {
if (depth == 0) {
return false;
}
out = seg_start[--depth];
} else if (len > 0 && !(len == SKIP_ONE && p[0] == '.')) {
if (depth == LR_SEG_MAX) {
return false;
}
size_t at = out > 0 ? out + SKIP_ONE : 0;
seg_start[depth++] = out;
if (out > 0) {
path[out] = '/';
}
memmove(path + at, p, len);
out = at + len;
}
p += len + (slash ? SKIP_ONE : 0);
}
path[out] = '\0';
return true;
}
/* Resolve one `api:` path expression of the app rooted at app_dir to a
* normalised repo-relative path:
* __DIR__ . '/../routes/api.php' (relative to bootstrap/)
* dirname(__DIR__) . '/routes/api.php' (relative to the app base)
* base_path('routes/api.php') (relative to the app base) */
static bool lr_route_file_path(const char *src, TSNode expr, const char *app_dir, char *out,
size_t out_sz) {
char lit[LR_PATH_MAX];
const char *base_suffix = NULL;
if (lr_node_is(expr, "binary_expression")) {
TSNode op = ts_node_child_by_field_name(expr, TS_FIELD("operator"));
TSNode left = ts_node_child_by_field_name(expr, TS_FIELD("left"));
if (!lr_node_text_is(src, op, ".") ||
!lr_string_literal(src, ts_node_child_by_field_name(expr, TS_FIELD("right")), lit,
sizeof(lit))) {
return false;
}
if (lr_node_is(left, "name") && lr_node_text_is(src, left, "__DIR__")) {
base_suffix = "bootstrap";
} else if (lr_is_dirname_dir(src, left)) {
base_suffix = "";
} else {
return false;
}
} else if (lr_call_literal_arg(src, expr, "base_path", lit, sizeof(lit))) {
base_suffix = "";
} else {
return false;
}
int n = snprintf(out, out_sz, "%s/%s/%s", app_dir, base_suffix, lit);
return n > 0 && (size_t)n < out_sz && lr_normalize(out);
}
/* Does the `api:` value (one path or an array of paths) name rel_path? */
static bool lr_api_names_file(const char *src, TSNode value, const char *app_dir,
const char *rel_path) {
char resolved[LR_PATH_MAX];
if (!lr_node_is(value, "array_creation_expression")) {
return lr_route_file_path(src, value, app_dir, resolved, sizeof(resolved)) &&
strcmp(resolved, rel_path) == 0;
}
uint32_t nc = ts_node_named_child_count(value);
for (uint32_t i = 0; i < nc; i++) {
TSNode elem = ts_node_named_child(value, i);
uint32_t ec = ts_node_named_child_count(elem);
if (!lr_node_is(elem, "array_element_initializer") || ec == 0) {
continue;
}
TSNode path_expr = ts_node_named_child(elem, ec - SKIP_ONE); /* value of `k => v` too */
if (lr_route_file_path(src, path_expr, app_dir, resolved, sizeof(resolved)) &&
strcmp(resolved, rel_path) == 0) {
return true;
}
}
return false;
}
/* Evaluate the named arguments of a withRouting(...) call. */
static bool lr_eval_with_routing(const char *src, TSNode call, const char *app_dir,
const char *rel_path, char *out, size_t out_sz) {
TSNode args = ts_node_child_by_field_name(call, TS_FIELD("arguments"));
char prefix[LR_PREFIX_MAX] = "api"; /* ApplicationBuilder::withRouting default */
bool prefix_known = true;
bool mounted = false;
uint32_t nc = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args);
for (uint32_t i = 0; i < nc; i++) {
TSNode arg = ts_node_named_child(args, i);
if (lr_node_is(arg, "comment")) {
continue;
}
TSNode label = ts_node_child_by_field_name(arg, TS_FIELD("name"));
char key[LR_KEY_MAX];
/* Positional arguments would need the parameter order modelled, and a
* `using:` callback replaces the web/api registration entirely. */
if (!lr_node_is(arg, "argument") || ts_node_is_null(label) ||
!lr_node_text(src, label, key, sizeof(key)) || strcmp(key, "using") == 0) {
return false;
}
TSNode value = lr_arg_value(arg, label);
if (strcmp(key, "api") == 0) {
mounted = lr_api_names_file(src, value, app_dir, rel_path);
} else if (strcmp(key, "apiPrefix") == 0) {
prefix_known = lr_string_literal(src, value, prefix, sizeof(prefix));
}
}
if (!mounted || !prefix_known) {
return false;
}
/* Laravel trims the group prefix of '/' on both ends. */
const char *start = prefix;
size_t len = strlen(prefix);
while (*start == '/') {
start++;
len--;
}
while (len > 0 && start[len - SKIP_ONE] == '/') {
len--;
}
int n = snprintf(out, out_sz, "/%.*s", (int)len, start);
return len > 0 && n > 0 && (size_t)n < out_sz;
}
/* First `->withRouting(...)` call in the tree (iterative pre-order walk). */
static TSNode lr_find_with_routing(const char *src, TSNode root) {
TSNode found = {0};
TSTreeCursor cur = ts_tree_cursor_new(root);
bool more = true;
while (more) {
TSNode node = ts_tree_cursor_current_node(&cur);
if ((lr_node_is(node, "member_call_expression") ||
lr_node_is(node, "nullsafe_member_call_expression")) &&
lr_node_text_is(src, ts_node_child_by_field_name(node, TS_FIELD("name")),
"withRouting")) {
found = node;
break;
}
if (ts_tree_cursor_goto_first_child(&cur)) {
continue;
}
while (!ts_tree_cursor_goto_next_sibling(&cur)) {
if (!ts_tree_cursor_goto_parent(&cur)) {
more = false;
break;
}
}
}
ts_tree_cursor_delete(&cur);
return found;
}
static bool lr_mount_from_bootstrap(const char *src, uint32_t len, const char *app_dir,
const char *rel_path, char *out, size_t out_sz) {
const TSLanguage *php = cbm_ts_language(CBM_LANG_PHP);
TSParser *parser = php ? ts_parser_new() : NULL;
if (!parser) {
return false;
}
bool mounted = false;
TSTree *tree =
ts_parser_set_language(parser, php) ? ts_parser_parse_string(parser, NULL, src, len) : NULL;
if (tree) {
TSNode call = lr_find_with_routing(src, ts_tree_root_node(tree));
mounted = !ts_node_is_null(call) &&
lr_eval_with_routing(src, call, app_dir, rel_path, out, out_sz);
ts_tree_delete(tree);
}
ts_parser_delete(parser);
return mounted;
}
/* Read <repo>/<app_dir>/bootstrap/app.php. NULL when absent or unreadable. */
static char *lr_read_bootstrap(const char *repo_path, const char *app_dir, uint32_t *out_len) {
char path[CBM_SZ_4K];
int n = snprintf(path, sizeof(path), "%s/%s%s%s", repo_path, app_dir, app_dir[0] ? "/" : "",
LR_BOOTSTRAP_REL);
if (n <= 0 || (size_t)n >= sizeof(path)) {
return NULL;
}
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
char *buf = (size > 0 && size <= (long)LR_SOURCE_MAX)
? cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)size + SKIP_ONE)
: NULL;
if (buf) {
size_t nread = fread(buf, SKIP_ONE, (size_t)size, f);
buf[nread] = '\0';
*out_len = (uint32_t)nread;
}
(void)fclose(f);
return buf;
}
bool cbm_laravel_api_mount(const char *repo_path, const char *rel_path, char *out, size_t out_sz) {
if (!out || out_sz == 0) {
return false;
}
out[0] = '\0';
size_t rel_len = rel_path ? strlen(rel_path) : 0;
if (!repo_path || rel_len < sizeof(".php") || rel_len >= LR_PATH_MAX ||
strcmp(rel_path + rel_len - (sizeof(".php") - SKIP_ONE), ".php") != 0) {
return false;
}
/* Walk the file's ancestor directories nearest-first; the nearest one
* holding bootstrap/app.php is the Laravel app that owns the file. */
char app_dir[LR_PATH_MAX];
memcpy(app_dir, rel_path, rel_len + SKIP_ONE);
char *slash = strrchr(app_dir, '/');
for (;;) {
if (slash) {
*slash = '\0';
} else {
app_dir[0] = '\0';
}
uint32_t len = 0;
char *src = lr_read_bootstrap(repo_path, app_dir, &len);
if (src) {
bool mounted = lr_mount_from_bootstrap(src, len, app_dir, rel_path, out, out_sz);
cbm_free(CBM_MEM_CLASS_EXTRACT, src);
if (!mounted) {
out[0] = '\0';
}
return mounted;
}
if (!app_dir[0]) {
return false;
}
slash = strrchr(app_dir, '/');
}
}
void cbm_laravel_file_route_mount(const char *repo_path, const char *rel_path, CBMLanguage lang,
const CBMFileResult *result, char *out, size_t out_sz) {
if (!out || out_sz == 0) {
return;
}
out[0] = '\0';
if (lang != CBM_LANG_PHP || !result) {
return;
}
for (int i = 0; i < result->calls.count; i++) {
const CBMCall *call = &result->calls.items[i];
if (call->callee_name && call->first_string_arg && call->first_string_arg[0] == '/' &&
cbm_service_pattern_route_method(call->callee_name) != NULL) {
(void)cbm_laravel_api_mount(repo_path, rel_path, out, out_sz);
return;
}
}
}
const char *cbm_laravel_mount_route(const char *mount, const char *path, char *buf, size_t buf_sz) {
if (!mount || !mount[0] || !path || !buf || buf_sz == 0) {
return path;
}
const char *rel = path;
while (*rel == '/') {
rel++;
}
int n =
rel[0] ? snprintf(buf, buf_sz, "%s/%s", mount, rel) : snprintf(buf, buf_sz, "%s", mount);
return (n > 0 && (size_t)n < buf_sz) ? buf : path;
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef CBM_LARAVEL_ROUTING_H
#define CBM_LARAVEL_ROUTING_H
/*
* laravel_routing.h — Laravel 11+ framework route mounts (#1146).
*
* Laravel 11+ registers route files in bootstrap/app.php:
*
* Application::configure(basePath: dirname(__DIR__))
* ->withRouting(web: __DIR__.'/../routes/web.php',
* api: __DIR__.'/../routes/api.php',
* apiPrefix: 'api')
*
* and mounts every file passed as `api:` under `apiPrefix` (default 'api');
* the `web:` file gets no prefix. The prefix is declared in a DIFFERENT file
* than the routes, so per-file extraction cannot compose it the way it does
* the in-file `Route::prefix()->group()` chain (#952). The route passes ask
* here once per PHP file that registers routes and prepend the mount to every
* Route path minted from that file.
*
* Only what bootstrap/app.php states as source fact is used: the nearest
* ancestor bootstrap/app.php must name the file under `api:`, and a given
* `apiPrefix:` must be a string literal. A non-literal prefix, positional
* arguments or a `using:` callback leave the paths untouched — nothing is
* inferred from file names.
*/
#include "cbm.h"
#include <stdbool.h>
#include <stddef.h>
/* Write the mount ("/api", "/v1/api") under which the Laravel app that owns
* rel_path serves its routes into out and return true. Returns false with
* out = "" when rel_path is not mounted under a known, non-empty prefix. */
bool cbm_laravel_api_mount(const char *repo_path, const char *rel_path, char *out, size_t out_sz);
/* Per-file entry for the route passes: probes cbm_laravel_api_mount only for
* a PHP file that registers at least one path-shaped route; out = "" for
* every other file, so no other language pays a lookup. */
void cbm_laravel_file_route_mount(const char *repo_path, const char *rel_path, CBMLanguage lang,
const CBMFileResult *result, char *out, size_t out_sz);
/* Compose mount + route path: "/api" + "/users/me" -> "/api/users/me",
* "/api" + "/" -> "/api". Returns path unchanged when mount is empty or the
* composed path does not fit buf. */
const char *cbm_laravel_mount_route(const char *mount, const char *path, char *buf, size_t buf_sz);
#endif /* CBM_LARAVEL_ROUTING_H */
+26 -13
View File
@@ -19,6 +19,7 @@ enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 };
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "pipeline/lsp_resolve.h"
#include "pipeline/laravel_routing.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/compat.h"
@@ -374,23 +375,29 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje
return n > 0 && (size_t)n < out_sz;
}
/* Handle a route registration call: create Route node + HANDLES edge. */
/* Handle a route registration call: create Route node + HANDLES edge.
* route_mount is the framework mount of the registering file ("/api" for a
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count) {
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char mounted[CBM_SZ_256];
const char *path =
cbm_laravel_mount_route(route_mount, call->first_string_arg, mounted, sizeof(mounted));
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY",
cbm_route_canon_path(call->first_string_arg, cpath, sizeof(cpath)));
cbm_route_canon_path(path, cpath, sizeof(cpath)));
char route_props[CBM_SZ_256];
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method ? method : "ANY");
int64_t route_id = cbm_gbuf_upsert_node(ctx->gbuf, "Route", call->first_string_arg, route_qn,
"", 0, 0, route_props);
int64_t route_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Route", path, route_qn, "", 0, 0, route_props);
char esc_cn[CBM_SZ_256]; /* sliced source text: escape quotes/newlines */
char esc_fa[CBM_SZ_256];
cbm_json_escape(esc_cn, sizeof(esc_cn), call->callee_name);
cbm_json_escape(esc_fa, sizeof(esc_fa), call->first_string_arg);
cbm_json_escape(esc_fa, sizeof(esc_fa), path);
char props[CBM_SZ_512];
snprintf(props, sizeof(props),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn,
@@ -588,10 +595,11 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
const cbm_resolution_t *res, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
bool suppress_plain_calls) {
bool suppress_plain_calls, const char *route_mount) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count);
handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count,
route_mount);
return;
}
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
@@ -646,7 +654,7 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c
static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, CBMLanguage lang,
const CBMImportArray *imports) {
const CBMImportArray *imports, const char *route_mount) {
const CBMResolvedCallArray *lsp_calls = &result->resolved_calls;
const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn);
if (!source_node) {
@@ -675,7 +683,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
res.strategy = lsp->strategy;
res.candidate_count = 1;
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count, false);
imp_vals, imp_count, false, route_mount);
return SKIP_ONE;
}
}
@@ -767,7 +775,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg &&
call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count);
imp_count, route_mount);
return SKIP_ONE;
}
cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name);
@@ -888,7 +896,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
return 0;
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
imp_count, drop_plain_call);
imp_count, drop_plain_call, route_mount);
return SKIP_ONE;
}
@@ -1039,6 +1047,11 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
char *module_qn = cbm_pipeline_fqn_module_dir(ctx->project_name, rel,
pc_module_is_dir(files[i].language));
/* Framework mount of this file's routes (Laravel 11+ withRouting, #1146). */
char route_mount[CBM_SZ_128];
cbm_laravel_file_route_mount(ctx->repo_path, rel, files[i].language, result, route_mount,
sizeof(route_mount));
/* Resolve each call */
for (int c = 0; c < result->calls.count; c++) {
CBMCall *call = &result->calls.items[c];
@@ -1047,7 +1060,7 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
}
total_calls++;
if (resolve_single_call(ctx, call, result, rel, module_qn, imp_keys, imp_vals,
imp_count, files[i].language, &result->imports)) {
imp_count, files[i].language, &result->imports, route_mount)) {
resolved++;
} else {
unresolved++;
+19 -8
View File
@@ -74,6 +74,7 @@ enum { PP_CSHARP_M_PREFIX_LEN = 2 };
#include "foundation/platform.h" /* cbm_resolve_cache_dir */
#include "pipeline/pass_lsp_cross.h" /* cbm_pxc_* helpers for fused cross-file LSP */
#include "pipeline/lsp_resolve.h"
#include "pipeline/laravel_routing.h"
#include "lsp/rust_cargo.h"
#include "helpers.h" /* cbm_kind_in_set_free_cache — per-worker-thread cache teardown */
#include "pipeline/worker_pool.h"
@@ -2209,13 +2210,18 @@ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sour
}
/* Classify a resolved call by library identity and emit the appropriate edge. */
/* Create Route node + CALLS + HANDLES edges for a route registration call. */
/* Create Route node + CALLS + HANDLES edges for a route registration call.
* route_mount is the framework mount of the registering file ("/api" for a
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const CBMCall *call, const char *route_path,
const char *handler_ref, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
const char **ik, const char **iv, int ic) {
const char **ik, const char **iv, int ic,
const char *route_mount) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char mounted[CBM_SZ_256];
route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted));
char rqn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(rqn, sizeof(rqn), "__route__%s__%s", method ? method : "ANY",
@@ -2526,7 +2532,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const cbm_resolution_t *res, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
const char **imp_keys, const char **imp_vals, int imp_count,
bool suppress_plain_calls) {
bool suppress_plain_calls, const char *route_mount) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
const char *arg = call->first_string_arg;
@@ -2553,7 +2559,8 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const char *route_path = find_route_path_in_args(call, &handler_ref);
if (route_path) {
emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count);
registry, main_gbuf, imp_keys, imp_vals, imp_count,
route_mount);
return;
}
/* No path found — fall through to normal CALLS edge */
@@ -2827,6 +2834,10 @@ static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CB
static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, CBMLanguage lang) {
/* Framework mount of this file's routes (Laravel 11+ withRouting, #1146). */
char route_mount[CBM_SZ_128];
cbm_laravel_file_route_mount(rc->repo_path, rel, lang, result, route_mount,
sizeof(route_mount));
/* Two occurrence-aware indexes preserve the authoritative matcher's
* primary ordering without restoring its O(calls × resolutions) scan:
* exact caller+leaf+span first, then the legacy caller+leaf fallback.
@@ -3096,7 +3107,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
.strategy = "service_pattern"};
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, false);
imp_count, false, route_mount);
continue;
}
}
@@ -3113,7 +3124,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* self-call, so it keeps only the route/service edges. */
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, rust_external);
imp_count, rust_external, route_mount);
} else if (cbm_service_pattern_is_global_fetch(call->callee_name)) {
/* Native `fetch()` (#856): only the global API once resolution
* has failed to find a local/imported `fetch`. Call the low-level
@@ -3165,7 +3176,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
if (url_or_topic) {
emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
false);
false, route_mount);
ws->calls_resolved++;
}
}
@@ -3174,7 +3185,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
_rc_t0 = extract_now_ns();
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
drop_plain_call);
drop_plain_call, route_mount);
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
memory_order_relaxed);
ws->calls_resolved++;
+149 -3
View File
@@ -144,12 +144,19 @@ static int et_edge_present(const EtFile *files, int nfiles, const char *edge, in
enum { ET_ROUTE_ASSERT_MAX = 16 };
static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int n_mean);
/* Assert the exact Route node set. Edge-count smoke tests cannot catch partial
* Spring paths such as "/orders" when the real route is "/api/orders", and a
* presence-only assertion would still allow stale partial Route nodes to leak. */
static int et_routes_exact(const EtFile *files, int nfiles, const char **routes) {
* presence-only assertion would still allow stale partial Route nodes to leak.
* parallel pads the fixture past MIN_FILES_FOR_PARALLEL so the parallel
* resolver mints the routes; must_qn (optional) is a Route qualified_name that
* must exist -- the identity cross-repo HTTP matching joins on. */
static int et_routes_exact_mode(const EtFile *files, int nfiles, const char **routes,
bool parallel, const char *must_qn) {
EtProj lp;
cbm_store_t *store = et_index_files(&lp, files, nfiles);
cbm_store_t *store =
parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles);
cbm_node_t *nodes = NULL;
int node_count = 0;
int wanted = 0;
@@ -197,11 +204,26 @@ static int et_routes_exact(const EtFile *files, int nfiles, const char **routes)
fprintf(stderr, "\n");
}
if (store && must_qn) {
cbm_node_t qn_node;
memset(&qn_node, 0, sizeof(qn_node));
if (cbm_store_find_node_by_qn(store, lp.project, must_qn, &qn_node) != CBM_STORE_OK) {
fprintf(stderr, " [ET-ROUTE] FAIL missing qualified_name %s\n", must_qn);
ok = 0;
} else {
cbm_node_free_fields(&qn_node);
}
}
cbm_store_free_nodes(nodes, node_count);
et_cleanup(&lp, store);
return ok;
}
static int et_routes_exact(const EtFile *files, int nfiles, const char **routes) {
return et_routes_exact_mode(files, nfiles, routes, false, NULL);
}
/* Index meaningful[] plus PARALLEL_PAD_FILES trivial pad files to force the
* parallel pipeline path (MIN_FILES_FOR_PARALLEL = 50). */
enum { ET_PARALLEL_PAD = 52, ET_PAD_MAX = 68 /* 52 pad + 16 meaningful */ };
@@ -651,6 +673,124 @@ TEST(handles_laravel_facade_no_junk_routes_issue952) {
PASS();
}
/* #1146: Laravel 11+ mounts the files named in bootstrap/app.php's
* `->withRouting(api: ...)` under `apiPrefix` (default 'api'); the `web:` file
* gets no prefix. The prefix is declared in a DIFFERENT file than the routes,
* so per-file extraction alone minted `/users/me` where the runtime route is
* `/api/users/me`, and cross-repo HTTP matching (which joins on the Route
* qualified_name) never linked real callers. */
#define ET_L11_BOOTSTRAP_HEAD \
"<?php\n\nuse Illuminate\\Foundation\\Application;\n\n" \
"return Application::configure(basePath: dirname(__DIR__))\n" \
" ->withRouting(\n"
#define ET_L11_BOOTSTRAP_TAIL \
" )\n" \
" ->withMiddleware(function ($middleware) {\n //\n })\n" \
" ->create();\n"
static const char ET_L11_API_ROUTES[] =
"<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
"Route::prefix('/users')->middleware('auth')->group(function (): void {\n"
" Route::get('/me', [UserController::class, 'me']);\n"
"});\n"
"Route::post('/orders', [OrderController::class, 'store']);\n";
static const char ET_L11_WEB_ROUTES[] = "<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/dashboard', [HomeController::class, 'index']);\n";
/* Default convention: `api:` given, no `apiPrefix:` -> '/api'. */
TEST(routes_laravel_withrouting_api_default_prefix_issue1146) {
static const char *routes[] = {"/api/users/me", "/api/orders", NULL};
static const EtFile f[] = {
{"bootstrap/app.php", ET_L11_BOOTSTRAP_HEAD
" web: __DIR__.'/../routes/web.php',\n"
" api: __DIR__.'/../routes/api.php',\n"
" commands: __DIR__.'/../routes/console.php',\n"
" health: '/up',\n" ET_L11_BOOTSTRAP_TAIL},
{"routes/api.php", ET_L11_API_ROUTES}};
ASSERT_TRUE(et_routes_exact_mode(f, 2, routes, false, "__route__GET__/api/users/me"));
PASS();
}
/* Explicit `apiPrefix:` (multi-segment, array form of `api:`) replaces the
* default for every mounted file. */
TEST(routes_laravel_withrouting_api_custom_prefix_issue1146) {
static const char *routes[] = {"/v1/api/users/me", "/v1/api/orders", "/v1/api/partners",
NULL};
static const EtFile f[] = {
{"bootstrap/app.php", ET_L11_BOOTSTRAP_HEAD
" api: [__DIR__.'/../routes/api.php', __DIR__.'/../routes/partner.php'],\n"
" apiPrefix: 'v1/api',\n" ET_L11_BOOTSTRAP_TAIL},
{"routes/api.php", ET_L11_API_ROUTES},
{"routes/partner.php", "<?php\nuse Illuminate\\Support\\Facades\\Route;\n\n"
"Route::get('/partners', [PartnerController::class, 'index']);\n"}};
ASSERT_TRUE(et_routes_exact_mode(f, 3, routes, false, "__route__GET__/v1/api/partners"));
PASS();
}
/* The `web:` file of the same app is NOT mounted under the api prefix. */
TEST(routes_laravel_withrouting_web_no_prefix_issue1146) {
static const char *routes[] = {"/dashboard", "/api/users/me", "/api/orders", NULL};
static const EtFile f[] = {
{"bootstrap/app.php", ET_L11_BOOTSTRAP_HEAD
" web: __DIR__.'/../routes/web.php',\n"
" api: __DIR__.'/../routes/api.php',\n" ET_L11_BOOTSTRAP_TAIL},
{"routes/web.php", ET_L11_WEB_ROUTES},
{"routes/api.php", ET_L11_API_ROUTES}};
ASSERT_TRUE(et_routes_exact(f, 3, routes));
PASS();
}
/* Control: the Laravel <= 10 RouteServiceProvider form (no withRouting, the
* prefix lives on a `->group(base_path(...))` call) is not composed today and
* must stay exactly as it is -- no filename-based `/api` inference. */
TEST(routes_laravel_routeserviceprovider_control_issue1146) {
static const char *routes[] = {"/users/me", "/orders", NULL};
static const EtFile f[] = {
{"bootstrap/app.php",
"<?php\n\n$app = new Illuminate\\Foundation\\Application(\n"
" $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)\n);\n\nreturn $app;\n"},
{"app/Providers/RouteServiceProvider.php",
"<?php\nnamespace App\\Providers;\n\n"
"use Illuminate\\Support\\Facades\\Route;\n\n"
"class RouteServiceProvider extends ServiceProvider {\n"
" public function boot(): void {\n"
" $this->routes(function () {\n"
" Route::middleware('api')->prefix('api')"
"->group(base_path('routes/api.php'));\n"
" Route::middleware('web')->group(base_path('routes/web.php'));\n"
" });\n }\n}\n"},
{"routes/api.php", ET_L11_API_ROUTES}};
ASSERT_TRUE(et_routes_exact(f, 3, routes));
PASS();
}
/* A non-literal `apiPrefix:` is unknown at index time: leave the paths alone
* rather than guess the default. */
TEST(routes_laravel_withrouting_nonliteral_prefix_issue1146) {
static const char *routes[] = {"/users/me", "/orders", NULL};
static const EtFile f[] = {
{"bootstrap/app.php", ET_L11_BOOTSTRAP_HEAD
" api: __DIR__.'/../routes/api.php',\n"
" apiPrefix: config('app.api_prefix'),\n" ET_L11_BOOTSTRAP_TAIL},
{"routes/api.php", ET_L11_API_ROUTES}};
ASSERT_TRUE(et_routes_exact(f, 2, routes));
PASS();
}
/* Same contract on the PARALLEL resolver (> 50 files), with the Laravel app
* in a monorepo subdirectory: the nearest ancestor bootstrap/app.php owns it. */
TEST(routes_laravel_withrouting_parallel_subdir_issue1146) {
static const char *routes[] = {"/api/users/me", "/api/orders", NULL};
static const EtFile f[] = {
{"gateway/bootstrap/app.php", ET_L11_BOOTSTRAP_HEAD
" web: __DIR__.'/../routes/web.php',\n"
" api: __DIR__.'/../routes/api.php',\n" ET_L11_BOOTSTRAP_TAIL},
{"gateway/routes/api.php", ET_L11_API_ROUTES}};
ASSERT_TRUE(et_routes_exact_mode(f, 2, routes, true, "__route__POST__/api/orders"));
PASS();
}
/* Rails (Ruby) — ActionDispatch router. The handler MUST be passed as a bare
* identifier (not the idiomatic `to: 'list_items'` string, which extract_handler_arg
* cannot capture). mapper.get resolves by name to the Mapper#get method whose QN
@@ -1708,6 +1848,12 @@ SUITE(edge_types_probe) {
RUN_TEST(handles_laravel_php);
RUN_TEST(handles_laravel_facade_routes_issue952);
RUN_TEST(handles_laravel_facade_no_junk_routes_issue952);
RUN_TEST(routes_laravel_withrouting_api_default_prefix_issue1146);
RUN_TEST(routes_laravel_withrouting_api_custom_prefix_issue1146);
RUN_TEST(routes_laravel_withrouting_web_no_prefix_issue1146);
RUN_TEST(routes_laravel_routeserviceprovider_control_issue1146);
RUN_TEST(routes_laravel_withrouting_nonliteral_prefix_issue1146);
RUN_TEST(routes_laravel_withrouting_parallel_subdir_issue1146);
RUN_TEST(handles_rails_ruby);
RUN_TEST(handles_actix_rust);