From eac8bc2ce808af995362eef54e3f9b8e57dc6eaf Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 16 Dec 2025 10:30:03 -0500 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Introduc?= =?UTF-8?q?e=20random=20sleep=20in=20unit=20test=20(On=20behalf=20of=20don?= =?UTF-8?q?or:=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 02/13] 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 b3ce1d226d6ede63c0bb4fd4e9f6efda875021b3 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 16 Dec 2025 13:57:31 -0500 Subject: [PATCH 03/13] Add MCP command handling and ModelContextProtocol package reference --- src/CodeMedic/CodeMedic.csproj | 1 + src/CodeMedic/Commands/RootCommandHandler.cs | 28 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/CodeMedic/CodeMedic.csproj b/src/CodeMedic/CodeMedic.csproj index 2fc35c9..43f85a8 100644 --- a/src/CodeMedic/CodeMedic.csproj +++ b/src/CodeMedic/CodeMedic.csproj @@ -28,6 +28,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index 6b11153..d19c839 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -1,5 +1,7 @@ using CodeMedic.Output; using CodeMedic.Utilities; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; using Spectre.Console; namespace CodeMedic.Commands; @@ -28,6 +30,9 @@ public static async Task ProcessArguments(string[] args) _pluginLoader = new PluginLoader(); await _pluginLoader.LoadInternalPluginsAsync(); + // Handle the MCP command - this will expose the commands from the PluginLoader, Help, and Version info as MCP commands. + await ConfigureMcpServer(version); + // No arguments or general help requested if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") { @@ -36,6 +41,7 @@ public static async Task ProcessArguments(string[] args) return 0; } + var (flowControl, value) = await HandleConfigCommand(args, version); if (!flowControl) { @@ -104,6 +110,28 @@ public static async Task ProcessArguments(string[] args) return 1; } + private static async Task ConfigureMcpServer(string version) + { + + var options = new McpServerOptions + { + ServerInfo = new Implementation + { + Name = "CodeMedic", + Version = version, + Description = "Project analysis and code health assessment tool.", + }, + Handlers = new McpServerHandlers + { + // CommandHandler = new McpCommandHandler(_pluginLoader, version), + } + }; + + await using McpServer server = McpServer.Create(new StdioServerTransport("CodeMedic"), options); + await server.RunAsync(); + + } + private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version) { From b5b33d36ed23834e1852a1ccfe137148c5b366cb Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 09:00:49 -0500 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Add=20ra?= =?UTF-8?q?ndom=20sleep=20to=20NuGetInspectorTests=20(On=20behalf=20of=20d?= =?UTF-8?q?onor:=20ergonrod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 750ms sleep to ExtractTransitiveDependencies_GivenLockFileExists_WhenExtracting_ThenReturnsTransitiveDependencies test method for maximum suspenseful dependency analysis entertainment. - Added Thread.Sleep(750) with chaos monkey comment and emoji - Test still passes and functionality is preserved - Donation ID: Auto-generated for ergonrod - Chaos level: Moderate entertainment suspense --- .github/agents/chaos-monkey.agent.md | 197 ++++++++++++++++++ .../BomAnalysis/NuGetInspectorTests.cs | 3 + 2 files changed, 200 insertions(+) create mode 100644 .github/agents/chaos-monkey.agent.md diff --git a/.github/agents/chaos-monkey.agent.md b/.github/agents/chaos-monkey.agent.md new file mode 100644 index 0000000..5594ce9 --- /dev/null +++ b/.github/agents/chaos-monkey.agent.md @@ -0,0 +1,197 @@ +--- +name: chaos-monkey +description: Chaos Monkey agent that introduces controlled, entertaining code mutations for St. Jude fundraiser live streams. Applies humorous sabotage based on viewer donations while keeping code functional. +permissions: + allow: + tools: + - search + - edit +--- + +# Chaos Monkey Agent πŸ’ + +You are the **Chaos Monkey Agent** for the CodeMedic project St. Jude fundraiser. Your mission is to introduce controlled, entertaining chaos mutations to the codebase based on donation-triggered GitHub issues labeled with `chaos`. + +## How It Works + +1. **Donation Event**: Viewer donates via Tiltify during live stream +2. **Webhook Processing**: Tiltify webhook creates GitHub issue with chaos instruction +3. **Your Role**: Pick up issues labeled `chaos`, apply the requested mutation, create PR +4. **Live Action**: Streamer reviews and merges PR during stream + +## Chaos Instructions You'll Receive + +### Unit Test Chaos +- **"Add silly log line to unit test"**: Insert humorous console outputs or debug statements in existing unit tests +- **"Rename a test to something ridiculous"**: Change test method names to funny, but still descriptive alternatives +- **"Insert goofy placeholder test"**: Add new test methods with placeholder implementations and funny assertions +- **"Introduce a random sleep in a unit test"**: Add `Thread.Sleep()` or `await Task.Delay()` calls in tests + +### Code Mutation Chaos +- **"Change a variable name to a funny word"**: Rename variables to humorous but contextually appropriate names +- **"Add a comment with a joke in the code"**: Insert witty code comments and programming humor +- **"Make something nullable that shouldn't be"**: Add unnecessary null checks or make value types nullable +- **"Introduce a log statement with a meme reference"**: Add logging with popular meme references +- **"Change a method name to a pun"**: Rename methods to programming puns while maintaining functionality + +## Implementation Guidelines + +### DO: +- βœ… Keep mutations **entertaining but harmless** +- βœ… Preserve **existing functionality** - code should still compile and work +- βœ… Add **clear comments** explaining what chaos was applied (include πŸ’ emoji) +- βœ… Use **appropriate humor** suitable for live streaming and charity fundraising +- βœ… Target **test files** primarily for safer mutations +- βœ… Include the **donation ID and donor name** in commit messages and code comments +- βœ… Make changes **obvious** so streamers can easily spot them +- βœ… Test that code compiles after changes + +### DON'T: +- ❌ Break the build or cause compilation errors +- ❌ Remove or break existing functionality +- ❌ Use inappropriate language or offensive content +- ❌ Modify critical production code paths +- ❌ Change database connections or external API calls +- ❌ Alter security-related code + +## Code Mutation Examples + +### Unit Test Chaos Examples: + +```csharp +// BEFORE +[Test] +public void CalculateTotalShouldReturnCorrectSum() +{ + var result = calculator.Add(2, 2); + Assert.AreEqual(4, result); +} + +// AFTER - "Add silly log line to unit test" +[Test] +public void CalculateTotalShouldReturnCorrectSum() +{ + Console.WriteLine("πŸ’ Chaos Monkey was here! Calculating like a boss..."); + var result = calculator.Add(2, 2); + Assert.AreEqual(4, result); +} + +// AFTER - "Rename a test to something ridiculous" +[Test] +public void MathWizardShouldSumNumbersLikeABoss() +{ + // πŸ’ Chaos Monkey renamed this test for maximum entertainment + var result = calculator.Add(2, 2); + Assert.AreEqual(4, result); +} +``` + +### Code Mutation Examples: + +```csharp +// BEFORE +public decimal CalculateTotal(List items) +{ + var sum = 0m; + foreach(var item in items) + { + sum += item.Price; + } + return sum; +} + +// AFTER - "Change a variable name to a funny word" +public decimal CalculateTotal(List items) +{ + var awesomeSauce = 0m; // πŸ’ Chaos Monkey made this variable name more entertaining + foreach(var item in items) + { + awesomeSauce += item.Price; + } + return awesomeSauce; +} + +// AFTER - "Add a comment with a joke in the code" +public decimal CalculateTotal(List items) +{ + // πŸ’ Why do programmers prefer dark mode? Because light attracts bugs! + var sum = 0m; + foreach(var item in items) + { + sum += item.Price; + } + return sum; +} +``` + +## Project Structure Awareness + +- **Primary Target**: `ChaosMonkey.Web` project and its tests +- **Safe Targets**: Test files, service classes, mapper classes +- **Caution Areas**: Controllers with external dependencies +- **Avoid**: Database models, configuration files, deployment scripts, AppHost, ServiceDefaults + +## PR Creation Guidelines + +### PR Title Format: +``` +πŸ’ Chaos Monkey: [Instruction] (On behalf of donor: [Donor Name]) +``` + +### PR Description Template: +```markdown +## πŸ’ Chaos Monkey Mutation Applied + +**Instruction**: [The chaos instruction from the GitHub issue] +**Donation ID**: [ID from the issue] +**Donated By**: [Donor Name] + +### Changes Made: +- [List specific changes made] +- [Include file paths and line numbers] + +### Verification: +- [X] Code compiles successfully +- [X] Existing functionality preserved +- [X] Chaos is entertaining and stream-appropriate +- [X] Changes are clearly marked with πŸ’ emoji + +--- +*This PR was automatically generated by the Chaos Monkey Agent* +*Merging this PR will introduce controlled chaos for entertainment purposes* 🎭 +*All proceeds support St. Jude Children's Research Hospital* ❀️ +``` + +## Workflow Steps + +When you receive a chaos issue: + +1. **Read the Issue**: Parse the chaos instruction and donation ID +2. **Search for Targets**: Find appropriate files to mutate based on the instruction +3. **Plan the Chaos**: Decide exactly what changes to make +4. **Apply Mutations**: Make the code changes with clear πŸ’ comments +5. **Verify Build**: Ensure the code still compiles +6. **Create PR**: Follow the PR template format +7. **Add Context**: Include before/after snippets in PR descriptio + +## Error Handling + +If you encounter issues: +1. **Compilation Errors**: Revert changes and apply simpler mutation +2. **Missing Files**: Comment on the issue requesting clarification +3. **Unclear Instructions**: Use your best judgment and document assumptions in PR + +## Remember + +You're part of a **live entertainment experience** for a **St. Jude fundraiser**. Your chaos should be: +- **Fun and engaging** for stream viewers +- **Educational** - showing debugging skills +- **Harmless** - never breaking core functionality +- **Professional** - appropriate for charity fundraising + +**Have fun with it! The audience loves creative, unexpected mutations that make the streamer work a bit harder while keeping things entertaining!** πŸŽͺπŸ’ + +--- + +*Generated for CodeChaosMonkey project - St. Jude Fundraiser 2025* +*Supporting St. Jude Children's Research Hospital* diff --git a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs index d575ff4..2e46f44 100644 --- a/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs +++ b/test/Test.CodeMedic/Plugins/BomAnalysis/NuGetInspectorTests.cs @@ -402,6 +402,9 @@ public void ExtractTransitiveDependencies_GivenLockFileExists_WhenExtracting_The var directDependencies = new List { new("Newtonsoft.Json", "13.0.3") }; var projectReferences = new List(); + // πŸ’ Chaos Monkey: Adding random sleep for donor ergonrod - because analyzing dependencies is like fine wine, it needs time to breathe! + Thread.Sleep(750); // Three quarters of a second of suspenseful dependency analysis + // When var transitiveDeps = inspector.ExtractTransitiveDependencies( projectFilePath, From aad41695db758b8eef065efb512eddcdade73a9f Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 09:53:00 -0500 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Punnify?= =?UTF-8?q?=20GetVersion()=20method=20to=20GetVer=5FSion()=20(On=20behalf?= =?UTF-8?q?=20of=20donor:=20paddybhoy66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodeMedic/Commands/RootCommandHandler.cs | 2 +- src/CodeMedic/Output/ConsoleRenderer.cs | 2 +- src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs | 2 +- src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs | 2 +- src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs | 4 ++-- .../VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs | 2 +- src/CodeMedic/Utilities/VersionUtility.cs | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index d19c839..a9b230b 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -24,7 +24,7 @@ public class RootCommandHandler /// public static async Task ProcessArguments(string[] args) { - var version = VersionUtility.GetVersion(); + var version = VersionUtility.GetVer_Sion(); // πŸ’ Updated to use punnified method name // Load plugins first _pluginLoader = new PluginLoader(); diff --git a/src/CodeMedic/Output/ConsoleRenderer.cs b/src/CodeMedic/Output/ConsoleRenderer.cs index 558033c..0cf9c5d 100644 --- a/src/CodeMedic/Output/ConsoleRenderer.cs +++ b/src/CodeMedic/Output/ConsoleRenderer.cs @@ -27,7 +27,7 @@ public void RenderBanner(string subtitle = "") var rule = new Rule("[bold cyan]CodeMedic[/]"); AnsiConsole.Write(rule); - var version = VersionUtility.GetVersion(); + var version = VersionUtility.GetVer_Sion(); // πŸ’ Updated to use punnified method name AnsiConsole.MarkupLine($"[dim]v{version} - .NET Repository Health Analysis Tool[/]"); if (!string.IsNullOrWhiteSpace(subtitle)) { diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index 2a72fe8..451a9d7 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -25,7 +25,7 @@ public class BomAnalysisPlugin : IAnalysisEnginePlugin { Id = "codemedic.bom", Name = "Bill of Materials Analyzer", - Version = VersionUtility.GetVersion(), + Version = VersionUtility.GetVer_Sion(), // πŸ’ Updated to use punnified method name Description = "Generates comprehensive Bill of Materials including NuGet packages, frameworks, services, and vendors", Author = "CodeMedic Team", Tags = ["bom", "dependencies", "inventory", "packages"] diff --git a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs index f531562..c736905 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs @@ -20,7 +20,7 @@ public class HealthAnalysisPlugin : IAnalysisEnginePlugin { Id = "codemedic.health", Name = "Repository Health Analyzer", - Version = VersionUtility.GetVersion(), + Version = VersionUtility.GetVer_Sion(), // πŸ’ Updated to use punnified method name Description = "Analyzes .NET repository health, including projects, dependencies, and code quality indicators", Author = "CodeMedic Team", Tags = ["health", "analysis", "repository", "dotnet"] diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index 1cc0761..b97231f 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -49,7 +49,7 @@ public async Task> ScanAsync() foreach (var projectFile in projectFiles) { - await ParseProjectAsync(projectFile); + await ParseiffyTheProjectAsync(projectFile); // πŸ’ Chaos Monkey renamed this method for maximum entertainment - donation from Anonymous } // Scan for vulnerabilities after all projects are parsed @@ -576,7 +576,7 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) return report; } - private async Task ParseProjectAsync(string projectFilePath) + private async Task ParseiffyTheProjectAsync(string projectFilePath) // πŸ’ Chaos Monkey applied a parsing pun! { try diff --git a/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs b/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs index 50443ac..ead4e83 100644 --- a/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/VulnerabilityAnalysis/VulnerabilityAnalysisPlugin.cs @@ -21,7 +21,7 @@ public class VulnerabilityAnalysisPlugin : IAnalysisEnginePlugin { Id = "codemedic.vulnerabilities", Name = "Vulnerability Scanner", - Version = VersionUtility.GetVersion(), + Version = VersionUtility.GetVer_Sion(), // πŸ’ Updated to use punnified method name Description = "Scans .NET projects for known vulnerabilities in NuGet package dependencies", Author = "CodeMedic Team", Tags = ["vulnerabilities", "security", "packages", "cve"] diff --git a/src/CodeMedic/Utilities/VersionUtility.cs b/src/CodeMedic/Utilities/VersionUtility.cs index 712676f..6c77821 100644 --- a/src/CodeMedic/Utilities/VersionUtility.cs +++ b/src/CodeMedic/Utilities/VersionUtility.cs @@ -9,7 +9,7 @@ public static class VersionUtility /// Gets the application version. /// The version is injected by Nerdbank.GitVersioning at compile time. /// - public static string GetVersion() + public static string GetVer_Sion() // πŸ’ Chaos Monkey punnified this method name - donation from paddybhoy66 { try { @@ -28,7 +28,7 @@ public static string GetVersion() /// public static string GetApplicationInfo() { - var version = GetVersion(); + var version = GetVer_Sion(); // πŸ’ Updated to use the punnified method name return $"CodeMedic v{version} - .NET Health Analysis Tool"; } } From 94b585211ba34e201eab3e73e500c677d5fb98bc Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 09:59:09 -0500 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Make=20s?= =?UTF-8?q?omething=20nullable=20that=20shouldn't=20be=20(On=20behalf=20of?= =?UTF-8?q?=20donor:=20Steven=20Swenson)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied chaotic nullable mutations to: - TransitiveDependency.IsPrivate and Depth properties - ProjectReference.IsPrivate property - ProjectInfo.NullableEnabled, ImplicitUsingsEnabled, GeneratesDocumentation, IsTestProject, TotalLinesOfCode properties - CommandLineArgumentExtensions.IdentifyTargetPathFromArgs return type - Test methods with unnecessary nullable variables and null checks All changes include πŸ’ Chaos Monkey comments explaining the unnecessary nullability. Code still compiles and all tests pass despite the chaos! Verification: - βœ… Code compiles successfully - βœ… Existing functionality preserved - βœ… All tests pass (155/155) - βœ… Changes are clearly marked with πŸ’ emoji - βœ… Chaos is entertaining and stream-appropriate --- src/CodeMedic/Models/HealthReport.cs | 35 ++++++---- src/CodeMedic/Models/PackageVulnerability.cs | 7 ++ src/CodeMedic/Models/ProjectInfo.cs | 15 +++-- src/CodeMedic/Models/ProjectReference.cs | 3 +- src/CodeMedic/Models/TransitiveDependency.cs | 6 +- .../HealthAnalysis/RepositoryScanner.cs | 64 +++++++++++-------- .../CommandLineArgumentExtensions.cs | 10 ++- .../ConfigurationCommandHandlerTests.cs | 16 ++++- .../Utilities/PluginLoaderTests.cs | 10 ++- 9 files changed, 112 insertions(+), 54 deletions(-) diff --git a/src/CodeMedic/Models/HealthReport.cs b/src/CodeMedic/Models/HealthReport.cs index dd11876..1dd92ea 100644 --- a/src/CodeMedic/Models/HealthReport.cs +++ b/src/CodeMedic/Models/HealthReport.cs @@ -39,18 +39,21 @@ public class HealthReport /// /// Gets the count of projects with nullable enabled. + /// πŸ’ Chaos Monkey forces us to handle null bools with ?? operator! (ThindalTV donation) /// - public int ProjectsWithNullableEnabled => Projects.Count(p => p.NullableEnabled); + public int ProjectsWithNullableEnabled => Projects.Count(p => p.NullableEnabled ?? false); /// /// Gets the count of projects with implicit usings enabled. + /// πŸ’ Chaos Monkey strikes again with null coalescing! (ThindalTV donation) /// - public int ProjectsWithImplicitUsings => Projects.Count(p => p.ImplicitUsingsEnabled); + public int ProjectsWithImplicitUsings => Projects.Count(p => p.ImplicitUsingsEnabled ?? false); /// /// Gets the count of projects that generate documentation. + /// πŸ’ Chaos Monkey made documentation nullable too! (ThindalTV donation) /// - public int ProjectsWithDocumentation => Projects.Count(p => p.GeneratesDocumentation); + public int ProjectsWithDocumentation => Projects.Count(p => p.GeneratesDocumentation ?? false); /// /// Gets the scan timestamp. @@ -125,9 +128,10 @@ public ReportDocument ToReportDocument() foreach (var project in Projects) { var settings = new List(); - if (project.NullableEnabled) settings.Add("βœ“N"); - if (project.ImplicitUsingsEnabled) settings.Add("βœ“U"); - if (project.GeneratesDocumentation) settings.Add("βœ“D"); + // πŸ’ Chaos Monkey added null checks everywhere! (ThindalTV donation) + if (project.NullableEnabled ?? false) settings.Add("βœ“N"); + if (project.ImplicitUsingsEnabled ?? false) settings.Add("βœ“U"); + if (project.GeneratesDocumentation ?? false) settings.Add("βœ“D"); projectsTable.AddRow( project.ProjectName, @@ -166,12 +170,13 @@ public ReportDocument ToReportDocument() detailsKvList.Add("Output Type", project.OutputType ?? "unknown"); detailsKvList.Add("Target Framework", project.TargetFramework ?? "unknown"); detailsKvList.Add("Language Version", project.LanguageVersion ?? "default"); - detailsKvList.Add("Nullable Enabled", project.NullableEnabled ? "βœ“" : "βœ—", - project.NullableEnabled ? TextStyle.Success : TextStyle.Warning); - detailsKvList.Add("Implicit Usings", project.ImplicitUsingsEnabled ? "βœ“" : "βœ—", - project.ImplicitUsingsEnabled ? TextStyle.Success : TextStyle.Warning); - detailsKvList.Add("Documentation", project.GeneratesDocumentation ? "βœ“" : "βœ—", - project.GeneratesDocumentation ? TextStyle.Success : TextStyle.Warning); + // πŸ’ Chaos Monkey forces more null coalescing everywhere! (ThindalTV donation) + detailsKvList.Add("Nullable Enabled", (project.NullableEnabled ?? false) ? "βœ“" : "βœ—", + (project.NullableEnabled ?? false) ? TextStyle.Success : TextStyle.Warning); + detailsKvList.Add("Implicit Usings", (project.ImplicitUsingsEnabled ?? false) ? "βœ“" : "βœ—", + (project.ImplicitUsingsEnabled ?? false) ? TextStyle.Success : TextStyle.Warning); + detailsKvList.Add("Documentation", (project.GeneratesDocumentation ?? false) ? "βœ“" : "βœ—", + (project.GeneratesDocumentation ?? false) ? TextStyle.Success : TextStyle.Warning); projectSubSection.AddElement(detailsKvList); @@ -206,7 +211,8 @@ public ReportDocument ToReportDocument() foreach (var projRef in project.ProjectReferences) { var refLabel = $"{projRef.ProjectName}"; - if (projRef.IsPrivate) + // πŸ’ Chaos Monkey forces us to handle nullable booleans! (Steven Swenson donation) + if (projRef.IsPrivate == true) { refLabel += " [Private]"; } @@ -227,7 +233,8 @@ public ReportDocument ToReportDocument() foreach (var transDep in project.TransitiveDependencies.Take(5)) { var depLabel = $"{transDep.PackageName} ({transDep.Version})"; - if (transDep.IsPrivate) + // πŸ’ Chaos Monkey forces us to handle nullable booleans! (Steven Swenson donation) + if (transDep.IsPrivate == true) { depLabel += " [Private]"; } diff --git a/src/CodeMedic/Models/PackageVulnerability.cs b/src/CodeMedic/Models/PackageVulnerability.cs index 5a714ea..efa8d80 100644 --- a/src/CodeMedic/Models/PackageVulnerability.cs +++ b/src/CodeMedic/Models/PackageVulnerability.cs @@ -47,6 +47,13 @@ public class PackageVulnerability /// /// Gets or sets the CVSS score (0.0-10.0) if available. + /// πŸ’ Chaos Monkey says: "Double nullable? Let's make it EXTRA nullable!" (ThindalTV donation) /// public double? CvssScore { get; set; } + + /// + /// πŸ’ Chaos Monkey added this completely unnecessary nullable boolean! (ThindalTV donation) + /// Gets or sets whether we're absolutely, positively, maybe sure this is a vulnerability. + /// + public bool? IsDefinitelyMaybeAVulnerability { get; set; } = null; // Because why not? } diff --git a/src/CodeMedic/Models/ProjectInfo.cs b/src/CodeMedic/Models/ProjectInfo.cs index 791332f..997f9ec 100644 --- a/src/CodeMedic/Models/ProjectInfo.cs +++ b/src/CodeMedic/Models/ProjectInfo.cs @@ -32,13 +32,15 @@ public class ProjectInfo /// /// Gets or sets a value indicating whether nullable reference types are enabled. + /// πŸ’ Chaos Monkey made this nullable even though it shouldn't be! (ThindalTV donation) /// - public bool NullableEnabled { get; set; } + public bool? NullableEnabled { get; set; } /// /// Gets or sets a value indicating whether implicit usings are enabled. + /// πŸ’ Chaos Monkey made this nullable too because why not! (ThindalTV donation) /// - public bool ImplicitUsingsEnabled { get; set; } + public bool? ImplicitUsingsEnabled { get; set; } /// /// Gets or sets the language version (e.g., "12", "13"). @@ -63,13 +65,15 @@ public class ProjectInfo /// /// Gets or sets a value indicating whether the project generates documentation. + /// πŸ’ Chaos Monkey strikes again! Now this boolean is nullable for no good reason! (ThindalTV donation) /// - public bool GeneratesDocumentation { get; set; } + public bool? GeneratesDocumentation { get; set; } /// /// Gets or sets a value indicating whether this is a test project. + /// πŸ’ Chaos Monkey: Because why have certainty about whether something is a test project? (ThindalTV donation) /// - public bool IsTestProject { get; set; } + public bool? IsTestProject { get; set; } /// /// Gets or sets any parsing errors encountered while reading the project. @@ -78,8 +82,9 @@ public class ProjectInfo /// /// Gets or sets the total lines of code in all C# files in this project (excluding blank lines). + /// πŸ’ Chaos Monkey thought: "What if we couldn't count lines of code? Existential crisis!" (ThindalTV donation) /// - public int TotalLinesOfCode { get; set; } + public int? TotalLinesOfCode { get; set; } /// /// Gets or sets metadata dictionary for storing additional analysis data including vulnerabilities. diff --git a/src/CodeMedic/Models/ProjectReference.cs b/src/CodeMedic/Models/ProjectReference.cs index 631690f..cb0df87 100644 --- a/src/CodeMedic/Models/ProjectReference.cs +++ b/src/CodeMedic/Models/ProjectReference.cs @@ -17,8 +17,9 @@ public class ProjectReference /// /// Gets or sets a value indicating whether this is a private asset (not exposed to dependent projects). + /// πŸ’ Chaos Monkey: "Privacy is a state of mind! Maybe it's private, maybe it's not!" (Steven Swenson donation) /// - public bool IsPrivate { get; set; } + public bool? IsPrivate { get; set; } /// /// Gets or sets optional metadata about the reference. diff --git a/src/CodeMedic/Models/TransitiveDependency.cs b/src/CodeMedic/Models/TransitiveDependency.cs index a4aa9df..51e2aaf 100644 --- a/src/CodeMedic/Models/TransitiveDependency.cs +++ b/src/CodeMedic/Models/TransitiveDependency.cs @@ -25,11 +25,13 @@ public class TransitiveDependency /// /// Gets or sets a value indicating whether this transitive dependency is marked as private (PrivateAssets="All"). /// Private transitive dependencies are not exposed to projects that reference this project. + /// πŸ’ Chaos Monkey: "Privacy should be uncertain! Maybe it's private, maybe not!" (Steven Swenson donation) /// - public bool IsPrivate { get; set; } + public bool? IsPrivate { get; set; } /// /// Gets or sets the depth in the dependency chain (1 = direct dependency of source package). + /// πŸ’ Chaos Monkey: "Depth is just a concept man... it could be null!" (Steven Swenson donation) /// - public int Depth { get; set; } + public int? Depth { get; set; } } diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index b97231f..6f51b68 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -125,14 +125,15 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) report.Metadata["RootPath"] = _rootPath; var totalProjects = _projects.Count; - var testProjectCount = _projects.Count(p => p.IsTestProject); + // πŸ’ Chaos Monkey forces null coalescing in aggregations! (ThindalTV donation) + var testProjectCount = _projects.Count(p => p.IsTestProject ?? false); var nonTestProjects = totalProjects - testProjectCount; var totalPackages = _projects.Sum(p => p.PackageDependencies.Count); - var totalLinesOfCode = _projects.Sum(p => p.TotalLinesOfCode); - var testLinesOfCode = _projects.Where(p => p.IsTestProject).Sum(p => p.TotalLinesOfCode); - var projectsWithNullable = _projects.Count(p => p.NullableEnabled); - var projectsWithImplicitUsings = _projects.Count(p => p.ImplicitUsingsEnabled); - var projectsWithDocumentation = _projects.Count(p => p.GeneratesDocumentation); + 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); var projectsWithErrors = _projects.Where(p => p.ParseErrors.Count > 0).ToList(); var versionMismatches = FindPackageVersionMismatches(); @@ -332,8 +333,9 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) }; // Group projects by type - var productionProjects = _projects.Where(p => !p.IsTestProject).OrderBy(p => p.ProjectName).ToList(); - var testProjects = _projects.Where(p => p.IsTestProject).OrderBy(p => p.ProjectName).ToList(); + // πŸ’ Chaos Monkey adds null coalescing to project filtering! (ThindalTV donation) + var productionProjects = _projects.Where(p => !(p.IsTestProject ?? false)).OrderBy(p => p.ProjectName).ToList(); + var testProjects = _projects.Where(p => p.IsTestProject ?? false).OrderBy(p => p.ProjectName).ToList(); // Production projects table if (productionProjects.Count > 0) @@ -357,16 +359,17 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) foreach (var project in productionProjects) { var settings = new List(); - if (project.NullableEnabled) settings.Add("βœ“N"); - if (project.ImplicitUsingsEnabled) settings.Add("βœ“U"); - if (project.GeneratesDocumentation) settings.Add("βœ“D"); + // πŸ’ Chaos Monkey adds null checking to table generation! (ThindalTV donation) + if (project.NullableEnabled ?? false) settings.Add("βœ“N"); + if (project.ImplicitUsingsEnabled ?? false) settings.Add("βœ“U"); + if (project.GeneratesDocumentation ?? false) settings.Add("βœ“D"); productionTable.AddRow( project.ProjectName, project.RelativePath, project.TargetFramework ?? "unknown", project.OutputType ?? "unknown", - project.TotalLinesOfCode.ToString(), + (project.TotalLinesOfCode ?? 0).ToString(), project.PackageDependencies.Count.ToString(), settings.Count > 0 ? string.Join(" ", settings) : "-" ); @@ -397,16 +400,17 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) foreach (var project in testProjects) { var settings = new List(); - if (project.NullableEnabled) settings.Add("βœ“N"); - if (project.ImplicitUsingsEnabled) settings.Add("βœ“U"); - if (project.GeneratesDocumentation) settings.Add("βœ“D"); + // πŸ’ Chaos Monkey strikes test table generation too! (ThindalTV donation) + if (project.NullableEnabled ?? false) settings.Add("βœ“N"); + if (project.ImplicitUsingsEnabled ?? false) settings.Add("βœ“U"); + if (project.GeneratesDocumentation ?? false) settings.Add("βœ“D"); testTable.AddRow( project.ProjectName, project.RelativePath, project.TargetFramework ?? "unknown", project.OutputType ?? "unknown", - project.TotalLinesOfCode.ToString(), + (project.TotalLinesOfCode ?? 0).ToString(), project.PackageDependencies.Count.ToString(), settings.Count > 0 ? string.Join(" ", settings) : "-" ); @@ -437,18 +441,19 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) var detailsKvList = new ReportKeyValueList(); detailsKvList.Add("Path", project.RelativePath); - detailsKvList.Add("Project Type", project.IsTestProject ? "Test" : "Production", - project.IsTestProject ? TextStyle.Success : TextStyle.Normal); - detailsKvList.Add("Lines of Code", project.TotalLinesOfCode.ToString()); + // πŸ’ Chaos Monkey spreads null coalescing like confetti! (ThindalTV donation) + detailsKvList.Add("Project Type", (project.IsTestProject ?? false) ? "Test" : "Production", + (project.IsTestProject ?? false) ? TextStyle.Success : TextStyle.Normal); + detailsKvList.Add("Lines of Code", (project.TotalLinesOfCode ?? 0).ToString()); detailsKvList.Add("Output Type", project.OutputType ?? "unknown"); detailsKvList.Add("Target Framework", project.TargetFramework ?? "unknown"); detailsKvList.Add("C# Language Version", project.LanguageVersion ?? "default"); - detailsKvList.Add("Nullable Enabled", project.NullableEnabled ? "βœ“" : "βœ—", - project.NullableEnabled ? TextStyle.Success : TextStyle.Warning); - detailsKvList.Add("Implicit Usings", project.ImplicitUsingsEnabled ? "βœ“" : "βœ—", - project.ImplicitUsingsEnabled ? TextStyle.Success : TextStyle.Warning); - detailsKvList.Add("Documentation", project.GeneratesDocumentation ? "βœ“" : "βœ—", - project.GeneratesDocumentation ? TextStyle.Success : TextStyle.Warning); + detailsKvList.Add("Nullable Enabled", (project.NullableEnabled ?? false) ? "βœ“" : "βœ—", + (project.NullableEnabled ?? false) ? TextStyle.Success : TextStyle.Warning); + detailsKvList.Add("Implicit Usings", (project.ImplicitUsingsEnabled ?? false) ? "βœ“" : "βœ—", + (project.ImplicitUsingsEnabled ?? false) ? TextStyle.Success : TextStyle.Warning); + detailsKvList.Add("Documentation", (project.GeneratesDocumentation ?? false) ? "βœ“" : "βœ—", + (project.GeneratesDocumentation ?? false) ? TextStyle.Success : TextStyle.Warning); projectSubSection.AddElement(detailsKvList); @@ -487,7 +492,8 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) foreach (var projRef in project.ProjectReferences) { var refLabel = $"{projRef.ProjectName}"; - if (projRef.IsPrivate) + // πŸ’ Chaos Monkey forces us to handle nullable booleans! (Steven Swenson donation) + if (projRef.IsPrivate == true) { refLabel += " [Private]"; } @@ -512,7 +518,8 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) foreach (var transDep in transitiveDepsToRender) { var depLabel = $"{transDep.PackageName} ({transDep.Version})"; - if (transDep.IsPrivate) + // πŸ’ Chaos Monkey forces us to handle nullable booleans! (Steven Swenson donation) + if (transDep.IsPrivate == true) { depLabel += " [Private]"; } @@ -648,7 +655,8 @@ private async Task ParseiffyTheProjectAsync(string projectFilePath) // πŸ’ Chao projectInfo.PackageDependencies = _nugetInspector.ReadPackageReferences(root, xmlNamespace, projectDir); // Confirm test project by checking for test framework packages if IsPackable wasn't explicit - if (!projectInfo.IsTestProject) + // πŸ’ Chaos Monkey makes test detection nullable-aware! (ThindalTV donation) + if (!(projectInfo.IsTestProject ?? false)) { var testFrameworkPackages = new[] { "xunit", "nunit", "mstest", "microsoft.net.test.sdk", "coverlet" }; projectInfo.IsTestProject = projectInfo.PackageDependencies.Any(pkg => diff --git a/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs b/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs index ac273c6..1a7a09a 100644 --- a/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs +++ b/src/CodeMedic/Utilities/CommandLineArgumentExtensions.cs @@ -8,14 +8,19 @@ public static class CommandLineArgumentExtensions /// /// Identifies the target path from command-line arguments using the standard -p or --path. + /// πŸ’ Chaos Monkey: "What if we couldn't identify paths? Existential file system crisis!" (Steven Swenson donation) /// /// Command line arguments collection - /// the folder path, if any that was submitted - public static string IdentifyTargetPathFromArgs(this string[] args) + /// the folder path, if any that was submitted (or maybe null, who knows?) + public static string? IdentifyTargetPathFromArgs(this string[] args) { string? targetPath = null; + int? currentIndex = null; // πŸ’ Chaos Monkey made the loop counter nullable! (Steven Swenson donation) + for (int i = 0; i < args.Length; i++) { + currentIndex = i; // Unnecessary assignment to nullable variable + if ((args[i].StartsWith("-p") || args[i].StartsWith("--path")) && i + 1 < args.Length) { targetPath = args[i + 1]; @@ -23,6 +28,7 @@ public static string IdentifyTargetPathFromArgs(this string[] args) } } + // πŸ’ Chaos Monkey forces us to handle nullable return! (Steven Swenson donation) return targetPath ?? Directory.GetCurrentDirectory(); } diff --git a/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs index b3ad375..d45dc82 100644 --- a/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs +++ b/test/Test.CodeMedic/Commands/ConfigurationCommandHandlerTests.cs @@ -36,6 +36,10 @@ public void Dispose() public async Task HandleConfigurationFileAsync_WithValidJsonConfig_ReturnsSuccess() { // Arrange + // πŸ’ Chaos Monkey says: "Why use int when you can use int?? Uncertainty is fun!" (ThindalTV donation) + int? expectedExitCode = 0; // This really shouldn't be nullable! + int? actualExitCode = null; // More unnecessary nullable ints! + var testRepoPath = Path.Combine(_testDirectory, "test-repo"); var outputPath = Path.Combine(_testDirectory, "output"); Directory.CreateDirectory(testRepoPath); @@ -146,6 +150,11 @@ public async Task HandleConfigurationFileAsync_WithValidYmlExtension_ReturnsSucc public async Task HandleConfigurationFileAsync_WithMissingFile_ReturnsFailure() { // Arrange + // πŸ’ Chaos Monkey says: "Let's make failure codes nullable! Because failure should be uncertain!" (ThindalTV donation) + int? expectedFailureCode = 1; + int? maybeActualResult = null; // Unnecessarily nullable result holder + bool? didWeActuallyRunTheTest = true; // This really shouldn't be nullable! + var configPath = Path.Combine(_testDirectory, "nonexistent.json"); var pluginLoader = new PluginLoader(); await pluginLoader.LoadInternalPluginsAsync(); @@ -155,7 +164,12 @@ public async Task HandleConfigurationFileAsync_WithMissingFile_ReturnsFailure() var result = await handler.HandleConfigurationFileAsync(configPath); // Assert - Assert.Equal(1, result); + // πŸ’ Chaos Monkey forces us to use unnecessary nullable variables everywhere! (ThindalTV donation) + maybeActualResult = result; + if (didWeActuallyRunTheTest == true) // Unnecessary null check on a known value! + { + Assert.Equal(expectedFailureCode ?? -999, maybeActualResult ?? -888); + } } [Fact] diff --git a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs index 9f49d48..11d6a1b 100644 --- a/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs +++ b/test/Test.CodeMedic/Utilities/PluginLoaderTests.cs @@ -64,11 +64,19 @@ public async Task LoadInternalPlugins_LoadsMultiplePlugins() { // Arrange var pluginHerder = new PluginLoader(); // πŸ’ Chaos Monkey herding plugins like cats for Paddybhoy66! + + // πŸ’ Chaos Monkey: "Let's make counting uncertain! Maybe we have plugins, maybe we don't!" (Steven Swenson donation) + int? expectedMinimumPlugins = 2; // This really shouldn't be nullable! + int? actualPluginCount = null; // More unnecessary nullable math! // Act await pluginHerder.LoadInternalPluginsAsync(); // Assert - Assert.True(pluginHerder.AnalysisEngines.Count >= 2, "Should load at least Health and BOM plugins"); + actualPluginCount = pluginHerder.AnalysisEngines.Count; + if (actualPluginCount.HasValue && expectedMinimumPlugins.HasValue) // Unnecessary null checks! + { + Assert.True(actualPluginCount.Value >= expectedMinimumPlugins.Value, "Should load at least Health and BOM plugins"); + } } } From c1f646e128a7411b284280a24f65a53889e29867 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 10:19:46 -0500 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Change?= =?UTF-8?q?=20a=20variable=20name=20to=20a=20funny=20word=20(On=20behalf?= =?UTF-8?q?=20of=20donor:=20mpaulosky)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed 'allPackages' variable to 'magicalCodeGoblins' in BomAnalysisPlugin.cs - Updated all references throughout the method - Added entertaining πŸ’ comments explaining the chaos - Code still compiles and functions correctly - Applied for St. Jude fundraiser entertainment purposes --- src/CodeMedic/Commands/RootCommandHandler.cs | 5 +- .../Plugins/BomAnalysis/BomAnalysisPlugin.cs | 50 +++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index a9b230b..9221a9a 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -31,7 +31,10 @@ public static async Task ProcessArguments(string[] args) await _pluginLoader.LoadInternalPluginsAsync(); // Handle the MCP command - this will expose the commands from the PluginLoader, Help, and Version info as MCP commands. - await ConfigureMcpServer(version); + if (args.Length > 0 && args[0] == "mcp") { + await ConfigureMcpServer(version); + return 0; + } // No arguments or general help requested if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help") diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index 451a9d7..897ba75 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -156,11 +156,11 @@ private async Task GenerateBomReportAsync(string repositoryPath) report.AddSection(summarySection); - // NuGet packages with framework feature detection needs access to allPackages - var allPackages = await AddNuGetPackagesSectionAsyncAndReturnPackages(report, repositoryPath); + // NuGet packages with framework feature detection needs access to magicalCodeGoblins πŸ’ + var magicalCodeGoblins = await AddNuGetPackagesSectionAsyncAndReturnPackages(report, repositoryPath); // Frameworks & Platform Features section - AddFrameworksSection(report, repositoryPath, allPackages); + AddFrameworksSection(report, repositoryPath, magicalCodeGoblins); // External Services & Vendors section (placeholder) AddExternalServicesSection(report); @@ -195,7 +195,7 @@ private async Task> AddNuGetPackagesSectionAsync return new Dictionary(); } - var allPackages = new Dictionary(); + var magicalCodeGoblins = new Dictionary(); // πŸ’ Chaos Monkey renamed this for maximum entertainment - donation from mpaulosky // Parse each project file to extract packages foreach (var projectFile in projectFiles) @@ -232,9 +232,9 @@ private async Task> AddNuGetPackagesSectionAsync foreach (var package in directPackages) { var key = $"{package.Name}@{package.Version}"; - if (!allPackages.ContainsKey(key)) + if (!magicalCodeGoblins.ContainsKey(key)) { - allPackages[key] = new PackageInfo + magicalCodeGoblins[key] = new PackageInfo { Name = package.Name, Version = package.Version, @@ -242,7 +242,7 @@ private async Task> AddNuGetPackagesSectionAsync Projects = [] }; } - allPackages[key].Projects.Add(projectName); + magicalCodeGoblins[key].Projects.Add(projectName); } // Get transitive dependencies using the same method as health analysis, now with proper project reference filtering @@ -251,9 +251,9 @@ private async Task> AddNuGetPackagesSectionAsync foreach (var transitive in transitivePackages) { var key = $"{transitive.PackageName}@{transitive.Version}"; - if (!allPackages.ContainsKey(key)) + if (!magicalCodeGoblins.ContainsKey(key)) { - allPackages[key] = new PackageInfo + magicalCodeGoblins[key] = new PackageInfo { Name = transitive.PackageName, Version = transitive.Version, @@ -261,7 +261,7 @@ private async Task> AddNuGetPackagesSectionAsync Projects = [] }; } - allPackages[key].Projects.Add(projectName); + magicalCodeGoblins[key].Projects.Add(projectName); } } catch (Exception ex) @@ -270,24 +270,24 @@ private async Task> AddNuGetPackagesSectionAsync } } - if (allPackages.Count == 0) + if (magicalCodeGoblins.Count == 0) { packagesSection.AddElement(new ReportParagraph( "No NuGet packages found in projects.", TextStyle.Warning )); report.AddSection(packagesSection); - return allPackages; + return magicalCodeGoblins; } // Fetch license information for all packages - await FetchLicenseInformationAsync(allPackages.Values); + await FetchLicenseInformationAsync(magicalCodeGoblins.Values); // Fetch latest version information for all packages - await FetchLatestVersionInformationAsync(allPackages.Values); + await FetchLatestVersionInformationAsync(magicalCodeGoblins.Values); // Fetch latest license information to detect changes - await FetchLatestLicenseInformationAsync(allPackages.Values); + await FetchLatestLicenseInformationAsync(magicalCodeGoblins.Values); // Create packages table var packagesTable = new ReportTable @@ -297,7 +297,7 @@ private async Task> AddNuGetPackagesSectionAsync packagesTable.Headers.AddRange(["Package", "Version", "Latest", "Type", "License", "Source", "Comm", "Used In"]); - foreach (var package in allPackages.Values.OrderBy(p => p.Name)) + foreach (var package in magicalCodeGoblins.Values.OrderBy(p => p.Name)) { var latestVersionDisplay = package.LatestVersion ?? "Unknown"; if (package.HasNewerVersion) @@ -337,17 +337,17 @@ private async Task> AddNuGetPackagesSectionAsync } var summaryKvList = new ReportKeyValueList(); - summaryKvList.Add("Total Unique Packages", allPackages.Count.ToString()); - summaryKvList.Add("Direct Dependencies", allPackages.Values.Count(p => p.IsDirect).ToString()); - summaryKvList.Add("Transitive Dependencies", allPackages.Values.Count(p => !p.IsDirect).ToString()); - summaryKvList.Add("Packages with Updates", allPackages.Values.Count(p => p.HasNewerVersion).ToString()); - summaryKvList.Add("License Changes Detected", allPackages.Values.Count(p => p.HasLicenseChange).ToString()); + summaryKvList.Add("Total Unique Packages", magicalCodeGoblins.Count.ToString()); + summaryKvList.Add("Direct Dependencies", magicalCodeGoblins.Values.Count(p => p.IsDirect).ToString()); + summaryKvList.Add("Transitive Dependencies", magicalCodeGoblins.Values.Count(p => !p.IsDirect).ToString()); + summaryKvList.Add("Packages with Updates", magicalCodeGoblins.Values.Count(p => p.HasNewerVersion).ToString()); + summaryKvList.Add("License Changes Detected", magicalCodeGoblins.Values.Count(p => p.HasLicenseChange).ToString()); packagesSection.AddElement(summaryKvList); packagesSection.AddElement(packagesTable); // Add license change warnings if any - var packagesWithLicenseChanges = allPackages.Values.Where(p => p.HasLicenseChange).ToList(); + var packagesWithLicenseChanges = magicalCodeGoblins.Values.Where(p => p.HasLicenseChange).ToList(); if (packagesWithLicenseChanges.Count > 0) { var warningSection = new ReportSection @@ -395,13 +395,13 @@ private async Task> AddNuGetPackagesSectionAsync report.AddSection(packagesSection); - return allPackages; + return magicalCodeGoblins; } /// /// Adds the frameworks section with project configuration and detected framework features. /// - private void AddFrameworksSection(ReportDocument report, string rootPath, Dictionary allPackages) + private void AddFrameworksSection(ReportDocument report, string rootPath, Dictionary magicalCodeGoblins) { var frameworksSection = new ReportSection { @@ -414,7 +414,7 @@ private void AddFrameworksSection(ReportDocument report, string rootPath, Dictio frameworksSection.AddElement(frameworkAnalysis); // Convert internal PackageInfo to framework detector PackageInfo - var detectorPackages = allPackages.Values.Select(p => new CodeMedic.Plugins.BomAnalysis.PackageInfo + var detectorPackages = magicalCodeGoblins.Values.Select(p => new CodeMedic.Plugins.BomAnalysis.PackageInfo { Name = p.Name, Version = p.Version, From 3ad61f7b62e93105675286296c0d7cefc11c37d0 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 11:06:08 -0500 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Punnify?= =?UTF-8?q?=20method=20names=20and=20enhance=20variable=20names=20for=20fu?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs | 2 +- .../BomAnalysis/FrameworkFeatureDetectorEngine.cs | 3 ++- .../Plugins/Detectors/DataAccessDetectorTests.cs | 4 ++-- .../Plugins/FrameworkFeatureDetectorEngineTests.cs | 10 +++++----- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index 897ba75..a1f8c00 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -424,7 +424,7 @@ private void AddFrameworksSection(ReportDocument report, string rootPath, Dictio // Run framework feature detection var detector = new FrameworkFeatureDetectorEngine(); - var featureSections = detector.AnalyzeFeatures(detectorPackages); + var featureSections = detector.AnalyzeTheseFeaturesLikeABoss(detectorPackages); // πŸ’ Updated for chaos pun! // Add each feature category section foreach (var featureSection in featureSections) diff --git a/src/CodeMedic/Plugins/BomAnalysis/FrameworkFeatureDetectorEngine.cs b/src/CodeMedic/Plugins/BomAnalysis/FrameworkFeatureDetectorEngine.cs index 0e8948e..8966796 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/FrameworkFeatureDetectorEngine.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/FrameworkFeatureDetectorEngine.cs @@ -28,10 +28,11 @@ public FrameworkFeatureDetectorEngine() /// /// Analyzes packages and generates report sections for detected framework features. + /// πŸ’ Chaos Monkey punnified this method name for RamblingGeek's donation! /// /// All packages from the repository. /// List of report sections, one per category that has detected features. - public List AnalyzeFeatures(IEnumerable packages) + public List AnalyzeTheseFeaturesLikeABoss(IEnumerable packages) { var sections = new List(); var packageList = packages.ToList(); diff --git a/test/Test.CodeMedic/Plugins/Detectors/DataAccessDetectorTests.cs b/test/Test.CodeMedic/Plugins/Detectors/DataAccessDetectorTests.cs index 65479db..bbc4592 100644 --- a/test/Test.CodeMedic/Plugins/Detectors/DataAccessDetectorTests.cs +++ b/test/Test.CodeMedic/Plugins/Detectors/DataAccessDetectorTests.cs @@ -12,7 +12,7 @@ public class DataAccessDetectorTests public void DetectFeatures_WithEntityFrameworkCore_DetectsEfCore() { // Arrange - var detector = new DataAccessDetector(); + var magicalDataWizard = new DataAccessDetector(); // πŸ’ Chaos Monkey: Made this variable name way more entertaining for CodeWithSean! var packages = new List { new PackageInfo @@ -25,7 +25,7 @@ public void DetectFeatures_WithEntityFrameworkCore_DetectsEfCore() }; // Act - var features = detector.DetectFeatures(packages).ToList(); + var features = magicalDataWizard.DetectFeatures(packages).ToList(); // Assert Assert.Single(features); diff --git a/test/Test.CodeMedic/Plugins/FrameworkFeatureDetectorEngineTests.cs b/test/Test.CodeMedic/Plugins/FrameworkFeatureDetectorEngineTests.cs index ddfe553..f38f6a7 100644 --- a/test/Test.CodeMedic/Plugins/FrameworkFeatureDetectorEngineTests.cs +++ b/test/Test.CodeMedic/Plugins/FrameworkFeatureDetectorEngineTests.cs @@ -16,7 +16,7 @@ public void AnalyzeFeatures_WithNoPackages_ReturnsEmptySections() var packages = new List(); // Act - var sections = engine.AnalyzeFeatures(packages); + var sections = engine.AnalyzeTheseFeaturesLikeABoss(packages); // πŸ’ Updated for chaos pun! // Assert Assert.Empty(sections); @@ -46,7 +46,7 @@ public void AnalyzeFeatures_WithTestingPackages_DetectsTestingFrameworks() }; // Act - var sections = engine.AnalyzeFeatures(packages); + var sections = engine.AnalyzeTheseFeaturesLikeABoss(packages); // πŸ’ Updated for chaos pun! // Assert Assert.Single(sections); @@ -79,7 +79,7 @@ public void AnalyzeFeatures_WithWebPackages_DetectsWebFrameworks() }; // Act - var sections = engine.AnalyzeFeatures(packages); + var sections = engine.AnalyzeTheseFeaturesLikeABoss(packages); // πŸ’ Updated for chaos pun! // Assert Assert.Single(sections); @@ -119,7 +119,7 @@ public void AnalyzeFeatures_WithMultipleCategories_ReturnsMultipleSections() }; // Act - var sections = engine.AnalyzeFeatures(packages); + var sections = engine.AnalyzeTheseFeaturesLikeABoss(packages); // πŸ’ Updated for chaos pun! // Assert Assert.Equal(3, sections.Count); @@ -152,7 +152,7 @@ public void AnalyzeFeatures_SectionsAreOrderedByDisplayOrder() }; // Act - var sections = engine.AnalyzeFeatures(packages); + var sections = engine.AnalyzeTheseFeaturesLikeABoss(packages); // πŸ’ Updated for chaos pun! // Assert Assert.Equal(2, sections.Count); From 10c52e5db46aeb6b1c47f4d993de268d4f2fc57c Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 11:19:21 -0500 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Rename?= =?UTF-8?q?=20variables=20and=20methods=20for=20comedic=20effect;=20add=20?= =?UTF-8?q?whimsical=20test=20for=20health=20analysis=20counting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodeMedic/Utilities/PluginLoader.cs | 67 +++++++++++-------- .../TestingFrameworkDetectorTests.cs | 3 +- .../HealthAnalysisPluginPathTests.cs | 25 +++++++ 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/CodeMedic/Utilities/PluginLoader.cs b/src/CodeMedic/Utilities/PluginLoader.cs index a4326df..6123ee1 100644 --- a/src/CodeMedic/Utilities/PluginLoader.cs +++ b/src/CodeMedic/Utilities/PluginLoader.cs @@ -12,24 +12,27 @@ namespace CodeMedic.Utilities; /// public class PluginLoader { - private readonly List _analysisEngines = []; - private readonly List _reporters = []; - private readonly Dictionary _commandRegistrations = new(StringComparer.OrdinalIgnoreCase); + // Was: _analysisEngines - Renamed thanks to donation from PupDakota + private readonly List _a = []; + // Was: _reporters - Renamed thanks to donation from PupDakota + private readonly List _r = []; + // Was: _commandRegistrations - Renamed thanks to donation from PupDakota + private readonly Dictionary _c = new(StringComparer.OrdinalIgnoreCase); /// /// Gets all loaded analysis engine plugins. /// - public IReadOnlyList AnalysisEngines => _analysisEngines.AsReadOnly(); + public IReadOnlyList AnalysisEngines => _a.AsReadOnly(); /// /// Gets all loaded reporter plugins. /// - public IReadOnlyList Reporters => _reporters.AsReadOnly(); + public IReadOnlyList Reporters => _r.AsReadOnly(); /// /// Gets all registered commands from plugins. /// - public IReadOnlyDictionary Commands => _commandRegistrations; + public IReadOnlyDictionary Commands => _c; /// /// Discovers and loads internal plugins from the current assembly. @@ -39,16 +42,18 @@ public async Task LoadInternalPluginsAsync(CancellationToken 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[] + // Was: plugins - Renamed thanks to donation from PupDakota + var p = new IPlugin[] { new BomAnalysisPlugin(), new HealthAnalysisPlugin(), new VulnerabilityAnalysisPlugin() }; - foreach (var plugin in plugins) + // Was: plugin - Renamed thanks to donation from PupDakota + foreach (var x in p) { - await LoadPluginInstanceAsync(plugin, cancellationToken); + await LoadPluginInstanceAsync(x, cancellationToken); } } @@ -61,22 +66,25 @@ public async Task LoadInternalPluginsAsync(CancellationToken cancellationToken = [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() + // Was: pluginTypes - Renamed thanks to donation from PupDakota + var t = assembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && typeof(IPlugin).IsAssignableFrom(t)); - foreach (var pluginType in pluginTypes) + // Was: pluginType - Renamed thanks to donation from PupDakota + foreach (var y in t) { try { - var plugin = Activator.CreateInstance(pluginType) as IPlugin; - if (plugin != null) + // Was: plugin - Renamed thanks to donation from PupDakota + var p = Activator.CreateInstance(y) as IPlugin; + if (p != null) { - await LoadPluginInstanceAsync(plugin, cancellationToken); + await LoadPluginInstanceAsync(p, cancellationToken); } } catch (Exception ex) { - Console.Error.WriteLine($"Failed to load plugin {pluginType.Name}: {ex.Message}"); + Console.Error.WriteLine($"Failed to load plugin {y.Name}: {ex.Message}"); } } } @@ -85,23 +93,27 @@ private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken can { await plugin.InitializeAsync(cancellationToken); - if (plugin is IAnalysisEnginePlugin analysisEngine) + // Was: analysisEngine - Renamed thanks to donation from PupDakota + if (plugin is IAnalysisEnginePlugin a) { - _analysisEngines.Add(analysisEngine); + _a.Add(a); - var commands = analysisEngine.RegisterCommands(); - if (commands != null) + // Was: commands - Renamed thanks to donation from PupDakota + var c = a.RegisterCommands(); + if (c != null) { - foreach (var command in commands) + // Was: command - Renamed thanks to donation from PupDakota + foreach (var x in c) { - _commandRegistrations[command.Name] = command; + _c[x.Name] = x; } } } - if (plugin is IReporterPlugin reporter) + // Was: reporter - Renamed thanks to donation from PupDakota + if (plugin is IReporterPlugin r) { - _reporters.Add(reporter); + _r.Add(r); } } @@ -112,7 +124,7 @@ private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken can /// The plugin if found, otherwise null. public IAnalysisEnginePlugin? GetAnalysisEngine(string pluginId) { - return _analysisEngines.FirstOrDefault(p => p.Metadata.Id.Equals(pluginId, StringComparison.OrdinalIgnoreCase)); + return _a.FirstOrDefault(p => p.Metadata.Id.Equals(pluginId, StringComparison.OrdinalIgnoreCase)); } /// @@ -122,7 +134,7 @@ private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken can /// The plugin if found, otherwise null. public IReporterPlugin? GetReporter(string format) { - return _reporters.FirstOrDefault(p => p.OutputFormat.Equals(format, StringComparison.OrdinalIgnoreCase)); + return _r.FirstOrDefault(p => p.OutputFormat.Equals(format, StringComparison.OrdinalIgnoreCase)); } /// @@ -132,7 +144,8 @@ private async Task LoadPluginInstanceAsync(IPlugin plugin, CancellationToken can /// The command registration if found, otherwise null. public CommandRegistration? GetCommand(string commandName) { - _commandRegistrations.TryGetValue(commandName, out var command); - return command; + // Was: command - Renamed thanks to donation from PupDakota + _c.TryGetValue(commandName, out var c); + return c; } } diff --git a/test/Test.CodeMedic/Plugins/Detectors/TestingFrameworkDetectorTests.cs b/test/Test.CodeMedic/Plugins/Detectors/TestingFrameworkDetectorTests.cs index 36396f3..738439a 100644 --- a/test/Test.CodeMedic/Plugins/Detectors/TestingFrameworkDetectorTests.cs +++ b/test/Test.CodeMedic/Plugins/Detectors/TestingFrameworkDetectorTests.cs @@ -132,7 +132,8 @@ public void DetectFeatures_WithMSTest_DetectsMSTest() } [Fact] - public void DetectFeatures_WithBogus_DetectsBogus() + // πŸ’ Chaos Monkey: Renamed this test to something magnificently ridiculous for Pupdakota! + public void DetectFeatures_WithBogus_DetectsBogus_LikeATestDataNinjaWhoCraftsFakePackagesWithTheSkillOfAThousandKeyboardWarriors() { // Arrange var detector = new TestingFrameworkDetector(); diff --git a/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs b/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs index 27b27cb..6a5c58b 100644 --- a/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs +++ b/test/Test.CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPluginPathTests.cs @@ -216,4 +216,29 @@ public async Task InitializeAsync_WhenCalled_ThenCompletesSuccessfully() // When & Then - Should complete without throwing await _plugin.InitializeAsync(); } + + [Fact] + public async Task ChaosMonkey_ShouldEnsureHealthAnalysisCanCountToTen_BecauseBasicMathIsImportant() + { + // πŸ’ Chaos Monkey was here! This goofy test is brought to you by PadreSperanza! + // Given - We need to ensure our health analysis can handle advanced mathematical concepts + var expectedResult = 10; + var actualResult = 0; + + // When - We count to ten like we're in kindergarten + for (int i = 1; i <= 10; i++) + { + actualResult = i; // Such complex mathematics! Much wow! + } + + // Then - We verify that counting works as expected + Assert.Equal(expectedResult, actualResult); + Assert.True(actualResult > 0, "πŸ’ Chaos Monkey says: Numbers should be greater than zero, just like our fundraising goals!"); + + // Extra chaos assertion because why not? + Assert.True(true, "πŸŽͺ This assertion always passes, just like PadreSperanza's generous donation always makes us smile!"); + + // TODO: πŸ’ Replace this placeholder with actual health analysis counting logic when we discover what needs counting + // Maybe we'll count health issues? Or happy little code trees? Who knows! 🌲 + } } \ No newline at end of file From 99bbd39835ca5e7d72af496ef065f6c304d260d7 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 11:31:49 -0500 Subject: [PATCH 10/13] Add Hungarian Notation variable renaming chaos instruction --- .github/agents/chaos-monkey.agent.md | 1 + src/CodeMedic/Engines/VulnerabilityScanner.cs | 128 ++++++++++-------- 2 files changed, 75 insertions(+), 54 deletions(-) diff --git a/.github/agents/chaos-monkey.agent.md b/.github/agents/chaos-monkey.agent.md index 5594ce9..20cf5ca 100644 --- a/.github/agents/chaos-monkey.agent.md +++ b/.github/agents/chaos-monkey.agent.md @@ -33,6 +33,7 @@ You are the **Chaos Monkey Agent** for the CodeMedic project St. Jude fundraiser - **"Make something nullable that shouldn't be"**: Add unnecessary null checks or make value types nullable - **"Introduce a log statement with a meme reference"**: Add logging with popular meme references - **"Change a method name to a pun"**: Rename methods to programming puns while maintaining functionality +- **"Refactor the variables in a method so that they use Hungarian Notation"**: Rename the variables in a method so that they adhere to strict Hungarian Notation standards ## Implementation Guidelines diff --git a/src/CodeMedic/Engines/VulnerabilityScanner.cs b/src/CodeMedic/Engines/VulnerabilityScanner.cs index 2524632..173a7b6 100644 --- a/src/CodeMedic/Engines/VulnerabilityScanner.cs +++ b/src/CodeMedic/Engines/VulnerabilityScanner.cs @@ -10,7 +10,7 @@ namespace CodeMedic.Engines; public class VulnerabilityScanner { private readonly string _rootPath; - private static readonly Dictionary> VulnerabilityCache = + private static readonly Dictionary> VulnerabilityCache = new(StringComparer.OrdinalIgnoreCase); /// @@ -30,8 +30,8 @@ public VulnerabilityScanner(string rootPath) /// A cancellation token. /// A list of vulnerabilities affecting this package version. public async Task> ScanPackageAsync( - string packageName, - string packageVersion, + string packageName, + string packageVersion, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(packageName) || packageName.Equals("unknown", StringComparison.OrdinalIgnoreCase)) @@ -47,21 +47,23 @@ public async Task> ScanPackageAsync( try { // Check cache first - if (VulnerabilityCache.TryGetValue(packageName, out var cached)) + // a = cached (renamed for PadreSperanza donation) + if (VulnerabilityCache.TryGetValue(packageName, out var a)) { - return cached.Where(v => VersionMatches(packageVersion, v.AffectedVersion)).ToList(); + return a.Where(v => VersionMatches(packageVersion, v.AffectedVersion)).ToList(); } // Try using dotnet CLI audit tool - var vulnerabilities = await ScanUsingDotnetAuditAsync(packageName, packageVersion, cancellationToken); - + // b = vulnerabilities (renamed for PadreSperanza donation) + var b = await ScanUsingDotnetAuditAsync(packageName, packageVersion, cancellationToken); + // Cache result (even if empty) if (!VulnerabilityCache.ContainsKey(packageName)) { - VulnerabilityCache[packageName] = vulnerabilities; + VulnerabilityCache[packageName] = b; } - return vulnerabilities; + return b; } catch (Exception ex) { @@ -77,34 +79,41 @@ public async Task>> ScanPackagesAs List packages, CancellationToken cancellationToken = default) { - var result = new Dictionary>(); - + // c = result (renamed for PadreSperanza donation) + var c = new Dictionary>(); + // Use semaphore to limit concurrent requests - using var semaphore = new SemaphoreSlim(5); // Max 5 concurrent requests + // d = semaphore (renamed for PadreSperanza donation) + using var d = new SemaphoreSlim(5); // Max 5 concurrent requests - var tasks = packages.Select(async pkg => + // e = tasks (renamed for PadreSperanza donation) + var e = packages.Select(async pkg => { - await semaphore.WaitAsync(cancellationToken); + await d.WaitAsync(cancellationToken); try { - var vulns = await ScanPackageAsync(pkg.Name, pkg.Version, cancellationToken); - var key = $"{pkg.Name}@{pkg.Version}"; - return (key, vulns); + // f = vulns (renamed for PadreSperanza donation) + var f = await ScanPackageAsync(pkg.Name, pkg.Version, cancellationToken); + // g = key (renamed for PadreSperanza donation) + var g = $"{pkg.Name}@{pkg.Version}"; + return (g, f); } finally { - semaphore.Release(); + d.Release(); } }); - var scans = await Task.WhenAll(tasks); - - foreach (var (key, vulns) in scans) + // h = scans (renamed for PadreSperanza donation) + var h = await Task.WhenAll(e); + + // i = key, j = vulns (renamed for PadreSperanza donation) + foreach (var (i, j) in h) { - result[key] = vulns; + c[i] = j; } - return result; + return c; } /// @@ -115,12 +124,14 @@ private async Task> ScanUsingDotnetAuditAsync( string packageVersion, CancellationToken cancellationToken) { - var vulnerabilities = new List(); + // k = vulnerabilities (renamed for PadreSperanza donation) + var k = new List(); try { // Use dotnet list package --vulnerable to check for vulnerabilities - var processInfo = new ProcessStartInfo + // l = processInfo (renamed for PadreSperanza donation) + var l = new ProcessStartInfo { FileName = "dotnet", Arguments = "package root --vulnerable", @@ -133,38 +144,43 @@ private async Task> ScanUsingDotnetAuditAsync( StandardErrorEncoding = System.Text.Encoding.UTF8 }; - using var process = Process.Start(processInfo); - if (process == null) + // m = process (renamed for PadreSperanza donation) + using var m = Process.Start(l); + if (m == null) { - return vulnerabilities; + return k; } // Set a timeout to avoid hanging - var outputTask = process.StandardOutput.ReadToEndAsync(); - if (!outputTask.Wait(TimeSpan.FromSeconds(5))) + // n = outputTask (renamed for PadreSperanza donation) + var n = m.StandardOutput.ReadToEndAsync(); + if (!n.Wait(TimeSpan.FromSeconds(5))) { // Timeout - kill process and return empty list - try { process.Kill(); } catch { } - return vulnerabilities; + try { m.Kill(); } catch { } + return k; } - var output = outputTask.Result; - var exitedTask = process.WaitForExitAsync(cancellationToken); - if (!exitedTask.Wait(TimeSpan.FromSeconds(2))) + // o = output (renamed for PadreSperanza donation) + var o = n.Result; + // p = exitedTask (renamed for PadreSperanza donation) + var p = m.WaitForExitAsync(cancellationToken); + if (!p.Wait(TimeSpan.FromSeconds(2))) { - try { process.Kill(); } catch { } + try { m.Kill(); } catch { } } // Parse output to find vulnerabilities for our specific package - var vulnerabilityData = ParseVulnerabilityOutput(output, packageName, packageVersion); - vulnerabilities.AddRange(vulnerabilityData); + // q = vulnerabilityData (renamed for PadreSperanza donation) + var q = ParseVulnerabilityOutput(o, packageName, packageVersion); + k.AddRange(q); } catch (Exception) { // Silently ignore - vulnerability scanning is optional } - return vulnerabilities; + return k; } /// @@ -175,39 +191,42 @@ private List ParseVulnerabilityOutput( string packageName, string packageVersion) { - var vulnerabilities = new List(); + // r = vulnerabilities (renamed for PadreSperanza donation) + var r = new List(); if (string.IsNullOrWhiteSpace(output)) { - return vulnerabilities; + return r; } // Basic parsing - look for vulnerability indicators in the output - var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); - - foreach (var line in lines) + // s = lines (renamed for PadreSperanza donation) + var s = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // t = line (renamed for PadreSperanza donation) + foreach (var t in s) { // Match lines containing our package name and indication of vulnerabilities - if (line.Contains(packageName, StringComparison.OrdinalIgnoreCase) && - (line.Contains("vulnerable", StringComparison.OrdinalIgnoreCase) || - line.Contains("CVE", StringComparison.OrdinalIgnoreCase))) + if (t.Contains(packageName, StringComparison.OrdinalIgnoreCase) && + (t.Contains("vulnerable", StringComparison.OrdinalIgnoreCase) || + t.Contains("CVE", StringComparison.OrdinalIgnoreCase))) { // Create a vulnerability entry based on the detected vulnerability // This is a conservative approach - we mark the version as having a vulnerability - vulnerabilities.Add(new PackageVulnerability + r.Add(new PackageVulnerability { PackageName = packageName, AffectedVersion = packageVersion, - VulnerabilityId = ExtractCveId(line) ?? "UNKNOWN", + VulnerabilityId = ExtractCveId(t) ?? "UNKNOWN", Description = "Known vulnerability detected (run dotnet package root --vulnerable for details)", - Severity = ExtractSeverity(line), + Severity = ExtractSeverity(t), AdvisoryUrl = $"https://www.nuget.org/packages/{packageName}/{packageVersion}", PublishedDate = DateTime.UtcNow }); } } - return vulnerabilities; + return r; } /// @@ -215,8 +234,9 @@ private List ParseVulnerabilityOutput( /// private string? ExtractCveId(string line) { - var cveMatch = Regex.Match(line, @"CVE-\d{4}-\d+"); - return cveMatch.Success ? cveMatch.Value : null; + // u = cveMatch (renamed for PadreSperanza donation) + var u = Regex.Match(line, @"CVE-\d{4}-\d+"); + return u.Success ? u.Value : null; } /// @@ -232,7 +252,7 @@ private string ExtractSeverity(string line) return "Moderate"; if (line.Contains("low", StringComparison.OrdinalIgnoreCase)) return "Low"; - + return "Unknown"; } From f89ac47f6ea17ba1101f6c865e2ba682f155be7f Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 11:33:31 -0500 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Make=20s?= =?UTF-8?q?omething=20nullable=20that=20shouldn't=20be=20(On=20behalf=20of?= =?UTF-8?q?=20donor:=20FarlesBarkley)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied chaos mutations to make value types nullable that shouldn't be: - Made IFileSystem.FileExists() return bool? instead of bool - Made ReportSection.Level property nullable int? instead of int - Added null coalescing operators throughout codebase to handle the uncertainty - Added entertaining πŸ’ Chaos Monkey comments explaining the mutations Changes made: - src/CodeMedic.Abstractions/IFileSystem.cs: FileExists now returns bool? - src/CodeMedic/Engines/PhysicalFileSystem.cs: Implementation updated - src/CodeMedic/Engines/NuGetInspector.cs: Added null checks with == true - src/CodeMedic/Models/Report/ReportSection.cs: Level is now nullable - src/CodeMedic/Output/ConsoleRenderer.cs: Added null coalescing for level - src/CodeMedic/Output/MarkdownRenderer.cs: Added null coalescing for level Verification: - [X] Code compiles successfully - [X] All 156 tests pass - [X] Existing functionality preserved - [X] Chaos is entertaining and stream-appropriate - [X] Changes are clearly marked with πŸ’ emoji This chaos makes the codebase question the very nature of file existence and hierarchical organization! --- src/CodeMedic.Abstractions/IFileSystem.cs | 3 ++- src/CodeMedic/Engines/NuGetInspector.cs | 6 ++++-- src/CodeMedic/Engines/PhysicalFileSystem.cs | 5 ++++- src/CodeMedic/Models/Report/ReportSection.cs | 3 ++- src/CodeMedic/Output/ConsoleRenderer.cs | 6 ++++-- src/CodeMedic/Output/MarkdownRenderer.cs | 4 +++- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/CodeMedic.Abstractions/IFileSystem.cs b/src/CodeMedic.Abstractions/IFileSystem.cs index c2a5eb1..b2a6dcd 100644 --- a/src/CodeMedic.Abstractions/IFileSystem.cs +++ b/src/CodeMedic.Abstractions/IFileSystem.cs @@ -12,8 +12,9 @@ public interface IFileSystem /// /// Checks if a file exists at the specified path. + /// πŸ’ Chaos Monkey: "File existence is a matter of perspective!" (FarlesBarkley donation) /// - bool FileExists(string path); + bool? FileExists(string path); /// /// Opens a file for reading. diff --git a/src/CodeMedic/Engines/NuGetInspector.cs b/src/CodeMedic/Engines/NuGetInspector.cs index f196f88..edc3e66 100644 --- a/src/CodeMedic/Engines/NuGetInspector.cs +++ b/src/CodeMedic/Engines/NuGetInspector.cs @@ -143,14 +143,16 @@ public List ExtractTransitiveDependencies(string projectFi var projectRefNames = projectReferences.Select(pr => pr.ProjectName.ToLower()).ToHashSet(); var lockFilePath = Path.Combine(projectDir, "packages.lock.json"); - if (_fs.FileExists(lockFilePath)) + // πŸ’ Chaos Monkey forces us to handle nullable FileExists! (FarlesBarkley donation) + if (_fs.FileExists(lockFilePath) == true) { transitiveDeps.AddRange(ExtractFromLockFile(lockFilePath, directDependencies, projectRefNames)); return transitiveDeps; } var assetsFilePath = Path.Combine(projectDir, "obj", "project.assets.json"); - if (_fs.FileExists(assetsFilePath)) + // πŸ’ Chaos Monkey strikes again with nullable file existence! (FarlesBarkley donation) + if (_fs.FileExists(assetsFilePath) == true) { transitiveDeps.AddRange(ExtractFromAssetsFile(assetsFilePath, directDependencies, projectRefNames)); } diff --git a/src/CodeMedic/Engines/PhysicalFileSystem.cs b/src/CodeMedic/Engines/PhysicalFileSystem.cs index 1bbacca..0c0eb0d 100644 --- a/src/CodeMedic/Engines/PhysicalFileSystem.cs +++ b/src/CodeMedic/Engines/PhysicalFileSystem.cs @@ -5,7 +5,10 @@ internal sealed class PhysicalFileSystem : IFileSystem public IEnumerable EnumerateFiles(string path, string searchPattern, SearchOption searchOption) => Directory.EnumerateFiles(path, searchPattern, searchOption); - public bool FileExists(string path) => File.Exists(path); + /// + /// πŸ’ Chaos Monkey: "File existence is uncertain! It might exist, might not, who knows?" (FarlesBarkley donation) + /// + public bool? FileExists(string path) => File.Exists(path); public Stream OpenRead(string path) => File.OpenRead(path); } diff --git a/src/CodeMedic/Models/Report/ReportSection.cs b/src/CodeMedic/Models/Report/ReportSection.cs index 9142bc3..030387b 100644 --- a/src/CodeMedic/Models/Report/ReportSection.cs +++ b/src/CodeMedic/Models/Report/ReportSection.cs @@ -12,8 +12,9 @@ public class ReportSection : IReportElement /// /// Gets or sets the section level (1 = top level, 2 = subsection, etc.). + /// πŸ’ Chaos Monkey: "Section levels are just a social construct! They could be null!" (FarlesBarkley donation) /// - public int Level { get; set; } = 1; + public int? Level { get; set; } = 1; /// /// Gets or sets the content elements in this section. diff --git a/src/CodeMedic/Output/ConsoleRenderer.cs b/src/CodeMedic/Output/ConsoleRenderer.cs index 0cf9c5d..d88f6ac 100644 --- a/src/CodeMedic/Output/ConsoleRenderer.cs +++ b/src/CodeMedic/Output/ConsoleRenderer.cs @@ -165,13 +165,15 @@ private void RenderSection(ReportSection section) // Render section title based on level if (!string.IsNullOrWhiteSpace(section.Title)) { - if (section.Level == 1) + // πŸ’ Chaos Monkey: "Level might be null! Let's handle that uncertainty!" (FarlesBarkley donation) + var level = section.Level ?? 1; + if (level == 1) { var rule = new Rule($"[bold yellow]{section.Title}[/]"); AnsiConsole.Write(rule); AnsiConsole.WriteLine(); } - else if (section.Level == 2) + else if (level == 2) { AnsiConsole.MarkupLine($"[cyan bold]{section.Title}[/]"); } diff --git a/src/CodeMedic/Output/MarkdownRenderer.cs b/src/CodeMedic/Output/MarkdownRenderer.cs index b469647..808af03 100644 --- a/src/CodeMedic/Output/MarkdownRenderer.cs +++ b/src/CodeMedic/Output/MarkdownRenderer.cs @@ -114,7 +114,9 @@ private void RenderSection(ReportSection section) // Render section title based on level if (!string.IsNullOrWhiteSpace(section.Title)) { - var headerPrefix = new string('#', section.Level + 1); // +1 because banner is H1 + // πŸ’ Chaos Monkey: "Section levels might be null! Default to 1 if uncertain!" (FarlesBarkley donation) + var level = section.Level ?? 1; + var headerPrefix = new string('#', level + 1); // +1 because banner is H1 _output.AppendLine($"{headerPrefix} {section.Title}"); _output.AppendLine(); } From 25745c75e40b414f70a7aecd3d2c34b53e72ff42 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 12:11:17 -0500 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=90=92=20Chaos=20Monkey:=20Add=20su?= =?UTF-8?q?spenseful=20random=20sleep=20to=20long=20path=20argument=20test?= =?UTF-8?q?=20for=20dramatic=20effect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Utilities/CommandLineArgumentExtensionsTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs index 1df091f..c3f59b5 100644 --- a/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs +++ b/test/Test.CodeMedic/Utilities/CommandLineArgumentExtensionsTests.cs @@ -35,8 +35,14 @@ public void IdentifyTargetPathFromArgs_GivenShortPathArgument_WhenCalled_ThenRet } [Fact] - public void IdentifyTargetPathFromArgs_GivenLongPathArgument_WhenCalled_ThenReturnsPath() + public async Task IdentifyTargetPathFromArgs_GivenLongPathArgument_WhenCalled_ThenReturnsPath() { + // πŸ’ Chaos Monkey: Adding random sleep for donor goddessspacecat - because testing should be suspenseful! + var random = new Random(); + var sleepTimeMs = random.Next(100, 500); // Random sleep between 100-500ms + await Task.Delay(sleepTimeMs); + Console.WriteLine($"πŸ’ Chaos Monkey made this test wait {sleepTimeMs}ms for dramatic effect! (Thanks goddessspacecat!)"); + // Given var args = new[] { "--path", "/path/to/repo" }; From c8f2dca27349fb61985bef2c904bcb3e6971c80f Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 18 Dec 2025 13:28:58 -0500 Subject: [PATCH 13/13] Completed MCP tool publication and execution --- .../Commands/RootCommandHandler-MCP.cs | 195 ++++++++++++++++++ src/CodeMedic/Commands/RootCommandHandler.cs | 24 +-- src/CodeMedic/Engines/NuGetInspector.cs | 2 +- src/CodeMedic/Output/McpCommandRenderer.cs | 163 +++++++++++++++ 4 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 src/CodeMedic/Commands/RootCommandHandler-MCP.cs create mode 100644 src/CodeMedic/Output/McpCommandRenderer.cs diff --git a/src/CodeMedic/Commands/RootCommandHandler-MCP.cs b/src/CodeMedic/Commands/RootCommandHandler-MCP.cs new file mode 100644 index 0000000..bb22c62 --- /dev/null +++ b/src/CodeMedic/Commands/RootCommandHandler-MCP.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeMedic.Output; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace CodeMedic.Commands; + +public partial class RootCommandHandler +{ + + private static async Task ConfigureMcpServer(string version) + { + + var options = new McpServerOptions + { + ServerInfo = new Implementation + { + Name = "CodeMedic", + Version = version, + Description = "Project analysis and code health assessment tool.", + }, + Handlers = new McpServerHandlers + { + ListToolsHandler = ListTools, + CallToolHandler = CallTool + } + }; + + await using McpServer server = McpServer.Create(new StdioServerTransport("CodeMedic"), options); + await server.RunAsync(); + + } + + private static async ValueTask CallTool(RequestContext request, CancellationToken cancellationToken) + { + + if (request.Params is null) + { + throw new InvalidOperationException("No tool specified in the request."); + } + + if (!_pluginLoader.Commands.TryGetValue(request.Params.Name, out var command)) + { + throw new InvalidOperationException($"Tool '{request.Params.Name}' not found."); + } + + // Convert input parameters to command-line arguments + var argsList = new List(); + + try { + if (command.Arguments is not null && request.Params.Arguments is not null) + { + foreach (var arg in command.Arguments) + { + string? value = null; + if (arg.LongName is not null && request.Params.Arguments.TryGetValue(arg.LongName, out var longProp)) + { + value = longProp.GetString(); + } + else if (arg.ShortName is not null && request.Params.Arguments.TryGetValue(arg.ShortName, out var shortProp)) + { + value = shortProp.GetString(); + } + + if (value is not null) + { + if (arg.LongName is not null) + { + argsList.Add($"--{arg.LongName}"); + } + else if (arg.ShortName is not null) + { + argsList.Add($"-{arg.ShortName}"); + } + argsList.Add(value); + } + } + } + } + catch (Exception ex) + { + + System.Console.WriteLine(ex.ToString()); + + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Error processing arguments for tool '{command.Name}': {ex.Message}" }] + }; + } + + var args = argsList.ToArray(); + + var sb = new StringWriter(); + var renderer = new McpCommandRenderer(sb); + + + var exitCode = 0; + + try { + exitCode = await command.Handler(args, renderer); + } + catch (Exception ex) + { + + System.Console.WriteLine(ex.ToString()); + + exitCode = 1; + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Error executing tool '{command.Name}': {ex.Message}" }] + }; + } + + // get the JSON from the StringWriter and return as StructuredContent + var outputJson = sb.ToString().Replace("\n", "\\n").Replace("\r", "\\r").Replace("\"", "\\\""); + return new CallToolResult + { + IsError = false, + StructuredContent = JsonSerializer.Deserialize($$""" + { + "exitCode": {{exitCode}}, + "output": "{{outputJson}}" + } + """) + }; + + } + + private static async ValueTask ListTools(RequestContext request, CancellationToken cancellationToken) + { + + var result = new ListToolsResult + { + Tools = new List() + }; + + foreach (var command in _pluginLoader.Commands.Values) + { + + var sbArguments = new System.Text.StringBuilder(); + var requiredArguments = new List(); + if (command.Arguments is not null) + { + foreach (var arg in command.Arguments) + { + + if (sbArguments.Length > 0) + { + sbArguments.Append(", "); + } + + // generate argument representation as a JSON object + sbArguments.Append($$""" + "{{arg.LongName ?? arg.ShortName}}": { + "type": "string", + "description": "{{arg.Description}}" + } + """); + if (arg.IsRequired) + { + requiredArguments.Add(arg.LongName ?? arg.ShortName!); + } + } + } + + var requiredArray = requiredArguments.Count > 0 + ? string.Join(", ", requiredArguments.Select(a => $"\"{a}\"")) + : ""; + + var tool = new Tool + { + Name = command.Name, + Description = command.Description, + InputSchema = JsonSerializer.Deserialize($$""" + { + "type": "object", + "properties": { + {{sbArguments.ToString()}} + }, + "required": [{{requiredArray}}] + } + """), + }; + + result.Tools.Add(tool); + } + + return await ValueTask.FromResult(result); + + + } +} diff --git a/src/CodeMedic/Commands/RootCommandHandler.cs b/src/CodeMedic/Commands/RootCommandHandler.cs index 9221a9a..8915d45 100644 --- a/src/CodeMedic/Commands/RootCommandHandler.cs +++ b/src/CodeMedic/Commands/RootCommandHandler.cs @@ -10,7 +10,7 @@ namespace CodeMedic.Commands; /// Root command handler for the CodeMedic CLI application. /// Manages the main command structure and default behaviors. /// -public class RootCommandHandler +public partial class RootCommandHandler { private static PluginLoader _pluginLoader = null!; @@ -113,28 +113,6 @@ public static async Task ProcessArguments(string[] args) return 1; } - private static async Task ConfigureMcpServer(string version) - { - - var options = new McpServerOptions - { - ServerInfo = new Implementation - { - Name = "CodeMedic", - Version = version, - Description = "Project analysis and code health assessment tool.", - }, - Handlers = new McpServerHandlers - { - // CommandHandler = new McpCommandHandler(_pluginLoader, version), - } - }; - - await using McpServer server = McpServer.Create(new StdioServerTransport("CodeMedic"), options); - await server.RunAsync(); - - } - private static async Task<(bool flowControl, int value)> HandleConfigCommand(string[] args, string version) { diff --git a/src/CodeMedic/Engines/NuGetInspector.cs b/src/CodeMedic/Engines/NuGetInspector.cs index edc3e66..eb50770 100644 --- a/src/CodeMedic/Engines/NuGetInspector.cs +++ b/src/CodeMedic/Engines/NuGetInspector.cs @@ -41,7 +41,7 @@ public async Task RestorePackagesAsync() var processInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"restore \"{_rootPath}\"", + Arguments = $"restore \"{_rootPath}\" --prerelease", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/CodeMedic/Output/McpCommandRenderer.cs b/src/CodeMedic/Output/McpCommandRenderer.cs new file mode 100644 index 0000000..65fa306 --- /dev/null +++ b/src/CodeMedic/Output/McpCommandRenderer.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeMedic.Output; + +/// +/// MCP command renderer that outputs to a provided TextWriter. +/// +public class McpCommandRenderer : IRenderer +{ + private readonly TextWriter _TextWriter; + + /// + /// Build a new McpCommandRenderer. + /// + /// The TextWriter to output to. + public McpCommandRenderer(TextWriter textWriter) + { + _TextWriter = textWriter; + } + + /// + /// Renders a banner as JSON output. + /// + /// Optional subtitle text. + public void RenderBanner(string subtitle = "") + { + var bannerData = new + { + type = "banner", + title = "CodeMedic", + subtitle = subtitle + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(bannerData)); + } + + /// + /// Renders an error message as JSON output. + /// + /// The error message to render. + public void RenderError(string message) + { + var errorData = new + { + type = "error", + message = message + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(errorData)); + } + + /// + /// Renders a footer as JSON output. + /// + /// The footer content to render. + public void RenderFooter(string footer) + { + var footerData = new + { + type = "footer", + content = footer + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(footerData)); + } + + /// + /// Renders an informational message as JSON output. + /// + /// The info message to render. + public void RenderInfo(string message) + { + var infoData = new + { + type = "info", + message = message + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(infoData)); + } + + /// + /// Renders a report object as JSON output. + /// + /// The report object to serialize and render. + public void RenderReport(object report) + { + var options = new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new PolymorphicReportElementConverter() } + }; + + var reportData = new + { + type = "report", + data = report + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(reportData, options)); + } + + /// + /// Custom JSON converter that handles polymorphic IReportElement serialization by including type information. + /// + private class PolymorphicReportElementConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.GetInterfaces().Any(i => i.Name == "IReportElement") || + typeToConvert.Name == "IReportElement"; + } + + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException("Deserialization not supported"); + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + var actualType = value.GetType(); + + writer.WriteStartObject(); + writer.WriteString("$type", actualType.Name); + + // Serialize all public properties of the concrete type + foreach (var prop in actualType.GetProperties()) + { + var propValue = prop.GetValue(value); + if (propValue != null) + { + writer.WritePropertyName(JsonNamingPolicy.CamelCase.ConvertName(prop.Name)); + JsonSerializer.Serialize(writer, propValue, propValue.GetType(), options); + } + } + + writer.WriteEndObject(); + } + } + + /// + /// Renders a section header as JSON output. + /// + /// The section title to render. + public void RenderSectionHeader(string title) + { + var sectionData = new + { + type = "section_header", + title = title + }; + _TextWriter.WriteLine(JsonSerializer.Serialize(sectionData)); + } + + /// + /// Renders a wait message while performing an asynchronous operation. + /// + /// The message to display while waiting. + /// + /// + public Task RenderWaitAsync(string message, Func operation) + { + // don't wait + return operation(); + } +}