Skip to content

feat(csharp): capture ASP.NET route templates as queryable route nodes - #3248

Open
NiSHoW wants to merge 3 commits into
Graphify-Labs:v8from
NiSHoW:csharp-route-nodes
Open

feat(csharp): capture ASP.NET route templates as queryable route nodes#3248
NiSHoW wants to merge 3 commits into
Graphify-Labs:v8from
NiSHoW:csharp-route-nodes

Conversation

@NiSHoW

@NiSHoW NiSHoW commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The gap

For a C# method, _csharp_attribute_names reads only attr.child_by_field_name("name").
The attribute_argument_list is never touched, so the extractor records that
a method is an endpoint — a references[attribute] edge to the attribute's type —
but not where it is served. Class-level attributes are not collected at all,
so a controller's [Route("api/Orders")] prefix is missing even in principle
and the full URL cannot be recomposed from what lands in the graph.

Measured on two production ASP.NET Core repositories (closed source, so names and
endpoints below are redacted; counts and route shapes are verbatim):

[Route(...)] in source attribute edges in graph route templates in graph.json
repo A (2.2k nodes) 4 14 0
repo B (101k nodes) 2944 5143 (2549 to Route) 0

In repo B the graph already knows which 2549 methods carry a [Route]. Only the
string is missing.

Why a node, and not metadata

serve.py:361 indexes (norm_label, label_tokens, nid_text, source, source_tokens)
and never reads node or edge metadata — the three metadata occurrences in that
file are an unrelated comment and an importlib.metadata call. A route carried as
metadata would therefore be invisible to graphify query, which is the whole point
of capturing it. Carried as a node label, it answers "which controller serves
api/x?" with the existing tooling:

$ graphify query "api/Orders/Add"
Start: ['OrdersController', 'POST api/Orders/Add', '.Add()', ...]
NODE POST api/Orders/Add [src=Controllers/OrdersController.cs loc=L14]
EDGE .GetItem() --references [context=route]--> GET api/Orders/Items/{orderId}

What it does

_csharp_attribute_names now also returns an attribute's first string-literal
argument, and _csharp_route_label composes the URL:

  • verb from the Http* attribute; path from that attribute's own template or
    from a sibling [Route] on the same method — the [HttpGet] + [Route("login")]
    style is the dominant one in large codebases (1232 bare [HttpGet] vs 11
    parameterised, in repo B);
  • prefixed by the class-level [Route], unless the method template is absolute
    (leading / or ~/), per ASP.NET's own rule;
  • the conventional [controller] token expanded to the class name minus its
    Controller suffix;
  • a [Route] with no verb sibling is verb-agnostic and labelled *.

The method points at the node with references[context="route"]. That mirrors
_ts_emit_decorator_edges, which already uses a bespoke context="decorator"
that is deliberately not part of REFERENCE_CONTEXTS — that frozenset gates
_semantic_reference_edge, not the AST walk — so no vocabulary change is needed.

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 inside the existing config.ts_module == "tree_sitter_c_sharp" branch.

Validation on real code

Run over the controllers of the two repositories above. Endpoint names are redacted,
but each line preserves the routing construct it exercises:

repo A: 5 files  -> 14 routes   GET  <prefix>/<segment>/{id}      <- .Get<X>Async()   class [Route] prefix + method template
                                POST <prefix>/<segment>           <- .Add()           verb from [HttpPost]
                                GET  <prefix>/{id:guid}/<segment> <- .Get<Y>Async()   route constraint preserved
repo B: 23 files -> 59 routes   GET  <segment>/<segment>          <- .Login()         [HttpGet] + separate [Route]
                                GET  <segment>/{id:guid}          <- .Get<Z>()        no class prefix + constraint

14/14 for the first repo, matching its method-level attribute count exactly.

Tests

tests/test_csharp_routes.py, 9 tests: template capture, prefix composition,
route→handler reachability, the two-attribute style, absolute-template override,
[controller] expansion, node anchoring (source_file + file_type), the
verb-less * case, and a negative test that [Obsolete("...")] mints nothing.

Full suite: 5245 passed. The 13 failures on my machine all reproduce on an
unmodified v8 checkout (Windows-only baseline: POSIX FIFO/socket tests, install
and hook path tests).

Open question for you

This adds one node per endpoint — about 2900 on a 101k-node graph, ~2.9%. They are
real entities, but they do shift community detection and node counts. I did not add
an opt-out because there is no precedent for gating graph content in this codebase
(every GRAPHIFY_* variable is operational — models, timeouts, caps, cache, paths)
and inventing one felt like a bigger decision than this PR should make. Happy to add
a flag, or to narrow the default, if you would rather it be opt-in.

The same gap exists for Java annotations (@RequestMapping("/api")) and TS
decorators (@Component({...})) — _java_annotation_names and _ts_decorator_name
both discard arguments by design. If you like this shape I am glad to follow up with
those, one language per PR.

🤖 Generated with Claude Code

NiSHoW added 2 commits August 31, 2026 19:29
_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)
A [Route] with no Http* sibling is verb-agnostic in ASP.NET; lock the '*'
label so the behaviour is not silently changed later.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds ASP.NET route extraction to the C# engine: methods carrying Http* or Route attributes now emit a route-typed node whose label is "<VERB> <path>", linked back to the handler via a references/route edge so graphify query can find endpoints by URL. Composes a class-level [Route] prefix with the method template (respecting absolute ~/ and / templates), expands the [controller] token from the class name, and labels verb-less [Route] methods with *; non-routing attributes and non-literal arguments mint nothing. Extends _csharp_attribute_names to also return each attribute's first string-literal argument, updating its lone type-reference caller to ignore the new field.

Worth a look

  • Named attribute arguments are mistaken for route templatesgraphify/extractors/engine.py:257 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _csharp_attribute_names return arity changed from 3-tuple to 4-tuplegraphify/extractors/engine.py:268 · Escalate · medium · 2 independent checks
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • C# Attribute suffix form is not recognized for routesgraphify/extractors/engine.py:351 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 662 functions depend on the 236 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 25 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 27 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 58 callees
  • …and 8 more — each is listed as a finding

Verification — 662 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 602 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_csharp\_attribute\_names.

The verifier did not have enough to check \_csharp\_attribute\_names, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 189 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 16 more finding(s) on lines outside this diff (see the check run).

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 <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds ASP.NET route extraction to the C# extractor: methods carrying Http*/Route attributes now mint a route-typed node whose label is "<VERB> <path>", linked back to the handler via a references[route] edge. _csharp_route_label composes a class-level [Route] prefix with the method template (respecting absolute ~/ and / templates), expands the [controller] token from the enclosing class name, and falls back to * for a verb-less [Route]; attributes outside the recognized verb set produce no route. Route templates are captured by extending _csharp_attribute_names to carry each attribute's first string-literal argument (a new fourth tuple element that existing callers ignore).

Worth a look

  • C# route labels treat named attribute strings as URL templatesgraphify/extractors/engine.py:253 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 662 functions depend on the 236 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 25 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 27 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 58 callees
  • …and 8 more — each is listed as a finding

Verification — 662 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 602 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_csharp\_attribute\_names.

The verifier did not have enough to check \_csharp\_attribute\_names, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 189 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 16 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant