Skip to content

feat(extract): discover Blazor .razor components and emit Route nodes for @page - #1824

Merged
DeusData merged 2 commits into
DeusData:mainfrom
The1nk:feat/blazor-razor-routes
Sep 2, 2026
Merged

feat(extract): discover Blazor .razor components and emit Route nodes for @page#1824
DeusData merged 2 commits into
DeusData:mainfrom
The1nk:feat/blazor-razor-routes

Conversation

@The1nk

@The1nk The1nk commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes Blazor .razor components visible to the indexer, and turns their
@page directives into Route nodes.

Two changes, in dependency order:

1. feat(discover): map .razor to C#.razor had no entry in
EXT_TABLE, so discovery skipped it and a Blazor application produced no
graph nodes for any component. The only way in was an undocumented
extra_extensions entry in a per-project .codebase-memory.json. This maps
.razor to CBM_LANG_CSHARP.

It is best-effort by design: the C# grammar recovers the @code block, while
the surrounding markup lands in ERROR regions and is reported through
parse_partial. That is still strictly more than the Module-and-imports the
other markup-hosted languages extract today, and it needs no new grammar.

2. feat(extract): emit Route nodes for Blazor @page directives
get_architecture's routes aspect returned nothing for a Blazor
application: the entry surface of the app was invisible even though every
@page names one.

@page lives in markup above the @code block, which tree-sitter's C#
grammar never parses, so there is no AST node to read it from. This scans the
raw source instead. The match is deliberately strict — the directive must be
the first token on its line, followed by whitespace and a double-quoted path
beginning with / — so @pageSize and prose mentions cannot match.

The route is attached to the file's module def. A .razor component's
class is implicit, so there is no class node to carry it, and the module QN
already is the component's identity. insert_def_into_gbuf is label-agnostic
and creates Route + HANDLES from route_path.

pass_route_nodes.c's ensure_decorator_routes gains "Module" for the
same reason. Extraction covers the full-index path on its own; this backstop
is what runs on an incremental re-index, so without it a component's
Route would appear on a full index and vanish the next time that one file
changed. Its loop bound now derives from the labels array rather than
borrowing the unrelated RN_STRIP_PASSES, so adding a label cannot silently
skip it. Verified there is no double-creation when both paths can fire (60
files: 60 Routes and 60 HANDLES either way).

Diff vs main: 5 files, 190 insertions, 12 deletions.

Measured

Rebased onto main at 5fbab7bb and re-measured from scratch. Arm A is now
main itself — the exact merge base — rather than a release
, so the only
difference between the two binaries is this PR and there is no upstream drift
to disclaim.

Paired control on a real Blazor application (36 .razor, 302 .cs, 11
@page), both binaries run against one checkout, isolated caches, one
container each:

metric A (main 5fbab7b) B (this branch)
nodes 5113 5124 (+11)
Route no such label 11
HANDLES no such label 11
total edges 24402 24413 (+11)
SEMANTICALLY_RELATED 365 365 (unchanged)
Class 404 404
Method 2023 2023
CALLS 4504 4504
Module 393 393
.razor Modules 36 36

+11 nodes is exactly the 11 Route nodes; +11 edges is exactly the 11
HANDLES. Nothing else moves. get_architecture(routes) went from the aspect
being absent to 11 entries. No store schema change.

Repeated independently on a second checkout of the same commit, same
result: 24327 → 24338 edges, 5113 → 5124 nodes, SEMANTICALLY_RELATED 290 →
290.

Why "both binaries on one checkout" is stated so insistently

An earlier version of this control gave each binary its own checkout of the
same commit, and reported a -75 swing in SEMANTICALLY_RELATED that looked
like a regression in this branch. It was not. SEMANTICALLY_RELATED depends
on the absolute path of the checkout: cbm_project_name_from_path
(src/pipeline/fqn.c:416) maps the whole path to the project name, that name
prefixes every qualified_name, and pass_semantic_edges.c:469-470 tokenizes
qualified_name into the vector that becomes the LSH signature. The
unmodified main binary reproduces the entire -75 on its own, just by
reading a second, content-identical checkout at a different path.

