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");
+ }
+}