diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..2d05f61 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,91 @@ +name: .NET Core Build and Publish +on: + push: +env: + NETCORE_VERSION: '8.0' + GIT_REPO_ROOT: src + MAJOR_MINOR_VERSION: 1.0. + SOLUTION_FILE: CodeCompass.sln + DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 +jobs: + build: + name: Build Package + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core SDK ${{ env.NETCORE_VERSION }} + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + - name: Restore + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet restore ${{ env.SOLUTION_FILE }} + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1 + - name: Build + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet build ${{ env.SOLUTION_FILE }} --configuration Release --no-restore + + - name: Pack Release + if: github.ref == 'refs/heads/master' + working-directory: ${{ env.GIT_REPO_ROOT }} + run: | + dotnet pack Agoda.CodeCompass/Agoda.CodeCompass.csproj --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + dotnet pack Agoda.CodeCompass.MSBuild/Agoda.CodeCompass.MSBuild.csproj --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + + - name: Pack Preview + if: github.ref != 'refs/heads/master' + working-directory: ${{ env.GIT_REPO_ROOT }} + run: | + dotnet pack Agoda.CodeCompass/Agoda.CodeCompass.csproj --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }}-preview + dotnet pack Agoda.CodeCompass.MSBuild/Agoda.CodeCompass.MSBuild.csproj --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }}-preview + + - name: Publish artifact + uses: actions/upload-artifact@master + with: + name: nupkg + path: ${{ env.GIT_REPO_ROOT }}/finalpackage + + deploy: + needs: build + name: Deploy Packages + runs-on: ubuntu-latest + steps: + - name: Download Package artifact + uses: actions/download-artifact@master + with: + name: nupkg + path: ./nupkg + - name: Setup NuGet + uses: NuGet/setup-nuget@v1.0.5 + with: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} + nuget-version: latest + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + + - name: Push to NuGet + run: dotnet nuget push nupkg/**/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://nuget.org --skip-duplicate + + release: + needs: deploy + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + steps: + - name: Create Draft Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + release_name: ${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + draft: true + prerelease: false + - uses: eregon/publish-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + release_id: ${{ steps.create_release.outputs.id }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..583883e --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,26 @@ +name: .NET Core Build +on: + pull_request: +env: + NETCORE_VERSION: '8.0' + GIT_REPO_ROOT: src + SOLUTION_FILE: CodeCompass.sln + DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 +jobs: + build: + name: Build Package + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core SDK ${{ env.NETCORE_VERSION }} + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + - name: Restore + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet restore ${{ env.SOLUTION_FILE }} + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1 + - name: Build + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet build ${{ env.SOLUTION_FILE }} --configuration Release --no-restore \ No newline at end of file diff --git a/README.md b/README.md index e9b6101..57e9d0d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,157 @@ -# code-compass-dotnet -CodeCompass .NET Library +# Agoda.CodeCompass 🧭 + +Because technical debt is like your laundry - it piles up when you're not looking. + +## What is CodeCompass? + +CodeCompass is a .NET analyzer that helps you navigate the treacherous waters of technical debt. It analyzes your code and produces standardized SARIF reports that quantify technical debt in terms of estimated remediation time, categorization, and priority. + +Think of it as your code's financial advisor, but instead of telling you to stop buying avocado toast, it tells you to stop ignoring those nullable reference warnings. + +## Installation + +```bash +dotnet add package Agoda.CodeCompass +``` + +That's it! No complicated setup, no configuration files, no sacrificial offerings required. + +## Features + +- 📊 Generates standardized SARIF reports +- ⏱️ Estimates remediation time for issues +- 🎯 Categorizes and prioritizes technical debt +- 🤝 Integrates seamlessly with existing .NET projects +- 🎨 Pretty colors in the SARIF viewer (okay, mostly different shades of blue) + +## Example + +Here's what CodeCompass will catch for you: + +```csharp +public class UserService +{ + // This will trigger AGD001: Unused parameter + public void UpdateUser(int userId, string name, string unusedParam) + { + Console.WriteLine($"Updating user {userId} to name {name}"); + // unusedParam is sitting here like that gym membership you never use + } +} +``` + +The analyzer will generate a SARIF report that looks something like this: + +```json +{ + "runs": [{ + "results": [{ + "ruleId": "AGD001", + "message": { + "text": "Parameter 'unusedParam' is unused" + }, + "properties": { + "techDebt": { + "minutes": 15, + "category": "CodeCleanup", + "priority": "Medium", + "rationale": "Unused parameters increase code complexity and maintenance burden", + "recommendation": "Remove the unused parameter or add a comment explaining why it's needed" + } + } + }] + }] +} +``` + +## Adding it to custom Rules + +When calling `Diagnostic.Create` Method pass the additional paramter proeprties and populate the dictionary with the additional meta data. + +```csharp +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class UnusedParameterAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "AGD001"; + private const string Category = "Usage"; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + title: "Unused parameter", + messageFormat: "Parameter '{0}' is unused", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Parameters that are not used in method bodies should be removed.", + helpLinkUri: "https://agoda.github.io/code-compass/rules/AGD001" + ); + + private void AnalyzeParameter(SyntaxNodeAnalysisContext context) + { + //... + if (!references.Any()) + { + var diagnostic = Diagnostic.Create( + Rule, + parameter.GetLocation(), + properties: new Dictionary + { + ["techDebtMinutes"] = "15", + ["techDebtCategory"] = "CodeCleanup", + ["techDebtPriority"] = "Medium", + ["techDebtRationale"] = "Unused parameters increase code complexity and maintenance burden", + ["techDebtRecommendation"] = "Remove the unused parameter or add a comment explaining why it's needed" + }.ToImmutableDictionary(), + parameter.Identifier.Text); + + context.ReportDiagnostic(diagnostic); + } + } +} +``` + +## How It Works + +1. Add the package to your project +2. Build your project +3. Look at the SARIF report +4. Feel slightly guilty about that code you wrote last Friday at 4:59 PM +5. Fix the issues +6. Repeat (because let's be honest, we all write Friday code sometimes) + +## Analyzing the Results + +The SARIF report can be viewed using: +- Visual Studio's built-in SARIF viewer +- VS Code with the SARIF Viewer extension +- Any text editor (if you really enjoy reading JSON) + +## Contributing + +We welcome contributions! Whether it's: +- 🐛 Bug fixes +- ✨ New analyzers +- 📝 Documentation improvements +- 💡 Feature suggestions +- 🤔 Philosophical debates about whether that TODO comment from 2019 should be classified as technical debt + +## License + +MIT License - Feel free to use it, modify it, or talk about it at developer conferences. + +## Acknowledgments + +Special thanks to: +- The developers who write the code we analyze +- The maintainers who deal with the technical debt we find +- Coffee ☕, without which this project wouldn't exist + +## Questions? + +Feel free to open an issue. We promise to read it, think about it, and maybe even fix it (no pinky promises though). + +--- + +Made with 💙 (and a bit of technical debt) by Agoda + +Remember: Technical debt is like regular debt, except your bank account doesn't hate you for it - just your future self and your teammates. \ No newline at end of file diff --git a/src/Agoda.CodeCompass.MSBuild/Agoda.CodeCompass.MSBuild.csproj b/src/Agoda.CodeCompass.MSBuild/Agoda.CodeCompass.MSBuild.csproj new file mode 100644 index 0000000..c80eae6 --- /dev/null +++ b/src/Agoda.CodeCompass.MSBuild/Agoda.CodeCompass.MSBuild.csproj @@ -0,0 +1,25 @@ + + + net8.0 + enable + enable + tools + true + true + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Agoda.CodeCompass.MSBuild/TechDebtReportTask.cs b/src/Agoda.CodeCompass.MSBuild/TechDebtReportTask.cs new file mode 100644 index 0000000..c375f56 --- /dev/null +++ b/src/Agoda.CodeCompass.MSBuild/TechDebtReportTask.cs @@ -0,0 +1,96 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using Task = Microsoft.Build.Utilities.Task; + +namespace Agoda.CodeCompass.MSBuild; + +public class TechDebtReportTask : Task +{ + [Required] + public string[] AnalyzerAssemblyPaths { get; set; } = Array.Empty(); + + [Required] + public string[] CompilationAssemblyPaths { get; set; } = Array.Empty(); + + [Required] + public string[] SourceFiles { get; set; } = Array.Empty(); + + [Required] + public string OutputPath { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + // Load all analyzer assemblies + var analyzers = LoadAnalyzers(); + + // Create compilation + var compilation = CreateCompilation(); + + // Run analysis + var compilationWithAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(analyzers.ToArray())); + + var diagnostics = compilationWithAnalyzers + .GetAnalyzerDiagnosticsAsync() + .GetAwaiter() + .GetResult(); + + // Generate and save SARIF report + var sarifOutput = SarifReporter.GenerateSarifReport(diagnostics); + File.WriteAllText(OutputPath, sarifOutput); + + return true; + } + catch (Exception ex) + { + Log.LogError($"Failed to generate tech debt report: {ex.Message}"); + return false; + } + } + + private IEnumerable LoadAnalyzers() + { + foreach (var path in AnalyzerAssemblyPaths) + { + var assembly = System.Runtime.Loader.AssemblyLoadContext + .Default + .LoadFromAssemblyPath(path); + + var analyzerTypes = assembly.GetTypes() + .Where(t => !t.IsAbstract && + typeof(DiagnosticAnalyzer).IsAssignableFrom(t)); + + foreach (var analyzerType in analyzerTypes) + { + if (Activator.CreateInstance(analyzerType) is DiagnosticAnalyzer analyzer) + { + yield return analyzer; + } + } + } + } + + private Compilation CreateCompilation() + { + var syntaxTrees = SourceFiles + .Select(file => Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree + .ParseText(File.ReadAllText(file), path: file)) + .ToArray(); + + var references = CompilationAssemblyPaths + .Select(path => MetadataReference.CreateFromFile(path)) + .ToArray(); + + return Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( + "TechDebtAnalysis", + syntaxTrees, + references, + new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions( + (OutputKind)Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest)); + } +} \ No newline at end of file diff --git a/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.props b/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.props new file mode 100644 index 0000000..9edb7ea --- /dev/null +++ b/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.props @@ -0,0 +1,5 @@ + + + $(MSBuildProjectDirectory)/tech-debt-report.sarif + + \ No newline at end of file diff --git a/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.targets b/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.targets new file mode 100644 index 0000000..a5e1747 --- /dev/null +++ b/src/Agoda.CodeCompass.MSBuild/build/Agoda.CodeCompass.MSBuild.targets @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Agoda.CodeCompass/Agoda.CodeCompass.csproj b/src/Agoda.CodeCompass/Agoda.CodeCompass.csproj new file mode 100644 index 0000000..4b2ba32 --- /dev/null +++ b/src/Agoda.CodeCompass/Agoda.CodeCompass.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + true + Agoda.CodeCompass + Agoda + Code analysis tool that measures technical debt and outputs to SARIF format + + + + + + + \ No newline at end of file diff --git a/src/Agoda.CodeCompass/SarifReporter.cs b/src/Agoda.CodeCompass/SarifReporter.cs new file mode 100644 index 0000000..c59d6b8 --- /dev/null +++ b/src/Agoda.CodeCompass/SarifReporter.cs @@ -0,0 +1,93 @@ + + +using System.Text.Json; +using Agoda.CodeCompass.Models.Agoda.CodeCompass.Data; +using Microsoft.CodeAnalysis; + +public class SarifReporter +{ + public static string GenerateSarifReport(IEnumerable diagnostics) + { + var sarifReport = new + { + schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + version = "2.1.0", + runs = new[] + { + new + { + tool = new + { + driver = new + { + name = "Agoda.CodeCompass", + semanticVersion = "1.0.0", + informationUri = "https://agoda.github.io/code-compass", + rules = diagnostics + .Select(d => d.Descriptor) + .Distinct() + .Select(descriptor => new + { + id = descriptor.Id, + name = descriptor.Title.ToString(), + shortDescription = new { text = descriptor.Title.ToString() }, + fullDescription = new { text = descriptor.Description.ToString() }, + help = new { text = descriptor.Description.ToString() }, + properties = GetTechDebtProperties(descriptor.Id) + }) + .ToArray() + } + }, + results = diagnostics.Select(d => new + { + ruleId = d.Id, + message = new { text = d.GetMessage() }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new + { + uri = d.Location.GetLineSpan().Path, + uriBaseId = "%SRCROOT%" + }, + region = new + { + startLine = d.Location.GetLineSpan().StartLinePosition.Line + 1, + startColumn = d.Location.GetLineSpan().StartLinePosition.Character + 1, + endLine = d.Location.GetLineSpan().EndLinePosition.Line + 1, + endColumn = d.Location.GetLineSpan().EndLinePosition.Character + 1 + } + } + } + }, + properties = GetTechDebtProperties(d.Id) + }).ToArray() + } + } + }; + + return JsonSerializer.Serialize(sarifReport, new JsonSerializerOptions + { + WriteIndented = true + }); + } + + private static object GetTechDebtProperties(string ruleId) + { + var techDebtInfo = TechDebtMetadata.GetTechDebtInfo(ruleId); + return new + { + techDebt = techDebtInfo == null ? null : new + { + techDebtInfo.Minutes, + techDebtInfo.Category, + techDebtInfo.Priority, + techDebtInfo.Rationale, + techDebtInfo.Recommendation + } + }; + } +} \ No newline at end of file diff --git a/src/Agoda.CodeCompass/TechDebtInfo.cs b/src/Agoda.CodeCompass/TechDebtInfo.cs new file mode 100644 index 0000000..f38b27d --- /dev/null +++ b/src/Agoda.CodeCompass/TechDebtInfo.cs @@ -0,0 +1,10 @@ +namespace Agoda.CodeCompass.Models; + +public class TechDebtInfo +{ + public required int Minutes { get; init; } + public required string Category { get; init; } + public required string Priority { get; init; } + public string? Rationale { get; init; } + public string? Recommendation { get; init; } +} \ No newline at end of file diff --git a/src/Agoda.CodeCompass/TechDebtMetadata.cs b/src/Agoda.CodeCompass/TechDebtMetadata.cs new file mode 100644 index 0000000..7bd5389 --- /dev/null +++ b/src/Agoda.CodeCompass/TechDebtMetadata.cs @@ -0,0 +1,32 @@ +namespace Agoda.CodeCompass.Models.Agoda.CodeCompass.Data; + +public static class TechDebtMetadata +{ + private static readonly Dictionary RuleMetadata = new() + { + ["CS8602"] = new TechDebtInfo + { + Minutes = 15, + Category = "NullableReferenceTypes", + Priority = "High", + Rationale = "Null reference exceptions are a common source of runtime errors", + Recommendation = "Use nullable reference types correctly to prevent null reference exceptions" + }, + ["CA1822"] = new TechDebtInfo + { + Minutes = 10, + Category = "Performance", + Priority = "Medium", + Rationale = "Non-static methods that don't access instance data create unnecessary allocations", + Recommendation = "Mark methods that don't access instance state as static" + } + }; + + public static TechDebtInfo? GetTechDebtInfo(string ruleId) => + RuleMetadata.TryGetValue(ruleId, out var info) ? info : null; + + public static void RegisterCustomRule(string ruleId, TechDebtInfo info) + { + RuleMetadata[ruleId] = info; + } +} \ No newline at end of file diff --git a/src/CodeCompass.sln b/src/CodeCompass.sln new file mode 100644 index 0000000..e4c9e07 --- /dev/null +++ b/src/CodeCompass.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Agoda.CodeCompass", "Agoda.CodeCompass\Agoda.CodeCompass.csproj", "{7CB8A73E-2302-4BD8-A7AC-F6D6FF60204E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Agoda.CodeCompass.MSBuild", "Agoda.CodeCompass.MSBuild\Agoda.CodeCompass.MSBuild.csproj", "{0BB5F054-97D3-4B0C-9398-EAB69D258E7B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{314049A9-07D2-46B3-BBC7-EC97DEBDE59F}" + ProjectSection(SolutionItems) = preProject + ..\README.md = ..\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7CB8A73E-2302-4BD8-A7AC-F6D6FF60204E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CB8A73E-2302-4BD8-A7AC-F6D6FF60204E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CB8A73E-2302-4BD8-A7AC-F6D6FF60204E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CB8A73E-2302-4BD8-A7AC-F6D6FF60204E}.Release|Any CPU.Build.0 = Release|Any CPU + {0BB5F054-97D3-4B0C-9398-EAB69D258E7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BB5F054-97D3-4B0C-9398-EAB69D258E7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BB5F054-97D3-4B0C-9398-EAB69D258E7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BB5F054-97D3-4B0C-9398-EAB69D258E7B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DB0EC560-477A-4D4E-BAD5-011FC42706EB} + EndGlobalSection +EndGlobal