diff --git a/Makefile.cbm b/Makefile.cbm index cf679a908..d76269f66 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -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 \ 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);