diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 6452261..1d78421 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -13,7 +13,7 @@ jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
- contents: read
+ contents: write
packages: write
steps:
@@ -45,6 +45,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
+ id: build
uses: docker/build-push-action@v5
with:
context: .
@@ -59,3 +60,25 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
+
+ - name: Create release image reference
+ if: github.event_name == 'release'
+ shell: bash
+ run: |
+ set -euo pipefail
+ {
+ echo "CodeMedic container image"
+ echo "Release: ${{ github.event.release.tag_name }}"
+ echo "Repository: ${{ github.repository }}"
+ echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
+ echo "Digest: ${{ steps.build.outputs.digest }}"
+ echo ""
+ echo "Tags:"
+ echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /'
+ } > codemedic-container-image.txt
+
+ - name: Attach image reference to release
+ if: github.event_name == 'release'
+ uses: softprops/action-gh-release@v2
+ with:
+ files: codemedic-container-image.txt
diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml
new file mode 100644
index 0000000..f9d16e5
--- /dev/null
+++ b/.github/workflows/release-binaries.yml
@@ -0,0 +1,126 @@
+name: Release Binaries (NativeAOT)
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+env:
+ DOTNET_NOLOGO: true
+
+jobs:
+ publish-aot:
+ name: Publish NativeAOT (${{ matrix.rid }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ rid: win-x64
+ ext: .exe
+ - os: windows-latest
+ rid: win-arm64
+ ext: .exe
+ - os: ubuntu-latest
+ rid: linux-x64
+ ext: ""
+ - os: ubuntu-latest
+ rid: linux-arm64
+ ext: ""
+ needsCross: true
+ - os: macos-13
+ rid: osx-x64
+ ext: ""
+ - os: macos-latest
+ rid: osx-arm64
+ ext: ""
+
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Install NativeAOT prerequisites (Linux)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y clang zlib1g-dev
+
+ - name: Install cross toolchain (linux-arm64)
+ if: runner.os == 'Linux' && matrix.needsCross
+ shell: bash
+ run: |
+ set -euo pipefail
+ sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
+
+ - name: Restore
+ run: dotnet restore src/CodeMedic.sln
+
+ - name: Publish (NativeAOT)
+ run: >-
+ dotnet publish src/CodeMedic/CodeMedic.csproj
+ -c Release
+ -r ${{ matrix.rid }}
+ /p:PublishAot=true
+
+ - name: Prepare artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ VERSION="${{ github.event.release.tag_name }}"
+ if [ -z "$VERSION" ]; then VERSION="manual"; fi
+
+ OUTDIR="src/CodeMedic/bin/Release/net10.0/${{ matrix.rid }}/publish"
+ BIN="$OUTDIR/CodeMedic${{ matrix.ext }}"
+
+ if [ ! -f "$BIN" ]; then
+ echo "Expected binary not found at $BIN"
+ echo "Publish directory contents:"
+ ls -la "$OUTDIR" || true
+ exit 1
+ fi
+
+ mkdir -p artifacts
+ DEST="artifacts/CodeMedic-${VERSION}-${{ matrix.rid }}${{ matrix.ext }}"
+ cp "$BIN" "$DEST"
+
+ - name: Generate checksums (SHA-256)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $files = Get-ChildItem -LiteralPath 'artifacts' -File | Where-Object { $_.Name -notmatch '\.sha256$' }
+ if (-not $files -or $files.Count -eq 0) {
+ throw 'No artifacts found to checksum.'
+ }
+
+ foreach ($file in $files) {
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()
+ $line = "$hash $($file.Name)"
+ $checksumPath = "$($file.FullName).sha256"
+ $line | Out-File -FilePath $checksumPath -Encoding ascii
+ }
+
+ - name: Upload workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: CodeMedic-${{ matrix.rid }}
+ path: artifacts/*
+
+ - name: Attach binaries to GitHub Release
+ if: github.event_name == 'release'
+ uses: softprops/action-gh-release@v2
+ with:
+ files: artifacts/*
diff --git a/src/CodeMedic/CodeMedic.csproj b/src/CodeMedic/CodeMedic.csproj
index f4741c8..a562f1e 100644
--- a/src/CodeMedic/CodeMedic.csproj
+++ b/src/CodeMedic/CodeMedic.csproj
@@ -9,6 +9,22 @@
true
Linux
..\..
+
+
+ win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64
+
+
+
+
+ true
+ true
+ true
+ true
+
+
+ none
+ false
+ false
diff --git a/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs b/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs
new file mode 100644
index 0000000..a294df9
--- /dev/null
+++ b/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Test.CodeMedic")]
diff --git a/src/CodeMedic/Output/ConsoleRenderer.cs b/src/CodeMedic/Output/ConsoleRenderer.cs
index e1466d8..558033c 100644
--- a/src/CodeMedic/Output/ConsoleRenderer.cs
+++ b/src/CodeMedic/Output/ConsoleRenderer.cs
@@ -234,7 +234,8 @@ private void RenderTable(ReportTable reportTable)
{
var table = new Table
{
- Border = TableBorder.Rounded
+ Border = TableBorder.Rounded,
+ Expand = true
};
if (!string.IsNullOrWhiteSpace(reportTable.Title))
@@ -242,10 +243,30 @@ private void RenderTable(ReportTable reportTable)
table.Title = new TableTitle($"[bold]{reportTable.Title}[/]");
}
- // Add columns
+ // Add columns (wrap long-text columns; keep short columns compact)
+ var noWrapHeaders = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Version",
+ "Latest",
+ "Type",
+ "License",
+ "Source",
+ "Comm",
+ "Severity",
+ "Score",
+ "Count",
+ "Status"
+ };
+
foreach (var header in reportTable.Headers)
{
- table.AddColumn(header);
+ var column = new TableColumn(header);
+ if (noWrapHeaders.Contains(header))
+ {
+ column.NoWrap();
+ }
+
+ table.AddColumn(column);
}
// Add rows
diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs
index 658db83..b181431 100644
--- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs
+++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs
@@ -309,8 +309,8 @@ private async Task> AddNuGetPackagesSectionAsync
latestVersionDisplay = "Current";
}
- // Truncate package names if too long to improve table formatting
- var displayName = package.Name.Length > 25 ? package.Name.Substring(0, 22) + "..." : package.Name;
+ // Keep full package names/licenses for report accuracy.
+ var displayName = package.Name;
// Shorten source type and commercial status for better formatting
var sourceType = package.SourceType == "Open Source" ? "Open" :
@@ -320,8 +320,7 @@ private async Task> AddNuGetPackagesSectionAsync
var commercial = package.Commercial == "Unknown" ? "?" :
package.Commercial == "Yes" ? "Y" : "N";
- // Truncate license if too long
- var license = package.License?.Length > 12 ? package.License.Substring(0, 9) + "..." : package.License ?? "Unknown";
+ var license = package.License ?? "Unknown";
packagesTable.AddRow(
displayName,
@@ -527,19 +526,36 @@ private async Task FetchLatestVersionForPackageAsync(PackageInfo package)
var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/index.json";
var response = await httpClient.GetStringAsync(apiUrl);
- var versionData = JsonSerializer.Deserialize(response, new JsonSerializerOptions
+
+ using var doc = JsonDocument.Parse(response);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
- PropertyNameCaseInsensitive = true
- });
-
- if (versionData?.Versions?.Length > 0)
+ return;
+ }
+
+ if (!doc.RootElement.TryGetProperty("versions", out var versionsElement) ||
+ versionsElement.ValueKind != JsonValueKind.Array)
+ {
+ return;
+ }
+
+ var versions = new List();
+ foreach (var element in versionsElement.EnumerateArray())
+ {
+ if (element.ValueKind == JsonValueKind.String)
+ {
+ var value = element.GetString();
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ versions.Add(value);
+ }
+ }
+ }
+
+ if (versions.Count > 0)
{
- // Get the latest stable version (not pre-release)
- var latestVersion = versionData.Versions
- .Where(v => !IsPreReleaseVersion(v))
- .LastOrDefault() ?? versionData.Versions.Last();
-
- package.LatestVersion = latestVersion;
+ var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault();
+ package.LatestVersion = latestStable ?? versions.Last();
}
}
catch (HttpRequestException ex)
diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs
index 8ab6430..ce86811 100644
--- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs
+++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs
@@ -171,7 +171,7 @@ public ReportDocument GenerateReport(bool limitPackageLists = true)
{
var versionDetails = string.Join(", ", mismatch.ProjectVersions
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
- .Select(kv => $"{kv.Key}={kv.Value}"));
+ .Select(kv => $"{kv.Key}={string.Join("|", kv.Value.OrderBy(v => v, StringComparer.OrdinalIgnoreCase))}"));
mismatchList.AddItem($"{mismatch.PackageName}: {versionDetails}");
}
@@ -651,45 +651,58 @@ private static string InferDefaultCSharpVersion(string? targetFramework)
}
private List FindPackageVersionMismatches()
- {
- var packageVersions = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ => ComputePackageVersionMismatches(_projects);
- foreach (var project in _projects)
- {
- foreach (var package in project.PackageDependencies)
+ internal static List ComputePackageVersionMismatches(IEnumerable projects)
+ {
+ var all = projects
+ .SelectMany(project =>
{
- if (string.IsNullOrWhiteSpace(package.Name) || package.Name.Equals("unknown", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
+ var projectName = string.IsNullOrWhiteSpace(project.ProjectName) ? "unknown" : project.ProjectName;
- if (string.IsNullOrWhiteSpace(package.Version) || package.Version.Equals("unknown", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
+ var direct = project.PackageDependencies
+ .Select(p => (Name: p.Name, Version: p.Version, Project: projectName));
- if (!packageVersions.TryGetValue(package.Name, out var versionsByProject))
- {
- versionsByProject = new Dictionary(StringComparer.OrdinalIgnoreCase);
- packageVersions[package.Name] = versionsByProject;
- }
+ var transitive = project.TransitiveDependencies
+ .Select(t => (Name: t.PackageName, Version: t.Version, Project: projectName));
- versionsByProject[project.ProjectName] = package.Version;
- }
- }
-
- var mismatches = new List();
+ return direct.Concat(transitive);
+ })
+ .Where(x =>
+ !string.IsNullOrWhiteSpace(x.Name) &&
+ !x.Name.Equals("unknown", StringComparison.OrdinalIgnoreCase) &&
+ !string.IsNullOrWhiteSpace(x.Version) &&
+ !x.Version.Equals("unknown", StringComparison.OrdinalIgnoreCase));
- foreach (var kvp in packageVersions)
- {
- var distinctVersions = kvp.Value.Values.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
- if (distinctVersions.Count > 1)
+ return all
+ .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(group =>
{
- mismatches.Add(new PackageVersionMismatch(kvp.Key, kvp.Value));
- }
- }
-
- return mismatches;
+ var versionsByProject = group
+ .GroupBy(x => x.Project, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ g => g.Key,
+ g => (IReadOnlyCollection)g
+ .Select(x => x.Version)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList(),
+ StringComparer.OrdinalIgnoreCase);
+
+ var distinctVersions = versionsByProject.Values
+ .SelectMany(v => v)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return new
+ {
+ PackageName = group.Key,
+ VersionsByProject = versionsByProject,
+ DistinctVersionCount = distinctVersions.Count
+ };
+ })
+ .Where(x => x.DistinctVersionCount > 1)
+ .Select(x => new PackageVersionMismatch(x.PackageName, x.VersionsByProject))
+ .ToList();
}
///
@@ -732,7 +745,7 @@ private async Task CollectVulnerabilitiesAsync(CancellationToken cancellationTok
}
}
- private sealed record PackageVersionMismatch(string PackageName, Dictionary ProjectVersions);
+ internal sealed record PackageVersionMismatch(string PackageName, Dictionary> ProjectVersions);
///
/// Counts total lines of code in all C# files included in a project, excluding blank lines and comments.
diff --git a/src/CodeMedic/Utilities/PluginLoader.cs b/src/CodeMedic/Utilities/PluginLoader.cs
index 32a6b37..a4326df 100644
--- a/src/CodeMedic/Utilities/PluginLoader.cs
+++ b/src/CodeMedic/Utilities/PluginLoader.cs
@@ -1,5 +1,9 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using CodeMedic.Abstractions.Plugins;
+using CodeMedic.Plugins.BomAnalysis;
+using CodeMedic.Plugins.HealthAnalysis;
+using CodeMedic.Plugins.VulnerabilityAnalysis;
namespace CodeMedic.Utilities;
@@ -33,8 +37,19 @@ public class PluginLoader
/// Cancellation token for async operations.
public async Task LoadInternalPluginsAsync(CancellationToken cancellationToken = default)
{
- var assembly = Assembly.GetExecutingAssembly();
- await LoadPluginsFromAssemblyAsync(assembly, cancellationToken);
+ // NativeAOT + trimming are not compatible with reflection-based plugin discovery.
+ // Internal plugins are known at compile-time, so register them explicitly.
+ var plugins = new IPlugin[]
+ {
+ new BomAnalysisPlugin(),
+ new HealthAnalysisPlugin(),
+ new VulnerabilityAnalysisPlugin()
+ };
+
+ foreach (var plugin in plugins)
+ {
+ await LoadPluginInstanceAsync(plugin, cancellationToken);
+ }
}
///
@@ -42,6 +57,8 @@ public async Task LoadInternalPluginsAsync(CancellationToken cancellationToken =
///
/// The assembly to scan for plugins.
/// Cancellation token.
+ [UnconditionalSuppressMessage("Trimming", "IL2026:Assembly.GetTypes", Justification = "Optional reflection-based plugin discovery is not used for NativeAOT publishing.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2072:Activator.CreateInstance", Justification = "Optional reflection-based plugin discovery is not used for NativeAOT publishing.")]
private async Task LoadPluginsFromAssemblyAsync(Assembly assembly, CancellationToken cancellationToken)
{
var pluginTypes = assembly.GetTypes()
@@ -51,41 +68,40 @@ private async Task LoadPluginsFromAssemblyAsync(Assembly assembly, CancellationT
{
try
{
- // Create instance of the plugin
var plugin = Activator.CreateInstance(pluginType) as IPlugin;
- if (plugin == null)
+ if (plugin != null)
{
- continue;
+ await LoadPluginInstanceAsync(plugin, cancellationToken);
}
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to load plugin {pluginType.Name}: {ex.Message}");
+ }
+ }
+ }
- // Initialize the plugin
- await plugin.InitializeAsync(cancellationToken);
+ private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken cancellationToken)
+ {
+ await plugin.InitializeAsync(cancellationToken);
- // Register the plugin based on its type
- if (plugin is IAnalysisEnginePlugin analysisEngine)
- {
- _analysisEngines.Add(analysisEngine);
-
- // Register commands if the plugin provides them
- var commands = analysisEngine.RegisterCommands();
- if (commands != null)
- {
- foreach (var command in commands)
- {
- _commandRegistrations[command.Name] = command;
- }
- }
- }
+ if (plugin is IAnalysisEnginePlugin analysisEngine)
+ {
+ _analysisEngines.Add(analysisEngine);
- if (plugin is IReporterPlugin reporter)
+ var commands = analysisEngine.RegisterCommands();
+ if (commands != null)
+ {
+ foreach (var command in commands)
{
- _reporters.Add(reporter);
+ _commandRegistrations[command.Name] = command;
}
}
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Failed to load plugin {pluginType.Name}: {ex.Message}");
- }
+ }
+
+ if (plugin is IReporterPlugin reporter)
+ {
+ _reporters.Add(reporter);
}
}
diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs
index 2d69670..e7c807c 100644
--- a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs
+++ b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs
@@ -173,7 +173,8 @@ public void ReadPackageReferences_GivenProjectWithDirectVersions_WhenReading_The
}
[Fact]
- public void ReadPackageReferences_GivenPackageWithVersionElement_WhenReading_ThenReadsVersionFromElement()
+ // 🐒 Chaos Monkey: This test now has a spectacularly ridiculous name! Thanks McNets!
+ public void ReadPackageReferences_GivenPackageWithVersionElement_WhenReading_ThenReadsVersionFromElement_LikeAPackageWhispererWithSupernaturalVersionDetectionSkills()
{
// Given
var rootPath = TestRootPath;
diff --git a/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs
index f6a6753..06d60e6 100644
--- a/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs
+++ b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs
@@ -139,4 +139,42 @@ public void CommandArgument_GivenVariousParameters_WhenCreated_ThenMatchesExpect
Assert.Equal(isRequired, argument.IsRequired);
Assert.Equal(valueName, argument.ValueName);
}
+
+ [Fact]
+ public void CommandArgument_WhenChaosMonkeyStrikesWithBananas_ThenShouldStillWorkPerfectly()
+ {
+ // 🐒 Chaos Monkey was here! This goofy test brought to you by ergonrod's donation!
+ // Given: A completely ridiculous but technically valid command argument
+ var bananaArgument = new CommandArgument(
+ Description: "The number of bananas required for optimal monkey performance",
+ ShortName: "b",
+ LongName: "bananas",
+ IsRequired: false,
+ HasValue: true,
+ DefaultValue: "42", // Because it's the answer to everything
+ ValueName: "banana-count");
+
+ // When: We test this completely silly but functional argument
+ // Then: It should work exactly like any other argument (because code doesn't care about our jokes!)
+ Assert.Equal("The number of bananas required for optimal monkey performance", bananaArgument.Description);
+ Assert.Equal("b", bananaArgument.ShortName);
+ Assert.Equal("bananas", bananaArgument.LongName);
+ Assert.False(bananaArgument.IsRequired); // Bananas are optional but recommended
+ Assert.True(bananaArgument.HasValue);
+ Assert.Equal("42", bananaArgument.DefaultValue);
+ Assert.Equal("banana-count", bananaArgument.ValueName);
+
+ // 🐒 Bonus assertion: Verify that even silly arguments maintain their equality contracts
+ var anotherBananaArgument = new CommandArgument(
+ Description: "The number of bananas required for optimal monkey performance",
+ ShortName: "b",
+ LongName: "bananas",
+ IsRequired: false,
+ HasValue: true,
+ DefaultValue: "42",
+ ValueName: "banana-count");
+
+ Assert.Equal(bananaArgument, anotherBananaArgument);
+ // Because two identical banana arguments should always be equal, obviously! 🍌
+ }
}
\ No newline at end of file
diff --git a/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs b/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs
index a6e50c6..18eb869 100644
--- a/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs
+++ b/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs
@@ -50,7 +50,8 @@ public void DetectFeatures_WithAspNetCoreMvc_DetectsMvc()
}
[Fact]
- public void DetectFeatures_WithSignalR_DetectsSignalR()
+ // 🐒 Chaos Monkey: Renamed this test to be ridiculously punny! Donation by: McNets
+ public void DetectFeatures_WithSignalR_DetectsSignalR_LikeABossWhoKnowsHowToSignalRightAndLeft()
{
// Arrange
var detector = new WebFrameworkDetector();
diff --git a/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs b/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs
new file mode 100644
index 0000000..cf4410e
--- /dev/null
+++ b/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs
@@ -0,0 +1,116 @@
+using CodeMedic.Models;
+using CodeMedic.Plugins.HealthAnalysis;
+
+namespace Test.CodeMedic.Plugins.HealthAnalysis;
+
+public class RepositoryScannerVersionMismatchTests
+{
+ [Fact]
+ public void ComputePackageVersionMismatches_WhenOnlyTransitiveMismatchesExist_ThenReportsMismatch()
+ {
+ var projects = new List
+ {
+ new()
+ {
+ ProjectPath = "C:/repo/A/A.csproj",
+ ProjectName = "A",
+ RelativePath = "A/A.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Newtonsoft.Json", Version = "13.0.1" }
+ ]
+ },
+ new()
+ {
+ ProjectPath = "C:/repo/B/B.csproj",
+ ProjectName = "B",
+ RelativePath = "B/B.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Newtonsoft.Json", Version = "13.0.3" }
+ ]
+ }
+ };
+
+ var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects);
+
+ Assert.Single(mismatches);
+ Assert.Equal("Newtonsoft.Json", mismatches[0].PackageName, ignoreCase: true);
+
+ var perProject = mismatches[0].ProjectVersions;
+ Assert.Equal(2, perProject.Count);
+ Assert.Contains("13.0.1", perProject["A"]);
+ Assert.Contains("13.0.3", perProject["B"]);
+ }
+
+ [Fact]
+ public void ComputePackageVersionMismatches_WhenAllProjectsUseSameTransitiveVersion_ThenNoMismatch()
+ {
+ var projects = new List
+ {
+ new()
+ {
+ ProjectPath = "C:/repo/A/A.csproj",
+ ProjectName = "A",
+ RelativePath = "A/A.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Serilog", Version = "4.0.0" }
+ ]
+ },
+ new()
+ {
+ ProjectPath = "C:/repo/B/B.csproj",
+ ProjectName = "B",
+ RelativePath = "B/B.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Serilog", Version = "4.0.0" }
+ ]
+ }
+ };
+
+ var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects);
+
+ Assert.Empty(mismatches);
+ }
+
+ [Fact]
+ public void ComputePackageVersionMismatches_WhenAProjectHasMultipleResolvedVersions_ThenShowsAllVersionsForThatProject()
+ {
+ var projects = new List
+ {
+ new()
+ {
+ ProjectPath = "C:/repo/A/A.csproj",
+ ProjectName = "A",
+ RelativePath = "A/A.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Example.Package", Version = "1.0.0" },
+ new TransitiveDependency { PackageName = "Example.Package", Version = "2.0.0" }
+ ]
+ },
+ new()
+ {
+ ProjectPath = "C:/repo/B/B.csproj",
+ ProjectName = "B",
+ RelativePath = "B/B.csproj",
+ TransitiveDependencies =
+ [
+ new TransitiveDependency { PackageName = "Example.Package", Version = "1.0.0" }
+ ]
+ }
+ };
+
+ var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects);
+
+ Assert.Single(mismatches);
+ Assert.Equal("Example.Package", mismatches[0].PackageName, ignoreCase: true);
+
+ Assert.Equal(2, mismatches[0].ProjectVersions["A"].Count);
+ Assert.Contains("1.0.0", mismatches[0].ProjectVersions["A"]);
+ Assert.Contains("2.0.0", mismatches[0].ProjectVersions["A"]);
+ Assert.Single(mismatches[0].ProjectVersions["B"]);
+ }
+}