diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index caef965..e7e59c2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -328,6 +328,19 @@ jobs:
throw 'Packages contain Roslyn runtime assets.'
}
+ - name: Validate package symbols
+ shell: pwsh
+ run: |
+ $version = '${{ steps.gitversion.outputs.semver }}'
+ $feed = (Resolve-Path '${{ env.PACKAGE_OUTPUT }}').Path
+ $symbolPairs = @()
+ foreach ($packageId in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) {
+ $symbolPairs += Join-Path $feed "$packageId.$version.nupkg"
+ $symbolPairs += Join-Path $feed "$packageId.$version.snupkg"
+ }
+ dotnet run --project tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj -c Release --no-restore -- @symbolPairs
+ if ($LASTEXITCODE -ne 0) { throw "Package symbol validation failed with exit code $LASTEXITCODE." }
+
- name: Validate final package consumers
shell: pwsh
run: |
diff --git a/DECISIONS.md b/DECISIONS.md
index 5ad9449..6f3ba9c 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1,5 +1,14 @@
# Mammoth.LiteMapper Decision Log
+## Generator symbol package layout (2026-10-15)
+
+- Contract: specification sections 4.1, 22.19 and 24.6 require analyzer isolation, valid shipping packages and portable reproducible symbols. No public API or mapping semantic change is needed.
+- Cause: the published 4.0.1 generator DLL lives under analyzers/dotnet/cs, while automatic debug-output packing placed its matching PDB under lib/netstandard2.0. Removing BuiltProjectOutputGroupOutput did not remove the separate debug-symbol output group.
+- Packing: disable automatic generator build-output packing and explicitly pack its DLL and PDB at the analyzer path. The PDB is also included beside the analyzer DLL in the regular generator package; neither becomes a runtime lib/ref asset.
+- Validation: use one standalone .NET validator in package tests, release CI before artifact promotion, and the manual publication script, including -SkipValidation and dry runs. Check metadata, path pairing, portable format, PE/PDB association and the compiler checksum with the PDB ID zeroed. Determinism and checksum manifests remain required but do not prove symbol validity.
+- Coverage follows NuGet's supplied-PDB-to-assembly rule; it does not require a duplicate PDB for every packaged DLL. Shipping tests require the Abstractions and primary runtime PDBs plus the standalone generator's analyzer PDB. The primary package's duplicated analyzer DLL does not need a second copy of those globally indexed symbols.
+- Recovery: the original release PDB matches the immutable 4.0.1 DLL. A separately prepared snupkg changes only that entry's path and can be submitted independently at the existing ID/version. Preparation and PR creation do not authorize publication. The PR targets develop because 4.0.1 is published and its release branch was removed.
+
## Issues #55 and #56: legal target accessors and required construction (2026-10-15)
- Contract: specification sections 6.4, 9.1, 10.4-10.5, 14.1, 16.1 and 20.1-20.3 already define legal target assignment and mandatory required-member diagnostics. No normative change or new API is needed.
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index cbf88c7..06f0a42 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -1,5 +1,13 @@
# Mammoth.LiteMapper Implementation Plan
+## Generator symbol package repair (2026-10-15)
+
+- Scope: user-authorized packaging maintenance on an isolated branch from develop after the 4.0.1 release. Prepare corrected 4.0.1 symbols separately, then open a draft PR against develop; the release branch has been removed. This is direct maintenance, not a claimed tracker item.
+- Test-first: validate every shipping nupkg/snupkg pair, reproducing the generator PDB path mismatch before changing its pack configuration. Reject mismatched PE/PDB identity, compiler checksum, nonportable symbols, and inconsistent package metadata.
+- Implementation: disable automatic generator build-output packing and explicitly place its DLL and portable PDB together under analyzers/dotnet/cs. Reuse one archive/identity/checksum validator in tests, release CI and the manual publishing script before publication.
+- Validation: focused regressions, full correctness and packaging coverage excluding local benchmark-launching tests, independent review, and terminal exact-head Windows/Linux and Roslyn CI. Preserve existing CI gates, skill content, and performance tests.
+- Boundary: corrected symbols are delivered for a separate human upload. No NuGet push, package replacement/unlisting, tag, merge, credential changes, machine settings, or local timing benchmarks are authorized.
+
## Release 4.0.1 corrections: issues #55 and #56 (2026-10-15)
- Scope: user-authorized combined repair on an isolated branch from release/4.0.1 at d7d8c3bbc578470ddb9403c2a9064b96d8d87b25, with a draft PR back to that release branch.
diff --git a/Mammoth.LiteMapper.sln b/Mammoth.LiteMapper.sln
index ca020fe..cda0c99 100644
--- a/Mammoth.LiteMapper.sln
+++ b/Mammoth.LiteMapper.sln
@@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.Benchmar
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.ProcessFixture", "tests\Mammoth.LiteMapper.Packaging.Tests\ProcessFixture\Mammoth.LiteMapper.ProcessFixture.csproj", "{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{E63B4D72-2B11-4B36-99B4-489B586E2321}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.PackageValidation", "tools\Mammoth.LiteMapper.PackageValidation\Mammoth.LiteMapper.PackageValidation.csproj", "{C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -189,6 +193,18 @@ Global
{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x64.Build.0 = Release|Any CPU
{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x86.ActiveCfg = Release|Any CPU
{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x86.Build.0 = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x64.Build.0 = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x86.Build.0 = Debug|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x64.ActiveCfg = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x64.Build.0 = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x86.ActiveCfg = Release|Any CPU
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -206,5 +222,6 @@ Global
{77429349-3F3A-4DDA-B18A-D148612D07DD} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{9FAC9778-94D0-4ADC-B2FE-6DBC5715A38E} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0} = {E63B4D72-2B11-4B36-99B4-489B586E2321}
EndGlobalSection
EndGlobal
diff --git a/Mammoth.LiteMapper.slnx b/Mammoth.LiteMapper.slnx
index 6cf3275..bdb4250 100644
--- a/Mammoth.LiteMapper.slnx
+++ b/Mammoth.LiteMapper.slnx
@@ -24,4 +24,7 @@
+
+
+
diff --git a/STATUS.md b/STATUS.md
index 006368b..f28f7dd 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -1,5 +1,14 @@
# Mammoth.LiteMapper Status
+## Generator symbol packaging checkpoint (2026-10-15)
+
+- Scope: direct user-authorized maintenance from develop after 4.0.1 publication. The old release branch is absent. The isolated fix branch targets develop and stops at draft PR/human review.
+- Reproduction: the retained tag-run generator snupkg has its PDB under lib/netstandard2.0 while the published DLL is under analyzers/dotnet/cs. All six release-artifact hashes were verified. Published nupkg payloads match the retained release packages, and the generator PDB matches the published DLL's portable identity and compiler checksum.
+- Red-first: the shipping-pair regression failed on that exact orphan path while ten fixture checks passed. Three publication-gate tests failed before script integration. After the fix, the focused symbol/gate group passed 14 tests and the release/native safeguard group passed 60 tests. Additional no-PDB and partial-symbol controls document NuGet's supplied-PDB validation direction.
+- Implementation: explicit generator analyzer DLL/PDB packing replaces the build-output-removal target. The shared nonshipping validator checks package metadata, archive paths, portability, PE/PDB association and compiler checksum. Release CI and the manual publishing script validate every shipping pair before promotion or push, including skip-validation and dry-run paths.
+- Validation: Release solution build passes with no compiler errors; two existing generator-test dependency audit warnings remain. Local generator correctness 1,083, runtime five and integration one pass, with allocation measurements and the benchmark launcher excluded from local execution. Package-suite results and terminal exact-head Windows/Linux/Roslyn CI evidence are recorded in the draft PR. Independent read-only review found no remaining actionable issue.
+- Recovery boundary: corrected 4.0.1 symbols preserve every entry's payload and change only the generator PDB archive path. They passed validation against the actual published nupkg and were delivered separately for human re-upload. No NuGet upload, unlist, tag, merge, credential/system change, skill/performance-test edit or local timing benchmark was performed.
+
## Release 4.0.1 issues #55 and #56 (2026-10-15)
- Base: remote release/4.0.1 independently verified unchanged at d7d8c3bbc578470ddb9403c2a9064b96d8d87b25. Work uses the isolated feature/4.0.1-mapping-corrections branch; exactly two nonduplicate bug issues were created and claimed through AgentStack.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index e2da38a..f0fbf42 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -1005,6 +1005,8 @@ Windows and Linux are supported. A `netstandard2.0` library runs inside an appli
The primary `Mammoth.LiteMapper` package is the normal install path. `Mammoth.LiteMapper.Generator` is the generator implementation package and is not the normal consumer install route.
+The generator DLL is an analyzer asset under `analyzers/dotnet/cs`, outside runtime `lib` and `ref` assets. Portable symbols must match the exact compiled DLL and use the same archive-relative path in the corresponding `.snupkg`, replacing `.dll` with `.pdb`. Symbol-package corrections do not require changing an application's package reference.
+
## Analyzer options
The generator recognizes these optional build properties for diagnostics and development. In the consuming project, set each value and export it with `CompilerVisibleProperty` so Roslyn passes it to the generator:
diff --git a/publish-nuget.ps1 b/publish-nuget.ps1
index 1e81778..6fac7ab 100644
--- a/publish-nuget.ps1
+++ b/publish-nuget.ps1
@@ -175,8 +175,21 @@ if ($unexpected.Count -ne 0 -or $missing.Count -ne 0 -or $artifacts.Count -ne $e
throw "Expected exactly $($expectedArtifacts.Count) versioned package artifacts for $version; missing: $($missing -join ', '); unexpected: $($unexpected.Name -join ', ')."
}
-foreach ($packageId in $packageIds) {
- $artifactName = "$packageId.$version.nupkg"
+$symbolValidator = 'tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj'
+if ($SkipValidation) {
+ & dotnet restore $symbolValidator
+ Assert-NativeSuccess 'Restore package symbol validator'
+}
+$symbolPairs = @()
+foreach ($packageId in $packageIds) {
+ $symbolPairs += Join-Path $resolvedOutput "$packageId.$version.nupkg"
+ $symbolPairs += Join-Path $resolvedOutput "$packageId.$version.snupkg"
+}
+& dotnet run --project $symbolValidator -c Release --no-restore -- @symbolPairs
+Assert-NativeSuccess 'Validate package symbols'
+
+foreach ($packageId in $packageIds) {
+ $artifactName = "$packageId.$version.nupkg"
$artifactPath = Join-Path $resolvedOutput $artifactName
if ($DryRun) {
Write-Host "Dry run: would publish $artifactPath to $Source"
diff --git a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj
index afdfa03..35cc701 100644
--- a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj
+++ b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj
@@ -2,6 +2,7 @@
netstandard2.0
true
+ false
true
Source generator package for Mammoth.LiteMapper.
true
@@ -14,11 +15,9 @@
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
+
-
-
-
-
-
-
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj
index 8766b16..bd07d57 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj
@@ -7,6 +7,7 @@
+
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs
new file mode 100644
index 0000000..6531b56
--- /dev/null
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs
@@ -0,0 +1,300 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using Mammoth.LiteMapper.PackageValidation;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Mammoth.LiteMapper.Packaging.Tests
+{
+ [TestClass]
+ public sealed class SymbolPackageValidationTests
+ {
+ [TestMethod]
+ public void MatchingPortableSymbolsAreAccepted()
+ {
+ using var fixture = SymbolPackageFixture.Create(pdbPath: "LIB/NET10.0/mammoth.litemapper.packaging.tests.PDB");
+
+ AssertValid(fixture);
+ }
+
+ [TestMethod]
+ public void SymbolPackageMustContainAPdb()
+ {
+ using var fixture = SymbolPackageFixture.Create(includePdb: false);
+
+ AssertInvalid(fixture, "does not contain a PDB");
+ }
+
+ [TestMethod]
+ public void AdditionalAssembliesDoNotRequireDuplicateSymbols()
+ {
+ using var fixture = SymbolPackageFixture.Create(addAssemblyWithoutSymbols: true);
+
+ AssertValid(fixture);
+ }
+
+ [TestMethod]
+ public void PdbMustUseTheAssemblyArchiveRelativePath()
+ {
+ using var fixture = SymbolPackageFixture.Create(pdbPath: "symbols/Mammoth.LiteMapper.Packaging.Tests.pdb");
+
+ AssertInvalid(fixture, "same archive-relative path");
+ }
+
+ [TestMethod]
+ public void PdbIdentityMustMatchTheAssembly()
+ {
+ using var fixture = SymbolPackageFixture.Create(pdbMutation: ChangePortablePdbIdentity);
+
+ AssertInvalid(fixture, "does not match the PE identity");
+ }
+
+ [TestMethod]
+ public void PdbChecksumMustMatchEvenWhenTheIdentityMatches()
+ {
+ using var fixture = SymbolPackageFixture.Create(pdbMutation: AppendByteWithoutChangingIdentity);
+
+ AssertInvalid(fixture, "does not match the compiler checksum");
+ }
+
+ [TestMethod]
+ public void PdbMustBePortable()
+ {
+ using var fixture = SymbolPackageFixture.Create(pdbMutation: RemovePortableSignature);
+
+ AssertInvalid(fixture, "is not portable");
+ }
+
+ [TestMethod]
+ public void AssemblyMustContainACompilerPdbChecksum()
+ {
+ using var fixture = SymbolPackageFixture.Create(peMutation: RemovePdbChecksumEntry);
+
+ AssertInvalid(fixture, "does not contain a compiler PDB checksum");
+ }
+
+ [TestMethod]
+ [DataRow("Other.Package", "1.2.3", "package ID")]
+ [DataRow("Test.Package", "1.2.4", "package version")]
+ public void SymbolIdentityMustMatchThePrimaryPackage(string symbolId, string symbolVersion, string expectedError)
+ {
+ using var fixture = SymbolPackageFixture.Create(symbolId: symbolId, symbolVersion: symbolVersion);
+
+ AssertInvalid(fixture, expectedError);
+ }
+
+ [TestMethod]
+ public void SymbolNuspecMustDeclareSymbolsPackage()
+ {
+ using var fixture = SymbolPackageFixture.Create(includeSymbolsPackageType: false);
+
+ AssertInvalid(fixture, "SymbolsPackage");
+ }
+
+ [TestMethod]
+ public void SymbolPackageRejectsUnsupportedEntryExtensions()
+ {
+ using var fixture = SymbolPackageFixture.Create(addUnsupportedEntry: true);
+
+ AssertInvalid(fixture, "unsupported extension");
+ }
+
+ [TestMethod]
+ public void ShippingSymbolPackagesMatchTheirBinaries()
+ {
+ var feed = Milestone14PackagingAndAotTests.CreateFeed();
+ Milestone14PackagingAndAotTests.PackAll(feed);
+
+ foreach (var packageId in new[]
+ {
+ "Mammoth.LiteMapper.Abstractions",
+ "Mammoth.LiteMapper.Generator",
+ "Mammoth.LiteMapper"
+ })
+ {
+ var package = Path.Combine(feed, packageId + ".1.0.0.nupkg");
+ var symbols = Path.Combine(feed, packageId + ".1.0.0.snupkg");
+ var errors = SymbolPackageValidator.Validate(package, symbols);
+ Assert.AreEqual(0, errors.Count, packageId + Environment.NewLine + string.Join(Environment.NewLine, errors));
+ }
+
+ AssertSymbolEntry(feed, "Mammoth.LiteMapper.Abstractions", "lib/netstandard2.0/Mammoth.LiteMapper.Abstractions.pdb");
+ AssertSymbolEntry(feed, "Mammoth.LiteMapper.Generator", "analyzers/dotnet/cs/Mammoth.LiteMapper.Generator.pdb");
+ AssertSymbolEntry(feed, "Mammoth.LiteMapper", "lib/netstandard2.0/Mammoth.LiteMapper.pdb");
+ }
+
+ private static void AssertSymbolEntry(string feed, string packageId, string entryPath)
+ {
+ using var archive = ZipFile.OpenRead(Path.Combine(feed, packageId + ".1.0.0.snupkg"));
+ Assert.IsTrue(
+ archive.Entries.Any(entry => string.Equals(entry.FullName, entryPath, StringComparison.OrdinalIgnoreCase)),
+ packageId + " is missing " + entryPath + ".");
+ }
+
+ private static void AssertValid(SymbolPackageFixture fixture)
+ {
+ var errors = SymbolPackageValidator.Validate(fixture.PackagePath, fixture.SymbolPackagePath);
+ Assert.AreEqual(0, errors.Count, string.Join(Environment.NewLine, errors));
+ }
+
+ private static void AssertInvalid(SymbolPackageFixture fixture, string expectedError)
+ {
+ var errors = SymbolPackageValidator.Validate(fixture.PackagePath, fixture.SymbolPackagePath);
+ Assert.AreNotEqual(0, errors.Count);
+ StringAssert.Contains(string.Join(Environment.NewLine, errors), expectedError);
+ }
+
+ private static byte[] ChangePortablePdbIdentity(byte[] bytes)
+ {
+ using var stream = new MemoryStream(bytes, false);
+ using var provider = MetadataReaderProvider.FromPortablePdbStream(stream, MetadataStreamOptions.LeaveOpen);
+ var offset = provider.GetMetadataReader().DebugMetadataHeader!.IdStartOffset;
+ var changed = bytes.ToArray();
+ changed[offset] ^= 0x01;
+ return changed;
+ }
+
+ private static byte[] AppendByteWithoutChangingIdentity(byte[] bytes)
+ {
+ var changed = new byte[bytes.Length + 1];
+ bytes.CopyTo(changed, 0);
+ changed[changed.Length - 1] = 0x01;
+ return changed;
+ }
+
+ private static byte[] RemovePortableSignature(byte[] bytes)
+ {
+ var changed = bytes.ToArray();
+ changed[0] = 0;
+ return changed;
+ }
+
+ private static byte[] RemovePdbChecksumEntry(byte[] bytes)
+ {
+ var changed = bytes.ToArray();
+ using var stream = new MemoryStream(changed, true);
+ using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen);
+ var directory = reader.PEHeaders.PEHeader!.DebugTableDirectory;
+ var section = reader.PEHeaders.SectionHeaders.Single(header =>
+ directory.RelativeVirtualAddress >= header.VirtualAddress &&
+ directory.RelativeVirtualAddress < header.VirtualAddress + Math.Max(header.VirtualSize, header.SizeOfRawData));
+ var tableOffset = section.PointerToRawData + directory.RelativeVirtualAddress - section.VirtualAddress;
+ const int debugDirectoryEntrySize = 28;
+ var checksumType = (int)DebugDirectoryEntryType.PdbChecksum;
+
+ for (var offset = tableOffset; offset < tableOffset + directory.Size; offset += debugDirectoryEntrySize)
+ {
+ if (BitConverter.ToInt32(changed, offset + 12) == checksumType)
+ {
+ Array.Clear(changed, offset + 12, sizeof(int));
+ return changed;
+ }
+ }
+
+ Assert.Fail("The packaging test assembly does not contain a compiler PDB checksum entry.");
+ return changed;
+ }
+
+ private sealed class SymbolPackageFixture : IDisposable
+ {
+ private SymbolPackageFixture(string directory)
+ {
+ Directory = directory;
+ PackagePath = Path.Combine(directory, "Test.Package.1.2.3.nupkg");
+ SymbolPackagePath = Path.Combine(directory, "Test.Package.1.2.3.snupkg");
+ }
+
+ internal string Directory { get; }
+
+ internal string PackagePath { get; }
+
+ internal string SymbolPackagePath { get; }
+
+ internal static SymbolPackageFixture Create(
+ string symbolId = "Test.Package",
+ string symbolVersion = "1.2.3",
+ string pdbPath = "lib/net10.0/Mammoth.LiteMapper.Packaging.Tests.pdb",
+ Func? pdbMutation = null,
+ Func? peMutation = null,
+ bool includeSymbolsPackageType = true,
+ bool includePdb = true,
+ bool addAssemblyWithoutSymbols = false,
+ bool addUnsupportedEntry = false)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "MammothLiteMapperSymbolTests", Guid.NewGuid().ToString("N"));
+ System.IO.Directory.CreateDirectory(directory);
+ var fixture = new SymbolPackageFixture(directory);
+ var assemblyPath = typeof(SymbolPackageValidationTests).Assembly.Location;
+ var pdbFilePath = Path.ChangeExtension(assemblyPath, ".pdb");
+ var peBytes = File.ReadAllBytes(assemblyPath);
+ var pdbBytes = File.ReadAllBytes(pdbFilePath);
+ if (peMutation != null)
+ {
+ peBytes = peMutation(peBytes);
+ }
+
+ if (pdbMutation != null)
+ {
+ pdbBytes = pdbMutation(pdbBytes);
+ }
+
+ using (var package = ZipFile.Open(fixture.PackagePath, ZipArchiveMode.Create))
+ {
+ WriteText(package, "Test.Package.nuspec", Nuspec("Test.Package", "1.2.3", false));
+ WriteBytes(package, "lib/net10.0/Mammoth.LiteMapper.Packaging.Tests.dll", peBytes);
+ if (addAssemblyWithoutSymbols)
+ {
+ WriteBytes(package, "analyzers/dotnet/cs/Additional.dll", peBytes);
+ }
+ }
+
+ using (var symbols = ZipFile.Open(fixture.SymbolPackagePath, ZipArchiveMode.Create))
+ {
+ WriteText(symbols, "Test.Package.nuspec", Nuspec(symbolId, symbolVersion, includeSymbolsPackageType));
+ if (includePdb)
+ {
+ WriteBytes(symbols, pdbPath, pdbBytes);
+ }
+
+ if (addUnsupportedEntry)
+ {
+ WriteText(symbols, "content/readme.txt", "unsupported");
+ }
+ }
+
+ return fixture;
+ }
+
+ public void Dispose()
+ {
+ if (System.IO.Directory.Exists(Directory))
+ {
+ System.IO.Directory.Delete(Directory, true);
+ }
+ }
+
+ private static string Nuspec(string id, string version, bool symbolsPackage)
+ {
+ var packageType = symbolsPackage
+ ? ""
+ : string.Empty;
+ return "" + id + "" + version + "" + packageType + "";
+ }
+
+ private static void WriteText(ZipArchive archive, string path, string value)
+ {
+ using var writer = new StreamWriter(archive.CreateEntry(path).Open());
+ writer.Write(value);
+ }
+
+ private static void WriteBytes(ZipArchive archive, string path, byte[] value)
+ {
+ using var stream = archive.CreateEntry(path).Open();
+ stream.Write(value, 0, value.Length);
+ }
+ }
+ }
+}
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs
new file mode 100644
index 0000000..e9d3c24
--- /dev/null
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs
@@ -0,0 +1,83 @@
+using System;
+using System.IO;
+using System.Text;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Mammoth.LiteMapper.Packaging.Tests
+{
+ [TestClass]
+ public sealed class SymbolPublicationGateTests
+ {
+ [TestMethod]
+ public void ReleaseWorkflowValidatesSymbolsBeforeConsumerValidationAndArtifactUpload()
+ {
+ var workflow = File.ReadAllText(Repository.Path(".github/workflows/ci.yml"));
+ var validation = workflow.IndexOf(" - name: Validate package symbols", StringComparison.Ordinal);
+ Assert.IsTrue(validation >= 0, "Release packages must pass symbol validation before promotion.");
+ Assert.IsTrue(validation < workflow.IndexOf(" - name: Validate final package consumers", StringComparison.Ordinal));
+ Assert.IsTrue(validation < workflow.IndexOf(" - name: Upload validated packages", StringComparison.Ordinal));
+ var block = workflow.Substring(validation, workflow.IndexOf(" - name: Validate final package consumers", StringComparison.Ordinal) - validation);
+ StringAssert.Contains(block, "Mammoth.LiteMapper.PackageValidation.csproj");
+ StringAssert.Contains(block, "'Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper'");
+ StringAssert.Contains(block, "if ($LASTEXITCODE -ne 0)");
+ }
+
+ [TestMethod]
+ public void ManualPublishingValidatesAllSymbolPairsEvenWhenBroadValidationIsSkipped()
+ {
+ var script = File.ReadAllText(Repository.Path("publish-nuget.ps1"));
+ var validation = script.IndexOf("& dotnet run --project $symbolValidator", StringComparison.Ordinal);
+ Assert.IsTrue(validation >= 0, "Symbol validation must be mandatory even with -SkipValidation.");
+ var artifactCheck = script.IndexOf("Expected exactly", StringComparison.Ordinal);
+ var publish = script.LastIndexOf("foreach ($packageId in $packageIds)", StringComparison.Ordinal);
+ Assert.IsTrue(artifactCheck < validation && validation < publish);
+ StringAssert.Contains(script, "Assert-NativeSuccess 'Validate package symbols'");
+ StringAssert.Contains(script, "$symbolPairs += Join-Path $resolvedOutput \"$packageId.$version.snupkg\"");
+ }
+
+ [TestMethod]
+ public void ManualPublishingStopsBeforePushWhenSymbolValidationFails()
+ {
+ var relativeOutput = Path.Combine("artifacts", "symbols-gate-" + Guid.NewGuid().ToString("N"));
+ var output = Repository.Path(relativeOutput);
+ Directory.CreateDirectory(output);
+ var marker = Path.Combine(output, "push.marker");
+ var command = @"
+$ErrorActionPreference = 'Stop'
+function dotnet {
+ $global:LASTEXITCODE = 0
+ $joined = $args -join ' '
+ if ($joined -like '*gitversion*') {
+ '{""SemVer"":""1.2.3"",""AssemblySemVer"":""1.2.3.0"",""AssemblySemFileVer"":""1.2.3.0"",""InformationalVersion"":""1.2.3""}'
+ } elseif ($joined -like 'pack *') {
+ $index = [Array]::IndexOf($args, '-o')
+ foreach ($id in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) {
+ foreach ($extension in @('nupkg', 'snupkg')) {
+ Set-Content -LiteralPath (Join-Path $args[$index + 1] ($id + '.1.2.3.' + $extension)) -Value 'mock artifact'
+ }
+ }
+ } elseif ($joined -like 'run *PackageValidation*') {
+ $global:LASTEXITCODE = 23
+ } elseif ($joined -like 'nuget push *') {
+ Set-Content -LiteralPath '" + Escape(marker) + @"' -Value 'pushed'
+ }
+}
+& '" + Escape(Repository.Path("publish-nuget.ps1")) + @"' -ApiKey 'local-mock-key' -Source local -OutputDirectory '" + Escape(relativeOutput) + @"' -SkipValidation -DryRun:$false
+";
+ try
+ {
+ var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
+ var result = TestProcess.Run("pwsh", "-NoProfile -NonInteractive -EncodedCommand " + encoded, Repository.Root, TimeSpan.FromMinutes(1));
+ Assert.AreNotEqual(0, result.ExitCode, result.Output + result.Error);
+ StringAssert.Contains(result.Error, "23");
+ Assert.IsFalse(File.Exists(marker), "Invalid symbol packages reached NuGet push.");
+ }
+ finally
+ {
+ Directory.Delete(output, true);
+ }
+ }
+
+ private static string Escape(string value) => value.Replace("'", "''", StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs
index 3dad244..585bdb4 100644
--- a/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs
+++ b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs
@@ -19,6 +19,8 @@ public static IEnumerable