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/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/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..b30c3b9 100644
--- a/docs/wiki/Configuration-Reference.md
+++ b/docs/wiki/Configuration-Reference.md
@@ -39,15 +39,16 @@ 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) |
| `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. 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`. 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/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/purview-build.json b/purview-build.json
index 09e41dd..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"
]
},
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
-
paths)
{
Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
@@ -24,21 +28,108 @@ 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)
+ );
+
+ ///
+ /// 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 [];
+
+ // Expand the $(TFM) token into one entry per target framework folder name.
+ 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."
+ );
+ }
}
}
@@ -47,8 +138,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."
+ );
+ }
}
}
}
@@ -283,4 +381,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/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/src/Build/Modules/ValidatePackModule.cs b/src/src/Build/Modules/ValidatePackModule.cs
index 2977587..975ab27 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."
@@ -173,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))
@@ -200,7 +211,8 @@ CancellationToken cancellationToken
"nupkg",
CreatePackageKey(id, version),
id,
- version
+ version,
+ PackageInspector.HasEmbeddedAnalyzerSymbols(files)
);
result.AddErrors(errors);
return result;
@@ -238,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(
@@ -297,24 +309,18 @@ 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."
);
}
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()}";
@@ -335,7 +341,8 @@ public sealed class PackValidationResult(
string kind,
string packageKey,
string packageId,
- NuGetVersion version
+ NuGetVersion version,
+ bool hasEmbeddedAnalyzerSymbols = false
)
{
readonly List _errors = [];
@@ -350,6 +357,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/src/Build/Settings/PackValidationSettings.cs b/src/src/Build/Settings/PackValidationSettings.cs
index 8c7d1f3..2b18795 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; } = true;
}
diff --git a/src/tests/Build.IntegrationTests/GlobTests.cs b/src/tests/Build.IntegrationTests/GlobTests.cs
index db3db1e..07fd6de 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()
{
@@ -128,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();
+ }
}
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,
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
{