Merge remote-tracking branch 'origin/main' into fix/data-flows-test-file-scope

Conflict in tests/test_edge_types_probe.c: both sides added tests after
et_handles_exact (the R1 + R2 DATA_FLOWS tests and the #1692 ASP.NET
overload test); both are kept.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-19 07:50:13 +02:00
commit 2cda42e894
8 files changed
+727 -24

No files matched your search

+323
View File
@@ -3584,6 +3584,325 @@ static const char *join_route_paths(CBMArena *a, const char *prefix, const char
return cbm_arena_sprintf(a, "%s%s", prefix, path);
}
/* ── ASP.NET Core attribute routing (C#, #1041 / #1692) ──────────────────────
* Controllers route through attributes rather than Spring-style mapping names:
* [Route("api/[controller]")] class QuotesController -> class prefix
* [HttpGet("{symbol}")] / [HttpPost] + [Route("x/{id}")] -> action route
* [AcceptVerbs("GET", "HEAD", Route = "x")]
* A template is relative to the class prefix unless it starts with "/" or
* "~/". The [controller]/[action]/[area] tokens are replaced and constraints,
* defaults and optional markers reduce to the bare parameter ("{id:int}" ->
* "{id}"), so the result leads with '/' and canonicalizes like any other
* decorator route. */
typedef struct {
const char *method; /* HTTP verb of the first verb attribute */
const char *verb_tmpl; /* its template (AcceptVerbs: Route = "...") */
const char *route_tmpl; /* template of the first [Route] */
const char *action_name; /* [ActionName("x")] */
const char *area; /* [Area("x")] */
bool has_route; /* a [Route] attribute was seen */
} aspnet_attrs_t;
/* Attribute name without namespace qualifier or "Attribute" suffix:
* Microsoft.AspNetCore.Mvc.HttpGetAttribute -> HttpGet. */
static const char *aspnet_attr_name(CBMArena *a, TSNode attr, const char *source) {
TSNode name_node = ts_node_child_by_field_name(attr, TS_FIELD("name"));
if (ts_node_is_null(name_node)) {
return NULL;
}
const char *name = cbm_node_text(a, name_node, source);
if (!name) {
return NULL;
}
const char *sep = strrchr(name, '.');
const char *colon = strrchr(name, ':');
if (colon > sep) {
sep = colon;
}
if (sep) {
name = sep + SKIP_CHAR;
}
static const char suffix[] = "Attribute";
size_t len = strlen(name);
size_t slen = sizeof(suffix) - SKIP_CHAR;
if (len > slen && strcmp(name + len - slen, suffix) == 0) {
return cbm_arena_strndup(a, name, len - slen);
}
return name;
}
static const char *aspnet_verb(const char *name) {
static const char *const verbs[][PAIR_CHARS] = {
{"HttpGet", "GET"}, {"HttpPost", "POST"}, {"HttpPut", "PUT"},
{"HttpDelete", "DELETE"}, {"HttpPatch", "PATCH"}, {"HttpHead", "HEAD"},
{"HttpOptions", "OPTIONS"}};
for (size_t i = 0; i < sizeof(verbs) / sizeof(verbs[0]); i++) {
if (strcmp(name, verbs[i][0]) == 0) {
return verbs[i][SKIP_CHAR];
}
}
return NULL;
}
/* Unquoted text of a C# "x" or @"x" string literal node; NULL otherwise. */
static const char *aspnet_string_value(CBMArena *a, TSNode node, const char *source) {
const char *kind = ts_node_type(node);
bool verbatim = strcmp(kind, "verbatim_string_literal") == 0;
if (!verbatim && strcmp(kind, "string_literal") != 0) {
return NULL;
}
const char *text = cbm_node_text(a, node, source);
size_t open = verbatim ? PAIR_CHARS : SKIP_CHAR; /* `@"` or `"` */
size_t len = text ? strlen(text) : 0;
if (len < open + SKIP_CHAR) {
return NULL;
}
return cbm_arena_strndup(a, text + open, len - open - SKIP_CHAR);
}
/* The first positional string argument (key == NULL) or the string value of
* the named argument `key` (Route = "..."). */
static const char *aspnet_attr_arg(CBMArena *a, TSNode attr, const char *source, const char *key) {
TSNode args = cbm_find_child_by_kind(attr, "attribute_argument_list");
if (ts_node_is_null(args)) {
return NULL;
}
uint32_t nc = ts_node_named_child_count(args);
for (uint32_t i = 0; i < nc; i++) {
TSNode arg = ts_node_named_child(args, i);
uint32_t vc = ts_node_named_child_count(arg);
if (strcmp(ts_node_type(arg), "attribute_argument") != 0 || vc == 0) {
continue;
}
TSNode name = ts_node_child_by_field_name(arg, TS_FIELD("name"));
TSNode value = ts_node_named_child(arg, vc - SKIP_CHAR);
if (ts_node_is_null(name) != (key == NULL)) {
continue; /* positional wanted but named seen, or vice versa */
}
if (key) {
const char *n = cbm_node_text(a, name, source);
if (!n || strcmp(n, key) != 0) {
continue;
}
}
return aspnet_string_value(a, value, source);
}
return NULL;
}
/* [AcceptVerbs("get", ...)]: first verb upper-cased; letters only, since the
* verb lands in the Route QN and its JSON properties unescaped. */
static const char *aspnet_accept_verb(CBMArena *a, const char *verb) {
enum { VERB_MAX = 7 }; /* OPTIONS */
size_t len = verb ? strlen(verb) : 0;
if (len == 0 || len > VERB_MAX) {
return "ANY";
}
char *up = cbm_arena_strndup(a, verb, len);
for (size_t i = 0; i < len; i++) {
if (!isalpha((unsigned char)up[i])) {
return "ANY";
}
up[i] = (char)toupper((unsigned char)up[i]);
}
return up;
}
static void aspnet_note_attr(CBMArena *a, TSNode attr, const char *source, aspnet_attrs_t *out) {
const char *name = aspnet_attr_name(a, attr, source);
if (!name) {
return;
}
const char *verb = aspnet_verb(name);
if (verb || strcmp(name, "AcceptVerbs") == 0) {
if (!out->method) {
out->method =
verb ? verb : aspnet_accept_verb(a, aspnet_attr_arg(a, attr, source, NULL));
out->verb_tmpl = aspnet_attr_arg(a, attr, source, verb ? NULL : "Route");
}
} else if (strcmp(name, "Route") == 0) {
if (!out->has_route) {
out->has_route = true;
out->route_tmpl = aspnet_attr_arg(a, attr, source, NULL);
}
} else if (strcmp(name, "ActionName") == 0) {
out->action_name = aspnet_attr_arg(a, attr, source, NULL);
} else if (strcmp(name, "Area") == 0) {
out->area = aspnet_attr_arg(a, attr, source, NULL);
}
}
static void aspnet_scan_attrs(CBMArena *a, TSNode owner, const char *source, aspnet_attrs_t *out) {
memset(out, 0, sizeof(*out));
TSNode wrappers[MAX_ATTR_WRAPPERS];
int wn = find_jvm_modifiers(owner, CBM_LANG_CSHARP, wrappers, MAX_ATTR_WRAPPERS);
for (int w = 0; w < wn; w++) {
uint32_t cc = ts_node_named_child_count(wrappers[w]);
for (uint32_t ci = 0; ci < cc; ci++) {
TSNode child = ts_node_named_child(wrappers[w], ci);
if (strcmp(ts_node_type(child), "attribute") == 0) {
aspnet_note_attr(a, child, source, out);
}
}
}
}
/* `name` minus `suffix` when it is a proper suffix (QuotesController ->
* Quotes, GetAsync -> Get); `name` otherwise. */
static const char *aspnet_trim_suffix(CBMArena *a, const char *name, const char *suffix) {
size_t len = name ? strlen(name) : 0;
size_t slen = strlen(suffix);
if (len > slen && strcmp(name + len - slen, suffix) == 0) {
return cbm_arena_strndup(a, name, len - slen);
}
return name;
}
typedef struct {
char *buf;
size_t len;
size_t cap;
} aspnet_buf_t;
static void aspnet_putc(aspnet_buf_t *b, char c) {
if (c == '/' && b->len > 0 && b->buf[b->len - SKIP_CHAR] == '/') {
return; /* "api/" + "/x" and empty token values never double a slash */
}
if (b->len + SKIP_CHAR < b->cap) {
b->buf[b->len++] = c;
}
}
static void aspnet_putn(aspnet_buf_t *b, const char *s, size_t n) {
for (size_t i = 0; i < n && s[i]; i++) {
aspnet_putc(b, s[i]);
}
}
/* Index just past the '}' closing the '{' at t[open] (braces nest, as in
* "{code:regex(^\d{{3}}$)}"); 0 when unterminated. */
static size_t aspnet_brace_end(const char *t, size_t open) {
int depth = 0;
for (size_t j = open; t[j]; j++) {
if (t[j] == '{') {
depth++;
} else if (t[j] == '}' && --depth == 0) {
return j + SKIP_CHAR;
}
}
return 0;
}
/* "{id:int}", "{page=1}", "{slug?}", "{**path}" -> "{id}", "{page}", "{slug}",
* "{**path}": keep the catch-all stars and the parameter name. */
static void aspnet_put_param(aspnet_buf_t *b, const char *t, size_t open, size_t end) {
size_t i = open + SKIP_CHAR;
aspnet_putc(b, '{');
while (i < end && t[i] == '*') {
aspnet_putc(b, t[i++]);
}
while (i < end && (isalnum((unsigned char)t[i]) || t[i] == '_')) {
aspnet_putc(b, t[i++]);
}
aspnet_putc(b, '}');
}
enum { ASPNET_TOKENS = 3 }; /* [controller], [action], [area] */
/* Replacement for the [controller]/[action]/[area] token starting at t[i]
* (values[k] belongs to tokens[k]); NULL when t[i] starts no known token or
* its value is unknown, so the token is kept verbatim. */
static const char *aspnet_token_value(const char *t, size_t i, const char *const *values,
size_t *tok_len) {
static const char *const tokens[ASPNET_TOKENS] = {"[controller]", "[action]", "[area]"};
for (size_t k = 0; k < ASPNET_TOKENS; k++) {
size_t n = strlen(tokens[k]);
if (values[k] && strncmp(t + i, tokens[k], n) == 0) {
*tok_len = n;
return values[k];
}
}
return NULL;
}
/* Normalize one ASP.NET route template into a '/'-led path (see the section
* comment above). values = {controller, action, area}. */
static const char *aspnet_route_template(CBMArena *a, const char *t, const char *const *values) {
size_t extra = 0;
for (int k = 0; k < ASPNET_TOKENS; k++) {
extra += values[k] ? strlen(values[k]) : 0;
}
size_t brackets = 0;
for (const char *p = t; *p; p++) {
brackets += *p == '[';
}
aspnet_buf_t b = {0};
b.cap = strlen(t) + (brackets * extra) + PAIR_CHARS;
b.buf = (char *)cbm_arena_alloc(a, b.cap);
if (!b.buf) {
return NULL;
}
aspnet_putc(&b, '/');
size_t i = (t[0] == '~') ? SKIP_CHAR : 0;
while (t[i]) {
size_t tok_len = 0;
const char *val = (t[i] == '[') ? aspnet_token_value(t, i, values, &tok_len) : NULL;
size_t end = (t[i] == '{' && t[i + SKIP_CHAR] != '{') ? aspnet_brace_end(t, i) : 0;
if (val) {
aspnet_putn(&b, val, strlen(val));
i += tok_len;
} else if (end) {
aspnet_put_param(&b, t, i, end);
i = end;
} else if ((t[i] == '{' || t[i] == '}' || t[i] == '[' || t[i] == ']') &&
t[i + SKIP_CHAR] == t[i]) {
aspnet_putc(&b, t[i]); /* "{{" / "[[" escapes a literal brace */
i += PAIR_CHARS;
} else {
aspnet_putc(&b, t[i++]);
}
}
if (b.len > SKIP_CHAR && b.buf[b.len - SKIP_CHAR] == '/') {
b.len--;
}
b.buf[b.len] = '\0';
return b.buf;
}
static bool aspnet_template_is_absolute(const char *t) {
return t[0] == '/' || (t[0] == '~' && t[SKIP_CHAR] == '/');
}
/* Route of one controller action: sets out_path and out_method when the method
* carries a verb attribute, [AcceptVerbs] or [Route]; clears them otherwise. */
static void aspnet_action_route(CBMArena *a, TSNode method_node, TSNode class_node,
const char *method_name, const char *source, const char **out_path,
const char **out_method) {
*out_path = NULL;
*out_method = NULL;
aspnet_attrs_t act;
aspnet_scan_attrs(a, method_node, source, &act);
if (!act.method && !act.has_route) {
return;
}
aspnet_attrs_t cls;
aspnet_scan_attrs(a, class_node, source, &cls);
TSNode cname = ts_node_child_by_field_name(class_node, TS_FIELD("name"));
const char *ctrl = ts_node_is_null(cname) ? NULL : cbm_node_text(a, cname, source);
const char *values[ASPNET_TOKENS] = {
aspnet_trim_suffix(a, ctrl, "Controller"),
act.action_name ? act.action_name : aspnet_trim_suffix(a, method_name, "Async"),
act.area ? act.area : cls.area,
};
const char *tmpl = act.verb_tmpl ? act.verb_tmpl : act.route_tmpl;
const char *path = tmpl ? aspnet_route_template(a, tmpl, values) : NULL;
if ((!tmpl || !aspnet_template_is_absolute(tmpl)) && cls.route_tmpl) {
path = join_route_paths(a, aspnet_route_template(a, cls.route_tmpl, values), path);
}
*out_path = path ? path : "/";
*out_method = act.method ? act.method : "ANY";
}
static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, const char *source,
const CBMLangSpec *spec) {
const char *map_path = NULL;
@@ -8447,6 +8766,10 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node,
def.route_path = prefix ? join_route_paths(a, prefix, def.route_path) : NULL;
def.route_method = prefix ? def.route_method : NULL;
}
if (ctx->language == CBM_LANG_CSHARP) {
aspnet_action_route(a, child, class_node, name, ctx->source, &def.route_path,
&def.route_method);
}
def.docstring = extract_docstring(ctx, child, name);
if (spec->branching_node_types && spec->branching_node_types[0]) {
+5
View File
@@ -500,6 +500,11 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const
if (props != stack) {
cbm_free(CBM_MEM_CLASS_GBUF_STRING, props);
}
/* Per definition, like pass_parallel.c: overloads sharing this QN each keep
* their own route (the merged node properties hold only one of them). */
if (node_id > 0) {
cbm_pipeline_emit_def_route(ctx->gbuf, node_id, def, def_rel);
}
/* Registry membership is defined ONCE by cbm_label_is_registry_symbol
* (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/
* field resolution), Variable/Field (READS/WRITES resolution), and Table/View
+1 -17
View File
@@ -826,23 +826,7 @@ static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_stem_mark
cbm_free(CBM_MEM_CLASS_GBUF_STRING, props);
}
ws->nodes_created++;
if (def->route_path && def->route_path[0] != '\0') {
const char *rm = def->route_method ? def->route_method : "ANY";
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm,
cbm_route_canon_path(def->route_path, cpath, sizeof(cpath)));
char rprops[CBM_SZ_256];
snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm);
int64_t route_id =
cbm_gbuf_upsert_node(ws->local_gbuf, "Route", def->route_path, route_qn,
def->file_path ? def->file_path : fi->rel_path, 0, 0, rprops);
char hprops[CBM_SZ_512];
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name);
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
cbm_gbuf_insert_edge(ws->local_gbuf, func_id, route_id, "HANDLES", hprops);
}
cbm_pipeline_emit_def_route(ws->local_gbuf, func_id, def, fi->rel_path);
}
static void log_extract_fail(int pos, uint64_t ms, const char *path) {
+45 -7
View File
@@ -2833,15 +2833,53 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t
static const char php_import_complete[] = "{\"php_imports_complete\":1,";
/* A resolver NULL can also mean unsupported input or a failed lookup. Only
* the bounded namespace-only path admits an allocation-free absence proof.
* A covered-but-missing PSR-4 file, global use, or unavailable namespace map
* leaves the whole file unknown; already resolved IMPORTS are still written. */
static bool php_import_known_absent(const char *rel, const CBMImport *imp,
* the bounded namespace-only path and an unaliased global class `use` with no
* same-named PHP type anywhere admit an absence proof. A covered-but-missing
* PSR-4 file, any other global use, or an unavailable namespace map leaves the
* whole file unknown; already resolved IMPORTS are still written. */
/* A global-namespace class `use Exception;` names a project type only when a
* PHP file declares a class-like symbol of that name. With no such node at all
* (namespaced ones included, so the proof stays conservative), the import is
* absent from the project: the PHP builtin or a vendor class. Without this a
* single `use Exception;` left the whole file's evidence unknown, and its
* project imports reached the PHP LSP as File QNs again. */
static bool php_global_use_known_absent(const cbm_gbuf_t *gbuf, const CBMImport *imp) {
if (!gbuf || imp->kind != CBM_IMPORT_KIND_DEFAULT) {
return false;
}
const char *name = imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path;
if (!name[0] || strcmp(name, imp->local_name) != 0) {
return false; /* `use X as Y;` keeps the old, unknown verdict */
}
const cbm_gbuf_node_t **nodes = NULL;
int count = 0;
if (cbm_gbuf_find_by_name(gbuf, name, &nodes, &count) != 0) {
return false;
}
for (int i = 0; i < count; i++) {
const cbm_gbuf_node_t *n = nodes[i];
if (!n || !n->label || !node_in_php_file(n)) {
continue;
}
if (strcmp(n->label, "Class") == 0 || strcmp(n->label, "Interface") == 0 ||
strcmp(n->label, "Trait") == 0 || strcmp(n->label, "Enum") == 0 ||
strcmp(n->label, "File") == 0 || strcmp(n->label, "Module") == 0) {
return false;
}
}
return true;
}
static bool php_import_known_absent(const cbm_gbuf_t *gbuf, const char *rel, const CBMImport *imp,
CBMHashTable *namespace_map) {
if (!namespace_map || !imp->module_path || !imp->local_name || !imp->local_name[0] ||
(imp->kind != CBM_IMPORT_KIND_DEFAULT && imp->kind != CBM_IMPORT_KIND_FUNCTION &&
imp->kind != CBM_IMPORT_KIND_CONST) ||
php_use_shape(rel, imp) != PHP_USE_NAMESPACED)
imp->kind != CBM_IMPORT_KIND_CONST))
return false;
php_use_shape_t shape = php_use_shape(rel, imp);
if (shape == PHP_USE_GLOBAL)
return php_global_use_known_absent(gbuf, imp);
if (shape != PHP_USE_NAMESPACED)
return false;
const char *name = imp->module_path[0] == '\\' ? imp->module_path + 1 : imp->module_path;
char key[CBM_SZ_1K];
@@ -3072,7 +3110,7 @@ int cbm_pipeline_php_create_import_edges(cbm_pipeline_ctx_t *ctx, const CBMFileR
: NULL;
if (targets[i] && targets[i]->id == file->id)
targets[i] = NULL;
if (!targets[i] && !php_import_known_absent(rel, imp, namespace_map))
if (!targets[i] && !php_import_known_absent(ctx->gbuf, rel, imp, namespace_map))
complete = false;
yyjson_mut_val *entry =
php_binding_json(doc, imp, targets[i] ? targets[i]->qualified_name : NULL);
+21
View File
@@ -397,6 +397,27 @@ static void match_infra_routes(cbm_gbuf_t *gb) {
}
}
void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefinition *def,
const char *rel) {
if (!def->route_path || def->route_path[0] == '\0') {
return;
}
const char *rm = def->route_method ? def->route_method : "ANY";
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm,
cbm_route_canon_path(def->route_path, cpath, sizeof(cpath)));
char rprops[CBM_SZ_256];
snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm);
int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", def->route_path, route_qn,
def->file_path ? def->file_path : rel, 0, 0, rprops);
char hprops[CBM_SZ_512];
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name);
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
cbm_gbuf_insert_edge(gb, func_id, route_id, "HANDLES", hprops);
}
/* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges.
* During incremental indexing, only changed files get Route nodes from extraction.
* This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */
+9
View File
@@ -302,6 +302,15 @@ void cbm_pxc_test_import_probe_fail_after(int successful_allocations);
/* cbm_route_canon_path: cbm.h (route_path.c). */
/* A definition's decorator route (def->route_path, e.g. an ASP.NET [HttpGet("x")]
* action): upsert its Route node and the definition -> Route HANDLES edge. Both
* resolvers call it per DEFINITION at node creation (pass_parallel.c,
* pass_definitions.c), so overloads sharing one QN -- a GET form action and its
* POST submit -- each keep their route; the merged node properties hold only one
* of them. Defined in pass_route_nodes.c. */
void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefinition *def,
const char *rel);
/* A file and the directory beside it share a stem (`pkg/foo.sh` beside
* `pkg/foo/`): the file's Module QN equals the directory's Folder QN, and the
* graph buffer keeps only the Folder. One side yields:
+154
View File
@@ -812,6 +812,126 @@ TEST(handles_aspnet_csharp) {
PASS();
}
/* ASP.NET Core attribute routing (C#, #1692) — the reporter's controller shape:
* stacked class attributes with a relative [Route("api/[controller]")] prefix,
* verb attributes with and without a template, and a verb + separate method
* [Route]. Every action got 0 Route nodes: the route extractor knew no
* HttpGet/HttpPost/Route attribute, and relative templates were dropped for lacking a
* leading '/'. The exact Route-name set also pins the [controller] token
* (class name minus "Controller") and the class-prefix join. */
TEST(handles_aspnet_attribute_routes_issue1692) {
static const char *routes[] = {"/api/Quotes/{symbol}", "/api/Quotes/orders/{id}/confirm",
"/api/Quotes/x/{id}", NULL};
static const EtFile f[] = {
{"Controllers/QuotesController.cs",
"using Microsoft.AspNetCore.Mvc;\n\n"
"namespace Feeder.Api.Controllers\n{\n"
" [ServiceFilter(typeof(ClientIpCheckFilter))]\n"
" [Route(\"api/[controller]\")]\n"
" [ApiController]\n"
" public class QuotesController : ControllerBase\n {\n"
" [HttpGet(\"{symbol}\")]\n"
" public IActionResult Get(string symbol) { return Ok(symbol); }\n\n"
" [HttpPost]\n"
" [Route(\"orders/{id}/confirm\")]\n"
" public IActionResult Confirm(int id) { return Ok(id); }\n\n"
" [HttpPut(\"x/{id}\")]\n"
" public IActionResult Update(int id) { return Ok(id); }\n }\n}\n"}};
ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 3));
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* ASP.NET Core (#1041) — the reporter's shape: [ApiController] before a
* versioned [Route("api/v1/[controller]")] and an async [HttpPost("login")]
* action produced no server-side Route node at all. */
TEST(handles_aspnet_attribute_routes_issue1041) {
static const char *routes[] = {"/api/v1/Auth/login", NULL};
static const EtFile f[] = {
{"Controllers/AuthController.cs",
"using System.Threading.Tasks;\n"
"using Microsoft.AspNetCore.Mvc;\n\n"
"[ApiController]\n"
"[Route(\"api/v1/[controller]\")]\n"
"public class AuthController : ControllerBase\n{\n"
" [HttpPost(\"login\")]\n"
" public async Task<IActionResult> Login(LoginRequest req) { return Ok(); }\n}\n"}};
ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 1));
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* ASP.NET Core [controller]/[action] token replacement (#1692): the action
* token is the method name ("Async" suffix trimmed, as ASP.NET Core does by
* default) or its [ActionName]; [AcceptVerbs] takes its path from Route=. */
TEST(handles_aspnet_route_tokens_issue1692) {
static const char *routes[] = {"/api/Items/List", "/api/Items/Details/{id}",
"/api/Items/Find/{name}", "/api/Items/archive", NULL};
static const EtFile f[] = {
{"ItemsController.cs", "using Microsoft.AspNetCore.Mvc;\n\n"
"[Route(\"api/[controller]\")]\n"
"public class ItemsController : Controller\n{\n"
" [HttpGet(\"[action]\")]\n"
" public IActionResult List() { return View(); }\n\n"
" [HttpGet(\"[action]/{id}\")]\n"
" public System.Threading.Tasks.Task<IActionResult> "
"DetailsAsync(int id) { return null; }\n\n"
" [HttpGet(\"[action]/{name}\")]\n"
" [ActionName(\"Find\")]\n"
" public IActionResult Search(string name) { return View(); }\n\n"
" [AcceptVerbs(\"POST\", \"PUT\", Route = \"archive\")]\n"
" public IActionResult Archive() { return View(); }\n}\n"}};
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* ASP.NET Core absolute override + constraints (#1692): a method template that
* starts with "/" or "~/" ignores the class prefix; route constraints,
* optional markers and defaults are stripped to the bare parameter so the
* Route name matches the clients' "{id}" spelling ("{code:regex(...)}" nests
* braces). A controller without a class [Route] uses the method template as
* the whole path. */
TEST(handles_aspnet_override_constraints_issue1692) {
static const char *routes[] = {
"/health", "/ping", "/api/Orders/{id}", "/api/Orders/{code}/{page}", "/api/ping", NULL};
static const EtFile f[] = {
{"OrdersController.cs",
"using Microsoft.AspNetCore.Mvc;\n\n"
"[Route(\"api/[controller]\")]\n"
"public class OrdersController : ControllerBase\n{\n"
" [HttpGet(\"/health\")]\n"
" public IActionResult Health() { return Ok(); }\n\n"
" [Microsoft.AspNetCore.Mvc.HttpHeadAttribute(\"~/ping\")]\n"
" public IActionResult Ping() { return Ok(); }\n\n"
" [HttpDelete(\"{id:int:min(1)}\")]\n"
" public IActionResult Delete(int id) { return Ok(); }\n\n"
" [HttpGet(\"{code:regex(^\\\\d{{3}}$)}/{page=1}\")]\n"
" public IActionResult ByCode(string code, int page) { return Ok(); }\n}\n"},
{"PingController.cs", "using Microsoft.AspNetCore.Mvc;\n\n"
"public class PingController : ControllerBase\n{\n"
" [HttpGet(\"api/ping\", Name = \"PingRoute\")]\n"
" public IActionResult Get() { return Ok(); }\n}\n"}};
ASSERT_TRUE(et_routes_exact(f, 2, routes));
PASS();
}
/* Control (#1692): a C# class with ordinary attributes — including string
* arguments that look like paths — must not produce Route nodes. */
TEST(handles_aspnet_non_controller_control_issue1692) {
static const char *routes[] = {NULL};
static const EtFile f[] = {{"Helper.cs", "using System;\n\n"
"[Serializable]\n"
"[Description(\"api/helper\")]\n"
"public class Helper\n{\n"
" [Obsolete(\"/use/other\")]\n"
" public void Run() { }\n\n"
" [Fact]\n"
" [Trait(\"Category\", \"api/[controller]\")]\n"
" public void Check() { }\n}\n"}};
ASSERT_TRUE(et_routes_exact(f, 1, routes));
PASS();
}
/* Laravel (PHP) — Route facade whose QN contains "Laravel".
* REAL BUG: internal/cbm/extract_calls.c:extract_handler_arg only accepts an
* identifier/member_expression/selector_expression/attribute/field_expression as
@@ -1214,6 +1334,34 @@ TEST(data_flows_test_files_pair_within_their_file_parallel) {
PASS();
}
/* ASP.NET Core MVC (#1692): a GET form action and its POST submit share one
* method name, so both overloads collapse into one Method node whose merged
* properties keep only one route. The parallel resolver emitted each
* definition's route at node creation and kept both; the sequential one rebuilt
* routes from the merged properties afterwards and kept only the last (28
* HANDLES on 6 ASP.NET corpora, 2026-10-18). Both venues keep both. */
TEST(handles_aspnet_overload_verbs_both_venues_issue1692) {
static const EtHandles want[] = {{"AccountController.Login", "/Account/login"},
{"AccountController.Login", "/Account/login"},
{NULL, NULL}};
static const char *const route_qns[] = {"__route__GET__/Account/login",
"__route__POST__/Account/login", NULL};
static const EtFile f[] = {
{"Controllers/AccountController.cs",
"using Microsoft.AspNetCore.Mvc;\n\n"
"namespace Shop.Web.Controllers\n{\n"
" [Route(\"[controller]\")]\n"
" public class AccountController : Controller\n {\n"
" [HttpGet(\"login\")]\n"
" public IActionResult Login() { return View(); }\n\n"
" [HttpPost(\"login\")]\n"
" public IActionResult Login(LoginModel model) { return Redirect(\"/\"); }\n"
" }\n}\n"}};
ASSERT_TRUE(et_handles_exact_routes(f, 1, want, 0, route_qns));
ASSERT_TRUE(et_handles_exact_routes(f, 1, want, 1, route_qns));
PASS();
}
/* #1146: Laravel's two class-based handler forms —
* Route::get('/x', [UserController::class, 'show']) (controller method)
* Route::get('/x', GetCurrentUserController::class) (invokable: __invoke)
@@ -2724,6 +2872,12 @@ SUITE(edge_types_probe) {
RUN_TEST(handles_spring_java_relative_string_not_route);
RUN_TEST(handles_jaxrs_scala);
RUN_TEST(handles_aspnet_csharp);
RUN_TEST(handles_aspnet_attribute_routes_issue1692);
RUN_TEST(handles_aspnet_attribute_routes_issue1041);
RUN_TEST(handles_aspnet_route_tokens_issue1692);
RUN_TEST(handles_aspnet_override_constraints_issue1692);
RUN_TEST(handles_aspnet_non_controller_control_issue1692);
RUN_TEST(handles_aspnet_overload_verbs_both_venues_issue1692);
RUN_TEST(handles_laravel_php);
RUN_TEST(handles_laravel_facade_routes_issue952);
RUN_TEST(handles_laravel_facade_no_junk_routes_issue952);
+169
View File
@@ -5233,6 +5233,173 @@ TEST(parallel_php_vendor_types_never_bind_by_name_issue1186) {
PASS();
}
/* ── PHP LSP types receivers of imported project classes (#1186) ─── */
/* Foo.php calls methods on receivers typed with classes imported from OTHER
* project files: a promoted property (`$this->svc->go()`) and an aliased
* import (`R $r` -> `$r->find()`). The import map handed to the PHP LSP held
* the IMPORTS edge target -- the imported file's File node QN
* (`...Svc.php.__file__`) -- as the class, so no receiver ever typed and the
* calls fell to name-only strategies (the #1186 "useful calls at 0.38"). The
* decoy App\Other\Svc shares the short name and the method, so a name-only
* match cannot tell the two apart. Controls: the vendor-typed receiver
* (`Client $c`) must stay unresolved, and the same-file calls keep their LSP
* strategies. */
static const struct {
const char *rel;
const char *src;
} k_php_xfile_files[] = {
{"lib/Services/Svc.php", "<?php\nnamespace App\\Services;\n\nclass Svc {\n"
" public function go() { return 1; }\n}\n"},
{"lib/Other/Svc.php", "<?php\nnamespace App\\Other;\n\nclass Svc {\n"
" public function go() { return 2; }\n}\n"},
{"lib/Repo/Repo.php", "<?php\nnamespace App\\Repo;\n\nclass Repo {\n"
" public function find() { return 3; }\n}\n"},
{"lib/Http/Foo.php",
"<?php\nnamespace App\\Http;\n\n"
"use App\\Services\\Svc;\nuse App\\Repo\\Repo as R;\nuse Vendor\\Pkg\\Client;\n\n"
"class Foo {\n"
" public function __construct(private Svc $svc) {}\n"
" public function run(R $r, Client $c) {\n"
" $this->svc->go();\n"
" $r->find();\n"
" $c->go();\n"
" $this->local();\n"
" (new Local())->ping();\n"
" }\n"
" private function local() { return 4; }\n}\n\n"
"class Local {\n"
" public function ping() { return 5; }\n}\n"},
};
enum { PHP_XFILE_FILE_COUNT = (int)(sizeof(k_php_xfile_files) / sizeof(k_php_xfile_files[0])) };
typedef struct {
const cbm_gbuf_t *gbuf;
const char *src_rel;
const char *tgt_rel;
const char *tgt_suffix;
const char *strategy_prefix;
int total;
int with_strategy;
} php_xfile_edge_count_t;
static void php_xfile_count_edge(const cbm_gbuf_edge_t *edge, void *ud) {
php_xfile_edge_count_t *c = ud;
if (!edge->type || strcmp(edge->type, "CALLS") != 0) {
return;
}
const cbm_gbuf_node_t *s = cbm_gbuf_find_by_id(c->gbuf, edge->source_id);
const cbm_gbuf_node_t *t = cbm_gbuf_find_by_id(c->gbuf, edge->target_id);
if (!s || !t || !s->file_path || !t->file_path || !t->qualified_name ||
strcmp(s->file_path, c->src_rel) != 0 || strcmp(t->file_path, c->tgt_rel) != 0) {
return;
}
size_t qlen = strlen(t->qualified_name);
size_t slen = strlen(c->tgt_suffix);
if (qlen < slen || strcmp(t->qualified_name + qlen - slen, c->tgt_suffix) != 0) {
return;
}
c->total++;
char needle[128];
snprintf(needle, sizeof(needle), "\"strategy\":\"%s", c->strategy_prefix);
if (edge->properties_json && strstr(edge->properties_json, needle)) {
c->with_strategy++;
}
}
/* 1 when every cross-file receiver resolved through the PHP LSP, the decoy and
* the vendor receiver drew nothing, and the same-file controls held. */
static int php_xfile_graph_ok(const cbm_gbuf_t *gbuf, const char *driver) {
static const char *const foo = "lib/Http/Foo.php";
struct {
const char *tgt;
const char *suffix;
int want_total;
int want_lsp;
} checks[] = {
{"lib/Services/Svc.php", ".Svc.go", 1, 1}, /* $this->svc->go(), not $c->go() */
{"lib/Other/Svc.php", ".Svc.go", 0, 0}, /* decoy: same short name + method */
{"lib/Repo/Repo.php", ".Repo.find", 1, 1}, /* aliased import */
{foo, ".Foo.local", 1, 1}, /* same-file control */
{foo, ".Local.ping", 1, 1}, /* same-file control */
};
int ok = 1;
for (size_t i = 0; i < sizeof(checks) / sizeof(checks[0]); i++) {
php_xfile_edge_count_t c = {gbuf, foo, checks[i].tgt, checks[i].suffix, "php_", 0, 0};
cbm_gbuf_foreach_edge(gbuf, php_xfile_count_edge, &c);
if (c.total != checks[i].want_total || c.with_strategy != checks[i].want_lsp) {
printf(" [%s] CALLS Foo.php -> %s%s: %d edge(s), %d via php_*; want %d, %d\n", driver,
checks[i].tgt, checks[i].suffix, c.total, c.with_strategy, checks[i].want_total,
checks[i].want_lsp);
ok = 0;
}
}
return ok;
}
/* Run the fixture on both venues. extra_use (nullable) is one more `use` line
* added to Foo.php. Returns 1 when both graphs pass, 0 when not, -1 on setup
* failure. */
static int php_xfile_run(const char *extra_use) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_phpxfile_XXXXXX");
if (!cbm_mkdtemp(tmpdir)) {
return -1;
}
char paths[PHP_XFILE_FILE_COUNT][512];
cbm_file_info_t files[PHP_XFILE_FILE_COUNT];
memset(files, 0, sizeof(files));
static const char foo_use[] = "use Vendor\\Pkg\\Client;\n";
for (int i = 0; i < PHP_XFILE_FILE_COUNT; i++) {
snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, k_php_xfile_files[i].rel);
char dir[512];
snprintf(dir, sizeof(dir), "%s", paths[i]);
*strrchr(dir, '/') = '\0';
cbm_mkdir_p(dir, 0755);
char src[2048];
const char *s = k_php_xfile_files[i].src;
const char *at = extra_use ? strstr(s, foo_use) : NULL;
if (at) {
size_t head = (size_t)(at - s) + sizeof(foo_use) - 1;
snprintf(src, sizeof(src), "%.*s%s%s", (int)head, s, extra_use, s + head);
s = src;
}
if (th_write_file(paths[i], s) != 0) {
th_rmtree(tmpdir);
return -1;
}
files[i].path = paths[i];
files[i].rel_path = (char *)k_php_xfile_files[i].rel;
files[i].language = CBM_LANG_PHP;
}
cbm_gbuf_t *seq = run_sequential_with_lsp_cross_and_mutator(
"cbm_par_phpxfile", tmpdir, files, PHP_XFILE_FILE_COUNT, NULL, NULL, true);
cbm_gbuf_t *par = run_parallel_with_extract_opts_and_mutator(
"cbm_par_phpxfile", tmpdir, files, PHP_XFILE_FILE_COUNT, 2, NULL, NULL, NULL, true);
int seq_ok = seq ? php_xfile_graph_ok(seq, "sequential") : 0;
int par_ok = par ? php_xfile_graph_ok(par, "parallel") : 0;
cbm_gbuf_free(seq);
cbm_gbuf_free(par);
th_rmtree(tmpdir);
return seq_ok && par_ok;
}
TEST(parallel_php_lsp_types_imported_project_receivers_issue1186) {
ASSERT_EQ(php_xfile_run(NULL), 1);
PASS();
}
/* The same file with one more import that no file in the project declares,
* the global class `use Exception;` written in most namespaced PHP files.
* The typed import evidence leaves such a file "unknown" as a whole (#2520),
* so the class-valued import mapping must not depend on that evidence. */
TEST(parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186) {
ASSERT_EQ(php_xfile_run("use Exception;\n"), 1);
PASS();
}
/* A fresh PHP external row replaces stale evidence for its exact occurrence,
* including an older external row, without clearing a real project invocation
* of the same method at the next occurrence. Test both old-row orders. */
@@ -5346,6 +5513,8 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) {
SUITE(parallel) {
RUN_TEST(parallel_php_external_row_replaces_only_its_exact_occurrence);
RUN_TEST(parallel_php_vendor_types_never_bind_by_name_issue1186);
RUN_TEST(parallel_php_lsp_types_imported_project_receivers_issue1186);
RUN_TEST(parallel_php_lsp_types_imported_project_receivers_unknown_bindings_issue1186);
RUN_TEST(usage_semantic_reference_candidate_trusts_marked_producer);
RUN_TEST(lsp_bare_segment_skips_preprocessor_spacing);
RUN_TEST(lsp_resolve_qualified_static_call_normalizes_colons);