From daedfd59dd758ad9b9ae5c6a69ee1fd3b746dc09 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 25 Sep 2026 12:26:17 +0100 Subject: [PATCH 1/2] fix: sg resolution on pack --- package.json | 2 +- src/src/BuildSdk/Sdk/Sdk.props | 22 ++ src/src/BuildSdk/Sdk/Sdk.targets | 471 ++++++++++++++++++++++++++++++- 3 files changed, 490 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 73d9e89..dd34a32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build-sdk", - "version": "1.0.0-prerelease.58", + "version": "1.0.0-prerelease.60", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/src/src/BuildSdk/Sdk/Sdk.props b/src/src/BuildSdk/Sdk/Sdk.props index 4ab7ccf..67fac4b 100644 --- a/src/src/BuildSdk/Sdk/Sdk.props +++ b/src/src/BuildSdk/Sdk/Sdk.props @@ -640,6 +640,28 @@ $(TargetsForTfmSpecificContentInPackage);IncludeLinkedSdkFiles;IncludeSdkDotAgentsGitIgnoreFiles;IncludeRepoReadmeInPackage + + + true + + true + + Microsoft.CodeAnalysis;System.Composition;System.Collections.Immutable;System.Memory;System.Threading.Tasks.Extensions;netstandard;mscorlib;System. + + + + + False to opt out of PRSGD0005 self-contained analyzer-closure validation. + + + diff --git a/src/src/BuildSdk/Sdk/Sdk.targets b/src/src/BuildSdk/Sdk/Sdk.targets index 66b42a0..b2fa1d3 100644 --- a/src/src/BuildSdk/Sdk/Sdk.targets +++ b/src/src/BuildSdk/Sdk/Sdk.targets @@ -144,8 +144,32 @@ > + + + + + - + + @@ -179,18 +203,55 @@ Condition="'$(IsCSharpProject)' == 'true' AND '$(IsRoslynComponent)' == 'true'" > - <_PurviewSourceGeneratorAnalyzerFile Include="@(TargetPathWithTargetPlatformMoniker)" /> + <_PurviewSourceGeneratorAnalyzerFileRaw Include="@(TargetPathWithTargetPlatformMoniker)" /> - <_PurviewSourceGeneratorAnalyzerFile + <_PurviewSourceGeneratorAnalyzerFileRaw Include="$(TargetDir)%(SourceGeneratorRuntimeDependency.DestinationSubPath)" Condition="'@(SourceGeneratorRuntimeDependency)' != '' AND '%(SourceGeneratorRuntimeDependency.DestinationSubPath)' != ''" /> - <_PurviewSourceGeneratorAnalyzerFile + <_PurviewSourceGeneratorAnalyzerFileRaw Include="$(TargetDir)%(SourceGeneratorRuntimeDependency.Filename)%(SourceGeneratorRuntimeDependency.Extension)" Condition="'@(SourceGeneratorRuntimeDependency)' != '' AND '%(SourceGeneratorRuntimeDependency.DestinationSubPath)' == ''" /> + + + + <_PurviewComponentReferenceProjectReference + Include="@(ProjectReference)" + Condition="'$(PurviewIncludeComponentReferenceAnalyzerClosure)' != 'false' AND '%(ProjectReference.ReferenceOutputAssembly)' != 'false' AND '%(ProjectReference.OutputItemType)' != 'Analyzer'" + /> + + + + + + <_PurviewSourceGeneratorAnalyzerFileRaw Include="@(_PurviewComponentReferenceAnalyzerFile)" /> + + + + + + + + + + + + + + + + + + + + + + + (); + if (!string.IsNullOrEmpty(PermittedReferences)) + { + foreach (var value in PermittedReferences.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = value.Trim(); + if (trimmed.Length > 0 && !permitted.Contains(trimmed)) + permitted.Add(trimmed); + } + } + + var closureNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in Files) + closureNames.Add(Path.GetFileNameWithoutExtension(file.ItemSpec)); + + foreach (var file in Files) + { + if (!File.Exists(file.ItemSpec)) + { + Log.LogError(null, "PRSGD0005", null, file.ItemSpec, 0, 0, 0, 0, + "The analyzer closure file '{0}' does not exist.", file.ItemSpec); + continue; + } + + foreach (var reference in ReadAssemblyReferenceNames(file.ItemSpec)) + { + if (closureNames.Contains(reference)) + continue; + + var isPermitted = false; + foreach (var prefix in permitted) + { + if (reference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + isPermitted = true; + break; + } + } + if (isPermitted) + continue; + + Log.LogError(null, "PRSGD0005", null, file.ItemSpec, 0, 0, 0, 0, + "The self-contained analyzer '{0}' references '{1}', which is neither part of the returned analyzer closure ({2}) nor a host-provided assembly. Make the referenced Roslyn component an analyzer dependency so its analyzer artifact is returned, or declare the dependency as a SourceGeneratorRuntimeDependency so it is copied beside the analyzer.", + Path.GetFileName(file.ItemSpec), reference, string.Join(", ", closureNames)); + } + } + + List ReadAssemblyReferenceNames(string path) + { + var names = new List(); + var image = File.ReadAllBytes(path); + if (image.Length < 0x40) + return names; + + int peOffset = BitConverter.ToInt32(image, 0x3C); + int coffOffset = peOffset + 4; + int sectionCount = BitConverter.ToUInt16(image, coffOffset + 2); + int optionalHeaderSize = BitConverter.ToUInt16(image, coffOffset + 16); + int optionalOffset = coffOffset + 20; + int magic = BitConverter.ToUInt16(image, optionalOffset); + int directoriesOffset = optionalOffset + (magic == 0x20B ? 112 : 96); + int cliRva = BitConverter.ToInt32(image, directoriesOffset + (14 * 8)); + int sectionsOffset = optionalOffset + optionalHeaderSize; + + int RvaToOffset(int rva) + { + for (int s = 0; s < sectionCount; s++) + { + int section = sectionsOffset + (s * 40); + int virtualSize = (int)BitConverter.ToUInt32(image, section + 8); + int virtualAddress = BitConverter.ToInt32(image, section + 12); + int rawSize = BitConverter.ToInt32(image, section + 16); + int rawPointer = BitConverter.ToInt32(image, section + 20); + int size = Math.Max(virtualSize, rawSize); + if (rva >= virtualAddress && rva < virtualAddress + size) + return rawPointer + (rva - virtualAddress); + } + return -1; + } + + int cliOffset = RvaToOffset(cliRva); + if (cliOffset < 0) + return names; + + int metadataOffset = RvaToOffset(BitConverter.ToInt32(image, cliOffset + 8)); + if (metadataOffset < 0 || BitConverter.ToUInt32(image, metadataOffset) != 0x424A5342) + return names; + + int versionPadded = (BitConverter.ToInt32(image, metadataOffset + 12) + 3) & ~3; + int streamCount = BitConverter.ToUInt16(image, metadataOffset + 16 + versionPadded + 2); + int cursor = metadataOffset + 20 + versionPadded; + int tablesStream = -1; + int stringsStream = -1; + for (int i = 0; i < streamCount; i++) + { + int relativeOffset = BitConverter.ToInt32(image, cursor); + int nameStart = cursor + 8; + int nameEnd = nameStart; + while (image[nameEnd] != 0) + nameEnd++; + var streamName = Encoding.ASCII.GetString(image, nameStart, nameEnd - nameStart); + if (streamName == "#~" || streamName == "#-") + tablesStream = metadataOffset + relativeOffset; + else if (streamName == "#Strings") + stringsStream = metadataOffset + relativeOffset; + cursor = nameStart + ((((nameEnd - nameStart) + 1) + 3) & ~3); + } + if (tablesStream < 0 || stringsStream < 0) + return names; + + int heapSizes = image[tablesStream + 6]; + int stringIndexSize = (heapSizes & 0x01) != 0 ? 4 : 2; + int guidIndexSize = (heapSizes & 0x02) != 0 ? 4 : 2; + int blobIndexSize = (heapSizes & 0x04) != 0 ? 4 : 2; + + ulong valid = BitConverter.ToUInt64(image, tablesStream + 8); + var rowCounts = new int[64]; + int rowsCursor = tablesStream + 24; + for (int t = 0; t < 64; t++) + { + if ((valid & (1UL << t)) == 0) + continue; + rowCounts[t] = BitConverter.ToInt32(image, rowsCursor); + rowsCursor += 4; + } + + int Rid(int table) => rowCounts[table] >= 65536 ? 4 : 2; + int Coded(int tagBits, int[] tables) + { + int max = 0; + foreach (var t in tables) + { + if (rowCounts[t] > max) + max = rowCounts[t]; + } + return max >= (1 << (16 - tagBits)) ? 4 : 2; + } + + int typeDefOrRef = Coded(2, new[] { 0x02, 0x01, 0x1B }); + int hasConstant = Coded(2, new[] { 0x04, 0x08, 0x17 }); + int hasCustomAttribute = Coded(5, new[] { 0x06, 0x04, 0x01, 0x02, 0x08, 0x09, 0x0A, 0x00, 0x0E, 0x17, 0x14, 0x11, 0x1A, 0x1B, 0x20, 0x23, 0x26, 0x27, 0x28, 0x2A, 0x2C, 0x2B }); + int hasFieldMarshal = Coded(1, new[] { 0x04, 0x08 }); + int hasDeclSecurity = Coded(2, new[] { 0x02, 0x06, 0x20 }); + int memberRefParent = Coded(3, new[] { 0x02, 0x01, 0x1A, 0x06, 0x1B }); + int hasSemantics = Coded(1, new[] { 0x14, 0x17 }); + int memberForwarded = Coded(1, new[] { 0x04, 0x06 }); + int customAttributeType = Coded(3, new[] { 0x06, 0x0A }); + int resolutionScope = Coded(2, new[] { 0x00, 0x1A, 0x23, 0x01 }); + + int RowSize(int table) + { + switch (table) + { + case 0x00: return 2 + stringIndexSize + guidIndexSize + guidIndexSize + guidIndexSize; + case 0x01: return resolutionScope + stringIndexSize + stringIndexSize; + case 0x02: return 4 + stringIndexSize + stringIndexSize + typeDefOrRef + Rid(0x04) + Rid(0x06); + case 0x03: return Rid(0x04); + case 0x04: return 2 + stringIndexSize + blobIndexSize; + case 0x05: return Rid(0x06); + case 0x06: return 4 + 2 + 2 + stringIndexSize + blobIndexSize + Rid(0x08); + case 0x07: return Rid(0x08); + case 0x08: return 2 + 2 + stringIndexSize; + case 0x09: return Rid(0x02) + typeDefOrRef; + case 0x0A: return memberRefParent + stringIndexSize + blobIndexSize; + case 0x0B: return 2 + hasConstant + blobIndexSize; + case 0x0C: return hasCustomAttribute + customAttributeType + blobIndexSize; + case 0x0D: return hasFieldMarshal + blobIndexSize; + case 0x0E: return 2 + hasDeclSecurity + blobIndexSize; + case 0x0F: return 2 + 4 + Rid(0x02); + case 0x10: return 4 + Rid(0x04); + case 0x11: return blobIndexSize; + case 0x12: return Rid(0x02) + Rid(0x14); + case 0x13: return Rid(0x14); + case 0x14: return 2 + stringIndexSize + typeDefOrRef; + case 0x15: return Rid(0x02) + Rid(0x17); + case 0x16: return Rid(0x17); + case 0x17: return 2 + stringIndexSize + blobIndexSize; + case 0x18: return 2 + Rid(0x06) + hasSemantics; + case 0x19: return Rid(0x02) + Rid(0x06) + Rid(0x06); + case 0x1A: return stringIndexSize; + case 0x1B: return blobIndexSize; + case 0x1C: return 2 + memberForwarded + stringIndexSize + Rid(0x1A); + case 0x1D: return 4 + Rid(0x04); + case 0x1E: return 4 + 4; + case 0x1F: return 4; + case 0x20: return 4 + 2 + 2 + 2 + 2 + 4 + blobIndexSize + stringIndexSize + stringIndexSize; + case 0x21: return 4; + case 0x22: return 4 + 4 + 4; + case 0x23: return 2 + 2 + 2 + 2 + 4 + blobIndexSize + stringIndexSize + stringIndexSize + blobIndexSize; + default: return 0; + } + } + + int assemblyRefOffset = rowsCursor; + for (int t = 0; t < 0x23; t++) + { + if ((valid & (1UL << t)) == 0) + continue; + assemblyRefOffset += RowSize(t) * rowCounts[t]; + } + + int assemblyRefRowSize = RowSize(0x23); + int nameColumnOffset = 12 + blobIndexSize; + for (int i = 0; i < rowCounts[0x23]; i++) + { + int nameColumn = assemblyRefOffset + (i * assemblyRefRowSize) + nameColumnOffset; + int nameIndex = stringIndexSize == 4 ? BitConverter.ToInt32(image, nameColumn) : BitConverter.ToUInt16(image, nameColumn); + int stringStart = stringsStream + nameIndex; + int stringEnd = stringStart; + while (image[stringEnd] != 0) + stringEnd++; + names.Add(Encoding.UTF8.GetString(image, stringStart, stringEnd - stringStart)); + } + + return names; + } + ]]> + + + + + + + <_PurviewAnalyzerClosurePermitted Include="$(PurviewAnalyzerClosurePermittedReferences)" /> + <_PurviewAnalyzerClosurePermitted Include="@(PurviewAnalyzerClosurePermittedReference)" /> + + + + + + + + + + + + + + + + + + + + + + [^\"']+)[\"']", RegexOptions.IgnoreCase); + var elementPattern = new Regex("[^>]*)>(?.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase); + var selfClosingPattern = new Regex("[^>]*)/>", RegexOptions.Singleline | RegexOptions.IgnoreCase); + var descriptionPattern = new Regex("(?.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Existing != null) + { + foreach (var item in Existing) + seen.Add(item.ItemSpec); + } + + var candidateFiles = new List(); + if (ProjectDirectories != null) + { + foreach (var directory in ProjectDirectories) + { + var buildTransitive = Path.Combine(directory.ItemSpec, "Sdk", "buildTransitive"); + if (!Directory.Exists(buildTransitive)) + continue; + + candidateFiles.AddRange(Directory.GetFiles(buildTransitive, "*.props", SearchOption.AllDirectories)); + candidateFiles.AddRange(Directory.GetFiles(buildTransitive, "*.targets", SearchOption.AllDirectories)); + } + } + + var results = new List(); + foreach (var file in candidateFiles) + { + string text; + try + { + text = File.ReadAllText(file); + } + catch (Exception) + { + continue; + } + + foreach (Match match in elementPattern.Matches(text)) + { + var includeMatch = includePattern.Match(match.Groups["attrs"].Value); + if (!includeMatch.Success) + continue; + + var name = includeMatch.Groups["name"].Value.Trim(); + if (name.Length == 0 || !seen.Add(name)) + continue; + + var item = new TaskItem(name); + var descriptionMatch = descriptionPattern.Match(match.Groups["inner"].Value); + if (descriptionMatch.Success) + { + var description = descriptionMatch.Groups["desc"].Value.Trim(); + if (description.Length > 0) + item.SetMetadata("Description", description); + } + results.Add(item); + } + + foreach (Match match in selfClosingPattern.Matches(text)) + { + var includeMatch = includePattern.Match(match.Groups["attrs"].Value); + if (!includeMatch.Success) + continue; + + var name = includeMatch.Groups["name"].Value.Trim(); + if (name.Length == 0 || !seen.Add(name)) + continue; + + results.Add(new TaskItem(name)); + } + } + + CompilerVisibleProperties = results.ToArray(); + ]]> + + + + + + + + <_PurviewBuildTransitiveProjectReference Include="@(ProjectReference)" /> + <_PurviewBuildTransitiveProjectDirectory Include="@(_PurviewBuildTransitiveProjectReference->'%(RootDir)%(Directory)')" /> + + + + + + + + From 004812dedf64afff3b9dd987ba57487e15785b09 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 25 Sep 2026 14:14:16 +0100 Subject: [PATCH 2/2] fix: regression on file copy --- .../prompts/sdk-diagnose-agent-folder-copy.md | 21 +- AGENTS.md | 6 +- README.md | 34 +- docs/wiki/Agent-Folder.md | 27 +- docs/wiki/Configuration-Reference.md | 24 +- docs/wiki/Home.md | 12 +- docs/wiki/Repository-Bootstrap.md | 28 +- .../prompts/sdk-diagnose-agent-folder-copy.md | 21 +- .../sdk-configuration-reference/SKILL.md | 25 +- src/src/BuildSdk/Sdk/Props/Defaults.props | 20 + src/src/BuildSdk/Sdk/Sdk.props | 36 + src/src/BuildSdk/Sdk/Sdk.targets | 836 ++++++++++++++++-- .../CompilerVisiblePropertyTests.cs | 10 + .../RepositoryFileSyncTests.cs | 358 ++++++++ 14 files changed, 1374 insertions(+), 84 deletions(-) create mode 100644 src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md index 85ad444..e234df9 100644 --- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ b/.agents/prompts/sdk-diagnose-agent-folder-copy.md @@ -11,16 +11,27 @@ destination in a consuming repository. 3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, `Directory.Build.props`, or command-line `-p:` overrides). 4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`. + `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` + folder (override with `PurviewAgentFolderSourcePath`). 5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control root metadata. -6. Re-run the build and confirm the destination folder now contains the copied files (including the +6. If the destination looks stale or incomplete, inspect the change-detection manifest + (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a + matching entry with a present destination file means the sync was skipped as up to date. Delete the + manifest (or the affected destination file) to force a fresh copy. +7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. + A copy that still fails after every retry is reported as an error naming the source, destination and + OS error - search the build log for `The Purview SDK could not copy`. Temporarily set + `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and + `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. +8. Re-run the build and confirm the destination folder now contains the copied files (including the generated `.gitignore` for skill/prompt/agent subfolders). ## Suggested output -- A short root-cause explanation (missing import, disabled flag, wrong destination override, or repo-root - discovery miss). +- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root + discovery miss, or a destination held open by another process). - The exact command used to reproduce/verify the fix (for example `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path. +- Confirmation that the expected files exist at the resolved destination path, plus whether the + manifest skipped the sync (in which case the content was already up to date). diff --git a/AGENTS.md b/AGENTS.md index 0642385..f98ff45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,13 +34,15 @@ For packable projects, `PurviewAutoSdkPack` (default `true`) automatically adds - `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root - everything else under `Sdk/` → `Sdk/` -The `BuildSdk.csproj` itself is an MSBuild SDK, so it disables `PurviewAutoSdkPack` and explicitly packs its `Sdk/` contents instead. This is an exception for the SDK project only; every other project that consumes this SDK relies on `PurviewAutoSdkPack` to ship its `Sdk/` folder. Consuming repositories that use this SDK get the bundled agent folder copied into `$(AgentPackDestinationFolder)/` (default `.agents/`) before build when `EnableAgentFolderInPackage` is `true` (default). +The `BuildSdk.csproj` itself is an MSBuild SDK, so it disables `PurviewAutoSdkPack` and explicitly packs its `Sdk/` contents instead. This is an exception for the SDK project only; every other project that consumes this SDK relies on `PurviewAutoSdkPack` to ship its `Sdk/` folder. Consuming repositories that use this SDK get the bundled agent folder mirrored into `$(AgentPackDestinationFolder)/` (default `.agents/`) before build when `EnableAgentFolderInPackage` is `true` (default). + +The mirror is change-aware and lock tolerant: `SyncPurviewRepositoryFiles` (inline task in `Sdk/Sdk.targets`) skips unchanged files via the `/.purview/agent-sync.cache` manifest, stages every write into a temporary file next to the destination before renaming it into place, treats "another project already wrote identical content" as success, retries quietly, and escalates only after `PurviewAgentFolderCopyRetries` attempts (`PurviewAgentFolderCopyFailureAsError=false` downgrades that to a warning). `PurviewSuppressCopyRetryWarnings` (default `true`) demotes the built-in copy task's retry notice (`MSB3026`) to a message, which is set in `Sdk.targets` so it can be toggled from the project file. The same task performs the `.editorconfig`/`global.json` bootstraps (`PurviewRepoBootstrapMode`: `IfMissing`/`Always`/`WarnOnDrift`/`Never`). `PurviewAgentFolderSourcePath` defaults to the package-level `.agents` folder and is deliberately defined in `Sdk/Sdk.props` — defining it in `Sdk/Props/Defaults.props` resolves `$(MSBuildThisFileDirectory)` to `Sdk/Props/` and silently breaks the packaged layout. During packaging, the SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`, so the copied folder is ignored by Git in consuming repositories while keeping the folder structure discoverable. Any edit, addition, or deletion in `src/src/BuildSdk/Sdk/.agents/` therefore changes the contents delivered to every repository that consumes this SDK. -Tests for this feature live in `src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs`. +Tests for this feature live in `src/tests/BuildSdk.IntegrationTests/AgentPackFolderTests.cs` (packaging) and `src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs` (repository mirroring, change detection, retry and failure behaviour). ## Repository map diff --git a/README.md b/README.md index 285907b..6e5a05d 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,15 @@ The `templates/` folder contains ready-to-copy starter files for new repos: | `.gitattributes` | Line-ending normalisation for .cs, .json, .yml, etc. | | `.config/dotnet-tools.json` | CSharpier tool manifest | -The package also ships bundled agent content under `.agents/**`. During build, the SDK copies it into the consuming repository's `.agents/` folder by default so compatible coding agents can discover repository-aware guidance automatically. The SDK also injects a `.gitignore` file into each second-level agent folder with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`, so the copied folder is ignored by Git while keeping the folder structure discoverable. +The package also ships bundled agent content under `.agents/**`. During build, the SDK mirrors it into the consuming repository's `.agents/` folder by default so compatible coding agents can discover repository-aware guidance automatically. The SDK also injects a `.gitignore` file into each second-level agent folder with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`, so the copied folder is ignored by Git while keeping the folder structure discoverable. + +The mirror is change-aware: file fingerprints and content hashes are recorded in +`.purview/agent-sync.cache` at the repository root, so unchanged content is skipped and repeat builds +touch no files. Every write is staged into a temporary file and renamed into place, and because all +projects in a solution share these destinations the retries are silent — copy retry notices (`MSB3026`) +are demoted to low-importance messages while a copy that still fails after every retry is reported as an +error. See [Agent Folder](docs/wiki/Agent-Folder.md) and +[Repository Bootstrap](docs/wiki/Repository-Bootstrap.md) for the retry and failure properties. --- @@ -272,14 +280,24 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports a file that differs from the SDK-provided one, and `Never` skips bootstrapping. | +| `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before a bootstrap write failure is reported. | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a failed bootstrap write is a warning instead of an error. | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes built-in copy retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt. | ### Agent folder | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | -| `EnableAgentFolderInPackage` | `true` | Copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | -| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the copied agent folder contents when `EnableAgentFolderInPackage` is `true`. | +| `EnableAgentFolderInPackage` | `true` | Mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | +| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the mirrored agent folder contents when `EnableAgentFolderInPackage` is `true`. | +| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Overrides the folder that provides the bundled `.agents` content. | +| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | +| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between copy attempts. | +| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is a warning instead of an error. | +| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest used to skip unchanged agent content. | To disable bundled agent folder copying in a consuming repo, set the opt-out property before importing the SDK: @@ -361,6 +379,16 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | | `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, fallback `1.0.0`). | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | +| `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | +| `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | Base delay between bootstrap write attempts (default `500`). | +| `PurviewRepoBootstrapCopyFailureAsError` | When `true` (default), a bootstrap write that still fails after every retry is an error. | +| `PurviewAgentFolderSourcePath` | Folder that provides the bundled `.agents` content mirrored into the repository. | +| `PurviewAgentFolderCopyRetries` | Number of copy attempts per file before an agent folder sync failure is reported (default `3`). | +| `PurviewAgentFolderCopyRetryDelayMilliseconds` | Base delay between agent folder copy attempts (default `500`). | +| `PurviewAgentFolderCopyFailureAsError` | When `true` (default), an agent folder copy that still fails after every retry is an error. | +| `PurviewAgentSyncManifestPath` | Change-detection manifest used to skip unchanged agent content (default `/.purview/agent-sync.cache`). | +| `PurviewSuppressCopyRetryWarnings` | When `true` (default), built-in copy retry notices (`MSB3026`) are demoted to messages. | | `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. | | `CurrentYear` | Current year used in generated assembly metadata. | | `AutoGeneratedAssemblyInfoFile` | Relative path to generated AssemblyInfo source file. | diff --git a/docs/wiki/Agent-Folder.md b/docs/wiki/Agent-Folder.md index d376b43..d03fa63 100644 --- a/docs/wiki/Agent-Folder.md +++ b/docs/wiki/Agent-Folder.md @@ -6,14 +6,35 @@ folder by default so compatible coding agents can discover repository-aware guid ## How the copy works -- `EnableAgentFolderInPackage=true` (default) copies the bundled `.agents/**` folder from the SDK +- `EnableAgentFolderInPackage=true` (default) syncs the bundled `.agents/**` folder from the SDK NuGet package into `$(AgentPackDestinationFolder)/` (default `.agents`) in the consuming repository **before build**. +- The source folder defaults to the package-level `.agents` folder beside `Sdk/` and can be pointed + elsewhere with `PurviewAgentFolderSourcePath`. - The destination root is resolved from `RepoRoot` (auto-discovered repo root), an `AGENTS.md` walk-up, or SourceLink source roots. -- The copy is also performed for bundled `.agents` folders shipped by **any** restored NuGet package +- The sync is also performed for bundled `.agents` folders shipped by **any** restored NuGet package that used `PurviewAutoSdkPack` (read from `project.assets.json`), not just this SDK. -- `SkipUnchangedFiles=true` keeps repeated builds cheap. +- Unchanged content is skipped using a repository manifest (`.purview/agent-sync.cache` by default), so + repeat builds do not touch any file. The manifest records a per-file size/timestamp fingerprint plus a + content hash, which is what makes an in-place package republish (same version, new content) re-sync. + Deleting a mirrored file also re-triggers its copy. +- Every write is staged into a temporary file in the destination folder and then renamed into place, so a + reader never observes a partially written file and concurrent writers cannot interleave. +- Several projects share the same destination, so simultaneous writes are expected. Those retries are + logged at low importance only (MSB3026 copy retry notices are demoted to messages) and a copy that + still fails after every retry is reported as an **error** by default. + +## Failure and retry behaviour + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Folder that provides the bundled `.agents` content mirrored into the repository. | +| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | +| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between attempts (a small increment is added per attempt). | +| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is reported as a warning and the build continues. | +| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest location. | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes MSB3026 copy retry notices to messages. Set to `false` to see every retry attempt. | ## Opting out diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 14a4afa..cb6f1ac 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -67,14 +67,34 @@ operations skip them silently. | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports that an existing file differs from the SDK-provided one, and `Never` skips bootstrapping entirely. | +| `PurviewRepoBootstrapCopyRetries` | `3` | Copy attempts before a bootstrap write failure is reported. | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a bootstrap write that still fails after every retry is reported as a warning and the build continues. | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes MSB3026 copy retry notices to messages. Set to `false` to see every retry attempt. | + +Bootstrap writes are staged into a temporary file and renamed into place, so editors and tools never +observe a partially written `.editorconfig` or `global.json`. Because every project in a solution runs +the same bootstrapping targets, a lost race between parallel projects is a no-op: an existing file is +treated as success rather than a copy failure. ## Agent folder | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | -| `EnableAgentFolderInPackage` | `true` | Copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | -| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the copied agent folder contents when `EnableAgentFolderInPackage` is `true`. | +| `EnableAgentFolderInPackage` | `true` | Mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | +| `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the mirrored agent folder contents when `EnableAgentFolderInPackage` is `true`. | +| `PurviewAgentFolderSourcePath` | *(package-level `.agents`)* | Overrides the folder that provides the bundled `.agents` content. | +| `PurviewAgentFolderCopyRetries` | `3` | Copy attempts per file before the failure is reported. | +| `PurviewAgentFolderCopyRetryDelayMilliseconds` | `500` | Base delay between copy attempts. | +| `PurviewAgentFolderCopyFailureAsError` | `true` | When `false`, a copy that still fails after every retry is reported as a warning and the build continues. | +| `PurviewAgentSyncManifestPath` | `/.purview/agent-sync.cache` | Overrides the change-detection manifest used to skip unchanged agent content. | + +The mirror is change-aware: content that already matches the manifest is skipped entirely, which keeps +repeat builds free of file writes and file locks. Retry notices are demoted to low-importance messages +(`MSB3026`); a copy that still fails after every retry is reported as an error that names the source, +destination and OS error. To disable bundled agent folder copying in a consuming repo, set the opt-out property before importing the SDK: diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index c8ea824..4809c95 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -45,12 +45,16 @@ repo root inherits everything automatically. `TestDataFramework`: **Bogus** (default) or `None`. - **Version detection** — the `version` field from the repo `package.json` is applied to `Version` and `PackageVersion` automatically, with local caching and a strict mode. -- **Repository bootstrap** — missing repo-root `.editorconfig` and `global.json` are auto-copied or - created by default (`DisableAutoCopySdkFiles=true` to opt out). +- **Repository bootstrap** — missing repo-root `.editorconfig` and `global.json` are written atomically by + default (`DisableAutoCopySdkFiles=true` to opt out). Existing files are never overwritten unless + `PurviewRepoBootstrapMode` says otherwise, and a race between parallel projects is a no-op rather than a + build failure. - **Bundled analyzers and code fixes** — `PDS0001`–`PDS0005` plus IDE code fixes for naming and extensions-namespace conventions. -- **Agent folder** — the package ships `.agents/**` content that is copied into the consuming - repository so coding agents can discover repository-aware guidance automatically. +- **Agent folder** — the package ships `.agents/**` content that is mirrored into the consuming + repository (change-aware, so unchanged files are never rewritten) so coding agents can discover + repository-aware guidance automatically. Copy retries are silent; a copy that still fails after every + retry is reported as an error. ## Requirements diff --git a/docs/wiki/Repository-Bootstrap.md b/docs/wiki/Repository-Bootstrap.md index 859554f..88a9d1e 100644 --- a/docs/wiki/Repository-Bootstrap.md +++ b/docs/wiki/Repository-Bootstrap.md @@ -10,9 +10,13 @@ The package ships an `.editorconfig` in `Sdk/.editorconfig`. It is: 1. Registered on `@(EditorConfigFiles)` via `EditorConfigFilePath` for build-time code-style enforcement (`EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`). -2. Copied to the repository root (as a physical file) when a `.editorconfig` does not already exist +2. Written to the repository root (as a physical file) when a `.editorconfig` does not already exist there, so tools like CSharpier pick it up. +The write is atomic (a temporary file is renamed into place) and retried quietly, so parallel projects +sharing the repository root cannot corrupt it or fail the build by racing. An existing file is never +overwritten unless you ask for it. + Control it with: | Property | Default | Description | @@ -20,6 +24,10 @@ Control it with: | `BootstrapEditorConfigToRepoRoot` | `true` | Copies the SDK `.editorconfig` to the repository root when missing. | | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | +| `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` (never overwrite), `Always` (overwrite), `WarnOnDrift` (warn when the existing file differs from the SDK-provided one) or `Never` (skip all bootstrapping). | +| `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before the failure is reported. | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between write attempts. | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a failed bootstrap write is reported as a warning instead of an error. | ## `global.json` bootstrapping @@ -37,7 +45,8 @@ The SDK creates a `global.json` at the repository root when one is missing, regi } ``` -Control it with: +Like the `.editorconfig` bootstrap it writes atomically, retries quietly, never overwrites an existing +file, and honours `PurviewRepoBootstrapMode`: | Property | Default | Description | | -- | -- | -- | @@ -45,6 +54,21 @@ Control it with: | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | +| `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing`, `Always`, `WarnOnDrift` or `Never`. | + +## Retry and failure behaviour + +Both bootstraps share the same retry contract: + +| Property | Default | Description | +| -- | -- | -- | +| `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before the failure is reported. | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between attempts (a small increment is added per attempt). | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | When `false`, a failure is reported as a warning and the build continues. | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry. | + +Retries are therefore silent by design, while a write that never succeeds is still reported - as an +error by default, including the destination path and the underlying OS error. ## Repository root discovery diff --git a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md index 85ad444..e234df9 100644 --- a/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md +++ b/src/src/BuildSdk/Sdk/.agents/prompts/sdk-diagnose-agent-folder-copy.md @@ -11,16 +11,27 @@ destination in a consuming repository. 3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, `Directory.Build.props`, or command-line `-p:` overrides). 4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with - `-p:AgentPackDestinationFolder=`. + `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents` + folder (override with `PurviewAgentFolderSourcePath`). 5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control root metadata. -6. Re-run the build and confirm the destination folder now contains the copied files (including the +6. If the destination looks stale or incomplete, inspect the change-detection manifest + (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a + matching entry with a present destination file means the sync was skipped as up to date. Delete the + manifest (or the affected destination file) to force a fresh copy. +7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings. + A copy that still fails after every retry is reported as an error naming the source, destination and + OS error - search the build log for `The Purview SDK could not copy`. Temporarily set + `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and + `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt. +8. Re-run the build and confirm the destination folder now contains the copied files (including the generated `.gitignore` for skill/prompt/agent subfolders). ## Suggested output -- A short root-cause explanation (missing import, disabled flag, wrong destination override, or repo-root - discovery miss). +- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root + discovery miss, or a destination held open by another process). - The exact command used to reproduce/verify the fix (for example `dotnet build -p:AgentPackDestinationFolder=`). -- Confirmation that the expected files exist at the resolved destination path. +- Confirmation that the expected files exist at the resolved destination path, plus whether the + manifest skipped the sync (in which case the content was already up to date). diff --git a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md b/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md index 9006578..b516205 100644 --- a/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md +++ b/src/src/BuildSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md @@ -92,8 +92,27 @@ These settings control the SDK’s repo-level helper file bootstrapping: - `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json` - `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0` - `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths -- `EnableAgentFolderInPackage` — default `true`; copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` -- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives copied agent content as `$(AgentPackDestinationFolder)/**` +- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/` +- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**` +- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`) +- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported +- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts +- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error +- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content +- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt + +## Shared repository copy behaviour + +`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written +by every project, so parallel builds race for the same destinations. The SDK therefore: + +1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version. +2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave. +3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default. +4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure. + +`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether +existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted. **Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths: @@ -152,6 +171,8 @@ Especially relevant exported properties include: - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` - `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` - `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile` ## Guidance for edits diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index 95a5d53..d439aa8 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -53,6 +53,26 @@ >true .agents + true + 3 + 500 + + true + 3 + 500 + IfMissing + true Repo-relative destination folder that receives copied .agents content. Defaults to .agents. + + Overrides the change-detection manifest used to skip unchanged .agents syncs. Defaults to the repository .purview/agent-sync.cache file. + + + Number of copy attempts used when syncing bundled .agents content into the consuming repository. Defaults to 3. + + + Delay in milliseconds between .agents copy retry attempts. Defaults to 500. + + + When true, an .agents sync that still fails after every retry is reported as an error instead of a warning. Defaults to true. + + + Controls repo-level file bootstrapping (IfMissing, Always, WarnOnDrift or Never). Defaults to IfMissing. + + + Source folder that provides the bundled .agents content mirrored into the consuming repository. Defaults to the package-level .agents folder beside Sdk/. + + + Number of copy attempts used when bootstrapping repo-level files such as .editorconfig and global.json. Defaults to 3. + + + Delay in milliseconds between repo-level bootstrap copy retry attempts. Defaults to 500. + + + When true, a repo-level bootstrap that still fails after every retry is reported as an error instead of a warning. Defaults to true. + + + When true, demotes MSB3026 copy retry notices to messages while terminal copy failures stay errors. Defaults to true. + When true, automatically packs the Sdk/ folder contents into the NuGet package with the correct root-level paths. Defaults to true. @@ -408,6 +438,12 @@ >$([System.IO.Path]::GetFullPath('$(_PurviewDiscoveredEditorConfigFilePath)')) false + + $([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '..', '.agents')) true + + + true + $(MSBuildWarningsAsMessages);MSB3026 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 ? Retries : 3; + var retryDelay = RetryDelayMilliseconds > 0 ? RetryDelayMilliseconds : 500; + var pathComparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + var copied = 0; + var skipped = 0; + var failed = 0; + var upToDate = false; + + bool IsTransient(Exception exception) => + exception is IOException || exception is UnauthorizedAccessException; + + bool IsPermanent(Exception exception) => + exception is ArgumentException + || exception is NotSupportedException + || exception is PathTooLongException + || exception is DirectoryNotFoundException; + + string NormalizeSlashes(string value) => + string.IsNullOrEmpty(value) ? string.Empty : value.Replace('\\', '/'); + + ulong ComputeHash(byte[] bytes) + { + unchecked + { + // FNV-1a: dependency-free and identical on every platform, which + // matters because the manifest outlives a single operating system. + var hash = 14695981039346656037UL; + foreach (var value in bytes) + { + hash ^= value; + hash *= 1099511628211UL; + } + return hash; + } + } + + string ComputeFileHash(string path) => + ComputeHash(File.ReadAllBytes(path)).ToString("x16", CultureInfo.InvariantCulture); + + string GetRelativePath(string root, string file) + { + var rootFull = Path.GetFullPath(root); + if (!rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + rootFull += Path.DirectorySeparatorChar; + + var fileFull = Path.GetFullPath(file); + return fileFull.StartsWith(rootFull, pathComparison) + ? NormalizeSlashes(fileFull.Substring(rootFull.Length)) + : NormalizeSlashes(Path.GetFileName(fileFull)); + } + + void ClearReadOnlyAttribute(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // Reported by the retry loop when the write itself fails. + } + } + + bool FilesMatch(string first, string second) + { + var firstInfo = new FileInfo(first); + var secondInfo = new FileInfo(second); + if (!secondInfo.Exists || firstInfo.Length != secondInfo.Length) + return false; + + return ComputeFileHash(first) == ComputeFileHash(second); + } + + bool TextMatches(string first, string second) + { + try + { + // Line endings differ between checkouts (core.autocrlf, .gitattributes), + // so drift detection compares normalized text. + var firstText = File.ReadAllText(first).Replace("\r\n", "\n"); + var secondText = File.ReadAllText(second).Replace("\r\n", "\n"); + return string.Equals(firstText, secondText, StringComparison.Ordinal); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + return false; + } + } + + // Stage the write next to the destination so the final rename stays + // on one volume and is atomic on Windows, Linux and macOS. + void WriteAtomically(string destination, Action writeTemporary) + { + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + writeTemporary(temporary); + + if (File.Exists(destination)) + { + ClearReadOnlyAttribute(destination); + try + { + File.Replace(temporary, destination, null); + } + catch (Exception e) when (e is PlatformNotSupportedException || e is IOException) + { + // Some file systems reject Replace; overwrite in place instead + // and let the retry loop report a genuinely locked destination. + File.Copy(temporary, destination, true); + } + } + else + { + File.Move(temporary, destination); + } + } + finally + { + if (File.Exists(temporary)) + { + try + { + File.Delete(temporary); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // A leftover temp file is harmless; the next build reuses the name. + } + } + } + } + + void ReportFailure(string source, string destination, int attempts, string message) + { + failed++; + if (TreatFailureAsError) + { + Log.LogError( + "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", + source, + destination, + attempts, + message + ); + } + else + { + Log.LogWarning( + "The Purview SDK could not copy '{0}' to '{1}' after {2} attempt(s): {3}", + source, + destination, + attempts, + message + ); + } + } + + bool Finish() + { + UpToDate = upToDate; + CopiedCount = copied; + SkippedCount = skipped; + FailedCount = failed; + return !Log.HasLoggedErrors; + } + + if (isMirror) + { + var sources = Sources ?? Array.Empty(); + var destinationRoot = DestinationRoot ?? string.Empty; + if (sources.Length == 0 || string.IsNullOrEmpty(destinationRoot)) + { + upToDate = true; + return Finish(); + } + + var manifestFile = ManifestPath ?? string.Empty; + if (!string.IsNullOrEmpty(manifestFile)) + { + // Keep the manifest folder invisible to Git without requiring any + // consumer configuration; the folder contents are build artifacts. + try + { + var manifestDirectory = Path.GetDirectoryName(manifestFile); + if (!string.IsNullOrEmpty(manifestDirectory)) + { + Directory.CreateDirectory(manifestDirectory); + var ignoreFile = Path.Combine(manifestDirectory, ".gitignore"); + if (!File.Exists(ignoreFile)) + File.WriteAllText(ignoreFile, "*" + Environment.NewLine, new UTF8Encoding(false)); + } + } + catch (Exception e) when (IsTransient(e)) + { + Log.LogMessage( + MessageImportance.Low, + "Could not prepare the agent sync manifest folder '{0}': {1}", + manifestFile, + e.Message + ); + } + } + + var previous = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(manifestFile) && File.Exists(manifestFile)) + { + try + { + foreach (var line in File.ReadAllLines(manifestFile)) + { + var parts = line.Split('|'); + if (parts.Length >= 6 && parts[0] == "file") + previous[parts[1] + "|" + parts[2]] = parts[3] + "|" + parts[4] + "|" + parts[5]; + } + } + catch (Exception e) when (IsTransient(e)) + { + Log.LogMessage( + MessageImportance.Low, + "Ignoring unreadable agent sync manifest '{0}': {1}", + manifestFile, + e.Message + ); + previous.Clear(); + } + } + + var groups = new HashSet(StringComparer.Ordinal); + var assets = new List(); + foreach (var source in sources) + { + var root = source.ItemSpec; + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + continue; + + var group = source.GetMetadata("ChangeGroup"); + if (string.IsNullOrEmpty(group)) + group = NormalizeSlashes(Path.GetFullPath(root)); + groups.Add(group); + + try + { + foreach (var file in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) + { + var relative = GetRelativePath(root, file); + var asset = new TaskItem(file); + asset.SetMetadata("PurviewGroup", group); + asset.SetMetadata("PurviewRelativePath", relative); + asset.SetMetadata( + "PurviewDestination", + Path.GetFullPath( + Path.Combine(destinationRoot, relative.Replace('/', Path.DirectorySeparatorChar)) + ) + ); + assets.Add(asset); + } + } + catch (Exception e) when (IsTransient(e)) + { + ReportFailure(root, destinationRoot, attemptLimit, e.Message); + } + } + + var desired = new Dictionary(StringComparer.Ordinal); + foreach (var asset in assets) + { + var assetSource = asset.ItemSpec; + var destination = asset.GetMetadata("PurviewDestination"); + var key = asset.GetMetadata("PurviewGroup") + "|" + asset.GetMetadata("PurviewRelativePath"); + + string fingerprint; + string hash; + try + { + var info = new FileInfo(assetSource); + var length = info.Length.ToString(CultureInfo.InvariantCulture); + var ticks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + fingerprint = length + "|" + ticks; + + var reuseCachedHash = false; + if (previous.TryGetValue(key, out var prior)) + { + var priorParts = prior.Split('|'); + // Only re-hash content when the cheap fingerprint moved. Hashing + // is what detects an in-place republish that keeps the package + // version and therefore may keep extraction timestamps. + reuseCachedHash = + priorParts.Length == 3 + && string.Equals(priorParts[0], length, StringComparison.Ordinal) + && string.Equals(priorParts[1], ticks, StringComparison.Ordinal); + if (reuseCachedHash) + hash = priorParts[2]; + else + hash = ComputeFileHash(assetSource); + } + else + { + hash = ComputeFileHash(assetSource); + } + } + catch (Exception e) when (IsTransient(e)) + { + ReportFailure(assetSource, destination, attemptLimit, e.Message); + continue; + } + + var value = fingerprint + "|" + hash; + desired[key] = value; + + if ( + File.Exists(destination) + && previous.TryGetValue(key, out var previousValue) + && string.Equals(previousValue, value, StringComparison.Ordinal) + ) + { + skipped++; + continue; + } + + upToDate = false; + for (var attempt = 1; attempt <= attemptLimit; attempt++) + { + try + { + if (File.Exists(destination) && FilesMatch(assetSource, destination)) + { + // Another project or node already produced identical content, + // so the race is a no-op rather than a failure. + skipped++; + break; + } + + WriteAtomically(destination, temporary => File.Copy(assetSource, temporary, true)); + copied++; + break; + } + catch (Exception e) when (IsTransient(e)) + { + if (attempt < attemptLimit) + { + Log.LogMessage( + MessageImportance.Low, + "Retrying copy of '{0}' to '{1}' (attempt {2} of {3}): {4}", + assetSource, + destination, + attempt + 1, + attemptLimit, + e.Message + ); + Thread.Sleep(retryDelay + ((attempt - 1) * 100)); + } + else + { + ReportFailure(assetSource, destination, attempt, e.Message); + } + } + catch (Exception e) when (IsPermanent(e)) + { + ReportFailure(assetSource, destination, attempt, e.Message); + break; + } + } + } + + if (!string.IsNullOrEmpty(manifestFile) && failed == 0) + { + var lines = new List { "schema|1" }; + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var pair in desired) + { + var separator = pair.Key.IndexOf('|'); + if (separator < 0) + continue; + + var group = pair.Key.Substring(0, separator); + counts[group] = counts.ContainsKey(group) ? counts[group] + 1 : 1; + lines.Add("file|" + group + "|" + pair.Key.Substring(separator + 1) + "|" + pair.Value); + } + + foreach (var group in groups) + lines.Add("group|" + group + "|" + (counts.ContainsKey(group) ? counts[group] : 0)); + + // Entries owned by another target (the packaged agent folders, for + // example) are preserved so both targets can share one manifest. + if (File.Exists(manifestFile)) + { + try + { + foreach (var line in File.ReadAllLines(manifestFile)) + { + var parts = line.Split('|'); + if ( + parts.Length >= 3 + && (parts[0] == "file" || parts[0] == "group") + && !groups.Contains(parts[1]) + ) + { + lines.Add(line); + } + } + } + catch (Exception e) when (IsTransient(e)) + { + Log.LogMessage( + MessageImportance.Low, + "Could not preserve existing agent sync manifest entries: {0}", + e.Message + ); + } + } + + // A no-op build must not touch repository files at all, so the + // manifest is only written when its content actually changes. + var manifestUpToDate = false; + try + { + if (File.Exists(manifestFile)) + { + var desiredManifest = new StringBuilder(); + foreach (var line in lines) + desiredManifest.Append(line).Append('\n'); + + manifestUpToDate = string.Equals( + File.ReadAllText(manifestFile).Replace("\r\n", "\n"), + desiredManifest.ToString(), + StringComparison.Ordinal + ); + } + } + catch (Exception e) when (IsTransient(e)) + { + manifestUpToDate = false; + } + + for (var attempt = 1; attempt <= attemptLimit && !manifestUpToDate; attempt++) + { + try + { + WriteAtomically( + manifestFile, + temporary => File.WriteAllLines(temporary, lines, new UTF8Encoding(false)) + ); + break; + } + catch (Exception e) when (IsTransient(e)) + { + if (attempt < attemptLimit) + { + Thread.Sleep(retryDelay); + } + else + { + Log.LogMessage( + MessageImportance.Low, + "Could not update the agent sync manifest '{0}': {1}", + manifestFile, + e.Message + ); + } + } + } + } + + upToDate = upToDate || copied == 0; + Log.LogMessage( + MessageImportance.Low, + "Purview .agents sync: {0} copied, {1} unchanged, {2} failed.", + copied, + skipped, + failed + ); + return Finish(); + } + + var destinationFile = DestinationFile ?? string.Empty; + if (string.IsNullOrEmpty(destinationFile)) + { + upToDate = true; + return Finish(); + } + + if (File.Exists(destinationFile)) + { + if (!OverwriteExisting) + { + // Bootstraps are user-owned: an existing file is never touched, which + // turns a lost race between parallel projects into a no-op. + if ( + VerifyExistingContent + && !string.IsNullOrEmpty(SourceFile) + && File.Exists(SourceFile) + && !TextMatches(destinationFile, SourceFile) + ) + { + Log.LogWarning( + "'{0}' already exists and was left untouched, but it differs from the SDK-provided '{1}'. Set PurviewRepoBootstrapMode=Always to overwrite it.", + destinationFile, + SourceFile + ); + } + + skipped++; + upToDate = true; + return Finish(); + } + + if (!string.IsNullOrEmpty(SourceFile) && File.Exists(SourceFile) && FilesMatch(SourceFile, destinationFile)) + { + skipped++; + upToDate = true; + return Finish(); + } + } + + byte[] content; + var lineItems = Lines ?? Array.Empty(); + var sourceDescription = string.IsNullOrEmpty(SourceFile) ? "generated content" : SourceFile; + if (!string.IsNullOrEmpty(SourceFile) && File.Exists(SourceFile)) + { + content = File.ReadAllBytes(SourceFile); + } + else if (lineItems.Length > 0) + { + var builder = new StringBuilder(); + foreach (var line in lineItems) + builder.Append(line.ItemSpec).Append(Environment.NewLine); + content = new UTF8Encoding(false).GetBytes(builder.ToString()); + } + else if (!string.IsNullOrEmpty(SourceFile)) + { + ReportFailure(sourceDescription, destinationFile, attemptLimit, "the source file does not exist"); + return Finish(); + } + else + { + upToDate = true; + return Finish(); + } + + for (var attempt = 1; attempt <= attemptLimit; attempt++) + { + try + { + WriteAtomically(destinationFile, temporary => File.WriteAllBytes(temporary, content)); + copied++; + upToDate = true; + break; + } + catch (Exception e) when (IsTransient(e)) + { + if (attempt < attemptLimit) + { + Log.LogMessage( + MessageImportance.Low, + "Retrying bootstrap write of '{0}' (attempt {1} of {2}): {3}", + destinationFile, + attempt + 1, + attemptLimit, + e.Message + ); + Thread.Sleep(retryDelay + ((attempt - 1) * 100)); + } + else + { + ReportFailure(sourceDescription, destinationFile, attempt, e.Message); + } + } + catch (Exception e) when (IsPermanent(e)) + { + ReportFailure(sourceDescription, destinationFile, attempt, e.Message); + break; + } + } + + Log.LogMessage( + MessageImportance.Low, + "Purview repository bootstrap: '{0}' {1}, {2} skipped, {3} failed.", + destinationFile, + copied > 0 ? "written" : "unchanged", + skipped, + failed + ); + + // This fragment deliberately ends without a return statement: the code task + // factory appends its own success return, so ending on a return here would + // make that generated statement unreachable (CS0162). Logged errors still + // fail the target, and the explicit check below keeps the task contract. + Finish(); + if (Log.HasLoggedErrors) + { + return false; + } + ]]> + + + + + + + <_PurviewRepoBootstrapOverwriteExisting Condition="'$(PurviewRepoBootstrapMode)' == 'Always'" + >true + <_PurviewRepoBootstrapVerifyExistingContent Condition="'$(PurviewRepoBootstrapMode)' == 'WarnOnDrift'" + >true + <_PurviewRepoBootstrapOverwriteExisting Condition="'$(_PurviewRepoBootstrapOverwriteExisting)' == ''" + >false + <_PurviewRepoBootstrapVerifyExistingContent + Condition="'$(_PurviewRepoBootstrapVerifyExistingContent)' == ''" + >false + + + @@ -556,14 +1263,16 @@ Condition="'$(RepositoryEditorConfigFilePath)' == '' AND '$(_EffectiveRepositoryEditorConfigFilePath)' != ''" >$(_EffectiveRepositoryEditorConfigFilePath) - - @@ -590,67 +1299,80 @@ + + + <_PurviewAgentSyncManifestPath>$([MSBuild]::NormalizePath('$(_PurviewAgentFolderRepoRoot)', '.purview', 'agent-sync.cache')) + + + <_PurviewAgentSyncManifestPath>$(PurviewAgentSyncManifestPath) + + + - <_PurviewAgentFolderFiles Include="$(MSBuildThisFileDirectory)..\.agents\**\*" /> - <_PurviewAgentFolderDirectories Include="@(_PurviewAgentFolderFiles -> '$(_PurviewAgentFolderDestinationRoot)\%(RecursiveDir)')" /> + <_PurviewAgentFolderSyncSource Include="$(PurviewAgentFolderSourcePath)"> + sdk:$(PurviewBuildSdkVersionForGlobalJson) + - - - + + + + - <_PurviewPackagedAgentFolderFiles Include="%(_PurviewPackagedAgentFolder.Identity)\**\*" /> - - - <_PurviewPackagedAgentFolderDirectories Include="@(_PurviewPackagedAgentFolderFiles -> '$(_PurviewAgentFolderDestinationRoot)\%(RecursiveDir)')" /> + <_PurviewPackagedAgentFolderSyncSource Include="@(_PurviewPackagedAgentFolder)"> + package:%(PackageId) + - - @@ -777,7 +1499,8 @@ @@ -832,11 +1555,7 @@ Condition="'$(RepositoryGlobalJsonFilePath)' == '' AND '$(_EffectiveRepositoryGlobalJsonFilePath)' != ''" >$(_EffectiveRepositoryGlobalJsonFilePath) - - + <_RepositoryGlobalJsonLines Include="{" /> <_RepositoryGlobalJsonLines Include=" "test": {" /> <_RepositoryGlobalJsonLines Include=" "runner": "Microsoft.Testing.Platform"" /> @@ -846,11 +1565,16 @@ <_RepositoryGlobalJsonLines Include=" }" /> <_RepositoryGlobalJsonLines Include="}" /> - diff --git a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs index 55d65db..8242798 100644 --- a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs @@ -29,6 +29,16 @@ public async Task CompilerVisibleProperties_Include_AllSdkProperties(Cancellatio "SourceLinkPackageName", "ExcludePurviewTelemetry", "ExcludeMSTelemetryExtension", + "PurviewAgentFolderSourcePath", + "PurviewAgentFolderCopyRetries", + "PurviewAgentFolderCopyRetryDelayMilliseconds", + "PurviewAgentFolderCopyFailureAsError", + "PurviewAgentSyncManifestPath", + "PurviewRepoBootstrapMode", + "PurviewRepoBootstrapCopyRetries", + "PurviewRepoBootstrapCopyRetryDelayMilliseconds", + "PurviewRepoBootstrapCopyFailureAsError", + "PurviewSuppressCopyRetryWarnings", "DisableGenerateAssemblyInfoClass", "EnableAssemblyNameGeneration", "DisableAutoInternalsVisibleTo", diff --git a/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs b/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs new file mode 100644 index 0000000..2d50d03 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/RepositoryFileSyncTests.cs @@ -0,0 +1,358 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Verifies how the SDK synchronises files into shared repository locations +/// (.agents content, .editorconfig and global.json). These destinations are written by +/// every project in a solution, so the behaviour under concurrency, unchanged content +/// and blocked destinations is part of the contract: +/// +/// * unchanged content is skipped using the repository manifest, +/// * retries are quiet and only the terminal failure is reported - as an error by +/// default, or a warning when the caller opts out, +/// * bootstrap files are never overwritten unless explicitly requested. +/// +public sealed class RepositoryFileSyncTests +{ + const string AgentSourceFolder = "agent-source"; + const string AgentsFolder = ".agents"; + const string ManifestRelativePath = ".purview/agent-sync.cache"; + + [Test] + public async Task AgentFolderSync_ColdBuild_MirrorsContentAndWritesManifest(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)); + // Retry notices are demoted to messages, so they never appear as warnings. + await Assert.That(output + errors).DoesNotContain("warning MSB3026"); + await Assert.That(output).DoesNotContain("already in sync"); + + await Assert + .That(await File.ReadAllTextAsync(DestinationPath(h, "agents", "guide.md"), cancellationToken)) + .IsEqualTo("# Guide\n"); + await Assert + .That(await File.ReadAllTextAsync(DestinationPath(h, "skills", "demo", "SKILL.md"), cancellationToken)) + .IsEqualTo("# Skill\n"); + await Assert + .That( + await File.ReadAllTextAsync( + DestinationPath(h, "prompts", "nested", "deep", "prompt.md"), + cancellationToken + ) + ) + .IsEqualTo("# Prompt\n"); + + var manifestPath = ManifestPath(h); + await Assert.That(File.Exists(manifestPath)).IsTrue(); + + var manifest = await File.ReadAllTextAsync(manifestPath, cancellationToken); + await Assert.That(manifest).Contains("schema|1"); + await Assert.That(manifest).Contains("group|sdk:"); + await Assert + .That(manifest.Split('\n').Count(line => line.StartsWith("file|", StringComparison.Ordinal))) + .IsEqualTo(3); + + // The manifest folder must stay invisible to Git without consumer configuration. + await Assert + .That( + await File.ReadAllTextAsync( + Path.Combine(h.SolutionDirectory, ".purview", ".gitignore"), + cancellationToken + ) + ) + .Contains("*"); + } + + [Test] + public async Task AgentFolderSync_SecondBuild_SkipsUnchangedContent(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + var (firstSuccess, firstOutput, firstErrors) = await h.BuildAsync( + restore: true, + verbose: true, + cancellationToken + ); + await Assert.That(firstSuccess).IsTrue().Because(TestHelpers.GenerateError(firstOutput, firstErrors)); + + var mirroredFile = DestinationPath(h, "agents", "guide.md"); + var manifestPath = ManifestPath(h); + var mirroredTime = File.GetLastWriteTimeUtc(mirroredFile); + var manifestTime = File.GetLastWriteTimeUtc(manifestPath); + + var (success, output, errors) = await h.BuildAsync(restore: false, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("already in sync"); + await Assert.That(output).Contains("0 copied"); + await Assert.That(output + errors).DoesNotContain("warning MSB3026"); + await Assert.That(File.GetLastWriteTimeUtc(mirroredFile)).IsEqualTo(mirroredTime); + await Assert.That(File.GetLastWriteTimeUtc(manifestPath)).IsEqualTo(manifestTime); + } + + [Test] + public async Task AgentFolderSync_SourceChange_IsMirroredOnNextBuild(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + var (firstSuccess, firstOutput, firstErrors) = await h.BuildAsync( + restore: true, + verbose: true, + cancellationToken + ); + await Assert.That(firstSuccess).IsTrue().Because(TestHelpers.GenerateError(firstOutput, firstErrors)); + + await File.WriteAllTextAsync(SourcePath(h, "agents", "guide.md"), "# Guide v2\n", cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: false, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("already in sync"); + await Assert + .That(await File.ReadAllTextAsync(DestinationPath(h, "agents", "guide.md"), cancellationToken)) + .IsEqualTo("# Guide v2\n"); + } + + [Test] + public async Task AgentFolderSync_DeletedDestination_IsRestored(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + var (firstSuccess, firstOutput, firstErrors) = await h.BuildAsync( + restore: true, + verbose: true, + cancellationToken + ); + await Assert.That(firstSuccess).IsTrue().Because(TestHelpers.GenerateError(firstOutput, firstErrors)); + + var mirroredFile = DestinationPath(h, "skills", "demo", "SKILL.md"); + File.Delete(mirroredFile); + + var (success, output, errors) = await h.BuildAsync(restore: false, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(File.Exists(mirroredFile)).IsTrue(); + await Assert.That(await File.ReadAllTextAsync(mirroredFile, cancellationToken)).IsEqualTo("# Skill\n"); + } + + [Test] + public async Task AgentFolderSync_BlockedDestination_FailsWithErrorAfterRetries(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync( + extraProps: """ + 1 + 1 + """, + cancellationToken: cancellationToken + ); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + // A directory occupying the destination file path fails the rename on every + // platform, which exercises the terminal-failure path deterministically. + Directory.CreateDirectory(DestinationPath(h, "agents", "guide.md")); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsFalse(); + await Assert.That(output + errors).Contains("The Purview SDK could not copy"); + await Assert.That(output + errors).Contains("guide.md"); + await Assert.That(output + errors).Contains("after 1 attempt"); + } + + [Test] + public async Task AgentFolderSync_BlockedDestination_WithFailureAsWarning_KeepsBuildPassing( + CancellationToken cancellationToken + ) + { + using var h = await CreateHarnessAsync( + extraProps: """ + 1 + 1 + false + """, + cancellationToken: cancellationToken + ); + await CreateAgentSourceFilesAsync(h, cancellationToken); + + Directory.CreateDirectory(DestinationPath(h, "agents", "guide.md")); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output + errors).Contains("The Purview SDK could not copy"); + await Assert.That(output + errors).Contains("warning"); + } + + [Test] + public async Task GlobalJsonBootstrap_CreatesFileOnceAndLeavesItUntouched(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + + var (firstSuccess, firstOutput, firstErrors) = await h.BuildAsync( + restore: true, + verbose: true, + cancellationToken + ); + await Assert.That(firstSuccess).IsTrue().Because(TestHelpers.GenerateError(firstOutput, firstErrors)); + + var globalJsonPath = Path.Combine(h.SolutionDirectory, "global.json"); + await Assert.That(File.Exists(globalJsonPath)).IsTrue(); + + var content = await File.ReadAllTextAsync(globalJsonPath, cancellationToken); + await Assert.That(content).Contains("\"Purview.BuildSdk\""); + await Assert.That(content).Contains("Microsoft.Testing.Platform"); + var writtenTime = File.GetLastWriteTimeUtc(globalJsonPath); + + var (success, output, errors) = await h.BuildAsync(restore: false, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output + errors).DoesNotContain("warning MSB3026"); + await Assert.That(File.GetLastWriteTimeUtc(globalJsonPath)).IsEqualTo(writtenTime); + } + + [Test] + public async Task EditorConfigBootstrap_ExistingFile_IsNeverOverwritten(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + + var editorConfigPath = Path.Combine(h.SolutionDirectory, ".editorconfig"); + await File.WriteAllTextAsync(editorConfigPath, "# user owned\n", cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(await File.ReadAllTextAsync(editorConfigPath, cancellationToken)).IsEqualTo("# user owned\n"); + } + + [Test] + public async Task EditorConfigBootstrap_WarnOnDrift_ReportsWarningAndKeepsUserFile( + CancellationToken cancellationToken + ) + { + using var h = await CreateHarnessAsync( + extraProps: "WarnOnDrift", + cancellationToken: cancellationToken + ); + + var editorConfigPath = Path.Combine(h.SolutionDirectory, ".editorconfig"); + await File.WriteAllTextAsync(editorConfigPath, "# user owned\n", cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output + errors).Contains("was left untouched"); + await Assert.That(await File.ReadAllTextAsync(editorConfigPath, cancellationToken)).IsEqualTo("# user owned\n"); + } + + [Test] + public async Task EditorConfigBootstrap_AlwaysOverwrite_ReplacesUserFile(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync( + extraProps: "Always", + cancellationToken: cancellationToken + ); + + var editorConfigPath = Path.Combine(h.SolutionDirectory, ".editorconfig"); + await File.WriteAllTextAsync(editorConfigPath, "# user owned\n", cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert + .That(await File.ReadAllTextAsync(editorConfigPath, cancellationToken)) + .DoesNotContain("# user owned"); + } + + [Test] + public async Task RepoBootstrapModeNever_SkipsAllBootstrapWrites(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync( + extraProps: "Never", + cancellationToken: cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, verbose: true, cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(File.Exists(Path.Combine(h.SolutionDirectory, "global.json"))).IsFalse(); + await Assert.That(File.Exists(Path.Combine(h.SolutionDirectory, ".editorconfig"))).IsFalse(); + } + + [Test] + public async Task CopyRetryWarnings_AreDemotedByDefaultAndRestorable(CancellationToken cancellationToken) + { + using var h = await CreateHarnessAsync(extraProps: null, cancellationToken: cancellationToken); + await Assert.That(await h.GetPropertyAsync("MSBuildWarningsAsMessages", cancellationToken)).Contains("MSB3026"); + + using var restored = await CreateHarnessAsync( + extraProps: "false", + cancellationToken: cancellationToken + ); + await Assert + .That(await restored.GetPropertyAsync("MSBuildWarningsAsMessages", cancellationToken)) + .DoesNotContain("MSB3026"); + } + + static async Task CreateHarnessAsync(string? extraProps, CancellationToken cancellationToken) + { + var props = "../" + AgentSourceFolder + ""; + if (!string.IsNullOrWhiteSpace(extraProps)) + props += extraProps; + + // The harness only declares SourceLink centrally, so the automatically injected + // telemetry package references are removed like the other harness-based tests do. + var harness = await ProjectHarness.CreateAsync( + "MyLibrary", + extraProps: props, + extraItems: """ + + + """, + cancellationToken: cancellationToken + ); + + // An AGENTS.md at the repository root is one of the discovery mechanisms that + // resolve the .agents destination root, and it keeps the tests free of git. + await File.WriteAllTextAsync( + Path.Combine(harness.SolutionDirectory, "AGENTS.md"), + "# Agent guidance\n", + cancellationToken + ); + + return harness; + } + + static Task CreateAgentSourceFilesAsync(ProjectHarness harness, CancellationToken cancellationToken) => + Task.WhenAll( + WriteAsync(SourcePath(harness, "agents", "guide.md"), "# Guide\n", cancellationToken), + WriteAsync(SourcePath(harness, "skills", "demo", "SKILL.md"), "# Skill\n", cancellationToken), + WriteAsync(SourcePath(harness, "prompts", "nested", "deep", "prompt.md"), "# Prompt\n", cancellationToken) + ); + + static async Task WriteAsync(string path, string content, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllTextAsync(path, content, cancellationToken); + } + + static string SourcePath(ProjectHarness harness, params string[] segments) => + Path.Combine([harness.SolutionDirectory, AgentSourceFolder, .. segments]); + + static string DestinationPath(ProjectHarness harness, params string[] segments) => + Path.Combine([harness.SolutionDirectory, AgentsFolder, .. segments]); + + static string ManifestPath(ProjectHarness harness) => + Path.Combine(harness.SolutionDirectory, ManifestRelativePath.Replace('/', Path.DirectorySeparatorChar)); +}