From c9e7424761201993f8b208f231ff6573d038e4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= Date: Mon, 31 Aug 2026 01:41:18 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(image):=20the=20container=20compiles?= =?UTF-8?q?=20Razor=20=E2=80=94=20the=20SDK-free=20build=20covers=20Blazor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// 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 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) --- .github/workflows/main-cd.yml | 43 ++ src/MeshWeaver.Cli/README.md | 9 +- .../Data/Architecture/InMeshBuildAndTest.md | 83 ++- .../MeshWeaver.PluginTester.Test.csproj | 9 + .../RazorBuildTest.cs | 387 ++++++++++++++ .../MeshWeaver.PluginTester.csproj | 90 ++++ tools/MeshWeaver.PluginTester/Program.cs | 15 +- tools/MeshWeaver.PluginTester/ProjectBuild.cs | 76 ++- tools/MeshWeaver.PluginTester/ProjectFile.cs | 190 ++++++- tools/MeshWeaver.PluginTester/README.md | 32 +- .../RazorGenerators.cs | 494 ++++++++++++++++++ 11 files changed, 1401 insertions(+), 27 deletions(-) create mode 100644 test/MeshWeaver.PluginTester.Test/RazorBuildTest.cs create mode 100644 tools/MeshWeaver.PluginTester/RazorGenerators.cs diff --git a/.github/workflows/main-cd.yml b/.github/workflows/main-cd.yml index f3c1c05933..6b27b1fc8f 100644 --- a/.github/workflows/main-cd.yml +++ b/.github/workflows/main-cd.yml @@ -1055,12 +1055,55 @@ jobs: # csproj (project-local — a global -p:RuntimeIdentifiers breaks referenced libs, see above). # `latest` (the tag the plugins repo pins as MW_TEST_IMAGE) and the GHCR mirror both moved # to `promote`: they are consumer-visible, so they must not appear until every leg is green. + # 🚨 THE RAZOR COMPILER, STAGED FOR BOTH IMAGE ARCHITECTURES. + # `mw-plugin-test build-project` compiles .razor/.cshtml, and Razor compilation in the .NET + # SDK is a Roslyn SOURCE GENERATOR. This image ships the runtime and no SDK, so the generator + # has to travel with the builder — and it CANNOT be copied once: the SDK crossgens it for the + # SDK's own RID, so the same 10.0.400 file carries PE machine 0xFD1D on linux-x64 and 0xD11D + # on linux-arm64, and the wrong one throws BadImageFormatException at run time and nowhere + # else. This image publishes BOTH architectures from ONE x64 host, so both copies are staged + # and the builder picks the one for the RID it is running on. + # + # The arm64 copy comes from the dotnet/sdk image of the EXACT SAME SDK VERSION the runner is + # using — one source of truth, no second pin to drift — and `docker create` + `docker cp` + # reads it out without ever executing an arm64 binary, so no qemu is needed. + - name: Stage the Razor source generator for linux-x64 and linux-arm64 + id: razor + run: | + set -euo pipefail + test "$(uname -m)" = "x86_64" \ + || { echo "::error::this step assumes an x64 runner (it takes the host SDK's copy as the linux-x64 one)"; exit 1; } + SDK_VERSION="$(dotnet --version)" + SDK_ROOT="$(dotnet --list-sdks | sed -n "s|^${SDK_VERSION} \[\(.*\)\]$|\1|p" | head -1)" + HOST_SOURCE="$SDK_ROOT/$SDK_VERSION/Sdks/Microsoft.NET.Sdk.Razor/source-generators" + ROOT="$RUNNER_TEMP/razor-generators" + echo "SDK $SDK_VERSION; host source-generators: $HOST_SOURCE" + + mkdir -p "$ROOT/linux-x64" "$ROOT/linux-arm64" + cp "$HOST_SOURCE"/*.dll "$ROOT/linux-x64/" + + IMAGE="mcr.microsoft.com/dotnet/sdk:$SDK_VERSION" + docker pull --platform linux/arm64 "$IMAGE" + CID="$(docker create --platform linux/arm64 "$IMAGE")" + trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT + docker cp "$CID:/usr/share/dotnet/sdk/$SDK_VERSION/Sdks/Microsoft.NET.Sdk.Razor/source-generators/." \ + "$ROOT/linux-arm64/" + + # 🚨 Fail closed. An empty or half-staged RID ships an image whose Razor builds fail on + # exactly one architecture — which nothing in this pipeline would notice. + for rid in linux-x64 linux-arm64; do + test -f "$ROOT/$rid/Microsoft.CodeAnalysis.Razor.Compiler.dll" \ + || { echo "::error::no Razor compiler staged for $rid"; exit 1; } + echo "$rid: $(ls "$ROOT/$rid" | tr '\n' ' ')" + done + echo "root=$ROOT" >> "$GITHUB_OUTPUT" - name: Build + push mw-plugin-test (staging tag, ACR) run: > dotnet publish tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj -c Release --no-self-contained -t:PublishContainer -p:PublishProfile= -p:ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"' -p:CIRun=true + -p:MeshWeaverRazorGeneratorRoot=${{ steps.razor.outputs.root }} -p:ContainerRegistry=${{ env.ACR }} -p:ContainerRepository=mw-plugin-test -p:ContainerImageTags=${{ needs.gate.outputs.staging_tag }} diff --git a/src/MeshWeaver.Cli/README.md b/src/MeshWeaver.Cli/README.md index a24527af44..ed91d47345 100644 --- a/src/MeshWeaver.Cli/README.md +++ b/src/MeshWeaver.Cli/README.md @@ -75,10 +75,17 @@ assembly the image already carries. See | `--output ` | where the emitted assemblies land (default: a temp dir) | | `--root ` | directory mounted as `/repo` (default: the nearest `Directory.Build.props` ancestor) | | `--extra-refs ` | libraries ADDITIONAL to the platform — the only way to satisfy a `PackageReference` the image does not supply. Repeatable | -| `--accept ` | acknowledge one construct the evaluator cannot reproduce (`target:`, `embedded-resource`, `conditions`). Repeatable | +| `--accept ` | acknowledge one construct the evaluator cannot reproduce (`target:`, `embedded-resource`, `conditions`, `razor-css-scope`, `razor-not-compiled`). Repeatable | | `--no-warn` / `--allow-warnings` | warnings fail the build (default); `--no-warn=false` or `--allow-warnings` opts out | | `--no-pull` | use the image the docker daemon already has, for one built locally | +**Razor/Blazor compiles** (2026-08-31): the image ships the SDK's Razor source generator beside the +builder, per RID, and `build-project` runs it for any project whose `Sdk` processes Razor items — +`MeshWeaver.Blazor` (31 `.cs` + 42 `.razor`) builds green against `memex-portal-ai`. What it will +not do quietly is skip a `.razor` file: a project with Razor input and no generator fails by name, +and CSS isolation (`*.razor.css`) needs `--accept razor-css-scope` because the `b-…` scope comes +from an MSBuild task this builder does not run. + 🚨 **Nothing is dropped in silence.** A project construct this builder cannot reproduce — an unknown element, an unevaluatable `Condition`, a ``, an `` — FAILS the run naming the construct and the file, and a `PackageReference` the container does not supply is reported by diff --git a/src/MeshWeaver.Documentation/Data/Architecture/InMeshBuildAndTest.md b/src/MeshWeaver.Documentation/Data/Architecture/InMeshBuildAndTest.md index 97798c037b..fed8e43937 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/InMeshBuildAndTest.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/InMeshBuildAndTest.md @@ -392,17 +392,92 @@ with `--accept targets --accept embedded-resource` and ClosedXML + CsvHelper as | | count | why | |---|---|---| | **green** | **12** | incl. `MeshWeaver.Import` — 90 source files, 0 warnings under warnings-as-errors | -| Razor/Blazor (CS0115) | 15 | `.razor` components are not compiled — see below | +| Razor/Blazor (CS0115) | 15 | *fixed 2026-08-31 — see below* | | source generators (CS8795) | 15 | `[GeneratedRegex]` / `[LoggerMessage]` / `[JsonSerializable]` | | gRPC `` | 3 | protoc codegen is a build task, not a compile | | additional libraries | 5 | Snowflake.Data, Microsoft.Data.Sqlite, Azure.Cosmos, … — supply with `--extra-refs` | | portal hosts | 3 | an `` above the mount; Aspire's `` ELEMENT | +## The container compiles Razor (2026-08-31) + +Razor was the biggest single category above, and it was one missing file rather than 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 generated `BuildRenderTree` override. A runtime image ships no SDK, so without the generator +every component compiled to a class with nothing to override — a wall of CS0115 that reads exactly +like broken source. + +The image now carries it, in `razor-generators/` beside the builder, and `build-project` finds and +runs it automatically for any project whose `Sdk` processes Razor items. + +### The dependency closure, measured + +Exactly **two** assemblies, established by reading the compiler's own assembly references and then +proving it by deleting one: + +| assembly | why | +|---|---| +| `Microsoft.CodeAnalysis.Razor.Compiler` | carries `RazorSourceGenerator`, the `[Generator]` type | +| `Microsoft.AspNetCore.Razor.Utilities.Shared` | its one private dependency — with it absent the compiler loads, its types enumerate, and every call into it throws | + +Everything else it references — `netstandard`, `Microsoft.CodeAnalysis(.CSharp)`, +`System.Collections.Immutable`, `System.Memory`, `System.Buffers` — binds to the assemblies the +image already has. 🚨 **`Microsoft.Extensions.ObjectPool` is NOT needed**, though an older Razor +compiler build referenced it; the belief that it was is exactly why the closure was measured rather +than assumed. + +### Two traps, both of which 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 +references `Microsoft.CodeAnalysis` **5.9.0.0** while 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 — and a generator loader that treats "cannot load" as "not a generator" then produces a build +indistinguishable from one where nobody asked for Razor. The generator is therefore loaded into a +context that binds every assembly the HOST already has to the host's copy, version ignored. That is +also what keeps `ISourceGenerator` ONE type: a second Roslyn in that context would give the +generator a different interface than the driver expects. + +🚨 **The generator is ReadyToRun-compiled for the SDK's own RID.** The same SDK 10.0.400 file carries +PE machine `0xFD1D` on linux-x64, `0xD11D` on linux-arm64 and `0xEC20` on osx-arm64 (the target +machine XOR'd with the operating system's R2R marker), and the wrong one throws +`BadImageFormatException`. `mw-plugin-test` publishes BOTH architectures from ONE x64 build host, so +copying the build machine's SDK once would have shipped an arm64 image that cannot compile a single +Blazor project. CD stages one directory per RID (`razor-generators//`, the arm64 copy read out +of the dotnet/sdk image of the same SDK version with `docker create` + `docker cp`, so no emulation +is involved), and the builder picks the directory for the RID it is running on. + +### What it refuses, and why + +- **A project with Razor files and no generator.** One named failure that says what the CS0115 wall + would have been, not the wall. +- **A generator that ran and emitted nothing.** Not a compile error — a generator that did not + recognise its input, which otherwise fails four steps later as CS0115. +- **CSS isolation (`*.razor.css`).** The `b-…` scope identifier comes from the SDK's + `ComputeCssScope`/`ApplyCssScopes` tasks, and this builder runs no MSBuild task, so the components + would compile without their scope attributes. `--accept razor-css-scope` builds them anyway (the + assembly is valid; only the isolated stylesheet stops applying). Reproducing the scope hash from + memory is the guess this evaluator exists to avoid. +- **`.razor` under a project whose `Sdk` does not process Razor.** The SDK's build ignores them too, + so the outcome matches — but it is stated rather than skipped. `--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 against +`memex-portal-ai@sha256:6f38db08…` with the builder mounted, `--accept targets --accept +razor-css-scope --accept embedded-resource`: + +| | count | | +|---|---|---| +| **green, no extra help** | **7** | Blazor (31 `.cs` + **42 `.razor`**), Blazor.EntityViews, Blazor.Graph, Blazor.Analysis, Blazor.OpenStreetMap, Blazor.GoogleMaps, Blazor.AppleMaps | +| green with `--generators` (the SDK's regex generator) | +2 | Blazor.Views, Blazor.Portal — they were blocked by `[GeneratedRegex]` in `MeshWeaver.Markdown.Collaboration`, not by Razor | +| green with `--extra-refs` | +1 | Blazor.Radzen — `Radzen.Blazor` is an additional library | +| still red | 1 | `Memex.Portal.Gui` — a transitive `MeshWeaver.Hosting.Grpc` carries ``; protoc is a build task, not a compile | + +**Razor is no longer what blocks any of them.** The two remaining categories are the pre-existing +ones — SDK source generators and ``. + ### What it does NOT do, and what each would take -- **Razor/Blazor.** `.razor` and `.cshtml` are not compiled at all. Doing so needs - `Microsoft.CodeAnalysis.Razor.Compiler`, `Microsoft.AspNetCore.Razor.Utilities.Shared` and - `Microsoft.Extensions.ObjectPool` present in the image — today it ships none of them. - **Source generators.** They live in the .NET **SDK**, and a MeshWeaver image ships the **runtime**. `--generators ` loads them when supplied; with none, the builder names the generator that did not run rather than leaving a wall of CS8795 that reads like broken source. diff --git a/test/MeshWeaver.PluginTester.Test/MeshWeaver.PluginTester.Test.csproj b/test/MeshWeaver.PluginTester.Test/MeshWeaver.PluginTester.Test.csproj index 69cd9ed65d..e86e553ea5 100644 --- a/test/MeshWeaver.PluginTester.Test/MeshWeaver.PluginTester.Test.csproj +++ b/test/MeshWeaver.PluginTester.Test/MeshWeaver.PluginTester.Test.csproj @@ -11,6 +11,15 @@ + + + + + + + $(NetCoreRoot)sdk/$(NETCoreSdkVersion)/Sdks/Microsoft.NET.Sdk.Razor/source-generators + $(NETCoreSdkRuntimeIdentifier) + + + + + + <_MeshWeaverRazorGenerator Include="$(MeshWeaverRazorGeneratorRoot)/*/*.dll" /> + <_MeshWeaverRazorGenerator> + $([System.IO.Path]::GetFileName($([System.IO.Path]::GetDirectoryName('%(FullPath)')))) + + + + <_MeshWeaverRazorGenerator Include="$(MeshWeaverRazorGeneratorSource)/*.dll" RidFolder="$(MeshWeaverRazorGeneratorRid)" /> + + + + + + + + <_MeshWeaverRazorGeneratorNames>@(_MeshWeaverRazorGenerator->'%(RidFolder)/%(Filename)%(Extension)', '", "') + <_MeshWeaverRazorGeneratorOrigin Condition="'$(MeshWeaverRazorGeneratorRoot)' != ''">$(MeshWeaverRazorGeneratorRoot) + <_MeshWeaverRazorGeneratorOrigin Condition="'$(MeshWeaverRazorGeneratorRoot)' == ''">$(MeshWeaverRazorGeneratorSource) + + + + + + + +