From 2a28be406f7c9197a52e3623c98ebb56efdaa81d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 09:03:34 +0100 Subject: [PATCH 1/5] feat: added roslyn-only component support --- README.md | 2 +- docs/wiki/Configuration-Reference.md | 4 ++-- package.json | 2 +- src/src/Build/Helpers/PackageInspector.cs | 12 +++++++++++ src/src/Build/Modules/ValidatePackModule.cs | 18 ++++++++++++----- src/tests/Build.IntegrationTests/GlobTests.cs | 14 +++++++++++++ .../PackageValidationTests.cs | 20 +++++++++++++++++++ 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 18c2da0..f1ed099 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Restore → Build → Test ├→ Pack → Validate → Publish → GitHub relea Version ───────────────┘ ``` -`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow). +`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow). ## Repository CI/CD diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 9bb004e..3ebd099 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -39,7 +39,7 @@ Command line > environment variables > `purview-build.json` > baked-in defaults | Key | Default | Purpose | | --- | --- | --- | -| `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa | +| `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa, except analyzer-only packages that embed their PDBs under `analyzers/dotnet/` | | `RequireSymbolFiles` | `true` | Every `.snupkg` must contain at least one `.pdb` | | `RequireSourceLink` | `false` | Every `.dll`/`.exe` must have a matching portable PDB containing a Source Link record | | `RequireDeterministic` | `false` | Every `.dll`/`.exe` must be built deterministically (the PE carries the Reproducible debug directory entry) | @@ -47,7 +47,7 @@ Command line > environment variables > `purview-build.json` > baked-in defaults | `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package) | | `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package) | -Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` (custom debug info records); they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). +Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). > **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above. diff --git a/package.json b/package.json index 1921bf2..1974360 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build", - "version": "0.3.2", + "version": "0.3.3", "private": true, "homepage": "https://purview.dev/projects/build/", "bugs": { diff --git a/src/src/Build/Helpers/PackageInspector.cs b/src/src/Build/Helpers/PackageInspector.cs index bb14ae7..5e5a78e 100644 --- a/src/src/Build/Helpers/PackageInspector.cs +++ b/src/src/Build/Helpers/PackageInspector.cs @@ -24,6 +24,15 @@ public static bool RequiresAssemblyInspection(PackValidationSettings settings) = || settings.RequireDeterministic || settings.RequiredCompilerFlags.Length > 0; + public static bool IsPdbAllowedInNupkg(string path) => + path.StartsWith("tools/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("analyzers/dotnet/", StringComparison.OrdinalIgnoreCase); + + public static bool HasEmbeddedAnalyzerSymbols(IEnumerable paths) => + paths.Any(path => + IsPdbFile(path) && path.StartsWith("analyzers/dotnet/", StringComparison.OrdinalIgnoreCase) + ); + public static void ValidateContentRules( IReadOnlyList files, string packageId, @@ -283,4 +292,7 @@ static bool IsAssembly(string path) return string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase); } + + static bool IsPdbFile(string path) => + string.Equals(Path.GetExtension(path), ".pdb", StringComparison.OrdinalIgnoreCase); } diff --git a/src/src/Build/Modules/ValidatePackModule.cs b/src/src/Build/Modules/ValidatePackModule.cs index 2977587..ebccb25 100644 --- a/src/src/Build/Modules/ValidatePackModule.cs +++ b/src/src/Build/Modules/ValidatePackModule.cs @@ -87,7 +87,11 @@ CancellationToken cancellationToken { foreach (var pair in packagePairs.Values) { - if (pair.Nupkg is not null && pair.Snupkg is null) + if ( + pair.Nupkg is not null + && pair.Snupkg is null + && !pair.Nupkg.HasEmbeddedAnalyzerSymbols + ) { pair.Nupkg.AddError( $"Package '{pair.Nupkg.PackageId}' {pair.Nupkg.Version.ToNormalizedString()} has no matching .snupkg." @@ -200,7 +204,8 @@ CancellationToken cancellationToken "nupkg", CreatePackageKey(id, version), id, - version + version, + PackageInspector.HasEmbeddedAnalyzerSymbols(files) ); result.AddErrors(errors); return result; @@ -297,12 +302,12 @@ static void ValidateNoPdbFiles(IEnumerable files, List errors) { var pdbFiles = files .Where(IsPdbFile) - .Where(file => !file.StartsWith("tools/", StringComparison.OrdinalIgnoreCase)) + .Where(file => !PackageInspector.IsPdbAllowedInNupkg(file)) .ToArray(); if (pdbFiles.Length > 0) errors.Add( $"Package contains PDB file(s): {string.Join(", ", pdbFiles)}. " - + "PDBs outside 'tools/' must only be delivered through the .snupkg." + + "PDBs outside 'tools/' and 'analyzers/dotnet/' must only be delivered through the .snupkg." ); } @@ -335,7 +340,8 @@ public sealed class PackValidationResult( string kind, string packageKey, string packageId, - NuGetVersion version + NuGetVersion version, + bool hasEmbeddedAnalyzerSymbols = false ) { readonly List _errors = []; @@ -350,6 +356,8 @@ NuGetVersion version public NuGetVersion Version { get; } = version; + public bool HasEmbeddedAnalyzerSymbols { get; } = hasEmbeddedAnalyzerSymbols; + public IReadOnlyList Errors => _errors; internal void AddError(string error) => _errors.Add(error); diff --git a/src/tests/Build.IntegrationTests/GlobTests.cs b/src/tests/Build.IntegrationTests/GlobTests.cs index db3db1e..5ca5070 100644 --- a/src/tests/Build.IntegrationTests/GlobTests.cs +++ b/src/tests/Build.IntegrationTests/GlobTests.cs @@ -57,6 +57,20 @@ public async Task ExactPattern_MatchesOnlyExactPath_IgnoringCase() await Assert.That(PackageInspector.MatchesAny("readme.txt", Files)).IsFalse(); } + [Test] + public async Task AnalyzerPdb_IsAllowedInNupkg() + { + await Assert.That(PackageInspector.IsPdbAllowedInNupkg("analyzers/dotnet/cs/Sample.pdb")).IsTrue(); + await Assert + .That( + PackageInspector.HasEmbeddedAnalyzerSymbols([ + "analyzers/dotnet/cs/Sample.dll", + "analyzers/dotnet/cs/Sample.pdb", + ]) + ) + .IsTrue(); + } + [Test] public async Task RequiredContent_GlobSatisfied_Passes() { diff --git a/src/tests/Build.IntegrationTests/PackageValidationTests.cs b/src/tests/Build.IntegrationTests/PackageValidationTests.cs index 612667a..fd8490f 100644 --- a/src/tests/Build.IntegrationTests/PackageValidationTests.cs +++ b/src/tests/Build.IntegrationTests/PackageValidationTests.cs @@ -110,6 +110,26 @@ await ValidateInPackagesAsync( ); } + [Test] + public async Task ValidateAssemblies_AnalyzerPdbInNupkg_IsInspected(CancellationToken cancellationToken) + { + var (dll, _) = TestHelper.EmitAssembly(deterministic: true, cancellationToken); + var pdb = BuildValidPdb(); + + await ValidateInPackagesAsync( + [("analyzers/dotnet/cs/Sample.dll", dll), ("analyzers/dotnet/cs/Sample.pdb", pdb)], + [], + new PackValidationSettings + { + RequireSourceLink = true, + RequireDeterministic = true, + RequiredCompilerFlags = ["optimization=release"], + }, + expectedErrors: 0, + cancellationToken: cancellationToken + ); + } + static async Task ValidateInPackagesAsync( (string Entry, byte[] Content)[] nupkgEntries, (string Entry, byte[] Content)[] snupkgEntries, From 682dcfc449afc23203f519167c9965dcf42a806a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 10:19:16 +0100 Subject: [PATCH 2/5] feat: updated package validation rules --- Justfile | 7 ++ docs/wiki/Configuration-Reference.md | 7 +- docs/wiki/Pack-Validation.md | 13 ++ src/src/Build/Helpers/BunCLIOptions.cs | 4 +- src/src/Build/Helpers/PackageInspector.cs | 100 ++++++++++++++- src/src/Build/Modules/ValidatePackModule.cs | 17 +-- .../Build/Settings/PackValidationSettings.cs | 25 ++++ src/tests/Build.IntegrationTests/GlobTests.cs | 118 ++++++++++++++++++ 8 files changed, 272 insertions(+), 19 deletions(-) diff --git a/Justfile b/Justfile index d29e689..b7d10e3 100644 --- a/Justfile +++ b/Justfile @@ -68,6 +68,13 @@ pipeline-tests *args: echo "Running tests pipeline..." "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} +# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing +[group('Pipeline')] +pipeline-pack-validate *args: + just ensure-pipeline-tool + echo "Running pack + validate pipeline..." + "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + # Build the project with the specified configuration, defaulting to "Debug" [group('Build and Test')] build *args: diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 3ebd099..b30c3b9 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -44,10 +44,11 @@ Command line > environment variables > `purview-build.json` > baked-in defaults | `RequireSourceLink` | `false` | Every `.dll`/`.exe` must have a matching portable PDB containing a Source Link record | | `RequireDeterministic` | `false` | Every `.dll`/`.exe` must be built deterministically (the PE carries the Reproducible debug directory entry) | | `RequiredCompilerFlags` | `[]` | Compiler-flag `key=value` entries that must appear in each assembly's PDB compiler-flags record (e.g. `optimization=release`) | -| `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package) | -| `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package) | +| `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package). Entries may use the `$(TFM)` target-framework partial token, e.g. `lib/$(TFM)/Foo.dll` | +| `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package). Also supports the `$(TFM)` partial token | +| `RequireExplicitContent` | `false` | Makes `RequiredContent` exhaustive: every generated package must match a rule, and every non-metadata entry must match a declared glob; undeclared packages/entries are errors | -Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). +Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Entries may also contain the `$(TFM)` partial token (e.g. `lib/$(TFM)/Foo.dll`), which expands to one entry per target framework the package actually ships (short folder name, discovered from the package's own content groups); each expanded entry is checked independently. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. When `RequireExplicitContent` is `true`, `RequiredContent` becomes exhaustive: every generated package must match a rule, and every non-metadata entry in a matched package must match a declared (and `$(TFM)`-expanded) glob — undeclared packages or entries are errors. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). > **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above. diff --git a/docs/wiki/Pack-Validation.md b/docs/wiki/Pack-Validation.md index a14588b..8cd4227 100644 --- a/docs/wiki/Pack-Validation.md +++ b/docs/wiki/Pack-Validation.md @@ -21,6 +21,19 @@ Both maps are keyed by package-id glob (case-insensitive; `"*"` matches every pa - **Required**: the rule is satisfied when any package entry matches the glob; a missing match is an error. - **Forbidden**: any matching entry is an error. +### Target-framework partials (`$(TFM)`) + +An entry may contain the literal token `$(TFM)`, e.g. `lib/$(TFM)/Purview.Telemetry.dll`. The token is expanded into one entry per target framework the package actually ships (short folder name, e.g. `net8.0`, `net48`, `netstandard2.0`, discovered via the package's own `lib`/`ref`/`build`/`tools`/`frameworkAssemblies` groups), so the rule must be satisfied independently for every one of the package's target frameworks. If the package has no detectable target frameworks, a `$(TFM)` entry is reported as an error rather than silently skipped. + +### Explicit/exhaustive content (`RequireExplicitContent`) + +When `RequireExplicitContent` is `true`, `RequiredContent` becomes the precise, exhaustive definition of every package's contents instead of a "must contain at least" list: + +- Every produced `.nupkg`'s package id must match a `RequiredContent` key; a generated package with no matching rule is an error. +- Every entry in a matched package (after `$(TFM)` expansion) — excluding standard NuGet/OPC metadata (`.nuspec`, `[Content_Types].xml`, `_rels/`, `package/services/metadata/`, `.signature.p7s`) — must match one of that package's `RequiredContent` globs; any undeclared entry is reported as an error. + +`ForbiddenContent` is unaffected by `RequireExplicitContent` and continues to apply as a simple deny-list. + ## Assembly inspection When any of `RequireSourceLink`, `RequireDeterministic`, or `RequiredCompilerFlags` is enabled, each `.dll`/`.exe` in the `.nupkg` that the package ships symbols for (a sibling PDB exists in the `.snupkg` or `.nupkg`) is inspected: diff --git a/src/src/Build/Helpers/BunCLIOptions.cs b/src/src/Build/Helpers/BunCLIOptions.cs index f42e901..0fab036 100644 --- a/src/src/Build/Helpers/BunCLIOptions.cs +++ b/src/src/Build/Helpers/BunCLIOptions.cs @@ -17,9 +17,9 @@ public static BunCLIOptions FromCommand(string command) var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); var commandParts = parts[0].Equals("bun", StringComparison.OrdinalIgnoreCase) - ? parts.Skip(1).ToArray() + ? [.. parts.Skip(1)] : parts; return new() { Tool = "bun", CommandParts = commandParts }; } -} \ No newline at end of file +} diff --git a/src/src/Build/Helpers/PackageInspector.cs b/src/src/Build/Helpers/PackageInspector.cs index 5e5a78e..b476f5f 100644 --- a/src/src/Build/Helpers/PackageInspector.cs +++ b/src/src/Build/Helpers/PackageInspector.cs @@ -12,6 +12,10 @@ static class PackageInspector static readonly Guid SourceLinkDebugInfoGuid = new("CC110556-A091-4D38-9FEC-25AB9A351A6A"); static readonly Guid CompilerFlagsDebugInfoGuid = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); + // Target-framework partial token, e.g. "lib/$(TFM)/Foo.dll" expands to one entry per + // target framework the package supports (short folder name, e.g. "net8.0", "net48"). + const string TfmToken = "$(TFM)"; + public static bool MatchesAny(string pattern, IEnumerable paths) { Matcher matcher = new(StringComparison.OrdinalIgnoreCase); @@ -33,21 +37,98 @@ public static bool HasEmbeddedAnalyzerSymbols(IEnumerable paths) => IsPdbFile(path) && path.StartsWith("analyzers/dotnet/", StringComparison.OrdinalIgnoreCase) ); + /// + /// True for standard NuGet/OPC package metadata entries that are never user-declared content: + /// the .nuspec, OPC parts ("[Content_Types].xml", "_rels/", "package/services/metadata/"), + /// and the package signature. + /// + public static bool IsPackageMetadata(string path) => + string.Equals(path, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("package/services/metadata/", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) + || string.Equals(path, ".signature.p7s", StringComparison.OrdinalIgnoreCase); + + /// + /// Expands the $(TFM) partial token in into one concrete entry + /// per target framework folder name. Entries without the token are returned unchanged. + /// + public static IReadOnlyList ExpandTfmToken( + string entry, + IReadOnlyCollection targetFrameworkFolderNames + ) + { + if (!entry.Contains(TfmToken, StringComparison.OrdinalIgnoreCase)) + return [entry]; + + if (targetFrameworkFolderNames.Count == 0) + return []; + + return [.. targetFrameworkFolderNames.Select(tfm => + entry.Replace(TfmToken, tfm, StringComparison.OrdinalIgnoreCase) + )]; + } + public static void ValidateContentRules( IReadOnlyList files, string packageId, Dictionary requiredRules, Dictionary forbiddenRules, - List errors + List errors, + IReadOnlyCollection? targetFrameworkFolderNames = null, + bool requireExplicitContent = false ) { + targetFrameworkFolderNames ??= []; + var required = GetContentRule(requiredRules, packageId); - if (required is not null) + if (required is null) + { + if (requireExplicitContent) + errors.Add( + $"Package '{packageId}' has no RequiredContent rule; RequireExplicitContent requires every generated package to declare its exact content." + ); + } + else { + List allowedEntries = []; foreach (var entry in required) { - if (!MatchesAny(entry, files)) - errors.Add($"Required content '{entry}' is missing from the package."); + var expanded = ExpandTfmToken(entry, targetFrameworkFolderNames); + if (expanded.Count == 0) + { + errors.Add( + $"Required content '{entry}' uses the '$(TFM)' partial but no target frameworks were detected in the package." + ); + continue; + } + + foreach (var candidate in expanded) + { + allowedEntries.Add(candidate); + if (!MatchesAny(candidate, files)) + { + errors.Add( + candidate == entry + ? $"Required content '{entry}' is missing from the package." + : $"Required content '{candidate}' (from '{entry}') is missing from the package." + ); + } + } + } + + if (requireExplicitContent) + { + foreach (var file in files) + { + if (IsPackageMetadata(file)) + continue; + + if (!allowedEntries.Any(entry => MatchesAny(entry, [file]))) + errors.Add( + $"Package '{packageId}' contains undeclared content '{file}' that is not defined in RequiredContent." + ); + } } } @@ -56,8 +137,15 @@ List errors { foreach (var entry in forbidden) { - if (MatchesAny(entry, files)) - errors.Add($"Forbidden content '{entry}' must not be in the package."); + foreach (var candidate in ExpandTfmToken(entry, targetFrameworkFolderNames)) + { + if (MatchesAny(candidate, files)) + errors.Add( + candidate == entry + ? $"Forbidden content '{entry}' must not be in the package." + : $"Forbidden content '{candidate}' (from '{entry}') must not be in the package." + ); + } } } } diff --git a/src/src/Build/Modules/ValidatePackModule.cs b/src/src/Build/Modules/ValidatePackModule.cs index ebccb25..975ab27 100644 --- a/src/src/Build/Modules/ValidatePackModule.cs +++ b/src/src/Build/Modules/ValidatePackModule.cs @@ -177,12 +177,19 @@ CancellationToken cancellationToken var files = reader.GetFiles().ToArray(); ValidateNoPdbFiles(files, errors); + var targetFrameworkFolderNames = (await reader.GetSupportedFrameworksAsync(cancellationToken)) + .Select(framework => framework.GetShortFolderName()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + PackageInspector.ValidateContentRules( files, id, settings.RequiredContent, settings.ForbiddenContent, - errors + errors, + targetFrameworkFolderNames, + settings.RequireExplicitContent ); if (PackageInspector.RequiresAssemblyInspection(settings)) @@ -243,7 +250,7 @@ CancellationToken cancellationToken var files = reader.GetFiles().ToArray(); var nonSymbolFiles = files - .Where(file => !IsPdbFile(file) && !IsSymbolPackageMetadata(file)) + .Where(file => !IsPdbFile(file) && !PackageInspector.IsPackageMetadata(file)) .ToArray(); if (nonSymbolFiles.Length > 0) errors.Add( @@ -314,12 +321,6 @@ static void ValidateNoPdbFiles(IEnumerable files, List errors) static bool IsPdbFile(string path) => string.Equals(Path.GetExtension(path), ".pdb", StringComparison.OrdinalIgnoreCase); - static bool IsSymbolPackageMetadata(string path) => - string.Equals(path, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("package/services/metadata/", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase); - static string CreatePackageKey(string id, NuGetVersion version) => $"{id}|{version.ToNormalizedString()}"; diff --git a/src/src/Build/Settings/PackValidationSettings.cs b/src/src/Build/Settings/PackValidationSettings.cs index 8c7d1f3..9316451 100644 --- a/src/src/Build/Settings/PackValidationSettings.cs +++ b/src/src/Build/Settings/PackValidationSettings.cs @@ -42,11 +42,36 @@ public sealed record PackValidationSettings /// that MUST be present in the .nupkg. Paths use forward slashes, e.g. /// "tools/**/Foo.dll" or "lib/netstandard2.0/Foo.dll". /// + /// + /// An entry may contain the literal token $(TFM) as a target-framework partial, e.g. + /// "lib/$(TFM)/Foo.dll". The token is expanded into one required entry per target framework + /// the package actually supports (short folder name, e.g. "net8.0", "net48", "netstandard2.0"), + /// so every one of the package's target frameworks must independently satisfy the entry. + /// public Dictionary RequiredContent { get; init; } = []; /// /// Package id (glob, case-insensitive; "*" matches every package) to entry path globs /// that MUST NOT be present in the .nupkg. Paths use forward slashes, e.g. "**/*.pdb". /// + /// + /// Entries may also use the $(TFM) target-framework partial token; see + /// . + /// public Dictionary ForbiddenContent { get; init; } = []; + + /// + /// When , becomes the exhaustive, exact + /// definition of every package's contents: + /// + /// Every produced .nupkg's id must match a key; a package + /// with no matching rule is an error. + /// Every entry in the .nupkg (excluding standard NuGet/OPC metadata such as the .nuspec, + /// "[Content_Types].xml", "_rels/", "package/services/metadata/", and ".signature.p7s") must + /// match one of that package's (TFM-expanded) globs; any + /// undeclared entry is an error. + /// + /// + /// Defaults to . + public bool RequireExplicitContent { get; init; } } diff --git a/src/tests/Build.IntegrationTests/GlobTests.cs b/src/tests/Build.IntegrationTests/GlobTests.cs index 5ca5070..07fd6de 100644 --- a/src/tests/Build.IntegrationTests/GlobTests.cs +++ b/src/tests/Build.IntegrationTests/GlobTests.cs @@ -142,4 +142,122 @@ public async Task ExactPackageId_WinsOverWildcard() ); await Assert.That(errors).IsEmpty(); } + + [Test] + public async Task TfmToken_ExpandsAndSatisfiesEachTargetFramework() + { + List errors = []; + string[] files = ["lib/net8.0/Foo.dll", "lib/netstandard2.0/Foo.dll"]; + + PackageInspector.ValidateContentRules( + files, + "purview.build", + new() { ["purview.build"] = ["lib/$(TFM)/Foo.dll"] }, + [], + errors, + targetFrameworkFolderNames: ["net8.0", "netstandard2.0"] + ); + + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task TfmToken_MissingForOneFramework_ReportsError() + { + List errors = []; + string[] files = ["lib/net8.0/Foo.dll"]; + + PackageInspector.ValidateContentRules( + files, + "purview.build", + new() { ["purview.build"] = ["lib/$(TFM)/Foo.dll"] }, + [], + errors, + targetFrameworkFolderNames: ["net8.0", "netstandard2.0"] + ); + + await Assert.That(errors).Count().IsEqualTo(1); + } + + [Test] + public async Task TfmToken_NoFrameworksDetected_ReportsError() + { + List errors = []; + + PackageInspector.ValidateContentRules( + Files, + "purview.build", + new() { ["purview.build"] = ["lib/$(TFM)/Foo.dll"] }, + [], + errors + ); + + await Assert.That(errors).Count().IsEqualTo(1); + } + + [Test] + public async Task ExplicitContent_AllDeclared_Passes() + { + List errors = []; + string[] files = ["README.md", "lib/netstandard2.0/Foo.dll"]; + + PackageInspector.ValidateContentRules( + files, + "purview.build", + new() { ["purview.build"] = ["README.md", "lib/netstandard2.0/Foo.dll"] }, + [], + errors, + requireExplicitContent: true + ); + + await Assert.That(errors).IsEmpty(); + } + + [Test] + public async Task ExplicitContent_UndeclaredFile_ReportsError() + { + List errors = []; + + PackageInspector.ValidateContentRules( + Files, + "purview.build", + new() { ["purview.build"] = ["README.md"] }, + [], + errors, + requireExplicitContent: true + ); + + // README.md is declared; every other non-metadata file in `Files` is undeclared. + await Assert.That(errors).Count().IsEqualTo(Files.Length - 1); + } + + [Test] + public async Task ExplicitContent_NoRuleForPackage_ReportsError() + { + List errors = []; + + PackageInspector.ValidateContentRules( + Files, + "some.other.package", + new() { ["purview.build"] = ["README.md"] }, + [], + errors, + requireExplicitContent: true + ); + + await Assert.That(errors).Count().IsEqualTo(1); + } + + [Test] + public async Task IsPackageMetadata_DetectsOpcAndNuspecEntries() + { + await Assert.That(PackageInspector.IsPackageMetadata("[Content_Types].xml")).IsTrue(); + await Assert.That(PackageInspector.IsPackageMetadata("_rels/.rels")).IsTrue(); + await Assert + .That(PackageInspector.IsPackageMetadata("package/services/metadata/core-properties/abc.psmdcp")) + .IsTrue(); + await Assert.That(PackageInspector.IsPackageMetadata("purview.build.nuspec")).IsTrue(); + await Assert.That(PackageInspector.IsPackageMetadata(".signature.p7s")).IsTrue(); + await Assert.That(PackageInspector.IsPackageMetadata("README.md")).IsFalse(); + } } From 3ded25e6666b0571247a658d82fa062708f8f9a9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 10:21:59 +0100 Subject: [PATCH 3/5] chore: formatting/ refactoring --- src/src/Build/Helpers/PackageInspector.cs | 1 + src/src/Build/Modules/LintModule.cs | 2 ++ src/src/Build/Modules/RestoreModule.cs | 1 + src/tests/Build.IntegrationTests/WebScriptsTests.cs | 8 ++++++-- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/src/Build/Helpers/PackageInspector.cs b/src/src/Build/Helpers/PackageInspector.cs index b476f5f..aa08e32 100644 --- a/src/src/Build/Helpers/PackageInspector.cs +++ b/src/src/Build/Helpers/PackageInspector.cs @@ -64,6 +64,7 @@ IReadOnlyCollection targetFrameworkFolderNames if (targetFrameworkFolderNames.Count == 0) return []; + // Expand the $(TFM) token into one entry per target framework folder name. return [.. targetFrameworkFolderNames.Select(tfm => entry.Replace(TfmToken, tfm, StringComparison.OrdinalIgnoreCase) )]; diff --git a/src/src/Build/Modules/LintModule.cs b/src/src/Build/Modules/LintModule.cs index cdfdffb..1d4fa60 100644 --- a/src/src/Build/Modules/LintModule.cs +++ b/src/src/Build/Modules/LintModule.cs @@ -101,9 +101,11 @@ string description settings.Value.WebFormatCheckCommand, "format check" ); + if (formatResult is not null) return formatResult; + // If the format check passed, run the linter return await RunIfDeclaredAsync(settings.Value.WebLintCommand, "lint"); } diff --git a/src/src/Build/Modules/RestoreModule.cs b/src/src/Build/Modules/RestoreModule.cs index 0ebbfa6..b084c75 100644 --- a/src/src/Build/Modules/RestoreModule.cs +++ b/src/src/Build/Modules/RestoreModule.cs @@ -25,6 +25,7 @@ CancellationToken cancellationToken ); } + // For non-web projects, perform a .NET restore return await context .DotNet() .Restore( diff --git a/src/tests/Build.IntegrationTests/WebScriptsTests.cs b/src/tests/Build.IntegrationTests/WebScriptsTests.cs index 9585333..f409da1 100644 --- a/src/tests/Build.IntegrationTests/WebScriptsTests.cs +++ b/src/tests/Build.IntegrationTests/WebScriptsTests.cs @@ -30,7 +30,9 @@ public async Task GetScriptName_ReturnsNullForNonScriptCommands() [Test] public async Task ReadScripts_ParsesDeclaredScripts() { - var repository = CreateTempRepository("""{"name":"site","scripts":{"build":"astro build","lint":"oxlint"}}"""); + var repository = CreateTempRepository( /*lang=json,strict*/ + """{"name":"site","scripts":{"build":"astro build","lint":"oxlint"}}""" + ); try { @@ -69,7 +71,9 @@ public async Task ReadScripts_ReturnsEmptyWhenMissing() [Test] public async Task ReadPackageName_ReturnsNameField() { - var repository = CreateTempRepository("""{"name":"purview-dev","version":"0.1.0"}"""); + var repository = CreateTempRepository( /*lang=json,strict*/ + """{"name":"purview-dev","version":"0.1.0"}""" + ); try { From 2c2baf3a65aaafbcf8232cbc38647fdc0dfd00b0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 10:24:33 +0100 Subject: [PATCH 4/5] refactor: made require explicit package content true by default --- purview-build.json | 2 +- src/src/Build/Settings/PackValidationSettings.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/purview-build.json b/purview-build.json index 09e41dd..4824c13 100644 --- a/purview-build.json +++ b/purview-build.json @@ -29,4 +29,4 @@ "Release": { "Mode": "None" } -} \ No newline at end of file +} diff --git a/src/src/Build/Settings/PackValidationSettings.cs b/src/src/Build/Settings/PackValidationSettings.cs index 9316451..2b18795 100644 --- a/src/src/Build/Settings/PackValidationSettings.cs +++ b/src/src/Build/Settings/PackValidationSettings.cs @@ -72,6 +72,6 @@ public sealed record PackValidationSettings /// undeclared entry is an error. /// /// - /// Defaults to . - public bool RequireExplicitContent { get; init; } + /// Defaults to . + public bool RequireExplicitContent { get; init; } = true; } From 53c36508121051691803cb0d3044a18dc02fe44a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 10:34:22 +0100 Subject: [PATCH 5/5] chore: disabled require explicit content --- LICENSE.md | 9 --------- purview-build.json | 5 +++-- src/Build.slnx | 1 + src/Directory.Build.props | 3 +-- 4 files changed, 5 insertions(+), 13 deletions(-) delete mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 9ea2dfc..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright © 2026 Kieron Lanning - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/purview-build.json b/purview-build.json index 4824c13..43f9597 100644 --- a/purview-build.json +++ b/purview-build.json @@ -9,14 +9,15 @@ "RequireSymbolFiles": true, "RequireSourceLink": true, "RequireDeterministic": true, + "RequireExplicitContent": false, "RequiredCompilerFlags": [ "optimization=release" ], "RequiredContent": { "purview.build": [ "tools/**/Purview.Build.dll", + "tools/**/appsettings.json", "README.md", - "LICENSE.md", "purview-logo-light.png" ] }, @@ -29,4 +30,4 @@ "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/src/Build.slnx b/src/Build.slnx index 744daeb..9bfbfe6 100644 --- a/src/Build.slnx +++ b/src/Build.slnx @@ -3,6 +3,7 @@ + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 64b6c50..2e795c1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -21,13 +21,12 @@ git purview-logo-light.png README.md - LICENSE.md + MIT false -