So the two rows above are measured with the path held constant, which removes
the effect entirely — hence 365 → 365 and 290 → 290 rather than a delta
needing explanation. I intend to file this separately once I have a clean
public reproduction; it is not caused by this PR and does not affect it.

End-to-end on a dedicated fixture, with a negative control:

Route /counter          <- HANDLES <- Module Pages/Counter.razor
Route /weather/forecast <- HANDLES <- Module Pages/Weather.razor
NoRoute.razor (no @page)  ->  Module, and NO Route

Stable across a re-index.

What this does not claim

  • @code methods are not reliably extracted from .razor. Real markup
    (class=, role= attributes) defeats recovery and the file comes back
    parse_partial as a whole. Bare @code fields remain a separate gap.
  • Two pre-existing gaps are untouched and are not caused by this change —
    they reproduce on the unmodified binary too: routes report handler:""
    although the HANDLES edge exists, and the language census counts .razor
    as C# rather than as its own surface.
  • Only the first @page on a component is taken, because CBMDefinition
    carries a single route_path.

Checklist

  • Every commit is signed off (git commit -s) — required, CI rejects
    unsigned commits (DCO, see CONTRIBUTING.md)
  • Tests pass locally (make -f Makefile.cbm test)
  • Lint passes (make -f Makefile.cbm lint-ci)
  • New behavior is covered by a test (reproduce-first for bug fixes)

Full suite on this branch rebased onto 5fbab7bb: 7626 passed / 0 failed /
7 skipped
, 141 suites, via scripts/test.sh. The branch itself adds 3 of
those tests — extract_blazor_page_directive_routes_component,
extract_blazor_component_without_page_has_no_route and lang_ext_razor, the
middle one being the negative control.

This PR has now been rebased twice rather than shipped with stale numbers:
first from 49d928be to 010569fa (7415/0/8, 139 suites), and now to
5fbab7bb. The extra 211 tests and 2 suites between those last two are
upstream's, not this branch's.

The second rebase needed two conflict resolutions in
internal/cbm/extract_defs.c (main's new
cbm_extract_definitions_without_module, and #519's
mod.docstring = extract_config_module_description(ctx) — both sides kept),
plus one collision that auto-merged cleanly and then did not compile:
tests/test_extraction.c grew a second find_module_def, since main added
its own for the #518/#519 work while this branch has carried one since August.
Textually disjoint, semantically a redefinition. I kept the earlier
definition — it precedes every call site and has a label && null guard the
other lacks — and removed the duplicate, leaving the #518/#519 comment block
intact. Main's eight call sites and this branch's two now share one definition.

On lint, stated plainly

make -f Makefile.cbm lint-ci — the gate this checklist names — passes: exit
0, run in a clean container against this branch rebased onto 5fbab7bb.

scripts/lint.sh in full mode does not pass, and I do not believe any of
it is mine. Full mode adds clang-tidy, which fails repo-wide on rules like
readability-magic-numbers, readability-braces-around-statements and
misc-no-recursion. The same run flags 22 diagnostics on untouched main
code
, including internal/cbm/extract_defs.c at lines 281, 588, 601 and
1257 — while this branch's hunks in that file are at 7905-7987 and 8009-8019.

Two diagnostics do land on this branch's added lines: readability-magic- numbers at 7912 (1U) and 7983 (1). Both are idiomatic and match the
surrounding style in that file. Happy to change them if you would rather.

One scope question I would rather raise than assume

CONTRIBUTING.md asks for prior design discussion on "new pipeline passes or
indexing algorithms — anything that changes what gets extracted or how", and
this does change what gets extracted. What I took as the go-ahead was #1667
being open and labelled bug / language-request / priority/high, plus the
standing exception for focused bug fixes. If you would rather this went
through a design discussion first, say so and I will close it and move the
conversation back to the issue — no hard feelings.

Fixes #1667


Built and measured with Claude Code; all numbers above are from real runs on
a real application, not generated.

