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