Skip to content

feat(image): the container compiles Razor — the SDK-free build covers Blazor - #2856

Merged
rbuergi merged 2 commits into
feat/cli-build-projectfrom
feat/image-razor-compiler
Aug 31, 2026
Merged

rbuergi merged 2 commits into
feat/cli-build-projectfrom
feat/image-razor-compiler

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Stacks on #2850 (feat/cli-build-project, the SDK-free memex build project). This PR is
based on that branch and targets it, so the diff here is only the Razor work; GitHub retargets it
to main when #2850 lands. Do not merge this into the feature branch by hand — a stacked
sub-PR that merges into its parent is merged and not delivered.

Razor was the biggest single gap in #2850's first no-SDK sweep of MeshWeaver.Plugins/src:
15 of 42 failures were CS0115, more than any other category. The cause was one missing file, not
a missing feature — Razor compilation in the .NET SDK is a Roslyn source generator
(Microsoft.CodeAnalysis.Razor.Compiler) that turns each .razor into the partial class carrying
its BuildRenderTree override. A runtime image ships no SDK, so every component compiled to a class
with nothing to override.

The image now carries the generator in razor-generators/<rid>/ beside the builder, and
build-project finds and runs it automatically for any project whose Sdk processes Razor items.

The dependency closure, established rather than assumed

The brief named three assemblies. Two are needed; the third is not.

assembly verdict
Microsoft.CodeAnalysis.Razor.Compiler needed — carries RazorSourceGenerator
Microsoft.AspNetCore.Razor.Utilities.Shared needed — its one private dependency
Microsoft.Extensions.ObjectPool not referenced at all by this compiler build

How it was established. Read the compiler's own AssemblyRef table (netstandard,
Microsoft.CodeAnalysis 5.9.0.0, Microsoft.CodeAnalysis.CSharp 5.9.0.0,
System.Collections.Immutable, System.Memory, System.Buffers,
Microsoft.AspNetCore.Razor.Utilities.Shared — no ObjectPool), then proved it by deleting:
with Utilities.Shared removed the compiler assembly still loads and its types still enumerate, and
every call into it throws — the exact "generator silently produces nothing" shape this design exists
to prevent. Microsoft.Extensions.ObjectPool is referenced by the older NuGet build of the Razor
compiler (10.0.0-preview.25277.114), which is presumably where the belief came from.

Two traps that fail at run time and nowhere else

🚨 The generator is built against the SDK's Roslyn, not the image's. SDK 10.0.400's copy wants
Microsoft.CodeAnalysis 5.9.0.0; the image carries this repo's pin, 5.6.0. The default load
context binds by name and refuses a lower version, so a plain Assembly.LoadFrom fails with
Could not load … Version=5.9.0.0 — and SourceGeneratorLoader correctly reads that as "not a
usable generator" and moves on, which is how a missing Razor compiler ends up indistinguishable from
a .razor file nobody asked to compile. Generators therefore load into a context that binds every
assembly the host already has to the host's copy, version ignored
(the same thing Roslyn's own
analyzer loader does). That is also what keeps ISourceGenerator one type: a second Roslyn in
that context would hand the generator a different interface than the driver expects.

🚨 The generator is ReadyToRun-compiled for the SDK's own RID. Measured on SDK 10.0.400, the same
Microsoft.CodeAnalysis.Razor.Compiler.dll carries PE machine 0xFD1D on linux-x64, 0xD11D
on linux-arm64
and 0xEC20 on osx-arm64 (the target machine XOR'd with the OS's R2R marker),
and the wrong one throws BadImageFormatException. mw-plugin-test publishes both architectures
from one x64 host, so "copy the build machine's SDK" would have shipped an arm64 image that
cannot compile a single Blazor project. CD now stages one directory per RID — the arm64 copy read out
of mcr.microsoft.com/dotnet/sdk:<the runner's own SDK version> with docker create + docker cp,
so no emulation runs and there is no second pin to drift.

The .razor item glob, and what is refused

