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/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-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md new file mode 100644 index 00000000..5dd68c61 --- /dev/null +++ b/.agents/skills/sdk-configuration-reference/SKILL.md @@ -0,0 +1,204 @@ +--- +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` +- `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 +- `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` + +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 +- `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` +- `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` +- `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. +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/.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/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md new file mode 100644 index 00000000..9961d001 --- /dev/null +++ b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md @@ -0,0 +1,214 @@ +--- +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 the SDK'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 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 +- 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. +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/.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-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/.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/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/.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/.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/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" 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", 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 20598bfe..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 @@ -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/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. 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."; }