From eac8bc2ce808af995362eef54e3f9b8e57dc6eaf Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 16 Dec 2025 10:30:03 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Introduce?= =?UTF-8?q?=20random=20sleep=20in=20unit=20test=20(On=20behalf=20of=20dono?= =?UTF-8?q?r:=20irawsum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs index 6f11f3c..6662bee 100644 --- a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs +++ b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs @@ -65,6 +65,10 @@ public async Task InitializeAsync_GivenValidPlugin_WhenInitializing_ThenComplete // Given var plugin = new VulnerabilityAnalysisPlugin(); + // πŸ’ Chaos Monkey was here! Adding a dramatic pause for maximum suspense... + // Donated by: irawsum - Thanks for supporting St. Jude! ❀️ + await Task.Delay(TimeSpan.FromMilliseconds(150)); + // When var task = plugin.InitializeAsync(CancellationToken.None); From c1080909bdbcb0209ac9fa26e3af5dbfe5291bde Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Mon, 15 Dec 2025 11:42:42 -0500 Subject: [PATCH 2/3] Add tests for package version mismatch detection(#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved reporting of packages Added AoT building and delivery at release time Add tests for package version mismatch detection and improve reportin… (#29) Add config file support (#36) --- .github/workflows/docker-publish.yml | 25 +- .github/workflows/release-binaries.yml | 126 ++++ .vscode/settings.json | 25 + README.md | 19 +- run-config.cmd | 17 + run-config.ps1 | 16 + run-config.sh | 13 + sample-config-multi-repo.json | 23 + sample-config-multi-repo.yaml | 20 + sample-config.json | 13 + sample-config.yaml | 11 + src/CodeMedic/CodeMedic.csproj | 17 + .../Commands/ConfigurationCommandHandler.cs | 145 ++++ src/CodeMedic/Commands/RootCommandHandler.cs | 683 ++++++++++-------- .../InternalsVisibleTo.Test.CodeMedic.cs | 3 + .../Options/CodeMedicRunConfiguration.cs | 78 ++ src/CodeMedic/Output/ConsoleRenderer.cs | 27 +- .../Plugins/BomAnalysis/BomAnalysisPlugin.cs | 124 ++-- .../HealthAnalysis/HealthAnalysisPlugin.cs | 5 +- .../HealthAnalysis/RepositoryScanner.cs | 81 ++- src/CodeMedic/Utilities/PluginLoader.cs | 72 +- .../ConfigurationCommandHandlerTests.cs | 444 ++++++++++++ .../BomAnalysis/NuGetInspectorTests.cs | 9 +- .../Plugins/CommandArgumentTests.cs | 38 + .../Detectors/WebFrameworkDetectorTests.cs | 3 +- .../RepositoryScannerVersionMismatchTests.cs | 116 +++ .../CommandLineArgumentExtensionsTests.cs | 24 + .../Utilities/PluginLoaderTests.cs | 29 +- user-docs/configuration-files.md | 177 +++++ 29 files changed, 1924 insertions(+), 459 deletions(-) create mode 100644 .github/workflows/release-binaries.yml create mode 100644 .vscode/settings.json create mode 100644 run-config.cmd create mode 100644 run-config.ps1 create mode 100644 run-config.sh create mode 100644 sample-config-multi-repo.json create mode 100644 sample-config-multi-repo.yaml create mode 100644 sample-config.json create mode 100644 sample-config.yaml create mode 100644 src/CodeMedic/Commands/ConfigurationCommandHandler.cs create mode 100644 src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs create mode 100644 src/CodeMedic/Options/CodeMedicRunConfiguration.cs create mode 100644 test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs create mode 100644 test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs create mode 100644 user-docs/configuration-files.md 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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..81c6c80 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false, + "[csharp]": { + "editor.insertSpaces": false, + "editor.tabSize": 4 + }, + "[json]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[xml]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[markdown]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + } +} diff --git a/README.md b/README.md index afd598d..308ff8e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,15 @@ dotnet run -- --help ## πŸ“– Documentation -- **User Guide:** `user-docs/cli_quick_reference.md` -- **Docker Guide:** `user-docs/docker_usage.md` -- **Technical Guide:** `doc/cli_skeleton_implementation.md` -- **Architecture & Extension:** `doc/cli_architecture.md` +### User Documentation +- **CLI Quick Reference:** `user-docs/cli_quick_reference.md` +- **Configuration Files:** `user-docs/configuration-files.md` - Batch analysis with JSON/YAML +- **Docker Usage:** `user-docs/docker_usage.md` +- **Vulnerability Scanning:** `user-docs/vulnerability-scanning.md` + +### Technical Documentation +- **CLI Architecture:** `doc/cli_architecture.md` +- **Implementation Guide:** `doc/cli_skeleton_implementation.md` - **Docker Implementation:** `doc/docker_implementation.md` - **Test Results:** `doc/cli_skeleton_test_results.md` @@ -52,6 +57,7 @@ dotnet run -- --help - βœ… Multiple output formats (console, markdown) - βœ… Path argument support (`-p` / `--path`) for all analysis commands - βœ… Command-specific help with argument documentation +- βœ… **Configuration file support (JSON & YAML) for batch analysis** ## 🎯 Current Commands @@ -61,6 +67,11 @@ codemedic # Show help (default) codemedic --help # Explicit help codemedic --version # Show version +# Configuration-based batch analysis +codemedic config # Run multiple analyses from config file +codemedic config config.json +codemedic config config.yaml + # Analysis commands codemedic health # Repository health dashboard codemedic health -p /path/to/repo --format markdown diff --git a/run-config.cmd b/run-config.cmd new file mode 100644 index 0000000..6c67fbc --- /dev/null +++ b/run-config.cmd @@ -0,0 +1,17 @@ +@echo off +REM Run CodeMedic with a configuration file + +if "%1"=="" ( + set CONFIG_FILE=sample-config.yaml +) else ( + set CONFIG_FILE=%1 +) + +if not exist "%CONFIG_FILE%" ( + echo Configuration file not found: %CONFIG_FILE% + exit /b 1 +) + +echo Running CodeMedic with configuration: %CONFIG_FILE% + +dotnet run --project src\CodeMedic config %CONFIG_FILE% diff --git a/run-config.ps1 b/run-config.ps1 new file mode 100644 index 0000000..f532151 --- /dev/null +++ b/run-config.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +# Run CodeMedic with a configuration file + +param( + [Parameter(Mandatory=$false)] + [string]$ConfigFile = "sample-config.yaml" +) + +if (-not (Test-Path $ConfigFile)) { + Write-Host "Configuration file not found: $ConfigFile" -ForegroundColor Red + exit 1 +} + +Write-Host "Running CodeMedic with configuration: $ConfigFile" -ForegroundColor Cyan + +dotnet run --project src\CodeMedic config $ConfigFile diff --git a/run-config.sh b/run-config.sh new file mode 100644 index 0000000..357ce90 --- /dev/null +++ b/run-config.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run CodeMedic with a configuration file + +CONFIG_FILE="${1:-sample-config.yaml}" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Configuration file not found: $CONFIG_FILE" + exit 1 +fi + +echo "Running CodeMedic with configuration: $CONFIG_FILE" + +dotnet run --project src/CodeMedic config "$CONFIG_FILE" diff --git a/sample-config-multi-repo.json b/sample-config-multi-repo.json new file mode 100644 index 0000000..755762a --- /dev/null +++ b/sample-config-multi-repo.json @@ -0,0 +1,23 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports/multi" + }, + "repositories": [ + { + "name": "MainProject", + "path": "./src", + "commands": ["health", "bom"] + }, + { + "name": "TestSuite", + "path": "./test", + "commands": ["health"] + }, + { + "name": "Documentation", + "path": "./docs", + "commands": ["bom"] + } + ] +} diff --git a/sample-config-multi-repo.yaml b/sample-config-multi-repo.yaml new file mode 100644 index 0000000..dfd62da --- /dev/null +++ b/sample-config-multi-repo.yaml @@ -0,0 +1,20 @@ +global: + format: markdown + output-dir: ./reports/multi + +repositories: + - name: MainProject + path: ./src + commands: + - health + - bom + + - name: TestSuite + path: ./test + commands: + - health + + - name: Documentation + path: ./docs + commands: + - bom diff --git a/sample-config.json b/sample-config.json new file mode 100644 index 0000000..939bb25 --- /dev/null +++ b/sample-config.json @@ -0,0 +1,13 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "CodeMedic", + "path": ".", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} diff --git a/sample-config.yaml b/sample-config.yaml new file mode 100644 index 0000000..ca36079 --- /dev/null +++ b/sample-config.yaml @@ -0,0 +1,11 @@ +global: + format: markdown + output-dir: ./reports + +repositories: + - name: CodeMedic + path: . + commands: + - health + - bom + - vulnerabilities diff --git a/src/CodeMedic/CodeMedic.csproj b/src/CodeMedic/CodeMedic.csproj index f4741c8..2fc35c9 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 @@ -18,6 +34,7 @@ + diff --git a/src/CodeMedic/Commands/ConfigurationCommandHandler.cs b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs new file mode 100644 index 0000000..22a3c6e --- /dev/null +++ b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs @@ -0,0 +1,145 @@ +using CodeMedic.Utilities; +using CodeMedic.Commands; +using CodeMedic.Output; + +namespace CodeMedic.Commands; + +/// +/// Handles configuration-related commands. +/// +public class ConfigurationCommandHandler +{ + private PluginLoader _PluginLoader; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin loader instance used to manage plugins. + public ConfigurationCommandHandler(PluginLoader pluginLoader) + { + _PluginLoader = pluginLoader; + } + + /// + /// Handles the configuration file command. + /// + /// The path to the configuration file. + /// A task that represents the asynchronous operation. The task result contains the exit code. + internal async Task HandleConfigurationFileAsync(string configFilePath) + { + + // Check if the configuration file exists + if (!File.Exists(configFilePath)) + { + RootCommandHandler.Console.RenderError($"Configuration file not found: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + + // Load the specified configuration file - we need to identify the file type and load accordingly + CodeMedicRunConfiguration config; + try { + config = LoadConfigurationFromFile(configFilePath); + if (config == null) + { + RootCommandHandler.Console.RenderError($"Failed to load configuration from file: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + } catch (Exception ex) + { + RootCommandHandler.Console.RenderError($"Error loading configuration file: {ex.Message}"); + return 1; // Return a non-zero exit code to indicate failure + } + + await RunCommandsForConfigurationAsync(config); + + return 0; // Return zero to indicate success + + } + + private async Task RunCommandsForConfigurationAsync(CodeMedicRunConfiguration config) + { + + // For each repository in the configuration, run the specified commands + foreach (var repoConfig in config.Repositories) + { + RootCommandHandler.Console.RenderInfo($"Processing repository: {repoConfig.Name} at {repoConfig.Path}"); + + foreach (var commandName in repoConfig.Commands) + { + + // Check that the command is registered with the plugin loader + if (!_PluginLoader.Commands.ContainsKey(commandName)) + { + RootCommandHandler.Console.RenderError($" Command not registered: {commandName}"); + continue; + } + + RootCommandHandler.Console.RenderInfo($" Running command: {commandName}"); + + // Load the command plugin + var commandPlugin = _PluginLoader.GetCommand(commandName); + if (commandPlugin == null) + { + RootCommandHandler.Console.RenderError($" Command plugin not found: {commandName}"); + continue; + } + + // Get the Formatter plugin for output formatting - we only support markdown for now + // Ensure output directory exists + Directory.CreateDirectory(config.Global.OutputDirectory); + + var reportPath = Path.Combine(config.Global.OutputDirectory, $"{repoConfig.Name}_{commandName}.md"); + + // Execute the command with proper disposal of the StreamWriter + using (var writer = new StreamWriter(reportPath)) + { + var formatter = new MarkdownRenderer(writer); + + // Build arguments array to pass the repository path to the command + var commandArgs = new[] { "--path", repoConfig.Path }; + + await commandPlugin.Handler(commandArgs, formatter); + } + + } + // For now, just simulate with a delay + await Task.Delay(500); + + RootCommandHandler.Console.RenderInfo($"Completed processing repository: {repoConfig.Name}"); + } + + } + + private CodeMedicRunConfiguration LoadConfigurationFromFile(string configFilePath) + { + // detect if the file is json or yaml based on extension + var extension = Path.GetExtension(configFilePath).ToLower(); + var fileContents = File.ReadAllText(configFilePath); + if (extension == ".json") + { + var config = System.Text.Json.JsonSerializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize JSON configuration file."); + } + return config; + } + else if (extension == ".yaml" || extension == ".yml") + { + // Configure YAML deserializer - we use explicit YamlMember aliases on properties + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder() + .Build(); + var config = deserializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize YAML configuration file."); + } + return config; + } + else + { + throw new InvalidOperationException("Unsupported configuration file format. Only JSON and YAML are supported."); + } + } + +} diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index d8acecb..6b11153 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -10,322 +10,369 @@ namespace CodeMedic.Commands; /// public class RootCommandHandler { - private static PluginLoader? _pluginLoader; - - /// - /// Gets a console renderer for providing feedback to the user. - /// - public static readonly ConsoleRenderer Console = new ConsoleRenderer(); - - /// - /// Processes command-line arguments and executes appropriate handler. - /// - public static async Task ProcessArguments(string[] args) - { - var version = VersionUtility.GetVersion(); - var console = new ConsoleRenderer(); - - // Load plugins first - _pluginLoader = new PluginLoader(); - await _pluginLoader.LoadInternalPluginsAsync(); - - // No arguments or general help requested - if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") - { - console.RenderBanner(version); - RenderHelp(); - return 0; - } - - // Version requested - if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) - { - ConsoleRenderer.RenderVersion(version); - RenderPluginInfo(); - return 0; - } - - // Check if a plugin registered this command - var commandName = args[0]; - var commandRegistration = _pluginLoader.GetCommand(commandName); - - if (commandRegistration != null) - { - // Check for command-specific help - var commandArgs = args.Skip(1).ToArray(); - if (commandArgs.Contains("--help") || commandArgs.Contains("-h")) - { - console.RenderBanner(version); - RenderCommandHelp(commandRegistration); - return 0; - } - - // Parse --format argument (default: console) - string format = "console"; - var commandArgsList = args.Skip(1).ToList(); - for (int i = 0; i < commandArgsList.Count; i++) - { - if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) - { - format = commandArgsList[i + 1].ToLower(); - commandArgsList.RemoveAt(i + 1); - commandArgsList.RemoveAt(i); - break; - } - } - IRenderer renderer = format switch - { - "markdown" or "md" => new MarkdownRenderer(), - _ => new ConsoleRenderer() - }; - return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); - } - - // Unknown command - console.RenderError($"Unknown command: {args[0]}"); - RenderHelp(); - return 1; - } - - /// - /// Renders the help text with available commands (including plugin commands). - /// - private static void RenderHelp() - { - var table = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold]Available Commands[/]") - }; - - table.AddColumn("Command"); - table.AddColumn("Description"); - - // Add plugin-registered commands - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - table.AddRow($"[cyan]{command.Name}[/]", command.Description); - } - } - - // Add built-in commands - table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); - table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Usage:[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Global Options:[/]"); - AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); - AnsiConsole.WriteLine(); - - // Show command-specific arguments - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - if (command.Arguments != null && command.Arguments.Length > 0) - { - AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]"); - - foreach (var arg in command.Arguments) - { - var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; - var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; - var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); - - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; - var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; - - AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}"); - } - AnsiConsole.WriteLine(); - } - } - } - - AnsiConsole.MarkupLine("[dim]Examples:[/]"); - - // Display examples from plugins - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - if (command.Examples != null) - { - foreach (var example in command.Examples) - { - AnsiConsole.MarkupLine($" [green]{example}[/]"); - } - } - } - } - - AnsiConsole.MarkupLine(" [green]codemedic --version[/]"); - } - - /// - /// Renders help text for a specific command. - /// - private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command) - { - AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"{command.Description}"); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Usage:[/]"); - var usage = $"codemedic {command.Name}"; - - if (command.Arguments != null && command.Arguments.Length > 0) - { - foreach (var arg in command.Arguments) - { - var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}"; - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var optionalWrapper = arg.IsRequired ? "" : "[ ]"; - - if (arg.IsRequired) - { - usage += $" {argName}{valuePart}"; - } - else - { - usage += $" [{argName}{valuePart}]"; - } - } - } - - usage += " [--format ]"; - AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]"); - AnsiConsole.WriteLine(); - - if (command.Arguments != null && command.Arguments.Length > 0) - { - AnsiConsole.MarkupLine("[dim]Command Options:[/]"); - - foreach (var arg in command.Arguments) - { - var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; - var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; - var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); - - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; - var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; - - AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}"); - } - AnsiConsole.WriteLine(); - } - - AnsiConsole.MarkupLine("[dim]Global Options:[/]"); - AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); - AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message"); - AnsiConsole.WriteLine(); - - if (command.Examples != null && command.Examples.Length > 0) - { - AnsiConsole.MarkupLine("[dim]Examples:[/]"); - foreach (var example in command.Examples) - { - AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]"); - } - AnsiConsole.WriteLine(); - } - } - - /// - /// Renders information about loaded plugins. - /// - private static void RenderPluginInfo() - { - if (_pluginLoader == null) - { - return; - } - - var analysisEngines = _pluginLoader.AnalysisEngines; - var reporters = _pluginLoader.Reporters; - var totalPlugins = analysisEngines.Count + reporters.Count; - - if (totalPlugins == 0) - { - return; - } - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); - AnsiConsole.WriteLine(); - - // Analysis Engine Plugins - if (analysisEngines.Count > 0) - { - var pluginTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold cyan]Analysis Engines[/]") - }; - - pluginTable.AddColumn("Name"); - pluginTable.AddColumn("Version"); - pluginTable.AddColumn("Description"); - pluginTable.AddColumn("Commands"); - - foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) - { - // Get commands registered by this specific plugin - var pluginCommands = plugin.RegisterCommands(); - var commandList = pluginCommands != null && pluginCommands.Length > 0 - ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) - : "[dim]none[/]"; - - pluginTable.AddRow( - plugin.Metadata.Name, - plugin.Metadata.Version, - plugin.Metadata.Description, - commandList - ); - } - - AnsiConsole.Write(pluginTable); - } - - // Reporter Plugins (if any) - if (reporters.Count > 0) - { - AnsiConsole.WriteLine(); - var reporterTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold yellow]Reporters[/]") - }; - - reporterTable.AddColumn("Name"); - reporterTable.AddColumn("Version"); - reporterTable.AddColumn("Format"); - reporterTable.AddColumn("Description"); - - foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) - { - reporterTable.AddRow( - reporter.Metadata.Name, - reporter.Metadata.Version, - $"[cyan]{reporter.OutputFormat}[/]", - reporter.Metadata.Description - ); - } - - AnsiConsole.Write(reporterTable); - } - } + private static PluginLoader _pluginLoader = null!; + + /// + /// Gets a console renderer for providing feedback to the user. + /// + public static readonly ConsoleRenderer Console = new ConsoleRenderer(); + + /// + /// Processes command-line arguments and executes appropriate handler. + /// + public static async Task ProcessArguments(string[] args) + { + var version = VersionUtility.GetVersion(); + + // Load plugins first + _pluginLoader = new PluginLoader(); + await _pluginLoader.LoadInternalPluginsAsync(); + + // No arguments or general help requested + if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") + { + Console.RenderBanner(version); + RenderHelp(); + return 0; + } + + var (flowControl, value) = await HandleConfigCommand(args, version); + if (!flowControl) + { + return value; + } + + // Version requested + if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) + { + ConsoleRenderer.RenderVersion(version); + RenderPluginInfo(); + return 0; + } + + // Check if a plugin registered this command + var commandName = args[0]; + var commandRegistration = _pluginLoader.GetCommand(commandName); + + if (commandRegistration != null) + { + // Check for command-specific help + var commandArgs = args.Skip(1).ToArray(); + if (commandArgs.Contains("--help") || commandArgs.Contains("-h")) + { + Console.RenderBanner(version); + RenderCommandHelp(commandRegistration); + return 0; + } + + // Parse --format argument (default: console) + string format = "console"; + string outputDir = string.Empty; + var commandArgsList = args.Skip(1).ToList(); + for (int i = 0; i < commandArgsList.Count; i++) + { + if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) + { + format = commandArgsList[i + 1].ToLower(); + commandArgsList.RemoveAt(i + 1); + commandArgsList.RemoveAt(i); + break; + } + + // if ((commandArgsList[i] == "--output-dir" || commandArgsList[i] == "-o") && i + 1 < commandArgsList.Count) + // { + // outputDir = commandArgsList[i + 1]; + // commandArgsList.RemoveAt(i + 1); + // commandArgsList.RemoveAt(i); + // break; + // } + + } + + + IRenderer renderer = format switch + { + "markdown" or "md" => new MarkdownRenderer(), + _ => new ConsoleRenderer() + }; + return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); + } + + // Unknown command + Console.RenderError($"Unknown command: {args[0]}"); + RenderHelp(); + return 1; + } + + private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version) + { + + if (args.Length < 2 || (args[0] != "config")) + { + return (flowControl: true, value: default); + } + + Console.RenderBanner(version); + + // Load a configuration file specified in the following argument and begin processing the instructions in that file. + if (args.Length < 2) + { + Console.RenderError("No configuration file specified. Please provide a path to a configuration file."); + return (flowControl: false, value: 1); + } + + var configFilePath = args[1]; + if (!File.Exists(configFilePath)) + { + Console.RenderError($"Configuration file not found: {configFilePath}"); + return (flowControl: false, value: 1); + } + + var configHandler = new ConfigurationCommandHandler(_pluginLoader); + return (flowControl: false, value: await configHandler.HandleConfigurationFileAsync(configFilePath)); + + } + + /// + /// Renders the help text with available commands (including plugin commands). + /// + private static void RenderHelp() + { + var table = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold]Available Commands[/]") + }; + + table.AddColumn("Command"); + table.AddColumn("Description"); + + // Add plugin-registered commands + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + table.AddRow($"[cyan]{command.Name}[/]", command.Description); + } + } + + // Add built-in commands + table.AddRow("[cyan]--config[/] or [cyan]-c[/]", "Provide a configuration file that CodeMedic will use"); + table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); + table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); + AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); + AnsiConsole.WriteLine(); + + // Show command-specific arguments + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}"); + } + AnsiConsole.WriteLine(); + } + } + } + + AnsiConsole.MarkupLine("[dim]Examples:[/]"); + + // Display examples from plugins + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Examples != null) + { + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example}[/]"); + } + } + } + } + + AnsiConsole.MarkupLine(" [green]codemedic --version[/]"); + } + + /// + /// Renders help text for a specific command. + /// + private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command) + { + AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"{command.Description}"); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + var usage = $"codemedic {command.Name}"; + + if (command.Arguments != null && command.Arguments.Length > 0) + { + foreach (var arg in command.Arguments) + { + var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}"; + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var optionalWrapper = arg.IsRequired ? "" : "[ ]"; + + if (arg.IsRequired) + { + usage += $" {argName}{valuePart}"; + } + else + { + usage += $" [{argName}{valuePart}]"; + } + } + } + + usage += " [--format ]"; + AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]"); + AnsiConsole.WriteLine(); + + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}"); + } + AnsiConsole.WriteLine(); + } + + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); + AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); + AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message"); + AnsiConsole.WriteLine(); + + if (command.Examples != null && command.Examples.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Examples:[/]"); + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]"); + } + AnsiConsole.WriteLine(); + } + } + + /// + /// Renders information about loaded plugins. + /// + private static void RenderPluginInfo() + { + if (_pluginLoader == null) + { + return; + } + + var analysisEngines = _pluginLoader.AnalysisEngines; + var reporters = _pluginLoader.Reporters; + var totalPlugins = analysisEngines.Count + reporters.Count; + + if (totalPlugins == 0) + { + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); + AnsiConsole.WriteLine(); + + // Analysis Engine Plugins + if (analysisEngines.Count > 0) + { + var pluginTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold cyan]Analysis Engines[/]") + }; + + pluginTable.AddColumn("Name"); + pluginTable.AddColumn("Version"); + pluginTable.AddColumn("Description"); + pluginTable.AddColumn("Commands"); + + foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) + { + // Get commands registered by this specific plugin + var pluginCommands = plugin.RegisterCommands(); + var commandList = pluginCommands != null && pluginCommands.Length > 0 + ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) + : "[dim]none[/]"; + + pluginTable.AddRow( + plugin.Metadata.Name, + plugin.Metadata.Version, + plugin.Metadata.Description, + commandList + ); + } + + AnsiConsole.Write(pluginTable); + } + + // Reporter Plugins (if any) + if (reporters.Count > 0) + { + AnsiConsole.WriteLine(); + var reporterTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold yellow]Reporters[/]") + }; + + reporterTable.AddColumn("Name"); + reporterTable.AddColumn("Version"); + reporterTable.AddColumn("Format"); + reporterTable.AddColumn("Description"); + + foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) + { + reporterTable.AddRow( + reporter.Metadata.Name, + reporter.Metadata.Version, + $"[cyan]{reporter.OutputFormat}[/]", + reporter.Metadata.Description + ); + } + + AnsiConsole.Write(reporterTable); + } + } } 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/Options/CodeMedicRunConfiguration.cs b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs new file mode 100644 index 0000000..5c50464 --- /dev/null +++ b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace CodeMedic.Commands; + +/// +/// Represents the configuration settings for running an instance of CodeMedic. +/// +public class CodeMedicRunConfiguration +{ + /// + /// Global properties for the CodeMedic configuration. + /// + [JsonPropertyName("global")] + [YamlMember(Alias = "global")] + public GlobalProperties Global { get; set; } = new GlobalProperties(); + + /// + /// The repositories to analyze. + /// + [JsonPropertyName("repositories")] + [YamlMember(Alias = "repositories")] + public RepositoryConfiguration[] Repositories { get; set; } = Array.Empty(); + + // TODO: Add command configuration section called "commands" to define settings for each command + + /// + /// Global properties for the CodeMedic configuration. + /// + public class GlobalProperties + { + + /// + /// The output format for the results. Supports "markdown" + /// + [JsonPropertyName("format")] + [YamlMember(Alias = "format")] + public string Format { get; set; } = "markdown"; + + /// + /// The output directory for the results. + /// + [JsonPropertyName("output-dir")] + [YamlMember(Alias = "output-dir")] + public string OutputDirectory { get; set; } = "."; + + + } + + /// + /// The definition of a repository to analyze. + /// + public class RepositoryConfiguration + { + /// + /// The relative path to the repository to analyze. + /// + [JsonPropertyName("path")] + [YamlMember(Alias = "path")] + public required string Path { get; set; } = string.Empty; + + /// + /// The name of the repository to analyze. + /// + [JsonPropertyName("name")] + [YamlMember(Alias = "name")] + public required string Name { get; set; } = string.Empty; + + /// + /// The commands to run against the repository. + /// + [JsonPropertyName("commands")] + [YamlMember(Alias = "commands")] + public string[] Commands { get; set; } = Array.Empty(); + + } + +} 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..e3bfd1a 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -9,6 +9,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.BomAnalysis; @@ -44,7 +45,7 @@ public Task InitializeAsync(CancellationToken cancellationToken = default) public async Task AnalyzeAsync(string repositoryPath, CancellationToken cancellationToken = default) { _inspector = new NuGetInspector(repositoryPath); - + // Restore packages to ensure we have all dependency information await _inspector.RestorePackagesAsync(); _inspector.RefreshCentralPackageVersionFiles(); @@ -123,7 +124,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); return 1; } } @@ -303,7 +304,7 @@ private async Task> AddNuGetPackagesSectionAsync { latestVersionDisplay = $"^ {package.LatestVersion}"; } - else if (!string.IsNullOrEmpty(package.LatestVersion) && + else if (!string.IsNullOrEmpty(package.LatestVersion) && string.Equals(package.Version, package.LatestVersion, StringComparison.OrdinalIgnoreCase)) { latestVersionDisplay = "Current"; @@ -311,15 +312,15 @@ private async Task> AddNuGetPackagesSectionAsync // Truncate package names if too long to improve table formatting var displayName = package.Name.Length > 25 ? package.Name.Substring(0, 22) + "..." : package.Name; - + // Shorten source type and commercial status for better formatting - var sourceType = package.SourceType == "Open Source" ? "Open" : - package.SourceType == "Closed Source" ? "Closed" : + var sourceType = package.SourceType == "Open Source" ? "Open" : + package.SourceType == "Closed Source" ? "Closed" : package.SourceType; - - var commercial = package.Commercial == "Unknown" ? "?" : + + 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"; @@ -354,19 +355,19 @@ private async Task> AddNuGetPackagesSectionAsync Title = "License Change Warnings", Level = 2 }; - + warningSection.AddElement(new ReportParagraph( "The following packages have different licenses in their latest versions:", TextStyle.Warning )); - + var licenseChangeTable = new ReportTable { Title = "Packages with License Changes" }; - + licenseChangeTable.Headers.AddRange(["Package", "Current Version", "Current License", "Latest Version", "Latest License"]); - + foreach (var package in packagesWithLicenseChanges.OrderBy(p => p.Name)) { licenseChangeTable.AddRow( @@ -377,7 +378,7 @@ private async Task> AddNuGetPackagesSectionAsync package.LatestLicense ?? "Unknown" ); } - + warningSection.AddElement(licenseChangeTable); packagesSection.AddElement(warningSection); } @@ -386,14 +387,14 @@ private async Task> AddNuGetPackagesSectionAsync packagesSection.AddElement(new ReportParagraph( "For more information about open source licenses, visit https://choosealicense.com/licenses/", TextStyle.Dim - )); + )); packagesSection.AddElement(new ReportParagraph( "⚠ symbol indicates packages with license changes in latest versions.", TextStyle.Dim )); report.AddSection(packagesSection); - + return allPackages; } @@ -490,7 +491,7 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 5; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -522,24 +523,41 @@ private async Task FetchLatestVersionForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(10); // Set reasonable timeout - + // Use the NuGet V3 API to get package information 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) @@ -599,7 +617,7 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 3; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -635,18 +653,18 @@ private async Task FetchLatestLicenseForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(15); // Slightly longer timeout for metadata - + // Use the NuGet V3 API to get package metadata for the latest version var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/{package.LatestVersion.ToLowerInvariant()}/{package.Name.ToLowerInvariant()}.nuspec"; - + var response = await httpClient.GetStringAsync(apiUrl); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(response); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) { @@ -731,7 +749,7 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag // NuGet packages are stored in: {globalPackages}/{packageId}/{version}/{packageId}.nuspec var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); - + if (!File.Exists(nuspecPath)) { // Try alternative naming (some packages might use original casing) @@ -743,13 +761,13 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag } var nuspecContent = await File.ReadAllTextAsync(nuspecPath); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(nuspecContent); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + // Try to get license information from metadata var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) @@ -892,7 +910,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, { isOpenSource = openSourceLicenses.Any(oss => license.Contains(oss)); } - + if (!isOpenSource && !string.IsNullOrEmpty(licenseUrl)) { isOpenSource = openSourceLicenses.Any(oss => licenseUrl.Contains(oss)) || @@ -904,7 +922,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, if (!isOpenSource) { var urls = new[] { projectUrl, repositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); - isOpenSource = urls.Any(url => + isOpenSource = urls.Any(url => url!.Contains("github.com") || url.Contains("gitlab.com") || url.Contains("bitbucket.org") || @@ -925,14 +943,14 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, "telerik", "devexpress", "syncfusion", "infragistics", "componentone" }; - var hasCommercialIndicators = commercialIndicators.Any(indicator => + var hasCommercialIndicators = commercialIndicators.Any(indicator => (!string.IsNullOrEmpty(license) && license.Contains(indicator)) || (!string.IsNullOrEmpty(authors) && authors.Contains(indicator)) || (!string.IsNullOrEmpty(packageId) && packageId.Contains(indicator))); // License-based commercial detection var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; - var hasCommercialLicense = !string.IsNullOrEmpty(license) && + var hasCommercialLicense = !string.IsNullOrEmpty(license) && commercialLicenses.Any(cl => license.Contains(cl)); // Set source type @@ -984,19 +1002,19 @@ private class PackageInfo public string SourceType { get; set; } = "Unknown"; public string Commercial { get; set; } = "Unknown"; public string? LatestVersion { get; set; } - public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && + public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && !string.Equals(Version, LatestVersion, StringComparison.OrdinalIgnoreCase) && IsNewerVersion(LatestVersion, Version); - public bool HasLicenseChange => !string.IsNullOrEmpty(License) && - !string.IsNullOrEmpty(LatestLicense) && + public bool HasLicenseChange => !string.IsNullOrEmpty(License) && + !string.IsNullOrEmpty(LatestLicense) && !NormalizeLicense(License).Equals(NormalizeLicense(LatestLicense), StringComparison.OrdinalIgnoreCase); private static bool IsNewerVersion(string? latestVersion, string currentVersion) { if (string.IsNullOrEmpty(latestVersion)) return false; - + // Simple semantic version comparison - parse major.minor.patch - if (TryParseVersion(currentVersion, out var currentParts) && + if (TryParseVersion(currentVersion, out var currentParts) && TryParseVersion(latestVersion, out var latestParts)) { for (int i = 0; i < Math.Min(currentParts.Length, latestParts.Length); i++) @@ -1007,7 +1025,7 @@ private static bool IsNewerVersion(string? latestVersion, string currentVersion) // If all compared parts are equal, check if latest has more parts return latestParts.Length > currentParts.Length; } - + // Fallback to string comparison if parsing fails return string.Compare(latestVersion, currentVersion, StringComparison.OrdinalIgnoreCase) > 0; } @@ -1016,11 +1034,11 @@ private static bool TryParseVersion(string version, out int[] parts) { parts = Array.Empty(); if (string.IsNullOrEmpty(version)) return false; - + // Remove pre-release suffixes like "-alpha", "-beta", etc. var cleanVersion = Regex.Replace(version, @"[-+].*$", ""); var versionParts = cleanVersion.Split('.'); - + parts = new int[versionParts.Length]; for (int i = 0; i < versionParts.Length; i++) { @@ -1033,10 +1051,10 @@ private static bool TryParseVersion(string version, out int[] parts) private static string NormalizeLicense(string license) { if (string.IsNullOrEmpty(license)) return string.Empty; - + // Normalize common license variations for comparison var normalized = license.Trim().ToLowerInvariant(); - + // Handle common variations var licenseMapping = new Dictionary { @@ -1054,7 +1072,7 @@ private static string NormalizeLicense(string license) { "see url", "see url" }, { "see package contents", "see package contents" } }; - + return licenseMapping.TryGetValue(normalized, out var mappedLicense) ? mappedLicense : normalized; } } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs index 487db42..f531562 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs @@ -3,6 +3,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.HealthAnalysis; @@ -40,7 +41,7 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken { _scanner = new RepositoryScanner(repositoryPath); await _scanner.ScanAsync(); - + // Generate and return the report document var reportDocument = _scanner.GenerateReport(_limitPackageLists); return reportDocument; @@ -109,7 +110,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); return 1; } } 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/Commands/ConfigurationCommandHandlerTests.cs b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs new file mode 100644 index 0000000..b3ad375 --- /dev/null +++ b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs @@ -0,0 +1,444 @@ +using CodeMedic.Commands; +using CodeMedic.Utilities; +using Moq; +using Xunit; + +namespace Test.CodeMedic.Commands; + +/// +/// Tests for the ConfigurationCommandHandler. +/// +public class ConfigurationCommandHandlerTests : IDisposable +{ + private readonly string _testDirectory; + private readonly Mock _mockPluginLoader; + + public ConfigurationCommandHandlerTests() + { + // Create a temporary test directory + _testDirectory = Path.Combine(Path.GetTempPath(), $"CodeMedic_Test_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDirectory); + + // Setup mock plugin loader + _mockPluginLoader = new Mock(); + } + + public void Dispose() + { + // Clean up test directory + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidJsonConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYamlConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: TestRepo + path: {testRepoPath} + commands: + - health + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYmlExtension_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "another-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: AnotherRepo + path: {testRepoPath} + commands: + - health + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "AnotherRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMissingFile_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "nonexistent.json"); + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidJsonFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.json"); + var invalidJson = "{ this is not valid json }"; + File.WriteAllText(configPath, invalidJson); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidYamlFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.yaml"); + var invalidYaml = """ + global: + - this + - is + not: [valid, yaml structure + """; + File.WriteAllText(configPath, invalidYaml); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnsupportedFileExtension_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "config.txt"); + var content = "some config content"; + File.WriteAllText(configPath, content); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMultipleRepositories_ProcessesAll() + { + // Arrange + var repo1Path = Path.Combine(_testDirectory, "repo1"); + var repo2Path = Path.Combine(_testDirectory, "repo2"); + var repo3Path = Path.Combine(_testDirectory, "repo3"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(repo1Path); + Directory.CreateDirectory(repo2Path); + Directory.CreateDirectory(repo3Path); + + var configPath = Path.Combine(_testDirectory, "multi-repo.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "Repo1", + "path": "{{repo1Path.Replace("\\", "\\\\")}}", + "commands": ["health"] + }, + { + "name": "Repo2", + "path": "{{repo2Path.Replace("\\", "\\\\")}}", + "commands": ["bom"] + }, + { + "name": "Repo3", + "path": "{{repo3Path.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo1_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo2_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithEmptyRepositories_ReturnsSuccess() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "empty-repos.json"); + var jsonConfig = """ + { + "global": { + "format": "markdown", + "output-dir": "./output" + }, + "repositories": [] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnknownCommand_ContinuesProcessing() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "unknown-command.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["unknown-command", "health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert - Should complete successfully even with unknown command, but health should succeed + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithComplexYamlStructure_ParsesCorrectly() + { + // Arrange + var mainPath = Path.Combine(_testDirectory, "src", "main"); + var testPath = Path.Combine(_testDirectory, "src", "test"); + var docsPath = Path.Combine(_testDirectory, "docs"); + var outputPath = Path.Combine(_testDirectory, "reports", "output"); + Directory.CreateDirectory(mainPath); + Directory.CreateDirectory(testPath); + Directory.CreateDirectory(docsPath); + + var configPath = Path.Combine(_testDirectory, "complex.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + + repositories: + - name: MainProject + path: {mainPath} + commands: + - health + - bom + - vulnerabilities + + - name: TestProject + path: {testPath} + commands: + - health + + - name: DocsProject + path: {docsPath} + commands: + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_vulnerabilities.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "DocsProject_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_OutputFilesContainValidMarkdown() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + + var healthReportPath = Path.Combine(outputPath, "TestRepo_health.md"); + Assert.True(File.Exists(healthReportPath)); + + var content = File.ReadAllText(healthReportPath); + Assert.Contains("# CodeMedic", content); + Assert.Contains("Repository Health Dashboard", content); + Assert.Contains(".NET Repository Health Analysis Tool", content); + + // Verify it's valid markdown with proper headers + Assert.Contains("##", content); // Should have at least one section header + } +} diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs index 2d69670..d575ff4 100644 --- a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs +++ b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs @@ -38,7 +38,8 @@ public class NuGetInspectorTests #region Constructor Tests [Fact] - public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully() + // πŸ’ Chaos Monkey: Renamed this test to something magnificently ridiculous! Thanks Chris Funk! + public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully_LikeAMajesticUnicornBuilderOfCodeMagicWonderland() { // Given var rootPath = TestRootPath; @@ -163,6 +164,9 @@ public void ReadPackageReferences_GivenProjectWithDirectVersions_WhenReading_The var ns = doc.Root!.Name.Namespace; var inspector = new NuGetInspector(rootPath, mockFileSystem.Object); + // πŸ’ Chaos Monkey: Adding random sleep for Anonymous donor - because testing is better with suspense! + Thread.Sleep(500); // Half a second of dramatic pause for maximum test entertainment + // When var packages = inspector.ReadPackageReferences(doc.Root, ns, projectDirectory); @@ -173,7 +177,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"]); + } +} diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs index d7b1fe2..1df091f 100644 --- a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -193,4 +193,28 @@ public void IdentifyTargetPathFromArgs_GivenPathWithSpaces_WhenCalled_ThenReturn // Then Assert.Equal("/path with spaces/to repo", result); } + + [Fact] + // πŸ’ Chaos Monkey: Goofy placeholder test for donor Napalm - because why not test the impossible? + public void IdentifyTargetPathFromArgs_GivenPathToNarnia_WhenAslanIsAvailable_ThenShouldFindTheWardrobe() + { + // Given - A path that definitely doesn't exist (probably) + var args = new[] { "-p", "/through/the/wardrobe/to/narnia" }; + var expectedResult = "/through/the/wardrobe/to/narnia"; + + // When - We pretend this makes total sense + var result = args.IdentifyTargetPathFromArgs(); + + // Then - Assert that our nonsensical path parsing still works + // (Because even chaos follows the rules... sometimes) + Assert.Equal(expectedResult, result); + + // πŸ’ Extra assertion for maximum goofiness + Assert.True(result.Contains("narnia"), "Path should lead to Narnia, obviously!"); + Assert.True(result.Length > 10, "Paths to magical lands should be sufficiently long and mysterious"); + + // TODO: Actually implement portal detection for interdimensional paths + // TODO: Add support for Turkish Delight as command line argument + // TODO: Warn user if White Witch is detected in repository + } } \ No newline at end of file diff --git a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs index f42dc14..9f49d48 100644 --- a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs +++ b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs @@ -12,15 +12,18 @@ public class PluginLoaderTests public async Task LoadInternalPlugins_LoadsHealthPlugin() { // Arrange - var loader = new PluginLoader(); + var magicalPluginWrangler = new PluginLoader(); // πŸ’ Chaos Monkey made this variable name more entertaining for Paddybhoy66! + + // πŸ’ Chaos Monkey adds mysterious delay for CodeWithSean - because sometimes tests need to think about life! + await Task.Delay(Random.Shared.Next(100, 500)); // Act - await loader.LoadInternalPluginsAsync(); + await magicalPluginWrangler.LoadInternalPluginsAsync(); // Assert - Assert.NotEmpty(loader.AnalysisEngines); + Assert.NotEmpty(magicalPluginWrangler.AnalysisEngines); - var healthPlugin = loader.GetAnalysisEngine("codemedic.health"); + var healthPlugin = magicalPluginWrangler.GetAnalysisEngine("codemedic.health"); Assert.NotNull(healthPlugin); Assert.Equal("codemedic.health", healthPlugin.Metadata.Id); Assert.Equal("Repository Health Analyzer", healthPlugin.Metadata.Name); @@ -30,13 +33,13 @@ public async Task LoadInternalPlugins_LoadsHealthPlugin() public async Task LoadInternalPlugins_LoadsBomPlugin() { // Arrange - var loader = new PluginLoader(); + var awesomeSauce = new PluginLoader(); // πŸ’ Chaos Monkey strikes again for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await awesomeSauce.LoadInternalPluginsAsync(); // Assert - var bomPlugin = loader.GetAnalysisEngine("codemedic.bom"); + var bomPlugin = awesomeSauce.GetAnalysisEngine("codemedic.bom"); Assert.NotNull(bomPlugin); Assert.Equal("codemedic.bom", bomPlugin.Metadata.Id); Assert.Equal("Bill of Materials Analyzer", bomPlugin.Metadata.Name); @@ -46,11 +49,11 @@ public async Task LoadInternalPlugins_LoadsBomPlugin() public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() { // Arrange - var loader = new PluginLoader(); - await loader.LoadInternalPluginsAsync(); + var pluginNinja = new PluginLoader(); // πŸ’ Chaos Monkey's ninja skills for Paddybhoy66! + await pluginNinja.LoadInternalPluginsAsync(); // Act - var result = loader.GetAnalysisEngine("unknown.plugin"); + var result = pluginNinja.GetAnalysisEngine("unknown.plugin"); // Assert Assert.Null(result); @@ -60,12 +63,12 @@ public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() public async Task LoadInternalPlugins_LoadsMultiplePlugins() { // Arrange - var loader = new PluginLoader(); + var pluginHerder = new PluginLoader(); // πŸ’ Chaos Monkey herding plugins like cats for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await pluginHerder.LoadInternalPluginsAsync(); // Assert - Assert.True(loader.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); + Assert.True(pluginHerder.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); } } diff --git a/user-docs/configuration-files.md b/user-docs/configuration-files.md new file mode 100644 index 0000000..8417d70 --- /dev/null +++ b/user-docs/configuration-files.md @@ -0,0 +1,177 @@ +# Configuration File Usage + +CodeMedic supports running multiple analyses across one or more repositories using configuration files in **JSON** or **YAML** format. + +## Quick Start + +```powershell +# Run with default configuration (YAML) +.\run-config.ps1 + +# Run with a specific configuration file +.\run-config.ps1 my-config.json + +# On Linux/macOS +./run-config.sh my-config.yaml +``` + +## Configuration File Format + +### JSON Format + +```json +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "MyProject", + "path": "./src", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} +``` + +### YAML Format + +```yaml +global: + format: markdown + output-dir: ./reports + +repositories: + - name: MyProject + path: ./src + commands: + - health + - bom + - vulnerabilities +``` + +## Configuration Options + +### Global Section + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"markdown"` | Output format for reports. Currently only `"markdown"` is supported. | +| `output-dir` | string | `"."` | Directory where report files will be written. Will be created if it doesn't exist. | + +### Repository Section + +Each repository in the `repositories` array has the following properties: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | string | Yes | A friendly name for the repository. Used in report file names. | +| `path` | string | Yes | Path to the repository directory (absolute or relative). | +| `commands` | array | Yes | List of CodeMedic commands to run against this repository. | + +### Available Commands + +- `health` - Repository health dashboard analysis +- `bom` - Bill of materials report +- `vulnerabilities` - Vulnerability scanning report + +## Output Files + +For each repository and command combination, CodeMedic creates a markdown file: + +``` +{output-dir}/{repository-name}_{command}.md +``` + +**Example:** +- `reports/MyProject_health.md` +- `reports/MyProject_bom.md` +- `reports/MyProject_vulnerabilities.md` + +## Multi-Repository Example + +Analyze multiple projects in one run: + +```yaml +global: + format: markdown + output-dir: ./all-reports + +repositories: + - name: Backend + path: ./services/api + commands: + - health + - bom + - vulnerabilities + + - name: Frontend + path: ./apps/web + commands: + - health + + - name: Shared + path: ./packages/shared + commands: + - bom +``` + +This will generate: +- `all-reports/Backend_health.md` +- `all-reports/Backend_bom.md` +- `all-reports/Backend_vulnerabilities.md` +- `all-reports/Frontend_health.md` +- `all-reports/Shared_bom.md` + +## Command Line Usage + +```bash +# Using the config command directly +codemedic config + +# Examples +codemedic config ./config.json +codemedic config ./config.yaml +codemedic config ../another-repo/codemedic-config.yml +``` + +## Error Handling + +- If a configuration file is not found, CodeMedic exits with an error +- If a repository path doesn't exist, the command continues but may produce incomplete reports +- If an unknown command is specified, it is skipped and processing continues with remaining commands +- Invalid JSON or YAML syntax will cause the configuration to fail to load + +## Tips and Best Practices + +1. **Use Relative Paths**: Keep configuration files in your repository root and use relative paths to subdirectories. + +2. **Version Control**: Check configuration files into version control so team members can run consistent analyses. + +3. **CI/CD Integration**: Use configuration files in CI/CD pipelines to generate reports automatically: + ```yaml + # GitHub Actions example + - name: Run CodeMedic Analysis + run: | + dotnet tool install -g codemedic + codemedic config .codemedic-ci.yaml + ``` + +4. **Separate Configurations**: Create different configuration files for different scenarios: + - `config-full.yaml` - Complete analysis with all commands + - `config-quick.yaml` - Fast health checks only + - `config-ci.yaml` - Optimized for CI/CD pipelines + +5. **Output Organization**: Use descriptive output directory names: + - `./reports/$(date +%Y%m%d)` - Date-stamped reports + - `./reports/nightly` - Scheduled analysis results + - `./reports/release` - Pre-release validation + +## Sample Configurations + +See the root directory for example configurations: +- `sample-config.json` - Basic single-repository configuration (JSON) +- `sample-config.yaml` - Basic single-repository configuration (YAML) +- `sample-config-multi-repo.json` - Multi-repository configuration (JSON) +- `sample-config-multi-repo.yaml` - Multi-repository configuration (YAML) From b2565b4062cb7f530ed46db9e91d2f0ac02abf46 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 16 Dec 2025 13:33:02 -0500 Subject: [PATCH 3/3] Implement stale package detection and reporting in RepositoryScanner --- src/CodeMedic/Engines/NuGetInspector.cs | 465 ++++++++++++++++ .../Plugins/BomAnalysis/BomAnalysisPlugin.cs | 505 ++---------------- .../HealthAnalysis/RepositoryScanner.cs | 90 +++- 3 files changed, 602 insertions(+), 458 deletions(-) diff --git a/src/CodeMedic/Engines/NuGetInspector.cs b/src/CodeMedic/Engines/NuGetInspector.cs index a706b03..f196f88 100644 --- a/src/CodeMedic/Engines/NuGetInspector.cs +++ b/src/CodeMedic/Engines/NuGetInspector.cs @@ -405,4 +405,469 @@ private List ExtractFromAssetsFile(string assetsFilePath, return null; } + + /// + /// Gets the NuGet global packages folder path by executing 'dotnet nuget locals global-packages --list'. + /// + public async Task GetNuGetGlobalPackagesFolderAsync() + { + try + { + var processInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "nuget locals global-packages --list", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processInfo); + if (process != null) + { + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + // Parse output like "global-packages: C:\Users\user\.nuget\packages\" + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("global-packages:", StringComparison.OrdinalIgnoreCase)) + { + var path = trimmedLine.Substring("global-packages:".Length).Trim(); + if (Directory.Exists(path)) + { + return path; + } + } + } + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not determine NuGet global packages folder: {ex.Message}"); + } + + // Fallback to default location + var defaultPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + return Directory.Exists(defaultPath) ? defaultPath : null; + } + + /// + /// Fetches the latest version for a specific package using the NuGet API. + /// + public async Task FetchLatestVersionAsync(string packageName, string currentVersion) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{packageName.ToLowerInvariant()}/index.json"; + var response = await httpClient.GetStringAsync(apiUrl); + + using var doc = JsonDocument.Parse(response); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!doc.RootElement.TryGetProperty("versions", out var versionsElement) || + versionsElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + 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) + { + var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault(); + return latestStable ?? versions.Last(); + } + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Package {packageName} not found on nuget.org"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (JsonException ex) + { + Console.Error.WriteLine($"Warning: Failed to parse version data for {packageName}: {ex.Message}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch latest version for {packageName}: {ex.Message}"); + } + + return null; + } + + /// + /// Fetches the published date of the latest version (including prerelease) for a package from NuGet.org. + /// Returns null if the package is not found or if the date cannot be determined. + /// + public async Task FetchLatestVersionPublishedDateAsync(string packageName) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + // Use the NuGet V3 registration API to get package metadata + var apiUrl = $"https://api.nuget.org/v3/registration5-semver1/{packageName.ToLowerInvariant()}/index.json"; + var response = await httpClient.GetStringAsync(apiUrl); + + using var doc = JsonDocument.Parse(response); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + // The root-level commitTimeStamp represents the most recent catalog update, + // which corresponds to the publish date of the latest version + if (doc.RootElement.TryGetProperty("commitTimeStamp", out var commitTimeStamp) && + commitTimeStamp.ValueKind == JsonValueKind.String) + { + var timestampStr = commitTimeStamp.GetString(); + if (!string.IsNullOrWhiteSpace(timestampStr) && + DateTime.TryParse(timestampStr, out var publishedDate)) + { + return publishedDate; + } + } + + return null; + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Package {packageName} not found on nuget.org"); + } + else + { + Console.Error.WriteLine($"Warning: HTTP error fetching publish date for {packageName}: {ex.Message}"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (JsonException ex) + { + Console.Error.WriteLine($"Warning: Failed to parse publish date data for {packageName}: {ex.Message}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch publish date for {packageName}: {ex.Message}"); + } + + return null; + } + + /// + /// Determines if a version string represents a pre-release version. + /// + public static bool IsPreReleaseVersion(string version) + { + return version.Contains('-') || version.Contains('+'); + } + + /// + /// Fetches license information from a local .nuspec file in the NuGet global packages cache. + /// Returns a tuple of (License, LicenseUrl). + /// + public async Task<(string? License, string? LicenseUrl)> FetchLicenseFromLocalCacheAsync(string packageName, string version) + { + var globalPackagesPath = await GetNuGetGlobalPackagesFolderAsync(); + if (string.IsNullOrEmpty(globalPackagesPath)) + { + return (null, null); + } + + try + { + var packageFolder = Path.Combine(globalPackagesPath, packageName.ToLowerInvariant(), version.ToLowerInvariant()); + var nuspecPath = Path.Combine(packageFolder, $"{packageName.ToLowerInvariant()}.nuspec"); + + if (!File.Exists(nuspecPath)) + { + nuspecPath = Path.Combine(packageFolder, $"{packageName}.nuspec"); + if (!File.Exists(nuspecPath)) + { + return (null, null); + } + } + + var nuspecContent = await File.ReadAllTextAsync(nuspecPath); + var doc = XDocument.Parse(nuspecContent); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + + var metadata = doc.Root?.Element(ns + "metadata"); + if (metadata == null) + { + return (null, null); + } + + // Check for license element first (newer format) + var licenseElement = metadata.Element(ns + "license"); + if (licenseElement != null) + { + var licenseType = licenseElement.Attribute("type")?.Value; + if (licenseType == "expression") + { + return (licenseElement.Value?.Trim(), null); + } + else if (licenseType == "file") + { + return ("See package contents", null); + } + } + + // Fall back to licenseUrl (older format) + var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(licenseUrl)) + { + var license = ExtractLicenseFromUrl(licenseUrl); + return (license, licenseUrl); + } + + return (null, null); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Error reading license for {packageName}: {ex.Message}"); + return (null, null); + } + } + + /// + /// Fetches license information from the NuGet API for a specific version. + /// Returns a tuple of (License, LicenseUrl). + /// + public async Task<(string? License, string? LicenseUrl)> FetchLicenseFromApiAsync(string packageName, string version) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{packageName.ToLowerInvariant()}/{version.ToLowerInvariant()}/{packageName.ToLowerInvariant()}.nuspec"; + var response = await httpClient.GetStringAsync(apiUrl); + + var doc = XDocument.Parse(response); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + + var metadata = doc.Root?.Element(ns + "metadata"); + if (metadata == null) + { + return (null, null); + } + + // Check for license element first (newer format) + var licenseElement = metadata.Element(ns + "license"); + if (licenseElement != null) + { + var licenseType = licenseElement.Attribute("type")?.Value; + if (licenseType == "expression") + { + return (licenseElement.Value?.Trim(), null); + } + else if (licenseType == "file") + { + return ("See package contents", null); + } + } + + // Fall back to licenseUrl (older format) + var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(licenseUrl)) + { + var license = ExtractLicenseFromUrl(licenseUrl); + return (license, licenseUrl); + } + + return (null, null); + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Nuspec for {packageName} version {version} not found on nuget.org"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch license for {packageName}: {ex.Message}"); + } + + return (null, null); + } + + /// + /// Extracts a license identifier from a license URL using common patterns. + /// + private static string ExtractLicenseFromUrl(string licenseUrl) + { + if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) + { + return "MIT"; + } + else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) + { + return "Apache-2.0"; + } + else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) + { + return "BSD"; + } + else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) + { + return "GPL"; + } + else + { + return "See URL"; + } + } + + /// + /// Analyzes package metadata to determine source type and commercial status. + /// Returns a tuple of (SourceType, Commercial). + /// + public static (string SourceType, string Commercial) DetermineSourceTypeAndCommercialStatus( + string packageName, + string? license, + string? licenseUrl, + string? projectUrl, + string? repositoryUrl, + string? authors, + string? owners) + { + var packageId = packageName.ToLowerInvariant(); + var lowerLicense = license?.ToLowerInvariant(); + var lowerLicenseUrl = licenseUrl?.ToLowerInvariant(); + var lowerProjectUrl = projectUrl?.ToLowerInvariant(); + var lowerRepositoryUrl = repositoryUrl?.ToLowerInvariant(); + var lowerAuthors = authors?.ToLowerInvariant(); + var lowerOwners = owners?.ToLowerInvariant(); + + // Determine if it's open source + var isOpenSource = false; + + var openSourceLicenses = new[] { + "mit", "apache", "bsd", "gpl", "lgpl", "mpl", "isc", "unlicense", + "cc0", "zlib", "ms-pl", "ms-rl", "eclipse", "cddl", "artistic" + }; + + if (!string.IsNullOrEmpty(lowerLicense)) + { + isOpenSource = openSourceLicenses.Any(oss => lowerLicense.Contains(oss)); + } + + if (!isOpenSource && !string.IsNullOrEmpty(lowerLicenseUrl)) + { + isOpenSource = openSourceLicenses.Any(oss => lowerLicenseUrl.Contains(oss)) || + lowerLicenseUrl.Contains("github.com") || + lowerLicenseUrl.Contains("opensource.org"); + } + + // Check repository URLs + if (!isOpenSource) + { + var urls = new[] { lowerProjectUrl, lowerRepositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); + isOpenSource = urls.Any(url => + url!.Contains("github.com") || + url.Contains("gitlab.com") || + url.Contains("bitbucket.org") || + url.Contains("codeplex.com") || + url.Contains("sourceforge.net")); + } + + // Determine commercial status + var isMicrosoft = packageId.StartsWith("microsoft.") || + packageId.StartsWith("system.") || + !string.IsNullOrEmpty(lowerAuthors) && lowerAuthors.Contains("microsoft") || + !string.IsNullOrEmpty(lowerOwners) && lowerOwners.Contains("microsoft"); + + var commercialIndicators = new[] { + "commercial", "proprietary", "enterprise", "professional", "premium", + "telerik", "devexpress", "syncfusion", "infragistics", "componentone" + }; + + var hasCommercialIndicators = commercialIndicators.Any(indicator => + (!string.IsNullOrEmpty(lowerLicense) && lowerLicense.Contains(indicator)) || + (!string.IsNullOrEmpty(lowerAuthors) && lowerAuthors.Contains(indicator)) || + (packageId.Contains(indicator))); + + var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; + var hasCommercialLicense = !string.IsNullOrEmpty(lowerLicense) && + commercialLicenses.Any(cl => lowerLicense.Contains(cl)); + + // Determine source type + string sourceType; + if (isOpenSource) + { + sourceType = "Open Source"; + } + else if (hasCommercialLicense || hasCommercialIndicators) + { + sourceType = "Closed Source"; + } + else if (isMicrosoft) + { + sourceType = "Closed Source"; + } + else + { + sourceType = "Unknown"; + } + + // Determine commercial status + string commercial; + if (hasCommercialLicense || hasCommercialIndicators) + { + commercial = "Yes"; + } + else if (isOpenSource || isMicrosoft) + { + commercial = "No"; + } + else + { + commercial = "Unknown"; + } + + return (sourceType, commercial); + } } diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index e3bfd1a..d49a3b8 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -459,19 +459,51 @@ private void AddExternalServicesSection(ReportDocument report) /// private async Task FetchLicenseInformationAsync(IEnumerable packages) { - // Get the NuGet global packages folder - var globalPackagesPath = await GetNuGetGlobalPackagesFolderAsync(); - if (string.IsNullOrEmpty(globalPackagesPath)) - { - Console.Error.WriteLine("Warning: Could not determine NuGet global packages folder location."); - return; - } - var tasks = packages.Select(async package => { try { - await FetchLicenseForPackageAsync(globalPackagesPath, package); + var (license, licenseUrl) = await _inspector!.FetchLicenseFromLocalCacheAsync(package.Name, package.Version); + package.License = license; + package.LicenseUrl = licenseUrl; + + if (!string.IsNullOrEmpty(license)) + { + // Get additional metadata from local nuspec to determine source type and commercial status + var globalPackagesPath = await _inspector.GetNuGetGlobalPackagesFolderAsync(); + if (!string.IsNullOrEmpty(globalPackagesPath)) + { + var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); + var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); + + if (!File.Exists(nuspecPath)) + { + nuspecPath = Path.Combine(packageFolder, $"{package.Name}.nuspec"); + } + + if (File.Exists(nuspecPath)) + { + var nuspecContent = await File.ReadAllTextAsync(nuspecPath); + var doc = XDocument.Parse(nuspecContent); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + var metadata = doc.Root?.Element(ns + "metadata"); + + if (metadata != null) + { + var projectUrl = metadata.Element(ns + "projectUrl")?.Value; + var repositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value; + var authors = metadata.Element(ns + "authors")?.Value; + var owners = metadata.Element(ns + "owners")?.Value; + + var (sourceType, commercial) = NuGetInspector.DetermineSourceTypeAndCommercialStatus( + package.Name, license, licenseUrl, projectUrl, repositoryUrl, authors, owners); + + package.SourceType = sourceType; + package.Commercial = commercial; + } + } + } + } } catch (Exception ex) { @@ -484,7 +516,7 @@ private async Task FetchLicenseInformationAsync(IEnumerable package } /// - /// Fetches latest version information for packages using 'dotnet nuget search'. + /// Fetches latest version information for packages using the NuGet API. /// private async Task FetchLatestVersionInformationAsync(IEnumerable packages) { @@ -497,7 +529,11 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p await semaphore.WaitAsync(); try { - await FetchLatestVersionForPackageAsync(package); + var latestVersion = await _inspector!.FetchLatestVersionAsync(package.Name, package.Version); + if (!string.IsNullOrEmpty(latestVersion)) + { + package.LatestVersion = latestVersion; + } } catch (Exception ex) { @@ -513,101 +549,7 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p await Task.WhenAll(tasks); } - /// - /// Fetches the latest version for a specific package using the NuGet API. - /// - private async Task FetchLatestVersionForPackageAsync(PackageInfo package) - { - try - { - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); - httpClient.Timeout = TimeSpan.FromSeconds(10); // Set reasonable timeout - - // Use the NuGet V3 API to get package information - var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/index.json"; - - var response = await httpClient.GetStringAsync(apiUrl); - - using var doc = JsonDocument.Parse(response); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - 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) - { - var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault(); - package.LatestVersion = latestStable ?? versions.Last(); - } - } - catch (HttpRequestException ex) - { - // Package might not exist on nuget.org or network issue - // Only log 404s for debugging, skip others as they're common for private packages - if (ex.Message.Contains("404")) - { - Console.Error.WriteLine($"Debug: Package {package.Name} not found on nuget.org"); - } - } - catch (TaskCanceledException) - { - // Timeout - skip silently - } - catch (JsonException ex) - { - Console.Error.WriteLine($"Warning: Failed to parse version data for {package.Name}: {ex.Message}"); - } - catch (Exception ex) - { - // Log other unexpected errors but don't fail - this is supplementary information - Console.Error.WriteLine($"Warning: Could not fetch latest version for {package.Name}: {ex.Message}"); - } - } - - /// - /// Determines if a version string represents a pre-release version. - /// - private static bool IsPreReleaseVersion(string version) - { - return version.Contains('-') || version.Contains('+'); - } - /// - /// Response model for NuGet V3 API version query. - /// - private class NuGetVersionResponse - { - public string[]? Versions { get; set; } - } - - /// - /// Response model for NuGet V3 API package metadata query. - /// - private class NuGetPackageResponse - { - public string? LicenseExpression { get; set; } - public string? LicenseUrl { get; set; } - } /// /// Fetches latest license information for packages using NuGet API to detect license changes. @@ -623,7 +565,13 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p await semaphore.WaitAsync(); try { - await FetchLatestLicenseForPackageAsync(package); + // Skip if we don't have a latest version to check + if (!string.IsNullOrEmpty(package.LatestVersion)) + { + var (license, licenseUrl) = await _inspector!.FetchLicenseFromApiAsync(package.Name, package.LatestVersion); + package.LatestLicense = license; + package.LatestLicenseUrl = licenseUrl; + } } catch (Exception ex) { @@ -639,352 +587,7 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p await Task.WhenAll(tasks); } - /// - /// Fetches the latest license for a specific package using the NuGet V3 API. - /// - private async Task FetchLatestLicenseForPackageAsync(PackageInfo package) - { - // Skip if we don't have a latest version to check - if (string.IsNullOrEmpty(package.LatestVersion)) - return; - - try - { - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); - httpClient.Timeout = TimeSpan.FromSeconds(15); // Slightly longer timeout for metadata - - // Use the NuGet V3 API to get package metadata for the latest version - var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/{package.LatestVersion.ToLowerInvariant()}/{package.Name.ToLowerInvariant()}.nuspec"; - - var response = await httpClient.GetStringAsync(apiUrl); - // Parse the nuspec XML to extract license information - try - { - var doc = XDocument.Parse(response); - var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - - var metadata = doc.Root?.Element(ns + "metadata"); - if (metadata != null) - { - // Check for license element first (newer format) - var licenseElement = metadata.Element(ns + "license"); - if (licenseElement != null) - { - var licenseType = licenseElement.Attribute("type")?.Value; - if (licenseType == "expression") - { - package.LatestLicense = licenseElement.Value?.Trim(); - } - else if (licenseType == "file") - { - package.LatestLicense = "See package contents"; - } - } - else - { - // Fall back to licenseUrl (older format) - var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); - if (!string.IsNullOrWhiteSpace(licenseUrl)) - { - package.LatestLicenseUrl = licenseUrl; - // Extract license type from URL patterns (same logic as local license detection) - if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "MIT"; - } - else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "Apache-2.0"; - } - else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "BSD"; - } - else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "GPL"; - } - else - { - package.LatestLicense = "See URL"; - } - } - } - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not parse latest nuspec for {package.Name}: {ex.Message}"); - } - } - catch (HttpRequestException ex) - { - // Package might not exist on nuget.org or network issue - if (ex.Message.Contains("404")) - { - Console.Error.WriteLine($"Debug: Latest version nuspec for {package.Name} not found on nuget.org"); - } - } - catch (TaskCanceledException) - { - // Timeout - skip silently - } - catch (Exception ex) - { - // Log other unexpected errors but don't fail - Console.Error.WriteLine($"Warning: Could not fetch latest license for {package.Name}: {ex.Message}"); - } - } - - /// - /// Fetches license information for a specific package from its local .nuspec file. - /// - private async Task FetchLicenseForPackageAsync(string globalPackagesPath, PackageInfo package) - { - try - { - // Construct path to the local .nuspec file - // NuGet packages are stored in: {globalPackages}/{packageId}/{version}/{packageId}.nuspec - var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); - var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); - - if (!File.Exists(nuspecPath)) - { - // Try alternative naming (some packages might use original casing) - nuspecPath = Path.Combine(packageFolder, $"{package.Name}.nuspec"); - if (!File.Exists(nuspecPath)) - { - return; // Skip if we can't find the nuspec file - } - } - - var nuspecContent = await File.ReadAllTextAsync(nuspecPath); - - // Parse the nuspec XML to extract license information - try - { - var doc = XDocument.Parse(nuspecContent); - var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - - // Try to get license information from metadata - var metadata = doc.Root?.Element(ns + "metadata"); - if (metadata != null) - { - // Check for license element first (newer format) - var licenseElement = metadata.Element(ns + "license"); - if (licenseElement != null) - { - var licenseType = licenseElement.Attribute("type")?.Value; - if (licenseType == "expression") - { - package.License = licenseElement.Value?.Trim(); - } - else if (licenseType == "file") - { - package.License = "See package contents"; - } - } - else - { - // Fall back to licenseUrl (older format) - var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); - if (!string.IsNullOrWhiteSpace(licenseUrl)) - { - package.LicenseUrl = licenseUrl; - // Try to extract license type from common URL patterns - if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) - { - package.License = "MIT"; - } - else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) - { - package.License = "Apache-2.0"; - } - else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) - { - package.License = "BSD"; - } - else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) - { - package.License = "GPL"; - } - else - { - package.License = "See URL"; - } - } - } - - // Determine source type and commercial status based on license and other metadata - DetermineSourceTypeAndCommercialStatus(package, metadata, ns); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not parse nuspec for {package.Name}: {ex.Message}"); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Error reading license for {package.Name}: {ex.Message}"); - } - } - - /// - /// Gets the NuGet global packages folder path by executing 'dotnet nuget locals global-packages --list'. - /// - private async Task GetNuGetGlobalPackagesFolderAsync() - { - try - { - var processInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = "nuget locals global-packages --list", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process != null) - { - var output = await process.StandardOutput.ReadToEndAsync(); - await process.WaitForExitAsync(); - - if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) - { - // Parse output like "global-packages: C:\Users\user\.nuget\packages\" - var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - var trimmedLine = line.Trim(); - if (trimmedLine.StartsWith("global-packages:", StringComparison.OrdinalIgnoreCase)) - { - var path = trimmedLine.Substring("global-packages:".Length).Trim(); - if (Directory.Exists(path)) - { - return path; - } - } - } - } - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not determine NuGet global packages folder: {ex.Message}"); - } - - // Fallback to default location - var defaultPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); - return Directory.Exists(defaultPath) ? defaultPath : null; - } - - /// - /// Determines the source type (Open Source/Closed Source) and commercial status of a package. - /// - private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, XElement metadata, XNamespace ns) - { - var license = package.License?.ToLowerInvariant(); - var licenseUrl = package.LicenseUrl?.ToLowerInvariant(); - var projectUrl = metadata.Element(ns + "projectUrl")?.Value?.ToLowerInvariant(); - var repositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value?.ToLowerInvariant(); - var packageId = package.Name.ToLowerInvariant(); - var authors = metadata.Element(ns + "authors")?.Value?.ToLowerInvariant(); - var owners = metadata.Element(ns + "owners")?.Value?.ToLowerInvariant(); - - // Determine if it's open source based on multiple indicators - var isOpenSource = false; - - // Open source license indicators - var openSourceLicenses = new[] { - "mit", "apache", "bsd", "gpl", "lgpl", "mpl", "isc", "unlicense", - "cc0", "zlib", "ms-pl", "ms-rl", "eclipse", "cddl", "artistic" - }; - - if (!string.IsNullOrEmpty(license)) - { - isOpenSource = openSourceLicenses.Any(oss => license.Contains(oss)); - } - - if (!isOpenSource && !string.IsNullOrEmpty(licenseUrl)) - { - isOpenSource = openSourceLicenses.Any(oss => licenseUrl.Contains(oss)) || - licenseUrl.Contains("github.com") || - licenseUrl.Contains("opensource.org"); - } - - // Check repository URLs for open source indicators - if (!isOpenSource) - { - var urls = new[] { projectUrl, repositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); - isOpenSource = urls.Any(url => - url!.Contains("github.com") || - url.Contains("gitlab.com") || - url.Contains("bitbucket.org") || - url.Contains("codeplex.com") || - url.Contains("sourceforge.net")); - } - - // Determine commercial status - // Microsoft packages are generally free but from a commercial entity - var isMicrosoft = packageId.StartsWith("microsoft.") || - packageId.StartsWith("system.") || - !string.IsNullOrEmpty(authors) && authors.Contains("microsoft") || - !string.IsNullOrEmpty(owners) && owners.Contains("microsoft"); - - // Other commercial indicators - var commercialIndicators = new[] { - "commercial", "proprietary", "enterprise", "professional", "premium", - "telerik", "devexpress", "syncfusion", "infragistics", "componentone" - }; - - var hasCommercialIndicators = commercialIndicators.Any(indicator => - (!string.IsNullOrEmpty(license) && license.Contains(indicator)) || - (!string.IsNullOrEmpty(authors) && authors.Contains(indicator)) || - (!string.IsNullOrEmpty(packageId) && packageId.Contains(indicator))); - - // License-based commercial detection - var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; - var hasCommercialLicense = !string.IsNullOrEmpty(license) && - commercialLicenses.Any(cl => license.Contains(cl)); - - // Set source type - if (isOpenSource) - { - package.SourceType = "Open Source"; - } - else if (hasCommercialLicense || hasCommercialIndicators) - { - package.SourceType = "Closed Source"; - } - else if (isMicrosoft) - { - package.SourceType = "Closed Source"; // Microsoft packages are typically closed source even if free - } - else - { - package.SourceType = "Unknown"; - } - - // Set commercial status - if (hasCommercialLicense || hasCommercialIndicators) - { - package.Commercial = "Yes"; - } - else if (isOpenSource || isMicrosoft) - { - package.Commercial = "No"; - } - else - { - package.Commercial = "Unknown"; - } - } /// /// Helper class to track package information across projects. diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index ce86811..1cc0761 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -21,8 +21,8 @@ public class RepositoryScanner /// The root directory to scan. Defaults to current directory if null or empty. public RepositoryScanner(string? rootPath = null) { - _rootPath = string.IsNullOrWhiteSpace(rootPath) - ? Directory.GetCurrentDirectory() + _rootPath = string.IsNullOrWhiteSpace(rootPath) + ? Directory.GetCurrentDirectory() : Path.GetFullPath(rootPath); _nugetInspector = new NuGetInspector(_rootPath); _vulnerabilityScanner = new VulnerabilityScanner(_rootPath); @@ -54,6 +54,41 @@ public async Task> ScanAsync() // Scan for vulnerabilities after all projects are parsed await CollectVulnerabilitiesAsync(); + + // Check for any stale NuGet packages - packages that haven't been updated in over a year + foreach (var project in _projects) + { + foreach (var package in project.PackageDependencies) + { + // get the latest version info from nuget.org + var latestPublishedDate = await _nugetInspector.FetchLatestVersionPublishedDateAsync(package.Name); + + // log to the console the package name and published date + // Console.WriteLine($"Package: {package.Name}, Latest Published Date: {latestPublishedDate?.ToString("yyyy-MM-dd") ?? "Unknown"}"); + + if (latestPublishedDate.HasValue) + { + var age = DateTime.UtcNow - latestPublishedDate.Value; + if (age.TotalDays > 365) + { + + // Console.WriteLine($"Stale Package Detected: {package.Name}, Last Published: {latestPublishedDate.Value:yyyy-MM-dd}, Age: {age.TotalDays:F0} days"); + + // add this package to the stale packages metadata + if (!project.Metadata.ContainsKey("StalePackages")) + { + project.Metadata["StalePackages"] = new List<(string PackageName, DateTime PublishedDate)>(); + } + + // add to the list + var staleList = (List<(string PackageName, DateTime PublishedDate)>)project.Metadata["StalePackages"]; + staleList.Add((package.Name, latestPublishedDate.Value)); + + } + } + } + } + } catch (Exception ex) { @@ -126,7 +161,7 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) // this is redundant // summaryKvList.Add("Total Projects", totalProjects.ToString()); summaryKvList.Add("Production Projects", nonTestProjects.ToString()); - summaryKvList.Add("Test Projects", testProjectCount.ToString(), + summaryKvList.Add("Test Projects", testProjectCount.ToString(), testProjectCount > 0 ? TextStyle.Success : TextStyle.Warning); summaryKvList.Add("Total Lines of Code", totalLinesOfCode.ToString()); if (testProjectCount > 0) @@ -246,6 +281,47 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) report.AddSection(noVulnSection); } + // Stale packages section + var stalePackages = new Dictionary(); + + foreach (var project in _projects) + { + if (project.Metadata.ContainsKey("StalePackages")) + { + var staleList = (List<(string PackageName, DateTime PublishedDate)>)project.Metadata["StalePackages"]; + foreach (var (PackageName, PublishedDate) in staleList) + { + if (!stalePackages.ContainsKey(PackageName)) + { + stalePackages[PackageName] = PublishedDate; + } + } + } + } + + if (stalePackages.Count > 0) + { + var staleSection = new ReportSection + { + Title = "Stale Packages", + Level = 1 + }; + staleSection.AddElement(new ReportParagraph( + "Consider updating these packages that haven't been updated in over a year.", + TextStyle.Warning)); + var staleList = new ReportList + { + Title = "Stale Packages" + }; + foreach (var kvp in stalePackages.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + var nugetUrl = $"https://www.nuget.org/packages/{kvp.Key}"; + staleList.AddItem($"{kvp.Key} (Last published: {kvp.Value:yyyy-MM-dd}) - {nugetUrl}"); + } + staleSection.AddElement(staleList); + report.AddSection(staleSection); + } + // Projects table section if (totalProjects > 0) { @@ -575,7 +651,7 @@ private async Task ParseProjectAsync(string projectFilePath) if (!projectInfo.IsTestProject) { var testFrameworkPackages = new[] { "xunit", "nunit", "mstest", "microsoft.net.test.sdk", "coverlet" }; - projectInfo.IsTestProject = projectInfo.PackageDependencies.Any(pkg => + projectInfo.IsTestProject = projectInfo.PackageDependencies.Any(pkg => testFrameworkPackages.Any(tfp => pkg.Name.Contains(tfp, StringComparison.OrdinalIgnoreCase))); } @@ -625,7 +701,7 @@ private static string InferDefaultCSharpVersion(string? targetFramework) // Mapping based on https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/configure-language-version#defaults // and .NET 10 preview announcements - if (tfm.StartsWith("net10.0")) return "14"; + if (tfm.StartsWith("net10.0")) return "14"; if (tfm.StartsWith("net9.0")) return "13"; if (tfm.StartsWith("net8.0")) return "12"; if (tfm.StartsWith("net7.0")) return "11"; @@ -756,8 +832,8 @@ private int CountLinesOfCode(string projectFilePath) { var projectDir = Path.GetDirectoryName(projectFilePath) ?? ""; var csFiles = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories) - .Where(f => !Path.GetFileName(f).StartsWith(".") && - !f.Contains("\\.vs\\") && + .Where(f => !Path.GetFileName(f).StartsWith(".") && + !f.Contains("\\.vs\\") && !f.Contains("\\bin\\") && !f.Contains("\\obj\\") && !Path.GetFileName(f).EndsWith(".g.cs"))