From 7ddbe66542a448c63777b716ebbb509f20c8b146 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 28 Sep 2026 19:34:03 +0200 Subject: [PATCH] fix(routes): compose the Laravel 11+ withRouting api prefix into Route paths (#1146) Laravel 11+ registers its route files in bootstrap/app.php through Application::configure()->withRouting(web: ..., api: ..., apiPrefix: ...) and mounts every file passed as `api:` under `apiPrefix` (default 'api'). That prefix lives in a different file than the routes, so per-file extraction -- which already composes the in-file Route::prefix()->group() chain (#952) -- minted `GET /users/me` for a route Laravel serves at `GET /api/users/me`. The Route qualified_name is the join key for cross-repo HTTP matching, so real callers of /api/... never linked, and an api route could silently merge with a same-path web route. Fix: a small Laravel mount resolver, src/pipeline/laravel_routing.c. For each PHP file that registers a path-shaped route, both route passes (the sequential calls pass and the parallel resolver) look up the nearest ancestor bootstrap/app.php once, parse it with the PHP grammar, and, when its withRouting(api: ...) names that file, prepend the apiPrefix to every Route minted from it. Only source facts are used: an `api:` entry must be a __DIR__ / dirname(__DIR__) concatenation or a base_path() call over a string literal (single path or array), and a given apiPrefix must be a string literal. A non-literal apiPrefix, positional arguments or a `using:` callback leave the paths untouched, the `web:` file is never prefixed, and nothing is inferred from file names. The lookup keys only on the repo path and the file path, so full, incremental and delta runs compose the same identity. Proof (origin/main 1f9b4db1 vs this change, same machine): - krayin/laravel-crm @03ebedd7: GET /user -> GET /api/user; the other four Route nodes are unchanged. - filamentphp/demo @d5b618a1: GET /user -> GET /api/user. - laravelio/laravel.io @24be4893: the api.php POST '/' inside prefix('articles') was merged into web.php's POST /articles node; it is now its own POST /api/articles (39 -> 40 Route nodes, every web route unchanged). - Cross-repo: a TypeScript client calling axios.get('/api/users/me') and axios.post('/api/orders') against a Laravel 11 server: CROSS_HTTP_CALLS 0 -> 2. Tests (edge_types_probe): default api prefix plus the composed qualified_name, a custom multi-segment apiPrefix with the array form of `api:`, the `web:` file stays unprefixed, a Laravel <= 10 RouteServiceProvider control stays as it is, a non-literal apiPrefix is left alone, and the parallel resolver with the app in a monorepo subdirectory. The four bug tests were RED 3x before the fix, pass with it, and go RED again with only the composition reverted. Fixes #1146 Signed-off-by: Martin Vogel --- Makefile.cbm | 1 + src/pipeline/laravel_routing.c | 403 +++++++++++++++++++++++++++++++++ src/pipeline/laravel_routing.h | 48 ++++ src/pipeline/pass_calls.c | 39 ++-- src/pipeline/pass_parallel.c | 27 ++- tests/test_edge_types_probe.c | 152 ++++++++++++- 6 files changed, 646 insertions(+), 24 deletions(-) create mode 100644 src/pipeline/laravel_routing.c create mode 100644 src/pipeline/laravel_routing.h diff --git a/Makefile.cbm b/Makefile.cbm index 963c0229c..10999d68e 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -436,6 +436,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 \ diff --git a/src/pipeline/laravel_routing.c b/src/pipeline/laravel_routing.c new file mode 100644 index 000000000..44acf8e28 --- /dev/null +++ b/src/pipeline/laravel_routing.c @@ -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 +#include +#include +#include + +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 //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; +} diff --git a/src/pipeline/laravel_routing.h b/src/pipeline/laravel_routing.h new file mode 100644 index 000000000..66e2d6a75 --- /dev/null +++ b/src/pipeline/laravel_routing.h @@ -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 +#include + +/* 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 */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index f068ad411..7f0a5ae64 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -19,6 +19,7 @@ enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 }; #include #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++; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a91acaf83..bbd1e5719 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -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++; diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index b591c5bf2..532ed7b1c 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -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 \ + "withRouting(\n" +#define ET_L11_BOOTSTRAP_TAIL \ + " )\n" \ + " ->withMiddleware(function ($middleware) {\n //\n })\n" \ + " ->create();\n" + +static const char ET_L11_API_ROUTES[] = + "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[] = " '/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", "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", + "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);