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