@The1nk
The1nk requested a review from DeusData as a code owner August 25, 2026 00:53
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@DeusData DeusData added enhancement New feature or request parsing/quality Graph extraction bugs, false positives, missing edges language-request Request for new language support priority/normal Standard review queue; useful PR with ordinary maintainer urgency. labels Sep 1, 2026
@DeusData

DeusData commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Thank you for the measured Blazor coverage and for testing both full and incremental route materialization. Mapping a mixed markup file to C# and extracting directives from raw source changes language and route semantics, so we need more time to review the precision boundary carefully. The contribution queue is quite full, but we will return with grounded feedback as soon as possible.

@DeusData

DeusData commented Sep 1, 2026

Copy link
Copy Markdown
Owner

On the scope question you raised: go ahead. Thank you for asking rather than assuming — that is the right instinct, and it is why the answer is easy.

The design-discussion requirement exists to stop direction changes arriving unreviewed. This one arrives as the discussion: a stated problem, a measured paired control, an explicit list of what it does not do, and a named alternative you rejected. Sending it back to the issue would ask you to rewrite as prose what you have already written as evidence. #1667 sat at priority/high for eight days; that is a go-ahead, not an oversight.

What made this straightforward

The paired control is the strongest form of this measurement. 4555 → 4566 nodes where +11 is exactly the 11 Route nodes, with 363 Class, 1775 Method and 3805 CALLS unchanged, means the change did what it claimed and nothing else. A total-node delta alone would have proved much less.

And you corrected your own numbers rather than shipping them. Rebasing after discovering the base was 129 commits behind, re-measuring, and saying so in the description is the opposite of the usual failure here, which is a benchmark quietly measured against something else.

The "what this does not claim" section is the part I would keep in a template. @code methods not reliably extracted because markup defeats grammar recovery; only the first @page taken because CBMDefinition carries one route_path; two pre-existing gaps named and shown to reproduce on the unmodified binary. Every one of those is something a reviewer would otherwise have had to find and then wonder about.

And you defused a real trap. I checked this on main: ensure_decorator_routes iterates a two-element labels[] array bounded by RN_STRIP_PASSES — a constant belonging to the strip loop three hundred lines away that merely happens to equal 2. Adding a third label would have silently not iterated it, which is exactly what you needed to do. Deriving the bound from the labels array rather than bumping the unrelated constant severs the coupling instead of tightening it. That fix is worth more than the feature to whoever adds the fourth label.

Attaching the route to the module def, with the reasoning that a .razor component's class is implicit so the module QN is the component's identity, is right. So is adding "Module" to the incremental backstop — without it a Route would appear on a full index and vanish on the next single-file change, which is the worst kind of bug to chase.

Two things

Please rebase — this is DIRTY. main moved three times yesterday: broken by a duplicate-symbol merge, repaired by #1993, then #1703 landed.

One interaction to know about, not to fix here. Indexing .razor as C# means those files land in parse_partial by design. There is an open proposal (#1968) for a CI gate that fails when parse_partial_count rises above a recorded ceiling. If both land, that ceiling needs raising by roughly this repo's .razor count — and the rise will be correct rather than a regression. I have noted it on that side too so it is not discovered as a mystery later.

On your lint analysis: lint-ci is the gate the checklist names and it passes, which settles it. I did not independently re-run full-mode clang-tidy, but your line-number argument is sound on its face — the diagnostics you quote sit far from your hunks, and cbm_resolve_func_name carrying a cognitive complexity of 396 is plainly not yours.

CaptainMittens added a commit to CaptainMittens/codebase-memory-mcp that referenced this pull request Sep 2, 2026
…eusData#963)

The gate failed a pull request on the state of the report, not on the
change the pull request made. Check 4 compared parse_partial_count
against a number checked into scripts/ci/parse-partial-baseline.txt, and
checks 1-3 asserted zero findings outright. Any of the four could go red
for something main did.

That is not theoretical. DeusData#1972 was this exact thing: main gained
src/daemon/runtime.c, the count went 58 -> 59 on its own, and the number
had to be raised by hand. DeusData#1824 will do it again and larger. Blazor
.razor files map to C#, their markup lands in ERROR regions by design,
and the count rises by roughly the repo's .razor count. A coverage
improvement would read as a gate failure on an unrelated branch, and the
person who hit it would have no way to tell that from a real regression.

