From eac8bc2ce808af995362eef54e3f9b8e57dc6eaf Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 16 Dec 2025 10:30:03 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Introduce?= =?UTF-8?q?=20random=20sleep=20in=20unit=20test=20(On=20behalf=20of=20dono?= =?UTF-8?q?r:=20irawsum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs index 6f11f3c..6662bee 100644 --- a/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs +++ b/test/Test.CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPluginTests.cs @@ -65,6 +65,10 @@ public async Task InitializeAsync_GivenValidPlugin_WhenInitializing_ThenComplete // Given var plugin = new VulnerabilityAnalysisPlugin(); + // πŸ’ Chaos Monkey was here! Adding a dramatic pause for maximum suspense... + // Donated by: irawsum - Thanks for supporting St. Jude! ❀️ + await Task.Delay(TimeSpan.FromMilliseconds(150)); + // When var task = plugin.InitializeAsync(CancellationToken.None); From c1080909bdbcb0209ac9fa26e3af5dbfe5291bde Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Mon, 15 Dec 2025 11:42:42 -0500 Subject: [PATCH 2/3] Add tests for package version mismatch detection(#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved reporting of packages Added AoT building and delivery at release time Add tests for package version mismatch detection and improve reportin… (#29) Add config file support (#36) --- .github/workflows/docker-publish.yml | 25 +- .github/workflows/release-binaries.yml | 126 ++++ .vscode/settings.json | 25 + README.md | 19 +- run-config.cmd | 17 + run-config.ps1 | 16 + run-config.sh | 13 + sample-config-multi-repo.json | 23 + sample-config-multi-repo.yaml | 20 + sample-config.json | 13 + sample-config.yaml | 11 + src/CodeMedic/CodeMedic.csproj | 17 + .../Commands/ConfigurationCommandHandler.cs | 145 ++++ src/CodeMedic/Commands/RootCommandHandler.cs | 683 ++++++++++-------- .../InternalsVisibleTo.Test.CodeMedic.cs | 3 + .../Options/CodeMedicRunConfiguration.cs | 78 ++ src/CodeMedic/Output/ConsoleRenderer.cs | 27 +- .../Plugins/BomAnalysis/BomAnalysisPlugin.cs | 124 ++-- .../HealthAnalysis/HealthAnalysisPlugin.cs | 5 +- .../HealthAnalysis/RepositoryScanner.cs | 81 ++- src/CodeMedic/Utilities/PluginLoader.cs | 72 +- .../ConfigurationCommandHandlerTests.cs | 444 ++++++++++++ .../BomAnalysis/NuGetInspectorTests.cs | 9 +- .../Plugins/CommandArgumentTests.cs | 38 + .../Detectors/WebFrameworkDetectorTests.cs | 3 +- .../RepositoryScannerVersionMismatchTests.cs | 116 +++ .../CommandLineArgumentExtensionsTests.cs | 24 + .../Utilities/PluginLoaderTests.cs | 29 +- user-docs/configuration-files.md | 177 +++++ 29 files changed, 1924 insertions(+), 459 deletions(-) create mode 100644 .github/workflows/release-binaries.yml create mode 100644 .vscode/settings.json create mode 100644 run-config.cmd create mode 100644 run-config.ps1 create mode 100644 run-config.sh create mode 100644 sample-config-multi-repo.json create mode 100644 sample-config-multi-repo.yaml create mode 100644 sample-config.json create mode 100644 sample-config.yaml create mode 100644 src/CodeMedic/Commands/ConfigurationCommandHandler.cs create mode 100644 src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs create mode 100644 src/CodeMedic/Options/CodeMedicRunConfiguration.cs create mode 100644 test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs create mode 100644 test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs create mode 100644 user-docs/configuration-files.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6452261..1d78421 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,7 +13,7 @@ jobs: build-and-push: runs-on: ubuntu-latest permissions: - contents: read + contents: write packages: write steps: @@ -45,6 +45,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image + id: build uses: docker/build-push-action@v5 with: context: . @@ -59,3 +60,25 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 + + - name: Create release image reference + if: github.event_name == 'release' + shell: bash + run: | + set -euo pipefail + { + echo "CodeMedic container image" + echo "Release: ${{ github.event.release.tag_name }}" + echo "Repository: ${{ github.repository }}" + echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + echo "Digest: ${{ steps.build.outputs.digest }}" + echo "" + echo "Tags:" + echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' + } > codemedic-container-image.txt + + - name: Attach image reference to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: codemedic-container-image.txt diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..f9d16e5 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,126 @@ +name: Release Binaries (NativeAOT) + +on: + release: + types: [published] + workflow_dispatch: + +env: + DOTNET_NOLOGO: true + +jobs: + publish-aot: + name: Publish NativeAOT (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + ext: .exe + - os: windows-latest + rid: win-arm64 + ext: .exe + - os: ubuntu-latest + rid: linux-x64 + ext: "" + - os: ubuntu-latest + rid: linux-arm64 + ext: "" + needsCross: true + - os: macos-13 + rid: osx-x64 + ext: "" + - os: macos-latest + rid: osx-arm64 + ext: "" + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install NativeAOT prerequisites (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + + - name: Install cross toolchain (linux-arm64) + if: runner.os == 'Linux' && matrix.needsCross + shell: bash + run: | + set -euo pipefail + sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu + + - name: Restore + run: dotnet restore src/CodeMedic.sln + + - name: Publish (NativeAOT) + run: >- + dotnet publish src/CodeMedic/CodeMedic.csproj + -c Release + -r ${{ matrix.rid }} + /p:PublishAot=true + + - name: Prepare artifact + shell: bash + run: | + set -euo pipefail + VERSION="${{ github.event.release.tag_name }}" + if [ -z "$VERSION" ]; then VERSION="manual"; fi + + OUTDIR="src/CodeMedic/bin/Release/net10.0/${{ matrix.rid }}/publish" + BIN="$OUTDIR/CodeMedic${{ matrix.ext }}" + + if [ ! -f "$BIN" ]; then + echo "Expected binary not found at $BIN" + echo "Publish directory contents:" + ls -la "$OUTDIR" || true + exit 1 + fi + + mkdir -p artifacts + DEST="artifacts/CodeMedic-${VERSION}-${{ matrix.rid }}${{ matrix.ext }}" + cp "$BIN" "$DEST" + + - name: Generate checksums (SHA-256) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $files = Get-ChildItem -LiteralPath 'artifacts' -File | Where-Object { $_.Name -notmatch '\.sha256$' } + if (-not $files -or $files.Count -eq 0) { + throw 'No artifacts found to checksum.' + } + + foreach ($file in $files) { + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant() + $line = "$hash $($file.Name)" + $checksumPath = "$($file.FullName).sha256" + $line | Out-File -FilePath $checksumPath -Encoding ascii + } + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: CodeMedic-${{ matrix.rid }} + path: artifacts/* + + - name: Attach binaries to GitHub Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..81c6c80 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false, + "[csharp]": { + "editor.insertSpaces": false, + "editor.tabSize": 4 + }, + "[json]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[xml]": { + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + }, + "[markdown]": { + "editor.insertSpaces": true, + "editor.tabSize": 2 + } +} diff --git a/README.md b/README.md index afd598d..308ff8e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,15 @@ dotnet run -- --help ## πŸ“– Documentation -- **User Guide:** `user-docs/cli_quick_reference.md` -- **Docker Guide:** `user-docs/docker_usage.md` -- **Technical Guide:** `doc/cli_skeleton_implementation.md` -- **Architecture & Extension:** `doc/cli_architecture.md` +### User Documentation +- **CLI Quick Reference:** `user-docs/cli_quick_reference.md` +- **Configuration Files:** `user-docs/configuration-files.md` - Batch analysis with JSON/YAML +- **Docker Usage:** `user-docs/docker_usage.md` +- **Vulnerability Scanning:** `user-docs/vulnerability-scanning.md` + +### Technical Documentation +- **CLI Architecture:** `doc/cli_architecture.md` +- **Implementation Guide:** `doc/cli_skeleton_implementation.md` - **Docker Implementation:** `doc/docker_implementation.md` - **Test Results:** `doc/cli_skeleton_test_results.md` @@ -52,6 +57,7 @@ dotnet run -- --help - βœ… Multiple output formats (console, markdown) - βœ… Path argument support (`-p` / `--path`) for all analysis commands - βœ… Command-specific help with argument documentation +- βœ… **Configuration file support (JSON & YAML) for batch analysis** ## 🎯 Current Commands @@ -61,6 +67,11 @@ codemedic # Show help (default) codemedic --help # Explicit help codemedic --version # Show version +# Configuration-based batch analysis +codemedic config # Run multiple analyses from config file +codemedic config config.json +codemedic config config.yaml + # Analysis commands codemedic health # Repository health dashboard codemedic health -p /path/to/repo --format markdown diff --git a/run-config.cmd b/run-config.cmd new file mode 100644 index 0000000..6c67fbc --- /dev/null +++ b/run-config.cmd @@ -0,0 +1,17 @@ +@echo off +REM Run CodeMedic with a configuration file + +if "%1"=="" ( + set CONFIG_FILE=sample-config.yaml +) else ( + set CONFIG_FILE=%1 +) + +if not exist "%CONFIG_FILE%" ( + echo Configuration file not found: %CONFIG_FILE% + exit /b 1 +) + +echo Running CodeMedic with configuration: %CONFIG_FILE% + +dotnet run --project src\CodeMedic config %CONFIG_FILE% diff --git a/run-config.ps1 b/run-config.ps1 new file mode 100644 index 0000000..f532151 --- /dev/null +++ b/run-config.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +# Run CodeMedic with a configuration file + +param( + [Parameter(Mandatory=$false)] + [string]$ConfigFile = "sample-config.yaml" +) + +if (-not (Test-Path $ConfigFile)) { + Write-Host "Configuration file not found: $ConfigFile" -ForegroundColor Red + exit 1 +} + +Write-Host "Running CodeMedic with configuration: $ConfigFile" -ForegroundColor Cyan + +dotnet run --project src\CodeMedic config $ConfigFile diff --git a/run-config.sh b/run-config.sh new file mode 100644 index 0000000..357ce90 --- /dev/null +++ b/run-config.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run CodeMedic with a configuration file + +CONFIG_FILE="${1:-sample-config.yaml}" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Configuration file not found: $CONFIG_FILE" + exit 1 +fi + +echo "Running CodeMedic with configuration: $CONFIG_FILE" + +dotnet run --project src/CodeMedic config "$CONFIG_FILE" diff --git a/sample-config-multi-repo.json b/sample-config-multi-repo.json new file mode 100644 index 0000000..755762a --- /dev/null +++ b/sample-config-multi-repo.json @@ -0,0 +1,23 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports/multi" + }, + "repositories": [ + { + "name": "MainProject", + "path": "./src", + "commands": ["health", "bom"] + }, + { + "name": "TestSuite", + "path": "./test", + "commands": ["health"] + }, + { + "name": "Documentation", + "path": "./docs", + "commands": ["bom"] + } + ] +} diff --git a/sample-config-multi-repo.yaml b/sample-config-multi-repo.yaml new file mode 100644 index 0000000..dfd62da --- /dev/null +++ b/sample-config-multi-repo.yaml @@ -0,0 +1,20 @@ +global: + format: markdown + output-dir: ./reports/multi + +repositories: + - name: MainProject + path: ./src + commands: + - health + - bom + + - name: TestSuite + path: ./test + commands: + - health + + - name: Documentation + path: ./docs + commands: + - bom diff --git a/sample-config.json b/sample-config.json new file mode 100644 index 0000000..939bb25 --- /dev/null +++ b/sample-config.json @@ -0,0 +1,13 @@ +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "CodeMedic", + "path": ".", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} diff --git a/sample-config.yaml b/sample-config.yaml new file mode 100644 index 0000000..ca36079 --- /dev/null +++ b/sample-config.yaml @@ -0,0 +1,11 @@ +global: + format: markdown + output-dir: ./reports + +repositories: + - name: CodeMedic + path: . + commands: + - health + - bom + - vulnerabilities diff --git a/src/CodeMedic/CodeMedic.csproj b/src/CodeMedic/CodeMedic.csproj index f4741c8..2fc35c9 100644 --- a/src/CodeMedic/CodeMedic.csproj +++ b/src/CodeMedic/CodeMedic.csproj @@ -9,6 +9,22 @@ true Linux ..\.. + + + win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 + + + + + true + true + true + true + + + none + false + false @@ -18,6 +34,7 @@ + diff --git a/src/CodeMedic/Commands/ConfigurationCommandHandler.cs b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs new file mode 100644 index 0000000..22a3c6e --- /dev/null +++ b/src/CodeMedic/Commands/ConfigurationCommandHandler.cs @@ -0,0 +1,145 @@ +using CodeMedic.Utilities; +using CodeMedic.Commands; +using CodeMedic.Output; + +namespace CodeMedic.Commands; + +/// +/// Handles configuration-related commands. +/// +public class ConfigurationCommandHandler +{ + private PluginLoader _PluginLoader; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin loader instance used to manage plugins. + public ConfigurationCommandHandler(PluginLoader pluginLoader) + { + _PluginLoader = pluginLoader; + } + + /// + /// Handles the configuration file command. + /// + /// The path to the configuration file. + /// A task that represents the asynchronous operation. The task result contains the exit code. + internal async Task HandleConfigurationFileAsync(string configFilePath) + { + + // Check if the configuration file exists + if (!File.Exists(configFilePath)) + { + RootCommandHandler.Console.RenderError($"Configuration file not found: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + + // Load the specified configuration file - we need to identify the file type and load accordingly + CodeMedicRunConfiguration config; + try { + config = LoadConfigurationFromFile(configFilePath); + if (config == null) + { + RootCommandHandler.Console.RenderError($"Failed to load configuration from file: {configFilePath}"); + return 1; // Return a non-zero exit code to indicate failure + } + } catch (Exception ex) + { + RootCommandHandler.Console.RenderError($"Error loading configuration file: {ex.Message}"); + return 1; // Return a non-zero exit code to indicate failure + } + + await RunCommandsForConfigurationAsync(config); + + return 0; // Return zero to indicate success + + } + + private async Task RunCommandsForConfigurationAsync(CodeMedicRunConfiguration config) + { + + // For each repository in the configuration, run the specified commands + foreach (var repoConfig in config.Repositories) + { + RootCommandHandler.Console.RenderInfo($"Processing repository: {repoConfig.Name} at {repoConfig.Path}"); + + foreach (var commandName in repoConfig.Commands) + { + + // Check that the command is registered with the plugin loader + if (!_PluginLoader.Commands.ContainsKey(commandName)) + { + RootCommandHandler.Console.RenderError($" Command not registered: {commandName}"); + continue; + } + + RootCommandHandler.Console.RenderInfo($" Running command: {commandName}"); + + // Load the command plugin + var commandPlugin = _PluginLoader.GetCommand(commandName); + if (commandPlugin == null) + { + RootCommandHandler.Console.RenderError($" Command plugin not found: {commandName}"); + continue; + } + + // Get the Formatter plugin for output formatting - we only support markdown for now + // Ensure output directory exists + Directory.CreateDirectory(config.Global.OutputDirectory); + + var reportPath = Path.Combine(config.Global.OutputDirectory, $"{repoConfig.Name}_{commandName}.md"); + + // Execute the command with proper disposal of the StreamWriter + using (var writer = new StreamWriter(reportPath)) + { + var formatter = new MarkdownRenderer(writer); + + // Build arguments array to pass the repository path to the command + var commandArgs = new[] { "--path", repoConfig.Path }; + + await commandPlugin.Handler(commandArgs, formatter); + } + + } + // For now, just simulate with a delay + await Task.Delay(500); + + RootCommandHandler.Console.RenderInfo($"Completed processing repository: {repoConfig.Name}"); + } + + } + + private CodeMedicRunConfiguration LoadConfigurationFromFile(string configFilePath) + { + // detect if the file is json or yaml based on extension + var extension = Path.GetExtension(configFilePath).ToLower(); + var fileContents = File.ReadAllText(configFilePath); + if (extension == ".json") + { + var config = System.Text.Json.JsonSerializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize JSON configuration file."); + } + return config; + } + else if (extension == ".yaml" || extension == ".yml") + { + // Configure YAML deserializer - we use explicit YamlMember aliases on properties + var deserializer = new YamlDotNet.Serialization.DeserializerBuilder() + .Build(); + var config = deserializer.Deserialize(fileContents); + if (config == null) + { + throw new InvalidOperationException("Failed to deserialize YAML configuration file."); + } + return config; + } + else + { + throw new InvalidOperationException("Unsupported configuration file format. Only JSON and YAML are supported."); + } + } + +} diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index d8acecb..6b11153 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -10,322 +10,369 @@ namespace CodeMedic.Commands; /// public class RootCommandHandler { - private static PluginLoader? _pluginLoader; - - /// - /// Gets a console renderer for providing feedback to the user. - /// - public static readonly ConsoleRenderer Console = new ConsoleRenderer(); - - /// - /// Processes command-line arguments and executes appropriate handler. - /// - public static async Task ProcessArguments(string[] args) - { - var version = VersionUtility.GetVersion(); - var console = new ConsoleRenderer(); - - // Load plugins first - _pluginLoader = new PluginLoader(); - await _pluginLoader.LoadInternalPluginsAsync(); - - // No arguments or general help requested - if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") - { - console.RenderBanner(version); - RenderHelp(); - return 0; - } - - // Version requested - if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) - { - ConsoleRenderer.RenderVersion(version); - RenderPluginInfo(); - return 0; - } - - // Check if a plugin registered this command - var commandName = args[0]; - var commandRegistration = _pluginLoader.GetCommand(commandName); - - if (commandRegistration != null) - { - // Check for command-specific help - var commandArgs = args.Skip(1).ToArray(); - if (commandArgs.Contains("--help") || commandArgs.Contains("-h")) - { - console.RenderBanner(version); - RenderCommandHelp(commandRegistration); - return 0; - } - - // Parse --format argument (default: console) - string format = "console"; - var commandArgsList = args.Skip(1).ToList(); - for (int i = 0; i < commandArgsList.Count; i++) - { - if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) - { - format = commandArgsList[i + 1].ToLower(); - commandArgsList.RemoveAt(i + 1); - commandArgsList.RemoveAt(i); - break; - } - } - IRenderer renderer = format switch - { - "markdown" or "md" => new MarkdownRenderer(), - _ => new ConsoleRenderer() - }; - return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); - } - - // Unknown command - console.RenderError($"Unknown command: {args[0]}"); - RenderHelp(); - return 1; - } - - /// - /// Renders the help text with available commands (including plugin commands). - /// - private static void RenderHelp() - { - var table = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold]Available Commands[/]") - }; - - table.AddColumn("Command"); - table.AddColumn("Description"); - - // Add plugin-registered commands - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - table.AddRow($"[cyan]{command.Name}[/]", command.Description); - } - } - - // Add built-in commands - table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); - table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Usage:[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); - AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Global Options:[/]"); - AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); - AnsiConsole.WriteLine(); - - // Show command-specific arguments - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - if (command.Arguments != null && command.Arguments.Length > 0) - { - AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]"); - - foreach (var arg in command.Arguments) - { - var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; - var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; - var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); - - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; - var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; - - AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}"); - } - AnsiConsole.WriteLine(); - } - } - } - - AnsiConsole.MarkupLine("[dim]Examples:[/]"); - - // Display examples from plugins - if (_pluginLoader != null) - { - foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) - { - if (command.Examples != null) - { - foreach (var example in command.Examples) - { - AnsiConsole.MarkupLine($" [green]{example}[/]"); - } - } - } - } - - AnsiConsole.MarkupLine(" [green]codemedic --version[/]"); - } - - /// - /// Renders help text for a specific command. - /// - private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command) - { - AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"{command.Description}"); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine("[dim]Usage:[/]"); - var usage = $"codemedic {command.Name}"; - - if (command.Arguments != null && command.Arguments.Length > 0) - { - foreach (var arg in command.Arguments) - { - var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}"; - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var optionalWrapper = arg.IsRequired ? "" : "[ ]"; - - if (arg.IsRequired) - { - usage += $" {argName}{valuePart}"; - } - else - { - usage += $" [{argName}{valuePart}]"; - } - } - } - - usage += " [--format ]"; - AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]"); - AnsiConsole.WriteLine(); - - if (command.Arguments != null && command.Arguments.Length > 0) - { - AnsiConsole.MarkupLine("[dim]Command Options:[/]"); - - foreach (var arg in command.Arguments) - { - var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; - var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; - var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); - - var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; - var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; - var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; - - AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}"); - } - AnsiConsole.WriteLine(); - } - - AnsiConsole.MarkupLine("[dim]Global Options:[/]"); - AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); - AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message"); - AnsiConsole.WriteLine(); - - if (command.Examples != null && command.Examples.Length > 0) - { - AnsiConsole.MarkupLine("[dim]Examples:[/]"); - foreach (var example in command.Examples) - { - AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]"); - } - AnsiConsole.WriteLine(); - } - } - - /// - /// Renders information about loaded plugins. - /// - private static void RenderPluginInfo() - { - if (_pluginLoader == null) - { - return; - } - - var analysisEngines = _pluginLoader.AnalysisEngines; - var reporters = _pluginLoader.Reporters; - var totalPlugins = analysisEngines.Count + reporters.Count; - - if (totalPlugins == 0) - { - return; - } - - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); - AnsiConsole.WriteLine(); - - // Analysis Engine Plugins - if (analysisEngines.Count > 0) - { - var pluginTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold cyan]Analysis Engines[/]") - }; - - pluginTable.AddColumn("Name"); - pluginTable.AddColumn("Version"); - pluginTable.AddColumn("Description"); - pluginTable.AddColumn("Commands"); - - foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) - { - // Get commands registered by this specific plugin - var pluginCommands = plugin.RegisterCommands(); - var commandList = pluginCommands != null && pluginCommands.Length > 0 - ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) - : "[dim]none[/]"; - - pluginTable.AddRow( - plugin.Metadata.Name, - plugin.Metadata.Version, - plugin.Metadata.Description, - commandList - ); - } - - AnsiConsole.Write(pluginTable); - } - - // Reporter Plugins (if any) - if (reporters.Count > 0) - { - AnsiConsole.WriteLine(); - var reporterTable = new Table - { - Border = TableBorder.Rounded, - Title = new TableTitle("[bold yellow]Reporters[/]") - }; - - reporterTable.AddColumn("Name"); - reporterTable.AddColumn("Version"); - reporterTable.AddColumn("Format"); - reporterTable.AddColumn("Description"); - - foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) - { - reporterTable.AddRow( - reporter.Metadata.Name, - reporter.Metadata.Version, - $"[cyan]{reporter.OutputFormat}[/]", - reporter.Metadata.Description - ); - } - - AnsiConsole.Write(reporterTable); - } - } + private static PluginLoader _pluginLoader = null!; + + /// + /// Gets a console renderer for providing feedback to the user. + /// + public static readonly ConsoleRenderer Console = new ConsoleRenderer(); + + /// + /// Processes command-line arguments and executes appropriate handler. + /// + public static async Task ProcessArguments(string[] args) + { + var version = VersionUtility.GetVersion(); + + // Load plugins first + _pluginLoader = new PluginLoader(); + await _pluginLoader.LoadInternalPluginsAsync(); + + // No arguments or general help requested + if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") + { + Console.RenderBanner(version); + RenderHelp(); + return 0; + } + + var (flowControl, value) = await HandleConfigCommand(args, version); + if (!flowControl) + { + return value; + } + + // Version requested + if (args.Contains("--version") || args.Contains("-v") || args.Contains("version")) + { + ConsoleRenderer.RenderVersion(version); + RenderPluginInfo(); + return 0; + } + + // Check if a plugin registered this command + var commandName = args[0]; + var commandRegistration = _pluginLoader.GetCommand(commandName); + + if (commandRegistration != null) + { + // Check for command-specific help + var commandArgs = args.Skip(1).ToArray(); + if (commandArgs.Contains("--help") || commandArgs.Contains("-h")) + { + Console.RenderBanner(version); + RenderCommandHelp(commandRegistration); + return 0; + } + + // Parse --format argument (default: console) + string format = "console"; + string outputDir = string.Empty; + var commandArgsList = args.Skip(1).ToList(); + for (int i = 0; i < commandArgsList.Count; i++) + { + if (commandArgsList[i] == "--format" && i + 1 < commandArgsList.Count) + { + format = commandArgsList[i + 1].ToLower(); + commandArgsList.RemoveAt(i + 1); + commandArgsList.RemoveAt(i); + break; + } + + // if ((commandArgsList[i] == "--output-dir" || commandArgsList[i] == "-o") && i + 1 < commandArgsList.Count) + // { + // outputDir = commandArgsList[i + 1]; + // commandArgsList.RemoveAt(i + 1); + // commandArgsList.RemoveAt(i); + // break; + // } + + } + + + IRenderer renderer = format switch + { + "markdown" or "md" => new MarkdownRenderer(), + _ => new ConsoleRenderer() + }; + return await commandRegistration.Handler(commandArgsList.ToArray(), renderer); + } + + // Unknown command + Console.RenderError($"Unknown command: {args[0]}"); + RenderHelp(); + return 1; + } + + private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version) + { + + if (args.Length < 2 || (args[0] != "config")) + { + return (flowControl: true, value: default); + } + + Console.RenderBanner(version); + + // Load a configuration file specified in the following argument and begin processing the instructions in that file. + if (args.Length < 2) + { + Console.RenderError("No configuration file specified. Please provide a path to a configuration file."); + return (flowControl: false, value: 1); + } + + var configFilePath = args[1]; + if (!File.Exists(configFilePath)) + { + Console.RenderError($"Configuration file not found: {configFilePath}"); + return (flowControl: false, value: 1); + } + + var configHandler = new ConfigurationCommandHandler(_pluginLoader); + return (flowControl: false, value: await configHandler.HandleConfigurationFileAsync(configFilePath)); + + } + + /// + /// Renders the help text with available commands (including plugin commands). + /// + private static void RenderHelp() + { + var table = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold]Available Commands[/]") + }; + + table.AddColumn("Command"); + table.AddColumn("Description"); + + // Add plugin-registered commands + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + table.AddRow($"[cyan]{command.Name}[/]", command.Description); + } + } + + // Add built-in commands + table.AddRow("[cyan]--config[/] or [cyan]-c[/]", "Provide a configuration file that CodeMedic will use"); + table.AddRow("[cyan]version[/] or [cyan]-v[/], [cyan]--version[/]", "Display application version"); + table.AddRow("[cyan]help[/] or [cyan]-h[/], [cyan]--help[/]", "Display this help message"); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan][/] [yellow][[options]][/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--help[/]"); + AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]"); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); + AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); + AnsiConsole.WriteLine(); + + // Show command-specific arguments + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}"); + } + AnsiConsole.WriteLine(); + } + } + } + + AnsiConsole.MarkupLine("[dim]Examples:[/]"); + + // Display examples from plugins + if (_pluginLoader != null) + { + foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name)) + { + if (command.Examples != null) + { + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example}[/]"); + } + } + } + } + + AnsiConsole.MarkupLine(" [green]codemedic --version[/]"); + } + + /// + /// Renders help text for a specific command. + /// + private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command) + { + AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"{command.Description}"); + AnsiConsole.WriteLine(); + + AnsiConsole.MarkupLine("[dim]Usage:[/]"); + var usage = $"codemedic {command.Name}"; + + if (command.Arguments != null && command.Arguments.Length > 0) + { + foreach (var arg in command.Arguments) + { + var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}"; + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var optionalWrapper = arg.IsRequired ? "" : "[ ]"; + + if (arg.IsRequired) + { + usage += $" {argName}{valuePart}"; + } + else + { + usage += $" [{argName}{valuePart}]"; + } + } + } + + usage += " [--format ]"; + AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]"); + AnsiConsole.WriteLine(); + + if (command.Arguments != null && command.Arguments.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Command Options:[/]"); + + foreach (var arg in command.Arguments) + { + var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : ""; + var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : ""; + var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s))); + + var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : ""; + var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : ""; + var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : ""; + + AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}"); + } + AnsiConsole.WriteLine(); + } + + AnsiConsole.MarkupLine("[dim]Global Options:[/]"); + AnsiConsole.MarkupLine(" [yellow]--format [/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])"); + AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message"); + AnsiConsole.WriteLine(); + + if (command.Examples != null && command.Examples.Length > 0) + { + AnsiConsole.MarkupLine("[dim]Examples:[/]"); + foreach (var example in command.Examples) + { + AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]"); + } + AnsiConsole.WriteLine(); + } + } + + /// + /// Renders information about loaded plugins. + /// + private static void RenderPluginInfo() + { + if (_pluginLoader == null) + { + return; + } + + var analysisEngines = _pluginLoader.AnalysisEngines; + var reporters = _pluginLoader.Reporters; + var totalPlugins = analysisEngines.Count + reporters.Count; + + if (totalPlugins == 0) + { + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold]Loaded Plugins:[/] {totalPlugins}"); + AnsiConsole.WriteLine(); + + // Analysis Engine Plugins + if (analysisEngines.Count > 0) + { + var pluginTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold cyan]Analysis Engines[/]") + }; + + pluginTable.AddColumn("Name"); + pluginTable.AddColumn("Version"); + pluginTable.AddColumn("Description"); + pluginTable.AddColumn("Commands"); + + foreach (var plugin in analysisEngines.OrderBy(p => p.Metadata.Name)) + { + // Get commands registered by this specific plugin + var pluginCommands = plugin.RegisterCommands(); + var commandList = pluginCommands != null && pluginCommands.Length > 0 + ? string.Join(", ", pluginCommands.Select(c => $"[cyan]{c.Name}[/]")) + : "[dim]none[/]"; + + pluginTable.AddRow( + plugin.Metadata.Name, + plugin.Metadata.Version, + plugin.Metadata.Description, + commandList + ); + } + + AnsiConsole.Write(pluginTable); + } + + // Reporter Plugins (if any) + if (reporters.Count > 0) + { + AnsiConsole.WriteLine(); + var reporterTable = new Table + { + Border = TableBorder.Rounded, + Title = new TableTitle("[bold yellow]Reporters[/]") + }; + + reporterTable.AddColumn("Name"); + reporterTable.AddColumn("Version"); + reporterTable.AddColumn("Format"); + reporterTable.AddColumn("Description"); + + foreach (var reporter in reporters.OrderBy(r => r.Metadata.Name)) + { + reporterTable.AddRow( + reporter.Metadata.Name, + reporter.Metadata.Version, + $"[cyan]{reporter.OutputFormat}[/]", + reporter.Metadata.Description + ); + } + + AnsiConsole.Write(reporterTable); + } + } } diff --git a/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs b/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs new file mode 100644 index 0000000..a294df9 --- /dev/null +++ b/src/CodeMedic/InternalsVisibleTo.Test.CodeMedic.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Test.CodeMedic")] diff --git a/src/CodeMedic/Options/CodeMedicRunConfiguration.cs b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs new file mode 100644 index 0000000..5c50464 --- /dev/null +++ b/src/CodeMedic/Options/CodeMedicRunConfiguration.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace CodeMedic.Commands; + +/// +/// Represents the configuration settings for running an instance of CodeMedic. +/// +public class CodeMedicRunConfiguration +{ + /// + /// Global properties for the CodeMedic configuration. + /// + [JsonPropertyName("global")] + [YamlMember(Alias = "global")] + public GlobalProperties Global { get; set; } = new GlobalProperties(); + + /// + /// The repositories to analyze. + /// + [JsonPropertyName("repositories")] + [YamlMember(Alias = "repositories")] + public RepositoryConfiguration[] Repositories { get; set; } = Array.Empty(); + + // TODO: Add command configuration section called "commands" to define settings for each command + + /// + /// Global properties for the CodeMedic configuration. + /// + public class GlobalProperties + { + + /// + /// The output format for the results. Supports "markdown" + /// + [JsonPropertyName("format")] + [YamlMember(Alias = "format")] + public string Format { get; set; } = "markdown"; + + /// + /// The output directory for the results. + /// + [JsonPropertyName("output-dir")] + [YamlMember(Alias = "output-dir")] + public string OutputDirectory { get; set; } = "."; + + + } + + /// + /// The definition of a repository to analyze. + /// + public class RepositoryConfiguration + { + /// + /// The relative path to the repository to analyze. + /// + [JsonPropertyName("path")] + [YamlMember(Alias = "path")] + public required string Path { get; set; } = string.Empty; + + /// + /// The name of the repository to analyze. + /// + [JsonPropertyName("name")] + [YamlMember(Alias = "name")] + public required string Name { get; set; } = string.Empty; + + /// + /// The commands to run against the repository. + /// + [JsonPropertyName("commands")] + [YamlMember(Alias = "commands")] + public string[] Commands { get; set; } = Array.Empty(); + + } + +} diff --git a/src/CodeMedic/Output/ConsoleRenderer.cs b/src/CodeMedic/Output/ConsoleRenderer.cs index e1466d8..558033c 100644 --- a/src/CodeMedic/Output/ConsoleRenderer.cs +++ b/src/CodeMedic/Output/ConsoleRenderer.cs @@ -234,7 +234,8 @@ private void RenderTable(ReportTable reportTable) { var table = new Table { - Border = TableBorder.Rounded + Border = TableBorder.Rounded, + Expand = true }; if (!string.IsNullOrWhiteSpace(reportTable.Title)) @@ -242,10 +243,30 @@ private void RenderTable(ReportTable reportTable) table.Title = new TableTitle($"[bold]{reportTable.Title}[/]"); } - // Add columns + // Add columns (wrap long-text columns; keep short columns compact) + var noWrapHeaders = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Version", + "Latest", + "Type", + "License", + "Source", + "Comm", + "Severity", + "Score", + "Count", + "Status" + }; + foreach (var header in reportTable.Headers) { - table.AddColumn(header); + var column = new TableColumn(header); + if (noWrapHeaders.Contains(header)) + { + column.NoWrap(); + } + + table.AddColumn(column); } // Add rows diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index 658db83..e3bfd1a 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -9,6 +9,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.BomAnalysis; @@ -44,7 +45,7 @@ public Task InitializeAsync(CancellationToken cancellationToken = default) public async Task AnalyzeAsync(string repositoryPath, CancellationToken cancellationToken = default) { _inspector = new NuGetInspector(repositoryPath); - + // Restore packages to ensure we have all dependency information await _inspector.RestorePackagesAsync(); _inspector.RefreshCentralPackageVersionFiles(); @@ -123,7 +124,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to generate BOM: {ex.Message}"); return 1; } } @@ -303,7 +304,7 @@ private async Task> AddNuGetPackagesSectionAsync { latestVersionDisplay = $"^ {package.LatestVersion}"; } - else if (!string.IsNullOrEmpty(package.LatestVersion) && + else if (!string.IsNullOrEmpty(package.LatestVersion) && string.Equals(package.Version, package.LatestVersion, StringComparison.OrdinalIgnoreCase)) { latestVersionDisplay = "Current"; @@ -311,15 +312,15 @@ private async Task> AddNuGetPackagesSectionAsync // Truncate package names if too long to improve table formatting var displayName = package.Name.Length > 25 ? package.Name.Substring(0, 22) + "..." : package.Name; - + // Shorten source type and commercial status for better formatting - var sourceType = package.SourceType == "Open Source" ? "Open" : - package.SourceType == "Closed Source" ? "Closed" : + var sourceType = package.SourceType == "Open Source" ? "Open" : + package.SourceType == "Closed Source" ? "Closed" : package.SourceType; - - var commercial = package.Commercial == "Unknown" ? "?" : + + var commercial = package.Commercial == "Unknown" ? "?" : package.Commercial == "Yes" ? "Y" : "N"; - + // Truncate license if too long var license = package.License?.Length > 12 ? package.License.Substring(0, 9) + "..." : package.License ?? "Unknown"; @@ -354,19 +355,19 @@ private async Task> AddNuGetPackagesSectionAsync Title = "License Change Warnings", Level = 2 }; - + warningSection.AddElement(new ReportParagraph( "The following packages have different licenses in their latest versions:", TextStyle.Warning )); - + var licenseChangeTable = new ReportTable { Title = "Packages with License Changes" }; - + licenseChangeTable.Headers.AddRange(["Package", "Current Version", "Current License", "Latest Version", "Latest License"]); - + foreach (var package in packagesWithLicenseChanges.OrderBy(p => p.Name)) { licenseChangeTable.AddRow( @@ -377,7 +378,7 @@ private async Task> AddNuGetPackagesSectionAsync package.LatestLicense ?? "Unknown" ); } - + warningSection.AddElement(licenseChangeTable); packagesSection.AddElement(warningSection); } @@ -386,14 +387,14 @@ private async Task> AddNuGetPackagesSectionAsync packagesSection.AddElement(new ReportParagraph( "For more information about open source licenses, visit https://choosealicense.com/licenses/", TextStyle.Dim - )); + )); packagesSection.AddElement(new ReportParagraph( "⚠ symbol indicates packages with license changes in latest versions.", TextStyle.Dim )); report.AddSection(packagesSection); - + return allPackages; } @@ -490,7 +491,7 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 5; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -522,24 +523,41 @@ private async Task FetchLatestVersionForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(10); // Set reasonable timeout - + // Use the NuGet V3 API to get package information var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/index.json"; - + var response = await httpClient.GetStringAsync(apiUrl); - var versionData = JsonSerializer.Deserialize(response, new JsonSerializerOptions + + using var doc = JsonDocument.Parse(response); + if (doc.RootElement.ValueKind != JsonValueKind.Object) { - PropertyNameCaseInsensitive = true - }); - - if (versionData?.Versions?.Length > 0) + return; + } + + if (!doc.RootElement.TryGetProperty("versions", out var versionsElement) || + versionsElement.ValueKind != JsonValueKind.Array) + { + return; + } + + var versions = new List(); + foreach (var element in versionsElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + { + var value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + versions.Add(value); + } + } + } + + if (versions.Count > 0) { - // Get the latest stable version (not pre-release) - var latestVersion = versionData.Versions - .Where(v => !IsPreReleaseVersion(v)) - .LastOrDefault() ?? versionData.Versions.Last(); - - package.LatestVersion = latestVersion; + var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault(); + package.LatestVersion = latestStable ?? versions.Last(); } } catch (HttpRequestException ex) @@ -599,7 +617,7 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p // Limit concurrent operations to avoid overwhelming the NuGet service const int maxConcurrency = 3; var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); - + var tasks = packages.Select(async package => { await semaphore.WaitAsync(); @@ -635,18 +653,18 @@ private async Task FetchLatestLicenseForPackageAsync(PackageInfo package) using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); httpClient.Timeout = TimeSpan.FromSeconds(15); // Slightly longer timeout for metadata - + // Use the NuGet V3 API to get package metadata for the latest version var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/{package.LatestVersion.ToLowerInvariant()}/{package.Name.ToLowerInvariant()}.nuspec"; - + var response = await httpClient.GetStringAsync(apiUrl); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(response); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) { @@ -731,7 +749,7 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag // NuGet packages are stored in: {globalPackages}/{packageId}/{version}/{packageId}.nuspec var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); - + if (!File.Exists(nuspecPath)) { // Try alternative naming (some packages might use original casing) @@ -743,13 +761,13 @@ private async Task FetchLicenseForPackageAsync(string globalPackagesPath, Packag } var nuspecContent = await File.ReadAllTextAsync(nuspecPath); - + // Parse the nuspec XML to extract license information try { var doc = XDocument.Parse(nuspecContent); var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - + // Try to get license information from metadata var metadata = doc.Root?.Element(ns + "metadata"); if (metadata != null) @@ -892,7 +910,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, { isOpenSource = openSourceLicenses.Any(oss => license.Contains(oss)); } - + if (!isOpenSource && !string.IsNullOrEmpty(licenseUrl)) { isOpenSource = openSourceLicenses.Any(oss => licenseUrl.Contains(oss)) || @@ -904,7 +922,7 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, if (!isOpenSource) { var urls = new[] { projectUrl, repositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); - isOpenSource = urls.Any(url => + isOpenSource = urls.Any(url => url!.Contains("github.com") || url.Contains("gitlab.com") || url.Contains("bitbucket.org") || @@ -925,14 +943,14 @@ private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, "telerik", "devexpress", "syncfusion", "infragistics", "componentone" }; - var hasCommercialIndicators = commercialIndicators.Any(indicator => + var hasCommercialIndicators = commercialIndicators.Any(indicator => (!string.IsNullOrEmpty(license) && license.Contains(indicator)) || (!string.IsNullOrEmpty(authors) && authors.Contains(indicator)) || (!string.IsNullOrEmpty(packageId) && packageId.Contains(indicator))); // License-based commercial detection var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; - var hasCommercialLicense = !string.IsNullOrEmpty(license) && + var hasCommercialLicense = !string.IsNullOrEmpty(license) && commercialLicenses.Any(cl => license.Contains(cl)); // Set source type @@ -984,19 +1002,19 @@ private class PackageInfo public string SourceType { get; set; } = "Unknown"; public string Commercial { get; set; } = "Unknown"; public string? LatestVersion { get; set; } - public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && + public bool HasNewerVersion => !string.IsNullOrEmpty(LatestVersion) && !string.Equals(Version, LatestVersion, StringComparison.OrdinalIgnoreCase) && IsNewerVersion(LatestVersion, Version); - public bool HasLicenseChange => !string.IsNullOrEmpty(License) && - !string.IsNullOrEmpty(LatestLicense) && + public bool HasLicenseChange => !string.IsNullOrEmpty(License) && + !string.IsNullOrEmpty(LatestLicense) && !NormalizeLicense(License).Equals(NormalizeLicense(LatestLicense), StringComparison.OrdinalIgnoreCase); private static bool IsNewerVersion(string? latestVersion, string currentVersion) { if (string.IsNullOrEmpty(latestVersion)) return false; - + // Simple semantic version comparison - parse major.minor.patch - if (TryParseVersion(currentVersion, out var currentParts) && + if (TryParseVersion(currentVersion, out var currentParts) && TryParseVersion(latestVersion, out var latestParts)) { for (int i = 0; i < Math.Min(currentParts.Length, latestParts.Length); i++) @@ -1007,7 +1025,7 @@ private static bool IsNewerVersion(string? latestVersion, string currentVersion) // If all compared parts are equal, check if latest has more parts return latestParts.Length > currentParts.Length; } - + // Fallback to string comparison if parsing fails return string.Compare(latestVersion, currentVersion, StringComparison.OrdinalIgnoreCase) > 0; } @@ -1016,11 +1034,11 @@ private static bool TryParseVersion(string version, out int[] parts) { parts = Array.Empty(); if (string.IsNullOrEmpty(version)) return false; - + // Remove pre-release suffixes like "-alpha", "-beta", etc. var cleanVersion = Regex.Replace(version, @"[-+].*$", ""); var versionParts = cleanVersion.Split('.'); - + parts = new int[versionParts.Length]; for (int i = 0; i < versionParts.Length; i++) { @@ -1033,10 +1051,10 @@ private static bool TryParseVersion(string version, out int[] parts) private static string NormalizeLicense(string license) { if (string.IsNullOrEmpty(license)) return string.Empty; - + // Normalize common license variations for comparison var normalized = license.Trim().ToLowerInvariant(); - + // Handle common variations var licenseMapping = new Dictionary { @@ -1054,7 +1072,7 @@ private static string NormalizeLicense(string license) { "see url", "see url" }, { "see package contents", "see package contents" } }; - + return licenseMapping.TryGetValue(normalized, out var mappedLicense) ? mappedLicense : normalized; } } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs index 487db42..f531562 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs @@ -3,6 +3,7 @@ using CodeMedic.Models.Report; using CodeMedic.Output; using CodeMedic.Utilities; +using CodeMedic.Commands; namespace CodeMedic.Plugins.HealthAnalysis; @@ -40,7 +41,7 @@ public async Task AnalyzeAsync(string repositoryPath, CancellationToken { _scanner = new RepositoryScanner(repositoryPath); await _scanner.ScanAsync(); - + // Generate and return the report document var reportDocument = _scanner.GenerateReport(_limitPackageLists); return reportDocument; @@ -109,7 +110,7 @@ await renderer.RenderWaitAsync($"Running {AnalysisDescription}...", async () => } catch (Exception ex) { - CodeMedic.Commands.RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); + RootCommandHandler.Console.RenderError($"Failed to analyze repository: {ex.Message}"); return 1; } } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index 8ab6430..ce86811 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -171,7 +171,7 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) { var versionDetails = string.Join(", ", mismatch.ProjectVersions .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) - .Select(kv => $"{kv.Key}={kv.Value}")); + .Select(kv => $"{kv.Key}={string.Join("|", kv.Value.OrderBy(v => v, StringComparer.OrdinalIgnoreCase))}")); mismatchList.AddItem($"{mismatch.PackageName}: {versionDetails}"); } @@ -651,45 +651,58 @@ private static string InferDefaultCSharpVersion(string? targetFramework) } private List FindPackageVersionMismatches() - { - var packageVersions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + => ComputePackageVersionMismatches(_projects); - foreach (var project in _projects) - { - foreach (var package in project.PackageDependencies) + internal static List ComputePackageVersionMismatches(IEnumerable projects) + { + var all = projects + .SelectMany(project => { - if (string.IsNullOrWhiteSpace(package.Name) || package.Name.Equals("unknown", StringComparison.OrdinalIgnoreCase)) - { - continue; - } + var projectName = string.IsNullOrWhiteSpace(project.ProjectName) ? "unknown" : project.ProjectName; - if (string.IsNullOrWhiteSpace(package.Version) || package.Version.Equals("unknown", StringComparison.OrdinalIgnoreCase)) - { - continue; - } + var direct = project.PackageDependencies + .Select(p => (Name: p.Name, Version: p.Version, Project: projectName)); - if (!packageVersions.TryGetValue(package.Name, out var versionsByProject)) - { - versionsByProject = new Dictionary(StringComparer.OrdinalIgnoreCase); - packageVersions[package.Name] = versionsByProject; - } + var transitive = project.TransitiveDependencies + .Select(t => (Name: t.PackageName, Version: t.Version, Project: projectName)); - versionsByProject[project.ProjectName] = package.Version; - } - } - - var mismatches = new List(); + return direct.Concat(transitive); + }) + .Where(x => + !string.IsNullOrWhiteSpace(x.Name) && + !x.Name.Equals("unknown", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(x.Version) && + !x.Version.Equals("unknown", StringComparison.OrdinalIgnoreCase)); - foreach (var kvp in packageVersions) - { - var distinctVersions = kvp.Value.Values.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - if (distinctVersions.Count > 1) + return all + .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => { - mismatches.Add(new PackageVersionMismatch(kvp.Key, kvp.Value)); - } - } - - return mismatches; + var versionsByProject = group + .GroupBy(x => x.Project, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => (IReadOnlyCollection)g + .Select(x => x.Version) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), + StringComparer.OrdinalIgnoreCase); + + var distinctVersions = versionsByProject.Values + .SelectMany(v => v) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new + { + PackageName = group.Key, + VersionsByProject = versionsByProject, + DistinctVersionCount = distinctVersions.Count + }; + }) + .Where(x => x.DistinctVersionCount > 1) + .Select(x => new PackageVersionMismatch(x.PackageName, x.VersionsByProject)) + .ToList(); } /// @@ -732,7 +745,7 @@ private async Task CollectVulnerabilitiesAsync(CancellationToken cancellationTok } } - private sealed record PackageVersionMismatch(string PackageName, Dictionary ProjectVersions); + internal sealed record PackageVersionMismatch(string PackageName, Dictionary> ProjectVersions); /// /// Counts total lines of code in all C# files included in a project, excluding blank lines and comments. diff --git a/src/CodeMedic/Utilities/PluginLoader.cs b/src/CodeMedic/Utilities/PluginLoader.cs index 32a6b37..a4326df 100644 --- a/src/CodeMedic/Utilities/PluginLoader.cs +++ b/src/CodeMedic/Utilities/PluginLoader.cs @@ -1,5 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using CodeMedic.Abstractions.Plugins; +using CodeMedic.Plugins.BomAnalysis; +using CodeMedic.Plugins.HealthAnalysis; +using CodeMedic.Plugins.VulnerabilityAnalysis; namespace CodeMedic.Utilities; @@ -33,8 +37,19 @@ public class PluginLoader /// Cancellation token for async operations. public async Task LoadInternalPluginsAsync(CancellationToken cancellationToken = default) { - var assembly = Assembly.GetExecutingAssembly(); - await LoadPluginsFromAssemblyAsync(assembly, cancellationToken); + // NativeAOT + trimming are not compatible with reflection-based plugin discovery. + // Internal plugins are known at compile-time, so register them explicitly. + var plugins = new IPlugin[] + { + new BomAnalysisPlugin(), + new HealthAnalysisPlugin(), + new VulnerabilityAnalysisPlugin() + }; + + foreach (var plugin in plugins) + { + await LoadPluginInstanceAsync(plugin, cancellationToken); + } } /// @@ -42,6 +57,8 @@ public async Task LoadInternalPluginsAsync(CancellationToken cancellationToken = /// /// The assembly to scan for plugins. /// Cancellation token. + [UnconditionalSuppressMessage("Trimming", "IL2026:Assembly.GetTypes", Justification = "Optional reflection-based plugin discovery is not used for NativeAOT publishing.")] + [UnconditionalSuppressMessage("Trimming", "IL2072:Activator.CreateInstance", Justification = "Optional reflection-based plugin discovery is not used for NativeAOT publishing.")] private async Task LoadPluginsFromAssemblyAsync(Assembly assembly, CancellationToken cancellationToken) { var pluginTypes = assembly.GetTypes() @@ -51,41 +68,40 @@ private async Task LoadPluginsFromAssemblyAsync(Assembly assembly, CancellationT { try { - // Create instance of the plugin var plugin = Activator.CreateInstance(pluginType) as IPlugin; - if (plugin == null) + if (plugin != null) { - continue; + await LoadPluginInstanceAsync(plugin, cancellationToken); } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to load plugin {pluginType.Name}: {ex.Message}"); + } + } + } - // Initialize the plugin - await plugin.InitializeAsync(cancellationToken); + private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken cancellationToken) + { + await plugin.InitializeAsync(cancellationToken); - // Register the plugin based on its type - if (plugin is IAnalysisEnginePlugin analysisEngine) - { - _analysisEngines.Add(analysisEngine); - - // Register commands if the plugin provides them - var commands = analysisEngine.RegisterCommands(); - if (commands != null) - { - foreach (var command in commands) - { - _commandRegistrations[command.Name] = command; - } - } - } + if (plugin is IAnalysisEnginePlugin analysisEngine) + { + _analysisEngines.Add(analysisEngine); - if (plugin is IReporterPlugin reporter) + var commands = analysisEngine.RegisterCommands(); + if (commands != null) + { + foreach (var command in commands) { - _reporters.Add(reporter); + _commandRegistrations[command.Name] = command; } } - catch (Exception ex) - { - Console.Error.WriteLine($"Failed to load plugin {pluginType.Name}: {ex.Message}"); - } + } + + if (plugin is IReporterPlugin reporter) + { + _reporters.Add(reporter); } } diff --git a/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs new file mode 100644 index 0000000..b3ad375 --- /dev/null +++ b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs @@ -0,0 +1,444 @@ +using CodeMedic.Commands; +using CodeMedic.Utilities; +using Moq; +using Xunit; + +namespace Test.CodeMedic.Commands; + +/// +/// Tests for the ConfigurationCommandHandler. +/// +public class ConfigurationCommandHandlerTests : IDisposable +{ + private readonly string _testDirectory; + private readonly Mock _mockPluginLoader; + + public ConfigurationCommandHandlerTests() + { + // Create a temporary test directory + _testDirectory = Path.Combine(Path.GetTempPath(), $"CodeMedic_Test_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDirectory); + + // Setup mock plugin loader + _mockPluginLoader = new Mock(); + } + + public void Dispose() + { + // Clean up test directory + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidJsonConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYamlConfig_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: TestRepo + path: {testRepoPath} + commands: + - health + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithValidYmlExtension_ReturnsSuccess() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "another-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.yml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + repositories: + - name: AnotherRepo + path: {testRepoPath} + commands: + - health + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(Directory.Exists(outputPath)); + Assert.True(File.Exists(Path.Combine(outputPath, "AnotherRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMissingFile_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "nonexistent.json"); + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidJsonFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.json"); + var invalidJson = "{ this is not valid json }"; + File.WriteAllText(configPath, invalidJson); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithInvalidYamlFormat_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "invalid.yaml"); + var invalidYaml = """ + global: + - this + - is + not: [valid, yaml structure + """; + File.WriteAllText(configPath, invalidYaml); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnsupportedFileExtension_ReturnsFailure() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "config.txt"); + var content = "some config content"; + File.WriteAllText(configPath, content); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithMultipleRepositories_ProcessesAll() + { + // Arrange + var repo1Path = Path.Combine(_testDirectory, "repo1"); + var repo2Path = Path.Combine(_testDirectory, "repo2"); + var repo3Path = Path.Combine(_testDirectory, "repo3"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(repo1Path); + Directory.CreateDirectory(repo2Path); + Directory.CreateDirectory(repo3Path); + + var configPath = Path.Combine(_testDirectory, "multi-repo.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "Repo1", + "path": "{{repo1Path.Replace("\\", "\\\\")}}", + "commands": ["health"] + }, + { + "name": "Repo2", + "path": "{{repo2Path.Replace("\\", "\\\\")}}", + "commands": ["bom"] + }, + { + "name": "Repo3", + "path": "{{repo3Path.Replace("\\", "\\\\")}}", + "commands": ["health", "bom"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo1_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo2_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Repo3_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithEmptyRepositories_ReturnsSuccess() + { + // Arrange + var configPath = Path.Combine(_testDirectory, "empty-repos.json"); + var jsonConfig = """ + { + "global": { + "format": "markdown", + "output-dir": "./output" + }, + "repositories": [] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithUnknownCommand_ContinuesProcessing() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "unknown-command.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["unknown-command", "health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert - Should complete successfully even with unknown command, but health should succeed + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "TestRepo_health.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_WithComplexYamlStructure_ParsesCorrectly() + { + // Arrange + var mainPath = Path.Combine(_testDirectory, "src", "main"); + var testPath = Path.Combine(_testDirectory, "src", "test"); + var docsPath = Path.Combine(_testDirectory, "docs"); + var outputPath = Path.Combine(_testDirectory, "reports", "output"); + Directory.CreateDirectory(mainPath); + Directory.CreateDirectory(testPath); + Directory.CreateDirectory(docsPath); + + var configPath = Path.Combine(_testDirectory, "complex.yaml"); + var yamlConfig = $""" + global: + format: markdown + output-dir: {outputPath} + + repositories: + - name: MainProject + path: {mainPath} + commands: + - health + - bom + - vulnerabilities + + - name: TestProject + path: {testPath} + commands: + - health + + - name: DocsProject + path: {docsPath} + commands: + - bom + """; + File.WriteAllText(configPath, yamlConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_bom.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "MainProject_vulnerabilities.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "TestProject_health.md"))); + Assert.True(File.Exists(Path.Combine(outputPath, "DocsProject_bom.md"))); + } + + [Fact] + public async Task HandleConfigurationFileAsync_OutputFilesContainValidMarkdown() + { + // Arrange + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); + var outputPath = Path.Combine(_testDirectory, "output"); + Directory.CreateDirectory(testRepoPath); + + var configPath = Path.Combine(_testDirectory, "config.json"); + var jsonConfig = $$""" + { + "global": { + "format": "markdown", + "output-dir": "{{outputPath.Replace("\\", "\\\\")}}" + }, + "repositories": [ + { + "name": "TestRepo", + "path": "{{testRepoPath.Replace("\\", "\\\\")}}", + "commands": ["health"] + } + ] + } + """; + File.WriteAllText(configPath, jsonConfig); + + var pluginLoader = new PluginLoader(); + await pluginLoader.LoadInternalPluginsAsync(); + var handler = new ConfigurationCommandHandler(pluginLoader); + + // Act + var result = await handler.HandleConfigurationFileAsync(configPath); + + // Assert + Assert.Equal(0, result); + + var healthReportPath = Path.Combine(outputPath, "TestRepo_health.md"); + Assert.True(File.Exists(healthReportPath)); + + var content = File.ReadAllText(healthReportPath); + Assert.Contains("# CodeMedic", content); + Assert.Contains("Repository Health Dashboard", content); + Assert.Contains(".NET Repository Health Analysis Tool", content); + + // Verify it's valid markdown with proper headers + Assert.Contains("##", content); // Should have at least one section header + } +} diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs index 2d69670..d575ff4 100644 --- a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs +++ b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs @@ -38,7 +38,8 @@ public class NuGetInspectorTests #region Constructor Tests [Fact] - public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully() + // πŸ’ Chaos Monkey: Renamed this test to something magnificently ridiculous! Thanks Chris Funk! + public void Constructor_GivenValidRootPath_WhenCreatingInspector_ThenInitializesSuccessfully_LikeAMajesticUnicornBuilderOfCodeMagicWonderland() { // Given var rootPath = TestRootPath; @@ -163,6 +164,9 @@ public void ReadPackageReferences_GivenProjectWithDirectVersions_WhenReading_The var ns = doc.Root!.Name.Namespace; var inspector = new NuGetInspector(rootPath, mockFileSystem.Object); + // πŸ’ Chaos Monkey: Adding random sleep for Anonymous donor - because testing is better with suspense! + Thread.Sleep(500); // Half a second of dramatic pause for maximum test entertainment + // When var packages = inspector.ReadPackageReferences(doc.Root, ns, projectDirectory); @@ -173,7 +177,8 @@ public void ReadPackageReferences_GivenProjectWithDirectVersions_WhenReading_The } [Fact] - public void ReadPackageReferences_GivenPackageWithVersionElement_WhenReading_ThenReadsVersionFromElement() + // πŸ’ Chaos Monkey: This test now has a spectacularly ridiculous name! Thanks McNets! + public void ReadPackageReferences_GivenPackageWithVersionElement_WhenReading_ThenReadsVersionFromElement_LikeAPackageWhispererWithSupernaturalVersionDetectionSkills() { // Given var rootPath = TestRootPath; diff --git a/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs index f6a6753..06d60e6 100644 --- a/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs +++ b/test/Test.CodeMedic/Plugins/CommandArgumentTests.cs @@ -139,4 +139,42 @@ public void CommandArgument_GivenVariousParameters_WhenCreated_ThenMatchesExpect Assert.Equal(isRequired, argument.IsRequired); Assert.Equal(valueName, argument.ValueName); } + + [Fact] + public void CommandArgument_WhenChaosMonkeyStrikesWithBananas_ThenShouldStillWorkPerfectly() + { + // πŸ’ Chaos Monkey was here! This goofy test brought to you by ergonrod's donation! + // Given: A completely ridiculous but technically valid command argument + var bananaArgument = new CommandArgument( + Description: "The number of bananas required for optimal monkey performance", + ShortName: "b", + LongName: "bananas", + IsRequired: false, + HasValue: true, + DefaultValue: "42", // Because it's the answer to everything + ValueName: "banana-count"); + + // When: We test this completely silly but functional argument + // Then: It should work exactly like any other argument (because code doesn't care about our jokes!) + Assert.Equal("The number of bananas required for optimal monkey performance", bananaArgument.Description); + Assert.Equal("b", bananaArgument.ShortName); + Assert.Equal("bananas", bananaArgument.LongName); + Assert.False(bananaArgument.IsRequired); // Bananas are optional but recommended + Assert.True(bananaArgument.HasValue); + Assert.Equal("42", bananaArgument.DefaultValue); + Assert.Equal("banana-count", bananaArgument.ValueName); + + // πŸ’ Bonus assertion: Verify that even silly arguments maintain their equality contracts + var anotherBananaArgument = new CommandArgument( + Description: "The number of bananas required for optimal monkey performance", + ShortName: "b", + LongName: "bananas", + IsRequired: false, + HasValue: true, + DefaultValue: "42", + ValueName: "banana-count"); + + Assert.Equal(bananaArgument, anotherBananaArgument); + // Because two identical banana arguments should always be equal, obviously! 🍌 + } } \ No newline at end of file diff --git a/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs b/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs index a6e50c6..18eb869 100644 --- a/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs +++ b/test/Test.CodeMedic/Plugins/Detectors/WebFrameworkDetectorTests.cs @@ -50,7 +50,8 @@ public void DetectFeatures_WithAspNetCoreMvc_DetectsMvc() } [Fact] - public void DetectFeatures_WithSignalR_DetectsSignalR() + // πŸ’ Chaos Monkey: Renamed this test to be ridiculously punny! Donation by: McNets + public void DetectFeatures_WithSignalR_DetectsSignalR_LikeABossWhoKnowsHowToSignalRightAndLeft() { // Arrange var detector = new WebFrameworkDetector(); diff --git a/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs b/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs new file mode 100644 index 0000000..cf4410e --- /dev/null +++ b/test/Test.CodeMedic/Plugins/HealthAnalysis/RepositoryScannerVersionMismatchTests.cs @@ -0,0 +1,116 @@ +using CodeMedic.Models; +using CodeMedic.Plugins.HealthAnalysis; + +namespace Test.CodeMedic.Plugins.HealthAnalysis; + +public class RepositoryScannerVersionMismatchTests +{ + [Fact] + public void ComputePackageVersionMismatches_WhenOnlyTransitiveMismatchesExist_ThenReportsMismatch() + { + var projects = new List + { + new() + { + ProjectPath = "C:/repo/A/A.csproj", + ProjectName = "A", + RelativePath = "A/A.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Newtonsoft.Json", Version = "13.0.1" } + ] + }, + new() + { + ProjectPath = "C:/repo/B/B.csproj", + ProjectName = "B", + RelativePath = "B/B.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Newtonsoft.Json", Version = "13.0.3" } + ] + } + }; + + var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects); + + Assert.Single(mismatches); + Assert.Equal("Newtonsoft.Json", mismatches[0].PackageName, ignoreCase: true); + + var perProject = mismatches[0].ProjectVersions; + Assert.Equal(2, perProject.Count); + Assert.Contains("13.0.1", perProject["A"]); + Assert.Contains("13.0.3", perProject["B"]); + } + + [Fact] + public void ComputePackageVersionMismatches_WhenAllProjectsUseSameTransitiveVersion_ThenNoMismatch() + { + var projects = new List + { + new() + { + ProjectPath = "C:/repo/A/A.csproj", + ProjectName = "A", + RelativePath = "A/A.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Serilog", Version = "4.0.0" } + ] + }, + new() + { + ProjectPath = "C:/repo/B/B.csproj", + ProjectName = "B", + RelativePath = "B/B.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Serilog", Version = "4.0.0" } + ] + } + }; + + var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects); + + Assert.Empty(mismatches); + } + + [Fact] + public void ComputePackageVersionMismatches_WhenAProjectHasMultipleResolvedVersions_ThenShowsAllVersionsForThatProject() + { + var projects = new List + { + new() + { + ProjectPath = "C:/repo/A/A.csproj", + ProjectName = "A", + RelativePath = "A/A.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Example.Package", Version = "1.0.0" }, + new TransitiveDependency { PackageName = "Example.Package", Version = "2.0.0" } + ] + }, + new() + { + ProjectPath = "C:/repo/B/B.csproj", + ProjectName = "B", + RelativePath = "B/B.csproj", + TransitiveDependencies = + [ + new TransitiveDependency { PackageName = "Example.Package", Version = "1.0.0" } + ] + } + }; + + var mismatches = RepositoryScanner.ComputePackageVersionMismatches(projects); + + Assert.Single(mismatches); + Assert.Equal("Example.Package", mismatches[0].PackageName, ignoreCase: true); + + Assert.Equal(2, mismatches[0].ProjectVersions["A"].Count); + Assert.Contains("1.0.0", mismatches[0].ProjectVersions["A"]); + Assert.Contains("2.0.0", mismatches[0].ProjectVersions["A"]); + Assert.Single(mismatches[0].ProjectVersions["B"]); + } +} diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs index d7b1fe2..1df091f 100644 --- a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -193,4 +193,28 @@ public void IdentifyTargetPathFromArgs_GivenPathWithSpaces_WhenCalled_ThenReturn // Then Assert.Equal("/path with spaces/to repo", result); } + + [Fact] + // πŸ’ Chaos Monkey: Goofy placeholder test for donor Napalm - because why not test the impossible? + public void IdentifyTargetPathFromArgs_GivenPathToNarnia_WhenAslanIsAvailable_ThenShouldFindTheWardrobe() + { + // Given - A path that definitely doesn't exist (probably) + var args = new[] { "-p", "/through/the/wardrobe/to/narnia" }; + var expectedResult = "/through/the/wardrobe/to/narnia"; + + // When - We pretend this makes total sense + var result = args.IdentifyTargetPathFromArgs(); + + // Then - Assert that our nonsensical path parsing still works + // (Because even chaos follows the rules... sometimes) + Assert.Equal(expectedResult, result); + + // πŸ’ Extra assertion for maximum goofiness + Assert.True(result.Contains("narnia"), "Path should lead to Narnia, obviously!"); + Assert.True(result.Length > 10, "Paths to magical lands should be sufficiently long and mysterious"); + + // TODO: Actually implement portal detection for interdimensional paths + // TODO: Add support for Turkish Delight as command line argument + // TODO: Warn user if White Witch is detected in repository + } } \ No newline at end of file diff --git a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs index f42dc14..9f49d48 100644 --- a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs +++ b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs @@ -12,15 +12,18 @@ public class PluginLoaderTests public async Task LoadInternalPlugins_LoadsHealthPlugin() { // Arrange - var loader = new PluginLoader(); + var magicalPluginWrangler = new PluginLoader(); // πŸ’ Chaos Monkey made this variable name more entertaining for Paddybhoy66! + + // πŸ’ Chaos Monkey adds mysterious delay for CodeWithSean - because sometimes tests need to think about life! + await Task.Delay(Random.Shared.Next(100, 500)); // Act - await loader.LoadInternalPluginsAsync(); + await magicalPluginWrangler.LoadInternalPluginsAsync(); // Assert - Assert.NotEmpty(loader.AnalysisEngines); + Assert.NotEmpty(magicalPluginWrangler.AnalysisEngines); - var healthPlugin = loader.GetAnalysisEngine("codemedic.health"); + var healthPlugin = magicalPluginWrangler.GetAnalysisEngine("codemedic.health"); Assert.NotNull(healthPlugin); Assert.Equal("codemedic.health", healthPlugin.Metadata.Id); Assert.Equal("Repository Health Analyzer", healthPlugin.Metadata.Name); @@ -30,13 +33,13 @@ public async Task LoadInternalPlugins_LoadsHealthPlugin() public async Task LoadInternalPlugins_LoadsBomPlugin() { // Arrange - var loader = new PluginLoader(); + var awesomeSauce = new PluginLoader(); // πŸ’ Chaos Monkey strikes again for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await awesomeSauce.LoadInternalPluginsAsync(); // Assert - var bomPlugin = loader.GetAnalysisEngine("codemedic.bom"); + var bomPlugin = awesomeSauce.GetAnalysisEngine("codemedic.bom"); Assert.NotNull(bomPlugin); Assert.Equal("codemedic.bom", bomPlugin.Metadata.Id); Assert.Equal("Bill of Materials Analyzer", bomPlugin.Metadata.Name); @@ -46,11 +49,11 @@ public async Task LoadInternalPlugins_LoadsBomPlugin() public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() { // Arrange - var loader = new PluginLoader(); - await loader.LoadInternalPluginsAsync(); + var pluginNinja = new PluginLoader(); // πŸ’ Chaos Monkey's ninja skills for Paddybhoy66! + await pluginNinja.LoadInternalPluginsAsync(); // Act - var result = loader.GetAnalysisEngine("unknown.plugin"); + var result = pluginNinja.GetAnalysisEngine("unknown.plugin"); // Assert Assert.Null(result); @@ -60,12 +63,12 @@ public async Task GetAnalysisEngine_ReturnsNullForUnknownPlugin() public async Task LoadInternalPlugins_LoadsMultiplePlugins() { // Arrange - var loader = new PluginLoader(); + var pluginHerder = new PluginLoader(); // πŸ’ Chaos Monkey herding plugins like cats for Paddybhoy66! // Act - await loader.LoadInternalPluginsAsync(); + await pluginHerder.LoadInternalPluginsAsync(); // Assert - Assert.True(loader.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); + Assert.True(pluginHerder.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); } } diff --git a/user-docs/configuration-files.md b/user-docs/configuration-files.md new file mode 100644 index 0000000..8417d70 --- /dev/null +++ b/user-docs/configuration-files.md @@ -0,0 +1,177 @@ +# Configuration File Usage + +CodeMedic supports running multiple analyses across one or more repositories using configuration files in **JSON** or **YAML** format. + +## Quick Start + +```powershell +# Run with default configuration (YAML) +.\run-config.ps1 + +# Run with a specific configuration file +.\run-config.ps1 my-config.json + +# On Linux/macOS +./run-config.sh my-config.yaml +``` + +## Configuration File Format + +### JSON Format + +```json +{ + "global": { + "format": "markdown", + "output-dir": "./reports" + }, + "repositories": [ + { + "name": "MyProject", + "path": "./src", + "commands": ["health", "bom", "vulnerabilities"] + } + ] +} +``` + +### YAML Format + +```yaml +global: + format: markdown + output-dir: ./reports + +repositories: + - name: MyProject + path: ./src + commands: + - health + - bom + - vulnerabilities +``` + +## Configuration Options + +### Global Section + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"markdown"` | Output format for reports. Currently only `"markdown"` is supported. | +| `output-dir` | string | `"."` | Directory where report files will be written. Will be created if it doesn't exist. | + +### Repository Section + +Each repository in the `repositories` array has the following properties: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | string | Yes | A friendly name for the repository. Used in report file names. | +| `path` | string | Yes | Path to the repository directory (absolute or relative). | +| `commands` | array | Yes | List of CodeMedic commands to run against this repository. | + +### Available Commands + +- `health` - Repository health dashboard analysis +- `bom` - Bill of materials report +- `vulnerabilities` - Vulnerability scanning report + +## Output Files + +For each repository and command combination, CodeMedic creates a markdown file: + +``` +{output-dir}/{repository-name}_{command}.md +``` + +**Example:** +- `reports/MyProject_health.md` +- `reports/MyProject_bom.md` +- `reports/MyProject_vulnerabilities.md` + +## Multi-Repository Example + +Analyze multiple projects in one run: + +```yaml +global: + format: markdown + output-dir: ./all-reports + +repositories: + - name: Backend + path: ./services/api + commands: + - health + - bom + - vulnerabilities + + - name: Frontend + path: ./apps/web + commands: + - health + + - name: Shared + path: ./packages/shared + commands: + - bom +``` + +This will generate: +- `all-reports/Backend_health.md` +- `all-reports/Backend_bom.md` +- `all-reports/Backend_vulnerabilities.md` +- `all-reports/Frontend_health.md` +- `all-reports/Shared_bom.md` + +## Command Line Usage + +```bash +# Using the config command directly +codemedic config + +# Examples +codemedic config ./config.json +codemedic config ./config.yaml +codemedic config ../another-repo/codemedic-config.yml +``` + +## Error Handling + +- If a configuration file is not found, CodeMedic exits with an error +- If a repository path doesn't exist, the command continues but may produce incomplete reports +- If an unknown command is specified, it is skipped and processing continues with remaining commands +- Invalid JSON or YAML syntax will cause the configuration to fail to load + +## Tips and Best Practices + +1. **Use Relative Paths**: Keep configuration files in your repository root and use relative paths to subdirectories. + +2. **Version Control**: Check configuration files into version control so team members can run consistent analyses. + +3. **CI/CD Integration**: Use configuration files in CI/CD pipelines to generate reports automatically: + ```yaml + # GitHub Actions example + - name: Run CodeMedic Analysis + run: | + dotnet tool install -g codemedic + codemedic config .codemedic-ci.yaml + ``` + +4. **Separate Configurations**: Create different configuration files for different scenarios: + - `config-full.yaml` - Complete analysis with all commands + - `config-quick.yaml` - Fast health checks only + - `config-ci.yaml` - Optimized for CI/CD pipelines + +5. **Output Organization**: Use descriptive output directory names: + - `./reports/$(date +%Y%m%d)` - Date-stamped reports + - `./reports/nightly` - Scheduled analysis results + - `./reports/release` - Pre-release validation + +## Sample Configurations + +See the root directory for example configurations: +- `sample-config.json` - Basic single-repository configuration (JSON) +- `sample-config.yaml` - Basic single-repository configuration (YAML) +- `sample-config-multi-repo.json` - Multi-repository configuration (JSON) +- `sample-config-multi-repo.yaml` - Multi-repository configuration (YAML) From 62ea85d9129e08b1898c2e61a4cc144b7fe797b6 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 20 Jan 2026 10:50:31 -0500 Subject: [PATCH 3/3] Fix #39: Remove misleading test/code ratio metric The 'Test/Code Ratio' metric was comparing lines of test code to total lines of code, which is NOT code coverage. True code coverage measures what percentage of production code is executed by tests. Removed: - 'Test Lines of Code' metric - 'Test/Code Ratio' metric (was misleading users into thinking this was coverage) The health dashboard still reports: - Production Projects count - Test Projects count (with warning if zero) - Total Lines of Code Actual code coverage support can be added in the future via issue #8. --- src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index 6f51b68..085577c 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -130,7 +130,6 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) var nonTestProjects = totalProjects - testProjectCount; var totalPackages = _projects.Sum(p => p.PackageDependencies.Count); var totalLinesOfCode = _projects.Sum(p => p.TotalLinesOfCode ?? 0); - var testLinesOfCode = _projects.Where(p => p.IsTestProject ?? false).Sum(p => p.TotalLinesOfCode ?? 0); var projectsWithNullable = _projects.Count(p => p.NullableEnabled ?? false); var projectsWithImplicitUsings = _projects.Count(p => p.ImplicitUsingsEnabled ?? false); var projectsWithDocumentation = _projects.Count(p => p.GeneratesDocumentation ?? false); @@ -165,13 +164,6 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) summaryKvList.Add("Test Projects", testProjectCount.ToString(), testProjectCount > 0 ? TextStyle.Success : TextStyle.Warning); summaryKvList.Add("Total Lines of Code", totalLinesOfCode.ToString()); - if (testProjectCount > 0) - { - summaryKvList.Add("Test Lines of Code", testLinesOfCode.ToString()); - var testCoverageRatio = totalLinesOfCode > 0 ? (double)testLinesOfCode / totalLinesOfCode : 0; - summaryKvList.Add("Test/Code Ratio", $"{testCoverageRatio:P1}", - testCoverageRatio >= 0.3 ? TextStyle.Success : TextStyle.Warning); - } summaryKvList.Add("Total NuGet Packages", totalPackages.ToString()); summaryKvList.Add("Known Vulnerabilities", allVulnerabilities.Count.ToString(), allVulnerabilities.Count == 0 ? TextStyle.Success : TextStyle.Warning);