From dfe21d72d815a62a44d961483db15460490ea0d4 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:46:18 +0000 Subject: [PATCH 01/25] build: move Purview.SourceGeneratorFramework to 1.2.2 1.2.1 copied Purview.SourceGeneratorFramework.dll to the output of every project that referenced the generator as an analyzer-only ProjectReference. Built on main, all eight sample bins carried it. With 1.2.2 only the generator and refactorings projects' own output holds it. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dbe844eb..cb6dfc6f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ 5.9.0 1.76.0 - 1.2.1 + 1.2.2 10.0.13 10.11.0 1.18.0 From 0574e54d5f7890278babbe6721bcc4d1d7c55bff Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 10:48:09 +0000 Subject: [PATCH 02/25] build: move Purview.BuildSdk and Purview.Skills.Testing to 1.2.0 The SDK refreshed its managed .editorconfig, which changes only the version marker on the first line. The solution builds with no new warnings and the skill rule set passes unchanged. --- .editorconfig | 2 +- Directory.Packages.props | 2 +- global.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index b5117897..ea030585 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/Directory.Packages.props b/Directory.Packages.props index cb6dfc6f..a987b886 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + diff --git a/global.json b/global.json index 8b388e3d..c9e8fce7 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" From a9792ed4c15003510f234298562f839aba47e823 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:14:27 +0000 Subject: [PATCH 03/25] test(packed): add a packed-consumer gate for Native AOT and Blazor consumers just smoke-packed-consumers packs the generator to an isolated feed and cache, then builds consumers that reference the .nupkg only, each with a class library of its own telemetry: - a Native AOT console, published with -warnaserror and run with in-process listeners and a capturing logger, asserting activities, tags, baggage, events, metrics, v1 and v2 logs, log-level gating, multi-target methods and TelemetryNames aggregation - a Blazor WebAssembly app, published trimmed and with RunAOTCompilation; the trimmed assemblies must keep every generated member, and every generated method must be AOT-compiled - a Blazor Server host, rendered on a TestServer, with the same telemetry assertions Every bin and publish folder is scanned for compiler-host assemblies by scripts/assert-no-compiler-host-assemblies.ps1 (also just scan-compiler-host-leaks). --- Directory.Packages.props | 10 + Justfile | 18 + .../assert-no-compiler-host-assemblies.ps1 | 159 +++++ .../AotConsole/AotConsole.csproj | 56 ++ .../packed-consumers/AotConsole/Program.cs | 58 ++ .../BlazorServer/BlazorServer.csproj | 47 ++ .../BlazorServer/Components/App.razor | 13 + .../Components/Pages/Orders.razor | 22 + .../BlazorServer/Components/Routes.razor | 5 + .../BlazorServer/Components/_Imports.razor | 5 + .../packed-consumers/BlazorServer/Program.cs | 71 ++ scripts/packed-consumers/BlazorWasm/App.razor | 8 + .../BlazorWasm/BlazorWasm.csproj | 57 ++ .../BlazorWasm/Pages/Orders.razor | 30 + .../packed-consumers/BlazorWasm/Program.cs | 17 + .../BlazorWasm/_Imports.razor | 3 + .../BlazorWasm/wwwroot/index.html | 13 + .../Library/IInventoryTelemetry.cs | 39 ++ .../packed-consumers/Library/Library.csproj | 37 + .../packed-consumers/Shared/Expectations.cs | 216 ++++++ .../Shared/IOrderTelemetry.cs | 49 ++ .../packed-consumers/Shared/OrderWorkflow.cs | 33 + .../Shared/TelemetryCapture.cs | 167 +++++ scripts/test-packed-consumers.ps1 | 638 ++++++++++++++++++ 24 files changed, 1771 insertions(+) create mode 100644 scripts/assert-no-compiler-host-assemblies.ps1 create mode 100644 scripts/packed-consumers/AotConsole/AotConsole.csproj create mode 100644 scripts/packed-consumers/AotConsole/Program.cs create mode 100644 scripts/packed-consumers/BlazorServer/BlazorServer.csproj create mode 100644 scripts/packed-consumers/BlazorServer/Components/App.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/Routes.razor create mode 100644 scripts/packed-consumers/BlazorServer/Components/_Imports.razor create mode 100644 scripts/packed-consumers/BlazorServer/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/App.razor create mode 100644 scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj create mode 100644 scripts/packed-consumers/BlazorWasm/Pages/Orders.razor create mode 100644 scripts/packed-consumers/BlazorWasm/Program.cs create mode 100644 scripts/packed-consumers/BlazorWasm/_Imports.razor create mode 100644 scripts/packed-consumers/BlazorWasm/wwwroot/index.html create mode 100644 scripts/packed-consumers/Library/IInventoryTelemetry.cs create mode 100644 scripts/packed-consumers/Library/Library.csproj create mode 100644 scripts/packed-consumers/Shared/Expectations.cs create mode 100644 scripts/packed-consumers/Shared/IOrderTelemetry.cs create mode 100644 scripts/packed-consumers/Shared/OrderWorkflow.cs create mode 100644 scripts/packed-consumers/Shared/TelemetryCapture.cs create mode 100644 scripts/test-packed-consumers.ps1 diff --git a/Directory.Packages.props b/Directory.Packages.props index a987b886..8ab13d43 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,4 +50,14 @@ + + + + + diff --git a/Justfile b/Justfile index 939da973..8cdfbd02 100644 --- a/Justfile +++ b/Justfile @@ -55,6 +55,18 @@ pipeline-local-release *args: echo "Running local release pipeline..." {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} +# Pack the generator to an isolated feed and cache, then build, publish and run throwaway consumers that reference +# the package only: a Native AOT console, a Blazor WebAssembly app (trimmed, plus RunAOTCompilation when wasm-tools is +# installed) and a Blazor Server host, each leak-scanned for compiler-host assemblies. +# +# The tests and samples reference the generator project; this is the gate that proves what ships. The CI +# `packed-consumers` job runs it for linux-x64. Purview.SourceGeneratorFramework and Purview.BuildSdk are restored +# from nuget.org, plus TELEMETRY_PRERELEASE_FEED or LOCAL_NUGET_FEED_PATH when set (or pass -PrereleaseFeed). +[group('Pipeline')] +smoke-packed-consumers *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} + # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: @@ -91,6 +103,12 @@ test filter="/*/*/*/*/" *args: echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} {{ args }} +# Scan build or publish output for compiler-host assemblies (the generator, the refactorings, +# Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. +[group('Build and Test')] +scan-compiler-host-leaks +paths: + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ paths }} + # Cleans the solution with the specified configuration (default: Release) [group('Build and Test')] clean *args: diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 00000000..81a93884 --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when a build or publish output carries a compiler-host-only assembly. + +.DESCRIPTION + The telemetry source generator, its refactorings and the framework they are built on run inside the compiler. None + of them may reach an application: Purview.SourceGeneratorFramework.dll once leaked into app build and publish output + through analyzer-only references (Purview.SourceGeneratorFramework 1.2.1 put it in every sample's bin here), and + Blazor WebAssembly AOT compilation fails on it. A build stays green while it leaks, because nothing looks at what is + actually written to disk. + + This script looks. Every file under each -Path is checked by name, and every dependency manifest + (*.deps.json, *.runtimeconfig.json, blazor.boot.json, dotnet.boot.js, and dotnet.js, which embeds the boot manifest since + .NET 10 - including fingerprinted and Brotli/gzip compressed copies) is checked by content. A file, or a manifest entry, naming any of these fails the scan: + + Purview.SourceGeneratorFramework* + Microsoft.CodeAnalysis* + Purview.Telemetry.SourceGenerator* (the generator, and Purview.Telemetry.SourceGenerator.Refactorings) + + A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. + + Used by scripts/test-packed-consumers.ps1 on every consumer bin and publish folder, by the samples CI job on the + sample build output, and by `just scan-compiler-host-leaks`. + +.PARAMETER Path + One or more build or publish output directories to scan, recursively. Space-separated when run with -File. + +.PARAMETER Allow + Wildcard patterns (PowerShell -like, matched against each file's path relative to the scanned directory, with + forward slashes) for third-party tools that legitimately carry Roslyn and are never loaded by the application - + for example '*BlazorDebugProxy/*', the WebAssembly debugging proxy Microsoft.AspNetCore.Components.WebAssembly.Server + publishes beside a Blazor server. Allowed files are counted and reported, never silently ignored. Keep each pattern + as narrow as the tool's own folder. + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path samples/SampleApp/SampleApp.Web/bin + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/blazor-host -Allow '*BlazorDebugProxy/*' + +.EXAMPLE + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/aot-console bin/Release +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory, Position = 0)] + [string[]] $Path, + + [string[]] $Allow = @(), + + # Collects the rest of `pwsh -File ... -Path a b` (space-separated): -File passes arguments as strings and does + # not parse PowerShell's comma array syntax. + [Parameter(ValueFromRemainingArguments, DontShow)] + [string[]] $AdditionalPath = @() +) + +$ErrorActionPreference = 'Stop' +$Path = @($Path) + @($AdditionalPath) + +# File-name prefixes. Published WebAssembly files carry a fingerprint (Name.abc123.wasm), which a prefix still matches. +$forbiddenNames = @( + 'Purview.SourceGeneratorFramework' + 'Microsoft.CodeAnalysis' + 'Purview.Telemetry.SourceGenerator' +) + +$forbiddenContent = [regex]::new( + (($forbiddenNames | ForEach-Object { [regex]::Escape($_) }) -join '|'), + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +# Dependency manifests, optionally fingerprinted (dotnet.boot.abc123.js) and optionally compressed (.br, .gz). Since .NET 10 +# the WebAssembly boot manifest is embedded in _framework/dotnet.js, so that file is a manifest too; dotnet.native.*.js and +# dotnet.runtime.*.js are the runtime itself and never list assemblies. +$manifestPattern = [regex]::new( + '(\.deps\.json|\.runtimeconfig\.json|^blazor\.boot(\.[a-z0-9]+)?\.json|^dotnet(\.boot)?(\.[a-z0-9]+)?\.js)(\.br|\.gz)?$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +function Read-ManifestText([System.IO.FileInfo] $file) { + $stream = [System.IO.File]::OpenRead($file.FullName) + try { + $source = switch -Regex ($file.Name) { + '\.br$' { [System.IO.Compression.BrotliStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + '\.gz$' { [System.IO.Compression.GZipStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + default { $stream } + } + + $reader = [System.IO.StreamReader]::new($source) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +$violations = [System.Collections.Generic.List[string]]::new() + +foreach ($directory in $Path) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + $violations.Add("'$directory' does not exist or is not a directory, so it cannot be scanned.") + continue + } + + $root = (Resolve-Path -LiteralPath $directory).Path + $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force) + if ($files.Count -eq 0) { + $violations.Add("'$root' holds no files, so the scan would prove nothing.") + continue + } + + $manifests = 0 + $allowed = 0 + foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName) + $forwardSlashed = $relative.Replace('\', '/') + if ($Allow | Where-Object { $forwardSlashed -like $_ }) { + $allowed++ + continue + } + + foreach ($name in $forbiddenNames) { + if ($file.Name.StartsWith($name, [System.StringComparison]::OrdinalIgnoreCase)) { + $violations.Add("compiler-host file '$relative' in '$root'.") + break + } + } + + if ($manifestPattern.IsMatch($file.Name)) { + $manifests++ + $matched = $forbiddenContent.Matches((Read-ManifestText $file)) | + ForEach-Object Value | + Sort-Object -Unique + if ($matched) { + $violations.Add("manifest '$relative' in '$root' names $($matched -join ', ').") + } + } + } + + $allowedNote = $allowed -gt 0 ? (", {0} allowed by -Allow {1}" -f $allowed, ($Allow -join ', ')) : '' + Write-Host ("Scanned {0}: {1} file(s), {2} manifest(s){3}." -f $root, $files.Count, $manifests, $allowedNote) +} + +if ($violations.Count -gt 0) { + foreach ($violation in $violations) { Write-Failure $violation } + Write-Failure ( + "$($violations.Count) compiler-host leak(s) found. Source generators, analyzers and their dependencies run " + + 'inside the compiler and must never reach build or publish output; check analyzer-only references ' + + '(OutputItemType="Analyzer", ReferenceOutputAssembly="false", PrivateAssets="all") and the package layout.' + ) + exit 1 +} + +Write-Host 'No compiler-host assemblies or manifest references found.' -ForegroundColor Green diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj new file mode 100644 index 00000000..d86dd8e4 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,56 @@ + + + + Exe + net11.0 + PackedTelemetryAot + PackedTelemetry + enable + enable + true + true + false + true + true + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 00000000..35fd9965 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PackedTelemetry; +using PackedTelemetry.Library; +using PackedTelemetryAot; +using Purview.Telemetry; + +// Only this project opts into the TelemetryNames class, which aggregates the names of every assembly in its reference +// graph - here its own and PackedTelemetry.Library's - from assembly metadata, at compile time. It is generated into +// the namespace named for the assembly, PackedTelemetryAot. +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] + +const string OrderId = "ord-42"; +const string CustomerId = "cust-7"; +const double Total = 129.5; + +using var capture = new TelemetryCapture(Expectations.Names); + +ServiceCollection services = new(); +services.AddLogging(logging => logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture)); +services.AddMetrics(); +services.AddOrderTelemetry(); +services.AddInventory(); +services.AddSingleton(); + +using (var provider = services.BuildServiceProvider()) +{ + provider.GetRequiredService().Place(OrderId, CustomerId, Total); +} + +var failures = Expectations.Verify(capture, OrderId, CustomerId, Total); + +foreach (var name in Expectations.Names) +{ + if (Array.IndexOf(TelemetryNames.ActivitySourceNames, name) < 0) + failures.Add( + $"TelemetryNames.ActivitySourceNames does not aggregate '{name}' ({string.Join(", ", TelemetryNames.ActivitySourceNames)})" + ); + + if (Array.IndexOf(TelemetryNames.MeterNames, name) < 0) + failures.Add( + $"TelemetryNames.MeterNames does not aggregate '{name}' ({string.Join(", ", TelemetryNames.MeterNames)})" + ); +} + +Console.WriteLine(Expectations.Dump(capture)); +if (failures.Count > 0) +{ + foreach (var failure in failures) + Console.Error.WriteLine($"MISMATCH: {failure}"); + + return 1; +} + +Console.WriteLine( + $"Native AOT: {capture.Activities.Count} activities, {capture.Measurements.Count} measurements and the expected log entries, as generated." +); +return 0; diff --git a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj new file mode 100644 index 00000000..f981adc5 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -0,0 +1,47 @@ + + + + net11.0 + PackedTelemetryServer + PackedTelemetry + enable + enable + true + true + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/App.razor b/scripts/packed-consumers/BlazorServer/Components/App.razor new file mode 100644 index 00000000..23ceaeba --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/App.razor @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor new file mode 100644 index 00000000..6fe4bf61 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor @@ -0,0 +1,22 @@ +@page "/" +@inject OrderWorkflow Workflow + +

Orders

+ +

@_status

+ +@code { + public const string OrderId = "ord-server"; + public const string CustomerId = "cust-server"; + public const double Total = 64.25; + + string _status = "No order placed."; + + // Runs once while the page is rendered on the server for the request; the generated telemetry is emitted by the + // service, captured in-process by the host. + protected override void OnInitialized() + { + Workflow.Place(OrderId, CustomerId, Total); + _status = $"Placed {OrderId}."; + } +} diff --git a/scripts/packed-consumers/BlazorServer/Components/Routes.razor b/scripts/packed-consumers/BlazorServer/Components/Routes.razor new file mode 100644 index 00000000..0f0d9193 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/_Imports.razor b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor new file mode 100644 index 00000000..c3ccf47c --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using PackedTelemetry +@using PackedTelemetry.Components diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs new file mode 100644 index 00000000..fade6da0 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.TestHost; +using PackedTelemetry; +using PackedTelemetry.Components; +using PackedTelemetry.Components.Pages; +using PackedTelemetry.Library; + +const string OrderId = Orders.OrderId; +const string CustomerId = Orders.CustomerId; +const double Total = Orders.Total; + +var selfTest = args.Contains("--self-test"); + +using var capture = new TelemetryCapture(Expectations.Names); + +var builder = WebApplication.CreateBuilder(args); +if (selfTest) + builder.WebHost.UseTestServer(); + +// Trace minimum, so the logging infrastructure filters nothing: the capture decides what is enabled. +builder.Logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture); + +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddOrderTelemetry(); +builder.Services.AddInventory(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.UseAntiforgery(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +if (!selfTest) +{ + await app.RunAsync(); + return 0; +} + +await app.StartAsync(); +List failures = []; +try +{ + using var client = app.GetTestClient(); + using var response = await client.GetAsync("/"); + var html = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + failures.Add($"GET / returned {(int)response.StatusCode}: {html}"); + else if (!html.Contains($"Placed {OrderId}.", StringComparison.Ordinal)) + failures.Add($"the page did not render the placed order: {html}"); + else if (!html.Contains("_framework/blazor.web.js", StringComparison.Ordinal)) + failures.Add("the page is not an interactive Blazor page (no blazor.web.js)"); + else + Console.WriteLine($"GET / rendered 'Placed {OrderId}.' on the server"); +} +finally +{ + await app.StopAsync(); +} + +failures.AddRange(Expectations.Verify(capture, OrderId, CustomerId, Total)); + +Console.WriteLine(Expectations.Dump(capture)); +if (failures.Count > 0) +{ + foreach (var failure in failures) + Console.Error.WriteLine($"MISMATCH: {failure}"); + + return 1; +} + +Console.WriteLine("Blazor Server: the page rendered and emitted the generated activities, metrics and log entries."); +return 0; diff --git a/scripts/packed-consumers/BlazorWasm/App.razor b/scripts/packed-consumers/BlazorWasm/App.razor new file mode 100644 index 00000000..abdf5c3b --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 00000000..67a7de52 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,57 @@ + + + + net11.0 + PackedTelemetryWasm + PackedTelemetry + enable + enable + true + false + true + true + true + true + + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor b/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor new file mode 100644 index 00000000..d61deda8 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor @@ -0,0 +1,30 @@ +@page "/" +@inject OrderWorkflow Workflow +@inject IOrderTelemetry Telemetry + +

Orders

+ + +

@_status

+ +@code { + int _placed; + string _status = "No order placed."; + + protected override void OnInitialized() => Place(); + + void Place() + { + var orderId = $"ord-wasm-{++_placed}"; + + // From the page: the generated telemetry directly. + Telemetry.OrdersInFlight(1); + Telemetry.OrderTotal(42, "GBP"); + + // From a service: the generated telemetry, and the library's. + Workflow.Place(orderId, "cust-wasm", 42); + + Telemetry.OrdersInFlight(-1); + _status = $"Placed {orderId}."; + } +} diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 00000000..5d948aa7 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using PackedTelemetry; +using PackedTelemetry.Library; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// The generated registrations: this assembly's AddOrderTelemetry, and the library's (through AddInventory). +builder.Services.AddMetrics(); +builder.Services.AddOrderTelemetry(); +builder.Services.AddInventory(); +builder.Services.AddSingleton(); + +await builder.Build().RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/_Imports.razor b/scripts/packed-consumers/BlazorWasm/_Imports.razor new file mode 100644 index 00000000..c9225c89 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/_Imports.razor @@ -0,0 +1,3 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using PackedTelemetry diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 00000000..f5fbb256 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + Packed Purview Telemetry Blazor WebAssembly consumer + + + +
Loading...
+ + + diff --git a/scripts/packed-consumers/Library/IInventoryTelemetry.cs b/scripts/packed-consumers/Library/IInventoryTelemetry.cs new file mode 100644 index 00000000..094fee79 --- /dev/null +++ b/scripts/packed-consumers/Library/IInventoryTelemetry.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Purview.Telemetry; + +namespace PackedTelemetry.Library; + +/// +/// Telemetry generated in a class library: every consumer references this project, so the packed generator runs in +/// two assemblies of the same application, and the application aggregates this one's names. +/// +[ActivitySource("packed.inventory")] +[Meter("packed.inventory")] +public interface IInventoryTelemetry +{ + [Activity("stock.reserve")] + Activity? ReservingStock([Tag] string sku); + + [Counter("stock.reserved")] + void StockReserved([InstrumentMeasurement] int quantity, [Tag] string sku); +} + +/// Reserves stock, emitting the library's own telemetry. +public sealed class StockReserver(IInventoryTelemetry telemetry) +{ + public void Reserve(string sku, int quantity) + { + using var activity = telemetry.ReservingStock(sku); + telemetry.StockReserved(quantity, sku); + } +} + +public static class InventoryServiceCollectionExtensions +{ + /// + /// The library owns its registration: the generated AddInventoryTelemetry() is internal to it. + /// + public static IServiceCollection AddInventory(this IServiceCollection services) => + services.AddInventoryTelemetry().AddSingleton(); +} diff --git a/scripts/packed-consumers/Library/Library.csproj b/scripts/packed-consumers/Library/Library.csproj new file mode 100644 index 00000000..a545f4ff --- /dev/null +++ b/scripts/packed-consumers/Library/Library.csproj @@ -0,0 +1,37 @@ + + + + net11.0 + PackedTelemetry.Library + PackedTelemetry.Library + enable + enable + true + true + true + + + + + + + + + + + diff --git a/scripts/packed-consumers/Shared/Expectations.cs b/scripts/packed-consumers/Shared/Expectations.cs new file mode 100644 index 00000000..88be961e --- /dev/null +++ b/scripts/packed-consumers/Shared/Expectations.cs @@ -0,0 +1,216 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace PackedTelemetry; + +/// +/// What one call must emit through the packed generator's code. Shared by the +/// Native AOT console and the Blazor Server host, so both prove the same contract. +/// +public static class Expectations +{ + public const string OrdersName = "packed.orders"; + public const string InventoryName = "packed.inventory"; + + public static readonly string[] Names = [OrdersName, InventoryName]; + + /// Returns every mismatch between what was captured and what one order must emit; empty when it all matches. + public static List Verify(TelemetryCapture capture, string orderId, string customerId, double total) + { + List failures = []; + + void Check(bool condition, string failure) + { + if (!condition) + failures.Add(failure); + } + + // Activities. + var order = capture.Activities.Where(a => a.Source == OrdersName && a.Name == "order.place").ToArray(); + Check(order.Length == 1, $"expected one '{OrdersName}/order.place' activity, got {order.Length}"); + if (order.Length == 1) + { + var activity = order[0]; + Check( + Get(activity.Tags, "order_id") == orderId, + $"order.place tag order_id is '{Get(activity.Tags, "order_id")}', not '{orderId}'" + ); + Check( + Get(activity.Baggage, "customer_id") == customerId, + $"order.place baggage customer_id is '{Get(activity.Baggage, "customer_id")}', not '{customerId}'" + ); + + var validated = activity.Events.Where(e => e.Name == "order.validated").ToArray(); + Check(validated.Length == 1, $"expected one order.validated event, got {validated.Length}"); + if (validated.Length == 1) + Check( + Get(validated[0].Tags, "line_count") == "3", + $"order.validated tag line_count is '{Get(validated[0].Tags, "line_count")}', not '3'" + ); + + var exception = activity.Events.Where(e => e.Name == "exception").ToArray(); + Check(exception.Length == 1, $"expected one exception event, got {exception.Length}"); + if (exception.Length == 1) + { + Check( + Get(exception[0].Tags, "exception.type") == "System.InvalidOperationException", + $"exception.type is '{Get(exception[0].Tags, "exception.type")}'" + ); + Check( + Get(exception[0].Tags, "exception.message") == "card declined", + $"exception.message is '{Get(exception[0].Tags, "exception.message")}'" + ); + Check( + Get(exception[0].Tags, "order_id") == orderId, + $"exception event tag order_id is '{Get(exception[0].Tags, "order_id")}'" + ); + } + } + + var reserve = capture.Activities.Where(a => a.Source == InventoryName && a.Name == "stock.reserve").ToArray(); + Check( + reserve.Length == 1, + $"expected one '{InventoryName}/stock.reserve' activity (the library's), got {reserve.Length}" + ); + if (reserve.Length == 1) + Check( + Get(reserve[0].Tags, "sku") == OrderWorkflow.Sku, + $"stock.reserve tag sku is '{Get(reserve[0].Tags, "sku")}'" + ); + + // Metrics. Instrument names carry the prefix the generator derives from the interface name (IOrderTelemetry -> + // "order", IInventoryTelemetry -> "inventory"); the meter name is only prepended with + // MeterNameGenerationType.OpenTelemetry, which these interfaces do not set. + var placed = Measurements(capture, OrdersName, "order.orders.placed"); + Check( + placed.Length == 1 && placed[0].Value == 1, + $"expected one orders.placed measurement of 1, got [{Values(placed)}]" + ); + + var totals = Measurements(capture, OrdersName, "order.order.total"); + Check( + totals.Length == 1 && totals[0].Value == total, + $"expected one order.total measurement of {total}, got [{Values(totals)}]" + ); + if (totals.Length == 1) + { + Check( + Get(totals[0].Tags, "currency") == "GBP", + $"order.total tag currency is '{Get(totals[0].Tags, "currency")}'" + ); + Check(totals[0].Unit == "GBP", $"order.total unit is '{totals[0].Unit}', not 'GBP'"); + } + + var inFlight = Measurements(capture, OrdersName, "order.orders.in_flight"); + Check(Values(inFlight) == "1, -1", $"expected orders.in_flight measurements [1, -1], got [{Values(inFlight)}]"); + + var reserved = Measurements(capture, InventoryName, "inventory.stock.reserved"); + Check( + reserved.Length == 1 && reserved[0].Value == 2, + $"expected one stock.reserved measurement of 2 (the library's), got [{Values(reserved)}]" + ); + if (reserved.Length == 1) + Check( + Get(reserved[0].Tags, "sku") == OrderWorkflow.Sku, + $"stock.reserved tag sku is '{Get(reserved[0].Tags, "sku")}'" + ); + + // Logs. Only this assembly's category: a host logs plenty of its own. + var logs = capture + .Logs.Where(l => l.Category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + .ToArray(); + + var placing = logs.Where(l => l.EventName?.Contains("PlacingOrder", StringComparison.Ordinal) == true) + .ToArray(); + Check( + placing.Length == 1 && placing[0].Level == LogLevel.Information, + $"expected one Information PlacingOrder log entry, got {Describe(placing)}" + ); + if (placing.Length == 1) + { + Check( + placing[0].Message.Contains(orderId, StringComparison.Ordinal), + $"the PlacingOrder message '{placing[0].Message}' does not carry the order id" + ); + Check( + Get(placing[0].State, "OrderId") == orderId, + $"the PlacingOrder state OrderId is '{Get(placing[0].State, "OrderId")}'" + ); + } + + var validatedLogs = logs.Where(l => l.EventName?.Contains("OrderValidated", StringComparison.Ordinal) == true) + .ToArray(); + Check( + validatedLogs.Length == 0, + $"the Debug OrderValidated entry was logged although Debug is disabled (log-level gating): {Describe(validatedLogs)}" + ); + Check(capture.IsEnabledCalls > 0, "the generated logging never asked the logger whether a level was enabled"); + + var shipped = logs.Where(l => l.EventName?.Contains("OrderShipped", StringComparison.Ordinal) == true) + .ToArray(); + Check( + shipped.Length == 1 && shipped[0].Level == LogLevel.Information, + $"expected one Information OrderShipped (v2) log entry, got {Describe(shipped)}" + ); + if (shipped.Length == 1) + { + var state = string.Join(", ", shipped[0].State.Select(p => $"{p.Key}={p.Value}")); + Check( + shipped[0].State.Values.Contains("dhl") && shipped[0].State.Values.Contains("ups"), + $"the v2 OrderShipped state does not expand the carriers: {state}" + ); + } + + var failed = logs.Where(l => l.EventName?.Contains("OrderFailed", StringComparison.Ordinal) == true).ToArray(); + Check( + failed.Length == 1 && failed[0].Level == LogLevel.Error, + $"expected one Error OrderFailed log entry, got {Describe(failed)}" + ); + if (failed.Length == 1) + Check( + failed[0].Exception is InvalidOperationException, + "the OrderFailed log entry does not carry the exception" + ); + + return failures; + } + + /// Everything captured, for the console when an expectation fails. + public static string Dump(TelemetryCapture capture) + { + StringBuilder builder = new(); + foreach (var activity in capture.Activities) + { + builder.AppendLine( + $"activity {activity.Source}/{activity.Name} tags[{Pairs(activity.Tags)}] baggage[{Pairs(activity.Baggage)}]" + ); + foreach (var e in activity.Events) + builder.AppendLine($" event {e.Name} [{Pairs(e.Tags)}]"); + } + + foreach (var m in capture.Measurements) + builder.AppendLine($"metric {m.Meter}/{m.Instrument} ({m.Unit}) = {m.Value} [{Pairs(m.Tags)}]"); + + foreach ( + var log in capture.Logs.Where(l => l.Category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + ) + builder.AppendLine($"log {log.Level} {log.Category} {log.EventName}: {log.Message} [{Pairs(log.State)}]"); + + builder.AppendLine($"IsEnabled calls: {capture.IsEnabledCalls}"); + return builder.ToString(); + } + + static CapturedMeasurement[] Measurements(TelemetryCapture capture, string meter, string instrument) => + [.. capture.Measurements.Where(m => m.Meter == meter && m.Instrument == instrument)]; + + static string Values(CapturedMeasurement[] measurements) => string.Join(", ", measurements.Select(m => m.Value)); + + static string Describe(CapturedLog[] logs) => + logs.Length == 0 ? "none" : string.Join("; ", logs.Select(l => $"{l.Level} {l.EventName}: {l.Message}")); + + static string Pairs(IReadOnlyDictionary pairs) => + string.Join(", ", pairs.Select(p => $"{p.Key}={p.Value}")); + + static string? Get(IReadOnlyDictionary values, string key) => + values.TryGetValue(key, out var value) ? value : null; +} diff --git a/scripts/packed-consumers/Shared/IOrderTelemetry.cs b/scripts/packed-consumers/Shared/IOrderTelemetry.cs new file mode 100644 index 00000000..b5bbdcda --- /dev/null +++ b/scripts/packed-consumers/Shared/IOrderTelemetry.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using Purview.Telemetry; + +namespace PackedTelemetry; + +/// +/// One interface, all three targets: compiled into every consumer, so each one runs the packed generator itself. +/// +/// +/// Names are explicit wherever the generator lets them be, so the assertions in follow +/// the attributes rather than a naming convention. The tag and baggage keys are left to the default +/// (OpenTelemetry snake_case) convention on purpose: that transformation is part of what ships. +/// +[ActivitySource("packed.orders")] +[Logger] +[Meter("packed.orders")] +public interface IOrderTelemetry +{ + /// Multi-target: starts an Activity, logs at Information and increments a counter. + [Activity("order.place")] + [Info] + [AutoCounter("orders.placed")] + Activity? PlacingOrder([Tag] string orderId, [Baggage] string customerId); + + /// + /// Multi-target: adds an ActivityEvent and logs at Debug. The consumers enable logging from Information only, + /// so the event must be recorded while the log entry must not be. + /// + [Event("order.validated")] + [Debug] + void OrderValidated(Activity? activity, [Tag] int lineCount); + + [Histogram("order.total", Unit = "GBP")] + void OrderTotal([InstrumentMeasurement] double total, [Tag] string currency); + + [UpDownCounter("orders.in_flight")] + void OrdersInFlight([InstrumentMeasurement] int delta); + + /// Forces the state-based (v2) log generation, with an expanded array. + [Info(GenerationMode = LoggerGenerationMode.V2)] + void OrderShipped(string orderId, [ExpandEnumerable] string[] carriers); + + /// + /// Multi-target: records the exception on the Activity (OpenTelemetry exception rules) and logs it at Error. + /// + [Event("exception")] + [Error] + void OrderFailed(Activity? activity, Exception exception, [Tag] string orderId); +} diff --git a/scripts/packed-consumers/Shared/OrderWorkflow.cs b/scripts/packed-consumers/Shared/OrderWorkflow.cs new file mode 100644 index 00000000..857d710d --- /dev/null +++ b/scripts/packed-consumers/Shared/OrderWorkflow.cs @@ -0,0 +1,33 @@ +using PackedTelemetry.Library; + +namespace PackedTelemetry; + +/// +/// The service every consumer calls: one order placed through the generated telemetry and the library's. +/// +public sealed class OrderWorkflow(IOrderTelemetry telemetry, StockReserver stock) +{ + public const string Sku = "SKU-1"; + + public void Place(string orderId, string customerId, double total) + { + telemetry.OrdersInFlight(1); + using (var activity = telemetry.PlacingOrder(orderId, customerId)) + { + telemetry.OrderValidated(activity, 3); + stock.Reserve(Sku, 2); + telemetry.OrderTotal(total, "GBP"); + telemetry.OrderShipped(orderId, ["dhl", "ups"]); + + try + { + throw new InvalidOperationException("card declined"); + } + catch (InvalidOperationException exception) + { + telemetry.OrderFailed(activity, exception, orderId); + } + } + telemetry.OrdersInFlight(-1); + } +} diff --git a/scripts/packed-consumers/Shared/TelemetryCapture.cs b/scripts/packed-consumers/Shared/TelemetryCapture.cs new file mode 100644 index 00000000..204c574f --- /dev/null +++ b/scripts/packed-consumers/Shared/TelemetryCapture.cs @@ -0,0 +1,167 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace PackedTelemetry; + +public sealed record CapturedEvent(string Name, IReadOnlyDictionary Tags); + +public sealed record CapturedActivity( + string Source, + string Name, + IReadOnlyDictionary Tags, + IReadOnlyDictionary Baggage, + IReadOnlyList Events +); + +public sealed record CapturedMeasurement( + string Meter, + string Instrument, + string? Unit, + double Value, + IReadOnlyDictionary Tags +); + +public sealed record CapturedLog( + string Category, + LogLevel Level, + string? EventName, + string Message, + IReadOnlyDictionary State, + Exception? Exception +); + +/// +/// An in-process , and that +/// record everything the generated telemetry emits, so a consumer can assert on it. +/// +/// +/// The logger reports itself enabled from up, but records every Log call it +/// receives whatever the level. Register it with the logging minimum at , so the logging +/// infrastructure filters nothing: then a log entry below can only be absent because the +/// generated code asked IsEnabled and skipped it. +/// +public sealed class TelemetryCapture : ILoggerProvider +{ + readonly ActivityListener _activityListener; + readonly MeterListener _meterListener; + readonly ConcurrentQueue _activities = new(); + readonly ConcurrentQueue _measurements = new(); + readonly ConcurrentQueue _logs = new(); + int _isEnabledCalls; + + public TelemetryCapture(string[] names, LogLevel minimumLevel = LogLevel.Information) + { + MinimumLevel = minimumLevel; + + _activityListener = new() + { + ShouldListenTo = source => Array.IndexOf(names, source.Name) >= 0, + Sample = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + _activities.Enqueue( + new( + activity.Source.Name, + activity.OperationName, + ToDictionary(activity.TagObjects), + ToDictionary(activity.Baggage), + [.. activity.Events.Select(static e => new CapturedEvent(e.Name, ToDictionary(e.Tags)))] + ) + ), + }; + ActivitySource.AddActivityListener(_activityListener); + + _meterListener = new() + { + InstrumentPublished = (instrument, listener) => + { + if (Array.IndexOf(names, instrument.Meter.Name) >= 0) + listener.EnableMeasurementEvents(instrument); + }, + }; + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, (double)m, t)); + _meterListener.Start(); + } + + public LogLevel MinimumLevel { get; } + + public IReadOnlyCollection Activities => _activities; + + public IReadOnlyCollection Measurements => _measurements; + + public IReadOnlyCollection Logs => _logs; + + /// + /// How often a PackedTelemetry.* logger - one the generated code created - was asked whether a level was enabled. + /// + public int IsEnabledCalls => Volatile.Read(ref _isEnabledCalls); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() + { + _activityListener.Dispose(); + _meterListener.Dispose(); + } + + void Record(Instrument instrument, double value, ReadOnlySpan> tags) + { + Dictionary captured = []; + foreach (var tag in tags) + captured[tag.Key] = Format(tag.Value); + + _measurements.Enqueue(new(instrument.Meter.Name, instrument.Name, instrument.Unit, value, captured)); + } + + static Dictionary ToDictionary(IEnumerable> pairs) + { + Dictionary result = []; + foreach (var pair in pairs) + result[pair.Key] = Format(pair.Value); + + return result; + } + + static string? Format(object? value) => Convert.ToString(value, CultureInfo.InvariantCulture); + + sealed class CapturingLogger(TelemetryCapture owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) + { + if (category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + Interlocked.Increment(ref owner._isEnabledCalls); + + return logLevel >= owner.MinimumLevel; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + Dictionary captured = []; + if (state is IEnumerable> pairs) + { + foreach (var pair in pairs) + captured[pair.Key] = Format(pair.Value); + } + + owner._logs.Enqueue( + new(category, logLevel, eventId.Name, formatter(state, exception), captured, exception) + ); + } + } +} diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 00000000..ad6fde7d --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,638 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves the Purview.Telemetry.SourceGenerator package works for real consumers - Native AOT, Blazor WebAssembly + (trimmed and AOT-compiled) and a Blazor Server host - built from the .nupkg alone. + +.DESCRIPTION + The in-repository tests drive the generator in-process, and the samples reference the generator project as an + analyzer. Neither is what ships: an IL-merged generator and refactorings assembly under analyzers/, consumed through + a PackageReference. A ProjectReference build also hides the class of bug this gate exists for: + Purview.SourceGeneratorFramework.dll - a compiler-host-only assembly - leaked into the build output of every + project that referenced a generator (SGF 1.2.1 put it in all eight sample bins here), and Blazor WebAssembly AOT + compilation fails on it. + + This gate consumes the package exactly as a user does: + + 1. Preflight: checks Purview.SourceGeneratorFramework (the generator's build-time framework, pinned in + Directory.Packages.props) and Purview.BuildSdk (the MSBuild SDK, pinned in global.json) can be restored, and fails + early, with what to do, if either cannot. + 2. Packs the shipping package (the ids in .config/purview-build.json PackValidation.RequiredContent) to an isolated + feed, with an isolated NUGET_PACKAGES, so no previously extracted package of the same version can be reused. The + feed must hold exactly the expected package, and it may carry no compiler-host assembly outside analyzers/ and no + generator assembly under lib/, ref/ or runtimes/. + 3. Copies the consumers in scripts/packed-consumers/ outside the repository, beside guard + Directory.Build.props/.targets and Directory.Packages.props files that stop any inheritance, with a nuget.config + that resolves Purview.Telemetry.SourceGenerator from the isolated feed only. Every consumer references + PackedTelemetry.Library, a class library with its own generated telemetry, so the generator runs in two + assemblies of each application: + (a) AotConsole - a Native AOT console. Published with -warnaserror (any IL2xxx/IL3xxx fails it), then run: with an + in-process ActivityListener, MeterListener and a capturing logger provider it checks the multi-target method + (an Activity, an Information log entry and a counter from one call), activity tags, baggage and events + (including the OpenTelemetry exception event), every metric measurement and its tags, the v1 and v2 log + entries, log-level gating (a Debug entry must not be written when only Information is enabled) and the + TelemetryNames aggregation of both assemblies. It exits non-zero on any mismatch. + (b) BlazorWasm - a Blazor WebAssembly app using the generated telemetry from a page and a service. Published + trimmed with -warnaserror, then again with RunAOTCompilation=true. _framework must hold both assemblies; the + assemblies ILLink wrote must still define every generated telemetry member (the library is IsTrimmable, so it + is genuinely trimmed); and the AOT publish must have compiled every generated method to WebAssembly. + (c) BlazorServer - a Blazor Web App with interactive server rendering. Published, then run with --self-test: the + host starts on an in-memory TestServer, the page is requested and rendered on the server, and the same + telemetry checks as (a) run against what the page emitted. + 4. (d) Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. + + The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping + projects against the isolated cache, so they are restored again against the normal cache at the end. + +.PARAMETER Configuration + Configuration the package is packed in. Defaults to Release: this gate checks what ships. + +.PARAMETER RuntimeIdentifier + RID for the Native AOT console. Native AOT cannot cross-compile between operating systems, so it defaults to this + machine's RID (win-x64, linux-x64, osx-arm64, ...). + +.PARAMETER PrereleaseFeed + A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework and Purview.BuildSdk from, in addition to + nuget.org - for versions that are not on nuget.org yet. The feed is used for those packages only. Defaults to + $env:TELEMETRY_PRERELEASE_FEED, then $env:LOCAL_NUGET_FEED_PATH. + +.PARAMETER WasmAot + Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is + skipped. Require fails when the workload is missing (CI installs it). Skip never runs it. + +.PARAMETER PackOutput + Also copy the packed .nupkg/.snupkg files here, so a later step can inspect exactly what this gate consumed - CI + runs scripts/test-pack-validation-rules.ps1 against them. + +.PARAMETER PreflightOnly + Run only the restore checks, so CI can fail on them before installing toolchains. + +.PARAMETER KeepArtifacts + Leave the temporary workspace in place for inspection. + +.EXAMPLE + just smoke-packed-consumers + +.EXAMPLE + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Release', + [string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, + [string] $PrereleaseFeed = ($env:TELEMETRY_PRERELEASE_FEED ? $env:TELEMETRY_PRERELEASE_FEED : $env:LOCAL_NUGET_FEED_PATH), + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $WasmAot = 'Auto', + [string] $PackOutput, + [switch] $PreflightOnly, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$leakScan = Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1' +$consumerTemplates = Join-Path $PSScriptRoot 'packed-consumers' + +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$packageVersions = [xml] (Get-Content (Join-Path $repositoryRoot 'Directory.Packages.props') -Raw) +function Get-PackageVersion([string] $id) { + $value = $packageVersions.SelectSingleNode("//PackageVersion[@Include='$id']")?.GetAttribute('Version') + if (-not $value) { throw "Could not read the $id version from Directory.Packages.props." } + return $value +} +function Get-VersionProperty([string] $name) { + $value = $packageVersions.SelectSingleNode("//$name")?.InnerText + if (-not $value) { throw "Could not read the $name property from Directory.Packages.props." } + return $value +} + +$sgfVersion = Get-VersionProperty 'PurviewSGFVersion' +$extensionsVersion = Get-VersionProperty 'DotnetRuntimeVersion' +$extensionsTelemetryVersion = Get-VersionProperty 'MSExtensionVersion' +$aspNetCoreVersion = Get-PackageVersion 'Microsoft.AspNetCore.Components.WebAssembly' +$testHostVersion = Get-PackageVersion 'Microsoft.AspNetCore.TestHost' +if ($testHostVersion -ne $aspNetCoreVersion) { + throw "Microsoft.AspNetCore.TestHost ($testHostVersion) and Microsoft.AspNetCore.Components.WebAssembly ($aspNetCoreVersion) must share a version in Directory.Packages.props." +} + +$globalJson = Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json +$sdk = $globalJson.sdk +$buildSdkVersion = $globalJson.'msbuild-sdks'.'Purview.BuildSdk' +if (-not $buildSdkVersion) { throw 'Could not read the Purview.BuildSdk pin from global.json msbuild-sdks.' } + +# The shipping packages are exactly the ids pack validation knows about. Each maps to a project under src/src by its +# id without "Purview.Telemetry." - Purview.Telemetry.SourceGenerator to src/src/SourceGenerator. +$packValidation = (Get-Content (Join-Path $repositoryRoot '.config/purview-build.json') -Raw | ConvertFrom-Json).PackValidation +$shipping = foreach ($id in $packValidation.RequiredContent.PSObject.Properties.Name) { + $folder = $id -ireplace '^purview\.telemetry\.', '' + $directory = Get-ChildItem (Join-Path $repositoryRoot 'src/src') -Directory | Where-Object { $_.Name -ieq $folder } + $project = $directory ? (Join-Path $directory.FullName "$($directory.Name).csproj") : $null + if (-not $project -or -not (Test-Path $project)) { + throw "No project for the shipping package '$id' (expected src/src/$folder/$folder.csproj)." + } + + [pscustomobject]@{ Id = $id; Project = $project } +} + +# The refactorings assembly is packed inside the generator package by an MSBuild task rather than a ProjectReference +# build, so it must be restored explicitly (NETSDK1004 otherwise). +$packInputs = @( + $shipping.Project + (Join-Path $repositoryRoot 'src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj') +) + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "tsg-packed-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$logs = Join-Path $workspace 'logs' +$consumers = Join-Path $workspace 'consumers' +$output = Join-Path $workspace 'out' +$exe = $IsWindows ? '.exe' : '' + +$timings = [System.Collections.Generic.List[object]]::new() +$scanned = [System.Collections.Generic.List[string]]::new() +$previousNuGetPackages = $env:NUGET_PACKAGES +$repositoryRestoreTouched = $false + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +function Invoke-Step([string] $name, [scriptblock] $body) { + if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $status = 'passed' + try { + & $body + } + catch { + $status = 'FAILED' + throw + } + finally { + $stopwatch.Stop() + $timings.Add([pscustomobject]@{ Step = $name; Result = $status; Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1) }) + if ($env:GITHUB_ACTIONS) { Write-Host '::endgroup::' } + } +} + +function Invoke-Skipped([string] $name, [string] $reason) { + Write-Host "`n=== $name - SKIPPED: $reason" -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::$name skipped: $reason" } + $timings.Add([pscustomobject]@{ Step = $name; Result = 'skipped'; Seconds = 0 }) +} + +# Runs dotnet, tees its output to a log, and fails on a non-zero exit. -warnaserror already fails the publish on any +# trim or AOT warning; the log check is a second gate in case one is ever logged without failing the command. +function Invoke-Dotnet([string] $logName, [string[]] $arguments) { + $log = Join-Path $logs "$logName.log" + Write-Host "dotnet $($arguments -join ' ')" -ForegroundColor DarkGray + & dotnet @arguments 2>&1 | Tee-Object -FilePath $log | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet $($arguments[0]) failed with exit code $LASTEXITCODE (log: $log)." } + + $warnings = Select-String -Path $log -Pattern 'warning IL[23][0-9]{3}' | Select-Object -First 5 + if ($warnings) { + throw "dotnet $($arguments[0]) reported trim or AOT warnings:`n$($warnings.Line -join "`n")" + } +} + +function Assert-NoLeaks([string[]] $directories) { + $global:LASTEXITCODE = 0 + & $leakScan -Path $directories + if ($LASTEXITCODE -ne 0) { throw "The compiler-host leak scan failed for: $($directories -join ', ')." } + $scanned.AddRange($directories) +} + +function Assert-Contains([string] $directory, [string] $pattern, [string] $what) { + $found = @(Get-ChildItem $directory -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern }) + if ($found.Count -eq 0) { throw "$what is missing from '$directory' (no file matches '$pattern')." } + Write-Host " found $what ($($found[0].Name))" +} + +# The generated telemetry, per assembly: the implementation type the generator writes for each interface, and the +# members a consumer reaches through that interface. The constructor and the meter initialisation are reached through +# dependency injection. +$generatedMembers = [ordered]@{ + PackedTelemetryWasm = [ordered]@{ + 'PackedTelemetry.OrderTelemetryCore' = @( + '.ctor', 'InitializeMeters', 'PlacingOrder', 'OrderValidated', 'OrderTotal', 'OrdersInFlight', 'OrderShipped', + 'OrderFailed', '_activitySource', '_logger', '_meter' + ) + } + 'PackedTelemetry.Library' = [ordered]@{ + 'PackedTelemetry.Library.InventoryTelemetryCore' = @('.ctor', 'ReservingStock', 'StockReserved', '_activitySource', '_meter') + } +} + +function Open-Metadata([string] $path) { + $stream = [System.IO.File]::OpenRead($path) + $reader = [System.Reflection.PortableExecutable.PEReader]::new($stream) + return [pscustomobject]@{ + Stream = $stream + Reader = $reader + Metadata = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($reader) + } +} + +function Close-Metadata($opened) { + $opened.Reader.Dispose() + $opened.Stream.Dispose() +} + +# Type full name -> member names (methods and fields), and method definition token -> "Type::Method". +function Read-Members([string] $path) { + $opened = Open-Metadata $path + try { + $metadata = $opened.Metadata + $types = @{} + $methods = @{} + foreach ($handle in $metadata.TypeDefinitions) { + $type = $metadata.GetTypeDefinition($handle) + $namespace = $metadata.GetString($type.Namespace) + $name = ($namespace ? "$namespace." : '') + $metadata.GetString($type.Name) + $members = [System.Collections.Generic.HashSet[string]]::new() + foreach ($method in $type.GetMethods()) { + $methodName = $metadata.GetString($metadata.GetMethodDefinition($method).Name) + [void] $members.Add($methodName) + $token = 0x06000000 -bor [System.Reflection.Metadata.Ecma335.MetadataTokens]::GetRowNumber( + [System.Reflection.Metadata.MethodDefinitionHandle] $method + ) + $methods[$token] = "${name}::$methodName" + } + foreach ($field in $type.GetFields()) { [void] $members.Add($metadata.GetString($metadata.GetFieldDefinition($field).Name)) } + $types[$name] = $members + } + + return [pscustomobject]@{ Types = $types; Methods = $methods } + } + finally { + Close-Metadata $opened + } +} + +# ILLink writes the assemblies it trimmed to obj///linked. A generated member missing there was +# trimmed away (or never generated), and the app would fail where it calls it. +function Assert-GeneratedMembersSurviveTrimming([string] $projectDirectory) { + $linked = Get-ChildItem (Join-Path $projectDirectory 'obj') -Recurse -Directory -Filter 'linked' | Select-Object -First 1 + if (-not $linked) { throw "ILLink wrote no linked assemblies under '$projectDirectory/obj': the publish did not trim." } + + foreach ($assembly in $generatedMembers.Keys) { + $path = Join-Path $linked.FullName "$assembly.dll" + if (-not (Test-Path $path)) { throw "ILLink did not write $assembly.dll to '$($linked.FullName)'." } + + $shape = Read-Members $path + foreach ($type in $generatedMembers[$assembly].Keys) { + if (-not $shape.Types.ContainsKey($type)) { throw "The trimmed $assembly.dll does not define the generated $type." } + $missing = @($generatedMembers[$assembly][$type] | Where-Object { -not $shape.Types[$type].Contains($_) }) + if ($missing.Count -gt 0) { + throw "Trimming removed generated members of $type from ${assembly}.dll: $($missing -join ', ')." + } + Write-Host " trimmed $assembly.dll keeps $type ($($generatedMembers[$assembly][$type].Count) members)" + } + } +} + +# The Mono AOT compiler records, per assembly, the method tokens it compiled to WebAssembly. Every generated telemetry +# method must be among them: a method that is not was left to the interpreter, or does not exist. +function Assert-GeneratedMethodsAotCompiled([string] $projectDirectory) { + foreach ($assembly in $generatedMembers.Keys) { + $tokensFile = Get-ChildItem (Join-Path $projectDirectory 'obj') -Recurse -File ` + -Filter "$($assembly.Replace('.', '_'))_dll_compiled_methods.txt" | Select-Object -First 1 + if (-not $tokensFile) { throw "The WebAssembly AOT compiler recorded no compiled methods for $assembly.dll." } + + $lines = @(Get-Content $tokensFile.FullName) + $aotInput = $lines[0].Trim() + if (-not (Test-Path $aotInput)) { throw "The AOT compiler's input assembly '$aotInput' does not exist." } + $shape = Read-Members $aotInput + $compiled = [System.Collections.Generic.HashSet[string]]::new() + foreach ($line in $lines | Select-Object -Skip 2) { + if (-not $line.Trim()) { continue } + $token = [System.Convert]::ToInt32($line.Trim(), 16) + if ($shape.Methods.ContainsKey($token)) { [void] $compiled.Add($shape.Methods[$token]) } + } + + foreach ($type in $generatedMembers[$assembly].Keys) { + $methods = @($generatedMembers[$assembly][$type] | Where-Object { -not $_.StartsWith('_') }) + $missing = @($methods | Where-Object { -not $compiled.Contains("${type}::$_") }) + if ($missing.Count -gt 0) { + throw "The WebAssembly AOT compiler did not compile these generated $type methods: $($missing -join ', ')." + } + Write-Host " AOT-compiled every generated $type method ($($methods.Count)) in $assembly.dll" + } + } +} + +function New-NuGetConfig([string] $path, [hashtable[]] $sources) { + $sourceXml = ($sources | ForEach-Object { "`t`t" }) -join "`n" + $mappingXml = ($sources | ForEach-Object { + $patterns = ($_.Patterns | ForEach-Object { "`t`t`t" }) -join "`n" + "`t`t`n$patterns`n`t`t" + }) -join "`n" + + @" + + + + + +$sourceXml + + + + + +$mappingXml + + +"@ | Set-Content $path -Encoding utf8 +} + +$exitCode = 0 +$totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +try { + New-Item -ItemType Directory -Path $feed, $packages, $logs, $consumers, $output -Force | Out-Null + + Write-Host "Packed-consumer gate: Purview.Telemetry.SourceGenerator $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Workspace: $workspace" + + # Guards at the workspace root: MSBuild stops at the first Directory.Build.props/.targets and + # Directory.Packages.props it finds walking up, so nothing above the workspace - a stray file in the temp folder, + # a user profile - can change how the consumers build. The consumers must see the package and nothing else. + foreach ($guard in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content (Join-Path $workspace $guard) -Encoding utf8 + } + + @' + + + + false + + +'@ | Set-Content (Join-Path $workspace 'Directory.Packages.props') -Encoding utf8 + + # The repository's SDK, without its MSBuild SDKs (Purview.BuildSdk): the consumers are plain projects. + [ordered]@{ sdk = [ordered]@{ version = $sdk.version; rollForward = $sdk.rollForward; allowPrerelease = $true } } | + ConvertTo-Json | Set-Content (Join-Path $workspace 'global.json') -Encoding utf8 + + # Pack-time sources: nuget.org for everything, plus the optional feed for the prerelease build dependencies only. + $prereleasePatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*', 'Purview.BuildSdk') + $packSources = @(@{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') + $prereleasePatterns }) + if ($PrereleaseFeed) { + $packSources += @{ Key = 'prerelease'; Value = $PrereleaseFeed; Patterns = $prereleasePatterns } + } + $packConfig = Join-Path $workspace 'pack.nuget.config' + New-NuGetConfig $packConfig $packSources + + # Consumer sources: Purview.Telemetry.SourceGenerator from the package just packed and nowhere else - not nuget.org, + # not a local feed holding an older build of the same version. + $consumerConfig = Join-Path $consumers 'nuget.config' + New-NuGetConfig $consumerConfig @( + @{ Key = 'packed'; Value = $feed; Patterns = @('Purview.Telemetry.SourceGenerator') } + @{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') } + ) + + # An isolated extraction directory: NuGet reuses an already-extracted package of the same id and version, so a + # stale copy from an earlier run - or an earlier SourceGeneratorFramework build - would be used instead. + $env:NUGET_PACKAGES = $packages + + Invoke-Step "Preflight: restore Purview.SourceGeneratorFramework $sgfVersion and Purview.BuildSdk $buildSdkVersion" { + $probe = Join-Path $workspace 'probe' + New-Item -ItemType Directory -Path $probe -Force | Out-Null + @" + + + net10.0 + + + + + + +"@ | Set-Content (Join-Path $probe 'probe.csproj') -Encoding utf8 + + & dotnet restore (Join-Path $probe 'probe.csproj') "-p:RestoreConfigFile=$packConfig" 2>&1 | + Tee-Object -FilePath (Join-Path $logs 'probe.log') | Out-Host + if ($LASTEXITCODE -ne 0) { + $from = ($packSources | ForEach-Object Value) -join ', ' + throw ( + "Purview.SourceGeneratorFramework $sgfVersion (Directory.Packages.props) or Purview.BuildSdk $buildSdkVersion " + + "(global.json) could not be restored from $from. The generator is built with both, so nothing can be " + + 'packed. If a version is not on nuget.org yet, publish it there, or point this gate at a feed that has ' + + 'it: -PrereleaseFeed , or set TELEMETRY_PRERELEASE_FEED (LOCAL_NUGET_FEED_PATH is used ' + + 'when set). Purview.BuildSdk is resolved by MSBuild through the normal NuGet configuration, so that feed ' + + 'must also be a configured source for the pack itself.' + ) + } + } + + if ($PreflightOnly) { + Write-Host 'The prerelease build dependencies restore; stopping after the preflight (-PreflightOnly).' -ForegroundColor Green + return + } + + if ($IsLinux -and -not (Get-Command clang -ErrorAction SilentlyContinue)) { + throw 'Native AOT on Linux needs clang and the zlib headers: sudo apt-get install -y clang zlib1g-dev.' + } + + Invoke-Step 'Pack the shipping package to the isolated feed' { + $script:repositoryRestoreTouched = $true + foreach ($project in $packInputs) { + Invoke-Dotnet "restore-$([System.IO.Path]::GetFileNameWithoutExtension($project))" @( + 'restore', $project, "-p:RestoreConfigFile=$packConfig", '-nr:false' + ) + } + + foreach ($package in $shipping) { + Invoke-Dotnet "pack-$($package.Id)" @( + 'pack', $package.Project, '--configuration', $Configuration, '--output', $feed, '--no-restore', + '-m:2', '-nr:false' + ) + } + + $expected = @($shipping.Id | ForEach-Object { "$_.$version.nupkg".ToLowerInvariant() } | Sort-Object) + $actual = @(Get-ChildItem $feed -Filter '*.nupkg' | ForEach-Object { $_.Name.ToLowerInvariant() } | Sort-Object) + $difference = Compare-Object $expected $actual + if ($difference) { + throw "The feed does not hold exactly the shipping packages:`n$($difference | Out-String)" + } + + # The bytes the consumers restore: a compiler-host assembly anywhere but analyzers/, or a generator assembly + # under lib/, ref/ or runtimes/, would reach every application that references the package. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $hostOnly = '(^|/)(Purview\.SourceGeneratorFramework|Microsoft\.CodeAnalysis)[^/]*$' + $generatorOnly = '^(lib|ref|runtimes)/.*Purview\.Telemetry\.SourceGenerator[^/]*$' + foreach ($nupkg in Get-ChildItem $feed -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $entries = @($archive.Entries.FullName) + } + finally { $archive.Dispose() } + + $bad = @($entries | Where-Object { ($_ -match $hostOnly -and $_ -notmatch '^analyzers/') -or $_ -match $generatorOnly }) + if ($bad.Count -gt 0) { throw "$($nupkg.Name) ships compiler-host content outside analyzers/: $($bad -join ', ')." } + $looseFramework = @($entries | Where-Object { $_ -match '^analyzers/.*Purview\.SourceGeneratorFramework[^/]*$' }) + if ($looseFramework.Count -gt 0) { + throw "$($nupkg.Name) ships a loose framework assembly under analyzers/ (the IL merge did not run): $($looseFramework -join ', ')." + } + Write-Host " $($nupkg.Name): no compiler-host content outside analyzers/, and the generator is IL-merged" + } + + if ($PackOutput) { + New-Item -ItemType Directory -Path $PackOutput -Force | Out-Null + Copy-Item (Join-Path $feed '*') $PackOutput -Force + Write-Host " copied the package to $PackOutput" + } + } + + Copy-Item (Join-Path $consumerTemplates '*') $consumers -Recurse -Force + $consumerProperties = @( + "-p:TelemetryPackageVersion=$version" + "-p:MicrosoftExtensionsVersion=$extensionsVersion" + "-p:MicrosoftExtensionsTelemetryVersion=$extensionsTelemetryVersion" + "-p:AspNetCorePackageVersion=$aspNetCoreVersion" + "-p:RestoreConfigFile=$consumerConfig" + '-nr:false' + ) + $libraryBin = Join-Path $consumers 'Library/bin' + + Invoke-Step '(a) Native AOT console: publish and run' { + $project = Join-Path $consumers 'AotConsole/AotConsole.csproj' + $publish = Join-Path $output 'aot-console' + + # A framework-dependent build first: its bin holds the deps.json and runtimeconfig.json that list every runtime + # dependency, which is where an analyzer-only assembly that leaks shows up first. + Invoke-Dotnet 'aot-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror', '-m:2') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'AotConsole/bin/Release/net11.0') '^PackedTelemetryAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedTelemetryAot$exe") | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Native AOT console exited with ${LASTEXITCODE}: the generated telemetry is wrong." } + + Assert-NoLeaks @((Join-Path $consumers 'AotConsole/bin'), $libraryBin, $publish) + } + + function Assert-WasmOutput([string] $publish) { + $framework = Join-Path $publish 'wwwroot/_framework' + Assert-Contains $framework '^PackedTelemetryWasm\.[a-z0-9]+\.wasm$' 'the app assembly' + Assert-Contains $framework '^PackedTelemetry\.Library\.[a-z0-9]+\.wasm$' 'the library assembly' + } + + Invoke-Step '(b) Blazor WebAssembly: trimmed publish' { + $projectDirectory = Join-Path $consumers 'BlazorWasm' + $publish = Join-Path $output 'wasm-trimmed' + Invoke-Dotnet 'wasm-trimmed-publish' (@( + 'publish', (Join-Path $projectDirectory 'BlazorWasm.csproj'), '--configuration', 'Release', '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-GeneratedMembersSurviveTrimming $projectDirectory + Assert-NoLeaks @((Join-Path $projectDirectory 'bin'), $libraryBin, $publish) + } + + $wasmTools = $false + if ($WasmAot -ne 'Skip') { + Push-Location $workspace + try { $wasmTools = [bool] (& dotnet workload list 2>$null | Select-String -Pattern '^\s*wasm-tools\s') } + finally { Pop-Location } + } + + if ($WasmAot -eq 'Skip') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' 'disabled with -WasmAot Skip.' + } + elseif (-not $wasmTools -and $WasmAot -eq 'Auto') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' ( + 'the wasm-tools workload is not installed, so the WebAssembly AOT compile was NOT checked. ' + + 'Install it with `dotnet workload install wasm-tools` and rerun, or pass -WasmAot Require to make this an error.' + ) + } + else { + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish' { + if (-not $wasmTools) { + throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' + } + + # A copy of the project, so the AOT build starts from clean obj/bin rather than the trimmed build's. + $projectDirectory = Join-Path $consumers 'BlazorWasmAot' + Copy-Item (Join-Path $consumerTemplates 'BlazorWasm') $projectDirectory -Recurse -Force + $publish = Join-Path $output 'wasm-aot' + Invoke-Dotnet 'wasm-aot-publish' (@( + 'publish', (Join-Path $projectDirectory 'BlazorWasm.csproj'), '--configuration', 'Release', + '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-Contains (Join-Path $publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-GeneratedMembersSurviveTrimming $projectDirectory + Assert-GeneratedMethodsAotCompiled $projectDirectory + Assert-NoLeaks @((Join-Path $projectDirectory 'bin'), $libraryBin, $publish) + } + } + + Invoke-Step '(c) Blazor Server: publish, render the page on a TestServer and check its telemetry' { + $project = Join-Path $consumers 'BlazorServer/BlazorServer.csproj' + $publish = Join-Path $output 'blazor-server' + Invoke-Dotnet 'blazor-server-publish' (@( + 'publish', $project, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + Push-Location $publish + try { + & dotnet (Join-Path $publish 'PackedTelemetryServer.dll') --self-test | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Blazor Server self-test exited with ${LASTEXITCODE}: the page or its telemetry is wrong." } + } + finally { + Pop-Location + } + + Assert-NoLeaks @((Join-Path $consumers 'BlazorServer/bin'), $libraryBin, $publish) + } +} +catch { + $exitCode = 1 + Write-Failure "Packed-consumer gate failed: $($_.Exception.Message)" + if (Test-Path $logs) { Write-Host "Logs: $logs" } +} +finally { + if ($null -eq $previousNuGetPackages) { Remove-Item Env:\NUGET_PACKAGES -ErrorAction SilentlyContinue } + else { $env:NUGET_PACKAGES = $previousNuGetPackages } + + # Packing restored the shipping projects against the isolated cache that is about to be deleted; restore them + # against the normal cache, so a later --no-restore build does not point at a missing folder. + if ($repositoryRestoreTouched) { + Write-Host "`nRestoring the shipping projects against the normal package cache..." -ForegroundColor Cyan + foreach ($project in $packInputs) { + & dotnet restore $project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $project against the normal cache failed; run dotnet restore." } + } + } + + $totalStopwatch.Stop() + Write-Host "`nPacked-consumer gate timings:" -ForegroundColor Cyan + $timings | Format-Table -AutoSize | Out-Host + Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) + if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { exit $exitCode } + +Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green From 15843098a2022363f3ed197810eea53578995e60 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:14:38 +0000 Subject: [PATCH 04/25] build(pack): forbid compiler-host content in the package, and prove each rule PackValidation ForbiddenContent now keeps Purview.SourceGeneratorFramework* and Microsoft.CodeAnalysis* out of analyzers/ (the generator ships IL-merged) and out of every folder NuGet hands to an application, and the generator and refactorings out of lib/, ref/ and runtimes/. scripts/test-pack-validation-rules.ps1 (just verify-pack-rules) injects a matching file per rule into scratch copies of the package and shows the real purview-build validation rejects each one. --- .config/purview-build.json | 45 +++++++ Justfile | 9 ++ scripts/test-pack-validation-rules.ps1 | 179 +++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 scripts/test-pack-validation-rules.ps1 diff --git a/.config/purview-build.json b/.config/purview-build.json index ed782b74..024bb3fb 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -18,6 +18,51 @@ "README.md", "purview-logo-light.png" ] + }, + // Every package ("*"). RequireExplicitContent (on by default) already rejects anything RequiredContent does not + // declare; these rules hold even if a RequiredContent glob is ever widened. Globs are + // Microsoft.Extensions.FileSystemGlobbing patterns: "*" stays within one path segment, "**" spans any number, + // including none. scripts/test-pack-validation-rules.ps1 injects a file for every rule into a scratch copy of + // each package and proves the validation fails; ReleaseGateTests proves no rule is removed. + "ForbiddenContent": { + "*": [ + // The generator and the refactorings ship IL-merged, with Purview.SourceGeneratorFramework internalized + // into them; a loose copy under analyzers/ means the merge did not run. + "analyzers/**/Purview.SourceGeneratorFramework*", + // The compiler supplies Roslyn to analyzers; a packed copy would shadow or conflict with it. + "analyzers/**/Microsoft.CodeAnalysis*", + // Compiler-host assemblies never appear outside analyzers/: anywhere else NuGet hands them to the + // application, which is how Purview.SourceGeneratorFramework.dll reaches app and Blazor WebAssembly + // output and breaks WebAssembly AOT compilation. + "Purview.SourceGeneratorFramework*", + "Microsoft.CodeAnalysis*", + "lib/**/Purview.SourceGeneratorFramework*", + "lib/**/Microsoft.CodeAnalysis*", + "ref/**/Purview.SourceGeneratorFramework*", + "ref/**/Microsoft.CodeAnalysis*", + "runtimes/**/Purview.SourceGeneratorFramework*", + "runtimes/**/Microsoft.CodeAnalysis*", + "build/**/Purview.SourceGeneratorFramework*", + "build/**/Microsoft.CodeAnalysis*", + "buildTransitive/**/Purview.SourceGeneratorFramework*", + "buildTransitive/**/Microsoft.CodeAnalysis*", + "buildMultiTargeting/**/Purview.SourceGeneratorFramework*", + "buildMultiTargeting/**/Microsoft.CodeAnalysis*", + "tools/**/Purview.SourceGeneratorFramework*", + "tools/**/Microsoft.CodeAnalysis*", + "content/**/Purview.SourceGeneratorFramework*", + "content/**/Microsoft.CodeAnalysis*", + "contentFiles/**/Purview.SourceGeneratorFramework*", + "contentFiles/**/Microsoft.CodeAnalysis*", + "staticwebassets/**/Purview.SourceGeneratorFramework*", + "staticwebassets/**/Microsoft.CodeAnalysis*", + // The generator and the refactorings (Purview.Telemetry.SourceGenerator.Refactorings) run only inside the + // compiler, so they ship under analyzers/ alone: under lib/, ref/ or runtimes/ every application would + // load them. + "lib/**/Purview.Telemetry.SourceGenerator*", + "ref/**/Purview.Telemetry.SourceGenerator*", + "runtimes/**/Purview.Telemetry.SourceGenerator*" + ] } }, "Release": { diff --git a/Justfile b/Justfile index 8cdfbd02..4ff1cc3b 100644 --- a/Justfile +++ b/Justfile @@ -67,6 +67,15 @@ smoke-packed-consumers *args: echo "Running the packed consumer gate..." pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching +# file into scratch copies of the packed package and runs the real purview-build validation on them. Reads artifacts/, +# so pack first (`just pipeline-pack-validate`). `just pipeline-local-release` moves its packages to the local feed, so +# pass -PackagesFolder p:/_sync-projects/.local-nuget after it. +[group('Pipeline')] +verify-pack-rules *args: + echo "Proving the pack-validation forbidden-content rules..." + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} + # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 00000000..fd161fdf --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,179 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every PackValidation ForbiddenContent rule in .config/purview-build.json actually fails a package. + +.DESCRIPTION + A forbidden-content glob that matches nothing - a typo, a wrong folder, a "*" where "**" was meant - passes + silently forever. This script proves each rule bites, with the real tool (purview-build, the version + .config/dotnet-tools.json pins) and the real rules. Each run validates copies of the packed packages in a scratch + repository that holds the repository's purview-build.json verbatim and an empty solution, so the pipeline's build is + a no-op and only pack validation does any work. RequireExplicitContent is turned off in every run, so the + explicit-content rule cannot be what fails one: only ForbiddenContent can. + + 1. Control: every package, untouched. Must pass, or the harness itself is broken. + 2. Per package: each package on its own, with one file per rule injected - a concrete path the rule's glob should + match (lib/**/Microsoft.CodeAnalysis* becomes lib/probe/Microsoft.CodeAnalysis.dll). Must fail, reporting every + rule. This proves the rules apply to every package id. + 3. Per rule: Purview.Telemetry.SourceGenerator with only that rule's file injected. Must fail, reporting that rule. + This proves each rule is triggered by its own file rather than by another rule's. + + The packages in -PackagesFolder are never modified. + +.PARAMETER PackagesFolder + Folder holding the packed .nupkg (and any .snupkg) files of the current version. Defaults to artifacts/, where + `just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed, + so pass that folder after it. + +.PARAMETER KeepArtifacts + Leave the scratch repositories in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $PackagesFolder = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'), + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$configPath = Join-Path $repositoryRoot '.config/purview-build.json' +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$config = Get-Content $configPath -Raw | ConvertFrom-Json + +$rules = @($config.PackValidation.ForbiddenContent.PSObject.Properties['*'].Value) +if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPath." } + +$packages = @(Get-ChildItem $PackagesFolder -Filter "*.$version.nupkg" -File | Sort-Object Name) +if ($packages.Count -eq 0) { + throw ( + "No $version packages in '$PackagesFolder'. Pack first (just pipeline-pack-validate), or pass -PackagesFolder: " + + 'just pipeline-local-release moves its packages to the local feed.' + ) +} + +# A concrete entry each glob must match: "**" spans one made-up folder, "*" becomes an assembly extension. +$cases = foreach ($rule in $rules) { + [pscustomobject]@{ Rule = $rule; Entry = ($rule -replace '\*\*', 'probe' -replace '\*', '.dll') } +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "tsg-pack-rules-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$runs = 0 + +# Validates $nupkgs (and their .snupkg) with $entries injected into each, and returns the rules the tool reported. +function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [string[]] $entries) { + $root = Join-Path $scratch $name + $artifacts = Join-Path $root 'artifacts' + New-Item -ItemType Directory -Path (Join-Path $root '.config'), $artifacts -Force | Out-Null + Copy-Item $configPath (Join-Path $root '.config/purview-build.json') + Copy-Item (Join-Path $repositoryRoot '.config/dotnet-tools.json') (Join-Path $root '.config/dotnet-tools.json') + Copy-Item (Join-Path $repositoryRoot 'global.json'), (Join-Path $repositoryRoot 'package.json') $root + '' | Set-Content (Join-Path $root 'empty.slnx') -Encoding utf8 + & git -C $root init --quiet + + foreach ($nupkg in $nupkgs) { + $copy = Join-Path $artifacts $nupkg.Name + Copy-Item $nupkg.FullName $copy + $symbols = [System.IO.Path]::ChangeExtension($nupkg.FullName, '.snupkg') + if (Test-Path $symbols) { Copy-Item $symbols $artifacts } + + if ($entries) { + $archive = [System.IO.Compression.ZipFile]::Open($copy, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $entries) { + $writer = [System.IO.StreamWriter]::new($archive.CreateEntry($entry).Open()) + try { $writer.Write('injected by scripts/test-pack-validation-rules.ps1') } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + + Push-Location $root + try { + & dotnet tool restore | Out-Null + $output = & dotnet purview-build ` + --Build:Solution=empty.slnx ` + --Build:ArtifactsFolder=artifacts ` + --Build:CleanArtifacts=false ` + --Build:RunPack=false ` + --Build:ValidatePack=true ` + --Build:RunTests=false ` + --Build:RunLint=false ` + --PackValidation:RequireExplicitContent=false ` + --Release:Mode=None 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + + $script:runs++ + # The console wraps long lines, so rejoin them before reading "Forbidden content '' must not be in ...". + $text = ($output -join "`n") -replace '\r?\n\s*', ' ' + $reported = @([regex]::Matches($text, "Forbidden content '(?[^']+)'") | ForEach-Object { $_.Groups['rule'].Value } | Sort-Object -Unique) + return [pscustomobject]@{ ExitCode = $exitCode; Reported = $reported; Output = ($output -join "`n") } +} + +$failures = [System.Collections.Generic.List[string]]::new() +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +try { + Write-Host "Control: $($packages.Count) untouched package(s)..." -ForegroundColor Cyan + $control = Invoke-Validation 'control' $packages @() + if ($control.ExitCode -ne 0) { + Write-Host $control.Output + throw 'The control run failed on untouched packages, so the harness cannot prove anything. Fix that first.' + } + Write-Host ' passed' + + Write-Host "Per package: each with all $($cases.Count) rule file(s) injected..." -ForegroundColor Cyan + foreach ($package in $packages) { + $result = Invoke-Validation "package-$($package.BaseName)" @($package) @($cases.Entry) + $missed = @($cases.Rule | Where-Object { $result.Reported -notcontains $_ }) + if ($result.ExitCode -eq 0 -or $missed.Count -gt 0) { + $failures.Add("$($package.Name): exit $($result.ExitCode); rules not reported: $($missed -join ', ')") + Write-Host " NOT REJECTED $($package.Name) ($($missed.Count) rule(s) missed)" -ForegroundColor Red + } + else { + Write-Host " rejected $($package.Name) ($($result.Reported.Count) of $($cases.Count) rules)" + } + } + + $core = $packages | Where-Object { $_.Name -ieq "Purview.Telemetry.SourceGenerator.$version.nupkg" } + if (-not $core) { throw "Purview.Telemetry.SourceGenerator.$version.nupkg is not in '$PackagesFolder'." } + + Write-Host "Per rule: $($core.Name) with one rule's file injected..." -ForegroundColor Cyan + $index = 0 + foreach ($case in $cases) { + $index++ + $result = Invoke-Validation "rule-$index" @($core) @($case.Entry) + $others = @($result.Reported | Where-Object { $_ -ne $case.Rule }) + if ($result.ExitCode -eq 0 -or $result.Reported -notcontains $case.Rule) { + $failures.Add("$($case.Rule): injecting '$($case.Entry)' was not rejected by it (exit $($result.ExitCode)).") + Write-Host (" NOT REJECTED {0,-55} {1}" -f $case.Entry, $case.Rule) -ForegroundColor Red + } + else { + $also = $others.Count -gt 0 ? " (also matched: $($others -join ', '))" : '' + Write-Host (" rejected {0,-55} {1}{2}" -f $case.Entry, $case.Rule, $also) + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($KeepArtifacts) { Write-Host "Scratch repositories kept at $scratch" -ForegroundColor Yellow } + else { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host ("{0} validation run(s) in {1:N1}s." -f $runs, $stopwatch.Elapsed.TotalSeconds) +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" -ForegroundColor Red } + } + exit 1 +} + +Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green From 50fd47a4c3eba843a0252bcf39603134b0d82a46 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:14:38 +0000 Subject: [PATCH 05/25] test(repository): guard the release gates with ReleaseGateTests The leak scan must catch every kind of leak it claims to, and fail on nothing to scan. Every workflow job that builds the samples or publishes must run it, the packed-consumers job must run the gate and the rule proof, the Justfile recipes must run their scripts, the consumers must reference the generator as a package, and no ForbiddenContent rule may be removed. --- Directory.Packages.props | 1 + .../Repository.UnitTests/ReleaseGateTests.cs | 544 ++++++++++++++++++ .../Repository.UnitTests.csproj | 6 +- 3 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 src/tests/Repository.UnitTests/ReleaseGateTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8ab13d43..23095707 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,7 @@ + diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs new file mode 100644 index 00000000..a4986398 --- /dev/null +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -0,0 +1,544 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using CliWrap; +using CliWrap.Buffered; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the release gates themselves: the compiler-host leak scan must catch every kind of leak it claims to, every +/// CI job that builds or publishes an application must run it, the packed-consumer gate must stay wired into CI and the +/// Justfile, its consumers must reference the package rather than the repository, and pack validation must keep +/// forbidding compiler-host content outside analyzers/. +/// +/// +/// +/// A gate that passes vacuously is worse than none, because it is trusted. The leak scan +/// (scripts/assert-no-compiler-host-assemblies.ps1) exists because Purview.SourceGeneratorFramework 1.2.1 put +/// Purview.SourceGeneratorFramework.dll into the build output of every project that referenced the generator as +/// an analyzer - all eight sample bins here - while every build stayed green. These tests run the scan against crafted +/// output, so a regex or file-pattern change that stops it seeing a leak fails here rather than in a consumer's Blazor +/// WebAssembly AOT build. +/// +/// +/// The scan is a PowerShell script, as the CI jobs run it, so these tests run it through pwsh as a child +/// process. They do not skip when pwsh is missing: the gate cannot run without it either. +/// +/// +public sealed partial class ReleaseGateTests +{ + const string LeakScanScript = "scripts/assert-no-compiler-host-assemblies.ps1"; + const string PackedConsumersScript = "scripts/test-packed-consumers.ps1"; + const string PackRulesScript = "scripts/test-pack-validation-rules.ps1"; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + /// A file written into otherwise clean build output. + public sealed record OutputFile(string Description, string RelativePath, string Content, bool Brotli = false) + { + public override string ToString() => Description; + } + + public static IEnumerable> Leaks() => + [ + () => new("the generator framework assembly", "Purview.SourceGeneratorFramework.dll", "MZ"), + () => new("a Roslyn assembly in a subfolder", "refs/Microsoft.CodeAnalysis.CSharp.dll", "MZ"), + () => new("the generator assembly", "Purview.Telemetry.SourceGenerator.dll", "MZ"), + () => + new( + "a fingerprinted WebAssembly refactorings assembly", + "wwwroot/_framework/Purview.Telemetry.SourceGenerator.Refactorings.ab12cd34ef.wasm", + "wasm" + ), + () => + new( + "a compressed WebAssembly generator", + "wwwroot/_framework/Purview.Telemetry.SourceGenerator.ab12cd34ef.wasm.br", + "br" + ), + () => + new( + "a deps.json library entry", + "PackedApp.deps.json", + """{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" + ), + () => + new( + "a runtimeconfig.json reference", + "PackedApp.runtimeconfig.json", + """{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" + ), + () => + new( + "a blazor.boot.json asset", + "wwwroot/_framework/blazor.boot.json", + """{"resources":{"assembly":{"Purview.Telemetry.SourceGenerator.wasm":"sha256-x"}}}""" + ), + () => + new( + "a fingerprinted dotnet.boot.js asset", + "wwwroot/_framework/dotnet.boot.ab12cd34ef.js", + """export const config = {"assembly":[{"name":"Purview.Telemetry.SourceGenerator.Refactorings.wasm"}]};""" + ), + () => + new( + "a Brotli-compressed dotnet.js boot manifest", + "wwwroot/_framework/dotnet.js.br", + """const config = {"assembly":[{"name":"Purview.SourceGeneratorFramework.wasm"}]};""", + Brotli: true + ), + ]; + + [Test] + [MethodDataSource(nameof(Leaks))] + public async Task LeakScan_GivenACompilerHostLeak_FailsAndNamesTheFile( + OutputFile leak, + CancellationToken cancellationToken + ) + { + // Arrange + using var output = CleanOutput(); + Write(output.Path, leak); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the scan missed {leak}:{Environment.NewLine}{result}"); + await Assert + .That(result.StandardOutput) + .Contains(Path.GetFileName(leak.RelativePath)) + .Because("a failure must name the leaking file"); + } + + [Test] + public async Task LeakScan_GivenCleanBuildAndPublishOutput_Passes(CancellationToken cancellationToken) + { + // Arrange + using var output = CleanOutput(); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(0) + .Because($"clean output was rejected:{Environment.NewLine}{result}"); + await Assert.That(result.StandardOutput).Contains("3 manifest(s)"); + } + + [Test] + public async Task LeakScan_GivenSeveralPaths_ScansEveryOne(CancellationToken cancellationToken) + { + // Arrange + using var clean = CleanOutput(); + using var leaking = CleanOutput(); + Write(leaking.Path, new("a leak in the second path", "Microsoft.CodeAnalysis.dll", "MZ")); + + // Act - space-separated, exactly as the Justfile recipe passes them through pwsh -File. + var result = await RunLeakScanAsync([clean.Path, leaking.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the second path was not scanned:{Environment.NewLine}{result}"); + } + + /// + /// A Blazor server publish can carry Microsoft's WebAssembly debugging proxy, whose expression evaluator ships + /// Roslyn. -Allow exempts that folder alone: a leak anywhere else in the same output must still fail. + /// + [Test] + public async Task LeakScan_GivenAnAllowedToolFolder_SkipsItButStillFailsALeakElsewhere( + CancellationToken cancellationToken + ) + { + // Arrange + using var allowedOnly = CleanOutput(); + Write(allowedOnly.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + using var alsoLeaking = CleanOutput(); + Write(alsoLeaking.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + Write(alsoLeaking.Path, new("a real leak", "Purview.SourceGeneratorFramework.dll", "MZ")); + + // Act + var allowed = await RunLeakScanAsync([allowedOnly.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + var leaking = await RunLeakScanAsync([alsoLeaking.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + + // Assert + await Assert + .That(allowed.ExitCode) + .IsEqualTo(0) + .Because($"the allowed folder was not skipped:{Environment.NewLine}{allowed}"); + await Assert.That(allowed.StandardOutput).Contains("1 allowed by -Allow"); + await Assert + .That(leaking.ExitCode) + .IsEqualTo(1) + .Because($"-Allow hid a leak outside its folder:{Environment.NewLine}{leaking}"); + await Assert.That(leaking.StandardOutput).Contains("Purview.SourceGeneratorFramework.dll"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task LeakScan_GivenNothingToScan_FailsRatherThanPassingVacuously( + bool directoryExists, + CancellationToken cancellationToken + ) + { + // Arrange + using TemporaryDirectory output = new(); + var path = directoryExists ? output.Path : System.IO.Path.Combine(output.Path, "missing"); + + // Act + var result = await RunLeakScanAsync([path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"scanning nothing passed:{Environment.NewLine}{result}"); + } + + /// + /// Every CI job that publishes an application, or builds the samples, must scan what it wrote. A job added later + /// without the scan would be a gate that cannot see the leak this repository has already shipped once - and the + /// samples are exactly where it was. + /// + [Test] + public async Task Workflows_GivenAJobThatBuildsApplications_ScanItsOutputForCompilerHostAssemblies( + CancellationToken cancellationToken + ) + { + // Arrange + var workflows = Directory.GetFiles( + System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows"), + "*.yml" + ); + + // Act + List building = []; + List unscanned = []; + foreach (var workflow in workflows) + { + var text = await File.ReadAllTextAsync(workflow, cancellationToken); + foreach (var (job, body) in Jobs(text)) + { + if ( + !body.Contains("dotnet publish", StringComparison.Ordinal) + && !body.Contains("dotnet build samples/", StringComparison.Ordinal) + ) + continue; + + var name = $"{System.IO.Path.GetFileName(workflow)}:{job}"; + building.Add(name); + if (!body.Contains("./" + LeakScanScript, StringComparison.Ordinal)) + unscanned.Add(name); + } + } + + // Assert + await Assert + .That(building) + .IsNotEmpty() + .Because("no workflow job builds the samples or publishes, so the job parsing is broken"); + await Assert + .That(unscanned) + .IsEmpty() + .Because($"these jobs build applications without running {LeakScanScript}: {string.Join(", ", unscanned)}"); + } + + [Test] + public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateAndTheRuleProof( + CancellationToken cancellationToken + ) + { + // Arrange + var text = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + cancellationToken + ); + + // Act + var job = Jobs(text).SingleOrDefault(static j => j.Name == "packed-consumers").Body ?? string.Empty; + + // Assert + await Assert.That(job).Contains($"./{PackedConsumersScript} -PreflightOnly"); + await Assert.That(job).Contains("-WasmAot Require"); + await Assert.That(job).Contains($"./{PackRulesScript}"); + await Assert.That(job).Contains("dotnet workload install wasm-tools"); + } + + [Test] + [Arguments("smoke-packed-consumers", PackedConsumersScript)] + [Arguments("verify-pack-rules", PackRulesScript)] + [Arguments("scan-compiler-host-leaks", LeakScanScript)] + public async Task Justfile_GivenAReleaseGateRecipe_RunsItsScript( + string recipe, + string script, + CancellationToken cancellationToken + ) + { + // Arrange + var text = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, "Justfile"), + cancellationToken + ); + + // Act + var match = Regex.Match( + text, + $@"^{Regex.Escape(recipe)}\b[^\n]*:\r?\n(?(?:[ \t]+[^\n]*\r?\n?)+)", + RegexOptions.Multiline + ); + + // Assert + await Assert.That(match.Success).IsTrue().Because($"the Justfile has no '{recipe}' recipe"); + await Assert.That(match.Groups["body"].Value).Contains($"pwsh -NoProfile -File {script}"); + } + + /// + /// The packed-consumer gate proves what ships only while its consumers reference the package: a ProjectReference + /// to the repository's generator would hand them the unmerged generator and its bin folder instead. + /// + [Test] + public async Task PackedConsumers_GivenEveryProject_ReferenceTheGeneratorAsAPackageOnly( + CancellationToken cancellationToken + ) + { + // Arrange + var consumers = System.IO.Path.Combine(RepositoryRoot.Value, "scripts", "packed-consumers"); + var projects = Directory.GetFiles(consumers, "*.csproj", SearchOption.AllDirectories); + + // Act + List problems = []; + foreach (var project in projects) + { + var document = XDocument.Parse(await File.ReadAllTextAsync(project, cancellationToken)); + var name = System.IO.Path.GetRelativePath(consumers, project); + + var generator = document + .Descendants("PackageReference") + .SingleOrDefault(static r => (string?)r.Attribute("Include") == "Purview.Telemetry.SourceGenerator"); + if (generator is null) + problems.Add($"{name} has no Purview.Telemetry.SourceGenerator PackageReference"); + else if ( + (string?)generator.Attribute("PrivateAssets") != "all" + || (string?)generator.Attribute("IncludeAssets") != "analyzers" + ) + problems.Add( + $"{name} does not reference the generator as the README says (PrivateAssets/IncludeAssets)" + ); + + foreach (var reference in document.Descendants("ProjectReference")) + { + var include = (string?)reference.Attribute("Include") ?? string.Empty; + var resolved = System.IO.Path.GetFullPath( + System.IO.Path.Combine(System.IO.Path.GetDirectoryName(project)!, include) + ); + if (!resolved.StartsWith(consumers + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal)) + problems.Add($"{name} references {include}, outside scripts/packed-consumers"); + } + } + + // Assert + await Assert.That(projects).IsNotEmpty(); + await Assert.That(problems).IsEmpty().Because(string.Join("; ", problems)); + } + + /// + /// Compiler-host assemblies must stay forbidden everywhere NuGet hands files to an application, and the generator + /// and refactorings everywhere an application loads assemblies from. scripts/test-pack-validation-rules.ps1 + /// proves each rule rejects a package; this proves nobody removes one. + /// + [Test] + public async Task PackValidation_GivenEveryPackage_ForbidsCompilerHostContentOutsideAnalyzers( + CancellationToken cancellationToken + ) + { + // Arrange + var json = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), + cancellationToken + ); + using var document = JsonDocument.Parse( + json, + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true } + ); + string[] hostOnly = ["Purview.SourceGeneratorFramework*", "Microsoft.CodeAnalysis*"]; + string[] generatorOnly = ["Purview.Telemetry.SourceGenerator*"]; + string[] applicationFolders = + [ + "lib", + "ref", + "runtimes", + "build", + "buildTransitive", + "buildMultiTargeting", + "tools", + "content", + "contentFiles", + "staticwebassets", + ]; + string[] assemblyFolders = ["lib", "ref", "runtimes"]; + + // Act + var rules = document + .RootElement.GetProperty("PackValidation") + .GetProperty("ForbiddenContent") + .GetProperty("*") + .EnumerateArray() + .Select(static rule => rule.GetString()) + .ToHashSet(StringComparer.Ordinal); + + // Assert + string[] expected = + [ + .. hostOnly, + .. hostOnly.Select(static name => $"analyzers/**/{name}"), + .. applicationFolders.SelectMany(folder => hostOnly.Select(name => $"{folder}/**/{name}")), + .. assemblyFolders.SelectMany(folder => generatorOnly.Select(name => $"{folder}/**/{name}")), + ]; + var missing = expected.Where(rule => !rules.Contains(rule)).ToArray(); + await Assert + .That(missing) + .IsEmpty() + .Because($"ForbiddenContent[\"*\"] no longer forbids: {string.Join(", ", missing)}"); + } + + static TemporaryDirectory CleanOutput() + { + TemporaryDirectory output = new(); + Write(output.Path, new("the app", "PackedApp.dll", "MZ")); + Write( + output.Path, + new( + "clean deps.json", + "PackedApp.deps.json", + """{"libraries":{"Microsoft.Extensions.Logging/10.0.13":{"type":"package"},"Microsoft.Extensions.Telemetry.Abstractions/10.11.0":{"type":"package"}}}""" + ) + ); + Write(output.Path, new("clean runtimeconfig", "PackedApp.runtimeconfig.json", """{"runtimeOptions":{}}""")); + Write( + output.Path, + new( + "clean boot manifest", + "wwwroot/_framework/dotnet.js", + """const config = {"assembly":[{"name":"PackedApp.wasm"},{"name":"PackedTelemetry.Library.wasm"}]};""" + ) + ); + Write(output.Path, new("the runtime", "wwwroot/_framework/dotnet.native.ab12cd34ef.js", "// runtime")); + return output; + } + + static void Write(string root, OutputFile file) + { + var path = System.IO.Path.Combine(root, file.RelativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + var bytes = Encoding.UTF8.GetBytes(file.Content); + if (!file.Brotli) + { + File.WriteAllBytes(path, bytes); + return; + } + + using var stream = File.Create(path); + using BrotliStream brotli = new(stream, CompressionLevel.Optimal); + brotli.Write(bytes); + } + + static async Task RunLeakScanAsync( + string[] paths, + CancellationToken cancellationToken, + string[]? allow = null + ) + { + var result = await Cli.Wrap("pwsh") + .WithArguments([ + "-NoProfile", + "-NonInteractive", + "-File", + System.IO.Path.Combine(RepositoryRoot.Value, LeakScanScript), + "-Path", + .. paths, + .. AllowArguments(allow), + ]) + .WithEnvironmentVariables(static env => env.Set("GITHUB_ACTIONS", null)) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + return new(result.ExitCode, result.StandardOutput, result.StandardError); + } + + static string[] AllowArguments(string[]? allow) => allow is null ? [] : ["-Allow", .. allow]; + + sealed record ScanResult(int ExitCode, string StandardOutput, string StandardError) + { + public override string ToString() => $"exit {ExitCode}{Environment.NewLine}{StandardOutput}{StandardError}"; + } + + /// The jobs of a workflow, split on their two-space-indented keys under jobs:. + static IEnumerable<(string Name, string Body)> Jobs(string workflow) + { + var jobs = workflow.IndexOf("\njobs:", StringComparison.Ordinal); + if (jobs < 0) + yield break; + + var headers = JobHeader().Matches(workflow[jobs..]); + for (var i = 0; i < headers.Count; i++) + { + var start = headers[i].Index; + var end = i + 1 < headers.Count ? headers[i + 1].Index : workflow.Length - jobs; + yield return (headers[i].Groups["name"].Value, workflow.Substring(jobs + start, end - start)); + } + } + + [GeneratedRegex(@"^ (?[A-Za-z0-9_-]+):[ \t]*\r?$", RegexOptions.Multiline)] + private static partial Regex JobHeader(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(System.IO.Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(System.IO.Path.Combine(directory.FullName, LeakScanScript)) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries both AGENTS.md and {LeakScanScript}." + ); + } + + sealed class TemporaryDirectory : IDisposable + { + public string Path { get; } = + Directory + .CreateDirectory( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"tsg-leak-scan-{Guid.NewGuid():N}") + ) + .FullName; + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) { } + } + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index ac16b2ea..132ee441 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -1,8 +1,8 @@ + skills/ folder and its release gates - not about the runtime's reach, so running them once is the + whole job, and Purview.Skills.Testing ships net10.0 only. --> net10.0 false @@ -11,5 +11,7 @@ + + From bc9b88fdc394be75bfc7842d7e3f60b19c150a51 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:14:39 +0000 Subject: [PATCH 06/25] ci: run the packed-consumer gate and leak-scan the sample output A new packed-consumers job runs the gate for linux-x64 with -WasmAot Require, then the forbidden-content rule proof. It fails at its first step, with what to do, until Purview.SourceGeneratorFramework 1.2.2 and Purview.BuildSdk 1.2.0 restore from nuget.org or the TELEMETRY_PRERELEASE_FEED repository variable. The samples job also builds SampleApp.MultiProject, and scans every sample's bin for compiler-host assemblies: that is where SGF 1.2.1 leaked its DLL. --- .github/workflows/pr.yml | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 724db0c9..02b061cd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,5 +34,66 @@ jobs: - name: Build SampleApp run: dotnet build samples/SampleApp/SampleApp.slnx --configuration Release + - name: Build SampleApp.MultiProject + run: dotnet build samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx --configuration Release + + # The samples reference the generator project as an analyzer, which is exactly how + # Purview.SourceGeneratorFramework.dll reached every sample's bin with SGF 1.2.1. Scan + # every sample project's build output for compiler-host assemblies. + - name: Scan the sample build output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path (Get-ChildItem samples/SampleApp, samples/SampleApp.MultiProject -Directory | + ForEach-Object { Join-Path $_.FullName bin } | Where-Object { Test-Path $_ }) + - name: Test SampleApp run: dotnet test samples/SampleApp/SampleApp.slnx --configuration Release --no-build + + packed-consumers: + name: Packed consumers (Native AOT, Blazor WebAssembly, Blazor Server) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + # An extra feed for Purview.SourceGeneratorFramework and Purview.BuildSdk only, for versions + # that are not on nuget.org yet. Unset, the gate restores them from nuget.org alone. + TELEMETRY_PRERELEASE_FEED: ${{ vars.TELEMETRY_PRERELEASE_FEED }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # First, before any toolchain is installed: the generator is built against the + # Purview.SourceGeneratorFramework version Directory.Packages.props pins, with the + # Purview.BuildSdk version global.json pins, so nothing can be packed until both restore. + # This fails with what to do (publish them to nuget.org, or set the + # TELEMETRY_PRERELEASE_FEED repository variable) rather than skipping. + - name: Check the prerelease build dependencies restore + shell: pwsh + run: ./scripts/test-packed-consumers.ps1 -PreflightOnly + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. + # -WasmAot Require below makes a missing workload an error instead of a skipped step. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + - name: Pack, then publish, run and leak-scan consumers of the package + shell: pwsh + run: >- + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require + -PackOutput artifacts/packed + + - name: Prove every pack-validation forbidden-content rule rejects a package + shell: pwsh + run: | + dotnet tool restore + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed From b3fcdc34f8e6ecdc58f549648e81983ca6adb828 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:14:52 +0000 Subject: [PATCH 07/25] build: pin the .NET 11 rc.3 SDK the gates are validated with global.json named rc.2, which is not installed here, so every local build already rolled forward to rc.3 while CI installs rc.2. The packed consumers use the rc.3 ASP.NET Core packages (Components.WebAssembly, TestHost), so CI now installs the SDK that matches them. --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index c9e8fce7..054e74db 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestMajor", "allowPrerelease": true }, From f39a5e0a699cd4585356f34d254ccfd267375984 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:15:09 +0000 Subject: [PATCH 08/25] docs: document the release gates and start a changelog Release-Flow.md gains a Release gates table (what each gate proves, and where it runs locally and in CI), the prerelease build-dependency note and a pre-release checklist. release-process.md and the AGENTS.md quality gates point at it. CHANGELOG.md starts at 5.0.7-prerelease.1. --- AGENTS.md | 36 +++++++++++++++++++++++- CHANGELOG.md | 59 +++++++++++++++++++++++++++++++++++++++ docs/release-process.md | 21 ++++++++++---- docs/wiki/Release-Flow.md | 52 ++++++++++++++++++++++++++++++---- 4 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index e6b9353e..0c63429b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,9 @@ These mirror the GitHub CI/CD workflows and are the supported way to run a full | `just pipeline-tests` | Build with tests enabled. | | `just pipeline-release` | Full release: build, test, pack, publish, GitHub release. | | `just pipeline-local-release` | Pack + publish to a local NuGet feed (see the Justfile note on argument quoting). | +| `just smoke-packed-consumers` | The packed-consumer gate: Native AOT, Blazor WebAssembly and Blazor Server consumers of the `.nupkg`. | +| `just verify-pack-rules` | Proves every `PackValidation` `ForbiddenContent` rule rejects an injected file. | +| `just scan-compiler-host-leaks ...` | Fails when build/publish output carries a generator, SGF or Roslyn assembly. | ### Benchmarks @@ -130,7 +133,8 @@ benchmarks/ skills/ # Authored skills shipped by this package (see "Authoring skills") .config/ # dotnet-tools.json, lefthook.yml .github/workflows/ # pr.yml, release.yml (reusable purview-dev/build workflows) -scripts/ # setup-release.* (legacy changesets DSC scripts) +scripts/ # release-gate scripts (see "Quality gates"), legacy setup-release.* +└── packed-consumers/ # Native AOT, Blazor WebAssembly and Blazor Server consumers of the .nupkg docs/release-process.md # Release process documentation ``` @@ -146,6 +150,36 @@ After making changes to the source generator: The sample projects enable `EmitCompilerGeneratedFiles`, so generated telemetry can be inspected in `obj/*/generated/` to sanity-check generator output. +## Quality gates + +Treat work as incomplete until every gate relevant to it passes. Before a release, all of them: + +- `just scrub`, then `just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/` + — restore, build with no new warnings, lint, tests, pack and pack validation (`RequiredContent` and + `ForbiddenContent`). +- `just smoke-packed-consumers` — packs the generator to an isolated feed and cache and proves the + `.nupkg` itself works: a Native AOT console (published with `-warnaserror`, then run, asserting the + emitted activities, metrics and log entries, log-level gating and multi-target methods), a Blazor + WebAssembly app (trimmed and `RunAOTCompilation`; no generated member trimmed away, every generated + method AOT-compiled) and a Blazor Server host (page rendered on a `TestServer`, telemetry asserted). + Every consumer's bin and publish output is leak-scanned. Set `LOCAL_NUGET_FEED_PATH` (or + `TELEMETRY_PRERELEASE_FEED`) while Purview.SourceGeneratorFramework or Purview.BuildSdk is not on + nuget.org yet. It needs the Native AOT toolchain and, for the WebAssembly AOT step, the `wasm-tools` + workload. +- `just verify-pack-rules` after any change to `PackValidation` in `.config/purview-build.json`. +- `just build-s && just test-s` — the samples, which reference the generator as an analyzer project. +- `just lint` — CSharpier reports no change (`just lint-fix` to apply it). + +Compiler-host assemblies (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`, and this +repository's `Purview.Telemetry.SourceGenerator*` generator and refactorings) must never reach an +application's build or publish output. `scripts/assert-no-compiler-host-assemblies.ps1` is the check; +any CI job that builds the samples or publishes an application must run it on what it wrote, and +`src/tests/Repository.UnitTests/ReleaseGateTests.cs` fails the test run when one does not. The same +tests keep the leak scan honest against crafted leaks, keep the `packed-consumers` job, the Justfile +recipes and every `ForbiddenContent` rule wired up, and require the consumers in +`scripts/packed-consumers/` to reference the generator as a package, never as a project. The full +table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#release-gates). + ### Test project details - Integration tests target `net8.0;net9.0;net10.0`, plus `net48` on Windows only. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..798f9193 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to `Purview.Telemetry.SourceGenerator`. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions come from the +`version` field in `package.json`, which is the single source of truth. This file starts at +5.0.7; earlier releases are described by their [GitHub releases](https://github.com/purview-dev/telemetry-sourcegenerator/releases) +and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). + +## [5.0.7-prerelease.1] - 2026-10-28 + +No change to the generated code or to the package's contents: consumers of the package see the same +generator. This release changes how the package is built and what has to pass before it ships. + +### Fixed + +- `Purview.SourceGeneratorFramework.dll`, a compiler-host-only assembly, was copied into the build + output of every project that referenced the generator project as an analyzer. Built on `main`, all + eight sample projects' `bin` folders carried it, and an application built that way would ship it in + its `deps.json`, publish output and Blazor WebAssembly `_framework` folder, where WebAssembly AOT + compilation fails on it. The cause was Purview.SourceGeneratorFramework 1.2.1's build targets; + 1.2.2 fixes them, and the generator now builds against 1.2.2. The packed generator was never + affected: it ships the framework IL-merged under `analyzers/`. + +### Changed + +- Purview.SourceGeneratorFramework (and its testing packages) 1.2.1 → 1.2.2. +- Purview.BuildSdk 1.1.0 → 1.2.0 and Purview.Skills.Testing 1.1.0 → 1.2.0. The SDK refreshed its + managed `.editorconfig`; no rule changed for this repository, and no new diagnostic fired. + +### Added + +- **Packed-consumer gate** (`just smoke-packed-consumers`, `scripts/test-packed-consumers.ps1`). + Packs the generator to an isolated feed and cache, then builds consumers that reference the + package only - each also references a class library with its own generated telemetry: + - a **Native AOT** console, published with `-warnaserror` and run with an in-process + `ActivityListener`, `MeterListener` and capturing logger provider, asserting the multi-target + method, activity tags, baggage and events (including the OpenTelemetry exception event), every + metric measurement, the v1 and v2 log entries, log-level gating and `TelemetryNames` + aggregation; + - a **Blazor WebAssembly** app using the telemetry from a page and a service, published trimmed + and with `RunAOTCompilation=true`; the trimmed assemblies must keep every generated member and + the AOT compiler must have compiled every generated method; + - a **Blazor Server** (interactive server) host, rendered on an in-memory `TestServer` and checked + for the same telemetry. +- **Compiler-host leak scan** (`scripts/assert-no-compiler-host-assemblies.ps1`, + `just scan-compiler-host-leaks`): fails when build or publish output - by file name, or in a + `deps.json`, `runtimeconfig.json` or WebAssembly boot manifest - carries + `Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*` or `Purview.Telemetry.SourceGenerator*`. + It runs on every consumer in the gate and on the samples' build output in CI. +- **Pack validation `ForbiddenContent` rules** in `.config/purview-build.json`, keeping compiler-host + assemblies out of every package folder an application receives, and a rule proof + (`just verify-pack-rules`, `scripts/test-pack-validation-rules.ps1`) that injects a file per rule + into scratch copies of the package and shows each one is rejected. +- `ReleaseGateTests` in `Repository.UnitTests`, which keep the gates honest and wired up. +- A `packed-consumers` job in `pr.yml`, and `SampleApp.MultiProject` built and leak-scanned in the + `samples` job. + +[5.0.7-prerelease.1]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.6...v5.0.7-prerelease.1 diff --git a/docs/release-process.md b/docs/release-process.md index 3e99e21b..e76df165 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -99,11 +99,22 @@ is enabled by `Purview.BuildSdk`). ## Pre-release validation -Before a release, validate locally: - -1. `just pipeline-pr` — restore, build, lint, tests. -2. `just build-s && just test-s` — sample solution build and tests. -3. `just pipeline-local-release` — confirm the pack + local publish succeed. +Before a release, validate locally on a clean tree. Each step must pass. + +1. `just scrub` — delete `bin`/`obj`, clean, force-restore, stop build servers. +2. `just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/` — + restore, build, lint, tests, pack, pack validation (including `ForbiddenContent`), local publish. +3. `just smoke-packed-consumers` — the packed-consumer gate: Native AOT, Blazor WebAssembly (trimmed + and `RunAOTCompilation`) and Blazor Server consumers of the `.nupkg`, each leak-scanned for + compiler-host assemblies. +4. `just build-s && just test-s` — sample solution build and tests. +5. `just lint` — CSharpier reports no change. + +After changing `PackValidation`, also run `just verify-pack-rules` (pass `-PackagesFolder ` after +`just pipeline-local-release`, which moves the package out of `artifacts/`). + +What each gate proves, and where CI runs it, is in the wiki's +[Release Flow — Release gates](wiki/Release-Flow.md#release-gates). ## Releasing diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 078a04f8..092aec0d 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -13,7 +13,7 @@ Feature branch | Component | File | Purpose | | --- | --- | --- | -| PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests) and builds/tests the sample solution. | +| PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests), builds/tests the samples and leak-scans their output, and runs the packed-consumer gate (see [Release gates](#release-gates)). | | CD pipeline | `.github/workflows/release.yml` | Runs the reusable `purview-dev/build` `purview-release.yml` pipeline on push to `main`. | | Local pipeline | `Justfile` `pipeline-*` recipes | Mirror the CI/CD pipelines locally via the `Purview.Build` local tool (`dotnet purview-build`). | @@ -40,13 +40,55 @@ Feature branch | `just pipeline-local-release` | Packs and publishes to a local NuGet feed (see the Justfile note on argument quoting for the feed path). | | `just pipeline-release` | Full release pipeline (build, test, pack, publish, GitHub release). | +## Release gates + +Each gate proves one thing the others cannot. The tests and the samples reference the generator project, which is +fast and debuggable but is not what ships; the packed gates consume the `.nupkg` itself. A gate that has nothing to +check fails rather than passing. + +| Gate | What it proves | Local | CI | +| --- | --- | --- | --- | +| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint`, `just test` | `pr.yml` `build` | +| Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` | +| Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | +| Packed consumers | The package, packed to an isolated feed and cache, works for consumers that reference it as a package - each with a class library of its own generated telemetry. (a) A **Native AOT** console publishes with `-warnaserror` and no IL2xxx/IL3xxx warning, then runs with an in-process `ActivityListener`, `MeterListener` and capturing logger provider and checks the multi-target method (Activity, Information log and counter from one call), tags, baggage, events and the OpenTelemetry exception event, every metric measurement, v1 and v2 logging, log-level gating and the `TelemetryNames` aggregation. (b) A **Blazor WebAssembly** app, using the telemetry from a page and a service, publishes trimmed with `-warnaserror` and with `RunAOTCompilation=true`: the trimmed assemblies keep every generated member, and the WebAssembly AOT compiler compiles every generated method. (c) A **Blazor Server** host renders its page on an in-memory `TestServer`, and the telemetry the page emitted is checked as in (a). (d) Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | +| Compiler-host leak scan | No generator, refactorings, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest (`blazor.boot.json`, `dotnet.boot.js`, `dotnet.js`). Purview.SourceGeneratorFramework 1.2.1 put its DLL in every sample's `bin` while every build passed. | `just scan-compiler-host-leaks ...` | the `samples` job (every sample's `bin`) and every consumer in `packed-consumers`; `ReleaseGateTests` fails a job that builds the samples or publishes without it | + +`ReleaseGateTests` (in `src/tests/Repository.UnitTests`, so `just test` runs it) guards the gates themselves: the leak +scan must catch each kind of leak it claims to, every workflow job that builds the samples or publishes must scan its +output, the `packed-consumers` job must run the gate and the rule proof, the Justfile recipes must run their scripts, +the consumers must reference the generator as a package and never the repository's projects, and `ForbiddenContent` +must keep every rule. + +### Prerelease build dependencies + +The generator is built against the Purview.SourceGeneratorFramework version `Directory.Packages.props` pins, with the +Purview.BuildSdk version `global.json` pins. Until those versions are on nuget.org, CI cannot restore them: the +`packed-consumers` job fails at its first step with what to do, rather than skipping. Publish them, or set the +`TELEMETRY_PRERELEASE_FEED` repository variable to a feed that has them. Locally, `just smoke-packed-consumers` also +reads `TELEMETRY_PRERELEASE_FEED` or `LOCAL_NUGET_FEED_PATH`, and uses that feed for those packages only. + +The consumers target `net11.0` and take their ASP.NET Core package versions from the `Packed consumers` group in +`Directory.Packages.props`, so bump those with the .NET 11 SDK in `global.json`. + ## Pre-release validation -Before a release, validate locally: +Run these on a clean tree before bumping and merging a release. Each must pass. + +```powershell +$env:LOCAL_NUGET_FEED_PATH = 'P:\_sync-projects\.local-nuget' # wherever the prerelease build dependencies are + +just scrub # delete bin/obj, clean, force-restore, stop build servers +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ + # restore, build, lint, tests, pack, pack validation, local publish +just smoke-packed-consumers # Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor Server +just build-s && just test-s # sample solution build and tests +just lint # CSharpier reports no change +``` -1. `just pipeline-pr` — restore, build, lint, tests. -2. `just build-s && just test-s` — sample solution build and tests. -3. `just pipeline-local-release` — confirm the pack + local publish succeed. +`just verify-pack-rules` re-proves the forbidden-content rules against the package in `artifacts/` (after +`just pipeline-local-release`, which moves it to the local feed, pass `-PackagesFolder `); run it after changing +`PackValidation`. ## Releasing From d98252a4565e4b604f264ae112a0a7c525af33e8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 11:15:09 +0000 Subject: [PATCH 09/25] chore(release): 5.0.7-prerelease.1 A patch prerelease: the generated code and the package contents are unchanged. The release moves the build to Purview.SourceGeneratorFramework 1.2.2 and Purview.BuildSdk 1.2.0, which are not on nuget.org yet, and adds the release gates. --- README.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 920d6bbb..1784d02b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system Add to your `Directory.Build.props` or `.csproj` file: ```xml - + all analyzers diff --git a/package.json b/package.json index 6441b50a..54836fe5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.0.6", + "version": "5.0.7-prerelease.1", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", From 54e88c97076c8ce118267f353f7ab6d0c12a6608 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:08:34 +0000 Subject: [PATCH 10/25] fix(benchmarks): load the merged generator instead of a missing Shared.dll The benchmark project referenced Purview.SourceGeneratorFramework.Shared.dll as an analyzer, a file no SGF version ships (its sources are compiled into Purview.SourceGeneratorFramework.dll), so every target failed with CS0006 before the generator ran. The project sits outside src/, so Purview.BuildSdk never asked the generator for its analyzer files. The ProjectReference now names Targets="GetSourceGeneratorAnalyzerFiles", which returns the merged, self-contained generator with no framework dependency to load, in every build mode, including BenchmarkDotNet's UseSharedCompilation=false host build. That exposed net48 compile errors: ArgumentNullException.ThrowIfNull does not exist there, and the generator emits no IMeterFactory constructor on .NET Framework. CA1515 is suppressed because BenchmarkDotNet needs public benchmark types. --- .../BenchmarkHelpers.cs | 20 ++++++++++ .../Manual/ManualMetricsTelemetry.cs | 3 +- .../Manual/ManualMultiTargetTelemetry.cs | 5 ++- .../Purview.Telemetry.Benchmarks.csproj | 37 ++++++++++--------- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs index 07e5d46c..fbdae414 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs @@ -48,7 +48,13 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua var logger = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + // IMeterFactory is a .NET 8+ host concept, so on .NET Framework the generator emits a + // constructor without it and creates the Meter itself. + var generated = new MultiTargetTelemetryCore(logger); +#else var generated = new MultiTargetTelemetryCore(logger, meterFactory); +#endif var manual = new ManualMultiTargetTelemetry(logger, meterFactory); return (generated, manual); @@ -59,9 +65,14 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua /// public static (IMetricsFewTagsTelemetry FewTags, IMetricsManyTagsTelemetry ManyTags) CreateMetricsTelemetry() { +#if NETFRAMEWORK + var fewTags = new MetricsFewTagsTelemetryCore(); + var manyTags = new MetricsManyTagsTelemetryCore(); +#else var meterFactory = new SimpleMeterFactory(); var fewTags = new MetricsFewTagsTelemetryCore(meterFactory); var manyTags = new MetricsManyTagsTelemetryCore(meterFactory); +#endif return (fewTags, manyTags); } @@ -76,7 +87,11 @@ ManualMetricsTelemetry Manual ) CreateMetricsBenchmarkTelemetry() { var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + var generated = new MetricsBenchmarkTelemetryCore(); +#else var generated = new MetricsBenchmarkTelemetryCore(meterFactory); +#endif var manual = new ManualMetricsTelemetry(meterFactory); return (generated, manual); } @@ -136,8 +151,13 @@ ManualMultiTargetTelemetry Manual var loggerManual = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + var generatedV2 = new MultiTargetTelemetryCore(loggerV2); + var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1); +#else var generatedV2 = new MultiTargetTelemetryCore(loggerV2, meterFactory); var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1, meterFactory); +#endif var manual = new ManualMultiTargetTelemetry(loggerManual, meterFactory); return (generatedV2, generatedV1, manual); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs index 841efcc1..936bb7d9 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs @@ -20,7 +20,8 @@ public sealed class ManualMetricsTelemetry : IDisposable public ManualMetricsTelemetry(IMeterFactory meterFactory) { - ArgumentNullException.ThrowIfNull(meterFactory); + // Not ArgumentNullException.ThrowIfNull: the project also targets net48, which lacks it. + _ = meterFactory ?? throw new ArgumentNullException(nameof(meterFactory)); _meter = meterFactory.Create(new MeterOptions("benchmark-manual-metrics")); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs index 8e84cbb6..398befc1 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs @@ -44,8 +44,9 @@ public sealed class ManualMultiTargetTelemetry : IDisposable public ManualMultiTargetTelemetry(ILogger logger, IMeterFactory meterFactory) { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(meterFactory); + // Not ArgumentNullException.ThrowIfNull: the project also targets net48, which lacks it. + _ = logger ?? throw new ArgumentNullException(nameof(logger)); + _ = meterFactory ?? throw new ArgumentNullException(nameof(meterFactory)); _logger = logger; _meter = meterFactory.Create(new MeterOptions("Purview.Telemetry.Benchmarks.Manual")); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj index 169ec3c0..b7c36866 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj +++ b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj @@ -8,37 +8,38 @@ true true true - - $(NoWarn);CA1707 + + $(NoWarn);CA1707;CA1515 - - - - - - From 525de4bb83610b728ca82b4f673a643644f099f1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:15:32 +0000 Subject: [PATCH 11/25] fix: make MessageTemplateHole internal so the merged generator exposes no SGF type The merge reported PSGFR41: MessageTemplateHole was the generator's only public non-component type, and its positional init accessors carry a modreq to IsExternalInit, which Purview.SourceGeneratorFramework defines publicly (by design) and the merge internalizes. Nothing outside the assembly uses it - the template tests parse into their own TestMessageTemplateHole - so it is now internal like every other pipeline model. PurviewMergePublicSurfaceSeverity is set to error in src/Directory.Build.props, so a leak like this fails the build (and the PSGFR41 analyzer fails it while editing) instead of logging a message nobody reads. ReleaseGateTests keeps that setting in place. --- src/Directory.Build.props | 7 +++++ .../SourceGenerator/Records/GenericRecords.cs | 19 ++++++++---- .../Repository.UnitTests/ReleaseGateTests.cs | 30 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0e4df7fb..20de63ed 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,6 +14,13 @@ causing duplicate-symbol errors during host builds. --> false + + error diff --git a/src/src/SourceGenerator/Records/GenericRecords.cs b/src/src/SourceGenerator/Records/GenericRecords.cs index 9b8c9ca2..cfa49976 100644 --- a/src/src/SourceGenerator/Records/GenericRecords.cs +++ b/src/src/SourceGenerator/Records/GenericRecords.cs @@ -24,7 +24,18 @@ readonly record struct TargetGeneration( bool RaiseMissingInterfaceSource = false ); -public readonly record struct MessageTemplateHole( +/// +/// A hole ({Name} or {0}) parsed from a log message template. +/// +/// +/// Internal, like every other pipeline model here. It was the generator's only public non-component type, and its +/// positional properties have init accessors whose required modifier references +/// System.Runtime.CompilerServices.IsExternalInit - a type Purview.SourceGeneratorFramework defines publicly and +/// the merge internalizes - so a public declaration put a framework type on the shipped analyzer's public surface +/// (PSGFR41). Nothing outside this assembly uses it: the template tests parse into their own +/// TestMessageTemplateHole. +/// +readonly record struct MessageTemplateHole( string? Name, int? Ordinal, int? Alignment, @@ -46,11 +57,9 @@ public readonly void Validate() } /// - /// Projects the regex matches into the hole records. Internal because the returned - /// EquatableArray is a framework type the merge internalizes in the shipped analyzer (seen by - /// PSGFR41); the BuildSdk grants the test assemblies access to internals. + /// Projects the regex matches into the hole records. /// - internal static EquatableArray FromMatches(MatchCollection matches) + public static EquatableArray FromMatches(MatchCollection matches) { List? holes = null; if (matches != null) diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs index a4986398..dfd065dc 100644 --- a/src/tests/Repository.UnitTests/ReleaseGateTests.cs +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -413,6 +413,36 @@ await Assert .Because($"ForbiddenContent[\"*\"] no longer forbids: {string.Join(", ", missing)}"); } + /// + /// The merge that makes the shipped analyzers self-contained reports a public type exposing a + /// Purview.SourceGeneratorFramework type (PSGFR41) as a plain message by default, which is how + /// MessageTemplateHole shipped. As an error it fails the build, so the setting must not be dropped. + /// + [Test] + public async Task BuildProps_GivenTheMergedAnalyzers_FailTheBuildOnAPublicSurfaceLeak( + CancellationToken cancellationToken + ) + { + // Arrange + var document = XDocument.Parse( + await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, "src", "Directory.Build.props"), + cancellationToken + ) + ); + + // Act + var severities = document + .Descendants("PurviewMergePublicSurfaceSeverity") + .Select(static element => element.Value.Trim()) + .ToArray(); + var disabled = document.Descendants("PurviewMergePublicSurfaceValidation").Select(static e => e.Value.Trim()); + + // Assert + await Assert.That(severities).IsEquivalentTo(["error"]); + await Assert.That(disabled).DoesNotContain("false"); + } + static TemporaryDirectory CleanOutput() { TemporaryDirectory output = new(); From 3781b3ef08becb78c6659d61889572ac9b8659bf Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:15:45 +0000 Subject: [PATCH 12/25] fix(docs): stop pinning a package version in the packaged README 5.0.6 shipped a README telling consumers to install 5.0.5. The package README is src/src/SourceGenerator/Sdk/README.md, a symbolic link to the repository README.md, and its version was only ever rewritten by `just update-version`, a manual step the release pipeline does not run. The installation section now carries no version: `dotnet add package` writes the latest one, and the snippet shows the analyzer-only PrivateAssets/IncludeAssets shape without one. PackagedDocumentationTests in Repository.UnitTests fails when any packaged Sdk/*.md pins a version (a Version="..." attribute, --version/-Version switches, the Current Version marker, or a version beside the package id), and when the generator's Sdk/README.md does not resolve to README.md, as it would in a checkout without symbolic link support. --- README.md | 13 +- .../PackagedDocumentationTests.cs | 150 ++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/tests/Repository.UnitTests/PackagedDocumentationTests.cs diff --git a/README.md b/README.md index 1784d02b..c882b74b 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,24 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system ## Installation -Add to your `Directory.Build.props` or `.csproj` file: +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +`dotnet add package` writes the latest version (shown on the NuGet badge above) into your project. The +generator is a compile-time dependency only, so mark the reference as analyzer-only: ```xml - + all analyzers ``` +The `Version` attribute that `dotnet add package` adds stays on that element; with +[Central Package Management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) +it lives in `Directory.Packages.props` instead. + ## Quick Start Define an interface with telemetry methods and the generator creates the implementation: diff --git a/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs b/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs new file mode 100644 index 00000000..e06cd03c --- /dev/null +++ b/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the documentation that ships inside the packages: every Sdk/*.md file under src/src, which +/// Purview.BuildSdk packs at the package root (the generator's Sdk/README.md is a symbolic link to the +/// repository README.md). +/// +/// +/// A packaged document is frozen into every version of the package it ships in, and nothing in the pipeline rewrites +/// it: just update-version is a manual step. 5.0.6 shipped a README telling consumers to install 5.0.5. So the +/// packaged documents carry no package version at all - dotnet add package and the NuGet badge supply it - and +/// these tests fail when one creeps back in. +/// +public sealed partial class PackagedDocumentationTests +{ + const string PackageId = "Purview.Telemetry.SourceGenerator"; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + public static IEnumerable> PackagedDocuments() => + Directory + .GetFiles(Path.Combine(FindRepositoryRoot(), "src", "src"), "*.md", SearchOption.AllDirectories) + .Where(static path => Path.GetFileName(Path.GetDirectoryName(path)) == "Sdk") + .Select(path => Path.GetRelativePath(FindRepositoryRoot(), path).Replace('\\', '/')) + .Order(StringComparer.Ordinal) + .Select(static path => (Func)(() => path)); + + [Test] + public async Task PackagedDocuments_GivenTheRepository_IncludeTheGeneratorReadme( + CancellationToken cancellationToken + ) + { + // Arrange + const string readme = "src/src/SourceGenerator/Sdk/README.md"; + + // Act + var documents = PackagedDocuments().Select(static document => document()).ToArray(); + var packaged = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot.Value, readme), cancellationToken); + var repository = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, "README.md"), + cancellationToken + ); + + // Assert + await Assert.That(documents).Contains(readme).Because("the discovery found no packaged README to check"); + await Assert + .That(packaged) + .IsEqualTo(repository) + .Because( + $"{readme} must resolve to the repository README.md. A checkout without symbolic link support " + + "(git config core.symlinks false) leaves the link's target path in the file, and that is what " + + "would be packed as the package README" + ); + } + + [Test] + [MethodDataSource(nameof(PackagedDocuments))] + public async Task PackagedDocument_GivenItsText_CarriesNoHardCodedPackageVersion( + string document, + CancellationToken cancellationToken + ) + { + // Arrange + var lines = await File.ReadAllLinesAsync(Path.Combine(RepositoryRoot.Value, document), cancellationToken); + + // Act + var pinned = lines + .Select(static (line, index) => (Line: line, Number: index + 1)) + .Where(static entry => PinsAVersion(entry.Line)) + .Select(static entry => $"line {entry.Number}: {entry.Line.Trim()}") + .ToArray(); + + // Assert + await Assert + .That(pinned) + .IsEmpty() + .Because( + $"{document} ships inside the package and is never rewritten, so a version in it goes stale with the " + + $"next release. Leave the version to `dotnet add package` or the NuGet badge: {string.Join("; ", pinned)}" + ); + } + + [Test] + [Arguments("", true)] + [Arguments("", true)] + [Arguments("dotnet add package Purview.Telemetry.SourceGenerator --version 5.0.6", true)] + [Arguments("Install-Package Purview.Telemetry.SourceGenerator -Version 5.0.6", true)] + [Arguments("**Current Version:** 5.0.6", true)] + [Arguments("dotnet add package Purview.Telemetry.SourceGenerator", false)] + [Arguments("", false)] + [Arguments("Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.401.", false)] + [Arguments("- Visual Studio 2026 (18.x) or .NET 10 SDK (Roslyn 5.9.0+)", false)] + public async Task PinsAVersion_GivenALine_RecognisesAPackageVersion( + string line, + bool expected, + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var pins = PinsAVersion(line); + + // Assert + await Assert.That(pins).IsEqualTo(expected); + } + + /// + /// A version written beside this package's id, in a Version="..." attribute, after a --version / + /// -Version switch, or as the **Current Version:** marker .build/update-version.ts rewrites. + /// + static bool PinsAVersion(string line) => + VersionAttribute().IsMatch(line) + || VersionSwitch().IsMatch(line) + || line.Contains("**Current Version:**", StringComparison.Ordinal) + || (line.Contains(PackageId, StringComparison.Ordinal) && SemanticVersion().IsMatch(line)); + + [GeneratedRegex(@"\bVersion\s*=\s*""\d", RegexOptions.IgnoreCase)] + private static partial Regex VersionAttribute(); + + [GeneratedRegex(@"(^|\s)--?version\s+\d", RegexOptions.IgnoreCase)] + private static partial Regex VersionSwitch(); + + [GeneratedRegex(@"\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b")] + private static partial Regex SemanticVersion(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "package.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, package.json and src/src." + ); + } +} From b552af77a023fbbf4e4e83afb1cc2bd83b5dd651 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:15:59 +0000 Subject: [PATCH 13/25] fix(metrics): prepend the opt-in meter name ahead of the interface prefix The README said OpenTelemetry naming prefixes instrument names with the meter name ("myapp.products.record.count"). It never has: MeterNameGenerationType defaults to DotNet in the attribute, the pipeline and every version since the option arrived, and the default name is the interface-derived prefix plus the snake_case instrument name ("product.record_count") - as the packed consumers' "order.orders.placed" shows. The docs were wrong, and are corrected. The opt-in MeterNameGenerationType.OpenTelemetry was wrong too. Its comment, the FAQ and the Metrics page all describe the meter name as the outermost namespace, but it was inserted between the interface prefix and the instrument name ("product.my_app.products.record_count"). It now comes first ("my_app.products.product.record_count"). Only that opt-in mode changes. The README, Metrics, FAQ, Generation and Breaking Changes pages now use one example interface, and the Generate_GivenTheDocumentedExample* tests pin its default, OpenTelemetry-meter-name and legacy names. A further test checks the meter name does not accumulate across instruments. --- README.md | 41 +++++- docs/wiki/Breaking-Changes.md | 2 +- docs/wiki/FAQ.md | 18 +-- docs/wiki/Generation.md | 2 +- docs/wiki/Metrics.md | 30 ++++- .../Helpers/InstrumentMethodModelBuilder.cs | 33 +++-- ...SourceGeneratorMetricsTests.MeterNaming.cs | 127 ++++++++++++++++++ 7 files changed, 222 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c882b74b..a6430766 100644 --- a/README.md +++ b/README.md @@ -238,32 +238,59 @@ v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry na | ---------------- | ------------- | ------------ | -------- | | **ActivitySource Name** | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | ActivitySource names change casing | | **Tag/Baggage Keys** | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | Tag keys have underscores for word boundaries | -| **Metric Instrument Names** | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | Includes meter name prefix + word boundaries | +| **Metric Instrument Names** | Lowercased, smashed: `"product.recordcount"` | Dot-separated namespaces, snake_case words: `"product.record_count"` | Word boundaries become underscores | | **Metric Tag Keys** | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | Metric tag keys have underscores | #### Examples +Given this interface: + +```csharp +[Meter("MyApp.Products")] +interface IProductTelemetry +{ + [Counter] + void RecordCount([InstrumentMeasurement] int count); +} +``` + **Before (v3/Legacy):** ```csharp // Generated code: new ActivitySource("myapp") // lowercase activity.SetTag("entityid", ...) // smashed compound -var meter = meterFactory.Create("MyApp.Products"); -meter.CreateCounter("recordcount") // smashed compound, no meter prefix +var meter = meterFactory.Create(new MeterOptions("MyApp.Products") { ... }); +meter.CreateCounter("product.recordcount") // smashed compound ``` -**After (v4 OpenTelemetry mode - DEFAULT):** +**After (v4 OpenTelemetry naming - DEFAULT):** ```csharp // Generated code: new ActivitySource("MyApp") // preserves casing activity.SetTag("entity_id", ...) // snake_case -var meter = meterFactory.Create("MyApp.Products"); -meter.CreateCounter("myapp.products.record.count") // hierarchical: meter + instrument +var meter = meterFactory.Create(new MeterOptions("MyApp.Products") { ... }); +meter.CreateCounter("product.record_count") // interface prefix + snake_case instrument +``` + +An instrument name is built from, in order: the assembly `[MeterGeneration(InstrumentPrefix = ...)]`, the +interface prefix, and the instrument name. The interface prefix is `[Meter(InstrumentPrefix = ...)]` when set, +otherwise it is derived from the interface name: the leading `I` and a trailing `Telemetry` or `Logs` are +removed, so `IProductTelemetry` becomes `product`. The instrument name is the attribute's `Name`, or the method +name in snake_case. + +**The meter name is not part of the instrument name by default.** The meter already identifies the instruments +it owns, and `MeterNameGenerationType` defaults to `DotNet`. Opt in to the meter name as the outermost namespace +with `MeterNameGenerationType.OpenTelemetry`, which also lowercases a meter name derived from the assembly name: + +```csharp +[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + +// The interface above now generates "my_app.products.product.record_count". ``` -**Note**: In OpenTelemetry mode, instrument names automatically include the meter name prefix (converted to lowercase dot.separated), following OpenTelemetry best practices for hierarchical metric naming. +See [Metrics](https://purview.dev/docs/telemetry-sourcegenerator/metrics/#meter-naming) for the full rules. #### Reverting to v3 Naming (Legacy Mode) diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/Breaking-Changes.md index 0f9e58eb..3e881880 100644 --- a/docs/wiki/Breaking-Changes.md +++ b/docs/wiki/Breaking-Changes.md @@ -67,7 +67,7 @@ v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry na | ActivitySource name | Assembly name lowercased | Assembly name casing preserved | `"myapp"` → `"MyApp"` | | Activity names | Method name lowercased | Method name casing preserved | `"getentity"` → `"GetEntity"` | | Tag/baggage keys | Lowercased, smashed compounds | snake_case with underscores | `"entityid"` → `"entity_id"` | -| Metric instrument names | Lowercased, smashed | Hierarchical with meter prefix | `"recordcount"` → `"myapp.products.record.count"` | +| Metric instrument names | Lowercased, smashed | Interface prefix + snake_case, no meter prefix | `"product.recordcount"` → `"product.record_count"` (`IProductTelemetry.RecordCount`) | | Metric tag keys | Lowercased, smashed | snake_case with underscores | `"requestcount"` → `"request_count"` | #### Examples diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 19a29a64..d22ee587 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -187,22 +187,24 @@ telemetry.QueueDepth(() => _queue.Count); ### When do metric names include the meter name? -Instrument names are generated with the meter name as a lowercase dot-separated prefix when using `NamingConvention.OpenTelemetry` combined with `MeterNameGenerationType.OpenTelemetry`: +Only when you opt in with `[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)]`. The default, `MeterNameGenerationType.DotNet`, prefixes instrument names with the interface name instead: ```csharp -[Meter("MyApp.Orders")] -interface IOrderMetrics +[Meter("MyApp.Products")] +interface IProductTelemetry { [Counter] - void OrderProcessed([InstrumentMeasurement]int count); + void RecordCount([InstrumentMeasurement] int count); } -// Generated name: "myapp.orders.order.processed" -// ^^^^^^^^^^^^^^^ meter prefix (lowercase) -// ^^^^^^^^^^^^^^^^ instrument name +// Default: "product.record_count" +// MeterNameGenerationType.OpenTelemetry: "my_app.products.product.record_count" +// ^^^^^^^^^^^^^^^ meter name (snake_case), outermost +// ^^^^^^^ interface prefix (IProductTelemetry) +// ^^^^^^^^^^^^ instrument name ``` -The default `MeterNameGenerationType.DotNet` does not add the meter-name prefix. See [Metrics](Metrics.md#meter-naming). +See [Instrument naming](Metrics.md#instrument-naming) for the full rules. ## Registering names diff --git a/docs/wiki/Generation.md b/docs/wiki/Generation.md index 69bdd3ad..bc37e1e1 100644 --- a/docs/wiki/Generation.md +++ b/docs/wiki/Generation.md @@ -55,7 +55,7 @@ public enum NamingConvention | --- | --- | --- | | ActivitySource name | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | | Tag / baggage keys | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | -| Metric instrument names | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | +| Metric instrument names | Lowercased, smashed: `"product.recordcount"` | Interface prefix + snake_case: `"product.record_count"` (see [Instrument naming](Metrics.md#instrument-naming)) | | Metric tag keys | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | ```csharp diff --git a/docs/wiki/Metrics.md b/docs/wiki/Metrics.md index d749480e..1bda398e 100644 --- a/docs/wiki/Metrics.md +++ b/docs/wiki/Metrics.md @@ -20,7 +20,35 @@ The meter name is resolved in this order: interface IInventoryMetrics { } ``` -When the `NamingConvention.OpenTelemetry` naming convention is combined with `MeterNameGenerationType.OpenTelemetry`, instrument names are generated with the meter name as a dot-separated prefix (for example, `myapp.products.record.count`). +### Instrument naming + +An instrument name is built from up to four dot-separated parts, outermost first: + +1. The meter name - **only** with `[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)]`. The default, `MeterNameGenerationType.DotNet`, leaves it out: the meter already identifies its instruments. +2. `MeterGenerationAttribute.InstrumentPrefix` (assembly), unless `MeterAttribute.IncludeAssemblyInstrumentPrefix` is `false`. +3. The interface prefix: `MeterAttribute.InstrumentPrefix` when set, otherwise derived from the interface name by removing the leading `I` and a trailing `Telemetry` or `Logs` (`IProductTelemetry` becomes `product`). +4. The instrument's `Name`, or the method name. + +With the default `NamingConvention.OpenTelemetry`, word boundaries in every part become underscores (`RecordCount` becomes `record_count`, `MyApp.Products` becomes `my_app.products`); `NamingConvention.Legacy` only lowercases them. `LowercaseInstrumentName = false` keeps every part as written. + +```csharp +[Meter("MyApp.Products")] +interface IProductTelemetry +{ + [Counter] + void RecordCount([InstrumentMeasurement] int count); +} +``` + +| Configuration | Instrument name | +| --- | --- | +| Defaults | `product.record_count` | +| `MeterNameGenerationType.OpenTelemetry` | `my_app.products.product.record_count` | +| `NamingConvention.Legacy` | `product.recordcount` | + +These three names are pinned by the `Generate_GivenTheDocumentedExample*` tests in `TelemetrySourceGeneratorMetricsTests.MeterNaming.cs`. + +`MeterNameGenerationType.OpenTelemetry` also lowercases a meter name that falls back to the assembly name. ## Initialisation diff --git a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs index 88eec8fc..52549fea 100644 --- a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs @@ -64,7 +64,7 @@ CancellationToken token if (string.IsNullOrWhiteSpace(instrumentName)) instrumentName = method.Name; - (instrumentName, prefix) = ResolveInstrumentName( + (instrumentName, prefix, var meterPrefix) = ResolveInstrumentName( instrumentName!, prefix, lowercaseInstrumentName, @@ -91,7 +91,7 @@ CancellationToken token FieldName: fieldName, InstrumentMeasurementType: instrumentMeasurementType, IsObservable: instrumentAttribute?.IsObservable == true, - MetricName: prefix + instrumentName!, + MetricName: meterPrefix + prefix + instrumentName!, InstrumentAttribute: instrumentAttribute, Parameters: parameters, Tags: tagParameters, @@ -113,7 +113,16 @@ CancellationToken token return [.. methodTargets]; } - static (string InstrumentName, string? Prefix) ResolveInstrumentName( + /// + /// Resolves the casing of an instrument name and its prefixes. The full name is + /// MeterPrefix + Prefix + InstrumentName. + /// + /// + /// MeterPrefix is returned separately rather than folded into either of the other two: Prefix is + /// shared by every instrument on the interface (so it must not accumulate), and the meter name is the outermost + /// namespace, so it belongs ahead of the interface prefix rather than between it and the instrument name. + /// + static (string InstrumentName, string? Prefix, string MeterPrefix) ResolveInstrumentName( string instrumentName, string? prefix, bool lowercaseInstrumentName, @@ -123,13 +132,13 @@ string meterName ) { if (!lowercaseInstrumentName) - return (instrumentName, prefix); + return (instrumentName, prefix, string.Empty); if (isLegacy) { // Legacy: Just lowercase without word-boundary splitting #pragma warning disable CA1308 // Intentional lowercase for legacy compatibility - return (instrumentName.ToLowerInvariant(), prefix?.ToLowerInvariant()); + return (instrumentName.ToLowerInvariant(), prefix?.ToLowerInvariant(), string.Empty); #pragma warning restore CA1308 } @@ -140,16 +149,14 @@ string meterName if (!string.IsNullOrEmpty(prefix)) prefix = Utilities.ConvertToSeparatedLowercase(prefix!, '_'); - // For OpenTelemetry convention only: Prepend meter name as namespace with dot separator - // e.g., meter "myapp.products" + instrument "pricing_page_requests" -> "myapp_products.pricing_page_requests" + // MeterNameGenerationType.OpenTelemetry only (the default is DotNet): prepend the meter name as the + // outermost namespace, e.g. meter "MyApp.Products" + interface IProductTelemetry + method RecordCount + // -> "my_app.products.product.record_count". var meterNameGenType = meterGenerationAttribute?.MeterNameGenerationType ?? 1; // Default to DotNet - if (meterNameGenType == 0 && !string.IsNullOrWhiteSpace(meterName)) // OpenTelemetry only - { - var meterPrefix = Utilities.ConvertToSeparatedLowercase(meterName, '_'); - instrumentName = $"{meterPrefix}.{instrumentName}"; - } + if (meterNameGenType == 0 && !string.IsNullOrWhiteSpace(meterName)) + return (instrumentName, prefix, Utilities.ConvertToSeparatedLowercase(meterName, '_') + "."); - return (instrumentName, prefix); + return (instrumentName, prefix, string.Empty); } static ImmutableArray GetInstrumentParameters( diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs index a0ea76b7..32237bea 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs @@ -370,6 +370,133 @@ await Assert .Because("the instrument name must be generated for the DotNet-convention meter name"); } + /// + /// The interface every naming example in README.md, docs/wiki/Metrics.md and docs/wiki/FAQ.md uses, so the + /// documented names are pinned by the generator itself. + /// + const string DocumentedProductTelemetry = """ + [Meter("MyApp.Products")] + interface IProductTelemetry + { + [Counter] + void RecordCount([InstrumentMeasurement] int count); + } + """; + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithDefaults_PrefixesTheInterfaceNameButNotTheMeterName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $""" + namespace Testing; + + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("ProductTelemetryCore.Metric.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode("\"product.record_count\"") + .Because( + "the default (OpenTelemetry naming, MeterNameGenerationType.DotNet) is the interface-derived prefix and the snake_case method name, without the meter name" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("\"MyApp.Products\"") + .Because("the meter keeps the name it was given"); + await Assert.That(generated).DoesNotContain("my_app.products."); + } + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithOpenTelemetryMeterNames_PrependsTheMeterName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $""" + [assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + + namespace Testing; + + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("ProductTelemetryCore.Metric.g.cs")) + .ContainsGeneratedCode("\"my_app.products.product.record_count\"") + .Because( + "MeterNameGenerationType.OpenTelemetry prepends the snake_cased meter name as the outermost namespace, ahead of the interface prefix" + ); + } + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithLegacyNaming_LowercasesWithoutWordBoundaries( + CancellationToken cancellationToken + ) + { + // Arrange + // Interface-level, because the test options already declare the assembly-level [TelemetryGeneration]. + const string source = $""" + namespace Testing; + + [TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("ProductTelemetryCore.Metric.g.cs")) + .ContainsGeneratedCode("\"product.recordcount\"") + .Because("the legacy convention lowercases the name and the interface prefix without splitting words"); + } + + [Test] + public async Task Generate_GivenOpenTelemetryMeterNamesAndSeveralInstruments_PrefixesEachInstrumentOnce( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + [assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + + namespace Testing; + + [Meter("orders")] + interface IOrderTelemetry + { + [AutoCounter] + void Placed(); + + [AutoCounter] + void Shipped(); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("OrderTelemetryCore.Metric.g.cs"); + await Assert.That(generated).ContainsGeneratedCode("\"orders.order.placed\""); + await Assert + .That(generated) + .ContainsGeneratedCode("\"orders.order.shipped\"") + .Because("the meter name must not accumulate across the interface's instruments"); + } + [Test] public async Task Generate_GivenInterfaceMeterNameOverridesAssemblyDefault(CancellationToken cancellationToken) { From 9131a5c1c8d7f20753a9fde83f449e23e6ec84c7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:18:00 +0000 Subject: [PATCH 14/25] fix: ignore static interface members when generating and analysing The generator implemented, and the analyzer validated, every method on a telemetry interface, static ones included. A static helper was emitted as an instance method on the generated class, and a static overload of a telemetry method was reported as a duplicate name (TSG1003). Static members are not part of what a class implements. Interfaces could declare them on .NET Core 3.0+ and .NET 7+ already, and C# 15 (.NET 11) allows non-virtual static members on older runtimes too, so they are now skipped everywhere the interface's methods are enumerated. --- .../Helpers/PipelineHelpers.cs | 17 +++- .../Records/TelemetryRules.Logging.cs | 5 +- .../SourceGenerator/Records/TelemetryRules.cs | 10 +-- ...ceGeneratorTests.StaticInterfaceMembers.cs | 89 +++++++++++++++++++ 4 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.cs index 25da9266..72e6c742 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator.Helpers; @@ -41,12 +41,25 @@ internal static string GenerateParameterName(string name, string? prefix, bool l internal static bool GetSkipOnNullOrEmptyValue(TagOrBaggageAttributeRecord? tagOrBaggageAttribute) => tagOrBaggageAttribute?.SkipOnNullOrEmpty == true; + /// + /// The interface's instance methods: the members a generated class implements. + /// + /// + /// Static members are not part of the contract a class implements, so they are skipped. An interface could + /// already declare them on .NET Core 3.0+ (static helpers) and .NET 7+ (static abstract/static + /// virtual); C# 15 lets a .NET Framework or netstandard2.0 interface declare non-virtual static members too, + /// so a static helper on a telemetry interface is now possible everywhere the generator runs. + /// internal static IEnumerable GetAllInterfaceMethods( INamedTypeSymbol interfaceSymbol, CancellationToken token ) { token.ThrowIfCancellationRequested(); - return interfaceSymbol.GetMembers().OfType(); + return GetInstanceMethods(interfaceSymbol); } + + /// + internal static IEnumerable GetInstanceMethods(INamedTypeSymbol interfaceSymbol) => + interfaceSymbol.GetMembers().OfType().Where(static method => !method.IsStatic); } diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs index a0115bcf..58ff3330 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs @@ -29,9 +29,8 @@ CancellationToken token var interfaceGenerationMode = loggerAttribute?.GenerationModeOrNull ?? loggerGenerationAttribute?.GenerationModeOrNull ?? 0; - var methods = interfaceSymbol - .GetMembers() - .OfType() + var methods = PipelineHelpers + .GetInstanceMethods(interfaceSymbol) .Where(m => !TypeHelpers.HasAttribute(m, TypeLibrary.Purview.Telemetry.ExcludeAttribute) && m.Arity == 0); foreach (var method in methods) diff --git a/src/src/SourceGenerator/Records/TelemetryRules.cs b/src/src/SourceGenerator/Records/TelemetryRules.cs index 9b3fc045..5750d948 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.cs @@ -175,11 +175,8 @@ CancellationToken token // Gather methods grouped by name (for TSG1003 and per-method checks). Dictionary> methodsByName = new(StringComparer.Ordinal); - foreach (var member in interfaceSymbol.GetMembers()) + foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { - if (member is not IMethodSymbol method) - continue; - if (!methodsByName.TryGetValue(method.Name, out var list)) { list = []; @@ -416,11 +413,8 @@ CancellationToken token } Dictionary> methodsByName = new(StringComparer.Ordinal); - foreach (var member in interfaceSymbol.GetMembers()) + foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { - if (member is not IMethodSymbol method) - continue; - if (!methodsByName.TryGetValue(method.Name, out var list)) { list = []; diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs new file mode 100644 index 00000000..5481493c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs @@ -0,0 +1,89 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator; + +/// +/// Static interface members are not part of what a class implements. An interface could already declare them on +/// .NET Core 3.0+ and .NET 7+ (static abstract); C# 15 lets one declare non-virtual static members on any +/// runtime, .NET Framework included, so a helper on a telemetry interface must be left alone by the generator and the +/// analyzer. The .NET Framework leg is skipped because the C# version these compilations use predates that. +/// +partial class TelemetrySourceGeneratorTests +{ + [Test] + [SkipOnNetFramework] + public async Task Generate_GivenAStaticInterfaceMember_ImplementsOnlyTheInstanceMethods( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + [Logger] + [Meter("testing-meter")] + public interface ITestTelemetry + { + [Activity] + [Info] + [AutoCounter] + Activity? Work([Tag] int id); + + static string Describe(int id) => "work " + id; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var implementation = generationResult.Generated().GetClass("TestTelemetryCore", "Testing"); + await Assert.That(implementation.HasMethod("Work")).IsTrue(); + await Assert + .That(implementation.HasMethod("Describe")) + .IsFalse() + .Because("a static interface member has nothing for the class to implement"); + await Assert + .That(generationResult) + .DoesNotHaveDiagnostic("TSG1001") + .Because("a static helper is not a method the generator must infer a target for"); + } + + [Test] + [SkipOnNetFramework] + public async Task Generate_GivenAStaticOverloadOfATelemetryMethod_DoesNotReportDuplicateNames( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + + static Activity? Work(ITestActivities telemetry, string id) => telemetry.Work(int.Parse(id)); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult) + .DoesNotHaveDiagnostic("TSG1003") + .Because("only instance methods share a generated implementation, so a static overload is no duplicate"); + await Assert + .That(generationResult.Generated().GetClass("TestActivitiesCore", "Testing").HasMethod("Work")) + .IsTrue(); + } +} From d0f76b185f5f7e5687a5c704e808586f4ac9b1d8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:22:29 +0000 Subject: [PATCH 15/25] feat(activities): create sources from the .NET 11 ActivitySourceFactory on opt-in .NET 11 adds System.Diagnostics.ActivitySourceFactory, registered by services.AddTracing(), the tracing counterpart of IMeterFactory. [assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)] now makes each generated class take an ActivitySourceFactory constructor parameter and create its source with Create(new ActivitySourceOptions(name)) instead of a static ActivitySource. The constructor is emitted by whichever target owns it (Logging, then Metrics, else Activities itself), so the decision lives on TelemetryCapabilities. It is gated on the compilation resolving the type, not on the TFM, because the System.Diagnostics.DiagnosticSource 11 package brings it to net462 and netstandard2.0. When the option is set but the type is missing, the static source is kept and the analyzer reports TSG3023. The option is opt-in because it changes the generated constructor. The integration tests gain a net11.0 leg, where the factory tests run; the fallback tests run on the earlier legs. In the packed-consumer gate every consumer assembly opts in (the library does not) and calls AddTracing(); the Native AOT console and Blazor Server host assert the orders source was created by the factory and the library's was not, and the Blazor WebAssembly checks require InitializeActivitySource to survive trimming and be AOT-compiled. The consumers' Microsoft .Extensions.* packages move to the .NET 11 version they target, for AddTracing(). RS2002 joins RS2003 in NoWarn: release tracking cannot follow the analyzer's descriptors, so an unshipped rule is reported as unsupported for the same reason the shipped ones were. --- Directory.Packages.props | 4 +- .../packed-consumers/AotConsole/Program.cs | 3 + .../packed-consumers/BlazorServer/Program.cs | 3 + .../packed-consumers/BlazorWasm/Program.cs | 3 + .../packed-consumers/Shared/Expectations.cs | 10 + .../Shared/IOrderTelemetry.cs | 6 + .../Shared/TelemetryCapture.cs | 10 +- scripts/test-packed-consumers.ps1 | 17 +- .../AnalyzerReleases.Unshipped.md | 1 + .../Analyzers/TelemetryDiagnosticAnalyzer.cs | 7 +- ...ActivitySourceTargetClassEmitter.Fields.cs | 82 +++++++ .../Emitters/ConstructorEmitter.cs | 31 +++ .../Emitters/GeneratedTypesEmitter.cs | 10 + ...TargetClassEmitter.InitializationMethod.cs | 31 ++- .../Generators/TelemetrySourceGenerator.cs | 3 +- .../Helpers/DiagnosticLibrary.Activities.cs | 12 + .../Helpers/PropertyLibrary.cs | 2 + .../Helpers/SharedHelpers.Activities.cs | 24 ++ .../TypeLibraryGenerator.Activities.cs | 11 + .../Records/AttributeDataModels.cs | 3 +- .../Records/TelemetryCapabilities.cs | 14 +- .../Records/TelemetryRules.Activities.cs | 15 ++ .../SourceGenerator/Records/TelemetryRules.cs | 1 + .../SourceGenerator/SourceGenerator.csproj | 4 +- ...atorActivityTests.ActivitySourceFactory.cs | 232 ++++++++++++++++++ ...vitySourceFactoryAvailabilityAttributes.cs | 40 +++ .../SourceGenerator.IntegrationTests.csproj | 6 +- 27 files changed, 561 insertions(+), 24 deletions(-) create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 23095707..8d8af845 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,7 +55,9 @@ No project in the repository references these: scripts/test-packed-consumers.ps1 reads them and hands them to the consumers in scripts/packed-consumers/, which build outside the repository with central package management off. They live here so the versions are bumped with everything else. The consumers target net11.0, - so these follow the .NET 11 SDK that global.json selects, and must share one version. + so these follow the .NET 11 SDK that global.json selects, and must share one version. The consumers' + Microsoft.Extensions.* packages (DependencyInjection, Diagnostics - for services.AddTracing() - and Logging) use + the same version. --> diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs index 35fd9965..a9a429af 100644 --- a/scripts/packed-consumers/AotConsole/Program.cs +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -19,6 +19,9 @@ ServiceCollection services = new(); services.AddLogging(logging => logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture)); services.AddMetrics(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +services.AddTracing(); services.AddOrderTelemetry(); services.AddInventory(); services.AddSingleton(); diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs index fade6da0..167292c7 100644 --- a/scripts/packed-consumers/BlazorServer/Program.cs +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -20,6 +20,9 @@ builder.Logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +builder.Services.AddTracing(); builder.Services.AddOrderTelemetry(); builder.Services.AddInventory(); builder.Services.AddSingleton(); diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs index 5d948aa7..27bf497d 100644 --- a/scripts/packed-consumers/BlazorWasm/Program.cs +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -10,6 +10,9 @@ // The generated registrations: this assembly's AddOrderTelemetry, and the library's (through AddInventory). builder.Services.AddMetrics(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +builder.Services.AddTracing(); builder.Services.AddOrderTelemetry(); builder.Services.AddInventory(); builder.Services.AddSingleton(); diff --git a/scripts/packed-consumers/Shared/Expectations.cs b/scripts/packed-consumers/Shared/Expectations.cs index 88be961e..e4ce60b8 100644 --- a/scripts/packed-consumers/Shared/Expectations.cs +++ b/scripts/packed-consumers/Shared/Expectations.cs @@ -31,6 +31,10 @@ void Check(bool condition, string failure) if (order.Length == 1) { var activity = order[0]; + Check( + activity.CreatedByFactory, + $"'{OrdersName}' was not created by the injected ActivitySourceFactory (UseActivitySourceFactory)" + ); Check( Get(activity.Tags, "order_id") == orderId, $"order.place tag order_id is '{Get(activity.Tags, "order_id")}', not '{orderId}'" @@ -73,10 +77,16 @@ void Check(bool condition, string failure) $"expected one '{InventoryName}/stock.reserve' activity (the library's), got {reserve.Length}" ); if (reserve.Length == 1) + { Check( Get(reserve[0].Tags, "sku") == OrderWorkflow.Sku, $"stock.reserve tag sku is '{Get(reserve[0].Tags, "sku")}'" ); + Check( + !reserve[0].CreatedByFactory, + $"'{InventoryName}' does not opt in to UseActivitySourceFactory, so it must stay a static ActivitySource" + ); + } // Metrics. Instrument names carry the prefix the generator derives from the interface name (IOrderTelemetry -> // "order", IInventoryTelemetry -> "inventory"); the meter name is only prepended with diff --git a/scripts/packed-consumers/Shared/IOrderTelemetry.cs b/scripts/packed-consumers/Shared/IOrderTelemetry.cs index b5bbdcda..6723c249 100644 --- a/scripts/packed-consumers/Shared/IOrderTelemetry.cs +++ b/scripts/packed-consumers/Shared/IOrderTelemetry.cs @@ -1,6 +1,12 @@ using System.Diagnostics; using Purview.Telemetry; +// .NET 11: every consumer assembly - and so IOrderTelemetry - creates its activity sources through the injected +// System.Diagnostics.ActivitySourceFactory, registered by services.AddTracing(). PackedTelemetry.Library does not opt +// in, so each application also runs a generated static ActivitySource, and the gate checks both shapes (trimmed, +// Native AOT and WebAssembly AOT). +[assembly: ActivitySourceGeneration("packed.orders", UseActivitySourceFactory = true)] + namespace PackedTelemetry; /// diff --git a/scripts/packed-consumers/Shared/TelemetryCapture.cs b/scripts/packed-consumers/Shared/TelemetryCapture.cs index 204c574f..a503a5ab 100644 --- a/scripts/packed-consumers/Shared/TelemetryCapture.cs +++ b/scripts/packed-consumers/Shared/TelemetryCapture.cs @@ -8,12 +8,17 @@ namespace PackedTelemetry; public sealed record CapturedEvent(string Name, IReadOnlyDictionary Tags); +/// +/// Whether the activity's source was created by an (.NET 11), which records itself +/// as the source's ; a static new ActivitySource(...) has no scope. +/// public sealed record CapturedActivity( string Source, string Name, IReadOnlyDictionary Tags, IReadOnlyDictionary Baggage, - IReadOnlyList Events + IReadOnlyList Events, + bool CreatedByFactory ); public sealed record CapturedMeasurement( @@ -68,7 +73,8 @@ public TelemetryCapture(string[] names, LogLevel minimumLevel = LogLevel.Informa activity.OperationName, ToDictionary(activity.TagObjects), ToDictionary(activity.Baggage), - [.. activity.Events.Select(static e => new CapturedEvent(e.Name, ToDictionary(e.Tags)))] + [.. activity.Events.Select(static e => new CapturedEvent(e.Name, ToDictionary(e.Tags)))], + activity.Source.Scope is ActivitySourceFactory ) ), }; diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index ad6fde7d..23d99eaa 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -30,8 +30,10 @@ in-process ActivityListener, MeterListener and a capturing logger provider it checks the multi-target method (an Activity, an Information log entry and a counter from one call), activity tags, baggage and events (including the OpenTelemetry exception event), every metric measurement and its tags, the v1 and v2 log - entries, log-level gating (a Debug entry must not be written when only Information is enabled) and the - TelemetryNames aggregation of both assemblies. It exits non-zero on any mismatch. + entries, log-level gating (a Debug entry must not be written when only Information is enabled), the + TelemetryNames aggregation of both assemblies, and that IOrderTelemetry's source was created by the .NET 11 + ActivitySourceFactory (UseActivitySourceFactory) while the library's stayed static. It exits non-zero on any + mismatch. (b) BlazorWasm - a Blazor WebAssembly app using the generated telemetry from a page and a service. Published trimmed with -warnaserror, then again with RunAOTCompilation=true. _framework must hold both assemblies; the assemblies ILLink wrote must still define every generated telemetry member (the library is IsTrimmable, so it @@ -109,13 +111,16 @@ function Get-VersionProperty([string] $name) { } $sgfVersion = Get-VersionProperty 'PurviewSGFVersion' -$extensionsVersion = Get-VersionProperty 'DotnetRuntimeVersion' $extensionsTelemetryVersion = Get-VersionProperty 'MSExtensionVersion' $aspNetCoreVersion = Get-PackageVersion 'Microsoft.AspNetCore.Components.WebAssembly' $testHostVersion = Get-PackageVersion 'Microsoft.AspNetCore.TestHost' if ($testHostVersion -ne $aspNetCoreVersion) { throw "Microsoft.AspNetCore.TestHost ($testHostVersion) and Microsoft.AspNetCore.Components.WebAssembly ($aspNetCoreVersion) must share a version in Directory.Packages.props." } +# The consumers target net11.0, so their Microsoft.Extensions.* packages are the .NET 11 ones, versioned with ASP.NET +# Core: services.AddTracing() - which registers the ActivitySourceFactory the generated code takes - is new in +# Microsoft.Extensions.Diagnostics 11. +$extensionsVersion = $aspNetCoreVersion $globalJson = Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json $sdk = $globalJson.sdk @@ -216,9 +221,11 @@ function Assert-Contains([string] $directory, [string] $pattern, [string] $what) # dependency injection. $generatedMembers = [ordered]@{ PackedTelemetryWasm = [ordered]@{ + # IOrderTelemetry opts in to the .NET 11 ActivitySourceFactory, so its source is created per instance by + # InitializeActivitySource; the library's InventoryTelemetryCore keeps the static source. 'PackedTelemetry.OrderTelemetryCore' = @( - '.ctor', 'InitializeMeters', 'PlacingOrder', 'OrderValidated', 'OrderTotal', 'OrdersInFlight', 'OrderShipped', - 'OrderFailed', '_activitySource', '_logger', '_meter' + '.ctor', 'InitializeMeters', 'InitializeActivitySource', 'PlacingOrder', 'OrderValidated', 'OrderTotal', + 'OrdersInFlight', 'OrderShipped', 'OrderFailed', '_activitySource', '_logger', '_meter' ) } 'PackedTelemetry.Library' = [ordered]@{ diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 7b845f48..e79915cb 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,3 +5,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +TSG3023 | Activity.Usage | Warning | [ActivitySourceFactory is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3023) diff --git a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs index cb01bece..3ebb69e1 100644 --- a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs @@ -76,7 +76,12 @@ static void AnalyzeNamedType(SymbolAnalysisContext context) var result = PipelineHelpers.BuildActivityTarget(interfaceSymbol, compilation, token); if (result.HasValue && result.Value is { } activityTarget) { - var diagnostics = TelemetryRules.GetActivityDiagnostics(activityTarget, interfaceSymbol, token); + var diagnostics = TelemetryRules.GetActivityDiagnostics( + activityTarget, + interfaceSymbol, + compilation, + token + ); foreach (var diagnostic in diagnostics) context.ReportDiagnostic(diagnostic.ToDiagnostic()); } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs index 456037a1..8b96e252 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs @@ -19,6 +19,12 @@ static void EmitFields(ActivityOutputContext output, CodeWriter writer, SourcePr activitySourceName = PropertyLibrary.Activities.DefaultActivitySourceName; } + if (output.Context.Capabilities.UseActivitySourceFactory) + { + EmitFactoryCreatedActivitySource(output, writer, activitySourceName!); + return; + } + writer.Field( new( PropertyLibrary.Activities.ActivitySourceFieldName, @@ -32,4 +38,80 @@ static void EmitFields(ActivityOutputContext output, CodeWriter writer, SourcePr } ); } + + /// + /// .NET 11: the source is created per instance by the injected ActivitySourceFactory, which owns it, so + /// the generated class never disposes it. The constructor calls the initialisation method before any generated + /// method can run. For an Activities-only interface this emitter writes that constructor; otherwise the target + /// that owns the constructor (Logging, then Metrics) adds the factory parameter and the call. + /// + static void EmitFactoryCreatedActivitySource( + ActivityOutputContext output, + CodeWriter writer, + string activitySourceName + ) + { + writer.Field( + new( + PropertyLibrary.Activities.ActivitySourceFieldName, + TypeLibrary.System.Diagnostics.ActivitySource.AsTypeReference() + ) + { + Initializer = "default!", + } + ); + + if (output.Target.GenerationType == GenerationType.Activities) + { + writer.NewLine(); + using ( + writer.ConstructorScope( + new ConstructorDeclarationOptions( + output.Target.ClassNameToGenerate, + TypeDeclarationAccessibility.Public + ) + { + Parameters = [ConstructorEmitter.ActivitySourceFactoryParameter()], + } + ) + ) + { + ConstructorEmitter.EmitActivitySourceInitializationCall(writer); + } + } + + var factoryParameter = PropertyLibrary.Activities.ActivitySourceFactoryParameterName; + + writer.NewLine(); + using ( + writer.MethodScope( + new MethodDeclarationOptions( + PropertyLibrary.Activities.ActivitySourceInitializationMethod, + TypeLibrary.System.Void.AsTypeReference(), + TypeDeclarationAccessibility.Private + ) + { + Parameters = [ConstructorEmitter.ActivitySourceFactoryParameter()], + } + ) + ) + { + writer.IfBlock( + factoryParameter + " == null", + body => body.Throw($"new global::System.ArgumentNullException(nameof({factoryParameter}))") + ); + + writer.NewLine(); + + writer + .Write(PropertyLibrary.Activities.ActivitySourceFieldName) + .Write(" = ") + .Write(factoryParameter) + .Write(".Create(new ") + .Write(TypeLibrary.System.Diagnostics.ActivitySourceOptions) + .Write("(") + .Write(activitySourceName.Wrap()) + .Line("));"); + } + } } diff --git a/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs b/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs index c1b1a993..52b86fba 100644 --- a/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs +++ b/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs @@ -63,9 +63,37 @@ GenerationContext generationContext ); } + if (NeedsActivitySourceFactory(generationType, generationContext)) + builder.Add(ActivitySourceFactoryParameter()); + return builder.ToImmutable(); } + /// + /// Whether the constructor receives an ActivitySourceFactory: the interface targets Activities and the + /// assembly opted in to (and can resolve) the .NET 11 factory. + /// + public static bool NeedsActivitySourceFactory( + GenerationType generationType, + GenerationContext generationContext + ) => generationType.HasFlag(GenerationType.Activities) && generationContext.Capabilities.UseActivitySourceFactory; + + /// The ActivitySourceFactory activitySourceFactory constructor parameter. + public static ParameterDeclarationOptions ActivitySourceFactoryParameter() => + new( + PropertyLibrary.Activities.ActivitySourceFactoryParameterName, + TypeLibrary.System.Diagnostics.ActivitySourceFactory + ); + + /// Writes the call that creates the activity source from the injected factory. + public static void EmitActivitySourceInitializationCall(CodeWriter writer) => + writer + .Write(PropertyLibrary.Activities.ActivitySourceInitializationMethod) + .Write('(') + .Write(PropertyLibrary.Activities.ActivitySourceFactoryParameterName) + .Write(");") + .NewLine(); + static void EmitBody( GenerationType generationType, CodeWriter writer, @@ -91,5 +119,8 @@ GenerationContext generationContext writer.Write(");").NewLine(); } + + if (NeedsActivitySourceFactory(generationType, generationContext)) + EmitActivitySourceInitializationCall(writer); } } diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index 8fa0b538..842b9ba1 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -775,6 +775,16 @@ static void WriteActivitySourceGenerationAttribute(CodeWriter writer, TypeIdenti "Determines whether diagnostics are generated for missing activity definitions.", "true" ); + WritePublicProperty( + body, + "UseActivitySourceFactory", + TypeLibrary.System.Boolean, + "Determines whether each generated activity source is created by the dependency-injected " + + "System.Diagnostics.ActivitySourceFactory (.NET 11, or System.Diagnostics.DiagnosticSource 11 " + + "and later) instead of as a static ActivitySource. The factory must be registered, for example " + + "with services.AddTracing(). Ignored, and reported as TSG3023, when the factory type is unavailable.", + "false" + ); }, "Specifies the default activity source generation behaviour for an assembly." ); diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs index 88bd3433..eb105b5e 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; @@ -57,29 +58,41 @@ static void EmitInlineConstructor(MeterOutputContext output, CodeWriter writer, { var target = output.Target; var supportsIMeterFactory = output.Context.Capabilities.SupportsIMeterFactory; + var needsActivitySourceFactory = ConstructorEmitter.NeedsActivitySourceFactory( + target.GenerationType, + output.Context + ); context.CancellationToken.ThrowIfCancellationRequested(); writer.NewLine(); + var parameters = ImmutableArray.CreateBuilder(); + if (supportsIMeterFactory) + parameters.Add( + new ParameterDeclarationOptions( + PropertyLibrary.Metrics.MeterFactoryParameterName, + TypeLibrary.System.Diagnostics.Metrics.IMeterFactory.AsTypeReference() + ) + ); + + // Activities + Metrics: Metrics owns the constructor, so it also receives the .NET 11 ActivitySourceFactory. + if (needsActivitySourceFactory) + parameters.Add(ConstructorEmitter.ActivitySourceFactoryParameter()); + using ( writer.ConstructorScope( new ConstructorDeclarationOptions(target.ClassNameToGenerate, TypeDeclarationAccessibility.Public) { - Parameters = supportsIMeterFactory - ? - [ - new ParameterDeclarationOptions( - PropertyLibrary.Metrics.MeterFactoryParameterName, - TypeLibrary.System.Diagnostics.Metrics.IMeterFactory.AsTypeReference() - ), - ] - : [], + Parameters = parameters.ToImmutable(), } ) ) { EmitInitializationBodyContent(output, writer); + + if (needsActivitySourceFactory) + ConstructorEmitter.EmitActivitySourceInitializationCall(writer); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs index 421c4b89..67177c12 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs @@ -95,7 +95,8 @@ CancellationToken token //SupportsNullableAnnotations: parseOptions is null //|| parseOptions.LanguageVersion >= LanguageVersion.CSharp8, SupportsIMeterFactory: parseOptions is null - || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER") + || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER"), + UseActivitySourceFactory: SharedHelpers.UseActivitySourceFactory(compilation, token) ); } } diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs index bb35475f..ace7ca3e 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs @@ -236,6 +236,18 @@ public static class Activities isBlocking: false ); + public static readonly ReportableDiagnostic ActivitySourceFactoryNotAvailable = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG3023", + title: "ActivitySourceFactory is not available", + messageFormat: $"UseActivitySourceFactory is set, but System.Diagnostics.ActivitySourceFactory cannot be resolved. It needs .NET 11, or System.Diagnostics.DiagnosticSource 11 or later. A static {TypeLibrary.System.Diagnostics.ActivitySource.Name} is generated instead.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Activity.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + // The trailing '{1}' placeholder carries an optional hint supplied by the rule (for example when // the 'Name' was applied to a logging attribute); rules pass string.Empty when there is no // additional context. diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index c50b8308..6d5318bb 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -53,6 +53,8 @@ public static class Activities public const string DefaultActivitySourceName = "purview"; public const int DefaultActivityKind = 0; public const string ActivitySourceFieldName = "_activitySource"; + public const string ActivitySourceInitializationMethod = "InitializeActivitySource"; + public const string ActivitySourceFactoryParameterName = "activitySourceFactory"; public const string ActivityVariableName = "activity"; public const string ParentIdParameterName = "parentId"; public const string StartTimeParameterName = "startTime"; diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs index 65fad1e2..b82e84b1 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs @@ -10,6 +10,30 @@ partial class SharedHelpers CancellationToken token ) => GetActivitySourceGenerationAttribute(compilation.Assembly, token); + /// + /// Whether the compilation can resolve System.Diagnostics.ActivitySourceFactory (.NET 11, or + /// System.Diagnostics.DiagnosticSource 11+ on any target). + /// + public static bool IsActivitySourceFactoryAvailable(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.System.Diagnostics.ActivitySourceFactory.MetadataFullName) + is not null + && compilation.GetTypeByMetadataName(TypeLibrary.System.Diagnostics.ActivitySourceOptions.MetadataFullName) + is not null; + + /// + /// Whether the assembly opted in with [ActivitySourceGeneration(UseActivitySourceFactory = true)]. + /// + public static bool IsActivitySourceFactoryRequested(Compilation compilation, CancellationToken token) => + GetActivitySourceGenerationAttribute(compilation, token)?.UseActivitySourceFactory == true; + + /// + /// Whether generated activity sources are created by an injected ActivitySourceFactory: requested and + /// available. A request the compilation cannot honour falls back to a static ActivitySource (and the analyzer + /// reports TSG3023). + /// + public static bool UseActivitySourceFactory(Compilation compilation, CancellationToken token) => + IsActivitySourceFactoryRequested(compilation, token) && IsActivitySourceFactoryAvailable(compilation); + public static ActivitySourceAttributeData? GetActivitySourceAttribute(ISymbol symbol, CancellationToken token) { if ( diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs index 91138ceb..17021dcc 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs @@ -11,6 +11,17 @@ partial class TypeLibraryGenerator [TypeRef(SystemDiagnosticsNamespace)] static readonly TypeIdentity ActivitySource = default; + /// + /// .NET 11 (System.Diagnostics.DiagnosticSource 11): the dependency-injected creator of activity sources, + /// the tracing counterpart of IMeterFactory. Emitted only when the compilation can resolve it. + /// + [TypeRef(SystemDiagnosticsNamespace)] + static readonly TypeIdentity ActivitySourceFactory = default; + + /// .NET 10 (System.Diagnostics.DiagnosticSource 10): the options an activity source is created from. + [TypeRef(SystemDiagnosticsNamespace)] + static readonly TypeIdentity ActivitySourceOptions = default; + [TypeRef(SystemDiagnosticsNamespace)] static readonly TypeIdentity ActivityEvent = default; diff --git a/src/src/SourceGenerator/Records/AttributeDataModels.cs b/src/src/SourceGenerator/Records/AttributeDataModels.cs index e8483c66..d8242081 100644 --- a/src/src/SourceGenerator/Records/AttributeDataModels.cs +++ b/src/src/SourceGenerator/Records/AttributeDataModels.cs @@ -27,7 +27,8 @@ readonly partial record struct ActivitySourceGenerationAttributeData( [Argument("lowercaseBaggageAndTagKeys")] [Property(DefaultValue = true)] bool LowercaseBaggageAndTagKeys, [Argument("generateDiagnosticsForMissingActivity")] [Property(DefaultValue = true)] - bool GenerateDiagnosticsForMissingActivity + bool GenerateDiagnosticsForMissingActivity, + [Property(DefaultValue = false)] bool UseActivitySourceFactory ); [Generate("Purview.Telemetry.ActivityAttribute")] diff --git a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs index 3b537260..a46d5c86 100644 --- a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs +++ b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs @@ -8,7 +8,19 @@ namespace Purview.Telemetry.SourceGenerator.Records; /// internalizes in the shipped analyzer (seen by PSGFR41); the BuildSdk grants the test /// assemblies access to internals. /// +/// +/// Whether meters are created through an injected IMeterFactory (.NET 8+; never .NET Framework). +/// +/// +/// Whether activity sources are created through an injected System.Diagnostics.ActivitySourceFactory: the +/// assembly opted in with [ActivitySourceGeneration(UseActivitySourceFactory = true)] and the +/// compilation can resolve the type (.NET 11, or System.Diagnostics.DiagnosticSource 11+). Gated on the symbol rather +/// than the target framework because the package brings the type to net462 and netstandard2.0 consumers too. It lives +/// here, rather than on the activity target, because the constructor that receives the factory is emitted by +/// whichever target owns the constructor. +/// sealed record TelemetryCapabilities( //bool SupportsNullableAnnotations, - bool SupportsIMeterFactory + bool SupportsIMeterFactory, + bool UseActivitySourceFactory = false ) : IGenerationCapabilities; diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index 9ac29c5b..37ad6025 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -14,11 +14,26 @@ static partial class TelemetryRules public static ImmutableArray GetActivityDiagnostics( ActivitySourceTarget target, INamedTypeSymbol interfaceSymbol, + Compilation compilation, CancellationToken token ) { var diagnostics = ImmutableArray.CreateBuilder(); + // TSG3023: the factory was requested but the compilation cannot resolve it, so the generator falls + // back to a static ActivitySource. + if ( + target.ActivitySourceGenerationAttribute?.UseActivitySourceFactory == true + && !SharedHelpers.IsActivitySourceFactoryAvailable(compilation) + ) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Activities.ActivitySourceFactoryNotAvailable.Descriptor, + isBlocking: false, + interfaceSymbol + ) + ); + // TSG3001: no activity source specified. if (string.IsNullOrWhiteSpace(target.ActivitySourceName)) diagnostics.Add( diff --git a/src/src/SourceGenerator/Records/TelemetryRules.cs b/src/src/SourceGenerator/Records/TelemetryRules.cs index 5750d948..8fe31e97 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.cs @@ -57,6 +57,7 @@ public static ImmutableArray GetAllSupportedDescriptors() DiagnosticLibrary.Activities.StatusDescriptionParameterInvalidType.Descriptor, DiagnosticLibrary.Activities.ExceptionEventNotStandardName.Descriptor, DiagnosticLibrary.Activities.ActivityReturnTypeShouldBeNullable.Descriptor, + DiagnosticLibrary.Activities.ActivitySourceFactoryNotAvailable.Descriptor, DiagnosticLibrary.Metrics.NoInstrumentDefined.Descriptor, DiagnosticLibrary.Metrics.DoesNotReturnVoid.Descriptor, DiagnosticLibrary.Metrics.AutoIncrementCountAndMeasurementParam.Descriptor, diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 46f17ef6..0634e1ab 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -14,8 +14,10 @@ analyzer exposes them through TelemetryRules.GetAllSupportedDescriptors(), which the release-tracking analyzer cannot follow, so every shipped rule is reported as unsupported. The rules are still tracked as shipped in AnalyzerReleases.Shipped.md. + RS2002 is the same check for AnalyzerReleases.Unshipped.md, so it is disabled for the same reason: + an unshipped rule (TSG3023) is reported as unsupported until it moves to the shipped file. --> - $(NoWarn);IDE0005;EnableGenerateDocumentationFile;RS2003; + $(NoWarn);IDE0005;EnableGenerateDocumentationFile;RS2002;RS2003; Purview Telemetry Source Generator .NET Source Generator for interface-based telemetry generating tracing, logs, and metrics. logs;log;logger;logging;source-generator;high-performance-logging;otel;open-telemetry;telemetry;traces;tracing;metric;metrics;meter;meters;instrumentation;instruments;events;distributed-traces;distributed-tracing;melt;dotnet;aspnet; diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs new file mode 100644 index 00000000..98d3d7af --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs @@ -0,0 +1,232 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Activities; + +/// +/// .NET 11's System.Diagnostics.ActivitySourceFactory, opted into with +/// [assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]. The factory path only runs on the +/// net11.0 leg, where the test compilation can resolve the type; the fallback only on the earlier legs. +/// +partial class TelemetrySourceGeneratorActivityTests +{ + const string FactoryOptIn = """ + [assembly: ActivitySourceGeneration("testing-activity-source", UseActivitySourceFactory = true)] + """; + + /// The initialisation method every factory-created source gets, as a code snapshot. + const string InitializeActivitySourceSnapshot = """ + private void InitializeActivitySource(global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + if (activitySourceFactory == null) + { + throw new global::System.ArgumentNullException(nameof(activitySourceFactory)); + } + + _activitySource = activitySourceFactory.Create(new global::System.Diagnostics.ActivitySourceOptions("testing-activity-source")); + } + """; + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryOnAnActivityOnlyInterface_CreatesTheSourceFromTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode("private global::System.Diagnostics.ActivitySource _activitySource = default!;") + .Because("a factory-created source is per instance, not static"); + await Assert + .That(generated) + .ContainsGeneratedCode( + """ + public TestActivitiesCore(global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + InitializeActivitySource(activitySourceFactory); + } + """ + ) + .Because("an Activities-only interface has no other target to own the constructor"); + await Assert.That(generated).ContainsGeneratedCode(InitializeActivitySourceSnapshot); + await Assert.That(generated).DoesNotContain("static readonly global::System.Diagnostics.ActivitySource"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3023"); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithLogging_TheLoggerConstructorTakesTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + [Logger] + public interface ITestTelemetry + { + [Activity] + [Info] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("TestTelemetryCore.Logging.g.cs")) + .ContainsGeneratedCode( + """ + public TestTelemetryCore(global::Microsoft.Extensions.Logging.ILogger logger, global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + _logger = logger; + InitializeActivitySource(activitySourceFactory); + } + """ + ) + .Because("Logging owns the constructor of a multi-target interface, so it receives the factory"); + await Assert + .That(generationResult.GetSource("TestTelemetryCore.Activity.g.cs")) + .ContainsGeneratedCode(InitializeActivitySourceSnapshot); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithMetrics_TheMetricsConstructorTakesBothFactories( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + [Meter("testing-meter")] + public interface ITestTelemetry + { + [Activity] + [AutoCounter] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var metrics = generationResult.GetSource("TestTelemetryCore.Metric.g.cs"); + await Assert + .That(metrics) + .ContainsGeneratedCode( + "public TestTelemetryCore(global::System.Diagnostics.Metrics.IMeterFactory meterFactory, global::System.Diagnostics.ActivitySourceFactory activitySourceFactory)" + ) + .Because("Metrics owns the constructor when there is no Logging target"); + await Assert.That(metrics).ContainsGeneratedCode("InitializeActivitySource(activitySourceFactory);"); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenTheFactoryIsAvailableButNotRequested_KeepsTheStaticSource( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + [assembly: ActivitySourceGeneration("testing-activity-source")] + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource(\"testing-activity-source\");" + ) + .Because( + "the factory is opt-in: an existing consumer's constructor must not change when it moves to .NET 11" + ); + await Assert.That(generated).DoesNotContain("ActivitySourceFactory"); + } + + [Test] + [RequiresNoActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithoutTheType_FallsBackToAStaticSourceAndReportsTSG3023( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource(\"testing-activity-source\");" + ); + await Assert.That(generated).DoesNotContain("ActivitySourceFactory"); + await Assert.That(generationResult).HasDiagnostic("TSG3023"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs b/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs new file mode 100644 index 00000000..b57420e9 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; + +namespace Purview.Telemetry.SourceGenerator.Infra; + +/// +/// Whether the runtime the tests run on - and so the references the test compilations are built from - provides +/// System.Diagnostics.ActivitySourceFactory (.NET 11). The generator gates the factory on the same symbol. +/// +static class ActivitySourceFactoryAvailability +{ + public static bool IsAvailable { get; } = + typeof(ActivitySource).Assembly.GetType("System.Diagnostics.ActivitySourceFactory") is not null; +} + +/// +/// Runs a test only where ActivitySourceFactory exists: the net11.0 leg of the test project. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +sealed class RequiresActivitySourceFactoryAttribute : SkipAttribute +{ + public RequiresActivitySourceFactoryAttribute() + : base("System.Diagnostics.ActivitySourceFactory needs .NET 11; this leg runs an earlier runtime.") { } + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(!ActivitySourceFactoryAvailability.IsAvailable); +} + +/// +/// Runs a test only where ActivitySourceFactory does not exist: every leg before net11.0, which is where +/// the fallback (and TSG3023) apply. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +sealed class RequiresNoActivitySourceFactoryAttribute : SkipAttribute +{ + public RequiresNoActivitySourceFactoryAttribute() + : base("This test covers the fallback for runtimes without System.Diagnostics.ActivitySourceFactory.") { } + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(ActivitySourceFactoryAvailability.IsAvailable); +} diff --git a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj index 69a09101..8936a7df 100644 --- a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj +++ b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj @@ -4,8 +4,10 @@ - - net8.0;net9.0;net10.0; + + net8.0;net9.0;net10.0;net11.0; net48;$(TargetFrameworks) From 8af99a686dce8beae11ab4a4893909522e63f360 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:26:01 +0000 Subject: [PATCH 16/25] docs: document .NET 11 support, the platform matrix and what is not yet supported Adds docs/wiki/Platform-Support.md: what's new for .NET 11, the target-framework and hosting matrix (console, ASP.NET Core, Aspire, Native AOT, Blazor WebAssembly trimmed and AOT, Blazor Server, .NET Framework) with the gate that verifies each, the trim/AOT guarantees, the .NET 11 features with their minimums, and a "Not yet supported" table with primary sources. Activities documents UseActivitySourceFactory, Diagnostics documents TSG3023, Installation and Home cover .NET 11 and the shared-framework abstractions, and the README gains a "What's new for .NET 11" section. Its usage sample now compiles, and its Agent Skills section names the real install command. AGENTS.md records the net11.0 test leg and the rule to gate new APIs on the compilation. The quickstart skill pinned 4.5.0 and named skills that do not exist, and the design skill claimed a meter-name prefix by default. All three are corrected, and the diagnostics skill lists TSG3023. --- AGENTS.md | 15 ++- README.md | 51 +++++++-- docs/wiki/Activities.md | 29 +++++ docs/wiki/Diagnostics.md | 8 ++ docs/wiki/Home.md | 8 +- docs/wiki/Installation.md | 9 +- docs/wiki/Platform-Support.md | 102 ++++++++++++++++++ docs/wiki/_Sidebar.md | 1 + skills/purview-telemetry-design/SKILL.md | 4 +- skills/purview-telemetry-diagnostics/SKILL.md | 1 + skills/purview-telemetry-quickstart/SKILL.md | 37 +++++-- 11 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 docs/wiki/Platform-Support.md diff --git a/AGENTS.md b/AGENTS.md index 0c63429b..d44ca629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ code to generated telemetry interfaces) in a companion assembly. ## Prerequisites -- .NET 10 SDK (projects target `net10.0`; the source generator is a Roslyn component targeting - `netstandard2.0`) +- The .NET 11 SDK pinned in `global.json` (currently an RC, `rollForward: latestMajor`), plus the .NET 8, 9 and + 10 runtimes for the test legs. Projects target `net10.0` by default; the source generator is a Roslyn component + targeting `netstandard2.0`. Consumers need Visual Studio 2026 / the .NET 10 SDK or later (Roslyn 5.9.0+). - [Bun](https://bun.sh) for the `package.json`/`.build/*.ts` scripts - The `Purview.BuildSdk` MSBuild SDK (pinned in `global.json` under `msbuild-sdks`) - `csharpier` dotnet tool (pinned in `.config/dotnet-tools.json`) for linting/formatting @@ -136,6 +137,7 @@ skills/ # Authored skills shipped by this pa scripts/ # release-gate scripts (see "Quality gates"), legacy setup-release.* └── packed-consumers/ # Native AOT, Blazor WebAssembly and Blazor Server consumers of the .nupkg docs/release-process.md # Release process documentation +docs/wiki/ # User documentation (Platform-Support.md: TFM/hosting matrix, .NET 11) ``` ## Build and validation workflow @@ -182,7 +184,14 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel ### Test project details -- Integration tests target `net8.0;net9.0;net10.0`, plus `net48` on Windows only. +- Integration tests target `net8.0;net9.0;net10.0;net11.0`, plus `net48` on Windows only. The `net11.0` leg is the + only one whose references carry `System.Diagnostics.ActivitySourceFactory`, so the `UseActivitySourceFactory` + tests (`[RequiresActivitySourceFactory]`) run there and the fallback tests (`[RequiresNoActivitySourceFactory]`, + `TSG3023`) run on the earlier legs. +- Gate new emitted APIs on the compilation (`Compilation.GetTypeByMetadataName`, surfaced through + `TelemetryCapabilities`), not on the target framework: the System.Diagnostics.DiagnosticSource package brings .NET 11 + types to `net462`/`netstandard2.0`. The supported matrix and the list of .NET 11 features that are not generated + yet live in [`docs/wiki/Platform-Support.md`](docs/wiki/Platform-Support.md); update it with any such change. - Tests use `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit + the framework's `CodeQuery` syntax-lookup API and assertion extensions). There is no Verify/snapshot library; refactoring tests use the framework's `CodeRefactoringTestBase` snapshot approach. diff --git a/README.md b/README.md index a6430766..7e39bc88 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,26 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system **Consumer runtime targets:** - .NET Framework 4.8 -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 + +**Hosting models:** console, ASP.NET Core, .NET Aspire, Native AOT, Blazor WebAssembly (trimmed and AOT-compiled) +and Blazor Server. The generated code uses no reflection and adds no trim or AOT warnings, and no compiler-host +assembly reaches your application's output. See +[Platform Support](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) for the full matrix. **Build toolchain requirement:** -- Visual Studio 2026 (18.x) or .NET 10 SDK (Roslyn 5.9.0+) +- Visual Studio 2026 (18.x) or .NET 10 SDK or later (Roslyn 5.9.0+) + +## What's new for .NET 11 + +- **`ActivitySourceFactory`** - opt in with + `[assembly: ActivitySourceGeneration("MyApp", UseActivitySourceFactory = true)]` and register the factory with + `services.AddTracing()`. The generated classes then create their sources from the injected .NET 11 factory instead + of a static `ActivitySource`. When the type isn't available, `TSG3023` reports it and the static source is kept. +- **C# 15 static interface members** are ignored rather than implemented. +- **Validated on .NET 11**: the tests run on `net11.0`, and Native AOT, Blazor WebAssembly and Blazor Server + consumers of the package are built on every release. ## Installation @@ -93,17 +108,25 @@ services.AddEntityStoreTelemetry(); Then inject and use - a single method call emits an Activity, Log, and Metric simultaneously: ```csharp -public class EntityService(IEntityStoreTelemetry telemetry) +public sealed record Entity(int Id, float TotalValue, int LastUpdatedByUserId, int SizeInBytes); + +public interface IEntityRepository +{ + Task GetAsync(int id, CancellationToken cancellationToken); +} + +public class EntityService(IEntityStoreTelemetry telemetry, IEntityRepository repository) { - public async Task GetEntityAsync(int id, string serviceUrl, CancellationToken cancellationToken) + public async Task GetEntityAsync(int id, string serviceUrl, CancellationToken cancellationToken) { // Single call creates Activity AND logs AND increments counter using var activity = telemetry.GettingEntityFromStore(id, serviceUrl); - - var entity = await _repository.GetAsync(id, cancellationToken); - + + var stopwatch = Stopwatch.StartNew(); + var entity = await repository.GetAsync(id, cancellationToken); + // Adds event to activity AND logs duration - telemetry.GetDuration(activity, stopwatch.ElapsedMilliseconds); + telemetry.GetDuration(activity, (int)stopwatch.ElapsedMilliseconds); if (entity == null) { @@ -114,7 +137,7 @@ public class EntityService(IEntityStoreTelemetry telemetry) // Activity context addition telemetry.RetrievedEntity(activity, entity.TotalValue, entity.LastUpdatedByUserId); - + // Histogram only records size telemetry.RecordEntitySize(entity.SizeInBytes); @@ -150,10 +173,18 @@ public class EntityService(IEntityStoreTelemetry telemetry) - [Generated Output Examples](https://purview.dev/docs/telemetry-sourcegenerator/generated-output/) - [Multi-Targeting Guide](https://purview.dev/docs/telemetry-sourcegenerator/multi-targeting/) - [Logging Configuration](https://purview.dev/docs/telemetry-sourcegenerator/logging/) +- [Platform Support and .NET 11](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) ## Agent Skills -This repository ships with [Agent Skills](https://agentskills.io/specification) under `.agents/skills/` covering the source generator, its test framework, and the `Purview.BuildSdk` build SDK. See [`AGENTS.md`](AGENTS.md) — "Source-generator and testing skills" for the full list and when to load each one. +This repository publishes [Agent Skills](https://agentskills.io/specification) for using the generator - quick +start, interface design, migration, multi-targeting and diagnostics. Install them with: + +```bash +npx skills add purview-dev/telemetry-sourcegenerator +``` + +See [`AGENTS.md`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/AGENTS.md) for the list. ## Sample Project diff --git a/docs/wiki/Activities.md b/docs/wiki/Activities.md index 5fb84ed6..dadec38a 100644 --- a/docs/wiki/Activities.md +++ b/docs/wiki/Activities.md @@ -15,6 +15,34 @@ In v5 the ActivitySource name defaults to the **assembly name with casing preser > [!NOTE] > `purview` is only used as a fallback when no assembly name is available, in which case the `TSG3001` diagnostic is generated. +## Creating sources with `ActivitySourceFactory` (.NET 11) + +By default each generated class owns a `static readonly ActivitySource`. .NET 11 adds +[`ActivitySourceFactory`](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration), +the tracing counterpart of `IMeterFactory`: registered by `services.AddTracing()` (Microsoft.Extensions.Diagnostics +11), it creates *local* sources that the container owns and that `AddTracing` rules can enable or disable. Opt in at +the assembly level: + +```csharp +[assembly: ActivitySourceGeneration("MyApp.Orders", UseActivitySourceFactory = true)] +``` + +The generated class then takes an `ActivitySourceFactory` constructor parameter (after `ILogger` and +`IMeterFactory`, when the interface also targets logging or metrics) and creates its source with +`activitySourceFactory.Create(new ActivitySourceOptions(name))`. Register the factory before resolving the telemetry: + +```csharp +builder.Services.AddTracing(); +builder.Services.AddOrderTelemetry(); +``` + +- The factory owns the source; the generated class never disposes it. +- The option is opt-in because it changes the generated constructor. Without it, nothing changes when you move to .NET 11. +- It is gated on the compilation, not the target framework: it needs `System.Diagnostics.ActivitySourceFactory`, which + ships in .NET 11 and in the System.Diagnostics.DiagnosticSource 11 package (net462, netstandard2.0, net10.0). When the + type cannot be resolved the generator keeps the static source and reports [`TSG3023`](Diagnostics.md#tsg3023). +- Listeners registered with `ActivitySource.AddActivityListener` (OpenTelemetry's included) still see factory-created sources. + ## Activity, Event, or Context There are three method types: @@ -121,6 +149,7 @@ Defines ActivitySource defaults at the assembly level. | `BaggageAndTagSeparator` | `string` | Separator used when generating prefixes. Default `.`. | | `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | | `GenerateDiagnosticsForMissingActivity` | `bool` | Whether diagnostics (`TSG3014`/`TSG3015`) are raised when Activity parameters or return values are missing. Default `true`. | +| `UseActivitySourceFactory` | `bool` | .NET 11: create each generated source from the injected `System.Diagnostics.ActivitySourceFactory` instead of a static `ActivitySource` (see below). Default `false`. | ### `[Baggage]` diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 3e4e92f4..c25ed958 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -67,6 +67,7 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG3017` | Error | Status Description parameters are only valid on Events, not Activity or Context methods. | | `TSG3021` | Info | Exception event does not use OpenTelemetry standard name. An `[Event]` method records an exception under the OpenTelemetry exception rules but the event name is not the standard `"exception"` (suggest `[Event(Name = "exception")]`). Not raised when `UseRecordExceptionRules` is disabled, for `[Baggage]` exceptions, or when the exception parameter is excluded from the Activities target. | | `TSG3022` | Warning | Activity return type should be nullable. An Activity method returns non-nullable `Activity`; use `Activity?` because the Activity can be null when no listeners are active. | +| `TSG3023` | Warning | ActivitySourceFactory is not available. `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or System.Diagnostics.DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. | ## Metrics diagnostics (TSG4xxx) @@ -135,6 +136,13 @@ The diagnostic is not raised when the exception is not recorded using the OpenTe Return `Activity?` so callers can handle the `null` case when no listeners are active. +### TSG3023 — ActivitySourceFactory is not available + +`[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` asks for sources created by +`System.Diagnostics.ActivitySourceFactory`, but the compilation cannot resolve that type, so a static `ActivitySource` +is generated instead. Target `net11.0`, or reference System.Diagnostics.DiagnosticSource 11 or later; or remove the +option. See [Creating sources with ActivitySourceFactory](Activities.md#creating-sources-with-activitysourcefactory-net-11). + ### TSG4002 — auto-increment counter with a measurement `[AutoCounter]` (or `[Counter(AutoIncrement = true)]`) must not declare a measurement parameter — the measurement is fixed to 1. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a816e041..9bf55de2 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -12,10 +12,13 @@ This approach allows for: ## Supported frameworks -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 - .NET Framework 4.8 or higher -**Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+). +**Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). + +Native AOT, trimmed and AOT-compiled Blazor WebAssembly, and Blazor Server are supported. See +[Platform Support and .NET 11](Platform-Support.md) for the full matrix, the trim/AOT guarantees and what's new for .NET 11. ## Documentation @@ -23,6 +26,7 @@ This approach allows for: - [Getting Started](Getting-Started.md) — install, first interface, and common patterns - [Installation](Installation.md) — supported frameworks, per-feature dependencies, and build configuration +- [Platform Support and .NET 11](Platform-Support.md) — support matrix, trim/AOT guarantees, .NET 11 features - [Sample Application](Sample-Application.md) — full .NET Aspire demo application ### Core features diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index b5c2f4fd..905f9a45 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -7,13 +7,13 @@ This page covers supported frameworks, installation methods, per-feature depende **Consumer runtime targets:** - .NET Framework 4.8 -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 **Build toolchain requirement:** -- Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+) +- Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+) -The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. +The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. See [Platform Support and .NET 11](Platform-Support.md) for the hosting models (Native AOT, Blazor WebAssembly, Blazor Server, Aspire) and the trim/AOT guarantees. ## Install the package @@ -45,9 +45,10 @@ Install-Package Purview.Telemetry.SourceGenerator | Telemetry type | Required reference | Notes | | --- | --- | --- | | Activities | `System.Diagnostics.DiagnosticSource` | Included in the SDK for .NET 5+ | -| Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved | +| Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved. Part of the shared framework on .NET 11, so no reference is needed there (an explicit one reports `NU1510`) | | Logging (v2) | `Microsoft.Extensions.Telemetry.Abstractions` | Required for state-based v2 generation (`LoggerMessageHelper`, `[LogProperties]`) | | Metrics | `System.Diagnostics.Metrics` | Included in the SDK for .NET 8+ | +| Activities from `ActivitySourceFactory` (opt-in) | `Microsoft.Extensions.Diagnostics` 11 (`services.AddTracing()`) | Only with `UseActivitySourceFactory`; see [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11) | ## Verifying the install diff --git a/docs/wiki/Platform-Support.md b/docs/wiki/Platform-Support.md new file mode 100644 index 00000000..9f33ff7d --- /dev/null +++ b/docs/wiki/Platform-Support.md @@ -0,0 +1,102 @@ +# Platform Support and .NET 11 + +This page lists the target frameworks and hosting models the generator supports, what the generated code guarantees +under trimming and Native AOT, and what .NET 11 changes. Every "Verified by" entry names a gate that runs on each +release; see [Release Flow](Release-Flow.md#release-gates). + +## What's new for .NET 11 + +- **`ActivitySourceFactory` (opt-in).** `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` + makes the generated classes create their sources from the injected .NET 11 factory, registered with + `services.AddTracing()`, instead of a static `ActivitySource`. See + [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11). `TSG3023` reports the option when + the factory type is unavailable. +- **Static interface members are ignored.** C# 15 lets an interface declare non-virtual static members on any runtime. + The generator and analyzer now skip static members everywhere, so a static helper on a telemetry interface is no + longer implemented as an instance method or reported as a duplicate name (`TSG1003`). +- **Validated on .NET 11.** The integration tests run on a `net11.0` leg. The packed-consumer gate builds `net11.0` + Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor Server consumers of the `.nupkg`. +- **No reference to the logging abstractions is needed on `net11.0`.** .NET 11 ships + `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Diagnostics.Abstractions` and + `Microsoft.Extensions.DependencyInjection.Abstractions` in the shared framework, and NuGet reports `NU1510` for an + explicit reference. + ([breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/11/extensions-in-shared-framework)) + +## Support matrix + +### Target frameworks + +| Consumer target | Activities | Logging v1 | Logging v2 | Metrics | Meters from `IMeterFactory` | `UseActivitySourceFactory` | Verified by | +| --- | --- | --- | --- | --- | --- | --- | --- | +| .NET Framework 4.8 (`net48`+) | Yes | Yes | Yes¹ | Yes | No (`new Meter(...)`) | With System.Diagnostics.DiagnosticSource 11+ | Integration tests (`net48` leg, Windows), `SampleApp.Net48` | +| .NET 8, .NET 9 | Yes | Yes | Yes¹ | Yes | Yes | Not supported² | Integration tests (`net8.0`, `net9.0` legs) | +| .NET 10 | Yes | Yes | Yes¹ | Yes | Yes | With System.Diagnostics.DiagnosticSource 11+ | Integration tests, `SampleApp`, `SampleApp.MultiProject` | +| .NET 11 | Yes | Yes | Yes¹ | Yes | Yes | Yes | Integration tests (`net11.0` leg), packed-consumer gate | +| `netstandard2.0` and other TFMs | Not supported: reported as [`TSG1011`](Diagnostics.md) (unsupported target framework). Multi-target the library to `net48`/`net8.0`+ instead. | | | | | | - | + +The `UseActivitySourceFactory` entries for `net48` and `net10.0` follow from the package's assets and are not run by a gate; only `net11.0` is. + +¹ Logging v2 needs `Microsoft.Extensions.Telemetry.Abstractions` (`LoggerMessageHelper`); without it the generator +uses v1. See [Logging Generation v2](Logging-Generation-v2.md). + +² The System.Diagnostics.DiagnosticSource 11 package has no `net8.0`/`net9.0` asset. Those targets would get its +`netstandard2.0` asset, and the package's build targets warn that the framework is unsupported. + +**Build toolchain:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). The generator itself is a +`netstandard2.0` Roslyn component. This repository builds and validates with the .NET 11 SDK pinned in `global.json`. + +### Hosting and render models + +| Model | Status | Verified by | +| --- | --- | --- | +| Console / worker / library | Supported | `SampleApp.MultiProject` (`net10.0`), packed-consumer Native AOT console (`net11.0`) | +| ASP.NET Core | Supported | `SampleApp` API service (`net10.0`), packed-consumer Blazor Server host (`net11.0`) | +| .NET Aspire | Supported. Pass `TelemetryNames.ActivitySourceNames`/`MeterNames` to your service defaults. | `SampleApp` AppHost (`net10.0`) | +| Native AOT | Supported, with no trim or AOT warning from the generated code | Packed-consumer gate (a): published with `-warnaserror`, then run, and the emitted activities, metrics and logs asserted | +| Blazor WebAssembly, trimmed | Supported | Packed-consumer gate (b): published trimmed with `-warnaserror`; every generated member must survive ILLink | +| Blazor WebAssembly, AOT (`RunAOTCompilation`) | Supported | Packed-consumer gate (b): every generated method must be AOT-compiled to WebAssembly | +| Blazor Server (interactive server) | Supported | Packed-consumer gate (c): page rendered on a `TestServer`, telemetry asserted | +| .NET Framework 4.8 applications | Supported | `SampleApp.Net48`, `net48` integration tests | + +The packed-consumer gate consumes the `.nupkg` exactly as a user does, and each of its applications also references a +class library with its own generated telemetry. On every run it exercises both a factory-created (`UseActivitySourceFactory`) +source and a static one. + +## Trim and AOT guarantees + +- **No reflection, no dynamic code.** The generated classes call `ActivitySource`, `ILogger` and `Meter` APIs directly, + with no reflection, `Expression` compilation or runtime code generation. A library built with `IsAotCompatible=true` + and warnings as errors builds clean, and so does a Native AOT publish with `-warnaserror` (IL2xxx/IL3xxx). +- **Nothing from the compiler host reaches your output.** The generator, its refactorings and the IL-merged + Purview.SourceGeneratorFramework ship under `analyzers/` only. They never appear in an application's `bin`, + `deps.json`, publish folder or Blazor `_framework` folder. Pack validation and the compiler-host leak scan enforce this. +- **Dependency injection.** The generated `Add{Interface}()` extension registers the implementation with + `AddSingleton()`, which the DI container supports under trimming and Native AOT. +- **Blazor WebAssembly metrics.** On .NET 11 the Blazor WebAssembly SDK turns `MetricsSupport` on by default + (`BlazorWebAssemblyDiagnosticsEnabled=true`, in `Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets`). If you set + `BlazorWebAssemblyDiagnosticsEnabled` or `MetricsSupport` to `false`, the trimmer removes metrics support. The + generated instruments still compile, but record nothing. Earlier SDKs default `MetricsSupport` to `false` for + WebAssembly, so set it to `true` there. + +## .NET 11 features + +| Feature | Generator support | Minimum | Source | +| --- | --- | --- | --- | +| `ActivitySourceFactory`, `services.AddTracing()` | Implemented: `UseActivitySourceFactory`, diagnostic `TSG3023` | `net11.0`, or System.Diagnostics.DiagnosticSource 11 + Microsoft.Extensions.Diagnostics 11 | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration) | +| C# 15 non-virtual static interface members on older runtimes | Implemented: static members are skipped | C# 15 (.NET 11 SDK) | [What's new in C# 15](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15) | +| Logging and diagnostics abstractions in the shared framework | Documented: no reference is needed on `net11.0` | `net11.0` | [Breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/11/extensions-in-shared-framework) | +| Blazor WebAssembly diagnostics on by default | Documented above | .NET 11 SDK | `Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets` in the .NET 11 SDK | + +## Not yet supported + +| Feature | Why it is not supported yet | Source | +| --- | --- | --- | +| Generic logging methods (the .NET 11 `[LoggerMessage]` generator supports them) | Generic interface methods still report `TSG1005`. Supporting them needs generic implementations across all three targets, not only logging. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#generic-source-generated-logging-methods) | +| Parity with the new `[LoggerMessage]` diagnostics for `params`/`scoped` and `ref struct` parameters (SYSLIB1028, SYSLIB1029) | No equivalent `TSG` rule exists yet. Such parameters are not rejected up front. | [`DiagnosticDescriptors.cs` (release/11.0)](https://github.com/dotnet/runtime/blob/release/11.0/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/DiagnosticDescriptors.cs) | +| C# 15 union types as tag, baggage or log values | A union is a struct implementing `IUnion`. It is recorded as the boxed struct rather than its case value. Unwrapping it needs a design for each target. | [What's new in C# 15](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15) | +| `ActivitySourceOptions` version, tags and `TelemetrySchemaUrl` for factory-created sources | Only the name is passed today, matching the static source. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | +| `AddTracing` rules from generated registrations | Rules are application configuration, so the generated `Add{Interface}()` does not call `AddTracing()`. Call it yourself. | [Distributed tracing configuration](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-configuration) | +| `ActivityTraceFlags.RandomTraceId` / `Activity.HasRandomizedTraceId` | Nothing to generate: the runtime sets the flag on the activities it creates, and generated code never rewrites trace flags. | [dotnet/runtime#124509](https://github.com/dotnet/runtime/issues/124509) | +| New `System.Diagnostics.Metrics` APIs | .NET 11 adds none. The earlier `Gauge` (.NET 9) and `InstrumentAdvice` histogram buckets (.NET 9) are not generated yet either. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | +| `Activity.AddException` (.NET 9) | Exception events are built with the OpenTelemetry `exception.*` tags directly, which works on every target, `net48` included. | [`Activity.AddException`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.addexception) | +| A `Microsoft.Extensions.Telemetry.Abstractions` 11 line | None is published yet. Logging v2 uses the 10.x package on every target. | [dotnet/extensions releases](https://github.com/dotnet/extensions/releases) | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index a38ff0c7..9f5302dd 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,6 +1,7 @@ - [Home](Home.md) - [Getting Started](Getting-Started.md) - [Installation](Installation.md) +- [Platform Support and .NET 11](Platform-Support.md) - [Generation](Generation.md) - [Activities](Activities.md) - [Logging](Logging.md) diff --git a/skills/purview-telemetry-design/SKILL.md b/skills/purview-telemetry-design/SKILL.md index c8df5f81..0b93e5f2 100644 --- a/skills/purview-telemetry-design/SKILL.md +++ b/skills/purview-telemetry-design/SKILL.md @@ -58,7 +58,9 @@ Default to the OpenTelemetry naming convention (`NamingConvention.OpenTelemetry` - ActivitySource names: preserve assembly casing (e.g., `MyApp`). - Activity names: descriptive, sentence-case-ish, no spaces: `placing-order`. - Log message templates: human-readable, with `{PascalCase}` placeholders. -- Metric instrument names: hierarchical, dot-separated, lowercase: `myapp.orders.placed`. +- Metric instrument names: dot-separated, snake_case words. The generator prefixes them with the interface name, not + the meter name: `IOrderTelemetry.OrdersPlaced` becomes `order.orders_placed`. Opt in to a meter-name prefix with + `MeterNameGenerationType.OpenTelemetry`. - Tag/baggage keys: `snake_case`. Only use `NamingConvention.Legacy` for exact v3 backward compatibility. diff --git a/skills/purview-telemetry-diagnostics/SKILL.md b/skills/purview-telemetry-diagnostics/SKILL.md index fec24d27..2ebf647d 100644 --- a/skills/purview-telemetry-diagnostics/SKILL.md +++ b/skills/purview-telemetry-diagnostics/SKILL.md @@ -83,6 +83,7 @@ changed diagnostic must be recorded there in the same change. | `TSG3017` | Error | Status-description parameters on an Activity or Context method — Events only. | | `TSG3021` | Info | An `[Event]` records an exception under the OpenTelemetry rules but is not named `"exception"`. | | `TSG3022` | Warning | An Activity method returns non-nullable `Activity`. | +| `TSG3023` | Warning | `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. Target `net11.0` or remove the option. | ## Metrics — `TSG4xxx` diff --git a/skills/purview-telemetry-quickstart/SKILL.md b/skills/purview-telemetry-quickstart/SKILL.md index 1743ffe3..f0957a1d 100644 --- a/skills/purview-telemetry-quickstart/SKILL.md +++ b/skills/purview-telemetry-quickstart/SKILL.md @@ -14,21 +14,30 @@ This skill helps you bootstrap telemetry generation in a C# project using `Purvi ## Installation -Add the analyzer package to your project: +Add the analyzer package to your project. `dotnet add package` writes the latest version; never copy a version +number from documentation, it goes stale: + +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +Then make the reference analyzer-only (keep the `Version` that `dotnet add package` wrote, or set it in +`Directory.Packages.props` under Central Package Management): ```xml - + all analyzers ``` -Also add runtime dependencies for the telemetry types you use: - -- `System.Diagnostics.DiagnosticSource` for Activities -- `Microsoft.Extensions.Logging.Abstractions` for ILogger -- `System.Diagnostics.Metrics` for metrics (built-in on .NET 6+) +Consumers can target .NET Framework 4.8 or .NET 8 and later, .NET 11 included, and the generated code works under +Native AOT and in trimmed or AOT-compiled Blazor WebAssembly. Runtime dependencies for the telemetry types you use: +- Activities: `System.Diagnostics.DiagnosticSource` (in-box on .NET). +- Logging: `Microsoft.Extensions.Logging.Abstractions`. It is in the shared framework on .NET 11, so do not reference + it there (NuGet reports `NU1510`). Add `Microsoft.Extensions.Telemetry.Abstractions` for the faster v2 logging. +- Metrics: `System.Diagnostics.Metrics` (in-box on .NET 8+). Register `services.AddMetrics()` for the `IMeterFactory`. ## Define a telemetry interface Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. @@ -105,6 +114,16 @@ The generator creates an extension method named `Add{InterfaceNameWithoutI}()`: services.AddOrderServiceTelemetry(); ``` +On .NET 11 you can opt in to creating activity sources from the injected `ActivitySourceFactory`. This changes the +generated constructor, so also register the factory: + +```csharp +[assembly: ActivitySourceGeneration("MyApp.Orders", UseActivitySourceFactory = true)] + +// in the host +services.AddTracing(); +``` + Inject the interface into your service: ```csharp @@ -136,5 +155,5 @@ Libraries need no configuration: they publish their names through the attribute, ## Next steps -- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use `telemetry-sourcegenerator-migration`. -- For design guidance and best practices, use `telemetry-sourcegenerator-design`. +- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use the `purview-telemetry-migration` skill. +- For design guidance and best practices, use the `purview-telemetry-design` skill. From 64e4e2014f88e3f0d886fa9dd5d1ae8992c1400f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 12:26:01 +0000 Subject: [PATCH 17/25] chore(release): 5.0.7-prerelease.2 Adds the opt-in .NET 11 ActivitySourceFactory generation (TSG3023 when unavailable), and fixes the MeterNameGenerationType.OpenTelemetry ordering, static interface members, the packaged README version, the PSGFR41 public-surface leak and the benchmark build. --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 798f9193..76991d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver 5.0.7; earlier releases are described by their [GitHub releases](https://github.com/purview-dev/telemetry-sourcegenerator/releases) and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). +## [5.0.7-prerelease.2] - 2026-10-28 + +.NET 11 support. Generated code is unchanged unless you opt in to the new option. The one +exception is the opt-in `MeterNameGenerationType.OpenTelemetry` naming, which was fixed (see below). + +### Added + +- **`ActivitySourceFactory` (.NET 11).** + `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` makes the generated + classes take a `System.Diagnostics.ActivitySourceFactory` constructor parameter and create their + source with `Create(new ActivitySourceOptions(name))`, instead of a static `ActivitySource`. + Register the factory with `services.AddTracing()`. The option is gated on the compilation resolving + the type (.NET 11, or System.Diagnostics.DiagnosticSource 11+). When the type is unavailable, + the static source is kept and the new warning **`TSG3023`** reports it. +- [Platform Support and .NET 11](docs/wiki/Platform-Support.md): the target-framework and hosting + matrix, the trim/AOT guarantees, the .NET 11 features and what is not supported yet. +- The integration tests run on a `net11.0` leg. +- The packed-consumer gate exercises `UseActivitySourceFactory` under Native AOT, Blazor + WebAssembly (trimmed and AOT) and Blazor Server, alongside a static source in the library. + +### Fixed + +- `MeterNameGenerationType.OpenTelemetry` placed the meter name between the interface prefix and + the instrument name (`product.my_app.products.record_count`). It is now the outermost namespace + (`my_app.products.product.record_count`), as documented. The default (`DotNet`) is unchanged. + **If you opted in, your instrument names change.** +- The README and wiki said OpenTelemetry naming prefixes instrument names with the meter name by + default. It never has; the docs now describe the interface-derived prefix + (`IProductTelemetry.RecordCount` becomes `product.record_count`), and tests pin the examples. +- Static interface members are ignored. They were implemented as instance methods and reported as + duplicates (`TSG1003`). This matters now because C# 15 allows them on every runtime. +- The packaged README no longer pins a package version. 5.0.6 shipped a README telling consumers to + install 5.0.5. `PackagedDocumentationTests` now fails on any version in a packaged document. +- `MessageTemplateHole` is internal. As a public type it exposed a Purview.SourceGeneratorFramework + type on the merged analyzer's surface (`PSGFR41`). `PurviewMergePublicSurfaceSeverity` is now + `error`, so such a leak fails the build. +- The benchmarks build again. They loaded a `Purview.SourceGeneratorFramework.Shared.dll` that no + framework version ships, and now use the merged generator. Their `net48` target compiles too. ## [5.0.7-prerelease.1] - 2026-10-28 No change to the generated code or to the package's contents: consumers of the package see the same @@ -56,4 +94,5 @@ generator. This release changes how the package is built and what has to pass be - A `packed-consumers` job in `pr.yml`, and `SampleApp.MultiProject` built and leak-scanned in the `samples` job. +[5.0.7-prerelease.2]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.7-prerelease.1...v5.0.7-prerelease.2 [5.0.7-prerelease.1]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.6...v5.0.7-prerelease.1 diff --git a/package.json b/package.json index 54836fe5..191b2f0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.0.7-prerelease.1", + "version": "5.0.7-prerelease.2", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", From 37438304bd64dade6081cab8e699422d36a79e77 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:38:20 +0000 Subject: [PATCH 18/25] fix(diagnostics): drop the non_nullable escape hatch from tsg1011 TSG1011 told consumers that defining PURVIEW_TELEMETRY_NON_NULLABLE suppresses it. Nothing ever implemented that. The message arrived with the net48 work in #91, while the framework check only read NET8_0_OR_GREATER and NET48_OR_GREATER, and IsUnsupportedTargetFramework kept that shape. The define did still do one thing. The marker attribute files used it, beside NET48_OR_GREATER, to omit the #nullable enable directive and declare plain string members. That opt-out goes with it, so the marker attributes now switch on NET48_OR_GREATER alone. A net8.0+ project that defined it gets the string? declarations every other modern project gets, which accept everything plain string did. The TSG1011 message now says nothing is generated for the target, and names net8.0+ and net48+ as the way out, with a conditional package reference for a multi-targeted library. The wiki, the diagnostics skill and the net48 sample README drop the define. UnsupportedTargetFrameworkTests pins the message and shows the define no longer suppresses the diagnostic. MarkerAttributeNullabilityTests pins the marker guard. --- docs/wiki/Diagnostics.md | 2 +- docs/wiki/Installation.md | 14 ++- samples/SampleApp.Net48/README.md | 13 +- skills/purview-telemetry-diagnostics/SKILL.md | 22 +++- .../Emitters/GeneratedTypesEmitter.cs | 15 +-- .../Helpers/DiagnosticLibrary.General.cs | 5 +- .../UnsupportedTargetFrameworkTests.cs | 114 ++++++++++++++++++ .../MarkerAttributeNullabilityTests.cs | 48 ++++++++ 8 files changed, 201 insertions(+), 32 deletions(-) create mode 100644 src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs create mode 100644 src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index c25ed958..a63f0927 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -26,7 +26,7 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG1007` | Warning | `ExcludeTargets` results in an empty or invalid parameter set for a target. | | `TSG1008` | Warning | Activity parameter has no Activity target. A parameter of type `Activity` is present on a method with no `[Activity]`/`[Event]`/`[Context]` attribute. | | `TSG1010` | Error | Method target not registered on interface. A method carries an attribute for a target family that is not registered on the interface. | -| `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+; define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | +| `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+ (for example `netstandard2.0`), so no telemetry is generated for it. There is no opt-out. Target `net8.0` or later, or `net48` or later; a multi-targeted library references the package only from its supported targets (a `Condition` on the `PackageReference`), with the telemetry interfaces under the same condition. See [Platform Support](Platform-Support.md). | ## Logging diagnostics (TSG2xxx) diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 905f9a45..552a5a8e 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -97,14 +97,18 @@ Define this constant to disable logging generation entirely. This is useful when ``` -### `PURVIEW_TELEMETRY_NON_NULLABLE` +### Unsupported target frameworks -Define this constant to opt out of the `TSG1011` unsupported-target-framework check for non-net8+/net48+ compilations: +A compilation that targets neither `net8.0`+ nor `net48`+ (for example `netstandard2.0`) reports +[`TSG1011`](Diagnostics.md#tsg1011) and generates nothing. No define suppresses it. A library that +keeps such a target references the package only from its supported targets, and puts its telemetry +interfaces under the same condition: ```xml - - $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE - + + all + analyzers + ``` ## Project-type snippets diff --git a/samples/SampleApp.Net48/README.md b/samples/SampleApp.Net48/README.md index 65f5204a..978b3a15 100644 --- a/samples/SampleApp.Net48/README.md +++ b/samples/SampleApp.Net48/README.md @@ -43,7 +43,7 @@ consuming project. Because `NET48_OR_GREATER` is automatically defined by the SD ```csharp // In the injected ErrorAttribute.g.cs: -#if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE +#if NET48_OR_GREATER public string MessageTemplate { get; set; } #else public string? MessageTemplate { get; set; } @@ -54,17 +54,6 @@ The attribute files are decorated with `[Conditional("PURVIEW_TELEMETRY_ATTRIBUT sample defines `PURVIEW_TELEMETRY_ATTRIBUTES` in `Directory.Build.props` to keep the attribute usages visible to the generator's incremental pipeline. -### Opt-out for any project - -Projects that prefer non-nullable attribute files regardless of target framework can define -`PURVIEW_TELEMETRY_NON_NULLABLE` in their project file: - -```xml - - $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE - -``` - ## Project structure ``` diff --git a/skills/purview-telemetry-diagnostics/SKILL.md b/skills/purview-telemetry-diagnostics/SKILL.md index 2ebf647d..b16d8735 100644 --- a/skills/purview-telemetry-diagnostics/SKILL.md +++ b/skills/purview-telemetry-diagnostics/SKILL.md @@ -25,8 +25,9 @@ diagnostics in four families: | `TSG4xxx` | metrics | Every rule is tracked in `AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` with its ID, -category and severity, validated by the Roslyn release-tracking analyzers on every build. A new or -changed diagnostic must be recorded there in the same change. +category and severity, validated by the Roslyn release-tracking analyzers on every build. A new +rule, or a changed category or severity, must be recorded there in the same change. The files do not +carry the message, so a reworded message is recorded in the CHANGELOG instead. ## General — `TSG1xxx` @@ -42,7 +43,7 @@ changed diagnostic must be recorded there in the same change. | `TSG1007` | Warning | `ExcludeTargets` leaves a target with an empty or invalid parameter set. | | `TSG1008` | Warning | An `Activity`-typed parameter on a method with no `[Activity]`/`[Event]`/`[Context]`. | | `TSG1010` | Error | A method carries an attribute for a target family the interface does not register. | -| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+. Define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | +| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+, so nothing is generated. There is no opt-out: target `net8.0`+ or `net48`+, and reference the package only from those targets of a multi-targeted library. | ## Logging — `TSG2xxx` @@ -157,6 +158,21 @@ measured the cost. `[AutoCounter]` — equivalently `[Counter(AutoIncrement = true)]` — must declare no measurement parameter. The measurement is fixed at 1. +### `TSG1011` — unsupported target framework + +Raised for a compilation that targets neither `net8.0`+ nor `net48`+ (typically `netstandard2.0`). +It cannot be suppressed by a define; no telemetry is generated for that target. A library that has +to keep such a target references the generator only from its supported ones: + +```xml + +``` + +The telemetry interfaces then need the same condition (or an `#if`), because the attributes are +generated only where the generator runs. + ### `TSG1003` — duplicate method names The method name becomes a member on the generated implementation class, so two methods cannot share diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index 842b9ba1..58aa3399 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -194,17 +194,14 @@ public static void EmitAll(IncrementalGeneratorPostInitializationContext context /// /// Writes the header for marker-attribute files. Nullable annotations are only enabled on modern /// targets — the injected templates declare plain string members for - /// NET48_OR_GREATER/PURVIEW_TELEMETRY_NON_NULLABLE consumers — and CS8625 is - /// suppressed because optional string parameters intentionally default to . + /// NET48_OR_GREATER consumers — and CS8625 is suppressed because optional string + /// parameters intentionally default to . /// static void WriteMarkerFileHeader(CodeWriter writer) { writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable); writer - .HashDefines( - "!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", - hashWriter => hashWriter.Line("#nullable enable") - ) + .HashDefines("!NET48_OR_GREATER", hashWriter => hashWriter.Line("#nullable enable")) .PragmaDisable("CS8625"); } @@ -383,8 +380,8 @@ static void WritePublicProperty( } /// - /// Writes a public nullable-capable string property inside the NET48_OR_GREATER/ - /// PURVIEW_TELEMETRY_NON_NULLABLE preprocessor guard used by the marker attributes. + /// Writes a public nullable-capable string property inside the NET48_OR_GREATER + /// preprocessor guard used by the marker attributes. /// static void WriteNullableStringProperty(CodeWriter writer, string name, string summary) { @@ -394,7 +391,7 @@ static void WriteNullableStringProperty(CodeWriter writer, string name, string s #endif writer.HashDefines( - "NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE", + "NET48_OR_GREATER", hashWriter => hashWriter .XmlSummary(summary) diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs index f596644d..147daf8c 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs @@ -142,8 +142,9 @@ public static class General id: "TSG1011", title: "Unsupported target framework", messageFormat: "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " - + "The current target framework is not supported. " - + "If this is incorrect, define PURVIEW_TELEMETRY_NON_NULLABLE to suppress this error and opt out of nullable reference type annotations.", + + "The current target framework is not supported, so no telemetry is generated for it. " + + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "(such as netstandard2.0) must reference the generator only from its supported targets.", defaultSeverity: DiagnosticSeverity.Error, category: Categories.Usage, isEnabledByDefault: true diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs new file mode 100644 index 00000000..b35eab2c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Analyzers; + +/// +/// TSG1011 is decided by the compilation's preprocessor symbols, which the framework's analyzer +/// harness does not expose, so these tests build the compilation themselves with the symbols the SDK +/// defines for each target framework. +/// +public class UnsupportedTargetFrameworkTests +{ + /// The exact TSG1011 message. It names the supported targets and offers no opt-out. + const string ExpectedMessage = + "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " + + "The current target framework is not supported, so no telemetry is generated for it. " + + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "(such as netstandard2.0) must reference the generator only from its supported targets."; + + const string TelemetryInterface = """ + + namespace Testing; + + [Purview.Telemetry.ActivitySource("testing")] + public interface ITestActivities + { + } + + """; + + [Test] + public async Task Analyze_GivenNetStandardCompilation_RaisesTSG1011WithSupportedTargetsMessage( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = CreateCompilation("NETSTANDARD", "NETSTANDARD2_0", "NETSTANDARD2_0_OR_GREATER"); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + var messages = diagnostics + .Where(static diagnostic => diagnostic.Id == "TSG1011") + .Select(static diagnostic => diagnostic.GetMessage(CultureInfo.InvariantCulture)) + .ToArray(); + + await Assert.That(messages.Length).IsEqualTo(1); + await Assert.That(messages[0]).IsEqualTo(ExpectedMessage); + } + + [Test] + public async Task Analyze_GivenNetStandardCompilationDefiningRemovedOptOut_StillRaisesTSG1011( + CancellationToken cancellationToken + ) + { + // Arrange: PURVIEW_TELEMETRY_NON_NULLABLE was documented as suppressing TSG1011, but never did. + var compilation = CreateCompilation("NETSTANDARD", "NETSTANDARD2_0", "PURVIEW_TELEMETRY_NON_NULLABLE"); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "TSG1011")).IsTrue(); + } + + [Test] + [Arguments("NET;NET8_0;NET8_0_OR_GREATER;NETCOREAPP")] + [Arguments("NETFRAMEWORK;NET48;NET48_OR_GREATER")] + public async Task Analyze_GivenSupportedTargetFramework_DoesNotRaiseTSG1011( + string preprocessorSymbols, + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = CreateCompilation(preprocessorSymbols.Split(';')); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "TSG1011")).IsFalse(); + } + + static CSharpCompilation CreateCompilation(params string[] preprocessorSymbols) + { + CSharpParseOptions parseOptions = new(LanguageVersion.Latest, preprocessorSymbols: preprocessorSymbols); + var references = AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) + .Select(static assembly => MetadataReference.CreateFromFile(assembly.Location)); + + return CSharpCompilation.Create( + "UnsupportedTargetFrameworkTests", + [ + CSharpSyntaxTree.ParseText(TelemetryTestAttributeSource.Attributes, parseOptions), + CSharpSyntaxTree.ParseText(TelemetryInterface, parseOptions), + ], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + static async Task> AnalyzeAsync( + CSharpCompilation compilation, + CancellationToken cancellationToken + ) => + await compilation + .WithAnalyzers([new TelemetryDiagnosticAnalyzer()], options: null) + .GetAnalyzerDiagnosticsAsync(cancellationToken) + .ConfigureAwait(false); +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs new file mode 100644 index 00000000..2833d83c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.Telemetry.SourceGenerator.Generators; + +/// +/// The marker attributes switch between string and string? on NET48_OR_GREATER +/// alone. They read the post-initialization output directly, because the generator test options +/// exclude the marker attributes from the generated-source queries. +/// +public class MarkerAttributeNullabilityTests +{ + [Test] + public async Task Generate_GivenAnyCompilation_GuardsMarkerNullabilityOnNet48Only( + CancellationToken cancellationToken + ) + { + // Arrange + const string hintName = "ActivityAttribute.g.cs"; + + // Act + var activityAttribute = GetMarkerSource(hintName, cancellationToken); + + // Assert + await Assert.That(activityAttribute).Contains("#if !NET48_OR_GREATER"); + await Assert.That(activityAttribute).Contains("#if NET48_OR_GREATER"); + await Assert.That(activityAttribute).DoesNotContain("PURVIEW_TELEMETRY_NON_NULLABLE"); + } + + static string GetMarkerSource(string hintName, CancellationToken cancellationToken) + { + var compilation = CSharpCompilation.Create( + "MarkerAttributeNullabilityTests", + [CSharpSyntaxTree.ParseText("namespace Testing;", cancellationToken: cancellationToken)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var runResult = CSharpGeneratorDriver + .Create(new TelemetrySourceGenerator()) + .RunGenerators(compilation, cancellationToken) + .GetRunResult(); + + return runResult + .Results.SelectMany(static result => result.GeneratedSources) + .Single(source => string.Equals(source.HintName, hintName, StringComparison.Ordinal)) + .SourceText.ToString(); + } +} From d237ecea114c3881d20b7692014ce6f7a49cad7b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:41:22 +0000 Subject: [PATCH 19/25] chore(skills): commit the installed skills under .agents only The skills installed from other repositories are kept in source again. .agents/skills/ and the skills CLI's skills-lock.json had been left untracked, and were then deleted from a working tree as if they were throwaway install output. Restored with the skills CLI, into the universal .agents/skills/ location and nothing else: npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y That is the six purview-sourcegenerator-* skills the tree had before, plus the eight purview-buildsdk-* skills AGENTS.md has always told contributors to install. /.claude/ is now gitignored, and src/Directory.Build.props sets PurviewSkillsAgents to universal, so the SDK's InstallPurviewSkills target no longer defaults to writing a copy for every agent. InstalledSkillTests checks that the lockfile and .agents/skills/ agree, and that no .claude/skills/ copy exists. --- .../skills/purview-buildsdk-adoption/SKILL.md | 114 +++++ .../purview-buildsdk-configuration/SKILL.md | 115 +++++ .../references/properties.md | 168 ++++++++ .../SKILL.md | 157 +++++++ .../references/blazor.md | 105 +++++ .../references/detection.md | 83 ++++ .../references/test-types.md | 35 ++ .../purview-buildsdk-project-layout/SKILL.md | 161 +++++++ .../SKILL.md | 111 +++++ .../SKILL.md | 166 +++++++ .../references/repository-root.md | 55 +++ .../purview-buildsdk-skill-tests/SKILL.md | 131 ++++++ .../references/authoring.md | 79 ++++ .../skills/purview-buildsdk-testing/SKILL.md | 121 ++++++ .../references/treenode-filter.md | 107 +++++ .../SKILL.md | 334 +++++++++++++++ .../references/legacy-emitter-migration.md | 58 +++ .../SKILL.md | 142 ++++++ .../SKILL.md | 245 +++++++++++ .../references/merge-internals.md | 139 ++++++ .../purview-sourcegenerator-pipeline/SKILL.md | 231 ++++++++++ .../purview-sourcegenerator-testing/SKILL.md | 404 ++++++++++++++++++ .../references/test-modernisation.md | 63 +++ .../SKILL.md | 230 ++++++++++ .gitignore | 4 + skills-lock.json | 89 ++++ src/Directory.Build.props | 5 + .../InstalledSkillTests.cs | 120 ++++++ 28 files changed, 3772 insertions(+) create mode 100644 .agents/skills/purview-buildsdk-adoption/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-configuration/references/properties.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/blazor.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/detection.md create mode 100644 .agents/skills/purview-buildsdk-project-detection/references/test-types.md create mode 100644 .agents/skills/purview-buildsdk-project-layout/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-repository-review/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-installation/references/repository-root.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-skill-tests/references/authoring.md create mode 100644 .agents/skills/purview-buildsdk-testing/SKILL.md create mode 100644 .agents/skills/purview-buildsdk-testing/references/treenode-filter.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md create mode 100644 .agents/skills/purview-sourcegenerator-diagnostics/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md create mode 100644 .agents/skills/purview-sourcegenerator-pipeline/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/SKILL.md create mode 100644 .agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md create mode 100644 .agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md create mode 100644 skills-lock.json create mode 100644 src/tests/Repository.UnitTests/InstalledSkillTests.cs diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 00000000..b1f222c5 --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 00000000..d82768b3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,115 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `IsPackable` | `false` | Opts a project into the packable defaults | +| `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | +| `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | +| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | + +The full set, grouped by area and with the behaviour each one changes, is in +[references/properties.md](references/properties.md). Read it rather than guessing a property name — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 00000000..1fb1f81b --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,168 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | +| `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | +| `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | +| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | +| `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | +| `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `EnableStaticNativeInstrumentation` | `false` for test projects | | +| `EnableDynamicNativeInstrumentation` | `false` for test projects | | +| `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages +instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. + +When the repository root is discoverable, the root `README.md` is packed automatically and +registered as `PackageReadmeFile`, unless one is configured already. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | +| `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | +| `DisableSourceLink` | `false` | | + +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 00000000..8573ca40 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,157 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Why Purview.BuildSdk did that + +Use this skill when the question is **why** the SDK applied something automatically. + +For where a project should live, use `purview-buildsdk-project-layout`. For the property that turns +a behaviour off, use `purview-buildsdk-configuration`. + +## Diagnose from computed properties, never from the folder name + +Read what the SDK actually computed: + +```bash +dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:RootNamespace \ + -getProperty:TestingType -getProperty:TargetProjectName -getProperty:IsTestProject +``` + +The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, +`PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, +`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. + +## Detection rules + +### Test projects + +Name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test`/`Tests`. +The captured word becomes `TestingType`. + +This is a pattern, **not** a lookup. `MyProject.GoldenTests` is detected exactly as readily as +`MyProject.UnitTests`, with `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a +name the SDK ships. + +**The dot is required.** A project named plainly `UnitTests` does not match, and silently gets no +runner, no coverage, no `InternalsVisibleTo` and no category. This is the single most common +surprise. + +Derived: `IsTestProject=true`, `TestingType=`, `PurviewTestType=Tests`, +`TargetProjectName=`. + +The SDK's explicit `TestType` item group governs **`InternalsVisibleTo` generation**, not detection: +a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name in the group. A +suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. +The 53 names are in [references/test-types.md](references/test-types.md). + +### Shared and shared-testing projects + +Recognised by **exact name**, never by substring. The two lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`. + +### SDK and content based + +| Signal | Result | +| -- | -- | +| the project's `Sdk="..."` declaration | `IsSdkProject`, `SdkProjectName` | +| `Microsoft.NET.Sdk.Web` | `IsWebSdkProject=true` | +| `Microsoft.NET.Sdk.Worker` | `IsWorkerSdkProject=true` | +| SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | +| `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | +| name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). + +## Namespace and identity + +1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the + project name already starts with the prefix's tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments + such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where + stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact + stays distinct from its parent. Test and shared-testing projects keep their detected suffix. + Explicit `AssemblyName`/`PackageId`/`RootNamespace` always win. +6. The naming defaults are applied during `Sdk.props`, before the Microsoft SDK computes + `TargetName`, so the compiled output name always matches `AssemblyName`. + +## Automatic packages and references + +- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages + (unless `ExcludePurviewTelemetry`), `GenerateDocumentationFile=true`, and `InternalsVisibleTo` + generation (unless `DisableAutoInternalsVisibleTo`). +- **Test and shared-testing projects**: test-friendly `NoWarn` defaults, not packable or + publishable, and the packages selected by `TestingFramework`, `SubstituteFramework` and + `TestDataFramework`. TUnit test projects get Microsoft.Testing.Platform wiring automatically. + Shared-testing projects skip the runnable test package. +- **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, + Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). + +Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. + +## The shipped analyzers + +The SDK adds `Purview.BuildSdk.Analyzers` as an `` item to every C# project, so its rules +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | + +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, +`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, +`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, +`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. + +Two Visual Studio refactorings ship with them, both described in +[references/detection.md](references/detection.md). + +## When changing a detection rule + +1. Keep the `.csproj` filename equal to its directory name. +2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in + prose is this repository's recurring failure. +3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 00000000..c65d1532 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 00000000..85fde09c --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,83 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## Shared testing project names (exact) + +`SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers` + +These are matched whole, not as substrings. `SharedModels` is not a shared project. + +A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with +`IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +keep that package-driven shape. + +## Packable projects + +Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, +`IncludeSource`, `DebugType=portable`. + +- Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. +- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and + nothing else is already being packed as one. +- Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a + solution-wide `dotnet pack` skips them silently. +- `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never + forces SourceLink's CI-mode dirty-repository checks. + +## Roslyn components + +A project that explicitly declares `IsRoslynComponent=true` gets: + +- a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, + `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules +- SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry excluded +- compiler-generated output under the framework-specific intermediate directory +- `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers`, so they + never leak into the packed nuspec + +`IsRoslynComponentOnly` defaults to `true`: `IncludeBuildOutput=false`, `IncludeSymbols=false`, no +`.symbols.nupkg` or `.snupkg`, and the analyzer PDB shipped inside the main `.nupkg` under +`analyzers/dotnet/cs/` beside the assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a +dual-role component that wants normal library symbol packaging. + +`ValidateRoslynComponentCompilerSettings` fails the pack of a packable Roslyn component missing +`LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules`. Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. + +`PackProjectReferencedSourceGenerators` (default `true`) makes a packable project include analyzer +`ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set it +`false` globally, or `Pack="false"` on one analyzer reference. + +## Special project types + +| Type | Applied | +| -- | -- | +| CLI | `OutputType=Exe`, `appsettings*.json` included as content | +| Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | +| Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + targeting several receiver types. It produces one `Extensions` class per receiver + (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes + `HostApplicationBuilderExtensions`) and places each under `Extensions//`. +- **Move extensions class to conventional location** — offered on a misplaced single-receiver + extensions class. It re-paths the file to `Extensions//`, fixes the namespace + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/test-types.md b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 00000000..dace44fc --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 00000000..6c19c3d4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,161 @@ +--- +name: purview-buildsdk-project-layout +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Project layout and naming for Purview.BuildSdk + +Use this skill when physically adding, moving, splitting or renaming a project. + +For *why* the SDK classified an existing project a particular way, use +`purview-buildsdk-project-detection`. For the property that changes a behaviour, use +`purview-buildsdk-configuration`. + +## First principle + +**Treat naming and placement as configuration, not decoration.** + +The SDK infers namespaces, assembly identity, test categories, package wiring and project references +from a small set of conventions. The closer a repository sits to them, the fewer overrides it needs. + +The corollary matters just as much: **preserve the host repository's existing layout first.** Only +introduce new structure when no established pattern exists. + +## Canonical layout + +```text +/ + package.json version source + .editorconfig bootstrapped by the SDK + skills/ authored agent skills, if any + src/ + .slnx + src//.csproj + tests/.Tests/.Tests.csproj +``` + +- The `.csproj` filename must equal its containing directory name, unless + `DisableProjectFileNamingConventionCheck=true`. +- Source projects under `src/`, test projects under `tests/`. +- Place a new project beside similar ones — same language, layer and test type. +- One test type per project by default. + +A repository that already uses a different shape keeps it; add to it rather than converting it. + +## Naming and identity + +`NamespacePrefix` is the root identity source. Keep project names short and let the SDK apply the +prefix: + +| `NamespacePrefix` | Project | `RootNamespace` | +| -- | -- | -- | +| `Aspire` | `Hosting` | `Aspire.Hosting` | +| `Acme.Sales.RegionalPipeline` | `Identity.API` | `Acme.Sales.RegionalPipeline.Identity.API` | +| `Acme.Sales.RegionalPipeline` | `Identity.Core` | `Acme.Sales.RegionalPipeline.Identity` | + +`AssemblyName` and `PackageId` follow the resolved `RootNamespace` — except where suffix-stripping +removed a segment (`Core`, `Shared`, `ServiceDefaults`), in which case they keep the fuller logical +name so the artifacts stay distinct from their parent. Explicit values always win, and +`EnableAssemblyNameGeneration=false` (before the SDK import) restores standard behaviour. + +Do not hand-author an alternate namespace scheme when one is already in use. + +## Test project naming + +Test detection is a **pattern**, not a list: `^.*\.([\w|_]\w*)Tests?$`. A target name, a dot, then +any word before `Test` or `Tests`. The captured word becomes `TestingType` and the baseline category. + +**The dot is required.** A project named plainly `UnitTests`, with no target in front of it, is not +detected as a test project at all — no runner, no coverage, no `InternalsVisibleTo`, no category. +Always name a test project `.Tests`. + +Prefer these by default: `UnitTests`, `IntegrationTests`, `E2ETests`, `FunctionalTests`, +`ContractTests`. Use another suffix only when the test type itself carries operational meaning. + +A suffix outside the SDK's shipped `TestType` group still builds, runs and gets its category — it +just cannot reach the target project's `internal` members, because that group drives +`InternalsVisibleTo` generation. The full list is in `purview-buildsdk-project-detection`. + +## Shared projects: exact names only + +The SDK recognises these names exactly — a near miss gets none of the behaviour. + +Shared: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. + +Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 00000000..ce1fd294 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.csproj` filename equals its containing directory name — unless + `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. +- Short project names, with `NamespacePrefix` carrying the repository identity. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 00000000..66c20a33 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends `npx skills update` + +`update` installs the *newest* published skill, which is only correct when the newest skill happens +to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in +a pinned `npx skills add` instead. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 00000000..e90b695c --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 00000000..4043ac4f --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,131 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**At the repository root, in `skills//SKILL.md`.** Always. + +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package +carries .NET artifacts, and skills are delivered by the CLI from git. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), +`SkillsFolderName` (default `skills`), and `SkipRule(string)`. + +The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather +than a real repository — the same three-layer shape this repository uses throughout: a reader, an +evaluator over an in-memory model, and a thin test base class. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 00000000..69d4ee45 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 00000000..5a5a7d53 --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: purview-buildsdk-testing +description: Write and run tests in a repository that uses Purview.BuildSdk. Use when authoring or refactoring TUnit tests (AAA structure, Subject_Scenario_Expectation naming, awaited assertions, CancellationToken), when choosing a test project's framework packages, and when running or filtering tests - the SDK wires Microsoft.Testing.Platform, so `dotnet test --filter` reports "Zero tests ran" and `--treenode-filter` is the one that works. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Testing in a Purview.BuildSdk repository + +Two things to know before anything else: + +1. A test project is detected by **name**, and detection is what wires the runner. See + `purview-buildsdk-project-layout` if the project is new or being moved. +2. The runner is **Microsoft.Testing.Platform (MTP)**, not VSTest. `--filter` does nothing useful. + +## What a test project gets automatically + +With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, +`TestDataFramework=Bogus`), a detected test project receives: + +- `TUnit`, `TUnit.Mocks`, `Bogus` +- Microsoft.Testing.Platform integration +- `CollectCoverage=true` +- test-friendly `NoWarn` defaults (`PurviewTestContextNoWarn`), while keeping the strict style + contract — `IDE0040`, field naming, formatting, `IDE1006` +- `IsPackable=false`, `IsPublishable=false` + +Add nothing else unless the project's purpose demands it. Two that stay explicit: + +- `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests +- `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers + (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, + Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 00000000..35d032bc --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 00000000..ee363c97 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -0,0 +1,334 @@ +--- +name: purview-sourcegenerator-codewriter +description: >- + Use when implementing, reviewing or refactoring C# source generators and analysers built on + Purview.SourceGeneratorFramework. Covers CodeWriter and XmlCommentWriter emission, scope safety, + incremental pipeline design, value equality, deterministic output, and migrating a legacy + string/StringBuilder emitter to CodeWriter. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +Refactoring an existing emitter off `string`/`StringBuilder`? The step-by-step playbook is in +[references/legacy-emitter-migration.md](references/legacy-emitter-migration.md). + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md new file mode 100644 index 00000000..54efd09f --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md @@ -0,0 +1,58 @@ +# Migrating a legacy emitter to CodeWriter + +A playbook for moving a source generator's emission from manual `string` / `StringBuilder` +construction to `CodeWriter` plus the `XmlCommentWriter` documentation extensions, with behaviour +parity. + +## Before you start, collect + +- The emitter file(s) being changed. +- The generator type name, and the version string it stamps into generated attributes or headers. +- Whether the output must stay **byte-identical**. If it must, every whitespace decision below is a + constraint rather than a preference, and the diff of the generated snapshots is the acceptance + test. + +## Requirements + +1. Use the structured declaration APIs rather than hand-written declaration text: + `Class` / `Struct` / `RecordClass` / `Interface` / `Enum`, and `Method`, `Property`, `Field`, + `Constructor`. +2. Use the XML helper extensions instead of composing `///` lines: `XmlSummary`, `XmlParam`, + `XmlReturn`, `XmlRemarks`, `XmlCode` / `XmlCodeBlock`. +3. Use `TypeReference` once the type text gets complex — nullability, generics, arrays. +4. Keep the writer **output-scoped**: `generationContext.CreateCodeWriter()` *inside* the output + callback. Never create it earlier in the pipeline, and never store it in incremental provider + state or in a custom context. +5. Preserve behaviour, diagnostics and generated names. +6. Keep the change minimal. Do not reformat logic the migration does not touch. + +## Strategy + +Work phase by phase, because each phase has a different mechanical replacement: + +| Emitter phase | Replace with | +| --- | --- | +| header | the header helper, not a literal banner string | +| namespace | the namespace scope API | +| type declarations | declaration option records, not signature strings | +| member declarations | `Method` / `Property` / `Field` / `Constructor` | +| indentation and braces | scoped APIs — never manual `{`/`}` and `\t` | +| `///` comment composition | `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` | + +Call out any intentional semantic delta explicitly; a migration that quietly changes emitted names or +diagnostics is the failure mode this playbook exists to avoid. + +## Verification + +- Run the impacted tests. +- Confirm the generated files still compile. +- Confirm there are **no scope leaks**: `OpenScopeCount == 0` when the writer is materialized. Enable + CodeWriter scope validation in the test run. +- Where byte-identical output was required, diff the generated snapshots. + +## Report + +1. Files changed. +2. Why each change was necessary. +3. Risks and behaviour differences, if any. +4. The verification that actually ran. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md new file mode 100644 index 00000000..21d9522c --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,142 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are +documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 00000000..92350db8 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,245 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and + content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what + lets Visual Studio load the dependent code-fix provider from its own `bin` without a + `FileNotFoundException` for the framework assembly. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 00000000..5d8b6c9e --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md @@ -0,0 +1,139 @@ +# The merge pass and the self-contained analyzer + +## Where the merged artifact lives + +`_PurviewMergeSourceGeneratorFramework` only ever writes to the component's **intermediate** output. +`GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged path into +`TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving `GetTargetPath` — +which resolves assembly references — pointing at the unmerged `bin`. That split is what keeps the +in-repo test harness working while shipped assemblies stay self-contained. + +The artifact is **content-addressed**: + +```text +$(IntermediateOutputPath)purview-merged//$(TargetFileName) +``` + +`` is a SHA-256 over the component, the framework assembly and the merge tool (the tool +computes it in its `--tag` mode). The tag is in the **directory**, never the file name, because +ILRepack derives the merged assembly's simple name from the output file name and an analyzer must +keep `.dll`. + +Two properties follow, and both matter: + +- A rebuild with identical inputs reuses the existing file and **skips the merge**, so + `_PurviewMergeSourceGeneratorFramework` is idempotent across the per-consumer — and potentially + parallel — invocations of `GetSourceGeneratorAnalyzerFiles`. +- Changed inputs select a **new directory**, so the merge never overwrites an assembly a compiler + host (csc, `VBCSCompiler`, Visual Studio) already has loaded. Overwriting such a file fails on + Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename. A concurrent invocation that loses the race simply observes the +published artifact rather than failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. + +## Framework type internalization + +ILRepack's `Internalize` is best-effort, and two gaps matter: + +- a framework type that reaches the merged component's public API surface stays public; and +- the types the framework's own generators emit **into** the component — the + `Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker + attributes — are not part of the merged framework assembly at all, so ILRepack never sees them. + +Either gap leaks framework types out of what must be a self-contained analyzer, and any project +loading that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every +leaked type. + +So the merge tool runs a deterministic internalization pass (`FrameworkTypeInternalizer`) after +ILRepack finishes: + +- Every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children, + including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated + through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type: + the framework's nested containers — generated type-library namespace classes, nested operator/enum + groups, `CodeWriter` scopes — are only reachable through their declaring type. +- Types the framework's generators emitted into the component, recognized by the tool name on their + `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`, + `AttributeDataModelGenerator`), become non-public too, so a generated type library can never leak a + `TypeIdentity`/`TypeReference` member as public API. +- `Microsoft.CodeAnalysis.EmbeddedAttribute`, the framework-emitted marker, becomes non-public. +- **Roslyn component entry points are never internalized.** A generator, analyzer, code fix provider + or refactoring provider reachable from the framework namespace stays public, because Roslyn only + instantiates public components (`PSGFR27`). This is what keeps the framework's own bundled + analyzers working after the pass. +- The pass reports the result. Leftover public framework types fail the merge with **exit code 5** + (`PSGFR43`). Public component members whose signature exposes a framework type are logged + (`PSGFR41`) so the author can make them, or their declaring type, non-public. Visibility is + followed through the declaring chain here too: a public nested type inside an internal type — for + example the compiler-synthesised `$`/`$` extension containers emitted for an extension class + — is not reachable from outside the component, so it is not part of the public surface and is not + reported. + +Ownership is seeded with the framework assembly's own type identities, so a framework type living +*outside* the framework namespace (`Microsoft.CodeAnalysis.*Extensions`, `System.StringExtensions`) +is internalized as well. The artifact's assembly-level `InternalsVisibleTo` / +`IgnoresAccessChecksTo` grants are removed, because a shipped analyzer is not the component's own +assembly. + +## Tuning the pass + +| Property | Default | Effect | +| --- | --- | --- | +| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. | +| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. | +| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none`. | +| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. | +| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; point it at a source or project path for IDE navigation. | + +All five participate in the content tag, so changing one re-runs the merge — and re-emits its +findings — rather than reusing a cached artifact. The compiler analyzer with the same id (`PSGFR41`) +reports the same condition while you edit, so the surface is usually fixed before the merge runs. + +## Generated accessibility: bin versus merged artifact + +The component's own generated types — the type library, the attribute data models — keep their +generated accessibility in the component's **bin output**. `TLB0015` requires a hand-written partial +to be `public static partial` so it can merge with the generated library, and in-repo consumers such +as code fixers and sibling assemblies compile against it. + +Only the **merged analyzer artifact** internalizes them. Self-containment is therefore enforced at +the merge boundary without rewriting the accessibility an in-repo consumer compiles against, and that +split is safe precisely because a merged artifact is never referenced as a compile-time dependency. + +> A merged component is an analyzer artifact. Never reference it as a compile-time dependency. Tests +> that need to run a *packaged* generator load it out of band. + +## How the framework package itself is assembled + +`Purview.SourceGeneratorFramework` is dual-role: the framework assembly ships in `lib/` so generator +projects compile against it, and `analyzers/` carries self-contained generator, analyzer and +code-fixer assemblies. The bundled projects are: + +- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; +- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; +- `SourceGeneratorFramework.CodeFixers` — the code fix providers. + +Each has the framework implementation merged and internalized into it. The package deliberately does +**not** put `Purview.SourceGeneratorFramework.dll` under `analyzers/dotnet/cs`, so generators built +against different framework versions never ask Roslyn to load competing versions of a same-named +dependency. + +The shared models and helpers that once shipped as a separate +`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly +(`SourceGeneratorShared.Link.targets` links the sources). Consumers get one +`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard a separately bundled +Shared assembly caused — generator packages loading a different Shared version in-process failed with +binary-incompatibility errors, such as removed `PurviewTypeLibrary` fields. + +Those projects are `IsRoslynComponent = true` and are **not** packable on their own. The +`SourceGeneratorFramework` project builds them via `GetPurviewMergedAnalyzerFile` and packs them +under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. They were previously +consumed as analyzer project references, but they now reference the framework assembly for the shared +types, which would form a project-reference cycle. diff --git a/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md new file mode 100644 index 00000000..373942fd --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md @@ -0,0 +1,231 @@ +--- +name: purview-sourcegenerator-pipeline +description: >- + Use when composing an incremental pipeline with Purview.SourceGeneratorFramework - + IncrementalPipeline helpers, GenerationContext and custom contexts, CreateCodeWriter lifetime, + GeneratorResult and blocking versus non-blocking diagnostics, disabling a generator from MSBuild, + scope validation, test logging, and stage tracking names. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Incremental pipeline composition + +`IncrementalPipeline` composes `IncrementalValueProvider` and `IncrementalValuesProvider`: +attribute-based discovery, generation-context creation, disable-property checks, and thin +source-output registration. + +Everything here follows from one rule: **pipeline values must be immutable and value-equatable.** +Roslyn caches the complete value a provider publishes, and offers no way to exclude one property of +it from caching. + +## `GenerationContext` + +A base execution-services context carrying the Roslyn `Compilation`, immutable `GenerationSettings`, +an optional `ISourceGenLogger`, and a factory for independently owned `CodeWriter` instances. + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); +``` + +### Custom contexts + +A custom context does not need to accept or read build properties itself: + +```csharp +public sealed class MyGenerationContext : GenerationContext +{ + public MyGenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) + : base(compilation, settings, logger) + { + } +} + +var contextProvider = IncrementalPipeline.GenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + factory: static (compilation, settings, logger, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new MyGenerationContext(compilation, settings, logger); + }, + disablePropertyName: "MyGenerator_Disable" +); +``` + +The provider resolves scope validation, generator disabling and test logging from analyzer-config +properties *before* invoking the factory. The logger is created internally only when logging is +enabled and a sink is registered for that run. + +## `CodeWriter` lifetime — the rule that bites + +Treat `GenerationContext` as **cached incremental state** and each `CodeWriter` as **mutable, +output-scoped execution state**. Create the writer inside the registered source-output callback, +after the incremental cache boundary: + +```csharp +IncrementalPipeline.RegisterSourceOutput( + context, + targets, + contextProvider, + static (spc, target, generationContext) => + { + var writer = generationContext.CreateCodeWriter(); + EmitTarget(generationContext, writer, target); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } +); +``` + +Creating it in the callback and passing it to emitter helpers called from that same callback is the +intended pattern. The only forbidden thing is **persisting the writer in pipeline state**. + +Why, concretely: + +- Roslyn caches the whole published value; you cannot exempt a field. +- `CodeWriter` is mutable, so a cached one retains previously written source when the context is + reused for another output or run. +- Source-output callbacks may process independent targets **concurrently**; a shared writer mixes + their output and races. +- A fresh writer gives each generated source independent scope tracking and deterministic ownership. + +`CreateCodeWriter()` returns a new, independently owned instance every call. + +**Never add a `CodeWriter` property or field to a class derived from `GenerationContext`.** A custom +context is still produced by an incremental provider and cached as one complete value. Store only +compilation-derived services and immutable configuration there. + +When an emitter needs both services and writing, pass the context and the writer separately, or +compose a short-lived wrapper created inside the callback — which must never be returned from a +provider: + +```csharp +public sealed class GenerationOutputContext : ISourceGenLogger + where TContext : GenerationContext +{ + public GenerationOutputContext(TContext generation) + { + Generation = generation; + Writer = generation.CreateCodeWriter(); + } + + public TContext Generation { get; } + + public CodeWriter Writer { get; } + + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Generation.Log(level, indentation, message, args); +} +``` + +## `GeneratorResult` and diagnostics that do not stop generation + +`RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs +the callback only for successful results: + +```csharp +var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + AttributeType, + static (ctx, ct) => + { + var symbol = ctx.TargetSymbol; + + return symbol is null + ? GeneratorResult.Empty + : GeneratorResult.Create(symbol.Name); + } +); +``` + +The callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value +**and** none of its diagnostics are blocking. + +`ReportableDiagnostic.IsBlocking` is an explicit per-diagnostic decision, **independent of +severity**. An `Error`-severity diagnostic can still allow generation to continue, which is what you +want when the generated code is what helps the developer fix the problem — a generator emitting an +abstract base class can report an error per missing override while still emitting the base class, so +the user can see exactly what to implement: + +```csharp +static readonly DiagnosticDescriptor MissingOverride = new( + "MYGEN001", + "Missing override", + "Type '{0}' must override '{1}'", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true +); + +// IsBlocking is false, so ShouldProcess stays true and the base class is still emitted. +var diagnostic = ReportableDiagnostic.Create(MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute"); + +return GeneratorResult.Create(model, diagnostic); +``` + +`isBlocking: true` stops generation for that target while still reporting. +`HasBlockingDiagnostics` reports whether any carried diagnostic blocked; +`HasErrorDiagnostics` reports the severity-based view. + +## Disabling a generator at build time + +Pass the compiler-visible disable property to the context provider; its resolved value lands in +`GenerationSettings` automatically: + +```xml + + true + +``` + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + disablePropertyName: "MyGenerator_Disable" +); + +// In the output stage: +if (generationContext.Settings.IsSourceGeneratorDisabled) + return; +``` + +`IsDisabledValueProvider` is still there for when expensive upstream transforms must be filtered +before being combined with the generation context. + +Declare the property with `PurviewGeneratorVisibleProperty` so it is compiler-visible wherever the +generator runs — `PSGF0003` otherwise fails the build. See the `purview-sourcegenerator-packaging` +skill. + +## Scope validation + +The default context provider reads +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` and threads it into +`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws +`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. + +## Test logging + +Framework logging is off in ordinary compiler runs. The testing integration enables it by registering +an isolated sink and supplying a per-run session ID through analyzer config. Context providers create +the internal logger automatically — generators implement no logging interface and no logging-support +source is generated. + +The sink registry stores **callbacks only**; it never buffers entries. With logging disabled, a +missing session ID, or no matching sink, the provider supplies no logger and log calls are discarded. +Test sinks own whatever they capture and are removed when the run completes. + +## Tracking names + +The pipeline helpers assign a tracking name to every stage, so a cache test can assert which stages +were recomputed. The `purview-sourcegenerator-testing` skill covers the stage matrix and the four +scenarios a cache test should assert. + +## Related skills + +`purview-sourcegenerator-codewriter` for emission, `purview-sourcegenerator-diagnostics` for the +`PSGFR` rules that enforce most of the above, and `purview-sourcegenerator-testing` for proving the +pipeline caches. diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md new file mode 100644 index 00000000..aaeff09e --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,404 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting + incremental pipeline caching stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 00000000..6db47803 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md @@ -0,0 +1,63 @@ +# Modernising a Roslyn test suite + +A playbook for moving an existing test suite onto `CodeQuery` plus the TUnit assertion extensions, +and adding a stage-by-stage incremental cache test. + +## Before you start, collect + +- The test file(s) being changed. +- The Roslyn component under test — generator, analyzer, code fix or refactor (infer it from the + base class if the task does not say). +- The component's type name. + +## Requirements + +### 1. The right base class and method + +| Component | Base class | Method | +| --- | --- | --- | +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync` | +| Analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync` | +| Code fix | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync` / `ApplyFixAllAsync` | +| Refactor | `TUnitRefactoringTestBase` | `RefactorAsync` | + +### 2. `CodeQuery` instead of string matching + +Replace `GetGeneratedTree(...)` + `string.Contains(...)` with `CodeQuery` +(`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions — +`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)` — which return the +node so a follow-up assertion has something to work with. + +### 3. `TypeReference` instead of signature strings + +Match parameter and return types structurally. A signature string breaks on a whitespace or +nullability change that means nothing. + +### 4. Options in one place + +Derive a `SourceGeneratorTestOptions` record that seeds the required namespaces and additional +assemblies, and remove the per-test duplication it replaces. + +### 5. A stage-by-stage cache test + +Use `RunIncrementalAsync`, or `GenerateIncrementalAsync` on the TUnit base, and assert the four +scenarios. `ServiceRegistrationCacheTests` and `IncrementalPipelineCacheTests` are the reference +pattern. + +| Scenario | Expected | +| --- | --- | +| First run | every framework stage `New` | +| Identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) | framework stages `Cached`/`Unchanged` | +| Source-only change | `ForAttribute_*` `Modified`; property/config stages stay `Cached` | +| Property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) | `GetMSBuildPropertyValue_*`, `GetGenerationConfiguration`, `GetGenerationContext_*` `Modified`; `ForAttribute_*` stays `Cached` | + +Use the `StepReasons(IncrementalCacheRun)` flattening helper. If the generator depends on its own +post-init output, assert on the framework-named stages rather than on every tracked step. + +### 6. Minimal change + +Keep behaviour equivalent, and do not reformat unrelated tests. + +## Verification + +Build the test project and run its suite before finishing. diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md new file mode 100644 index 00000000..bc711c59 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,230 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.gitignore b/.gitignore index a9370176..7340a12d 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,7 @@ node_modules/ BenchmarkDotNet.Artifacts/!scripts/* !build/ .tools/ + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..7e9b1652 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "skills": { + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + } + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 20de63ed..7da3ffa0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -21,6 +21,11 @@ error, the build fails instead. ReleaseGateTests keeps this setting in place. --> error + + universal diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 00000000..37f126b6 --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,120 @@ +using System.Text.Json; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the skills installed from other repositories. They are installed by the skills CLI into the +/// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json +/// are committed, so the two must agree and no per-agent copy may appear beside them. +/// +/// +/// SkillTests validates this repository's own skills in skills/; these are someone else's, +/// so their content is not checked here, only that the installed set is the recorded set. +/// +public sealed class InstalledSkillTests +{ + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + [Test] + public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var missing = recorded + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + + // Assert + await Assert.That(recorded).IsNotEmpty().Because("skills-lock.json records no installed skills"); + await Assert + .That(missing) + .IsEmpty() + .Because( + "skills-lock.json records skills that are not installed in .agents/skills/. Restore them with " + + $"`npx -y skills@1 experimental_install`: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var unrecorded = Directory + .GetDirectories(AgentsSkillsFolder()) + .Select(static path => Path.GetFileName(path)) + .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unrecorded) + .IsEmpty() + .Because( + "every folder in .agents/skills/ must be written by the skills CLI, which records it in " + + $"skills-lock.json: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + + // Act + var exists = Directory.Exists(claudeSkills); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because( + "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " + + "`-a universal`; delete it and reinstall with `-a universal`" + ); + } + + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); + + static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(RepositoryRoot.Value, "skills-lock.json")); + using var lockfile = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return + [ + .. lockfile + .RootElement.GetProperty("skills") + .EnumerateObject() + .Select(static property => property.Name) + .Order(StringComparer.Ordinal), + ]; + } + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} From d764561710e42a70c08dfcdf360d20bba4b5c7bd Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:41:31 +0000 Subject: [PATCH 20/25] docs: describe the committed .agents skills and how to update them AGENTS.md said .agents/ was a gitignored, package-delivered mirror and that skills-lock.json went with it. Neither is true. The installed skills and the lockfile are committed, and are written only into .agents/skills/ by the skills CLI with -a universal. The new Installed skills section lists each skill, its source and when to load it, and gives the commands to add, update and restore them. The copilot instructions and the Contributing page pointed at a Source-generator and testing skills section that no longer existed, and now point at that one. --- .github/copilot-instructions.md | 4 +-- AGENTS.md | 46 +++++++++++++++++++++++++++------ docs/wiki/Contributing.md | 2 +- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index be121536..37e2717c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,11 +7,11 @@ root. Read it first and treat it as the source of truth for: - Project structure and layout - Build and validation workflow - Conventions (conventional commits, csharpier, `.slnx`, SDK behaviour) -- Source-generator and testing skills (`.agents/skills/`) +- Installed source-generator, testing and SDK skills (`.agents/skills/`, committed) - Benchmarking and performance documentation rules - Release process If you are working on source generators, analyzers, code fixes, refactorings, or tests for Roslyn -components, load the relevant skill listed under "Source-generator and testing skills" in `AGENTS.md` +components, load the relevant skill listed under "Installed skills" in `AGENTS.md` before starting. Fall back to searching the repository only when `AGENTS.md` does not match what you observe. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d44ca629..512cef6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,8 @@ benchmarks/ └── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net8.0/net9.0/net10.0) .build/ # TS scripts (build-pack.ts, update-version.ts) -.agents/ # Package-delivered skills/prompts/agents (gitignored mirror) +.agents/skills/ # Installed skills from other repos, committed (see "Installed skills") +skills-lock.json # The skills CLI lockfile for .agents/skills/, committed skills/ # Authored skills shipped by this package (see "Authoring skills") .config/ # dotnet-tools.json, lefthook.yml .github/workflows/ # pr.yml, release.yml (reusable purview-dev/build workflows) @@ -208,7 +209,7 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel models). - **SDK**: projects import `Purview.BuildSdk` via `Directory.Build.props`/`.targets`. The repo sets `ExcludePurviewTelemetry=true` (it does not consume the telemetry package it generates) and - `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/sdk-*` for SDK behavior. + `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/purview-buildsdk-*` for SDK behavior. - **Analyzer release tracking**: the `TSG` rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.{Shipped,Unshipped}.md` (wired as `AdditionalFiles`, so the Roslyn RS2000-series checks run under `EnforceExtendedAnalyzerRules=true`). Add new rules to @@ -216,19 +217,48 @@ table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#rel `AnalyzerReleases.Shipped.md` at release time. `RS2003` is suppressed — see the comment in `src/src/SourceGenerator/SourceGenerator.csproj`. -## Skills from other repositories +## Installed skills (`.agents/skills/`) Specialist source-generator and SDK guidance — `CodeWriter` emission, incremental pipeline design, test runners and `CodeQuery`, `Purview.BuildSdk` configuration — is published by the repositories -that own it. Install what you need through the skills CLI; it lands in `.agents/skills/`, which is -gitignored and recorded in `skills-lock.json`: +that own it, and installed here by the skills CLI. The installed skills are **committed**: +`.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every +entry) are both in source, so a clone has them without running anything. + +| Skill | Source | Load before | +| --- | --- | --- | +| `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes, `Emitters/`) | +| `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | +| `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing a `TSG` diagnostic or the analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator is packed or merged | +| `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or refactoring tests (`CodeQuery`) | +| `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | +| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | + +Skills are installed into the universal `.agents/skills/` location **only**. Never install copies +into `.claude/` or any other per-agent folder; `/.claude/` is gitignored for that reason. Pass +`-a universal`, which writes `.agents/skills/` and nothing else: ```bash -npx skills add purview-dev/sourcegenerator-framework -npx skills add purview-dev/build-sdk +# add or update every skill from a source +npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y +npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y + +# restore the committed set from skills-lock.json +npx -y skills@1 experimental_install ``` -Do not edit anything under `.agents/skills/` — the CLI is its only writer. +Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. +Do not edit anything under `.agents/skills/` by hand — the CLI is its only writer, and a hand edit +no longer matches the lockfile's hash. This repository's *own* skills are never installed here; they +live in `skills/` (below). `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same +CLI; `src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so it is restricted the same +way rather than defaulting to every agent (`*`). None of the restored packages ship skills, so +`ListPurviewSkills` has nothing to recommend and the set above is chosen by hand. + +`src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` +entry has its `.agents/skills//SKILL.md`, every installed folder is in the lockfile, and no +`.claude/skills/` copy exists. ## Authoring skills diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index a357f5d9..90443087 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -71,7 +71,7 @@ The repo mirrors its GitHub CI/CD pipelines locally with the reusable `purview-b ## Source-generator skills -Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Source-generator and testing skills") for the full list and when to load each one. +Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Installed skills") for the full list, when to load each one, and how to add or update them. The installed skills and `skills-lock.json` are committed; install only into `.agents/` (`-a universal`), never into `.claude/` or another per-agent folder. ## Release process From 4cbe9c0bdfe4067c8563178f654405b8ba8700a2 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 13:42:01 +0000 Subject: [PATCH 21/25] chore(release): 5.0.7-prerelease.3 Rewords TSG1011 so it no longer offers the PURVIEW_TELEMETRY_NON_NULLABLE opt-out, which never suppressed it, and removes that define from the generated marker attributes. --- CHANGELOG.md | 23 +++++++++++++++++++++++ package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76991d09..071bc377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver 5.0.7; earlier releases are described by their [GitHub releases](https://github.com/purview-dev/telemetry-sourcegenerator/releases) and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). +## [5.0.7-prerelease.3] - 2026-10-28 + +The `TSG1011` message no longer offers an opt-out that never existed, and the define it named is +gone. Generated code is unchanged for every project that did not define it. + +### Changed + +- **`TSG1011` (unsupported target framework) has a new message.** It said that defining + `PURVIEW_TELEMETRY_NON_NULLABLE` suppresses the error. Nothing ever implemented that: the check reads + only `NET8_0_OR_GREATER` and `NET48_OR_GREATER`, so the diagnostic was raised regardless. The message + now says no telemetry is generated for the target, and that a project must target `net8.0`+ or + `net48`+, with a multi-targeted library referencing the package only from its supported targets. + The rule's ID, title, category and severity are unchanged, so the analyzer release files are too. + +### Removed + +- **`PURVIEW_TELEMETRY_NON_NULLABLE`.** Beyond the false `TSG1011` claim, its one effect was on the + generated marker attributes: it dropped `#nullable enable` and declared their optional members as + `string` rather than `string?`. The attributes now switch on `NET48_OR_GREATER` alone. A `net8.0`+ + project that defined it gets the `string?` declarations every other modern project gets, which + accept every value the plain `string` ones did. `net48` projects are unaffected. + ## [5.0.7-prerelease.2] - 2026-10-28 .NET 11 support. Generated code is unchanged unless you opt in to the new option. The one @@ -94,5 +116,6 @@ generator. This release changes how the package is built and what has to pass be - A `packed-consumers` job in `pr.yml`, and `SampleApp.MultiProject` built and leak-scanned in the `samples` job. +[5.0.7-prerelease.3]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.7-prerelease.2...v5.0.7-prerelease.3 [5.0.7-prerelease.2]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.7-prerelease.1...v5.0.7-prerelease.2 [5.0.7-prerelease.1]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.6...v5.0.7-prerelease.1 diff --git a/package.json b/package.json index 191b2f0c..6c4515e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.0.7-prerelease.2", + "version": "5.0.7-prerelease.3", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", From 6ec268db3327fea670dfd73b98e6a66ee15b01ca Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:14:40 +0000 Subject: [PATCH 22/25] test(repository): match the installed-skills guards used across purview repos Adds the two checks zodsharp and sourcegenerator-framework already carry: the authored skills in skills/ are never copied into .agents/skills/, and no CLAUDE.md is added beside AGENTS.md. --- .../InstalledSkillTests.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs index 37f126b6..8c67fad6 100644 --- a/src/tests/Repository.UnitTests/InstalledSkillTests.cs +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -5,7 +5,7 @@ namespace Purview.Telemetry.Repository; /// /// Guards the skills installed from other repositories. They are installed by the skills CLI into the /// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json -/// are committed, so the two must agree and no per-agent copy may appear beside them. +/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside them. /// /// /// SkillTests validates this repository's own skills in skills/; these are someone else's, @@ -80,6 +80,55 @@ await Assert ); } + [Test] + public async Task AgentsSkills_GivenTheAuthoredSkills_HoldsNoCopyOfThem(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var authored = Directory + .GetDirectories(Path.Combine(RepositoryRoot.Value, "skills")) + .Select(static path => Path.GetFileName(path)) + .ToArray(); + + // Act + var copied = authored + .Where(static name => Directory.Exists(Path.Combine(AgentsSkillsFolder(), name))) + .ToArray(); + + // Assert + await Assert + .That(copied) + .IsEmpty() + .Because( + "this repository's own skills live in skills/ only; a copy in .agents/skills/ would drift from " + + $"the authored one: {string.Join(", ", copied)}" + ); + } + + [Test] + public async Task Repository_GivenAgentInstructions_HasNoClaudeMd(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + string[] candidates = + [ + Path.Combine(RepositoryRoot.Value, "CLAUDE.md"), + Path.Combine(RepositoryRoot.Value, ".github", "CLAUDE.md"), + ]; + + // Act + var present = candidates.Where(File.Exists).ToArray(); + + // Assert + await Assert + .That(present) + .IsEmpty() + .Because( + "this repository is not agent-specific: AGENTS.md is its one instruction file, so a CLAUDE.md " + + $"must not be added: {string.Join(", ", present)}" + ); + } + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) From 1a4313b276ada81a4adc715251a514d11cad5abc Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 14:30:35 +0000 Subject: [PATCH 23/25] chore(skills): install the engineering-playbook skills and state the own-skills rule Every purview repository installs purview-dev/engineering-playbook alongside build-sdk, with -a universal only. AGENTS.md lists the six skills and says that this repository's own skills in skills/ are for consumers and are never installed into its .agents/skills/. --- .../purview-architecture-testing/SKILL.md | 183 +++++++++ .agents/skills/purview-architecture/SKILL.md | 176 +++++++++ .../references/contract-strategy.md | 236 ++++++++++++ .../references/project-context-discovery.md | 115 ++++++ .../references/project-responsibilities.md | 119 ++++++ .../references/proportional-architecture.md | 92 +++++ .../purview-engineering-principles/SKILL.md | 171 +++++++++ .agents/skills/purview-persistence/SKILL.md | 106 ++++++ .../references/guid-generation.md | 93 +++++ .../references/postgresql.md | 98 +++++ .../references/sqlserver.md | 101 +++++ .agents/skills/purview-telemetry/SKILL.md | 123 ++++++ .../references/observability-levels.md | 93 +++++ .../purview-testing-conventions/SKILL.md | 357 ++++++++++++++++++ .../acceptance-and-container-tests.md | 117 ++++++ AGENTS.md | 4 + skills-lock.json | 36 ++ 17 files changed, 2220 insertions(+) create mode 100644 .agents/skills/purview-architecture-testing/SKILL.md create mode 100644 .agents/skills/purview-architecture/SKILL.md create mode 100644 .agents/skills/purview-architecture/references/contract-strategy.md create mode 100644 .agents/skills/purview-architecture/references/project-context-discovery.md create mode 100644 .agents/skills/purview-architecture/references/project-responsibilities.md create mode 100644 .agents/skills/purview-architecture/references/proportional-architecture.md create mode 100644 .agents/skills/purview-engineering-principles/SKILL.md create mode 100644 .agents/skills/purview-persistence/SKILL.md create mode 100644 .agents/skills/purview-persistence/references/guid-generation.md create mode 100644 .agents/skills/purview-persistence/references/postgresql.md create mode 100644 .agents/skills/purview-persistence/references/sqlserver.md create mode 100644 .agents/skills/purview-telemetry/SKILL.md create mode 100644 .agents/skills/purview-telemetry/references/observability-levels.md create mode 100644 .agents/skills/purview-testing-conventions/SKILL.md create mode 100644 .agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 00000000..0619225f --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,183 @@ +--- +name: purview-architecture-testing +description: >- + Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction + rules, forbidden framework references, project naming checks, cycle detection - or when a boundary + keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing + and a single policy file rather than hand-maintained lists. +license: MIT +metadata: + author: Purview-Dev + version: "2.0" +--- + +# Purview architecture testing + +A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a +rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of +brittle assertions that everyone disables six months later. + +Prerequisite: know what the projects are *for*. See `purview-architecture`. + +## 1. Only test a rule that is real + +Before writing anything, answer: **has this rule been broken, or is it certain to be?** + +A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates: + +- A direction that has already been violated once. +- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by + nothing. +- A rule a newcomer cannot infer from the code. + +Bad candidates: anything the compiler already enforces, and style preferences dressed up as +architecture. + +## 2. Adopt the package; define the policy once + +The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and +once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails +when it is wrong. + +So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming, +sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are +defined once and shared. Adoption is two files. + +**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor +validates it as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": ["Shared"], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"] + } + ], + "waivers": [] +} +``` + +**One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather +than collapsing the set into one assertion, and the package validates the policy against its schema +first - a policy that does not match the schema may be silently missing a rule. + +The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is +authoritative on its API and rule ids. Read it rather than this page when you are extending or +debugging the package. + +Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable +too. Full worked example: +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). + +## 3. Read project files, and assemblies + +Two sources, because they catch different things: + +| Source | Catches | Misses | +| -- | -- | -- | +| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively | +| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive | + +The package's rules read the **project files**, because a declared `ProjectReference` is what a +reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled +assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a +public type. + +Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence` +are both Persistence - qualification is a naming choice, never a different rule. The package does +this for you; a hand-written assembly test must do it too. + +## 4. The rules worth having + +| Rule | What it catches | +| -- | -- | +| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider | +| Nothing references Composition | A circular implementation choice | +| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` | +| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project | +| Cycle detection | A graph that MSBuild rejects with an unreadable message | +| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names | +| No production → test reference | A test helper leaking into shipped code | + +The package provides all of these. Do not re-implement one; change the policy instead. + +## 5. Write failures someone can act on + +This is what decides whether the suite survives. A failure must name: + +1. The offending project. +2. The rule violated. +3. The specific reference that broke it. +4. The direction the dependency *should* run. +5. The likely fix. + +```text +'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application +must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce +an abstraction that Application owns and Persistence implements. +``` + +Not `Expected collection to be empty`. Collect every violation and report them as a numbered list - +one test run should show all of them, not the first. The package already reports this way; keep the +same standard in any rule you add. + +## 6. Waivers, not suppressions + +A repository that predates the policy will violate it. Deleting the test or adding a blanket +`#pragma` makes the policy a lie. Record the departure in the policy instead: + +```json +{ + "project": "Infrastructure", + "mapsTo": "Adapters", + "rules": ["naming"], + "reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.", + "reviewedOn": "2026-10-15" +} +``` + +The package tests the waivers themselves: each must name a project that still exists, a rule, and a +substantive reason. A waiver for a deleted project is dead weight that hides the next one. + +Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the +project classified so every dependency rule still applies to it. Prefer a waiver to +`SkipRule`, which removes a rule for the whole repository with no record. + +## 7. Conventions + +- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with + `[InheritsTests]`. +- Arrange/Act/Assert, one behaviour per test. +- Let the package find the repository root - it probes upward for the policy and stops at the `.git` + boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary. +- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that + looks enforced and is not. + +## Checks before you answer + +- Is every rule you added one that has been, or will be, broken? +- Is the matrix defined once, in the policy, with the documentation checked against it? +- Does each failure name the fix, not just the fact? +- Did you record a waiver rather than weaken a rule for existing code? + +## See also + +- `purview-architecture` — what the projects are for, and how they are named +- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md new file mode 100644 index 00000000..c04f3d61 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-architecture +description: >- + Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview architecture + +The job of this skill is to stop two failures that cost far more than they look: + +- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an + application that has one table and one caller. +- **A recommendation made without context.** Advice about database-side GUID generation given to a + stateless CLI, or a Clean Architecture split proposed for a 300-line utility. + +So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the +one that gets skipped. + +## 1. Establish context before deciding + +Before recommending a structure, adding a project, or generating substantial code, you must know four +things. **Read them out of the repository first** — asking a user a question the code already answers +is the most common way this skill is applied badly. + +| Dimension | Read it from | Ask only if | +| -- | -- | -- | +| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere | +| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty | +| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now | +| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it | + +Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and* +GUID keys are in play. See `purview-persistence`. + +Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A +900-line service that reconciles three upstream systems and owns money movement is not. + +**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file, +an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise, +targeted questions only for the unknowns that change your recommendation — and if nothing changes, +ask nothing. + +Full decision procedure: `references/project-context-discovery.md`. + +## 2. Choose proportionally + +| Scale | Shape | What you still do not add | +| -- | -- | -- | +| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces | +| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for | +| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem | + +Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a +boundary added in anticipation of a second caller that never appears is permanent. + +**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a +problem, not a starting position. If you cannot name the failure a layer prevents in this codebase, +do not add the layer. + +Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`. + +## 3. Name what you do create + +A project name states a **responsibility**, optionally qualified by an implementation: + +```text +[.] +``` + +The responsibility decides the rules; the qualifier never changes them. Three rules people usually +get wrong: + +- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server. + Rename when a second provider arrives, not in anticipation. +- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing + shared need no `Persistence` project. +- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same + dependencies belong in one project. + +Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`, +`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never +rename an established project just to match canonical terminology; `Domain`, `UseCases` and +`Integrations` are recognised aliases. + +Responsibility catalogue, dependency matrix and the naming decision tree: +`references/project-responsibilities.md`. + +## 4. Check the direction + +`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP +request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer +about whether a dependency is allowed. + +The rules that matter most: + +- Core depends on nothing but `Shared`. +- Application never references a concrete `Persistence`, `Providers` or `Adapters` project. +- Composition references implementations; **nothing references Composition** except a host. +- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent. +- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup + does not apply schema migrations. + +When a boundary is worth having, make it fail the build rather than the review — see +`purview-architecture-testing`. + +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays + +Reuse the Purview libraries when they remove a real source of truth, not for coverage: + +| Concern | Library | Use it when | +| -- | -- | -- | +| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them | +| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary | +| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today | +| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator | +| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` | + +Duplication that matters: validation repeated per entry point, a domain rule restated in the UI, +configuration bound in two places, two types that both claim to be the truth. Duplication that does +not: two similar-looking methods that answer to different owners. + +## 7. Telemetry is not an advanced topic + +Recommend instrumentation from the first commit, at every project size. A small application gets +small telemetry — it does not get none. See `purview-telemetry`. + +## Checks before you answer + +- Did you read the four context dimensions rather than ask for them? +- Can you name, for each project and each abstraction you are proposing, the failure it prevents here? +- Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? +- Did you include telemetry? + +## See also + +- `purview-telemetry` — instrumentation at every scale +- `purview-persistence` — storage and GUID-key guidance, only when persistence is real +- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 00000000..e281a410 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 00000000..d40569a3 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-context-discovery.md @@ -0,0 +1,115 @@ +# Project context discovery + +The procedure for establishing enough context to make an architectural recommendation, and — just as +importantly — for knowing when to stop asking. + +## The principle + +Every question you ask a user is a question they have usually already answered in the repository. +Reading is free and silent; asking is slow and, when the answer was already in a project file, it +reads as not having looked. So the order is fixed: + +1. Read the repository. +2. Read the conversation so far. +3. Ask only about what remains *and* changes your answer. + +A dimension whose answer would not change your recommendation is not worth a question. If a project +is a stateless CLI, the database GUID strategy is not an unknown — it is irrelevant, and raising it +is noise. + +## Dimension 1 — complexity + +Assess from requirements, never from line or project count. + +| Signal | Suggests | +| -- | -- | +| One functional area, one consumer, no integrations | Small | +| A handful of features, one or two integrations, a single team | Medium | +| Several bounded areas, multiple services or teams, operational obligations (SLOs, auditability, money) | Large | + +Where to look: `README.md`, the wiki, issue or ticket references, the solution file's project list, +`PackageReference` entries naming external systems, the number of distinct hosts. + +Traps: + +- **A big repository is not a complex one.** A monorepo of ten tiny tools is ten small projects. +- **A small repository can be complex.** Few files, hard invariants. +- **Generated code inflates everything.** Discount it. + +## Dimension 2 — project type + +Read it; do not ask it. + +| Evidence | Type | +| -- | -- | +| `Microsoft.NET.Sdk.Web` | Web application or HTTP API — distinguish by whether views/pages or endpoints dominate | +| `OutputType` of `Exe` plus an argument parser | CLI | +| `IsPackable`, no entry point | Reusable library | +| `IHostedService` / `BackgroundService`, `Worker` SDK | Background worker | +| An AppHost project, `Aspire.Hosting` | Distributed application | +| WPF/WinForms/Avalonia/MAUI SDK or `UseWPF` | Desktop application | + +A reusable library carries the strongest constraint on the rest of your advice: it must not configure +global telemetry providers, must not read configuration from the ambient environment, and must not +decide a persistence technology for its consumer. + +## Dimension 3 — persistence + +| Question | Read it from | +| -- | -- | +| Is persistence required at all? | Any data-access `PackageReference`, a `DbContext`, a connection string, a repository type, an Aspire resource | +| Which technology? | The EF Core provider package (`Microsoft.EntityFrameworkCore.SqlServer`, `Npgsql.EntityFrameworkCore.PostgreSQL`), the client package, or the `Purview.Containers` resource in the AppHost | +| Is more than one implementation required? | Two providers referenced, a provider abstraction already present, or an explicitly stated requirement | +| EF Core or something else? | Presence of `Microsoft.EntityFrameworkCore` versus Dapper, a raw client, or a document store SDK | + +"No persistence" is a complete and common answer. When it is the answer, every storage-shaped +recommendation — repositories, migrations, GUID strategies, connection resilience — is out of scope. + +## Dimension 4 — telemetry + +| Question | Read it from | +| -- | -- | +| Is telemetry configured? | `ILogger` injection, `ActivitySource`/`Meter` construction, `AddOpenTelemetry`, a `ServiceDefaults` project | +| Is Purview Telemetry available? | A `Purview.Telemetry.SourceGenerator` `PackageReference`, or interfaces attributed `[Logger]`, `[ActivitySource]`, `[Meter]` | +| Which signals are relevant? | Logs always; traces once a call crosses a process or a dependency; metrics once someone would alert on a rate or a duration | +| Is there an exporter? | An OTLP endpoint in configuration, a console exporter, an Aspire dashboard, an APM package | + +Existing instrumentation is a constraint, not a starting point to replace. Preserve it, extend it, +and never register the same source twice. + +## Conditional dimension — identifier generation + +Resolve **only** when persistence exists and GUIDs are, or are about to be, primary keys. + +- Are GUIDs primary keys, or just surface identifiers? +- Is the value generated in the application or by the database? +- Which provider and version? (This changes the answer — see `purview-persistence`.) +- Does index locality matter at the expected write volume? +- Must the identifier exist before the entity is persisted (outbox, correlation, client-side graph)? + +The last question usually decides it: if the application needs the value before `SaveChanges`, the +database cannot be the generator. + +## Asking well + +When you must ask, ask few and ask concretely. Batch the unknowns into one short set, offer the +likely answers, and state what you will do if the user does not care. + +Good: + +> Two things I can't tell from the repository: is this service expected to own its own database, or +> read from the existing one? And should failures be returned to the caller or thrown? I'll assume +> its own database and `Purview.Results` for expected failures unless you say otherwise. + +Bad: + +> What is the project complexity? What is the project type? Is persistence required? Which database? +> Is telemetry configured? Do you use GUID primary keys? + +The second asks six questions, five of which the repository answers, and one of which is irrelevant. + +## Recording the answer + +When a dimension is resolved by conversation rather than by the repository, that is a decision the +next agent will not be able to read. Write it down — in `AGENTS.md`, a wiki page, or an ADR — so the +question is asked once and never again. diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 00000000..d74d2cf4 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 00000000..3ed7f553 --- /dev/null +++ b/.agents/skills/purview-architecture/references/proportional-architecture.md @@ -0,0 +1,92 @@ +# Proportional architecture + +Structure is a cost paid now against a failure expected later. Proportional architecture is the +practice of only paying when the failure is named, present, and more expensive than the structure. + +## The justification test + +Before adding a project, an interface, a layer or a pattern, answer in one sentence: + +> **What failure does this prevent in *this* codebase, and has it happened or is it certain to?** + +If the sentence needs the words "in case", "later", "when we scale", or "it's good practice", the +structure is not yet justified. Write the simpler thing. The simpler thing is also the thing that is +cheapest to change when the failure finally does arrive. + +This is not an argument against structure. It is an argument against *unfalsifiable* structure. + +## By scale + +### Small — a utility, CLI, prototype, simple API, limited-scope application + +- One project. A second only when there is a test project, or when a genuinely reusable library + falls out. +- Call the database, the API or the file system directly from the code that needs it. +- Validate at the edge, once. +- Logging from the first commit; traces and metrics when there is something to correlate. + +Do not add: a repository interface over `DbContext`, a service layer that forwards, a mapping layer +between two near-identical shapes, an interface with one implementation and no test double need +(`Purview.Containers` and TUnit.Mocks remove most of that need), a `Domain`/`Application`/ +`Infrastructure` split. + +### Medium — several functional areas, some integrations, moderate domain complexity + +- Introduce a boundary where you can name what it separates. Usually that is domain rules from the + host, so a domain project and an application host. +- An abstraction over an external dependency earns its place when the dependency is genuinely + swappable, or when testing against it is otherwise painful. "Swappable in principle" is not + swappable. +- Dependency instrumentation, trace propagation across the integrations, metrics on the operations + someone would be paged about. + +Do not add: CQRS unless reads and writes have actually diverged in shape, lifetime or scaling; +event sourcing unless the history *is* the product; a second infrastructure implementation that has +no second consumer. + +### Large — complex domain, multiple services or teams, substantial operational requirements + +- Modularity along ownership lines, because the real constraint is people, not code. +- Independent infrastructure implementations where they are genuinely independent. +- Boundaries made enforceable rather than documented — a dependency-direction rule that a wrong + `ProjectReference` fails the build. See the playbook's Architecture-As-Tests page. +- End-to-end tracing, standardised resource attributes, SLIs, retention and sampling policy. + +Even here, the justification test holds per decision. "Large" licenses the *option* to add +structure, not the obligation to add all of it on day one. + +## Patterns and the conditions that earn them + +| Pattern | Earned when | Not earned by | +| -- | -- | -- | +| Repository over EF Core | You must swap the store, or the `DbContext` API genuinely leaks into domain decisions | Testability — EF Core's in-memory and container-backed testing already covers it | +| CQRS | Read and write models have diverged in shape or scaling needs | A desire for symmetry with a reference architecture | +| Event sourcing | The sequence of changes is the product, or audit is a hard requirement | "We might want history" | +| Mediator / in-process bus | Many handlers, genuinely decoupled registration | Three call sites that could be method calls | +| Mapping layer | Two shapes that must diverge and are owned separately | Two shapes that are identical today | +| Extra DI indirection | A real second implementation, or a composition root that must vary | Inversion for its own sake | +| Generic infrastructure framework | Three or more concrete uses exist now | One use and an expectation of more | + +## Evolving incrementally + +The honest sequence is: write the direct implementation; let the second caller arrive; extract the +boundary the second caller revealed. Each step is small, reversible and informed. + +The dishonest sequence is: predict the second caller; build the boundary; never get the second +caller; maintain the boundary forever. The cost is invisible because it is spread across every +future change. + +When you do extract, extract along the seam the code showed you — not the seam the diagram +predicted. + +## What this does not license + +Proportional is not an excuse for: + +- Skipping tests, or skipping telemetry. +- Leaving validation duplicated across entry points. +- Letting domain rules live in the UI or in the database. +- Two sources of truth for the same fact. + +Those are not structure bought in advance; they are defects. Simplicity means fewer moving parts, +not fewer guarantees. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 00000000..ba4dd9bd --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,171 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a + build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its +`mkdocs.yml`, its navigation and its `edit_uri`. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance. diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 00000000..4667fa29 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,106 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, deciding + whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific + guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for + projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. GUID keys — only if GUIDs are keys + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**There is no Purview GUID generation package.** Do not reference one, and do not create one without +first establishing a concrete, measured need that the built-in options cannot meet. If such a +capability is added later, this skill is where the guidance for recommending it belongs. + +## 4. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 5. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 00000000..432053a2 --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,93 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| Any | Either | Either, but must be unpredictable | Random v4 | + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## There is no Purview GUID package + +No Purview library provides GUID generation today. Do not reference one and do not invent one. + +If a concrete need emerges that the built-in options cannot meet — established by measurement, not by +anticipation — that is the point at which creating one is worth discussing. Until then this guidance +is the deliverable, and this page is where a recommendation for such a package would be documented. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 00000000..cd557ecc --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,98 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 00000000..c7282955 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,101 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to +`NEWSEQUENTIALID()` through the default value-generation strategy. + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 00000000..6d831731 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 00000000..df97d785 --- /dev/null +++ b/.agents/skills/purview-telemetry/references/observability-levels.md @@ -0,0 +1,93 @@ +# Observability levels + +Instrumentation is constant across these levels. Export, infrastructure and operational practice are +what scale. Read this after establishing project complexity — see `purview-architecture`. + +## Small + +*A utility, CLI, prototype, simple API or limited-scope application.* + +**Do:** + +- Structured logging with parameters as fields. +- Exceptions recorded with enough context to reproduce. +- An `ActivitySource` around the unit of work, so a failure has a shape and a duration. +- Console export, or a single OTLP endpoint when one already exists. +- Log level from configuration, so a user can raise it without a rebuild. + +**Do not:** + +- Stand up a collector. +- Adopt a centralised monitoring platform. +- Build distributed tracing infrastructure for a single process. +- Create dashboards nobody reads. +- Write a custom exporter. + +The test: a user reports a failure and you can ask for one log output that tells you what happened. + +## Medium + +*Several functional areas, integrations, moderate domain complexity.* + +Everything above, plus: + +- **Dependency instrumentation** — HTTP clients, EF Core, messaging. These are where latency and + failure actually live, and the instrumentation is a package reference, not code. +- **Trace propagation** across every integration boundary, so a trace survives the process hop. W3C + `traceparent` by default; do not invent a correlation header. +- **Application metrics** for the operations someone would notice: request rate, error rate, duration, + queue depth. Prefer histograms over averages. +- **OTLP export** to a real backend. +- **Health checks**, distinguishing liveness from readiness. +- **A deliberate sampling decision.** Head sampling at a fixed ratio is fine; the point is that it was + chosen rather than defaulted into. + +Common mistake at this level: adding metrics for everything measurable instead of the few that drive +a decision. A metric nobody alerts or decides on is cost without signal. + +## Large + +*Complex domain, multiple services or teams, substantial operational requirements.* + +Everything above, plus: + +- **End-to-end distributed tracing**, including asynchronous hops — message producers and consumers + linked, not orphaned. +- **Standardised resource attributes** — `service.name`, `service.version`, `deployment.environment` + — applied uniformly so cross-service queries work at all. Follow OpenTelemetry semantic conventions + rather than inventing names. +- **Operational metrics** beyond application ones: saturation, dependency health, cost drivers. +- **Dashboards** owned by the team that gets paged. +- **Alerting** on symptoms users feel, not on causes. Alert on error rate and latency; investigate + with CPU and queue depth. +- **Service-level indicators** derived from the telemetry, with objectives agreed with whoever cares. +- **Retention and sampling policy**, documented and costed. Tail sampling if error traces must always + survive. + +## Signal selection + +| Signal | Add when | Warning | +| -- | -- | -- | +| Logs | Always | Unstructured logs are not telemetry; they are strings | +| Traces | Work crosses a process, a dependency or an async boundary | A span per method produces noise, not insight | +| Metrics | Someone would alert on, or decide from, a rate or duration | High-cardinality attributes (user id, request id) are a cost incident | + +Put high-cardinality context on spans and logs, never on metric dimensions. + +## Sensitive information + +At every level, and without exception: + +- No credentials, tokens, keys or connection strings in messages, attributes or baggage. +- No personal data beyond what the system is entitled to process — and where an identifier is + necessary, prefer a pseudonymous one. +- Remember that **baggage propagates across service boundaries**; treat it as the most public place + in your telemetry. +- Redact at the point of emission. A redaction step in the pipeline is a backstop, not a control. + +## Export configuration + +The host owns this. Keep the endpoint, protocol, sampling ratio and resource attributes in +configuration, not in code, so the same build runs in every environment. + +Libraries contribute names and signals; they never contribute configuration. diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 00000000..1fe32320 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -0,0 +1,357 @@ +--- +name: purview-testing-conventions +description: >- + Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit + and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under + test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Testing conventions + +| Concern | The rule | +| --- | --- | +| Framework | **TUnit**. Not xUnit, NUnit or MSTest. | +| Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | +| Test data | **Bogus**, reached through `TestBuilder`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | +| Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | +| Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | +| Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | +| Domain content | Built through `TestBuilder`. Never inline. | + +## Why these, specifically + +**One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that +wiring is why a test project file is empty. A second framework means every project file grows a +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. + +**Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` +to reach the type, and **cannot silently test a different type with the same name**. + +**`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under +setup. A helper with optional parameters says what the test changed and defaults everything else: + +```csharp +static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => + new(repository ?? CreateRepository().Object); +``` + +**`TestBuilder`.** Inline construction encodes a domain type's shape into every test that touches it, +so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about +which values the test depends on, because every literal looks deliberate. + +## A test + +```csharp +using Acme.Builders; + +namespace Acme.Domain; + +public sealed class CustomerIdTests +{ + [Test] + public async Task Create_GivenLowercaseSlug_ReturnsTheValue() + { + // Arrange + var id = TestBuilder.CustomerId("build-enforced"); + + // Act + var value = id.Value; + + // Assert + await Assert.That(value).IsEqualTo("build-enforced"); + } +} +``` + +Two deliberate details: + +- **The empty `// Arrange` block stays.** The comments are what makes a three-line test readable next + to a thirty-line one, and a convention applied "when needed" is applied inconsistently. +- **Assertions are awaited.** `Assert.That(...)` is terminal and returns a value, so + `await Assert.That(result).IsEqualTo(expected)` is the form. **An un-awaited assertion is a test + that cannot fail.** + +## Assertions + +| Form | Use | +| --- | --- | +| `await Assert.That(x).IsEqualTo(y)` / `.IsNotEqualTo(y)` | value equality | +| `await Assert.That(x).IsTrue()` / `.IsFalse()` | booleans | +| `await Assert.That(x).IsEmpty()` | collections — the workhorse of architecture rules | +| `await Assert.That(x).Count().IsEqualTo(2)` | collection size | +| `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | membership | +| `await Assert.That(act).Throws()` | a synchronous delegate that should throw | +| `await Assert.That(async () => await f()).Throws()` | an async one | +| `await Assert.That(x).IsTypeOf()` | exact runtime type | +| `await Assert.That(x).IsEquivalentTo(sequence)` | order-independent sequence equality | + +For an exception test the two shapes are `Action` and `Func`. The compiler will not infer +either, so the explicit type is required and is **not** a style violation: + +```csharp +Action act = () => CustomerId.Create(""); + +await Assert.That(act).Throws(); +``` + +## Parameterised tests + +`[Arguments]` puts the interesting values in the attribute rather than a loop, so a failure names the +case: + +```csharp +[Test] +[Arguments("")] +[Arguments(" ")] +[Arguments("Has Spaces")] +[Arguments("has_underscore")] +public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) +{ + // Arrange + // Act + Action act = () => CustomerId.Create(value); + + // Assert + await Assert.That(act).Throws(); +} +``` + +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + +## Substitutes + +TUnit.Mocks is globally imported by the SDK, so no `using` is needed: + +```csharp +[Test] +public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) +{ + // Arrange + var repository = CreateRepository(); + repository.ExistsAsync(Any(), Any()).Returns(true); + var catalogue = CreateCatalogue(repository.Object); + + // Act + await Assert + .That(async () => await catalogue.RegisterAsync(TestBuilder.EnforcedConvention(), cancellationToken)) + .Throws(); + + // Assert + repository.AddAsync(Any(), Any()).WasNeverCalled(); +} +``` + +- `Mock.Of()` produces a `Mock`; `.Object` is the instance you pass in. +- `Any()` matches any argument, `Is(value)` a specific one, `AnyArgs()` the whole call. +- `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is + unwrapped for you. +- `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. + +The `// Assert` after a `Throws` is not a mistake: that test's point is that the exception was thrown +**and** that nothing was written, and both are assertions about the same act. + +## `TestBuilder` + +It lives in the shared testing project and is the single means by which a test creates domain +content: + +```csharp +public static class TestBuilder +{ + public const int DefaultCount = 3; + + internal static Faker Faker { get; } = new(); + + public static Domain.ConventionId ConventionId(string? value = null) => + Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); + + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); +} +``` + +Four rules make it work: + +- **Omitted arguments are generated; supplied arguments are used exactly.** A test overrides only the + value it is proving, and everything else is realistic without the test saying so. +- **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a + distinct set, so a test needing "some" gets "some" without its own loop. +- **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit + `using Bogus;` in a shared testing project is reported as unnecessary. +- **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and + reject has to name the rejected input itself, so it constructs the invalid value directly. + Everything valid still goes through the builder. + +## Two constraints that shaped the builder + +Both are consequences of SDK rules, not preferences, and both were found by building it. + +**The factories are flat, not grouped.** The natural shape is a nested class per feature area +(`TestBuilder.Catalogue.Convention(...)`), and it cannot compile: the SDK ships +`dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type fails the build, and +`PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does +not help either — the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access +*to* test and shared-testing projects rather than *from* them, so a shared testing project's +internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own +top-level builder class. + +**`var` is the house style, with one carve-out.** `csharp_style_var_elsewhere = true:warning` makes +an explicit type on a declaration initialised from a method call a warning (`IDE0007`). The carve-out +is object creation: the `PDS0003` analyzer prefers an explicit type with target-typed `new()`: + +```csharp +var id = TestBuilder.ConventionId("build-enforced"); // method call -> var +string title = new('a', Convention.MaxTitleLength); // new() -> explicit type +``` + +## What is enforced, and what is review + +| Rule | Enforced by | +| --- | --- | +| TUnit is the only framework | the build — the SDK adds it; a second has to be added by hand | +| Test projects are discovered and categorised | the build (name-suffix detection) | +| A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | +| Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | +| AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. + +## Tiers + +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. + +Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on +Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## At your scale + +- **One test project.** TUnit, AAA, the naming convention, `TestBuilder`. That is the whole of it. +- **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared + testing project is a library, so its fixtures are legitimately public API and `CA1515` does not + apply. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. +- **A package with a documented contract.** Add a consumer-verification script; see + `purview-engineering-principles`. + +## Related skills + +`purview-engineering-principles` for the rules this implements, and `purview-architecture-testing` +for turning an architectural boundary into a failing build. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 00000000..e8b91109 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/AGENTS.md b/AGENTS.md index 512cef6e..0f9f05a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,6 +234,9 @@ entry) are both in source, so a clone has them without running anything. | `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or refactoring tests (`CodeQuery`) | | `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | | `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-architecture`, `purview-architecture-testing`, `purview-engineering-principles`, `purview-persistence`, `purview-telemetry`, `purview-testing-conventions` | `purview-dev/engineering-playbook` | solution shape, architecture tests, engineering conventions, persistence, telemetry and testing conventions | + +This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`, which holds only skills installed from other repositories; `InstalledSkillTests` enforces this. Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` or any other per-agent folder; `/.claude/` is gitignored for that reason. Pass @@ -243,6 +246,7 @@ into `.claude/` or any other per-agent folder; `/.claude/` is gitignored for tha # add or update every skill from a source npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y +npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y # restore the committed set from skills-lock.json npx -y skills@1 experimental_install diff --git a/skills-lock.json b/skills-lock.json index 7e9b1652..3bd69121 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,18 @@ { "version": 1, "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", @@ -49,6 +61,18 @@ "skillPath": "skills/purview-buildsdk-testing/SKILL.md", "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", @@ -84,6 +108,18 @@ "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" } } } From b28b50a049a1d6d6becd75eea6c71bb7deaed408 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 28 Oct 2026 17:19:20 +0000 Subject: [PATCH 24/25] build: bumped shared build --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f8a47855..3fc1c2ce 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.0", "commands": [ "purview-build" ], "rollForward": false } } -} \ No newline at end of file +} From 7c6542b4b1d81b6b33e5fdc7977a351f40461157 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 20:41:49 +0000 Subject: [PATCH 25/25] chore: bumped version --- Directory.Packages.props | 6 +++--- docs/wiki/index.md | 6 ------ mkdocs.yml | 21 --------------------- package.json | 2 +- 4 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 docs/wiki/index.md delete mode 100644 mkdocs.yml diff --git a/Directory.Packages.props b/Directory.Packages.props index 8d8af845..83d80a53 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,14 +3,14 @@ 5.9.0 1.76.0 1.2.2 - 10.0.13 + 10.0.14 10.11.0 - 1.18.0 + 1.20.1 - + diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index 1ff2fcfd..00000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,6 +0,0 @@ -# Telemetry Source Generator - -Source-generated logging, metrics, activities, diagnostics, and telemetry integrations for .NET. - -[Documentation overview](Home.md){ .md-button .md-button--primary } -[Get started](Getting-Started.md){ .md-button } diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 9be1412f..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,21 +0,0 @@ -site_name: Telemetry Source Generator -site_description: Developer documentation for the Purview Telemetry Source Generator -repo_url: https://github.com/purview-dev/telemetry-sourcegenerator -edit_uri: edit/main/docs/wiki/ -docs_dir: docs/wiki - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/package.json b/package.json index 6c4515e8..9ff58b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.0.7-prerelease.3", + "version": "5.1.0", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md",