The gate now resolves the base commit, checks it out into a temporary
worktree, and indexes both trees with the same binary. All four checks
compare the two:

  1. a whole-file parse failure fails only when it is new at head
  2. a "+N" clipping marker fails only when it is new at head
  3. a range over 25% of its file fails only when the file was within
     the share at the base
  4. the flagged-file count fails only when it is above the base's

parse-partial-baseline.txt stops being a gate. The script still prints
the recorded number so a reader can see the drift, and says plainly that
nothing fails on it. Nobody has to raise that number again.

What this cannot see: both trees are indexed with the same binary, so a
branch that changes the extractor itself moves the base side and the
head side together and this gate will not fail on it. Catching that
needs the base commit's own binary, which means a second full build --
about twelve minutes against the twenty-six seconds the whole gate step
takes. Two things still cover it: the FLOOR asserted in
tests/test_index_resilience.c stops the signal being switched off, and
the absolute counts for both sides now print on every run, so a jump is
visible in the log even when it does not fail. The script header and the
scripts/ci/README.md row both say so.

tests/test_coverage_gate_contract.sh pins the behaviour. It drives the
production script with a fake binary that prints canned JSON, so no seam
is added to the script itself and no indexing happens. Fifteen cases:
each of the four findings present at both sides (pass) and new at head
(fail), the count equal to, below and above the base, the recorded
number not gating, both sides printing, a clipped file list still
stopping the run outright, the allowlist skipping a path, and an
unresolvable base commit stopping the run.

Verified by reverting each of the four comparisons one at a time and
confirming the matching "present at both sides" case goes red, then
restoring. A real run against a built binary passes with both sides
reported, and takes 45s for two indexes.

pr.yml passes COVERAGE_GATE_BASE_SHA so the gate uses the commit GitHub
itself used to build the merge, rather than falling back to the first
parent of HEAD.

Refs DeusData#963, DeusData#1972

Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
The1nk and others added 2 commits September 1, 2026 20:32
A .razor file had no entry in EXT_TABLE, so discovery skipped it entirely
and a Blazor application produced no graph nodes for any of its
components. The only way in was an undocumented extra_extensions entry in
a per-project .codebase-memory.json.

Map .razor to CBM_LANG_CSHARP. This is best-effort by design: the C#
grammar recovers the @code block, while the surrounding markup lands in
ERROR regions and is reported through parse_partial. That is strictly
more than the Module-and-imports the other markup-hosted languages
(Vue, Svelte, Astro) extract today, and it needs no new grammar.

Covered by tests/test_language.c:lang_ext_razor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: The1nk <23087600+The1nk@users.noreply.github.com>
get_architecture's routes aspect returned nothing for a Blazor
application: the entry surface of the app was invisible in the graph
even though every @page directive names one.

@page lives in markup above the @code block, which tree-sitter's C#
grammar never parses, so there is no AST node to read it from. Scan the
raw source for it instead. The match is deliberately strict — the
directive must be the first token on its line, followed by whitespace
and a double-quoted path beginning with '/' — so @pageSize and prose
mentions cannot match.

The route is attached to the file's module def. A .razor component's
class is implicit, so there is no class node to carry it, and the module
QN already is the component's identity. insert_def_into_gbuf is
label-agnostic and creates Route + HANDLES from route_path.

pass_route_nodes.c's ensure_decorator_routes gains "Module" for the same
reason. Extraction covers the full-index path on its own; this backstop
is what runs on an incremental re-index, so without it a component's
Route would appear on a full index and vanish the next time that one
file changed. Its loop bound now derives from the labels array rather
than borrowing the unrelated RN_STRIP_PASSES, so adding a label cannot
silently skip it. Verified no double-creation when both paths can fire.

Measured on a real Blazor application (36 .razor files): routes went
from 0 to 11, with 11 matching HANDLES edges, and no change to the
363 Class / 1775 Method / 3805 CALLS already extracted from its C#.

