From 99aaf4878b84f49a9ec02a9dd72b9ac186f5e27b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:53:30 +0000 Subject: [PATCH 1/4] Initial plan From 05a88139876862f079031373c76c8c62869c4496 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:00:30 +0000 Subject: [PATCH 2/4] refactor: use .Select() for explicit mapping in foreach loop Co-authored-by: HandyS11 <62420910+HandyS11@users.noreply.github.com> --- .../EfAnalysis/EntityFileDiscovery.cs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs index ea3c68e..3e585c6 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs @@ -187,22 +187,17 @@ private static async Task SearchDirectoryForEntitiesAsync( RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.System }; - foreach (var csFile in Directory.EnumerateFiles(searchDir, EfAnalysisConstants.FilePatterns.CSharpFiles, - options)) - { - var fullPath = Path.GetFullPath(csFile); - if (fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)) + var filesToProcess = Directory.EnumerateFiles(searchDir, EfAnalysisConstants.FilePatterns.CSharpFiles, options) + .Select(Path.GetFullPath) + .Where(fullPath => !fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)) + .Where(fullPath => { - continue; - } - - // Skip common non-source directories that can be large - var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules")) - { - continue; - } + var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return !pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules"); + }); + foreach (var fullPath in filesToProcess) + { await ProcessSourceFileAsync(fullPath, entityTypeNames, entityFiles); } } From 29e22407916feb81bd132c05e801ebb426bc0f73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:20:43 +0000 Subject: [PATCH 3/4] Merge refacto/ImproveCodeQuality and resolve conflicts Co-authored-by: HandyS11 <62420910+HandyS11@users.noreply.github.com> --- samples/erd/simple-context/README.md | 3 + .../Rendering/MermaidErdRenderer.cs | 8 ++ .../ClassAnalysis/WorkspaceTypeDiscovery.cs | 52 +++++-- .../Constants/EfAnalysisConstants.cs | 18 ++- .../EfAnalysis/EntityFileDiscovery.cs | 136 ++++++++++++++---- .../Cli/MarkdownErdTests.cs | 2 +- 6 files changed, 176 insertions(+), 43 deletions(-) diff --git a/samples/erd/simple-context/README.md b/samples/erd/simple-context/README.md index 20479f8..94b891e 100644 --- a/samples/erd/simple-context/README.md +++ b/samples/erd/simple-context/README.md @@ -41,6 +41,9 @@ The tool generates a **Mermaid ERD diagram** showing: ### Example Output ```mermaid +--- +title: MyDbContext +--- erDiagram Author { int Id PK diff --git a/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs b/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs index f78b009..30303ba 100644 --- a/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs +++ b/src/ProjGraph.Lib/Rendering/MermaidErdRenderer.cs @@ -20,6 +20,14 @@ public static string Render(EfModel model) { var sb = new StringBuilder(); sb.AppendLine("```mermaid"); + + if (!string.IsNullOrEmpty(model.ContextName)) + { + sb.AppendLine("---"); + sb.AppendLine($"title: {model.ContextName}"); + sb.AppendLine("---"); + } + sb.AppendLine("erDiagram"); RenderEntities(model, sb); diff --git a/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs b/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs index a386caa..f4480dd 100644 --- a/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs +++ b/src/ProjGraph.Lib/Services/ClassAnalysis/WorkspaceTypeDiscovery.cs @@ -10,6 +10,13 @@ namespace ProjGraph.Lib.Services.ClassAnalysis; /// public static class WorkspaceTypeDiscovery { + /// + /// Directories to skip during recursive file search for better performance. + /// + private static readonly HashSet ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", ".git", "node_modules" + }; /// /// Finds the file containing the definition of a specific type within a given directory or its subdirectories. /// The method first attempts to search in common subdirectories for better performance, and if not found, @@ -58,20 +65,25 @@ public static class WorkspaceTypeDiscovery /// private static async Task SearchDirectoryForTypeAsync(string directory, string typeName) { - var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + return await SearchDirectoryRecursiveAsync(directory, typeName); + } - foreach (var file in Directory.EnumerateFiles(directory, "*.cs", options)) + /// + /// Recursively searches a directory and its subdirectories for a C# file containing a specific type definition. + /// This method manually handles recursion to avoid descending into common non-source directories for better performance. + /// + /// The path of the directory to search. + /// The name of the type to search for. + /// + /// The full path of the file containing the type definition if found; otherwise, null. + /// + private static async Task SearchDirectoryRecursiveAsync(string directory, string typeName) + { + // Search files in the current directory + foreach (var file in Directory.EnumerateFiles(directory, "*.cs", new EnumerationOptions { IgnoreInaccessible = true })) { - var fullPath = Path.GetFullPath(file); - // Skip common non-source directories - var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules")) - { - continue; - } - // Simple string check first for performance - var content = await File.ReadAllTextAsync(fullPath); + var content = await File.ReadAllTextAsync(file); if (!content.Contains($"class {typeName}") && !content.Contains($"interface {typeName}") && !content.Contains($"struct {typeName}") && @@ -90,7 +102,23 @@ public static class WorkspaceTypeDiscovery if (hasType) { - return fullPath; + return file; + } + } + + // Recursively search subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(directory, "*", new EnumerationOptions { IgnoreInaccessible = true })) + { + var dirName = Path.GetFileName(subDir); + if (ExcludedDirectories.Contains(dirName)) + { + continue; + } + + var result = await SearchDirectoryRecursiveAsync(subDir, typeName); + if (result != null) + { + return result; } } diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs b/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs index 70d164c..3a04b31 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/Constants/EfAnalysisConstants.cs @@ -26,6 +26,14 @@ public static class DataTypes public const string Long = "long"; public const string Single = "Single"; public const string Short = "short"; + public const string Byte = "byte"; + public const string SByte = "sbyte"; + public const string UShort = "ushort"; + public const string UInt = "uint"; + public const string UInt32 = "UInt32"; + public const string ULong = "ulong"; + public const string UInt64 = "UInt64"; + public const string Char = "char"; /// /// A set of common .NET value types that are treated as having a default value in EF. @@ -46,7 +54,15 @@ public static class DataTypes Double, Float, Single, - Short + Short, + Byte, + SByte, + UShort, + UInt, + UInt32, + ULong, + UInt64, + Char }; } diff --git a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs index 3e585c6..739756c 100644 --- a/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs +++ b/src/ProjGraph.Lib/Services/EfAnalysis/EntityFileDiscovery.cs @@ -16,6 +16,11 @@ namespace ProjGraph.Lib.Services.EfAnalysis; /// public static class EntityFileDiscovery { + /// + /// Maximum recursion depth when searching for base class files to prevent infinite recursion + /// and limit search scope to reasonable project structures. + /// + private const int MaxSearchDepth = 10; /// /// Discovers the file paths of entity files within the specified search directories. /// @@ -86,18 +91,16 @@ public static async Task> DiscoverBaseClassFilesAsync } /// - /// Builds a list of directories to search for entity files based on the provided context directory - /// and a list of entity namespaces. + /// Builds a list of directories to search for entity files based on the provided context directory. /// /// The directory containing the context file. /// - /// A list of directories to search for entity files, including the context directory, its parent directory, - /// and any sibling directories that are likely to contain entity files. + /// A list of directories to search for entity files, including the context directory and its parent directory. /// /// /// This method starts with the context directory and then its parent directory. /// Since the parent directory scan is recursive, it will naturally include the context directory - /// and all siblings. The sibling check is kept as a fallback for non-nested structures. + /// and all siblings through the recursive search. /// public static List BuildSearchDirectories( string contextDirectory) @@ -173,33 +176,68 @@ public static HashSet ExtractEntityTypeNames(ClassDeclarationSyntax cont /// For each file, it calls to process the file and add matching /// entity type names and their file paths to the dictionary. /// Any access errors encountered during directory traversal are ignored. + /// Directories like bin, obj, .git, and node_modules are skipped during traversal for performance. /// private static async Task SearchDirectoryForEntitiesAsync( string searchDir, HashSet entityTypeNames, string normalizedContextPath, Dictionary entityFiles) + { + await SearchDirectoryRecursiveAsync(searchDir, entityTypeNames, normalizedContextPath, entityFiles); + } + + /// + /// Recursively searches a directory for C# files, skipping common build and version control directories. + /// + /// The current directory to search. + /// A set of entity type names to search for in the C# files. + /// The normalized file path of the context file to exclude from the search. + /// + /// A dictionary where the keys are entity type names and the values are the corresponding file paths. + /// + /// A task that represents the asynchronous operation. + /// + /// This method implements manual recursion to avoid descending into directories that typically + /// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance + /// for large projects. + /// + private static async Task SearchDirectoryRecursiveAsync( + string currentDir, + HashSet entityTypeNames, + string normalizedContextPath, + Dictionary entityFiles) { try { + // Process files in the current directory var options = new EnumerationOptions { - RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.System + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.System }; - var filesToProcess = Directory.EnumerateFiles(searchDir, EfAnalysisConstants.FilePatterns.CSharpFiles, options) + var filesToProcess = Directory.EnumerateFiles(currentDir, EfAnalysisConstants.FilePatterns.CSharpFiles, options) .Select(Path.GetFullPath) - .Where(fullPath => !fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)) - .Where(fullPath => - { - var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return !pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules"); - }); + .Where(fullPath => !fullPath.Equals(normalizedContextPath, StringComparison.OrdinalIgnoreCase)); foreach (var fullPath in filesToProcess) { await ProcessSourceFileAsync(fullPath, entityTypeNames, entityFiles); } + + // Recursively process subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options)) + { + var dirName = Path.GetFileName(subDir); + if (dirName is "bin" or "obj" or ".git" or "node_modules") + { + continue; + } + + await SearchDirectoryRecursiveAsync(subDir, entityTypeNames, normalizedContextPath, entityFiles); + } } catch { @@ -381,43 +419,85 @@ private static DirectoryInfo FindSolutionRoot(string startDirectory, int maxLeve /// if the files are found; otherwise, the dictionary will be empty. /// /// - /// This method iterates through the provided base class names and attempts to locate their corresponding - /// file paths by calling the method. If a file is found, it is added - /// to the resulting dictionary. If no file is found for a base class name, it is skipped. + /// This method recursively searches for base class files while skipping common build and version control + /// directories (bin, obj, .git, node_modules) to improve performance for large projects. /// public static Dictionary SearchForBaseClassFiles( HashSet baseClassNames, DirectoryInfo solutionRoot) { var baseClassFiles = new Dictionary(); + SearchForBaseClassFilesRecursive(solutionRoot.FullName, baseClassNames, baseClassFiles, 0, MaxSearchDepth); + return baseClassFiles; + } + + /// + /// Recursively searches for base class files, skipping common build and version control directories. + /// + /// The current directory to search. + /// A set of base class names to search for. + /// + /// A dictionary to store the found base class files where the keys are base class names + /// and the values are the corresponding file paths. + /// + /// The current recursion depth. + /// The maximum recursion depth to prevent infinite recursion. + /// + /// This method implements manual recursion to avoid descending into directories that typically + /// contain build artifacts or dependencies (bin, obj, .git, node_modules), improving performance + /// for large projects. + /// + private static void SearchForBaseClassFilesRecursive( + string currentDir, + HashSet baseClassNames, + Dictionary baseClassFiles, + int currentDepth, + int maxDepth) + { + if (currentDepth >= maxDepth || baseClassFiles.Count == baseClassNames.Count) + { + return; + } try { var options = new EnumerationOptions { - RecurseSubdirectories = true, IgnoreInaccessible = true, MaxRecursionDepth = 10 + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.System }; - foreach (var file in Directory.EnumerateFiles(solutionRoot.FullName, "*.cs", options)) + // Process files in the current directory + foreach (var file in Directory.EnumerateFiles(currentDir, "*.cs", options)) { - var fullPath = Path.GetFullPath(file); - // Skip common non-source directories that can be large - var pathSegments = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (pathSegments.Any(s => s is "bin" or "obj" or ".git" or "node_modules")) + var fileName = Path.GetFileNameWithoutExtension(file); + if (baseClassNames.Contains(fileName)) { - continue; + var fullPath = Path.GetFullPath(file); + baseClassFiles.TryAdd(fileName, fullPath); + + if (baseClassFiles.Count == baseClassNames.Count) + { + return; + } } + } - var fileName = Path.GetFileNameWithoutExtension(file); - if (!baseClassNames.Contains(fileName)) + // Recursively process subdirectories, skipping excluded directories + foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", options)) + { + var dirName = Path.GetFileName(subDir); + if (dirName is "bin" or "obj" or ".git" or "node_modules") { continue; } - baseClassFiles.TryAdd(fileName, fullPath); + SearchForBaseClassFilesRecursive(subDir, baseClassNames, baseClassFiles, currentDepth + 1, maxDepth); + if (baseClassFiles.Count == baseClassNames.Count) { - break; + return; } } } @@ -425,7 +505,5 @@ public static Dictionary SearchForBaseClassFiles( { // Ignore access errors } - - return baseClassFiles; } } \ No newline at end of file diff --git a/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs b/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs index 04e2e70..94b8e33 100644 --- a/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs +++ b/tests/ProjGraph.Tests.Integration/Cli/MarkdownErdTests.cs @@ -46,7 +46,7 @@ private static string Normalize(string input) return NormalizeRegex().Replace(input, "\n").Trim(); } - [GeneratedRegex(@"```mermaid\s+(erDiagram[\s\S]*?)\s+```")] + [GeneratedRegex(@"```mermaid\s+([\s\S]*?)\s+```")] private static partial Regex ExtractMermaidRegex(); [GeneratedRegex(@"\r\n|\n|\r")] From 98cb7bbedcdb7d9cea96ff2bb34e210ad0e92fc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:33:12 +0000 Subject: [PATCH 4/4] Trigger PR update