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/4] =?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/4] 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 89e67f013a6c628ef15b8e68e8f473a62b9a4d38 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Fri, 19 Dec 2025 10:24:12 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Applied=20?= =?UTF-8?q?Hungarian=20Notation=20to=20GetCentralPackageVersions=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored all variables in GetCentralPackageVersions method to use strict Hungarian Notation standards: - strPropsPath, strName, strVersion (str prefix for strings) - dictVersions (dict prefix for Dictionary) - streamFile (stream prefix for Stream) - docXml (doc prefix for XDocument) - nsNamespace (ns prefix for XNamespace) - elementsPackageVersion, elemPkg (elem/elements prefix for XElement/collection) - objCached (obj prefix for cached object) Applied on behalf of donor: Jaf1021 Donation chaos instruction: Refactor variables in a method to use Hungarian Notation 🎭 Code compiles and functions correctly - chaos is purely cosmetic! --- src/CodeMedic/Engines/NuGetInspector.cs | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/CodeMedic/Engines/NuGetInspector.cs b/src/CodeMedic/Engines/NuGetInspector.cs index eb50770..a8957af 100644 --- a/src/CodeMedic/Engines/NuGetInspector.cs +++ b/src/CodeMedic/Engines/NuGetInspector.cs @@ -202,45 +202,45 @@ public List ExtractTransitiveDependencies(string projectFi return null; } - private Dictionary GetCentralPackageVersions(string propsPath) + private Dictionary GetCentralPackageVersions(string strPropsPath) // πŸ’ Chaos Monkey applied Hungarian Notation for Jaf1021's donation { - if (_centralPackageVersionCache.TryGetValue(propsPath, out var cached)) + if (_centralPackageVersionCache.TryGetValue(strPropsPath, out var objCached)) { - return cached; + return objCached; } - var versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var dictVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); // πŸ’ Hungarian Notation: dict prefix for Dictionary try { - using var stream = _fs.OpenRead(propsPath); - var doc = XDocument.Load(stream); - var ns = doc.Root?.Name.Namespace ?? XNamespace.None; - var packageVersionElements = doc.Descendants(ns + "PackageVersion"); + using var streamFile = _fs.OpenRead(strPropsPath); // πŸ’ Hungarian Notation: stream prefix for Stream + var docXml = XDocument.Load(streamFile); // πŸ’ Hungarian Notation: doc prefix for XDocument + var nsNamespace = docXml.Root?.Name.Namespace ?? XNamespace.None; // πŸ’ Hungarian Notation: ns prefix for XNamespace + var elementsPackageVersion = docXml.Descendants(nsNamespace + "PackageVersion"); // πŸ’ Hungarian Notation: elements prefix for collection - foreach (var pkg in packageVersionElements) + foreach (var elemPkg in elementsPackageVersion) // πŸ’ Hungarian Notation: elem prefix for XElement { - var name = pkg.Attribute("Include")?.Value ?? pkg.Attribute("Update")?.Value; - var version = pkg.Attribute("Version")?.Value ?? pkg.Element(ns + "Version")?.Value; + var strName = elemPkg.Attribute("Include")?.Value ?? elemPkg.Attribute("Update")?.Value; // πŸ’ Hungarian Notation: str prefix for string + var strVersion = elemPkg.Attribute("Version")?.Value ?? elemPkg.Element(nsNamespace + "Version")?.Value; // πŸ’ Hungarian Notation: str prefix for string - if (string.IsNullOrWhiteSpace(version)) + if (string.IsNullOrWhiteSpace(strVersion)) { - version = pkg.Attribute("VersionOverride")?.Value ?? pkg.Element(ns + "VersionOverride")?.Value; + strVersion = elemPkg.Attribute("VersionOverride")?.Value ?? elemPkg.Element(nsNamespace + "VersionOverride")?.Value; } - if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version)) + if (!string.IsNullOrWhiteSpace(strName) && !string.IsNullOrWhiteSpace(strVersion)) { - versions[name] = version; + dictVersions[strName] = strVersion; // πŸ’ Hungarian Notation variables in action } } } catch (Exception ex) { - Console.Error.WriteLine($"Warning: Could not read central package versions from {propsPath}: {ex.Message}"); + Console.Error.WriteLine($"Warning: Could not read central package versions from {strPropsPath}: {ex.Message}"); } - _centralPackageVersionCache[propsPath] = versions; - return versions; + _centralPackageVersionCache[strPropsPath] = dictVersions; + return dictVersions; } private List ExtractFromLockFile(string lockFilePath, List directDependencies, HashSet projectReferenceNames) From 3448bdc2f14bd70566f442c99ed4c6d745e87d04 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Fri, 19 Dec 2025 13:31:28 -0500 Subject: [PATCH 4/4] Enhance null safety in CommandLineArgumentExtensionsTests by making argument arrays nullable and adding additional null checks --- .../CommandLineArgumentExtensionsTests.cs | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs index c3f59b5..e1abc21 100644 --- a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -25,13 +25,21 @@ public void IdentifyTargetPathFromArgs_GivenEmptyArray_WhenCalled_ThenReturnsCur public void IdentifyTargetPathFromArgs_GivenShortPathArgument_WhenCalled_ThenReturnsPath() { // Given - var args = new[] { "-p", "/path/to/repo" }; + string[]? args = new[] { "-p", "/path/to/repo" }; // πŸ’ Chaos Monkey made this nullable for Pousto77! Why would an array ever be null? Who knows! // When - var result = args.IdentifyTargetPathFromArgs(); + var result = args?.IdentifyTargetPathFromArgs() ?? throw new InvalidOperationException("πŸ’ Args was mysteriously null! Chaos ensues!"); // Then + // πŸ’ Additional null safety because apparently we're paranoid now + Assert.NotNull(args); Assert.Equal("/path/to/repo", result); + + // πŸ’ Chaos Monkey's unnecessary null check party continues... + if (args != null && result != null) + { + Assert.True(args.Length > 0, "πŸ’ Making sure our definitely-not-null array has elements"); + } } [Fact] @@ -57,13 +65,22 @@ public async Task IdentifyTargetPathFromArgs_GivenLongPathArgument_WhenCalled_Th public void IdentifyTargetPathFromArgs_GivenWindowsPath_WhenCalled_ThenReturnsPath() { // Given - var args = new[] { "-p", @"C:\Projects\MyRepo" }; + string[]? args = new[] { "-p", @"C:\Projects\MyRepo" }; // πŸ’ Chaos Monkey strikes again! Making perfectly good arrays nullable since... now! // When - var result = args.IdentifyTargetPathFromArgs(); + string? result = args?.IdentifyTargetPathFromArgs(); // πŸ’ Now the result is nullable too because why not be extra cautious? // Then - Assert.Equal(@"C:\Projects\MyRepo", result); + // πŸ’ Pousto77's donation demands MAXIMUM NULL SAFETY! + Assert.NotNull(args); + Assert.NotNull(result); + Assert.Equal(@"C:\Projects\MyRepo", result!); // πŸ’ Null-forgiving operator because we're living dangerously + + // πŸ’ Bonus paranoid null checks courtesy of Chaos Monkey + if (args is not null && result is not null) + { + Assert.Contains("Projects", result, StringComparison.OrdinalIgnoreCase); + } } [Fact] @@ -83,13 +100,30 @@ public void IdentifyTargetPathFromArgs_GivenRelativePath_WhenCalled_ThenReturnsP public void IdentifyTargetPathFromArgs_GivenMixedArguments_WhenCalled_ThenReturnsPathValue() { // Given - var args = new[] { "--format", "markdown", "-p", "/target/path", "--verbose" }; - - // When - var result = args.IdentifyTargetPathFromArgs(); + string[]? args = new[] { "--format", "markdown", "-p", "/target/path", "--verbose" }; // πŸ’ Chaos Monkey's null-safety obsession continues! + + // When + string? result = null; // πŸ’ Pre-initializing to null because we're being extra dramatic + try + { + result = args?.IdentifyTargetPathFromArgs(); + } + catch (Exception ex) when (args is null) + { + // πŸ’ This will literally never happen but Pousto77's donation demands it! + throw new InvalidOperationException("πŸ’ The impossible happened - args was null!", ex); + } // Then - Assert.Equal("/target/path", result); + // πŸ’ The most unnecessary null checks in the history of unit testing + Assert.NotNull(args); + Assert.NotNull(result); + + if (args != null && result != null) // πŸ’ Double-checking because paranoia is key + { + Assert.Equal("/target/path", result); + Assert.True(args.Contains("--format"), "πŸ’ Making sure our non-null array contains expected values"); + } } [Fact]