Covered by two new tests in tests/test_extraction.c.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: The1nk <23087600+The1nk@users.noreply.github.com>
@The1nk
The1nk force-pushed the feat/blazor-razor-routes branch from 7b9b459 to fd73c34 Compare September 2, 2026 03:49
@DeusData

DeusData commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Approved on merit, 34/34 green and clean. One consequence I want on the record before it lands, not an objection.

What I verified

No language-count impact. .razor maps to the existing CBM_LANG_CSHARP rather than adding an enum entry and a display name, unlike .vue which has its own CBM_LANG_VUE. So the gated published language count is untouched — worth knowing, because a new language would have needed every count surface updated in the same change.

The @page scanner is properly scoped. cbm_path_is_razor() gates it on the extension, so ordinary C# files never see it, and razor_page_route_on_line() skips leading whitespace and then requires @page to be the first token on the line. A commented-out or mid-line occurrence will not match. Scanning raw source is the right call here for the reason you give: @page lives above the @code block, which the C# grammar never parses, so there is no AST node to read it from — this is not a shortcut around an available parse.

Emitting Route nodes is the valuable half. A Blazor app whose entire entry surface was invisible to get_architecture's routes aspect is a real gap, and @page names it unambiguously.

The consequence worth stating

Mapping .razor to C# means the surrounding markup lands in ERROR regions, so every .razor file will report parse_partial. You say so plainly in the code comment, which I appreciate.

That is a deliberate trade, and I think it is the right one — but it changes what parse_partial means for a user indexing a Blazor project. This repository has spent several PRs (#1610, #1746, and one currently open) eliminating phantom parse_partial precisely so the signal stays trustworthy: today it says "something was genuinely lost". After this, a high parse_partial_count on a Blazor codebase means "these are .razor files", which a user has no way to distinguish from real extraction failures elsewhere in the same repo.

I am not asking you to solve that here — the alternative is a dedicated grammar, which is a much larger piece of work and not what this PR set out to do. I am flagging it to our maintainer alongside the merge so the tradeoff is a decision rather than a discovery. If it turns out to matter, the follow-up is probably suppressing the markup ERROR regions for .razor specifically, in the same spirit as the EOF-terminator suppression, rather than reverting this.

Everything else is ready. I will merge shortly unless the maintainer wants the parse_partial question settled first.

@DeusData
DeusData merged commit e6a34fc into DeusData:main Sep 2, 2026
34 checks passed
@DeusData

DeusData commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Merged as e6a34fce. Thank you — and thank you for writing the tradeoff into the code comment rather than leaving it to be discovered.

One thing I looked into while reviewing, which turns out to be interesting: your approach is the first of its kind in this codebase. Every other markup-hosted format here has its own language rather than a mapping to a host — .svelteCBM_LANG_SVELTE, .astroCBM_LANG_ASTRO, .vueCBM_LANG_VUE, .j2/.jinjaCBM_LANG_JINJA2, .liquidCBM_LANG_LIQUID. Yours is the only one that maps to the host language and accepts the markup landing in ERROR regions.

That is not a criticism — it is why the parse_partial consequence is worth naming, and it is also why the fix is genuinely useful: .razor had no grammar and no prospect of one soon, so the choice was between best-effort extraction and nothing at all. A Blazor app producing zero nodes is a worse outcome than one producing @code blocks and Route nodes with a noisier signal.

Our maintainer has asked me to evaluate whether the same pattern should be offered more broadly. My reading, for what it is worth to you: the Razor/ASP.NET family is the obvious extension — .cshtml is literally the same syntax with the same host language and is equally absent from the extension table today, so covering .razor and not .cshtml is hard to justify. Beyond that family I would be more cautious, since a blanket host-mapping would make parse_partial unreliable across a dozen file types at once.

If you have appetite for .cshtml as a follow-up it would be a natural continuation of this work — but no obligation, and we may do it ourselves.

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

Labels

enhancement New feature or request language-request Request for new language support parsing/quality Graph extraction bugs, false positives, missing edges priority/normal Standard review queue; useful PR with ordinary maintainer urgency.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blazor: .razor files are never discovered, and @page routes produce no Route nodes

2 participants