From 934db14cfb4fa368e8130fc7b7a33a1ab7b7d2ee Mon Sep 17 00:00:00 2001 From: "Mike Treit (from Dev Box)" Date: Mon, 30 Mar 2026 11:18:53 -0700 Subject: [PATCH 1/3] Reduce unnecessary heap allocations and improve efficiency Eliminate redundant ancestor enumeration in FunctionWalker.AddFunction by traversing TypeDeclarationSyntax ancestors once instead of twice. Cache path separator strings outside the LINQ Where predicate in CSharpAnalyzer so they are not re-allocated per file. Remove unnecessary .ToList() call on csFiles enumerable since the foreach loop does not require a materialized list. Switch ProjectResolver from Directory.GetFiles (allocates full array) to Directory.EnumerateFiles (lazy, stops at first match). Replace Split('/').Last() with LastIndexOf-based slicing in GetDefaultBranch to avoid allocating a throwaway string array. Replace LINQ TakeWhile/Where/ToArray chain with a simple loop in ParseRemoteUrl for the visualstudio.com code path. Avoid double materialization in BuildRepoStats by accepting IEnumerable and calling ToList once inside the method instead of at the call site. Use string interpolation instead of string concatenation with + in AppendRepoFunctionTable header generation. Rewrite GetShortFileName to use Span-based scanning instead of Split + Join to find the last two path segments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analysis/CSharpAnalyzer.cs | 25 +++++------ .../Report/HtmlReportGenerator.cs | 43 +++++++++++++------ .../Utilities/AdoUrlHelper.cs | 25 +++++++---- 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs index e77fccb..5c561dc 100644 --- a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs +++ b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs @@ -45,10 +45,10 @@ public AnalysisResult AnalyzeRepos(string rootPath, Action? 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; @@ -270,16 +270,12 @@ 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().FirstOrDefault(); - if (typeDecl != null) + var typeAncestors = node.Ancestors().OfType().ToList(); + if (typeAncestors.Count > 0) { - // Include nested type names - var typeNames = node.Ancestors().OfType() - .Reverse() - .Select(t => t.Identifier.Text); - className = string.Join(".", typeNames); + typeAncestors.Reverse(); + className = string.Join(".", typeAncestors.Select(t => t.Identifier.Text)); } // Find containing namespace @@ -318,7 +314,7 @@ private void AddFunction(SyntaxNode node, string functionName, int parameterCoun /// 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 _cache = new(StringComparer.OrdinalIgnoreCase); @@ -344,10 +340,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; } diff --git a/src/ComplexityRipper/Report/HtmlReportGenerator.cs b/src/ComplexityRipper/Report/HtmlReportGenerator.cs index 905eac9..5e14343 100644 --- a/src/ComplexityRipper/Report/HtmlReportGenerator.cs +++ b/src/ComplexityRipper/Report/HtmlReportGenerator.cs @@ -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(); @@ -63,11 +63,12 @@ private record RepoStatsRow( private static RepoStatsRow BuildRepoStats( string repoName, - List functions, + IEnumerable functionsEnumerable, Dictionary 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(); @@ -524,14 +525,14 @@ private void AppendRepoFunctionTable(StringBuilder sb, string title, List"); sb.AppendLine(""); - sb.AppendLine("Project "); - sb.AppendLine("File "); - sb.AppendLine("Class "); - sb.AppendLine("Function "); - sb.AppendLine("Lines "); - sb.AppendLine("Complexity "); - sb.AppendLine("Params "); - sb.AppendLine("Nesting "); + sb.AppendLine($"Project "); + sb.AppendLine($"File "); + sb.AppendLine($"Class "); + sb.AppendLine($"Function "); + sb.AppendLine($"Lines "); + sb.AppendLine($"Complexity "); + sb.AppendLine($"Params "); + sb.AppendLine($"Nesting "); sb.AppendLine(""); sb.AppendLine(""); @@ -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); diff --git a/src/ComplexityRipper/Utilities/AdoUrlHelper.cs b/src/ComplexityRipper/Utilities/AdoUrlHelper.cs index 46d2a19..4128552 100644 --- a/src/ComplexityRipper/Utilities/AdoUrlHelper.cs +++ b/src/ComplexityRipper/Utilities/AdoUrlHelper.cs @@ -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 @@ -139,14 +139,23 @@ public static string GetDefaultBranch(string repoPath) } 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(); + 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}"; From 82dbec85070349edf527c769d4e58537ad94341a Mon Sep 17 00:00:00 2001 From: "Mike Treit (from Dev Box)" Date: Mon, 30 Mar 2026 11:21:18 -0700 Subject: [PATCH 2/3] Seal all classes and add explicit StringComparison Seal all 9 non-static classes (CSharpAnalyzer, FunctionMetrics, AnalysisResult, AnalysisMetadata, Thresholds, RepoInfo, AnalysisSummary, LanguageStats, HtmlReportGenerator) to enable JIT devirtualization. Add StringComparison.Ordinal to all string Contains, EndsWith, and StartsWith calls in AdoUrlHelper that were using the slower culture-sensitive overload on ASCII-only literals. Use SourceText.From(Stream) instead of File.ReadAllText for Roslyn parsing to avoid allocating the entire file content as a managed string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analysis/CSharpAnalyzer.cs | 9 ++++++--- src/ComplexityRipper/Models/AnalysisResult.cs | 12 +++++------ .../Models/FunctionMetrics.cs | 2 +- .../Report/HtmlReportGenerator.cs | 2 +- .../Utilities/AdoUrlHelper.cs | 20 +++++++++---------- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs index 5c561dc..89b483c 100644 --- a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs +++ b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs @@ -1,7 +1,9 @@ +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; @@ -9,7 +11,7 @@ namespace ComplexityRipper.Analysis; /// /// Analyzes C# source files using Roslyn to extract function-level metrics. /// -public class CSharpAnalyzer +public sealed class CSharpAnalyzer { /// /// Analyzes all .cs files under the given repo directories, running in parallel for performance. @@ -187,8 +189,9 @@ public List 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); diff --git a/src/ComplexityRipper/Models/AnalysisResult.cs b/src/ComplexityRipper/Models/AnalysisResult.cs index 65a1ad7..309212d 100644 --- a/src/ComplexityRipper/Models/AnalysisResult.cs +++ b/src/ComplexityRipper/Models/AnalysisResult.cs @@ -2,7 +2,7 @@ namespace ComplexityRipper.Models; -public class AnalysisResult +public sealed class AnalysisResult { [JsonPropertyName("metadata")] public AnalysisMetadata Metadata { get; set; } = new(); @@ -17,7 +17,7 @@ public class AnalysisResult public List Functions { get; set; } = new(); } -public class AnalysisMetadata +public sealed class AnalysisMetadata { [JsonPropertyName("generatedAt")] public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; @@ -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; @@ -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; @@ -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; } @@ -74,7 +74,7 @@ public class AnalysisSummary public Dictionary LanguageBreakdown { get; set; } = new(); } -public class LanguageStats +public sealed class LanguageStats { [JsonPropertyName("files")] public int Files { get; set; } diff --git a/src/ComplexityRipper/Models/FunctionMetrics.cs b/src/ComplexityRipper/Models/FunctionMetrics.cs index f7e48e6..7c949f8 100644 --- a/src/ComplexityRipper/Models/FunctionMetrics.cs +++ b/src/ComplexityRipper/Models/FunctionMetrics.cs @@ -2,7 +2,7 @@ namespace ComplexityRipper.Models; -public class FunctionMetrics +public sealed class FunctionMetrics { [JsonPropertyName("file")] public string File { get; set; } = string.Empty; diff --git a/src/ComplexityRipper/Report/HtmlReportGenerator.cs b/src/ComplexityRipper/Report/HtmlReportGenerator.cs index 5e14343..be2439e 100644 --- a/src/ComplexityRipper/Report/HtmlReportGenerator.cs +++ b/src/ComplexityRipper/Report/HtmlReportGenerator.cs @@ -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. /// -public class HtmlReportGenerator +public sealed class HtmlReportGenerator { public void Generate(AnalysisResult data, string outputPath, int thresholdLines = 200, int thresholdComplexity = 25, string theme = "light") { diff --git a/src/ComplexityRipper/Utilities/AdoUrlHelper.cs b/src/ComplexityRipper/Utilities/AdoUrlHelper.cs index 4128552..5a8aa8d 100644 --- a/src/ComplexityRipper/Utilities/AdoUrlHelper.cs +++ b/src/ComplexityRipper/Utilities/AdoUrlHelper.cs @@ -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; } @@ -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('/'); @@ -131,7 +131,7 @@ 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( @@ -163,7 +163,7 @@ public static string GetDefaultBranch(string repoPath) } // 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) @@ -174,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]; } @@ -185,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]; } @@ -209,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]; } @@ -238,7 +238,7 @@ public static string GetDefaultBranch(string repoPath) /// /// Returns true if the base URL is a GitHub URL. /// - public static bool IsGitHub(string baseUrl) => baseUrl.Contains("github.com"); + public static bool IsGitHub(string baseUrl) => baseUrl.Contains("github.com", StringComparison.Ordinal); /// /// Builds a URL to a specific file and line range, supporting both ADO and GitHub. From afe394ddc7503ebd1f7978714d593898dbf00038 Mon Sep 17 00:00:00 2001 From: "Mike Treit (from Dev Box)" Date: Mon, 30 Mar 2026 11:24:12 -0700 Subject: [PATCH 3/3] Add comment explaining ToList materialization for ancestors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComplexityRipper/Analysis/CSharpAnalyzer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs index 89b483c..44f0735 100644 --- a/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs +++ b/src/ComplexityRipper/Analysis/CSharpAnalyzer.cs @@ -274,6 +274,7 @@ private void AddFunction(SyntaxNode node, string functionName, int parameterCoun int lineCount = endLine - startLine + 1; string? className = null; + // Materialize once to avoid re-enumerating the ancestor tree for Reverse/Select. var typeAncestors = node.Ancestors().OfType().ToList(); if (typeAncestors.Count > 0) {