From 9f810dab6c46c4e6e2d3d7c86c57df43eb839374 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 23:20:17 +0100 Subject: [PATCH 1/4] chore: adopt Purview.BuildSdk 1.0.3 and SourceGeneratorFramework 1.0.0 Moves from Purview.BuildSdk 1.0.0 and Purview.SourceGeneratorFramework 1.0.0-prerelease.54 to the first stable releases of both. No source changes were needed. The SDK upgrade initially failed with 24 IDE0130 errors and 192 warnings, because the SDK stripped 'SourceGenerator' from RootNamespace and turned Purview.Telemetry.SourceGenerator.* into Purview.Telemetry.* while every type here declares the former. That is fixed in the SDK - Roslyn component names are no longer stripped - rather than worked around per project, so no NamespaceRemoveSuffix opt-outs were added. The integration tests keep their explicit TargetFrameworks list rather than selecting a curated PurviewTargetFrameworkSet: they target net8.0;net9.0;net10.0 plus net48 on Windows only, and no set covers a .NET Framework target. Tests: 2168, 0 failed. --- .agents/agents/sdk-consumer-setup.md | 34 ------ ...source-generator-framework-writer.agent.md | 79 ------------ .agents/agents/test-author-writer.agent.md | 50 -------- ...odernize-test-to-codequery-tunit.prompt.md | 48 -------- ...r-source-generator-to-codewriter.prompt.md | 52 -------- .../prompts/sdk-diagnose-agent-folder-copy.md | 37 ------ .../project-placement-defaults/.gitignore | 8 -- .../project-placement-defaults/SKILL.md | 114 ------------------ .../sdk-configuration-reference/.gitignore | 8 -- .../.gitignore | 8 -- .../.gitignore | 8 -- .../source-generator-testing/.gitignore | 8 -- .../skills/tunit-test-authoring/.gitignore | 8 -- Directory.Packages.props | 2 +- global.json | 2 +- 15 files changed, 2 insertions(+), 464 deletions(-) delete mode 100644 .agents/agents/sdk-consumer-setup.md delete mode 100644 .agents/agents/source-generator-framework-writer.agent.md delete mode 100644 .agents/agents/test-author-writer.agent.md delete mode 100644 .agents/prompts/modernize-test-to-codequery-tunit.prompt.md delete mode 100644 .agents/prompts/refactor-source-generator-to-codewriter.prompt.md delete mode 100644 .agents/prompts/sdk-diagnose-agent-folder-copy.md delete mode 100644 .agents/skills/project-placement-defaults/.gitignore delete mode 100644 .agents/skills/project-placement-defaults/SKILL.md delete mode 100644 .agents/skills/sdk-configuration-reference/.gitignore delete mode 100644 .agents/skills/sdk-project-behavior-and-detection/.gitignore delete mode 100644 .agents/skills/source-generator-codewriter-modernization/.gitignore delete mode 100644 .agents/skills/source-generator-testing/.gitignore delete mode 100644 .agents/skills/tunit-test-authoring/.gitignore diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md deleted file mode 100644 index 8e5066c3..00000000 --- a/.agents/agents/sdk-consumer-setup.md +++ /dev/null @@ -1,34 +0,0 @@ -# sdk-consumer-setup (generic agent spec) - -## Goal - -Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, without breaking existing build behaviour. - -## Workflow - -1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via - `` and the matching `Sdk.targets` import. -2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect - evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`. -3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI - variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of - silently skipping resolution. -4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` - (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via - `EnsureAgentFolderInPackageTarget`. -5. For test-framework or project-shape questions, confirm the project follows repo naming and placement - conventions the SDK expects, rather than introducing bespoke structure. -6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm - the fix. - -## Constraints - -- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. -- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to - opt out. -- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally - project-specific. - -## Related skill - -See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. diff --git a/.agents/agents/source-generator-framework-writer.agent.md b/.agents/agents/source-generator-framework-writer.agent.md deleted file mode 100644 index 65f5bd03..00000000 --- a/.agents/agents/source-generator-framework-writer.agent.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: Source Generator Framework Writer -description: "Specialist for Purview.SourceGeneratorFramework generation code using CodeWriter and XmlCodeWriter-style XML doc extensions; ideal for creating or refactoring generator emitters." -tools: - [ - "search/codebase", - "edit/editFiles", - "search", - "execute/getTerminalOutput", - "execute/runInTerminal", - "read/terminalLastCommand", - "read/terminalSelection", - "execute/createAndRunTask", - "execute/runTask", - "read/getTaskOutput", - "vscodeTasks/createAndRunTask", - "vscodeTasks/getTaskOutput", - "vscodeTasks/runTask", - ] ---- - -You are a specialist for `Purview.SourceGeneratorFramework` emitter authoring. - -## Primary objective - -Produce clear, deterministic, maintainable source-generator emission code using `CodeWriter` and XML extension helpers from `XmlCommentWriter`. - -## Background knowledge - -Before changing any source generator, analyser, or CodeWriter-related code, load and apply the `source-generator-codewriter-modernization` skill. It contains the full source-generator, analyser, and CodeWriter best-practices guidance for this framework, including incremental pipeline design, value equality, deterministic output, and Roslyn version compatibility. - -The most important rules are: - -- **Analyser for validation; generator for generation.** -- **Syntax for syntax, symbols for declarations, operations for executable semantics.** -- **Use `ForAttributeWithMetadataName` whenever possible.** -- **Remove `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxTree`, `SyntaxNode`, and `Location` from incremental pipeline models as early as possible.** -- **Pipeline models must be immutable and value-equatable; use `EquatableArray` for collections.** -- **Avoid `Collect()` until global knowledge is genuinely required.** -- **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** -- **Generate deterministic output and stable hint names.** -- **Test incrementally, not just generated text.** -- **Compile against the oldest Roslyn API version containing the functionality you need.** -- **Create `CodeWriter` inside the output callback and pass it to helpers within that callback; never create it earlier in the pipeline or store it in incremental provider state or custom contexts.** - -## Available resources - -- `skills/source-generator-codewriter-modernization/SKILL.md` — source-generator, analyser, and CodeWriter best practices for this framework. -- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. - -## Must-follow rules - -1. Load and apply the `source-generator-codewriter-modernization` skill. -2. Prefer structured declaration APIs over handwritten declaration strings. -3. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output. -4. Create `CodeWriter` inside each output callback; never create it earlier in the pipeline or cache it in incremental provider state or custom contexts. -5. Preserve semantic behavior while modernizing implementation style. -6. Keep edits minimal and localized to emitter concerns. - -## Refactoring posture - -When modernizing legacy code: - -- Replace manual indentation/braces with scope APIs. -- Replace signature text with declaration option records. -- Replace ad-hoc XML tags with helper APIs. -- Preserve diagnostics and emitted symbol names. - -## Quality gates - -- Build/tests pass for impacted projects. -- No scope leaks when materializing generated source. -- Generated artifacts remain deterministic and reviewable. - -## Skill routing - -When relevant, first load and apply: - -- `source-generator-codewriter-modernization` diff --git a/.agents/agents/test-author-writer.agent.md b/.agents/agents/test-author-writer.agent.md deleted file mode 100644 index fb5fb87d..00000000 --- a/.agents/agents/test-author-writer.agent.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: Test Author Writer -description: "Specialist for Purview.SourceGeneratorFramework test suites — writing, fixing, and modernising TUnit tests for generators, diagnostic analyzers, code fixes, and refactorings, and for adding stage-by-stage incremental cache tests." -tools: - [ - "search/codebase", - "edit/editFiles", - "search", - "execute/getTerminalOutput", - "execute/runInTerminal", - "read/terminalLastCommand", - "read/terminalSelection", - "execute/createAndRunTask", - "execute/runTask", - "read/getTaskOutput", - "vscodeTasks/createAndRunTask", - "vscodeTasks/getTaskOutput", - "vscodeTasks/runTask", - ] ---- - -You are a specialist for `Purview.SourceGeneratorFramework` test authoring. - -## Primary objective - -Produce correct, maintainable TUnit tests for source generators, diagnostic analyzers, code fix -providers, and refactoring providers, and prove incremental pipelines cache correctly. - -## Background knowledge - -Before writing or changing any test, load and apply the `source-generator-testing` skill (runner layer, -result types, `CodeQuery`, options, cache testing) and the `tunit-test-authoring` skill (base classes, -methods, assertion extensions, modernisation checklist). For source-generator emission work, also load the -`source-generator-codewriter-modernization` skill. - -Key rules: - -- Pick the base class by the Roslyn component type: generator → `TUnitSourceGeneratorTestBase` + - `GenerateAsync`; analyzer → `TUnitDiagnosticAnalyzerTestBase` + `AnalyzeAsync`; code fix → - `TUnitCodeFixTestBase` + `ApplyCodeFixAsync`/`ApplyFixAllAsync`; refactor → - `TUnitRefactoringTestBase` + `RefactorAsync`. -- Prefer `CodeQuery` (`result.Generated()` / `result.FixedCode()` with `Get/Has/TryGet`) over - raw-string assertions. -- Prefer the terminal assertion extensions (`HasGeneratedMethod`, `HasGeneratedClass`, …) that return - syntax nodes. -- Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies. -- For incremental pipelines, add a stage-by-stage cache test with `RunIncrementalAsync` / - `GenerateIncrementalAsync`, asserting `New` on first run and `Cached`/`Unchanged` on an identical rerun, - and `Modified` only on the stages whose inputs changed. -- Keep generated-output assertions deterministic (no timestamps); enable CodeWriter scope validation. \ No newline at end of file diff --git a/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md b/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md deleted file mode 100644 index cf463ceb..00000000 --- a/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -agent: ask -description: "Modernise a Roslyn test suite to use CodeQuery + TUnit assertion extensions, and add a stage-by-stage incremental cache test." ---- - -You are modernising tests in this repository. Apply the guidance from the `source-generator-testing` and -`tunit-test-authoring` skills for picking the right base class, querying generated code with `CodeQuery`, -and asserting incremental caching. - -## Inputs - -- Target test file(s): `${input:targetFiles:Path(s) to test file(s)}` -- Roslyn component under test: `${input:componentType:generator|analyzer|codefix|refactor}` (inferred if blank) -- Generator/analyzer/code-fix/refactor type name: `${input:componentName:Component type name}` - -## Task - -Modernise each test so it uses the framework's `CodeQuery` syntax-lookup API and the TUnit assertion -extensions, and add a stage-by-stage cache test proving each incremental pipeline layer caches correctly. - -### Requirements - -1. Choose the correct base class and method for the component type: - - Generator → `TUnitSourceGeneratorTestBase` → `GenerateAsync`. - - Analyzer → `TUnitDiagnosticAnalyzerTestBase` → `AnalyzeAsync`. - - Code fix → `TUnitCodeFixTestBase` → `ApplyCodeFixAsync` / `ApplyFixAllAsync`. - - Refactor → `TUnitRefactoringTestBase` → `RefactorAsync`. -2. Replace `GetGeneratedTree(...)` + `string.Contains(...)` assertions with `CodeQuery` - (`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions - (`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)`) that return the node. -3. Replace signature string checks with `TypeReference` parameter/return-type matching. -4. Ensure options come from a derived `SourceGeneratorTestOptions` record seeding the required namespaces - and additional assemblies; remove per-test duplication. -5. Add an incremental cache test using `RunIncrementalAsync` (or `GenerateIncrementalAsync` on the TUnit - base) with the four scenarios from the skills' "Incremental cache testing" sections - (`ServiceRegistrationCacheTests` / `IncrementalPipelineCacheTests` are the reference pattern): - - 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 every tracked step. -6. Keep changes minimal and behavior equivalent; do not reformat unrelated tests. - -Verify by building the test project and running its suite before finishing. \ No newline at end of file diff --git a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md b/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md deleted file mode 100644 index e230d224..00000000 --- a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -agent: ask -description: "Refactor a legacy source generator emitter from string/StringBuilder to CodeWriter + XmlCodeWriter-style XML extensions with behavior parity." ---- - -You are modernizing a source generator implementation in this repository. Apply the guidance from the `source-generator-codewriter-modernization` skill for incremental pipelines, value equality, deterministic output, and CodeWriter scope safety. - -## Inputs - -- Target file(s): `${input:targetFiles:Path(s) to emitter file(s)}` -- Generator type name: `${input:generatorName:Generator class name}` -- Generator version: `${input:generatorVersion:Version string (for generated attributes/header)}` -- Keep output byte-identical where possible: `${input:preserveFormatting:true|false}` - -## Task - -Refactor the selected legacy emitter implementation from manual `string` / `StringBuilder` output construction to `CodeWriter` and XML documentation extension helpers from `XmlCommentWriter` (XmlCodeWriter-style API usage). - -### Requirements - -1. Use structured declaration APIs where applicable: - - `Class/Struct/RecordClass/Interface/Enum` - - `Method`, `Property`, `Field`, `Constructor` -2. Use XML helper extensions instead of raw `///` composition: - - `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` or `XmlCodeBlock` -3. Use `TypeReference` when type text becomes complex (nullability, generics, arrays). -4. Ensure writer lifetime is output-scoped (`generationContext.CreateCodeWriter()` inside callback). -5. Preserve behavior, diagnostics, and generated names. -6. Keep changes minimal and focused; do not reformat unrelated logic. - -### Migration strategy - -- Identify emitter phases: header, namespace, type declarations, member declarations. -- Replace indentation/braces with scoped APIs. -- Replace signature strings with declaration options. -- Replace XML comments with XmlCommentWriter extension methods. -- Keep semantic equivalence; call out any intentional deltas. - -### Verification - -- Run relevant tests. -- Confirm generated files still compile. -- Confirm no `CodeWriter` scope leaks (`OpenScopeCount == 0` when materialized). - -### Output format - -Return: - -1. Files changed -2. Why each change was necessary -3. Risks/behavior differences (if any) -4. Verification performed diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md deleted file mode 100644 index e234df90..00000000 --- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ /dev/null @@ -1,37 +0,0 @@ -# sdk-diagnose-agent-folder-copy (generic prompt spec) - -Diagnose why the bundled `.agents/**` folder from `Purview.BuildSdk` did not appear at the expected -destination in a consuming repository. - -## Required behaviour - -1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available). -2. Confirm the consuming project is packable/buildable and imports the SDK via - `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build. -3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, - `Directory.Build.props`, or command-line `-p:` overrides). -4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` - folder (override with `PurviewAgentFolderSourcePath`). -5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control - root metadata. -6. If the destination looks stale or incomplete, inspect the change-detection manifest - (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a - matching entry with a present destination file means the sync was skipped as up to date. Delete the - manifest (or the affected destination file) to force a fresh copy. -7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. - A copy that still fails after every retry is reported as an error naming the source, destination and - OS error - search the build log for `The Purview SDK could not copy`. Temporarily set - `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and - `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. -8. Re-run the build and confirm the destination folder now contains the copied files (including the - generated `.gitignore` for skill/prompt/agent subfolders). - -## Suggested output - -- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root - discovery miss, or a destination held open by another process). -- The exact command used to reproduce/verify the fix (for example - `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path, plus whether the - manifest skipped the sync (in which case the content was already up to date). diff --git a/.agents/skills/project-placement-defaults/.gitignore b/.agents/skills/project-placement-defaults/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/project-placement-defaults/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md deleted file mode 100644 index b650da81..00000000 --- a/.agents/skills/project-placement-defaults/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: project-placement-defaults -description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior." ---- - -# Project placement defaults for Purview.BuildSdk - -Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions. - -## Core principle - -Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In Purview-based repos, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides. - -## Placement heuristics - -Use the repository's current structure as the source of truth, with these Purview-friendly defaults: - -1. Prefer source projects under `src/`. -2. Prefer test projects under `tests/`. -3. Place new projects beside similar projects (same language, layer, and test type). -4. Keep one test type per project by default. -5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist. - -When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths: - -- Source/library projects under `src/` -- Test projects under `tests/` -- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/` - -## Purview-specific naming rules - -The SDK relies heavily on project names. - -- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used. -- Use conventional test suffixes such as `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`, and other supported `*Tests` suffixes. -- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior: - - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` - - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers` -- Do not invent near-miss names if you expect the SDK to classify the project automatically. - -## Test-type boundaries - -Separate tests by behavior and dependency scope: - -- **Unit tests**: isolate logic with minimal external dependencies. -- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior). -- **End-to-end/system tests**: verify full workflow behavior across the assembled system. - -If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders. - -The SDK recognizes many test suffixes, including `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, and `Contract`. - -## Naming and namespace defaults - -Align identities with existing repository conventions: - -- Project names should follow prevailing patterns in sibling projects. -- Test project names should clearly indicate scope/type with recognized test suffixes. -- `NamespacePrefix` should remain the root identity source for the repo. -- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. -- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`. -- When moving files between projects, update namespaces so they match the destination project's conventions. - -Do not invent a new naming scheme when an existing one is already in use. - -## Project defaults - -When creating a new project: - -1. Match the SDK/project style used by sibling projects. -2. Reuse central dependency/version management if present. -3. Add only dependencies required for the project's scope. -4. Add the project to the repository solution/workspace entry point. -5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings). - -When working in a Purview-based repo, also assume: - -- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`. -- Test projects receive framework packages and coverage defaults from the SDK. -- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out. - -## Move/split workflow checklist - -When splitting or relocating tests/projects: - -1. Create destination project/folder using established layout patterns. -2. Move files physically. -3. Update namespaces/imports/references for the destination. -4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`. -5. Remove stale dependencies from the source project. -6. Update solution/workspace membership and project references. -6. Run build and relevant tests. - -## Automatic project-reference behavior to preserve - -The SDK automatically searches for project references based on naming and placement. - -- Test projects probe for their target project in these relative locations: - - `../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../$(TargetProjectName)/$(TargetProjectName).csproj` - - `../src/$(TargetProjectName)/$(TargetProjectName).csproj` - - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` -- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`. -- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`. - -If you move projects away from these conventions, be prepared to add explicit project references. - -## Guardrails - -- Prefer minimal, targeted diffs. -- Avoid cross-cutting renames unrelated to the move/split intent. -- Keep test intent unchanged while relocating. -- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary. -- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior. diff --git a/.agents/skills/sdk-configuration-reference/.gitignore b/.agents/skills/sdk-configuration-reference/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/sdk-configuration-reference/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/sdk-project-behavior-and-detection/.gitignore b/.agents/skills/sdk-project-behavior-and-detection/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/sdk-project-behavior-and-detection/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/source-generator-codewriter-modernization/.gitignore b/.agents/skills/source-generator-codewriter-modernization/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/source-generator-codewriter-modernization/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/source-generator-testing/.gitignore b/.agents/skills/source-generator-testing/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/source-generator-testing/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/.agents/skills/tunit-test-authoring/.gitignore b/.agents/skills/tunit-test-authoring/.gitignore deleted file mode 100644 index 27997545..00000000 --- a/.agents/skills/tunit-test-authoring/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Ignore all files -* - -# Don't ignore directories, so Git can traverse them -!*/ - -# Keep this file -!.gitignore \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 3ae076f0..1b19636d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ true 5.9.0 1.67.0 - 1.0.0-prerelease.54 + 1.0.0 10.0.12 10.10.0 1.18.0 diff --git a/global.json b/global.json index 7f367b18..c5bd1b1a 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0" + "Purview.BuildSdk": "1.0.3" }, "test": { "runner": "Microsoft.Testing.Platform" From bd6804a43c2a3a0269d84b2f20b250e9f2ddc97a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 07:34:05 +0100 Subject: [PATCH 2/4] chore: bumped version --- .../sdk-configuration-reference/SKILL.md | 185 +++++++++ .../SKILL.md | 202 ++++++++++ .../SKILL.md | 324 ++++++++++++++++ .../skills/source-generator-testing/SKILL.md | 364 ++++++++++++++++++ .agents/skills/tunit-test-authoring/SKILL.md | 214 ++++++++++ package.json | 2 +- 6 files changed, 1290 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/sdk-configuration-reference/SKILL.md create mode 100644 .agents/skills/sdk-project-behavior-and-detection/SKILL.md create mode 100644 .agents/skills/source-generator-codewriter-modernization/SKILL.md create mode 100644 .agents/skills/source-generator-testing/SKILL.md create mode 100644 .agents/skills/tunit-test-authoring/SKILL.md diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md new file mode 100644 index 00000000..b5162054 --- /dev/null +++ b/.agents/skills/sdk-configuration-reference/SKILL.md @@ -0,0 +1,185 @@ +--- +name: sdk-configuration-reference +description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings." +--- + +# Purview.BuildSdk configuration reference + +Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically. + +## First rule: know where a property must be set + +Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation. + +Common pre-import properties: + +- `NamespacePrefix` +- `UsePackageJsonVersion` +- `RootPackageJson` +- Repo-wide testing framework selection properties when you want every project to inherit them + +If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific. + +## Version detection settings + +These properties control package/app version resolution from `package.json`: + +- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict` +- `RootPackageJson` — explicit path to the `package.json` to read +- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data +- `VersionDetectionCacheFile` — optional explicit cache file path +- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version + +Behavior rules: + +1. If `RootPackageJson` is set, the SDK uses that path. +2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`. +3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field. +4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”. + +## Core identity and build settings + +These are the most important configurable properties exposed by the SDK: + +- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` +- `DisableNamespacePrefixCheck` — default `false` +- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` +- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` +- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. +- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. +- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour +- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation +- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` +- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation +- `AutoIncludeUsings` — default `true`; controls SDK-added global usings +- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub` +- `DisableSourceLink` — default `false` + +## Telemetry and package-related settings + +- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator` +- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway +- `IsPackable` — defaults to `false` if not set elsewhere +- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects +- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead +- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly + +## Test framework settings + +The SDK supports opinionated testing defaults and validation. + +Primary settings: + +- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None` +- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` +- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` + +Related toggles and derived settings: + +- `CollectCoverage` — defaults to `true` for detected test projects +- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects +- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects +- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects + +## Repo bootstrap and developer-experience settings + +These settings control the SDK’s repo-level helper file bootstrapping: + +- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying +- `BootstrapEditorConfigToRepoRoot` — default `true` +- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig` +- `BootstrapGlobalJsonToRepoRoot` — default `true` +- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` +- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` +- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths +- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` +- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` +- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) +- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported +- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts +- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error +- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content +- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt + +## Shared repository copy behaviour + +`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written +by every project, so parallel builds race for the same destinations. The SDK therefore: + +1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. +2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. +3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. +4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. + +`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether +existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. + +**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: + +- `Sdk/.agents/**` → `.agents/**` +- `Sdk/.github/**` → `.github/**` +- `Sdk/build/**` → `build/**` +- `Sdk/buildTransitive/**` → `buildTransitive/**` +- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**` +- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root +- everything else under `Sdk/` → `Sdk/` + +The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content: + +```text[.gitignore] +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore +``` + +This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git. + +## Important derived properties you can inspect + +When explaining SDK behavior, prefer these derived values over guessing: + +- `PurviewLogicalProjectName` +- `PurviewNamespacePrefix` +- `PurviewProjectShortName` +- `PurviewTestType` +- `RootNamespace` +- `AssemblyName` +- `PackageVersion` +- `TestingType` +- `TargetProjectName` +- `RepoRoot` +- `RootPackageJson` + +## Compiler-visible properties + +The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually. + +Especially relevant exported properties include: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` + +## Guidance for edits + +When changing SDK configuration: + +1. Preserve existing defaults unless the task explicitly changes product behavior. +2. Keep README, SDK property declarations, validation, and any shipped skills aligned. +3. If you add a new user-facing property, update both the configuration docs and the bundled skills. +4. If the property affects import-time behavior, document that it must be set before the SDK import. diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md new file mode 100644 index 00000000..cdd0be36 --- /dev/null +++ b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md @@ -0,0 +1,202 @@ +--- +name: sdk-project-behavior-and-detection +description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions." +--- + +# Purview.BuildSdk project behavior and detection + +Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on Purview’s naming and project-type inference. + +## Project-type detection rules + +The SDK infers behavior from project names, project contents, and SDK declarations. + +### Test detection + +A project is treated as a test project when its name ends with `*Test` or `*Tests` and the suffix before `Test(s)` matches a supported testing type such as: + +- `Unit` +- `Integration` +- `E2E` +- `EndToEnd` +- `Acceptance` +- `Functional` +- `Performance` +- `Load` +- `Smoke` +- `Stress` +- `Regression` +- `Security` +- `Chaos` +- `Scenario` +- `System` +- `Threat` +- `BlackBox` +- `WhiteBox` +- `Accessibility` +- `Interactive` +- `Environment` +- `Architecture` +- `Contract` + +Derived properties: + +- `IsTestProject=true` +- `TestingType=` +- `PurviewTestType=Tests` +- `TargetProjectName=` + +### Shared project detection + +The SDK recognizes shared project names exactly. These are not generic substring matches. + +Shared project names: + +- `Shared` +- `SharedFramework` +- `SharedInfrastructure` +- `SharedInfra` +- `SharedUtilities` +- `SharedUtils` +- `SharedLibrary` +- `SharedLib` +- `SharedHelpers` + +Shared testing project names: + +- `SharedTestingFramework` +- `SharedTestingInfrastructure` +- `SharedTestingInfra` +- `SharedTestingUtilities` +- `SharedTestingUtils` +- `SharedTestingLibrary` +- `SharedTestingLib` +- `SharedTestingHelpers` + +Derived flags: + +- `IsSharedProject` +- `IsSharedTestingProject` + +### SDK/content-based detection + +- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration +- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web` +- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker` +- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` +- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory +- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts` + +## Namespace and identity behavior + +The SDK derives the project identity from `NamespacePrefix` and the project name. + +Key behavior: + +1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. +4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win. +6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. +7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats 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. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles. + +Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. + +## Automatic project references + +The SDK adds project references based on layout conventions. + +### Non-test projects + +For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects: + +- `../Shared*/Shared*.csproj` + +It also removes accidental self/shared-testing matches. + +### Test projects + +For detected test projects, it attempts these target-project paths in order when they exist: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +It also adds sibling shared-testing project references via: + +- `../SharedTesting*/SharedTesting*.csproj` + +This is why consistent naming and placement matter so much in repos that use the SDK. + +## Automatic framework/package behavior + +### For non-test C# projects + +- Adds SourceLink unless `DisableSourceLink=true` +- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` +- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled +- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` + +### For packable projects + +- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value +- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly +- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed +- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently + +### For Roslyn component (analyzer/source-generator) projects + +- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true` +- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging. +- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into +- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec +- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` +- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks + +### For test and shared-testing projects + +- Applies test-friendly `NoWarn` defaults +- Marks projects as not packable/publishable +- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework` +- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically +- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework + +### For special project types + +- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content +- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references +- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode +- Aspire host projects default to `OutputType=Exe` + +## How to reason about surprising behavior + +If the SDK “did something unexpected”, inspect these values first: + +- `MSBuildProjectName` +- `NamespacePrefix` +- `PurviewLogicalProjectName` +- `RootNamespace` +- `TestingType` +- `TargetProjectName` +- `SdkProjectName` +- `IsTestProject` +- `IsSharedProject` +- `IsSharedTestingProject` +- `IsContainerProject` +- `IsCLIProject` +- `IsWebSdkProject` +- `IsAspireHostProject` + +Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone. + +## Guidance for structural changes + +When adding or moving projects in a repo using this SDK: + +1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation. +2. Preserve established `src/` and `tests/`-style layouts whenever possible. +3. Use test project suffixes intentionally so auto-detection and auto-references work. +4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. +5. If you change a naming rule in the SDK, update the README and the shipped skills together. diff --git a/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/.agents/skills/source-generator-codewriter-modernization/SKILL.md new file mode 100644 index 00000000..8501ca40 --- /dev/null +++ b/.agents/skills/source-generator-codewriter-modernization/SKILL.md @@ -0,0 +1,324 @@ +--- +name: source-generator-codewriter-modernization +description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices." +--- + +# 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. + +## 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/source-generator-testing/SKILL.md b/.agents/skills/source-generator-testing/SKILL.md new file mode 100644 index 00000000..2a565445 --- /dev/null +++ b/.agents/skills/source-generator-testing/SKILL.md @@ -0,0 +1,364 @@ +--- +name: source-generator-testing +description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching." +--- + +# 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 +`sdk` package's `tunit-test-authoring` skill. + +## 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 `tunit-test-authoring`): +`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. + +## 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). + +## 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. + +### 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/tunit-test-authoring/SKILL.md b/.agents/skills/tunit-test-authoring/SKILL.md new file mode 100644 index 00000000..fa58cd8f --- /dev/null +++ b/.agents/skills/tunit-test-authoring/SKILL.md @@ -0,0 +1,214 @@ +--- +name: tunit-test-authoring +description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests." +--- + +# 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 +`sdk` package's `source-generator-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. + +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 `source-generator-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 `source-generator-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`. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `source-generator-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 `source-generator-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/package.json b/package.json index f9095d67..dd797a72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-telemetry-sourcegenerator", - "version": "5.0.0", + "version": "5.0.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 5728a7c8f9a2bb141edbd6a104415ae6cd07fb84 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 07:43:28 +0100 Subject: [PATCH 3/4] refactor: fixed nested type issues with extension keyword --- .../sdk-configuration-reference/SKILL.md | 19 ++ .../SKILL.md | 14 +- .../Services/IWeatherServiceTelemetry.cs | 2 +- .../ConfigurationExtensions.cs | 13 +- .../ErrorOrExtensions.cs | 19 +- .../HostApplicationBuilderExtensions.cs | 252 +++++++++--------- .../SampleApp.ServiceDefaults.csproj | 6 +- .../WebApplicationExtensions.cs | 46 ++-- .../Records/TelemetryRules.Activities.cs | 1 + 9 files changed, 197 insertions(+), 175 deletions(-) diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md index b5162054..5dd68c61 100644 --- a/.agents/skills/sdk-configuration-reference/SKILL.md +++ b/.agents/skills/sdk-configuration-reference/SKILL.md @@ -43,11 +43,15 @@ These are the most important configurable properties exposed by the SDK: - `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` - `DisableNamespacePrefixCheck` — default `false` +- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored - `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` - `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` - `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging. - `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. - `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour +- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape +- `PurviewTestContextNoWarn` — default `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/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set +- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well - `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation - `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` - `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation @@ -74,6 +78,16 @@ Primary settings: - `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None` - `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None` +Default outcome for standard test projects: + +- `TUnit` +- `TUnit.Mocks` +- `Bogus` +- Microsoft.Testing.Platform integration + +Specialised packages such as `TUnit.Aspire` and `Testcontainers` are not automatic defaults; they remain +explicit choices based on the project's purpose. + Related toggles and derived settings: - `CollectCoverage` — defaults to `true` for detected test projects @@ -147,6 +161,9 @@ When explaining SDK behavior, prefer these derived values over guessing: - `PurviewNamespacePrefix` - `PurviewProjectShortName` - `PurviewTestType` +- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape) +- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects) +- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts) - `RootNamespace` - `AssemblyName` - `PackageVersion` @@ -183,3 +200,5 @@ When changing SDK configuration: 2. Keep README, SDK property declarations, validation, and any shipped skills aligned. 3. If you add a new user-facing property, update both the configuration docs and the bundled skills. 4. If the property affects import-time behavior, document that it must be set before the SDK import. +5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level + property explanations aligned with the wiki reference pages. diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md index cdd0be36..9961d001 100644 --- a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md +++ b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md @@ -5,7 +5,7 @@ description: "Use when explaining why Purview.BuildSdk classified a project as t # Purview.BuildSdk project behavior and detection -Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on Purview’s naming and project-type inference. +Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference. ## Project-type detection rules @@ -163,6 +163,16 @@ This is why consistent naming and placement matter so much in repos that use the - For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically - For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework +For the default configuration, standard test projects receive: + +- `TUnit` +- `TUnit.Mocks` +- `Bogus` +- Microsoft.Testing.Platform integration + +Specialized testing dependencies such as `TUnit.Aspire` and `Testcontainers` are still explicit additions by +project purpose. + ### For special project types - CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content @@ -200,3 +210,5 @@ When adding or moving projects in a repo using this SDK: 3. Use test project suffixes intentionally so auto-detection and auto-references work. 4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior. 5. If you change a naming rule in the SDK, update the README and the shipped skills together. +6. If the question is really about repository policy rather than one computed property, point the user to the + engineering-principles documentation first, then explain the specific SDK mechanics. diff --git a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs index 20598bfe..b09aa1af 100644 --- a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs +++ b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs @@ -26,7 +26,7 @@ public interface IWeatherServiceTelemetry void ForecastReceived(Activity? activity, int minTempInC, int maxTempInC); // --> SINGLE-TARGET: Event (Error) - [Event(ActivityStatusCode.Error)] + [Event("exception", StatusCode = ActivityStatusCode.Error)] void FailedToRetrieveForecast(Activity? activity, Exception exception); // --> SINGLE-TARGET: Event (Ok) diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs index a4569c46..e72ddb32 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs +++ b/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs @@ -4,12 +4,9 @@ namespace Microsoft.Extensions.Configuration; public static class ConfigurationExtensions { - extension([NotNull] IConfiguration configuration) - { - public string GetRequiredValue(string name) => - configuration[name] - ?? throw new InvalidOperationException( - $"Configuration missing value for: {(configuration is IConfigurationSection s ? s.Path + ":" + name : name)}" - ); - } + public static string GetRequiredValue([NotNull] this IConfiguration configuration, string name) => + configuration[name] + ?? throw new InvalidOperationException( + $"Configuration missing value for: {(configuration is IConfigurationSection s ? s.Path + ":" + name : name)}" + ); } diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs index 37914efc..02922603 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs +++ b/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -7,16 +7,13 @@ namespace ErrorOr; [EditorBrowsable(EditorBrowsableState.Never)] public static class ErrorOrExtensions { - extension(Error error) - { - public ProblemDetails ToProblemDetails() => - new() - { - Status = GetStatusCode(error), - Title = error.Code, - Detail = error.Description, - }; - } + public static ProblemDetails ToProblemDetails(this Error error) => + new() + { + Status = GetStatusCode(error), + Title = error.Code, + Detail = error.Description, + }; static int GetStatusCode(Error error) { diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs index b8accf73..c94f41ae 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs +++ b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs @@ -15,151 +15,151 @@ namespace Microsoft.Extensions.Hosting; [EditorBrowsable(EditorBrowsableState.Never)] public static class HostApplicationBuilderExtensions { - extension([NotNull] IHostApplicationBuilder builder) + public static IHostApplicationBuilder AddServiceDefaults( + [NotNull] this IHostApplicationBuilder builder, + string[]? meterNames = null, + string[]? activitySourceNames = null, + bool throwOnMissingOpenApiConfig = true + ) { - public IHostApplicationBuilder AddServiceDefaults( - string[]? meterNames = null, - string[]? activitySourceNames = null, - bool throwOnMissingOpenApiConfig = true - ) + ConfigureOpenTelemetry(builder, meterNames, activitySourceNames); + AddDefaultHealthChecks(builder); + AddDefaultOpenAPI(builder, throwOnMissing: throwOnMissingOpenApiConfig); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => { - builder.ConfigureOpenTelemetry(meterNames, activitySourceNames); + // Turn on resilience by default + http.AddStandardResilienceHandler(); - builder.AddDefaultHealthChecks().AddDefaultOpenAPI(throwOnMissing: throwOnMissingOpenApiConfig); + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); - builder.Services.AddServiceDiscovery(); + return builder; + } - builder.Services.ConfigureHttpClientDefaults(http => + static IHostApplicationBuilder ConfigureOpenTelemetry( + IHostApplicationBuilder builder, + string[]? meterNames = null, + string[]? activitySourceNames = null + ) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + var metricsAssembly = Assembly.GetEntryAssembly()!.GetName().Name!; + builder + .Services.AddOpenTelemetry() + .WithMetrics(metrics => + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddProcessInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(meterNames ?? []) + ) + .WithTracing(tracing => { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); + if (builder.Environment.IsDevelopment()) + { + // We want to view all traces in development + tracing.SetSampler(new AlwaysOnSampler()); + } + + tracing + .AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddSource(activitySourceNames ?? []); }); - return builder; - } + AddOpenTelemetryExporters(builder); - IHostApplicationBuilder ConfigureOpenTelemetry( - string[]? meterNames = null, - string[]? activitySourceNames = null - ) + return builder; + } + + static IHostApplicationBuilder AddDefaultHealthChecks(IHostApplicationBuilder builder) + { + builder + .Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + static IHostApplicationBuilder AddDefaultOpenAPI( + IHostApplicationBuilder builder, + IApiVersioningBuilder? apiVersioning = default, + bool throwOnMissing = true + ) + { + var openApiSection = builder.Configuration.GetSection("OpenAPI"); + if (!openApiSection.Exists()) { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); + return throwOnMissing + ? throw new InvalidOperationException("OpenAPI configuration section is missing.") + : builder; + } - var metricsAssembly = Assembly.GetEntryAssembly()!.GetName().Name!; - builder - .Services.AddOpenTelemetry() - .WithMetrics(metrics => - metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddProcessInstrumentation() - .AddRuntimeInstrumentation() - .AddMeter(meterNames ?? []) - ) - .WithTracing(tracing => + // the default format will just be ApiVersion.ToString(); for example, 1.0. + // this will format the version as "'v'major[.minor][-status]" + var versioned = apiVersioning?.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV"); + string[] versions = ["v1"]; + foreach (var description in versions) + { + builder.Services.AddOpenApi( + description, + options => { - if (builder.Environment.IsDevelopment()) - { - // We want to view all traces in development - tracing.SetSampler(new AlwaysOnSampler()); - } - - tracing - .AddAspNetCoreInstrumentation() - .AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation() - .AddSource(activitySourceNames ?? []); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; + options.ApplyAPIVersionInfo( + openApiSection.GetRequiredValue("Document:Title"), + openApiSection.GetRequiredValue("Document:Description") + ); + + // Clear out the default servers so we can fallback to + // whatever ports have been allocated for the service by Aspire + options.AddDocumentTransformer( + (document, _, _) => + { + document.Servers = []; + return Task.CompletedTask; + } + ); + } + ); } - IHostApplicationBuilder AddDefaultHealthChecks() - { - builder - .Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + return builder; + } - return builder; - } + static IHostApplicationBuilder AddOpenTelemetryExporters(IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - IHostApplicationBuilder AddDefaultOpenAPI( - IApiVersioningBuilder? apiVersioning = default, - bool throwOnMissing = true - ) + if (useOtlpExporter) { - var openApiSection = builder.Configuration.GetSection("OpenAPI"); - if (!openApiSection.Exists()) - { - return throwOnMissing - ? throw new InvalidOperationException("OpenAPI configuration section is missing.") - : builder; - } - - // the default format will just be ApiVersion.ToString(); for example, 1.0. - // this will format the version as "'v'major[.minor][-status]" - var versioned = apiVersioning?.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV"); - string[] versions = ["v1"]; - foreach (var description in versions) - { - builder.Services.AddOpenApi( - description, - options => - { - options.ApplyAPIVersionInfo( - openApiSection.GetRequiredValue("Document:Title"), - openApiSection.GetRequiredValue("Document:Description") - ); - - // Clear out the default servers so we can fallback to - // whatever ports have been allocated for the service by Aspire - options.AddDocumentTransformer( - (document, _, _) => - { - document.Servers = []; - return Task.CompletedTask; - } - ); - } - ); - } - - return builder; + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } - IHostApplicationBuilder AddOpenTelemetryExporters() - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .WithMetrics(metrics => metrics.AddPrometheusExporter()); - if (useOtlpExporter) - { - builder.Services.Configure(logging => logging.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - } - - // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - // builder.Services.AddOpenTelemetry() - // .WithMetrics(metrics => metrics.AddPrometheusExporter()); - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; } } diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj b/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj index 3c0c937d..c4d1b8c7 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj +++ b/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj @@ -9,12 +9,10 @@ (Microsoft.Extensions.*, Scalar.AspNetCore, etc.), so the SDK's namespace-prefix global using is not resolvable here. --> false - - $(NoWarn);CA1034;IDE0130;AV0027;AV0029;AV0030; + $(NoWarn);IDE0130;AV0027;AV0029;AV0030; diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs index c45ef28b..9695279d 100644 --- a/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs +++ b/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -8,39 +9,36 @@ namespace Microsoft.AspNetCore.Builder; public static class WebApplicationExtensions { - extension(WebApplication app) + public static WebApplication MapDefaultEndpoints([NotNull] this WebApplication app) { - public WebApplication MapDefaultEndpoints() - { - // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) - // app.MapPrometheusScrapingEndpoint(); + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); - app.UseDefaultOpenAPI(); + UseDefaultOpenAPI(app); - return app; - } + return app; + } - void UseDefaultOpenAPI() - { - var configuration = app.Configuration; - var openApiSection = configuration.GetSection("OpenAPI"); + static void UseDefaultOpenAPI(WebApplication app) + { + var configuration = app.Configuration; + var openApiSection = configuration.GetSection("OpenAPI"); - if (!openApiSection.Exists()) - return; + if (!openApiSection.Exists()) + return; - app.MapOpenApi("v1"); + app.MapOpenApi("v1"); - if (app.Environment.IsDevelopment()) - { - app.MapScalarApiReference(); - app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription(); - } + if (app.Environment.IsDevelopment()) + { + app.MapScalarApiReference(); + app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription(); } } } diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index 2c2649d2..9ac29c5b 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -385,6 +385,7 @@ out var attributeData if (!IsStandardExceptionEventName(GetLogEntryName(methodSymbol, attributeData!, token))) return string.Empty; + // The log entry name is the same as the activity event name, so no hint is needed. return $" The Name on [{matchingType.RenderAttributeTypeName}] renames the log entry, not the activity event."; } From 17feb5d2c2e25fbcd3e2fccc32b9cdfc04de7273 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 07:53:37 +0100 Subject: [PATCH 4/4] fix: fixed suggestions about public types --- .agents/agents/test-author-writer.agent.md | 50 +++++++++++++++++++ ...odernize-test-to-codequery-tunit.prompt.md | 48 ++++++++++++++++++ samples/SampleApp/Directory.Build.props | 3 +- .../Services/IWeatherService.cs | 4 +- .../Services/IWeatherServiceTelemetry.cs | 2 +- .../Clients/IWeatherAPIClientTelemetry.cs | 4 +- .../SampleApp.Web/Clients/WeatherAPIClient.cs | 2 +- 7 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 .agents/agents/test-author-writer.agent.md create mode 100644 .agents/prompts/modernize-test-to-codequery-tunit.prompt.md diff --git a/.agents/agents/test-author-writer.agent.md b/.agents/agents/test-author-writer.agent.md new file mode 100644 index 00000000..fb5fb87d --- /dev/null +++ b/.agents/agents/test-author-writer.agent.md @@ -0,0 +1,50 @@ +--- +name: Test Author Writer +description: "Specialist for Purview.SourceGeneratorFramework test suites — writing, fixing, and modernising TUnit tests for generators, diagnostic analyzers, code fixes, and refactorings, and for adding stage-by-stage incremental cache tests." +tools: + [ + "search/codebase", + "edit/editFiles", + "search", + "execute/getTerminalOutput", + "execute/runInTerminal", + "read/terminalLastCommand", + "read/terminalSelection", + "execute/createAndRunTask", + "execute/runTask", + "read/getTaskOutput", + "vscodeTasks/createAndRunTask", + "vscodeTasks/getTaskOutput", + "vscodeTasks/runTask", + ] +--- + +You are a specialist for `Purview.SourceGeneratorFramework` test authoring. + +## Primary objective + +Produce correct, maintainable TUnit tests for source generators, diagnostic analyzers, code fix +providers, and refactoring providers, and prove incremental pipelines cache correctly. + +## Background knowledge + +Before writing or changing any test, load and apply the `source-generator-testing` skill (runner layer, +result types, `CodeQuery`, options, cache testing) and the `tunit-test-authoring` skill (base classes, +methods, assertion extensions, modernisation checklist). For source-generator emission work, also load the +`source-generator-codewriter-modernization` skill. + +Key rules: + +- Pick the base class by the Roslyn component type: generator → `TUnitSourceGeneratorTestBase` + + `GenerateAsync`; analyzer → `TUnitDiagnosticAnalyzerTestBase` + `AnalyzeAsync`; code fix → + `TUnitCodeFixTestBase` + `ApplyCodeFixAsync`/`ApplyFixAllAsync`; refactor → + `TUnitRefactoringTestBase` + `RefactorAsync`. +- Prefer `CodeQuery` (`result.Generated()` / `result.FixedCode()` with `Get/Has/TryGet`) over + raw-string assertions. +- Prefer the terminal assertion extensions (`HasGeneratedMethod`, `HasGeneratedClass`, …) that return + syntax nodes. +- Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies. +- For incremental pipelines, add a stage-by-stage cache test with `RunIncrementalAsync` / + `GenerateIncrementalAsync`, asserting `New` on first run and `Cached`/`Unchanged` on an identical rerun, + and `Modified` only on the stages whose inputs changed. +- Keep generated-output assertions deterministic (no timestamps); enable CodeWriter scope validation. \ No newline at end of file diff --git a/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md b/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md new file mode 100644 index 00000000..cf463ceb --- /dev/null +++ b/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md @@ -0,0 +1,48 @@ +--- +agent: ask +description: "Modernise a Roslyn test suite to use CodeQuery + TUnit assertion extensions, and add a stage-by-stage incremental cache test." +--- + +You are modernising tests in this repository. Apply the guidance from the `source-generator-testing` and +`tunit-test-authoring` skills for picking the right base class, querying generated code with `CodeQuery`, +and asserting incremental caching. + +## Inputs + +- Target test file(s): `${input:targetFiles:Path(s) to test file(s)}` +- Roslyn component under test: `${input:componentType:generator|analyzer|codefix|refactor}` (inferred if blank) +- Generator/analyzer/code-fix/refactor type name: `${input:componentName:Component type name}` + +## Task + +Modernise each test so it uses the framework's `CodeQuery` syntax-lookup API and the TUnit assertion +extensions, and add a stage-by-stage cache test proving each incremental pipeline layer caches correctly. + +### Requirements + +1. Choose the correct base class and method for the component type: + - Generator → `TUnitSourceGeneratorTestBase` → `GenerateAsync`. + - Analyzer → `TUnitDiagnosticAnalyzerTestBase` → `AnalyzeAsync`. + - Code fix → `TUnitCodeFixTestBase` → `ApplyCodeFixAsync` / `ApplyFixAllAsync`. + - Refactor → `TUnitRefactoringTestBase` → `RefactorAsync`. +2. Replace `GetGeneratedTree(...)` + `string.Contains(...)` assertions with `CodeQuery` + (`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions + (`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)`) that return the node. +3. Replace signature string checks with `TypeReference` parameter/return-type matching. +4. Ensure options come from a derived `SourceGeneratorTestOptions` record seeding the required namespaces + and additional assemblies; remove per-test duplication. +5. Add an incremental cache test using `RunIncrementalAsync` (or `GenerateIncrementalAsync` on the TUnit + base) with the four scenarios from the skills' "Incremental cache testing" sections + (`ServiceRegistrationCacheTests` / `IncrementalPipelineCacheTests` are the reference pattern): + - 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 every tracked step. +6. Keep changes minimal and behavior equivalent; do not reformat unrelated tests. + +Verify by building the test project and running its suite before finishing. \ No newline at end of file diff --git a/samples/SampleApp/Directory.Build.props b/samples/SampleApp/Directory.Build.props index e681179d..d1dd7909 100644 --- a/samples/SampleApp/Directory.Build.props +++ b/samples/SampleApp/Directory.Build.props @@ -9,13 +9,12 @@ true - $(NoWarn);CA1031;CA1515;CA1724;CA2007;CA2201;TSG2008; + $(NoWarn);CA1031;CA1724;CA2007;CA2201;TSG2008; diff --git a/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs b/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs index 21d5f211..092ddc2f 100644 --- a/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs +++ b/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs @@ -1,6 +1,6 @@ -namespace SampleApp.APIService.Services; +namespace SampleApp.APIService.Services; -public interface IWeatherService +interface IWeatherService { Task>> GetWeatherForecastsAsync( int requestCount, diff --git a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs index b09aa1af..e06a898a 100644 --- a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs +++ b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs @@ -13,7 +13,7 @@ namespace SampleApp.APIService.Services; [ActivitySource] [Logger] [Meter] -public interface IWeatherServiceTelemetry +interface IWeatherServiceTelemetry { // --> MULTI-TARGET: Activity, with Trace log entry // Single Activity method diff --git a/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs b/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs index 4020b585..2ce209fe 100644 --- a/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs +++ b/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs @@ -7,14 +7,14 @@ namespace SampleApp.Web.Clients; [ActivitySource] [Logger] [Meter(InstrumentPrefix = "weather")] -public interface IWeatherAPIClientTelemetry +interface IWeatherAPIClientTelemetry { [Activity(ActivityKind.Client)] [Info] [AutoCounter] Activity? GetWeatherForecasts(int? count); - [Event] + [Event("exception")] [Error] [AutoCounter] void FailedToGetForecast(Activity? activity, Exception ex, [ExcludeTargets(Targets.Activities)] int? count); diff --git a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs index f4be1c36..d4a9f020 100644 --- a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs +++ b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs @@ -4,7 +4,7 @@ namespace SampleApp.Web.Clients; /// Typed HTTP client for communicating with the Weather API service. /// Uses Aspire service discovery and HTTP resiliency (retries, circuit breaker). /// -public sealed class WeatherAPIClient(HttpClient httpClient, IWeatherAPIClientTelemetry telemetry) +sealed class WeatherAPIClient(HttpClient httpClient, IWeatherAPIClientTelemetry telemetry) { /// /// Gets weather forecasts from the API service.