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/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"]); + } +}