Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions src/ComplexityRipper/Analysis/CSharpAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using ComplexityRipper.Models;

namespace ComplexityRipper.Analysis;

/// <summary>
/// Analyzes C# source files using Roslyn to extract function-level metrics.
/// </summary>
public class CSharpAnalyzer
public sealed class CSharpAnalyzer
{
/// <summary>
/// Analyzes all .cs files under the given repo directories, running in parallel for performance.
Expand Down Expand Up @@ -45,10 +47,10 @@ public AnalysisResult AnalyzeRepos(string rootPath, Action<string>? onProgress =
? Utilities.AdoUrlHelper.GetDefaultBranch(repoDir)
: "main";

var objSegment = $"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}";
var binSegment = $"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}";
var csFiles = Directory.EnumerateFiles(repoDir, "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
&& !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"))
.ToList();
.Where(f => !f.Contains(objSegment) && !f.Contains(binSegment));

int functionCount = 0;
int fileCount = 0;
Expand Down Expand Up @@ -187,8 +189,9 @@ public List<FunctionMetrics> AnalyzeFile(string filePath, string repoRoot, strin

try
{
var code = File.ReadAllText(filePath);
var tree = CSharpSyntaxTree.ParseText(code, path: filePath);
using var stream = File.OpenRead(filePath);
var sourceText = SourceText.From(stream, Encoding.UTF8);
var tree = CSharpSyntaxTree.ParseText(sourceText, path: filePath);
var root = tree.GetRoot();

var relativePath = Path.GetRelativePath(repoRoot, filePath);
Expand Down Expand Up @@ -270,16 +273,13 @@ private void AddFunction(SyntaxNode node, string functionName, int parameterCoun
int endLine = lineSpan.EndLinePosition.Line + 1;
int lineCount = endLine - startLine + 1;

// Find containing class/struct/record
string? className = null;
var typeDecl = node.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault();
if (typeDecl != null)
// Materialize once to avoid re-enumerating the ancestor tree for Reverse/Select.
var typeAncestors = node.Ancestors().OfType<TypeDeclarationSyntax>().ToList();
if (typeAncestors.Count > 0)
{
// Include nested type names
var typeNames = node.Ancestors().OfType<TypeDeclarationSyntax>()
.Reverse()
.Select(t => t.Identifier.Text);
className = string.Join(".", typeNames);
typeAncestors.Reverse();
className = string.Join(".", typeAncestors.Select(t => t.Identifier.Text));
}

// Find containing namespace
Expand Down Expand Up @@ -318,7 +318,7 @@ private void AddFunction(SyntaxNode node, string functionName, int parameterCoun
/// </summary>
internal class ProjectResolver
{
private static readonly string[] ProjectExtensions = ["*.csproj", "*.fsproj", "*.vbproj"];
private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"];
private readonly string _repoRoot;
private readonly Dictionary<string, string?> _cache = new(StringComparer.OrdinalIgnoreCase);

Expand All @@ -344,10 +344,9 @@ public ProjectResolver(string repoRoot)

foreach (var pattern in ProjectExtensions)
{
var projFiles = Directory.GetFiles(dir, pattern);
if (projFiles.Length > 0)
foreach (var file in Directory.EnumerateFiles(dir, "*" + pattern))
{
var name = Path.GetFileNameWithoutExtension(projFiles[0]);
var name = Path.GetFileNameWithoutExtension(file);
CacheUpTo(Path.GetDirectoryName(Path.GetFullPath(sourceFilePath))!, dir, name);
return name;
}
Expand Down
12 changes: 6 additions & 6 deletions src/ComplexityRipper/Models/AnalysisResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace ComplexityRipper.Models;

public class AnalysisResult
public sealed class AnalysisResult
{
[JsonPropertyName("metadata")]
public AnalysisMetadata Metadata { get; set; } = new();
Expand All @@ -17,7 +17,7 @@ public class AnalysisResult
public List<FunctionMetrics> Functions { get; set; } = new();
}

public class AnalysisMetadata
public sealed class AnalysisMetadata
{
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
Expand All @@ -29,7 +29,7 @@ public class AnalysisMetadata
public Thresholds DefaultThresholds { get; set; } = new();
}

public class Thresholds
public sealed class Thresholds
{
[JsonPropertyName("maxLines")]
public int MaxLines { get; set; } = 200;
Expand All @@ -38,7 +38,7 @@ public class Thresholds
public int MaxComplexity { get; set; } = 25;
}

public class RepoInfo
public sealed class RepoInfo
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
Expand All @@ -59,7 +59,7 @@ public class RepoInfo
public int FunctionCount { get; set; }
}

public class AnalysisSummary
public sealed class AnalysisSummary
{
[JsonPropertyName("totalRepos")]
public int TotalRepos { get; set; }
Expand All @@ -74,7 +74,7 @@ public class AnalysisSummary
public Dictionary<string, LanguageStats> LanguageBreakdown { get; set; } = new();
}

public class LanguageStats
public sealed class LanguageStats
{
[JsonPropertyName("files")]
public int Files { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion src/ComplexityRipper/Models/FunctionMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace ComplexityRipper.Models;

public class FunctionMetrics
public sealed class FunctionMetrics
{
[JsonPropertyName("file")]
public string File { get; set; } = string.Empty;
Expand Down
45 changes: 31 additions & 14 deletions src/ComplexityRipper/Report/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace ComplexityRipper.Report;
/// Generates a self-contained HTML report from analysis results.
/// Matches the dark GitHub-like theme of the existing build health report.
/// </summary>
public class HtmlReportGenerator
public sealed class HtmlReportGenerator
{
public void Generate(AnalysisResult data, string outputPath, int thresholdLines = 200, int thresholdComplexity = 25, string theme = "light")
{
Expand All @@ -19,7 +19,7 @@ public void Generate(AnalysisResult data, string outputPath, int thresholdLines

var repoStats = data.Functions
.GroupBy(f => f.Repo)
.Select(g => BuildRepoStats(g.Key, g.ToList(), repoLookup, thresholdLines, thresholdComplexity))
.Select(g => BuildRepoStats(g.Key, g, repoLookup, thresholdLines, thresholdComplexity))
.OrderByDescending(r => r.ConcernScore)
.ToList();

Expand Down Expand Up @@ -63,11 +63,12 @@ private record RepoStatsRow(

private static RepoStatsRow BuildRepoStats(
string repoName,
List<FunctionMetrics> functions,
IEnumerable<FunctionMetrics> functionsEnumerable,
Dictionary<string, RepoInfo> repoLookup,
int thresholdLines,
int thresholdComplexity)
{
var functions = functionsEnumerable.ToList();
repoLookup.TryGetValue(repoName, out var info);

var longFuncs = functions.Where(f => f.LineCount >= thresholdLines).OrderByDescending(f => f.LineCount).ToList();
Expand Down Expand Up @@ -524,14 +525,14 @@ private void AppendRepoFunctionTable(StringBuilder sb, string title, List<Functi

sb.AppendLine($"<table id=\"{tableId}\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 0, 'string')\">Project <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 1, 'string')\">File <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 2, 'string')\">Class <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 3, 'string')\">Function <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 4, 'number')\" class=\"numeric\">Lines <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 5, 'number')\" class=\"numeric\">Complexity <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 6, 'number')\" class=\"numeric\">Params <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("<th onclick=\"sortTable('" + tableId + "', 7, 'number')\" class=\"numeric\">Nesting <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 0, 'string')\">Project <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 1, 'string')\">File <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 2, 'string')\">Class <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 3, 'string')\">Function <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 4, 'number')\" class=\"numeric\">Lines <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 5, 'number')\" class=\"numeric\">Complexity <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 6, 'number')\" class=\"numeric\">Params <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine($"<th onclick=\"sortTable('{tableId}', 7, 'number')\" class=\"numeric\">Nesting <span class=\"sort-arrow\">⇅</span></th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");

Expand Down Expand Up @@ -685,13 +686,29 @@ private static string GetSeverityClass(int value, int criticalThreshold, int hig

private static string GetShortFileName(string path)
{
var parts = path.Replace('\\', '/').Split('/');
if (parts.Length <= 2)
var normalized = path.AsSpan();
int separatorCount = 0;
int secondLastSep = -1;

for (int i = normalized.Length - 1; i >= 0; i--)
{
if (normalized[i] == '/' || normalized[i] == '\\')
{
separatorCount++;
if (separatorCount == 2)
{
secondLastSep = i;
break;
}
}
}

if (secondLastSep < 0)
{
return path;
}

return ".../" + string.Join("/", parts[^2..]);
return $".../{path[(secondLastSep + 1)..].Replace('\\', '/')}";
}

private static string Encode(string text) => HttpUtility.HtmlEncode(text);
Expand Down
45 changes: 27 additions & 18 deletions src/ComplexityRipper/Utilities/AdoUrlHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ public static string GetDefaultBranch(string repoPath)
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000);

// Output: refs/remotes/origin/main
if (!string.IsNullOrEmpty(output))
{
return output.Split('/').Last();
var lastSlash = output.LastIndexOf('/');
return lastSlash >= 0 ? output[(lastSlash + 1)..] : output;
}
}
catch
Expand All @@ -103,7 +103,7 @@ public static string GetDefaultBranch(string repoPath)
}

var urlAndSuffix = tabParts[1];
if (!urlAndSuffix.Contains("(fetch)"))
if (!urlAndSuffix.Contains("(fetch)", StringComparison.Ordinal))
{
continue;
}
Expand All @@ -114,7 +114,7 @@ public static string GetDefaultBranch(string repoPath)

// ADO HTTPS: https://msdata@dev.azure.com/msdata/Sentinel%20Graph/_git/NEXT
// Legacy: https://msdata.visualstudio.com/DefaultCollection/Sentinel%20Graph/_git/PerfBenchInfra
if ((urlPart.Contains("dev.azure.com") || urlPart.Contains("visualstudio.com")) && urlPart.Contains("/_git/"))
if ((urlPart.Contains("dev.azure.com", StringComparison.Ordinal) || urlPart.Contains("visualstudio.com", StringComparison.Ordinal)) && urlPart.Contains("/_git/", StringComparison.Ordinal))
{
var uri = new Uri(urlPart.Replace(" ", "%20"));
var segments = uri.AbsolutePath.Trim('/').Split('/');
Expand All @@ -131,30 +131,39 @@ public static string GetDefaultBranch(string repoPath)
string org;
string project;

if (urlPart.Contains("dev.azure.com"))
if (urlPart.Contains("dev.azure.com", StringComparison.Ordinal))
{
org = segments[0];
project = Uri.EscapeDataString(Uri.UnescapeDataString(
string.Join("/", segments[1..gitIdx])));
}
else
{
// visualstudio.com: org is the subdomain (e.g., msdata.visualstudio.com -> msdata)
org = uri.Host.Split('.')[0];
// Project is everything between DefaultCollection (or first segment) and _git
var projectSegments = segments.TakeWhile(s => s != "_git")
.Where(s => !s.Equals("DefaultCollection", StringComparison.OrdinalIgnoreCase))
.ToArray();
var projectParts = new List<string>();
foreach (var s in segments)
{
if (s == "_git")
{
break;
}

if (!s.Equals("DefaultCollection", StringComparison.OrdinalIgnoreCase))
{
projectParts.Add(s);
}
}

project = Uri.EscapeDataString(Uri.UnescapeDataString(
string.Join("/", projectSegments)));
string.Join("/", projectParts)));
}

return $"https://dev.azure.com/{org}/{project}/_git/{repo}";
}
}

// ADO SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
if (urlPart.Contains("ssh.dev.azure.com"))
if (urlPart.Contains("ssh.dev.azure.com", StringComparison.Ordinal))
{
var parts = urlPart.Split(':');
if (parts.Length >= 2)
Expand All @@ -165,7 +174,7 @@ public static string GetDefaultBranch(string repoPath)
var org = pathParts[1];
var project = Uri.EscapeDataString(pathParts[2]);
var repo = pathParts[3].Split([' ', '\t'])[0];
if (repo.EndsWith(".git"))
if (repo.EndsWith(".git", StringComparison.Ordinal))
{
repo = repo[..^4];
}
Expand All @@ -176,10 +185,10 @@ public static string GetDefaultBranch(string repoPath)
}

// GitHub HTTPS: https://github.com/{owner}/{repo}.git
if (urlPart.Contains("github.com"))
if (urlPart.Contains("github.com", StringComparison.Ordinal))
{
var cleaned = urlPart.TrimEnd('/');
if (cleaned.EndsWith(".git"))
if (cleaned.EndsWith(".git", StringComparison.Ordinal))
{
cleaned = cleaned[..^4];
}
Expand All @@ -200,10 +209,10 @@ public static string GetDefaultBranch(string repoPath)
}

// GitHub SSH: git@github.com:{owner}/{repo}.git
if (urlPart.StartsWith("git@github.com:"))
if (urlPart.StartsWith("git@github.com:", StringComparison.Ordinal))
{
var path = urlPart["git@github.com:".Length..];
if (path.EndsWith(".git"))
if (path.EndsWith(".git", StringComparison.Ordinal))
{
path = path[..^4];
}
Expand All @@ -229,7 +238,7 @@ public static string GetDefaultBranch(string repoPath)
/// <summary>
/// Returns true if the base URL is a GitHub URL.
/// </summary>
public static bool IsGitHub(string baseUrl) => baseUrl.Contains("github.com");
public static bool IsGitHub(string baseUrl) => baseUrl.Contains("github.com", StringComparison.Ordinal);

/// <summary>
/// Builds a URL to a specific file and line range, supporting both ADO and GitHub.
Expand Down