ProjectFile now evaluates the Razor SDK's default items (Content Include="**\*.razor" /
"**\*.cshtml", promoted to RazorComponent/RazorGenerate) plus the project's own
Content/RazorComponent/RazorGenerate Include/Remove, and assigns each the TargetPath
AssignTargetPath would have (relative to the project directory — that is what decides the generated
type's namespace, and getting it wrong compiles into a namespace nothing references).

Nothing is skipped in silence. Four named failures, all tested:

refusal why
Razor files + no generator says what the CS0115 wall would have been, instead of printing it — one error, not a wall
generator ran, emitted nothing not a compile error; a generator that did not recognise its input
*.razor.css CSS isolation the b-… scope comes from the SDK's ComputeCssScope/ApplyCssScopes tasks; this builder runs no MSBuild task, so the components would compile without their scope attributes. --accept razor-css-scope builds them anyway. Reproducing the scope hash from memory is the guess this evaluator exists to avoid
.razor under a non-Razor Sdk the SDK ignores them too — but this says so. --accept razor-not-compiled

Measured, 2026-08-31 — 10 of the 11 Razor projects

Every Microsoft.NET.Sdk.Razor project in MeshWeaver.Plugins/src, built inside
memex-portal-ai@sha256:6f38db08… with this branch's builder mounted:

count
green, no extra help 7 MeshWeaver.Blazor (31 .cs + 42 .razor, 0 warnings under warnings-as-errors), .EntityViews, .Graph, .Analysis, .OpenStreetMap, .GoogleMaps, .AppleMaps
green with --generators +2 .Views, .Portal — blocked by [GeneratedRegex] in MeshWeaver.Markdown.Collaboration, not by Razor
green with --extra-refs +1 .Radzen — Radzen.Blazor is an additional library
still red 1 Memex.Portal.Gui — a transitive MeshWeaver.Hosting.Grpc carries <Protobuf>; protoc is a build task, not a compile

Razor blocks none of them. The two remaining categories are the pre-existing ones.

⚠️ The sweep ran on linux/arm64, natively on an Apple Silicon host. The linux/amd64 runs on
that host crash under Rosetta — and the control proves it is the emulator, not this change: the
same emulated container dies with AccessViolationException building MeshWeaver.Speech, a project
with no Razor in it that #2850 verified green. CI runs on real x64 and is the x64 evidence.

The framework build identity is untouched

No ProjectReference was added to tools/MeshWeaver.PluginTester — whose closure is the
framework identity. The assemblies ride along as Content with CopyToPublishDirectory and are
loaded at run time. FrameworkBuildIdentityTest (23 cases) still passes. OptionsFingerprint and
EmitPipeline.CreateCompilationOptions are not touched either; MeshWeaver.Compiler is not
modified at all by this PR.

Tests — 13 new, EXECUTED

dotnet test test/MeshWeaver.PluginTester.Test -c Release → Passed! Failed: 0, Passed: 188
(175 before, no regressions).

  • the shipped generator is found beside the builder and loads against this repo's Roslyn, and the
    loaded generator is RazorSourceGenerator;
  • the staged closure is the compiler + its one private dependency, in a per-RID directory, with a
    provenance manifest;
  • a real .razor (with @inherits, markup and @code) compiles and the emitted assembly's
    component type is loaded and its generated BuildRenderTree override found on it;
  • a sub-directory component lands in the namespace the TargetPath dictates;
  • _Imports.razor contributes its usings to a sibling (it would not compile otherwise);
  • the failure path names the missing generator and reports one error rather than CS0115 noise;
  • scoped CSS, and .razor under a plain Sdk, are each refused by name and build under their
    --accept token;
  • Content Remove takes a component out of the build; --razor-generators replaces the search
    path rather than heading it (no silent fallback to the image's own copy);
  • a missing directory, and a directory of non-generator assemblies, are each a named failure.

Docs

Doc/Architecture/InMeshBuildAndTest.md gains a "The container compiles Razor" section — the
measured closure, both traps, the refusals and the sweep table — and the two READMEs
(MeshWeaver.Cli, MeshWeaver.PluginTester) are updated.

🤖 Generated with Claude Code

rbuergi and others added 2 commits August 31, 2026 01:41
… Blazor

Razor was the biggest single gap in `memex build project` (#2850): 15 of the 42
failures in its first no-SDK sweep of MeshWeaver.Plugins/src were CS0115, more
than any other category. The cause was one missing file, not a missing feature —
Razor compilation in the .NET SDK is a Roslyn SOURCE GENERATOR, and a runtime
image ships no SDK, so every component compiled to a class with nothing to
override.

The image now carries the generator in razor-generators/<rid>/ beside the
builder, and build-project finds and runs it for any project whose Sdk processes
Razor items.

The closure was MEASURED, not assumed: exactly Microsoft.CodeAnalysis.Razor.Compiler
plus Microsoft.AspNetCore.Razor.Utilities.Shared. Microsoft.Extensions.ObjectPool,
believed to be needed, is not referenced by this compiler build at all.

Two traps that fail at run time and nowhere else, both handled and both tested:

  * The generator is built against the SDK's Roslyn (10.0.400 wants
    Microsoft.CodeAnalysis 5.9.0.0) and the image carries this repo's pin
    (5.6.0). The default load context refuses the lower version, and a loader
    that reads "cannot load" as "not a generator" produces a build indis-
    tinguishable from one nobody asked Razor for. Generators therefore load into
    a context that binds every assembly the host already has to the host's copy,
    version ignored — which is also what keeps ISourceGenerator ONE type.

  * The generator is ReadyToRun-compiled for the SDK's own RID: the same
    10.0.400 file carries PE machine 0xFD1D on linux-x64, 0xD11D on linux-arm64
    and 0xEC20 on osx-arm64. mw-plugin-test publishes both architectures from one
    x64 host, so a single copy would have shipped an arm64 image that cannot
    compile a Blazor project. CD stages one directory per RID, reading the arm64
    copy out of the dotnet/sdk image of the SAME SDK version with docker
    create + docker cp — no emulation, no second pin.

Nothing is skipped in silence: a project with Razor files and no generator fails
by name (saying what the CS0115 wall would have been), a generator that runs and
emits nothing fails by name, CSS isolation is refused with --accept
razor-css-scope because the b-… scope comes from an MSBuild task this builder
does not run, and .razor under a non-Razor Sdk is refused with --accept
razor-not-compiled.

No ProjectReference was added to tools/MeshWeaver.PluginTester — the assemblies
ride along as Content and are loaded at run time, so the framework build identity
is untouched (FrameworkBuildIdentityTest, 23 cases, still green).

Measured against memex-portal-ai@sha256:6f38db08…, all 11 Microsoft.NET.Sdk.Razor
projects in MeshWeaver.Plugins/src: 7 green with no extra help (incl.
MeshWeaver.Blazor — 31 .cs + 42 .razor, zero warnings under warnings-as-errors),
+2 with --generators for a [GeneratedRegex] dependency, +1 with --extra-refs for
Radzen.Blazor. The one still red is Memex.Portal.Gui, blocked by <Protobuf> in a
transitive ProjectReference. Razor blocks none of them.

Tests: 13 new in test/MeshWeaver.PluginTester.Test/RazorBuildTest.cs, executed —
188 passing, up from 175, no regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or parameters

check-record-signatures.py is right and the fix is not an allow entry: adding a
parameter to a record's primary constructor — even with a default — REPLACES the
signature, so every assembly compiled against the old arity calls a constructor
that no longer exists. ProjectFile.Model's four Razor members and
ProjectResult.RazorCount become init properties instead, which is additive.
RazorItems defaults to [] rather than default(ImmutableArray<T>), which throws on
enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

792 tests  ±0   788 ✅ ±0   4m 14s ⏱️ -7s
  7 suites ±0     4 💤 ±0 
  7 files   ±0     0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 085 tests  ±0   1 085 ✅ ±0   5m 0s ⏱️ -44s
    7 suites ±0       0 💤 ±0 
    7 files   ±0       0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 015 tests  +13   1 015 ✅ +13   5m 16s ⏱️ -7s
    7 suites ± 0       0 💤 ± 0 
    7 files   ± 0       0 ❌ ± 0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 025 tests  ±0   2 833 ✅ ±0   6m 3s ⏱️ +28s
    8 suites ±0     192 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    8 files  ±0      8 suites  ±0   5m 48s ⏱️ -9s
1 533 tests ±0  1 532 ✅ ±0  1 💤 ±0  0 ❌ ±0 
2 226 runs  ±0  2 225 ✅ ±0  1 💤 ±0  0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 181 tests  ±0   1 181 ✅ ±0   8m 7s ⏱️ -7s
    7 suites ±0       0 💤 ±0 
    7 files   ±0       0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   44 files  ± 0     44 suites  ±0   34m 32s ⏱️ -43s
8 631 tests +13  8 434 ✅ +13  197 💤 ±0  0 ❌ ±0 
9 324 runs  +13  9 127 ✅ +13  197 💤 ±0  0 ❌ ±0 

Results for commit e29696b. ± Comparison against base commit 94be044.

@rbuergi
rbuergi merged commit c700412 into feat/cli-build-project Aug 31, 2026
25 checks passed
@rbuergi

rbuergi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

I merged this, and I should say plainly that I did not check its base first

Merged at 04:51:25Z by an automated drain of the green PR backlog. This PR's base is feat/cli-build-project (the branch of #2850), not main — so the merge landed here into that branch, which is what the PR declared it would do, but not what I was doing when I ran it: I was draining PRs to main and treated this as one of them.

The check I ran before merging was mergeStateStatus == CLEAN and Consolidate test results == SUCCESS. Both were true. Neither says anything about which branch the merge targets, and I did not look.

Consequence

Small, and not a loss: this is a deliberately stacked PR, so its content was always going to reach main via #2850. It is there now instead of waiting, and #2850's branch has moved without its owner asking.

What it is not is progress toward main — c700412783f3 is diverged from main (ahead 3, behind 10), so nothing here has shipped.

If this was not what you wanted

Say so and I will revert the merge on feat/cli-build-project. I have deliberately not done anything further to that branch.

Every other PR I queued in the same batch (#2848, #2849, #2852, and my own #2867/#2868/#2872/#2873) has base=main, verified explicitly after this.

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