From 10747bbf1b845c3bba959213e69e60e38f3a0b0c Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 6 Oct 2026 23:19:50 +0100 Subject: [PATCH] feat: add curated target framework sets and stop stripping Roslyn component namespaces Roslyn component names are no longer stripped from RootNamespace. SourceGenerator, SourceGenerators, SourceGeneration, Generators, Analyzers, CodeFixers and CodeFixes were in the suffix list, and because the stripped value is what reaches the compiler as build_property.RootNamespace, every file whose namespace kept the suffix failed IDE0130. That was not an edge case: it broke five of the six repositories consuming this SDK, each of which keeps the suffix because a generator, its analyzers and its code fixes are separate assemblies that each need their own namespace. A component's suffix is part of its identity rather than noise. Add one back per project with NamespaceRemoveSuffix Include if a repository really wants it collapsed. Curated target framework sets replace a hard-coded literal as the way a project's targets are decided: Supported Current net10.0 (the default) Latest net11.0 Supported net10.0;net11.0 Broad net8.0;net9.0;net10.0 every shipped release All net8.0;net9.0;net10.0;net11.0 Broad plus the newest major A multi-targeting repository otherwise hard-codes its TFM list in every project, so a lifecycle change is an edit in every repository that has to be found and kept consistent. 'Current' applies when nothing is selected and resolves to the same single TFM this SDK used to hard-code, so the out-of-box result is unchanged. Broad and All differ by one real product decision - whether a package commits to the newest release - so they are separate names rather than one set that silently widens every consumer. Overrides are layered: an explicit TargetFramework/TargetFrameworks in the project always wins, then PurviewTargetFrameworks redefines a set for a repository, then the definitions above. A single-entry set resolves to TargetFramework so it does not pay for an outer build. An unrecognised name fails with PurviewInvalidTargetFrameworkSet rather than silently falling back and dropping every target asked for. Roslyn components keep netstandard2.0; one that declares a set itself is told through PurviewTargetFrameworkSetIgnored, while inheriting a repository-wide selection stays silent because that is the normal arrangement. Fixed: an explicitly declared RootNamespace was overwritten. FixRootNamespaceTarget runs after the project body, so it could not tell an author's value from a derived one and rewrote Foo.CodeFixers to Foo - then every file in it failed IDE0130 against a namespace nobody asked for. The declaration is now recorded during evaluation and the target leaves it alone. Fixed: project detection read commented-out declarations. The SDK is imported before the project body is evaluated, so TargetFramework, TargetFrameworks, IsRoslynComponent, IsRoslynComponentOnly and IsPackable are read from the project XML. XML comments were not stripped, so a TargetFramework left inside a comment counted as a real declaration - and for a Roslyn component that meant skipping the netstandard2.0 default, letting the generic .NET default fill in, and shipping an analyzer the IDE cannot load. Comments are stripped now. Only unconditional declarations are matched, because a Condition cannot be evaluated that early and guessing either way is worse than not matching; a declaration that exists *only* under a Condition is reported as PurviewConditionedProjectDeclaration. Declaring a property unconditionally and then augmenting it conditionally is correct and stays silent. Fixed: 168 InternalsVisibleTo attributes per shipped assembly, 53 of them malformed. Three grants were emitted per test type - from AssemblyName, TargetProjectName and MSBuildProjectName - with no empty guard, and TargetProjectName has no default, so every test type produced a grant with an empty assembly name ('.UnitTests'). The three are usually the same string, so each real grant was emitted up to three times. Measured on a project using this SDK: 168 grants down to 116, malformed 53 down to 0, duplicates 0. The existing tests only asserted the attribute type appeared, which is why this went unnoticed; the generated values are asserted now. Fixed: mirrored .agents content dirtied every consuming repository. The packaged blanket .gitignore is only written for a folder this SDK wholly owns - a skill is its own folder - so files mirrored directly into .agents/agents and .agents/prompts received none and stayed tracked, and an SDK upgrade that changed a prompt showed up as a local modification. A blanket rule there is not an option: a consuming repository keeps its own authored prompts and agents beside the mirrored ones. The sync now writes .agents/.gitignore from the manifest it already builds, listing only the mirrored paths, so authored files keep their normal status. Both that file and the packaged per-skill one ignore themselves rather than being committed - '!.gitignore' left a mirrored, never-committed file permanently untracked, and because the nearest .gitignore wins it also overrode the generated parent list. Tests: 436 passing, up from 409. The new coverage is the part that was missing rather than an addition for its own sake - the detection, namespace and InternalsVisibleTo behaviour had no assertions on observable output, which is precisely why these defects survived. --- docs/wiki/Configuration-Reference.md | 62 ++++- package.json | 2 +- src/src/BuildSdk/Sdk/Props/Defaults.props | 6 + src/src/BuildSdk/Sdk/Sdk.props | 158 +++++++++++- src/src/BuildSdk/Sdk/Sdk.targets | 243 +++++++++++++++++- .../AgentPackFolderTests.cs | 93 ++++++- .../InternalsVisibleToTests.cs | 87 +++++++ .../RepositoryFileSyncTests.cs | 50 ++++ .../RootNamespaceNamingTests.cs | 90 ++++++- .../RoslynComponentDefaultsTests.cs | 105 ++++++++ .../TargetFrameworkSetTests.cs | 210 +++++++++++++++ 11 files changed, 1078 insertions(+), 28 deletions(-) create mode 100644 src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 0e28633..38a82a2 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -24,7 +24,8 @@ See [Version Detection](Version-Detection.md) for the full resolution rules. | `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | Production API-surface rules exempted in test and shared-testing projects (they keep the strict style contract). Override before the SDK import to narrow or extend the list. | | `DisablePurviewTestContextRuleSet` | `false` | Set to `true` to make test and shared-testing projects enforce the production API-surface rules as well. | | `PurviewSharedTestingOutputType` | `Library` | Output type forced on `IsSharedTestingProject` projects; `Library` also clears `IsTestProject`/`IsTestingPlatformApplication`. Set to `Exe` before the SDK import to keep the test packages' executable/test-host shape. | -| `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. | +| `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. To multi-target from a central, overridable definition rather than a hard-coded list, see [Target framework sets](#target-framework-sets). | +| `DisableUnconditionalProjectDeclarationCheck` | `false` | Set to `true` to suppress `PurviewConditionedProjectDeclaration`. The SDK reads `TargetFramework`, `TargetFrameworks`, `IsRoslynComponent`, `IsRoslynComponentOnly`, `IsPackable` and `PurviewTargetFrameworkSet` from the project XML before the project body is evaluated, so it cannot evaluate a `Condition` on them and will not see the declaration — the SDK default applies instead. Declare those properties unconditionally, or set them in a file imported before this SDK. | | `IsRoslynComponent` | `false` | 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`, compiler-generated output under the intermediate directory, no dependency file, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly and its PDB into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols). Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | | `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. | | `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider. Set to `Microsoft.SourceLink.AzureDevOps.Git` for ADO repos. | @@ -33,7 +34,64 @@ See [Version Detection](Version-Detection.md) for the full resolution rules. | `DisableProjectFileNamingConventionCheck` | `false` | Set to `true` to disable the validation that requires `MyProject\MyProject.csproj` naming alignment. | | `DisableGenerateAssemblyInfoClass` | `false` | Set to `true` to disable the generated `AssemblyInfo` helper source. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | -| `NamespaceRemoveSuffix` | *(built-in list)* | Item type listing the suffixes stripped from `RootNamespace`. Remove an entry **after** the `Sdk.props` import to keep that suffix in the namespace, e.g. ``. See [Namespace stripping](Assembly-Name-Generation.md#namespace-stripping). | +| `NamespaceRemoveSuffix` | *(built-in list)* | Item type listing the suffixes stripped from `RootNamespace`. Remove an entry **after** the `Sdk.props` import to keep that suffix in the namespace, e.g. ``. **Roslyn component names are not in the list** — `SourceGenerator`, `SourceGenerators`, `SourceGeneration`, `Generators`, `Analyzers`, `CodeFixers` and `CodeFixes` are part of a component's identity, and a generator, its analyzers and its code fixes are separate assemblies that each need their own namespace. Add one with `` if a repository really wants it collapsed. See [Namespace stripping](Assembly-Name-Generation.md#namespace-stripping). | + +## Target framework sets + +A multi-targeting repository otherwise hard-codes its TFM list in every project, so a lifecycle change — +a release leaving support, a new one arriving — is an edit in every repository that has to be found and +kept consistent. Name a set instead and the decision moves into the SDK, so bumping `Purview.BuildSdk` +moves every consumer at once: + +```xml + + Supported + +``` + +| Set | Resolves to | Use for | +| -- | -- | -- | +| `Current` | `net10.0` | The default. A single target, stated explicitly. | +| `Latest` | `net11.0` | Newest release only. | +| `Supported` | `net10.0;net11.0` | A package that should work on anything still in support. | +| `Broad` | `net8.0;net9.0;net10.0` | Every shipped release — widest reach without committing to the newest major while it is pre-GA. | +| `All` | `net8.0;net9.0;net10.0;net11.0` | `Broad` plus the newest major. | + +`net8.0` and `net9.0` are deliberately outside `Supported`: both are at or near end of support, so a new +package should not imply a commitment to them. Choose `Broad` to keep them. + +`Broad` and `All` differ by one real product decision — whether the package commits to the newest +release — so they are separate names rather than one set that silently widens every consumer of it. + +A set is **always** in play: `Current` applies when nothing is selected, which resolves to the same +single TFM the SDK used to hard-code. The out-of-box result is therefore unchanged — but the value is +named and overridable in one place, so moving a repository (or every repository) between runtimes is a +property change rather than an edit in each project. + +Overrides are layered, narrowest first: + +1. an explicit `TargetFramework`/`TargetFrameworks` in the project always wins; +2. a repository can redefine any set with `PurviewTargetFrameworks` — `PurviewTargetFrameworksSupported`, + `PurviewTargetFrameworksBroad`, and so on — set before the SDK import. This is also how a set is pinned + while a consumer is not ready to follow the SDK's definition; +3. otherwise the table above applies. + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewTargetFrameworkSet` | `Current` | `Current`, `Latest`, `Supported`, `Broad` or `All`. An unrecognised name fails the build with `PurviewInvalidTargetFrameworkSet` rather than silently falling back to the single-TFM default. A single-entry set resolves to `TargetFramework`, so it does not pay for an outer multi-targeting build. | +| `PurviewTargetFrameworksCurrent` | `net10.0` | Redefines the `Current` set. | +| `PurviewTargetFrameworksLatest` | `net11.0` | Redefines the `Latest` set. | +| `PurviewTargetFrameworksSupported` | `net10.0;net11.0` | Redefines the `Supported` set. | +| `PurviewTargetFrameworksBroad` | `net8.0;net9.0;net10.0` | Redefines the `Broad` set. | +| `PurviewTargetFrameworksAll` | `net8.0;net9.0;net10.0;net11.0` | Redefines the `All` set. | + +Roslyn components are not covered: a component targets `netstandard2.0` so every compiler host can load +it. Selecting a set on one is ignored and reported as `PurviewTargetFrameworkSetIgnored`. + +Set the property in `Directory.Build.props` for a repository-wide choice, or in a `.csproj` for a single +project. Because the SDK is imported before the project body is evaluated, a `.csproj` selection is read +from the project XML directly — so declare it **unconditionally**. A `Condition` on it cannot be evaluated +that early and is reported as `PurviewConditionedProjectDeclaration`. ## Repository metadata diff --git a/package.json b/package.json index 052f711..05063dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build-sdk", - "version": "1.0.2.2", + "version": "1.0.3.0", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index 50330f6..eeea833 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -40,6 +40,8 @@ false false + false @@ -146,6 +148,10 @@ >Microsoft.SourceLink.GitHub snupkg + + false diff --git a/src/src/BuildSdk/Sdk/Sdk.props b/src/src/BuildSdk/Sdk/Sdk.props index 3985fd2..f468005 100644 --- a/src/src/BuildSdk/Sdk/Sdk.props +++ b/src/src/BuildSdk/Sdk/Sdk.props @@ -23,15 +23,23 @@ Ordered longest-first: overlapping entries (Shared/ClientShared, Infra/Infrastructure, Data/DataAccess, Lib/Library) must match the longest candidate, or a single-pass strip mangles them. + + Roslyn component names are deliberately absent. A component's suffix is + part of its identity rather than noise: a generator, its analyzers and its + code fixes are separate assemblies that each need their own namespace, and + collapsing them onto the product namespace both loses that distinction and + collides with the namespace the sources actually declare. Stripping + SourceGenerator/SourceGenerators/SourceGeneration/Generators/Analyzers/ + CodeFixers/CodeFixes produced IDE0130 across every Roslyn-component + repository that consumes this SDK, because each one keeps the suffix. + Add one back per project with if a + repository really wants it collapsed. ===================================================================== --> - - - @@ -39,14 +47,10 @@ - - - - @@ -160,24 +164,70 @@ false false false + true false false true false true <_PurviewProjectDeclaresTargetFramework - Condition="$([System.Text.RegularExpressions.Regex]::IsMatch($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?is)<TargetFrameworks?\s*>`))" + Condition="$([System.Text.RegularExpressions.Regex]::IsMatch($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<TargetFrameworks?\s*>`))" >true - - $([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s-i)(?:^|\s|>)(?s-i)(?:^|\s|>)<\s*(?:Project|Import)\s(?:[^>]*?)\s?Sdk\s*="(?<sdkproj>.*?)"`).Groups['sdkproj'].Value) + + <_PurviewProjectDeclaresRootNamespace + Condition="$([System.Text.RegularExpressions.Regex]::IsMatch($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<RootNamespace\s*>\s*\S`))" + >true + + <_PurviewProjectTargetFrameworkSet>$([System.Text.RegularExpressions.Regex]::Match($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<PurviewTargetFrameworkSet\s*>\s*(?<set>[^<]*?)\s*</PurviewTargetFrameworkSet\s*>`).Groups['set'].Value) + + <_PurviewProjectDeclaresTargetFrameworkSet Condition="'$(_PurviewProjectTargetFrameworkSet)' != ''" + >true + $(_PurviewProjectTargetFrameworkSet) + + $([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s-i)(?:^|\s|>)<\s*(?:Project|Import)\s(?:[^>]*?)\s?Sdk\s*="(?<sdkproj>.*?)"`).Groups['sdkproj'].Value) true true true @@ -485,9 +535,89 @@ false + + + + net11.0 + + net10.0 + + net10.0;net11.0 + + net8.0;net9.0;net10.0 + + net8.0;net9.0;net10.0;net11.0 + + + + + Current + + <_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'Latest'" + >$(PurviewTargetFrameworksLatest) + <_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'Current'" + >$(PurviewTargetFrameworksCurrent) + <_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'Supported'" + >$(PurviewTargetFrameworksSupported) + <_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'Broad'" + >$(PurviewTargetFrameworksBroad) + <_PurviewResolvedTargetFrameworkSet Condition="'$(PurviewTargetFrameworkSet)' == 'All'" + >$(PurviewTargetFrameworksAll) + + + $(_PurviewResolvedTargetFrameworkSet) + $(_PurviewResolvedTargetFrameworkSet) + + + net10.0 + >$(PurviewTargetFrameworksCurrent) true /'), so the + // ignore cannot reach anything the consuming repository authored. + // + // '.agents/agents/' and '.agents/prompts/' are deliberately NOT covered here. In a + // consuming repository those folders hold the SDK's mirrored .md files *alongside* the + // repository's own authored prompts and agents, so ownership is file-granular rather + // than folder-granular and a blanket ignore would hide the author's own content. The + // mirrored files there are ignored per file by WritePurviewAgentSyncGitIgnore, which + // runs consumer-side where the mirrored set is known. var files = new List(); if (Directory.Exists(AgentPackRoot)) { @@ -668,6 +680,104 @@ // Stage the write next to the destination so the final rename stays // on one volume and is atomic on Windows, Linux and macOS. + // Writes '/.gitignore' listing every file the manifest records as + // mirrored, so those files stay untracked while anything the repository authored in the + // same folder keeps its normal status. Manifest lines are 'file|group|relpath|...'. + // The file is only rewritten when its content changes, so a no-op build touches nothing. + void WriteAgentSyncGitIgnore(string root, List manifestLines, int attempts, int delay) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + return; + + var paths = new List(); + foreach (var manifestLine in manifestLines) + { + var parts = manifestLine.Split('|'); + if (parts.Length < 3 || parts[0] != "file") + continue; + + var relative = NormalizeSlashes(parts[2]).TrimStart('/'); + if (relative.Length != 0) + paths.Add("/" + relative); + } + + paths.Sort(StringComparer.Ordinal); + + var content = new List + { + "# Generated by Purview.BuildSdk - do not edit.", + "#", + "# These files are mirrored into this folder from NuGet packages on build. Ignoring them", + "# keeps an SDK or package upgrade from showing up as local modifications. Files you", + "# author in this folder are not listed here and are tracked as normal.", + "", + // This file is generated, so it ignores itself rather than being committed - + // otherwise it would show up as a new untracked file in every repository, which + // is the very thing it exists to prevent. Git reads an ignored .gitignore, so + // the entries below still apply. Matches the generated .purview/.gitignore. + "/.gitignore", + }; + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var path in paths) + { + if (seen.Add(path)) + content.Add(path); + } + + var target = Path.Combine(root, ".gitignore"); + var desiredText = new StringBuilder(); + foreach (var contentLine in content) + desiredText.Append(contentLine).Append('\n'); + + try + { + if ( + File.Exists(target) + && string.Equals( + File.ReadAllText(target).Replace("\r\n", "\n"), + desiredText.ToString(), + StringComparison.Ordinal + ) + ) + { + return; + } + } + catch (Exception e) when (IsTransient(e)) + { + // Fall through and attempt the write. + } + + for (var attempt = 1; attempt <= attempts; attempt++) + { + try + { + WriteAtomically( + target, + temporary => File.WriteAllLines(temporary, content, new UTF8Encoding(false)) + ); + break; + } + catch (Exception e) when (IsTransient(e)) + { + if (attempt < attempts) + { + Thread.Sleep(delay); + } + else + { + Log.LogMessage( + MessageImportance.Low, + "Could not update the mirrored-content .gitignore '{0}': {1}", + target, + e.Message + ); + } + } + } + } + void WriteAtomically(string destination, Action writeTemporary) { var directory = Path.GetDirectoryName(destination); @@ -1043,8 +1153,16 @@ } } } + // Ignore the mirrored files per file, driven by the manifest just built. + // + // The packaged blanket '.gitignore' only covers folders this SDK wholly owns (a + // skill is its own folder). '.agents/agents/' and '.agents/prompts/' hold mirrored + // .md files beside the repository's own authored prompts and agents, so a blanket + // ignore there would hide the author's content. Listing only the manifest's paths + // keeps an SDK upgrade from dirtying the working tree while leaving authored files + // tracked as normal. + WriteAgentSyncGitIgnore(destinationRoot, lines, attemptLimit, retryDelay); } - upToDate = upToDate || copied == 0; Log.LogMessage( MessageImportance.Low, @@ -1184,11 +1302,18 @@ Strips known suffixes from RootNamespace. @(NamespaceRemoveSuffix) only expands inside a Target (after static item evaluation), so the regex cannot be computed in a static PropertyGroup. + + A RootNamespace the project declared itself is left alone. This target + runs after the project body, so it cannot tell an author's value apart + from a derived one - the declaration is recorded in Sdk.props. Without + that guard an explicit Foo.CodeFixers + was silently rewritten to Foo, and every file in it then failed + IDE0130 against a namespace the author never asked for. ===================================================================== --> @@ -1428,10 +1553,17 @@ Directories="$(_PurviewSdkDotAgentsGitIgnoreStagingRoot)" Condition="'$(_PurviewSdkDotAgentsGitIgnoreStagingRoot)' != ''" /> + @@ -1963,6 +2095,77 @@ /> + + + + + + + + + + + <_PurviewConditionedDeclaration>$([System.Text.RegularExpressions.Regex]::Match($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<(?<decl>TargetFrameworks?|IsRoslynComponent|IsRoslynComponentOnly|IsPackable|PurviewTargetFrameworkSet|RootNamespace)\s+[A-Za-z][\w:.-]*\s*=`).Groups['decl'].Value) + + <_PurviewConditionedDeclarationIsOnlyForm + Condition="'$(_PurviewConditionedDeclaration)' != '' AND !$([System.Text.RegularExpressions.Regex]::IsMatch($([System.Text.RegularExpressions.Regex]::Replace($([System.IO.File]::ReadAllText(`$(MSBuildProjectFullPath)`)), `(?s)<!--.*?-->`, ``)), `(?is)<$(_PurviewConditionedDeclaration)\s*>`))" + >true + + + + + - + <_Parameter1>$(AssemblyName).%(TestType.Identity)Tests - + <_Parameter1>$(TargetProjectName).%(TestType.Identity)Tests - + <_Parameter1>$(MSBuildProjectName).%(TestType.Identity)Tests diff --git a/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs b/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs index 5e77bcd..bb0d0e2 100644 --- a/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs @@ -179,7 +179,10 @@ await Assert await Assert .That(gitIgnoreContent) .IsEqualTo( - "# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore" + // The last entry ignores this file itself. Re-including it ('!.gitignore') left a + // mirrored, never-committed file permanently untracked in every consuming repository, + // and overrode the generated '.agents/.gitignore' because the nearest file wins. + "# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# This file is mirrored from a NuGet package, so ignore it too\n/.gitignore" ); } @@ -257,6 +260,94 @@ await Assert .Because($"{stdOut}\n{stdErr}\n--- package entries ---\n{string.Join("\n", entries)}"); } + /// + /// A blanket '.gitignore' is packed only for a folder this SDK wholly owns - the two tests above + /// use nested folders ('skills/observability', 'prompts/example'), which is that case. It must + /// **not** be packed for a file sitting directly in '.agents/prompts' or '.agents/agents', because + /// in a consuming repository those folders hold the mirrored files beside the repository's own + /// authored prompts and agents: a blanket rule there would hide the author's work. Those files are + /// ignored per file by the consumer-side sync instead - see + /// RepositoryFileSyncTests.AgentFolderSync_IgnoresMirroredFilesPerFile_AndLeavesAuthoredFilesTracked. + /// + [Test] + public async Task PurviewAutoSdkPack_DoesNotPackABlanketGitIgnoreForASharedAgentFolder( + CancellationToken cancellationToken + ) + { + // Arrange + using var h = await ProjectHarness.CreateAsync( + "PackableProject", + extraProps: "truetruefalse", + cancellationToken: cancellationToken + ); + + await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(h.SolutionDirectory, "package.json"), + /*lang=json,strict*/ + """{"name": "packable-project", "version": "1.0.0"}""", + cancellationToken + ); + await File.WriteAllTextAsync( + Path.Combine(h.SolutionDirectory, "Directory.Packages.props"), + """ + + + true + + + + + + + + """, + cancellationToken + ); + + // Files directly inside the first-level folders, exactly as the SDK's own Sdk/.agents is laid out. + var promptsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "prompts"); + Directory.CreateDirectory(promptsDirectory); + await File.WriteAllTextAsync(Path.Combine(promptsDirectory, "diagnose.md"), "# Diagnose\n", cancellationToken); + + var agentsDirectory = Path.Combine(h.ProjectDirectory, "Sdk", ".agents", "agents"); + Directory.CreateDirectory(agentsDirectory); + await File.WriteAllTextAsync(Path.Combine(agentsDirectory, "setup.md"), "# Setup\n", cancellationToken); + + var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); + Directory.CreateDirectory(feedDirectory); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + + // Act + var (exitCode, stdOut, stdErr) = await RunProcessAsync( + "dotnet", + $"pack \"{h.ProjectFilePath}\" -c Release -o \"{feedDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + h.SolutionDirectory, + cancellationToken + ); + + // Assert + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var packagePath = Directory + .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) + .SingleOrDefault(); + + await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); + + using var zip = await ZipFile.OpenReadAsync(packagePath!, cancellationToken); + var entries = zip.Entries.Select(entry => entry.FullName).ToList(); + var because = $"{stdOut}\n{stdErr}\n--- package entries ---\n{string.Join("\n", entries)}"; + + // The content itself still ships. + await Assert.That(entries).Contains(".agents/prompts/diagnose.md").Because(because); + await Assert.That(entries).Contains(".agents/agents/setup.md").Because(because); + + // ...but without a blanket ignore over a folder the consumer also authors into. + await Assert.That(entries).DoesNotContain(".agents/prompts/.gitignore").Because(because); + await Assert.That(entries).DoesNotContain(".agents/agents/.gitignore").Because(because); + } + [Test] public async Task PurviewAutoSdkPack_PacksAllSdkRootFoldersIntoNuGetPackage(CancellationToken cancellationToken) { diff --git a/src/tests/BuildSdk.IntegrationTests/InternalsVisibleToTests.cs b/src/tests/BuildSdk.IntegrationTests/InternalsVisibleToTests.cs index fa8582e..626886f 100644 --- a/src/tests/BuildSdk.IntegrationTests/InternalsVisibleToTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/InternalsVisibleToTests.cs @@ -1,4 +1,5 @@ using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; namespace Purview.BuildSdk; @@ -93,6 +94,92 @@ public async Task InternalsVisibleTo_AlwaysIncludesDynamicProxyGenAssembly2(Canc await Assert.That(assemblyAttrs).Contains("System.Runtime.CompilerServices.InternalsVisibleToAttribute"); } + // The tests above only assert the attribute *type* appears, which is why malformed grants went + // unnoticed: a shipped Purview assembly carried 168 InternalsVisibleTo attributes, 53 of them with + // an empty assembly name. These two assert the generated values instead. The target adds + // AssemblyAttribute items during the build, so -getItem cannot see them - the generated + // AssemblyInfo.cs is the observable output. + [Test] + public async Task InternalsVisibleTo_NeverGrantsToAnEmptyAssemblyName(CancellationToken cancellationToken) + { + using var h = await CreateBuildableLibraryAsync(cancellationToken); + + var malformed = (await ReadInternalsVisibleToGrantsAsync(h, cancellationToken)) + .Where(static grant => grant.StartsWith('.')) + .ToArray(); + + // TargetProjectName has no default, so an unguarded '$(TargetProjectName).%(TestType)Tests' + // produced '.UnitTests' and friends - never a valid assembly identity. + await Assert.That(string.Join(", ", malformed)).IsEmpty(); + } + + [Test] + public async Task InternalsVisibleTo_DoesNotRepeatTheSameGrant(CancellationToken cancellationToken) + { + using var h = await CreateBuildableLibraryAsync(cancellationToken); + + var duplicates = (await ReadInternalsVisibleToGrantsAsync(h, cancellationToken)) + .GroupBy(static grant => grant, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .Select(static group => $"{group.Key} x{group.Count()}") + .ToArray(); + + // AssemblyName, TargetProjectName and MSBuildProjectName are usually the same string, so each + // test type was granted up to three times over. + await Assert.That(string.Join(", ", duplicates)).IsEmpty(); + } + + // The harness declares no central package versions for the packages the SDK injects, so a plain + // harness project cannot restore. The other build-based tests in this suite remove them the same way. + static Task CreateBuildableLibraryAsync(CancellationToken cancellationToken) => + ProjectHarness.CreateAsync( + "MyLibrary", + extraProps: """ + true + true + """, + extraItems: """ + + + """, + cancellationToken: cancellationToken + ); + + static async Task> ReadInternalsVisibleToGrantsAsync( + ProjectHarness harness, + CancellationToken cancellationToken + ) + { + // The grants are written by a target, so the project has to be compiled rather than merely + // evaluated. + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync("-restore -t:Build", cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var objDirectory = Path.Combine(harness.ProjectDirectory, "obj"); + var generated = Directory + .EnumerateFiles(objDirectory, "*.AssemblyInfo.cs", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .FirstOrDefault(); + + await Assert.That(generated).IsNotNull().Because("the SDK must generate an AssemblyInfo file"); + + const string marker = "InternalsVisibleTo(\""; + List grants = []; + foreach (var line in await File.ReadAllLinesAsync(generated!, cancellationToken)) + { + var start = line.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + continue; + + start += marker.Length; + var end = line.IndexOf('"', start); + if (end > start || end == start) + grants.Add(line[start..end]); + } + + return grants; + } + //[Test] //public async Task InternalsVisibleTo_WhenAssemblyNameSet_UsesAssemblyNameForTestProjects(CancellationToken cancellationToken) //{ diff --git a/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs b/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs index e6deeae..62298ea 100644 --- a/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs @@ -69,6 +69,56 @@ await File.ReadAllTextAsync( .Contains("*"); } + /// + /// The mirrored files have to be ignored per file rather than by a blanket rule. The packaged + /// blanket '.gitignore' only covers a folder the SDK wholly owns (a skill is its own folder), so + /// files mirrored directly into '.agents/agents' and '.agents/prompts' stayed tracked and every SDK + /// upgrade that changed one of them showed up as a local modification. A blanket rule in those two + /// folders is not an option: a consuming repository keeps its own authored prompts and agents + /// beside the mirrored ones, and ignoring those would hide the author's work. + /// + [Test] + public async Task AgentFolderSync_IgnoresMirroredFilesPerFile_AndLeavesAuthoredFilesTracked( + CancellationToken cancellationToken + ) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + + var gitIgnorePath = Path.Combine(h.SolutionDirectory, AgentsFolder, ".gitignore"); + await Assert.That(File.Exists(gitIgnorePath)).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + + var lines = await File.ReadAllLinesAsync(gitIgnorePath, cancellationToken); + // The self-ignoring entry is asserted separately below. + var entries = lines.Where(static line => line.StartsWith('/') && line != "/.gitignore").ToArray(); + + // Every mirrored file, at whatever depth, listed by path - never a bare '*'. + await Assert + .That(entries) + .IsEquivalentTo(["/agents/guide.md", "/prompts/nested/deep/prompt.md", "/skills/demo/SKILL.md"]); + // Generated, so it ignores itself: committing it, or leaving it untracked without the entry, + // would show up as a repository modification - the thing this exists to prevent. + await Assert.That(lines).Contains("/.gitignore"); + await Assert.That(lines).DoesNotContain("*"); + + // A file the repository authored beside a mirrored one must not be covered. + await Assert.That(entries.Any(static entry => entry.Contains("authored", StringComparison.Ordinal))).IsFalse(); + + // A no-op build must not rewrite it, so the working tree stays clean. + var written = File.GetLastWriteTimeUtc(gitIgnorePath); + var (secondSuccess, secondOutput, secondErrors) = await h.BuildAsync( + restore: false, + verbose: true, + cancellationToken + ); + + await Assert.That(secondSuccess).IsTrue().Because(TestHelpers.GenerateError(secondOutput, secondErrors)); + await Assert.That(File.GetLastWriteTimeUtc(gitIgnorePath)).IsEqualTo(written); + } + [Test] public async Task AgentFolderSync_SecondBuild_SkipsUnchangedContent(CancellationToken cancellationToken) { diff --git a/src/tests/BuildSdk.IntegrationTests/RootNamespaceNamingTests.cs b/src/tests/BuildSdk.IntegrationTests/RootNamespaceNamingTests.cs index b432263..4f9c629 100644 --- a/src/tests/BuildSdk.IntegrationTests/RootNamespaceNamingTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/RootNamespaceNamingTests.cs @@ -1,4 +1,5 @@ -using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; namespace Purview.BuildSdk; @@ -9,6 +10,93 @@ namespace Purview.BuildSdk; /// sealed class RootNamespaceNamingTests { + // Suffix stripping is a convention for a RootNamespace this SDK derived, not licence to rewrite one + // the author wrote down. FixRootNamespaceTarget runs after the project body and cannot tell the two + // apart on its own, so without a guard an explicit 'Acme.CodeFixers' became 'Acme' - and because the + // stripped value is what reaches the compiler as build_property.RootNamespace, every file in that + // namespace then failed IDE0130 against a namespace nobody asked for. + // + // The generated MSBuildEditorConfig is asserted rather than the MSBuild property, because that file + // is what the analyzer actually reads and it is written after the target has run. + /// + /// A Roslyn component's suffix is part of its identity, not noise: a generator, its analyzers and its + /// code fixes are separate assemblies that each need their own namespace. Stripping these collapsed + /// them onto the product namespace and produced IDE0130 across every Roslyn-component repository + /// consuming this SDK, because each one declares the suffix in its sources. + /// + [Test] + [Arguments("Acme.SourceGenerator")] + [Arguments("Acme.SourceGenerators")] + [Arguments("Acme.SourceGeneration")] + [Arguments("Acme.Generators")] + [Arguments("Acme.Analyzers")] + [Arguments("Acme.CodeFixers")] + [Arguments("Acme.CodeFixes")] + public async Task RoslynComponentSuffixes_AreNotStripped(string projectName, CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + projectName, + namespacePrefix: "Acme", + extraProps: """ + true + true + """, + extraItems: """ + + + """, + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-restore -t:Build", cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var editorConfig = Directory + .EnumerateFiles( + Path.Combine(h.ProjectDirectory, "obj"), + "*.GeneratedMSBuildEditorConfig.editorconfig", + SearchOption.AllDirectories + ) + .First(); + + var content = await File.ReadAllTextAsync(editorConfig, cancellationToken); + await Assert.That(content).Contains($"build_property.RootNamespace = {projectName}"); + } + + [Test] + public async Task ExplicitRootNamespace_IsNotSuffixStripped(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "Acme.CodeFixers", + namespacePrefix: "Acme", + extraProps: """ + Acme.CodeFixers + true + true + """, + // The harness declares no central versions for the packages the SDK injects. + extraItems: """ + + + """, + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-restore -t:Build", cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var editorConfig = Directory + .EnumerateFiles( + Path.Combine(h.ProjectDirectory, "obj"), + "*.GeneratedMSBuildEditorConfig.editorconfig", + SearchOption.AllDirectories + ) + .First(); + + var content = await File.ReadAllTextAsync(editorConfig, cancellationToken); + await Assert.That(content).Contains("build_property.RootNamespace = Acme.CodeFixers"); + } + [Test] public async Task PackableLibrary_AssemblyNameAndPackageId_DefaultToRootNamespace( CancellationToken cancellationToken diff --git a/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs b/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs index e0cfb30..12ad6eb 100644 --- a/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/RoslynComponentDefaultsTests.cs @@ -32,6 +32,111 @@ CancellationToken cancellationToken await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); } + // Sdk.props imports before the project body is evaluated, so the SDK cannot ask MSBuild whether the + // project declares a TargetFramework - it regex-scans the raw project text instead. These tests pin the + // two ways that scan can be wrong: XML comments must not count as a declaration, and a declaration + // carrying a Condition attribute must. + [Test] + public async Task CommentedOutTargetFramework_DoesNotCountAsADeclaration(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + + + + """ + ) + .BuildAsync(cancellationToken); + + // A commented-out declaration leaves the project declaring nothing, so the Roslyn component + // default must still apply. Resolving to a .NET TFM here would ship an analyzer the IDE cannot load. + var properties = await harness.GetPropertiesAsync(cancellationToken, "TargetFramework"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); + } + + // A Condition cannot be evaluated before the project body, so a conditioned declaration must not be + // counted: if it never fires, the Roslyn component has to fall back to netstandard2.0 rather than the + // generic .NET default. The author is warned instead of being silently misread. + [Test] + public async Task ConditionedTargetFramework_KeepsTheRoslynDefaultAndWarns(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + netstandard2.1 + + + """ + ) + .BuildAsync(cancellationToken); + + var properties = await harness.GetPropertiesAsync(cancellationToken, "TargetFramework"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); + + var (_, stdOut, _) = await harness.RunMSBuildAsync("-t:Build", cancellationToken); + await Assert.That(stdOut).Contains("PurviewConditionedProjectDeclaration"); + } + + /// + /// Declaring a property unconditionally and then augmenting it under a condition is correct and + /// common - an unconditional TargetFrameworks list plus a Windows-only net48 addition, for example. + /// The SDK sees the unconditional declaration, so there is nothing to warn about, and warning anyway + /// would train people to ignore the diagnostic. + /// + [Test] + public async Task ConditionedDeclarationAlongsideAnUnconditionalOne_DoesNotWarn(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + + + + net9.0;net10.0 + net8.0;$(TargetFrameworks) + + + """ + ) + .BuildAsync(cancellationToken); + + var (_, stdOut, stdErr) = await harness.RunMSBuildAsync("-t:Build", cancellationToken); + await Assert.That(stdOut + stdErr).DoesNotContain("PurviewConditionedProjectDeclaration"); + } + + [Test] + public async Task CommentedOutIsRoslynComponent_DoesNotClassifyTheProject(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + + + + + + """ + ) + .BuildAsync(cancellationToken); + + // Misclassifying a plain library as a Roslyn component forces netstandard2.0 and + // TreatWarningsAsErrors onto it. + var properties = await harness.GetPropertiesAsync(cancellationToken, "IsRoslynComponent", "TargetFramework"); + await Assert.That(properties["IsRoslynComponent"]).IsEqualTo("false"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("net10.0"); + } + [Test] public async Task PackableProject_AutomaticallyPacksAnalyzerProjectReference(CancellationToken cancellationToken) { diff --git a/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs b/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs new file mode 100644 index 0000000..36a9e6a --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/TargetFrameworkSetTests.cs @@ -0,0 +1,210 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Verifies the curated PurviewTargetFrameworkSet selection: a repository names a set instead of +/// hard-coding a TFM list in every project, so a lifecycle change is one SDK bump rather than an edit in +/// every consuming repository. The feature is opt-in, and every narrower declaration still wins. +/// +sealed class TargetFrameworkSetTests +{ + [Test] + public async Task NoSetSelected_KeepsTheSingleTargetFrameworkDefault(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + + + + + """ + ) + .BuildAsync(cancellationToken); + + // An SDK upgrade must never change a package's targets on its own. + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("net10.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + [Test] + [Arguments("Supported", "net10.0;net11.0")] + [Arguments("Broad", "net8.0;net9.0;net10.0")] + [Arguments("All", "net8.0;net9.0;net10.0;net11.0")] + public async Task MultiTargetSet_ResolvesToTargetFrameworks( + string set, + string expected, + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + $""" + + + {set} + + + """ + ) + .BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + await Assert.That(properties["TargetFrameworks"]).IsEqualTo(expected); + } + + [Test] + [Arguments("Current", "net10.0")] + [Arguments("Latest", "net11.0")] + public async Task SingleTargetSet_ResolvesToTargetFramework( + string set, + string expected, + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + $""" + + + {set} + + + """ + ) + .BuildAsync(cancellationToken); + + // A one-entry set must not pay for an outer multi-targeting build. + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + await Assert.That(properties["TargetFramework"]).IsEqualTo(expected); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + } + + [Test] + public async Task ExplicitTargetFrameworks_WinsOverASelectedSet(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + + + Broad + net10.0 + + + """ + ) + .BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFrameworks"); + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net10.0"); + } + + [Test] + public async Task ARepositoryCanRedefineASet(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Library") + // Set before the SDK is imported, which is how a repository pins a set while it is not + // ready to follow the SDK's definition. + .WithPreImportProperty("PurviewTargetFrameworksSupported", "net9.0;net10.0") + .WithProjectFileContent( + """ + + + Supported + + + """ + ) + .BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFrameworks"); + await Assert.That(properties["TargetFrameworks"]).IsEqualTo("net9.0;net10.0"); + } + + [Test] + public async Task RoslynComponent_KeepsNetStandardAndWarns(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + Supported + + + """ + ) + .BuildAsync(cancellationToken); + + // A component must load in every compiler host, so netstandard2.0 wins over any set. + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework", "TargetFrameworks"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); + await Assert.That(properties["TargetFrameworks"]).IsEmpty(); + + var (_, stdOut, _) = await h.RunMSBuildAsync("-t:Build", cancellationToken); + await Assert.That(stdOut).Contains("PurviewTargetFrameworkSetIgnored"); + } + + /// + /// Selecting a set repository-wide in Directory.Build.props is the normal arrangement, and every + /// project inherits it - including any Roslyn components. Warning about that would fire on every + /// well-configured repository, so only a component that declares a set itself is told. + /// + [Test] + public async Task RoslynComponent_InheritingARepositoryWideSet_DoesNotWarn(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("SourceGeneration") + .WithPreImportProperty("PurviewTargetFrameworkSet", "All") + .WithProjectFileContent( + """ + + + true + + + """ + ) + .BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "TargetFramework"); + await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); + + var (_, stdOut, stdErr) = await h.RunMSBuildAsync("-t:Build", cancellationToken); + await Assert.That(stdOut + stdErr).DoesNotContain("PurviewTargetFrameworkSetIgnored"); + } + + [Test] + public async Task UnrecognisedSet_FailsTheBuild(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Library") + .WithProjectFileContent( + """ + + + LTS + + + """ + ) + .BuildAsync(cancellationToken); + + // A typo must not quietly fall through to the single-TFM default and drop every target asked for. + var (exitCode, stdOut, stdErr) = await h.RunMSBuildAsync("-t:Build", cancellationToken); + + await Assert.That(exitCode).IsNotEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut + stdErr).Contains("PurviewInvalidTargetFrameworkSet"); + } +}