From 69e0928330bbc07411fa6892bba8bf97e2c790a1 Mon Sep 17 00:00:00 2001 From: Nicola Avancini Date: Mon, 31 Aug 2026 19:29:27 +0200 Subject: [PATCH 1/3] feat(csharp): capture ASP.NET route templates as queryable route nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _csharp_attribute_names read only an attribute's name, so the argument of [Route("api/x")] / [HttpGet("Status")] never reached the graph: the extractor recorded THAT a method is an endpoint (a references[attribute] edge to the attribute type) but not WHERE it is served. Class-level attributes were not collected at all, so the controller's route prefix was missing even in principle, and the full URL could not be recomposed. The helper now also returns an attribute's first string-literal argument, and _csharp_route_label composes the endpoint URL: verb from the Http* attribute, path from that attribute's template or from a sibling [Route] on the same method (the [HttpGet] + [Route("login")] style that dominates large codebases), prefixed by the class-level [Route] unless the method template is absolute (leading / or ~/), with the conventional [controller] token expanded. The result is a node whose LABEL is the route. That is deliberate: serve.py indexes (norm_label, label_tokens, nid, source_file, source_tokens) and never reads node or edge metadata, so a route carried as metadata would be invisible to `graphify query`. As a node it answers "which controller serves api/x?" with the existing query/path/explain tooling. The method points at it with references[context="route"], matching how TS decorators already use a bespoke context that is not part of REFERENCE_CONTEXTS (that frozenset gates _semantic_reference_edge, not the AST walk). Recognition is limited to the ASP.NET routing attributes, so [Obsolete("...")] and [Display(Name="x")] keep their payload out of the graph. Only C# is affected: every new helper is _csharp_-prefixed and the emission site sits in the existing tree_sitter_c_sharp branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- graphify/extractors/engine.py | 148 +++++++++++++++++++++++++- tests/test_csharp_routes.py | 191 ++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 tests/test_csharp_routes.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 38e9a54201..9c3ed4bac0 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -239,9 +239,43 @@ def _csharp_collect_type_refs( if c.is_named: _csharp_collect_type_refs(c, source, generic, out, skip) -def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str]]: - """Collect attribute names from a C# method/declaration's attribute_list children.""" - names: list[tuple[str, bool, str]] = [] +def _csharp_attribute_string_argument(attr_node, source: bytes) -> str | None: + """First string-literal argument of a C# attribute, unquoted. + + ``[HttpGet("Status")]`` -> ``"Status"``. Verbatim literals (``@"..."``) are + unwrapped too. Non-string arguments (``[ApiExplorerSettings(IgnoreApi = true)]``) + and argument-less attributes yield None — only a literal path is useful to a + reader, and anything computed cannot be resolved statically anyway. + """ + args = attr_node.child_by_field_name("arguments") + if args is None: + args = next((c for c in attr_node.children + if c.type == "attribute_argument_list"), None) + if args is None: + return None + stack = list(args.children) + while stack: + node = stack.pop(0) + if node.type in ("string_literal", "verbatim_string_literal"): + text = _read_text(node, source) + if text.startswith("@"): + text = text[1:] + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + return text[1:-1] + return text + stack.extend(node.children) + return None + + +def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str, str | None]]: + """Collect attribute names from a C# method/declaration's attribute_list children. + + Each entry is ``(name, qualified, qualifier, string_argument)``. The argument + carries an attribute's literal payload — the route template of + ``[Route("api/x")]`` — which the type-reference side ignores but + :func:`_csharp_route_label` needs. + """ + names: list[tuple[str, bool, str, str | None]] = [] skip = _csharp_type_parameters_in_scope(method_node, source) for child in method_node.children: if child.type != "attribute_list": @@ -259,9 +293,104 @@ def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, qualified = name_node.type == "qualified_name" prefix, _, text = _read_text(name_node, source).rpartition(".") if text and text not in skip: - names.append((text, qualified, prefix if qualified else "")) + names.append((text, qualified, prefix if qualified else "", + _csharp_attribute_string_argument(attr, source))) return names + +# ASP.NET routing attributes. The verb map doubles as the recognizer: an +# attribute outside this set never mints a route node, so `[Obsolete("...")]` +# and `[Display(Name="x")]` keep their payload out of the graph. +_CSHARP_ROUTE_VERBS = { + "HttpGet": "GET", + "HttpPost": "POST", + "HttpPut": "PUT", + "HttpDelete": "DELETE", + "HttpPatch": "PATCH", + "HttpHead": "HEAD", + "HttpOptions": "OPTIONS", +} +_CSHARP_ROUTE_ATTRIBUTE = "Route" + + +def _csharp_enclosing_class(node): + """Nearest enclosing type declaration of ``node``, or None at file scope.""" + scope = node.parent + while scope is not None: + if scope.type in ("class_declaration", "record_declaration", "struct_declaration"): + return scope + scope = scope.parent + return None + + +def _csharp_expand_route_tokens(template: str, class_node, source: bytes) -> str: + """Expand the conventional ``[controller]`` token. + + ``PresenceController`` + ``api/[controller]`` -> ``api/Presence``. ``[action]`` + is left alone: it resolves per-method and the method name is already the edge's + other endpoint. + """ + if "[controller]" not in template or class_node is None: + return template + name_node = class_node.child_by_field_name("name") + if name_node is None: + return template + name = _read_text(name_node, source) + if name.endswith("Controller") and len(name) > len("Controller"): + name = name[: -len("Controller")] + return template.replace("[controller]", name) + + +def _csharp_route_label(method_node, source: bytes) -> str | None: + """Compose ``" "`` for a method carrying ASP.NET routing attributes. + + The verb comes from an ``Http*`` attribute, the path from that attribute's own + template or from a sibling ``[Route]`` on the same method — the two-attribute + style (``[HttpGet]`` + ``[Route("login")]``) is the dominant one in large + codebases. A class-level ``[Route]`` is the prefix, unless the method template + is absolute (leading ``/`` or ``~/``), per ASP.NET's own rule. A method with a + ``[Route]`` but no verb attribute matches every verb and is labelled ``*``. + + Returns None when the method carries no routing attribute at all. + """ + verb = None + method_template = None + saw_routing_attribute = False + for name, _qualified, _qualifier, argument in _csharp_attribute_names(method_node, source): + if name in _CSHARP_ROUTE_VERBS: + saw_routing_attribute = True + verb = verb or _CSHARP_ROUTE_VERBS[name] + if argument and method_template is None: + method_template = argument + elif name == _CSHARP_ROUTE_ATTRIBUTE: + saw_routing_attribute = True + if argument and method_template is None: + method_template = argument + if not saw_routing_attribute: + return None + + class_node = _csharp_enclosing_class(method_node) + prefix = "" + if class_node is not None: + for name, _q, _qual, argument in _csharp_attribute_names(class_node, source): + if name == _CSHARP_ROUTE_ATTRIBUTE and argument: + prefix = argument + break + + template = method_template or "" + if template.startswith("~/"): + path = template[1:] + elif template.startswith("/"): + path = template + elif prefix and template: + path = f"{prefix.rstrip('/')}/{template.lstrip('/')}" + else: + path = template or prefix + path = _csharp_expand_route_tokens(path, class_node, source) + if not path: + return None + return f"{verb or '*'} {path}" + _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS = frozenset({ "class_declaration", "interface_declaration", @@ -4302,7 +4431,7 @@ def scala_base_name(type_node) -> str | None: metadata["ref_qualifier"] = qualifier add_edge(func_nid, target_nid, "references", line, context=ctx, metadata=metadata) - for attr_name, qualified, qualifier in _csharp_attribute_names(node, source): + for attr_name, qualified, qualifier, _argument in _csharp_attribute_names(node, source): target_nid = ensure_named_node(attr_name, line) if target_nid != func_nid: metadata = {"ref_token": attr_name} @@ -4312,6 +4441,15 @@ def scala_base_name(type_node) -> str | None: metadata["ref_qualifier"] = qualifier add_edge(func_nid, target_nid, "references", line, context="attribute", metadata=metadata) + # The endpoint's URL as its own node: `serve.py` indexes a node's + # label (never its metadata), so a route is only reachable by a + # `graphify query "api/..."` when the label IS the route. + route_label = _csharp_route_label(node, source) + if route_label: + route_nid = _make_id(stem, "route", route_label) + add_node(route_nid, route_label, line, node_type="route") + add_edge(func_nid, route_nid, "references", line, + context="route", metadata={"route": route_label}) if config.ts_module == "tree_sitter_java": params_node = node.child_by_field_name("parameters") diff --git a/tests/test_csharp_routes.py b/tests/test_csharp_routes.py new file mode 100644 index 0000000000..cc23ece5b0 --- /dev/null +++ b/tests/test_csharp_routes.py @@ -0,0 +1,191 @@ +"""ASP.NET routing attributes become queryable route nodes. + +The C# extractor records that a method carries `[HttpGet]` — as a +`references[attribute]` edge to the attribute's type — but discards the +attribute's argument, so the route template itself never reaches the graph. +These tests pin the behaviour that makes a route findable: the template is +captured, the controller-level `[Route]` prefix composes with the method-level +one, and the result is a node whose *label* is the route (the only field +`serve.py` indexes for search). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _routes(result: dict) -> dict[str, dict]: + """Route nodes by label — the ones a method points at with context='route'.""" + by_id = {n["id"]: n for n in result["nodes"]} + out = {} + for e in result["edges"]: + if e.get("relation") == "references" and e.get("context") == "route": + node = by_id.get(e.get("target")) + if node is not None: + out[node["label"]] = node + return out + + +def _route_source(result: dict, label: str) -> str | None: + """Label of the method that serves ``label``.""" + by_id = {n["id"]: n for n in result["nodes"]} + for e in result["edges"]: + if e.get("relation") == "references" and e.get("context") == "route": + if by_id.get(e.get("target"), {}).get("label") == label: + return by_id.get(e.get("source"), {}).get("label") + return None + + +def test_method_route_template_becomes_a_node(tmp_path: Path): + """`[HttpGet("Status")]` on a method yields a route node labelled with the verb + and the template — today the template is dropped entirely.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class ServerController {\n" + ' [HttpGet("Status")]\n' + " public int GetStatus() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET Status" in _routes(result), ( + f"no route node; got {sorted(_routes(result))}" + ) + + +def test_controller_route_prefix_composes_with_the_method_template(tmp_path: Path): + """A class-level `[Route]` is the endpoint's prefix. Class attributes are not + collected at all today, so the prefix is missing even in principle.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Presence")]\n' + " public class PresenceController {\n" + ' [HttpPost("Add")]\n' + " public int Add() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "POST api/Presence/Add" in _routes(result), ( + f"prefix not composed; got {sorted(_routes(result))}" + ) + + +def test_route_node_points_back_at_its_handler(tmp_path: Path): + """The point of the node: from the route you reach the controller method.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Presence")]\n' + " public class PresenceController {\n" + ' [HttpGet("MobileAccess/{mobileAccessId}")]\n' + " public int GetMobileAccess(string mobileAccessId) { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + label = "GET api/Presence/MobileAccess/{mobileAccessId}" + assert _route_source(result, label) is not None, ( + f"route node has no handler; got {sorted(_routes(result))}" + ) + assert "GetMobileAccess" in str(_route_source(result, label)) + + +def test_verb_attribute_and_route_attribute_on_the_same_method(tmp_path: Path): + """The dominant style in large ASP.NET codebases: a bare `[HttpGet]` for the + verb and a separate `[Route]` for the path.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("account")]\n' + " public class AccountController {\n" + " [HttpGet]\n" + ' [Route("login")]\n' + " public int Login() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET account/login" in _routes(result), ( + f"verb and path came from different attributes; got {sorted(_routes(result))}" + ) + + +def test_absolute_method_template_ignores_the_controller_prefix(tmp_path: Path): + """ASP.NET rule: a template starting with '/' or '~/' is absolute.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Presence")]\n' + " public class PresenceController {\n" + ' [HttpGet("/health")]\n' + " public int Health() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET /health" in _routes(result), ( + f"absolute template was prefixed; got {sorted(_routes(result))}" + ) + + +def test_controller_token_expands_to_the_controller_name(tmp_path: Path): + """`[controller]` is the conventional token for the class name minus the + 'Controller' suffix.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/[controller]")]\n' + " public class RuleSetController {\n" + " [HttpGet]\n" + " public int GetAll() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET api/RuleSet" in _routes(result), ( + f"[controller] token not expanded; got {sorted(_routes(result))}" + ) + + +def test_route_node_is_anchored_to_the_controller_file(tmp_path: Path): + """A route node must carry a real source_file: it is a code artifact, not a + sourceless stub, and `serve.py` indexes source_file alongside the label.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class ServerController {\n" + ' [HttpGet("Status")]\n' + " public int GetStatus() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + node = _routes(result).get("GET Status") + assert node is not None + assert node.get("source_file", "").endswith("c.cs") + assert node.get("file_type") == "code" + + +def test_a_method_without_routing_attributes_mints_no_route_node(tmp_path: Path): + """Only routing attributes produce route nodes — `[Obsolete("...")]` must not.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class Plain {\n" + ' [Obsolete("gone")]\n' + " public int Old() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert _routes(result) == {} From 9ef4c59e1c33387081c60ee1dec9335a4d86db9a Mon Sep 17 00:00:00 2001 From: Nicola Avancini Date: Mon, 31 Aug 2026 19:31:23 +0200 Subject: [PATCH 2/3] test(csharp): pin the verb-less [Route] label A [Route] with no Http* sibling is verb-agnostic in ASP.NET; lock the '*' label so the behaviour is not silently changed later. --- tests/test_csharp_routes.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_csharp_routes.py b/tests/test_csharp_routes.py index cc23ece5b0..adc7f04f23 100644 --- a/tests/test_csharp_routes.py +++ b/tests/test_csharp_routes.py @@ -176,6 +176,24 @@ def test_route_node_is_anchored_to_the_controller_file(tmp_path: Path): assert node.get("file_type") == "code" +def test_route_without_a_verb_attribute_matches_every_verb(tmp_path: Path): + """A bare `[Route]` with no `Http*` sibling is verb-agnostic in ASP.NET; the + label says so with `*` rather than guessing a verb.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class AnyController {\n" + ' [Route("api/any")]\n' + " public int Any() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "* api/any" in _routes(result), ( + f"verb-less route not labelled '*'; got {sorted(_routes(result))}" + ) + + def test_a_method_without_routing_attributes_mints_no_route_node(tmp_path: Path): """Only routing attributes produce route nodes — `[Obsolete("...")]` must not.""" f = _write( From a6fb368efc46da6adccfdc0830b2e65a74bc9766 Mon Sep 17 00:00:00 2001 From: Nicola Avancini Date: Tue, 1 Sep 2026 18:28:26 +0200 Subject: [PATCH 3/3] test(csharp): name route fixtures after a neutral domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixtures read better when the controller names carry their own meaning: Orders for prefix composition, Products for the [controller] token, Reports for the verb-less [Route]. No behaviour change — the same nine assertions, only the sample code they run against differs. Co-Authored-By: Claude Opus 5 --- tests/test_csharp_routes.py | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/test_csharp_routes.py b/tests/test_csharp_routes.py index adc7f04f23..c0ec32d3b4 100644 --- a/tests/test_csharp_routes.py +++ b/tests/test_csharp_routes.py @@ -49,7 +49,7 @@ def test_method_route_template_becomes_a_node(tmp_path: Path): f = _write( tmp_path / "c.cs", "namespace N {\n" - " public class ServerController {\n" + " public class OrdersController {\n" ' [HttpGet("Status")]\n' " public int GetStatus() { return 1; }\n" " }\n" @@ -67,15 +67,15 @@ def test_controller_route_prefix_composes_with_the_method_template(tmp_path: Pat f = _write( tmp_path / "c.cs", "namespace N {\n" - ' [Route("api/Presence")]\n' - " public class PresenceController {\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" ' [HttpPost("Add")]\n' " public int Add() { return 1; }\n" " }\n" "}\n", ) result = extract([f], cache_root=tmp_path) - assert "POST api/Presence/Add" in _routes(result), ( + assert "POST api/Orders/Add" in _routes(result), ( f"prefix not composed; got {sorted(_routes(result))}" ) @@ -85,19 +85,19 @@ def test_route_node_points_back_at_its_handler(tmp_path: Path): f = _write( tmp_path / "c.cs", "namespace N {\n" - ' [Route("api/Presence")]\n' - " public class PresenceController {\n" - ' [HttpGet("MobileAccess/{mobileAccessId}")]\n' - " public int GetMobileAccess(string mobileAccessId) { return 1; }\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" + ' [HttpGet("Items/{orderId}")]\n' + " public int GetItem(string orderId) { return 1; }\n" " }\n" "}\n", ) result = extract([f], cache_root=tmp_path) - label = "GET api/Presence/MobileAccess/{mobileAccessId}" + label = "GET api/Orders/Items/{orderId}" assert _route_source(result, label) is not None, ( f"route node has no handler; got {sorted(_routes(result))}" ) - assert "GetMobileAccess" in str(_route_source(result, label)) + assert "GetItem" in str(_route_source(result, label)) def test_verb_attribute_and_route_attribute_on_the_same_method(tmp_path: Path): @@ -125,8 +125,8 @@ def test_absolute_method_template_ignores_the_controller_prefix(tmp_path: Path): f = _write( tmp_path / "c.cs", "namespace N {\n" - ' [Route("api/Presence")]\n' - " public class PresenceController {\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" ' [HttpGet("/health")]\n' " public int Health() { return 1; }\n" " }\n" @@ -145,14 +145,14 @@ def test_controller_token_expands_to_the_controller_name(tmp_path: Path): tmp_path / "c.cs", "namespace N {\n" ' [Route("api/[controller]")]\n' - " public class RuleSetController {\n" + " public class ProductsController {\n" " [HttpGet]\n" " public int GetAll() { return 1; }\n" " }\n" "}\n", ) result = extract([f], cache_root=tmp_path) - assert "GET api/RuleSet" in _routes(result), ( + assert "GET api/Products" in _routes(result), ( f"[controller] token not expanded; got {sorted(_routes(result))}" ) @@ -163,7 +163,7 @@ def test_route_node_is_anchored_to_the_controller_file(tmp_path: Path): f = _write( tmp_path / "c.cs", "namespace N {\n" - " public class ServerController {\n" + " public class OrdersController {\n" ' [HttpGet("Status")]\n' " public int GetStatus() { return 1; }\n" " }\n" @@ -182,14 +182,14 @@ def test_route_without_a_verb_attribute_matches_every_verb(tmp_path: Path): f = _write( tmp_path / "c.cs", "namespace N {\n" - " public class AnyController {\n" - ' [Route("api/any")]\n' - " public int Any() { return 1; }\n" + " public class ReportsController {\n" + ' [Route("api/reports")]\n' + " public int Summary() { return 1; }\n" " }\n" "}\n", ) result = extract([f], cache_root=tmp_path) - assert "* api/any" in _routes(result), ( + assert "* api/reports" in _routes(result), ( f"verb-less route not labelled '*'; got {sorted(_routes(result))}" )