diff --git a/.gitignore b/.gitignore index 217b24f..44248d8 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .vs .vscode +.idea bin obj .DS_Store app +*.DotSettings.user diff --git a/Dockerfile b/Dockerfile index 61bc883..7093663 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS sdk +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS sdk RUN mkdir -p /src/Stahz WORKDIR /src COPY Strazh/Strazh.csproj Strazh/Strazh.csproj diff --git a/Strazh.Tests/AnalyzerTests.cs b/Strazh.Tests/AnalyzerTests.cs new file mode 100644 index 0000000..39244f5 --- /dev/null +++ b/Strazh.Tests/AnalyzerTests.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Buildalyzer; +using Strazh.Analysis; +using Xunit; + +namespace Strazh.Tests; + +public class AnalyzerTests +{ + /// + /// Regression test for the bug where projects pulled into the Roslyn workspace as transitive + /// references by an earlier project's AddToWorkspace(workspace, addProjectReferences: true) + /// call were subsequently skipped by the deduplication check and never entered + /// context.Projects — causing them to receive no CONTAINS triple and no analysis. + /// + /// The fixture solution (SystemUnderTest.sln) lists ProjectA before ProjectB. + /// ProjectA references ProjectB, so when ProjectA is added to the workspace with + /// addProjectReferences: true, Buildalyzer pulls ProjectB in as a reference. Without the + /// fix, ProjectB's loop iteration finds it already in the workspace and is dropped. + /// + [Fact] + public async Task GetAnalysisContext_IncludesProjectsAddedAsTransitiveReferences() + { + var solutionPath = Path.Combine(GetRepoRoot(), "SystemUnderTest", "SystemUnderTest.sln"); + var manager = new AnalyzerManager(solutionPath); + + var context = await Analyzer.GetAnalysisContext(manager); + + var projectFileNames = context.Projects + .Select(p => Path.GetFileName(p.Item2.ProjectFilePath)) + .ToList(); + + Assert.Contains("Strazh.Tests.ProjectA.csproj", projectFileNames); + Assert.Contains("Strazh.Tests.ProjectB.csproj", projectFileNames); + } + + // [CallerFilePath] gives the compile-time absolute path of this source file. + // From Strazh.Tests/AnalyzerTests.cs, two levels up reaches the repo root. + private static string GetRepoRoot([CallerFilePath] string callerFile = "") => + Path.GetFullPath(Path.Combine(callerFile, "../..")); +} diff --git a/Strazh.Tests/Strazh.Tests.csproj b/Strazh.Tests/Strazh.Tests.csproj new file mode 100644 index 0000000..d0a460d --- /dev/null +++ b/Strazh.Tests/Strazh.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + false + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Strazh/Analysis/Analyzer.cs b/Strazh/Analysis/Analyzer.cs index fc13743..983b0a0 100755 --- a/Strazh/Analysis/Analyzer.cs +++ b/Strazh/Analysis/Analyzer.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Linq; @@ -7,11 +8,9 @@ using Buildalyzer.Workspaces; using System.Collections.Generic; using System; -using System.Collections.Concurrent; using Strazh.Database; using static Strazh.Analysis.AnalyzerConfig; using System.IO; -using Microsoft.Build.Construction; namespace Strazh.Analysis { @@ -30,71 +29,99 @@ public static async Task Analyze(AnalyzerConfig config) : config.Projects.Select(x => manager.GetProject(x))).ToList(); Console.WriteLine($"Analyzer ready to analyze {projectAnalyzers.Count} project/s."); - - Console.WriteLine("Building workspace..."); - var context = GetAnalysisContext(manager); - Console.WriteLine("done."); - - Console.WriteLine("Analyzing workspace..."); - - for (var index = 0; index < context.Projects.Count; index++) + + // Delete graph data upfront before parallel analysis begins, so that no project + // races against the delete. + if (config.IsDelete) { - var triples = new List(); + await DbManager.DeleteData(config.Credentials); + } - if (config.IsSolutionBased) - { - var solutionRoot = GetRoot(manager.SolutionFilePath); - var solutionRootNode = new FolderNode(solutionRoot, solutionRoot); - - var solutionName = GetSolutionName(manager.SolutionFilePath); - var solutionNode = new SolutionNode(solutionName); - triples.Add(new TripleIncludedIn(solutionNode, solutionRootNode)); - - var projectNode = new ProjectNode(GetProjectName(context.Projects[index].Item1.Name)); - triples.Add(new TripleContains(solutionNode, projectNode)); - } + var workspace = CreateWorkspace(manager); - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: analyze - starting"); - var projectTriples = await AnalyzeProject(index + 1, context.Projects[index], config.Tier); - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: analyze - finished"); - - triples.AddRange(projectTriples); - - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: grouping - starting"); - try - { - triples = triples.GroupBy(x => x.ToString()).Select(x => x.First()).OrderBy(x => x.NodeA.Label) - .ToList(); - } - catch (Exception error) + // Limit concurrent Neo4j connections to one per logical processor. + var semaphore = new SemaphoreSlim(Environment.ProcessorCount); + var total = projectAnalyzers.Count; + var tasks = new List(); + var index = 0; + + // Stream projects through the Build → Load pipeline and launch an Analyze + Insert + // task for each one as soon as it becomes available, without waiting for all + // projects to finish building and loading first. + await foreach (var entry in StreamProjectsAsync(manager, workspace)) + { + var capturedEntry = entry; + var capturedIndex = index++; + + tasks.Add(Task.Run(async () => { - Console.WriteLine("Error detected. Dumping detailed logging data."); - Console.WriteLine("["); - var first = true; - foreach (var triple in triples) + var triples = new List(); + + if (config.IsSolutionBased) { - if (!first) + var solutionRoot = GetRoot(manager.SolutionFilePath); + var solutionRootNode = new FolderNode(solutionRoot, solutionRoot); + + var solutionName = GetSolutionName(manager.SolutionFilePath); + var solutionNode = new SolutionNode(solutionName); + triples.Add(new TripleIncludedIn(solutionNode, solutionRootNode)); + + var projectNode = new ProjectNode(GetProjectName(capturedEntry.Item1.Name)); + triples.Add(new TripleContains(solutionNode, projectNode)); + } + + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: analyze - starting"); + var projectTriples = await AnalyzeProject(capturedIndex + 1, capturedEntry, config.Tier); + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: analyze - finished"); + + triples.AddRange(projectTriples); + + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: grouping - starting"); + try + { + triples = triples.GroupBy(x => x.ToString()).Select(x => x.First()).OrderBy(x => x.NodeA.Label) + .ToList(); + } + catch (Exception) + { + Console.WriteLine("Error detected. Dumping detailed logging data."); + Console.WriteLine("["); + var first = true; + foreach (var triple in triples) { - Console.WriteLine(","); + if (!first) + { + Console.WriteLine(","); + } + Console.Write($$"""{ "triple": {{ triple.ToInspection()}} }"""); + + first = false; + } + if (triples.Any()) + { + Console.WriteLine(""); } - Console.Write($$"""{ "triple": {{ triple.ToInspection()}} }"""); + Console.WriteLine("]"); + throw; + } + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: grouping - finished"); - first = false; + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: insert - starting"); + await semaphore.WaitAsync(); + try + { + await DbManager.InsertData(triples, config.Credentials); } - if (triples.Any()) + finally { - Console.WriteLine(""); + semaphore.Release(); } - Console.WriteLine("]"); - throw; - } - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: grouping - finished"); - - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: inserting - starting"); - await DbManager.InsertData(triples, config.Credentials, config.IsDelete && index == 0); - Console.WriteLine($"+ [{index + 1}/{context.Projects.Count} {context.Projects[index].Item1.Name}: inserting - finished"); + Console.WriteLine($"+ [{capturedIndex + 1}/{total}] {capturedEntry.Item1.Name}: insert - finished"); + })); } - context.Workspace.Dispose(); + + await Task.WhenAll(tasks); + workspace.Dispose(); } public class AnalysisContext(AdhocWorkspace workspace, List<(Project, IAnalyzerResult)> projects) @@ -104,53 +131,96 @@ public class AnalysisContext(AdhocWorkspace workspace, List<(Project, IAnalyzerR } // Based on https://github.com/phmonte/Buildalyzer/blob/9db3390b49dca033fd3f70439bab3a6327440a47/src/Buildalyzer.Workspaces/AnalyzerManagerExtensions.cs#L24-L60 - public static AnalysisContext GetAnalysisContext(IAnalyzerManager manager) + public static async Task GetAnalysisContext(IAnalyzerManager manager) { if (manager is null) { throw new ArgumentNullException(nameof(manager)); } - - var projectResults = new ConcurrentBag<(Project, IAnalyzerResult)>(); - Console.WriteLine("Building projects - starting"); - - List results = manager.Projects.Values - .Select(p => - { - Console.WriteLine($"Building projects - {p.ProjectFile.Name} - starting"); - var result = p.Build().FirstOrDefault(); - Console.WriteLine($"Building projects - {p.ProjectFile.Name} - finished"); - return result; - }) - .Where(x => x != null) - .ToList(); - Console.WriteLine("Building projects - finished."); - - // Create a new workspace and add the solution (if there was one) - AdhocWorkspace workspace = new AdhocWorkspace(); - if (!string.IsNullOrEmpty(manager.SolutionFilePath)) + var workspace = CreateWorkspace(manager); + var projects = new List<(Project, IAnalyzerResult)>(); + await foreach (var item in StreamProjectsAsync(manager, workspace)) { - SolutionInfo solutionInfo = SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Default, manager.SolutionFilePath); - workspace.AddSolution(solutionInfo); - - // Sort the projects so the order that they're added to the workspace in the same order as the solution file - List projectsInOrder = [.. manager.SolutionFile.ProjectsInOrder]; - results = [.. results.OrderBy(p => projectsInOrder.FindIndex(g => g.AbsolutePath == p.ProjectFilePath))]; + projects.Add(item); } - // Add each result to the new workspace (sorted in solution order above, if we have a solution) - foreach (IAnalyzerResult result in results) + return new AnalysisContext(workspace, projects); + } + + // Runs all MSBuild design-time builds in parallel (Build stage), waits for all to + // complete, then feeds results sequentially into the Roslyn AdhocWorkspace (Load stage), + // yielding each (Project, IAnalyzerResult) pair immediately so the Analyze and Insert + // stages can begin on each project without waiting for all projects to finish loading. + // + // Build and Load cannot be interleaved: AddToWorkspace(addProjectReferences: true) calls + // analyzer.Build() internally for any referenced project not yet in the workspace. If + // other builds are still in-flight when this happens, two concurrent builds of the same + // project race and Buildalyzer's environment detection fails. Waiting for all builds to + // complete first avoids the race while still streaming Load → Analyze. + // + // AdhocWorkspace is not thread-safe, so workspace mutations remain sequential. + private static async IAsyncEnumerable<(Project, IAnalyzerResult)> StreamProjectsAsync( + IAnalyzerManager manager, AdhocWorkspace workspace) + { + // Build stage: run MSBuild design-time builds in parallel, capped at the number + // of logical processors so we don't spawn an unbounded number of dotnet processes + var buildSemaphore = new SemaphoreSlim(Environment.ProcessorCount); + IAnalyzerResult?[] results = await Task.WhenAll( + manager.Projects.Values.Select(p => Task.Run(async () => + { + await buildSemaphore.WaitAsync(); + try + { + Console.WriteLine($"Build - {p.ProjectFile.Name} - starting"); + var result = p.Build().FirstOrDefault(); + Console.WriteLine($"Build - {p.ProjectFile.Name} - finished"); + return result; + } + finally + { + buildSemaphore.Release(); + } + }))); + + // Load stage: add each completed result to the workspace and yield immediately, + // so analysis can begin on each project without waiting for all to be loaded + foreach (var result in results) { - // Check for duplicate project files and don't add them - if (workspace.CurrentSolution.Projects.All(p => p.FilePath != result.ProjectFilePath)) + if (result is null) continue; + + Console.WriteLine($"Load - {Path.GetFileName(result.ProjectFilePath)} - starting"); + var existingProject = workspace.CurrentSolution.Projects + .FirstOrDefault(p => p.FilePath == result.ProjectFilePath); + if (existingProject is null) { + // AddToWorkspace with addProjectReferences: true eagerly adds referenced projects + // into the workspace. Those will be picked up via existingProject on their own + // iteration below. var project = result.AddToWorkspace(workspace, true); - projectResults.Add((project, result)); + Console.WriteLine($"Load - {Path.GetFileName(result.ProjectFilePath)} - finished"); + yield return (project, result); + } + else + { + // Already in the workspace because an earlier project pulled it in as a + // transitive reference. Still include it so it gets CONTAINS triples and + // is analyzed — just reuse the workspace Project object already there. + Console.WriteLine($"Load - {Path.GetFileName(result.ProjectFilePath)} - finished"); + yield return (existingProject, result); } } + } - return new AnalysisContext(workspace, projectResults.ToList()); + private static AdhocWorkspace CreateWorkspace(IAnalyzerManager manager) + { + var workspace = new AdhocWorkspace(); + if (!string.IsNullOrEmpty(manager.SolutionFilePath)) + { + SolutionInfo solutionInfo = SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Default, manager.SolutionFilePath); + workspace.AddSolution(solutionInfo); + } + return workspace; } private static async Task> AnalyzeProject(int index, (Project project, IAnalyzerResult projectAnalyzerResult) item, Tiers mode) diff --git a/Strazh/Database/DbManager.cs b/Strazh/Database/DbManager.cs index 04df7af..5170b31 100644 --- a/Strazh/Database/DbManager.cs +++ b/Strazh/Database/DbManager.cs @@ -11,23 +11,30 @@ public static class DbManager { private const string CONNECTION = "neo4j://localhost:7687"; - public static async Task InsertData(IList triples, CredentialsConfig credentials, bool isDelete) + public static async Task DeleteData(CredentialsConfig credentials) + { + if (credentials == null) + { + throw new ArgumentException($"Please, provide credentials."); + } + Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database..."); + await using var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password)); + await using var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database)); + await session.RunAsync("MATCH (n) DETACH DELETE n;"); + Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database complete."); + } + + public static async Task InsertData(IList triples, CredentialsConfig credentials) { if (credentials == null) { throw new ArgumentException($"Please, provide credentials."); } Console.WriteLine($"Code Knowledge Graph use \"{credentials.Database}\" Neo4j database."); - var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password)); - var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database)); + await using var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password)); + await using var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database)); try { - if (isDelete) - { - Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database..."); - await session.RunAsync("MATCH (n) DETACH DELETE n;"); - Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database complete."); - } Console.WriteLine($"Processing {triples.Count} triples..."); foreach (var triple in triples) { @@ -39,11 +46,6 @@ public static async Task InsertData(IList triples, CredentialsConfig cre { Console.WriteLine(ex.Message); } - finally - { - await session.CloseAsync(); - await driver.CloseAsync(); - } } } } \ No newline at end of file diff --git a/Strazh/Domain/Nodes.cs b/Strazh/Domain/Nodes.cs index 61c774a..c7859ae 100755 --- a/Strazh/Domain/Nodes.cs +++ b/Strazh/Domain/Nodes.cs @@ -1,4 +1,7 @@ +using System; using System.Linq; +using System.Security.Cryptography; +using System.Text; namespace Strazh.Domain { @@ -24,7 +27,17 @@ public Node(string fullName, string name) protected virtual void SetPrimaryKey() { - Pk = FullName.GetHashCode().ToString(); + Pk = DeterministicHash(FullName); + } + + // string.GetHashCode() is randomized per-process in .NET Core and later, so using it + // as a Neo4j pk causes every run to generate different values for the same node, + // defeating MERGE and creating duplicate nodes. MD5 gives a stable, collision-resistant + // identifier across runs. (This is not a security use — stability is all that matters.) + protected static string DeterministicHash(string value) + { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(bytes); } public virtual string Set(string node) => @@ -36,46 +49,26 @@ public string ToInspection() => // Code - public abstract class CodeNode : Node + public abstract class CodeNode(string fullName, string name, string[] modifiers = null) : Node(fullName, name) { - public CodeNode(string fullName, string name, string[] modifiers = null) - : base(fullName, name) - { - - Modifiers = modifiers == null ? "" : string.Join(", ", modifiers); - } - - public string Modifiers { get; } + public string Modifiers { get; } = modifiers == null ? "" : string.Join(", ", modifiers); public override string Set(string node) => $"{base.Set(node)}{(string.IsNullOrEmpty(Modifiers) ? "" : $", {node}.modifiers = \"{Modifiers}\"")}"; } - public abstract class TypeNode : CodeNode - { - public TypeNode(string fullName, string name, string[] modifiers = null) - : base(fullName, name, modifiers) - { - } - } + public abstract class TypeNode(string fullName, string name, string[] modifiers = null) + : CodeNode(fullName, name, modifiers); - public class ClassNode : TypeNode + public class ClassNode(string fullName, string name, string[] modifiers = null) + : TypeNode(fullName, name, modifiers) { - public ClassNode(string fullName, string name, string[] modifiers = null) - : base(fullName, name, modifiers) - { - } - public override string Label { get; } = "Class"; } - public class InterfaceNode : TypeNode + public class InterfaceNode(string fullName, string name, string[] modifiers = null) + : TypeNode(fullName, name, modifiers) { - public InterfaceNode(string fullName, string name, string[] modifiers = null) - : base(fullName, name, modifiers) - { - } - public override string Label { get; } = "Interface"; } @@ -100,25 +93,19 @@ public override string Set(string node) protected override void SetPrimaryKey() { - Pk = $"{FullName}{Arguments}{ReturnType}".GetHashCode().ToString(); + Pk = DeterministicHash($"{FullName}{Arguments}{ReturnType}"); } } // Structure - public class FileNode : Node + public class FileNode(string fullName, string name) : Node(fullName, name) { - public FileNode(string fullName, string name) - : base(fullName, name) { } - public override string Label { get; } = "File"; } - public class FolderNode : Node + public class FolderNode(string fullName, string name) : Node(fullName, name) { - public FolderNode(string fullName, string name) - : base(fullName, name) { } - public override string Label { get; } = "Folder"; } @@ -127,14 +114,11 @@ public class SolutionNode(string name) : Node(name, name) public override string Label => "Solution"; } - public class ProjectNode : Node + public class ProjectNode(string fullName, string name) : Node(fullName, name) { public ProjectNode(string name) : this(name, name) { } - public ProjectNode(string fullName, string name) - : base(fullName, name) { } - public override string Label { get; } = "Project"; } @@ -156,7 +140,7 @@ public override string Set(string node) protected override void SetPrimaryKey() { - Pk = $"{FullName}{Version}".GetHashCode().ToString(); + Pk = DeterministicHash($"{FullName}{Version}"); } } } \ No newline at end of file diff --git a/Strazh/Domain/Triples.cs b/Strazh/Domain/Triples.cs index e213591..0e83d94 100755 --- a/Strazh/Domain/Triples.cs +++ b/Strazh/Domain/Triples.cs @@ -1,19 +1,12 @@ namespace Strazh.Domain { - public abstract class Triple : IInspectable + public abstract class Triple(Node nodeA, Node nodeB, Relationship relationship) : IInspectable { - public Node NodeA { get; set; } + public Node NodeA { get; set; } = nodeA; - public Node NodeB { get; set; } + public Node NodeB { get; set; } = nodeB; - public Relationship Relationship { get; set; } - - protected Triple(Node nodeA, Node nodeB, Relationship relationship) - { - NodeA = nodeA; - NodeB = nodeB; - Relationship = relationship; - } + public Relationship Relationship { get; set; } = relationship; public override string ToString() => $"MERGE (a:{NodeA.Label} {{ pk: \"{NodeA.Pk}\" }}) ON CREATE SET {NodeA.Set("a")} ON MATCH SET {NodeA.Set("a")} MERGE (b:{NodeB.Label} {{ pk: \"{NodeB.Pk}\" }}) ON CREATE SET {NodeB.Set("b")} ON MATCH SET {NodeB.Set("b")} MERGE (a)-[:{Relationship.Type}]->(b);"; @@ -24,23 +17,13 @@ public string ToInspection() => // Structure - public class TripleDependsOnProject : Triple - { - public TripleDependsOnProject( - ProjectNode projectA, - ProjectNode projectB) - : base(projectA, projectB, new DependsOnRelationship()) - { } - } + public class TripleDependsOnProject( + ProjectNode projectA, + ProjectNode projectB) : Triple(projectA, projectB, new DependsOnRelationship()); - public class TripleDependsOnPackage : Triple - { - public TripleDependsOnPackage( - ProjectNode projectA, - PackageNode packageB) - : base(projectA, packageB, new DependsOnRelationship()) - { } - } + public class TripleDependsOnPackage( + ProjectNode projectA, + PackageNode packageB) : Triple(projectA, packageB, new DependsOnRelationship()); public class TripleIncludedIn : Triple { @@ -71,53 +54,27 @@ public TripleIncludedIn( } - public class TripleContains : Triple - { - public TripleContains( - SolutionNode solution, - ProjectNode project) - : base(solution, project, new ContainsRelationship()) - { - } - } + public class TripleContains( + SolutionNode solution, + ProjectNode project) : Triple(solution, project, new ContainsRelationship()); - public class TripleDeclaredAt : Triple - { - public TripleDeclaredAt( - TypeNode typeA, - FileNode fileB) - : base(typeA, fileB, new DeclaredAtRelationship()) - { } - } + public class TripleDeclaredAt( + TypeNode typeA, + FileNode fileB) : Triple(typeA, fileB, new DeclaredAtRelationship()); // Code - public class TripleInvoke : Triple - { - public TripleInvoke( - MethodNode methodA, - MethodNode methodB) - : base(methodA, methodB, new InvokeRelationship()) - { } - } + public class TripleInvoke( + MethodNode methodA, + MethodNode methodB) : Triple(methodA, methodB, new InvokeRelationship()); - public class TripleHave : Triple - { - public TripleHave( - TypeNode typeA, - MethodNode methodB) - : base(typeA, methodB, new HaveRelationship()) - { } - } + public class TripleHave( + TypeNode typeA, + MethodNode methodB) : Triple(typeA, methodB, new HaveRelationship()); - public class TripleConstruct : Triple - { - public TripleConstruct( - MethodNode methodA, - ClassNode classB) - : base(methodA, classB, new ConstructRelationship()) - { } - } + public class TripleConstruct( + MethodNode methodA, + ClassNode classB) : Triple(methodA, classB, new ConstructRelationship()); public class TripleOfType : Triple { diff --git a/Strazh/Program.cs b/Strazh/Program.cs index ae0a8c9..c43421f 100644 --- a/Strazh/Program.cs +++ b/Strazh/Program.cs @@ -1,86 +1,94 @@ -using System; -using System.CommandLine; -using System.CommandLine.Invocation; -using Microsoft.Build.Logging.StructuredLogger; -using Strazh.Analysis; -using Task = System.Threading.Tasks.Task; - -namespace Strazh -{ - public class Program - { - - public static async Task Main(params string[] args) - { -#if DEBUG - // There is an issue with using Neo4j.Driver 4.2.0 - // System.IO.FileNotFoundException: Could not load file or assembly '4.2.37.0'. The system cannot find the file specified. - // Workaround to load assembly and avoid issue - System.Reflection.Assembly.Load("Neo4j.Driver"); -#endif - var rootCommand = new RootCommand(); - - var optionCredentials = new Option("--credentials", "required information in format `dbname:user:password` to connect to Neo4j Database"); - optionCredentials.AddAlias("-c"); - optionCredentials.IsRequired = true; - rootCommand.Add(optionCredentials); - - var optionMode = new Option("--tier", "optional flag as `project` or `code` or 'all' (default `all`) selected tier to scan in a codebase"); - optionMode.AddAlias("-t"); - optionMode.IsRequired = false; - rootCommand.Add(optionMode); - - var optionDelete = new Option("--delete", "optional flag as `true` or `false` or no flag (default `true`) to delete data in graph before execution"); - optionDelete.AddAlias("-d"); - optionDelete.IsRequired = false; - rootCommand.Add(optionDelete); - - var optionSolution = new Option("--solution", "optional absolute path to only one `.sln` file (can't be used together with -p / --projects)"); - optionSolution.AddAlias("-s"); - optionSolution.IsRequired = false; - rootCommand.Add(optionSolution); - - var optionProjects = new Option("--projects", "optional list of absolute path to one or many `.csproj` files (can't be used together with -s / --solution)"); - optionProjects.AddAlias("-p"); - optionProjects.IsRequired = false; - rootCommand.Add(optionProjects); - - rootCommand.SetHandler(BuildKnowledgeGraph, optionCredentials, optionMode, optionDelete, optionSolution, optionProjects); - - await rootCommand.InvokeAsync(args); - } - - private static async Task BuildKnowledgeGraph(string credentials, string tier, string delete, string solution, string[] projects) - { - try - { - var config = new AnalyzerConfig( - credentials, - tier, - delete, - solution, - projects - ); - if (!config.IsValid) - { - Console.WriteLine("Please submit only one thing: `--solution` (-s) or `--projects` (-p)"); - return; - } - var isNeo4jReady = await Healthcheck.IsNeo4jReady(); - if (!isNeo4jReady) - { - Console.WriteLine("Strazh failed to start. There is no Neo4j instance ready to use."); - return; - } - - Console.WriteLine($"Brewing a Code Knowledge Graph of tier \"{config.Tier}\"."); - await Analyzer.Analyze(config); - Console.WriteLine("Code Knowledge Graph created."); - } - catch (Exception ex) - { - Console.WriteLine(ex); - } - } - } -} +using System; +using System.CommandLine; +using System.Threading; +using System.Threading.Tasks; +using Strazh.Analysis; + +namespace Strazh +{ + public class Program + { + + public static async Task Main(params string[] args) + { + var rootCommand = new RootCommand(); + + var optionCredentials = new Option("--credentials", "-c") + { + Description = "required information in format `dbname:user:password` to connect to Neo4j Database", + Required = true + }; + rootCommand.Options.Add(optionCredentials); + + var optionMode = new Option("--tier", "-t") + { + Description = "optional flag as `project` or `code` or 'all' (default `all`) selected tier to scan in a codebase" + }; + rootCommand.Options.Add(optionMode); + + var optionDelete = new Option("--delete", "-d") + { + Description = "optional flag as `true` or `false` or no flag (default `true`) to delete data in graph before execution" + }; + rootCommand.Options.Add(optionDelete); + + var optionSolution = new Option("--solution", "-s") + { + Description = "optional absolute path to only one `.sln` file (can't be used together with -p / --projects)" + }; + rootCommand.Options.Add(optionSolution); + + var optionProjects = new Option("--projects", "-p") + { + Description = "optional list of absolute path to one or many `.csproj` files (can't be used together with -s / --solution)", + AllowMultipleArgumentsPerToken = true + }; + rootCommand.Options.Add(optionProjects); + + rootCommand.SetAction(async (ParseResult parseResult, CancellationToken token) => + { + await BuildKnowledgeGraph( + parseResult.GetValue(optionCredentials), + parseResult.GetValue(optionMode), + parseResult.GetValue(optionDelete), + parseResult.GetValue(optionSolution), + parseResult.GetValue(optionProjects)); + }); + + return await rootCommand.Parse(args).InvokeAsync(); + } + + private static async Task BuildKnowledgeGraph(string credentials, string tier, string delete, string solution, string[] projects) + { + try + { + var config = new AnalyzerConfig( + credentials, + tier, + delete, + solution, + projects + ); + if (!config.IsValid) + { + Console.WriteLine("Please submit only one thing: `--solution` (-s) or `--projects` (-p)"); + return; + } + var isNeo4jReady = await Healthcheck.IsNeo4jReady(); + if (!isNeo4jReady) + { + Console.WriteLine("Strazh failed to start. There is no Neo4j instance ready to use."); + return; + } + + Console.WriteLine($"Brewing a Code Knowledge Graph of tier \"{config.Tier}\"."); + await Analyzer.Analyze(config); + Console.WriteLine("Code Knowledge Graph created."); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + } + } +} diff --git a/Strazh/Strazh.csproj b/Strazh/Strazh.csproj index 5ebf613..e0d0ff1 100644 --- a/Strazh/Strazh.csproj +++ b/Strazh/Strazh.csproj @@ -2,19 +2,18 @@ Exe - net9.0 + net10.0 1.0.0-beta.1 enable - - - - - - - + + + + + + \ No newline at end of file diff --git a/Strazh/Strazh.sln b/Strazh/Strazh.sln index fb230ae..b892bd2 100755 --- a/Strazh/Strazh.sln +++ b/Strazh/Strazh.sln @@ -1,49 +1,91 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.808.7 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Strazh", "Strazh.csproj", "{1C92D6A7-867F-42CC-933B-DB78249BA75B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B52B8BC0-5B94-4169-8EE1-CFF023E6F34E}" - ProjectSection(SolutionItems) = preProject - ..\docker-compose.yml = ..\docker-compose.yml - ..\Dockerfile = ..\Dockerfile - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectA", "..\SystemUnderTest\Strazh.Tests.ProjectA\Strazh.Tests.ProjectA.csproj", "{CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectB", "..\SystemUnderTest\Strazh.Tests.ProjectB\Strazh.Tests.ProjectB.csproj", "{D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EF685B52-D0F9-4350-9956-90CF2DE13610}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|Any CPU.Build.0 = Release|Any CPU - {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.Build.0 = Release|Any CPU - {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C} = {EF685B52-D0F9-4350-9956-90CF2DE13610} - {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC} = {EF685B52-D0F9-4350-9956-90CF2DE13610} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {2B976F72-0E21-452F-9EAE-868AEEF480D8} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.808.7 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Strazh", "Strazh.csproj", "{1C92D6A7-867F-42CC-933B-DB78249BA75B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B52B8BC0-5B94-4169-8EE1-CFF023E6F34E}" + ProjectSection(SolutionItems) = preProject + ..\docker-compose.yml = ..\docker-compose.yml + ..\Dockerfile = ..\Dockerfile + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectA", "..\SystemUnderTest\Strazh.Tests.ProjectA\Strazh.Tests.ProjectA.csproj", "{CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectB", "..\SystemUnderTest\Strazh.Tests.ProjectB\Strazh.Tests.ProjectB.csproj", "{D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EF685B52-D0F9-4350-9956-90CF2DE13610}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests", "..\Strazh.Tests\Strazh.Tests.csproj", "{267F7E5F-419B-434D-B9C0-27A12FC95B77}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|x64.Build.0 = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Debug|x86.Build.0 = Debug|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|Any CPU.Build.0 = Release|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|x64.ActiveCfg = Release|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|x64.Build.0 = Release|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|x86.ActiveCfg = Release|Any CPU + {1C92D6A7-867F-42CC-933B-DB78249BA75B}.Release|x86.Build.0 = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|x64.Build.0 = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|x86.Build.0 = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.Build.0 = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|x64.ActiveCfg = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|x64.Build.0 = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|x86.ActiveCfg = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|x86.Build.0 = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|x64.Build.0 = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|x86.Build.0 = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|x64.ActiveCfg = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|x64.Build.0 = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|x86.ActiveCfg = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|x86.Build.0 = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|x64.ActiveCfg = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|x64.Build.0 = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|x86.ActiveCfg = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Debug|x86.Build.0 = Debug|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|Any CPU.Build.0 = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|x64.ActiveCfg = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|x64.Build.0 = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|x86.ActiveCfg = Release|Any CPU + {267F7E5F-419B-434D-B9C0-27A12FC95B77}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C} = {EF685B52-D0F9-4350-9956-90CF2DE13610} + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC} = {EF685B52-D0F9-4350-9956-90CF2DE13610} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2B976F72-0E21-452F-9EAE-868AEEF480D8} + EndGlobalSection +EndGlobal diff --git a/SystemUnderTest/SystemUnderTest.sln b/SystemUnderTest/SystemUnderTest.sln new file mode 100644 index 0000000..9251aef --- /dev/null +++ b/SystemUnderTest/SystemUnderTest.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectA", "Strazh.Tests.ProjectA\Strazh.Tests.ProjectA.csproj", "{CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Strazh.Tests.ProjectB", "Strazh.Tests.ProjectB\Strazh.Tests.ProjectB.csproj", "{D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBE72A8F-DDA9-45DC-987B-B55FA9A3EC2C}.Release|Any CPU.Build.0 = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8AEF0F8-1FC1-4CAF-AC35-C62D29A83ABC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml index 1d52306..9c0beb6 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: strazh: @@ -7,7 +6,7 @@ services: container_name: strazh network_mode: host volumes: - - C:\src\github\strazh\SystemUnderTest:/dest + - ./SystemUnderTest:/dest environment: - c=neo4j:neo4j:strazhpass - p=/dest/Strazh.Tests.ProjectB/Strazh.Tests.ProjectB.csproj /dest/Strazh.Tests.ProjectA/Strazh.Tests.ProjectA.csproj @@ -15,7 +14,7 @@ services: - neo4j neo4j: - image: neo4j:4.2.0 + image: neo4j:2026.03.1 container_name: strazh_neo4j restart: unless-stopped ports: @@ -23,8 +22,8 @@ services: - 7687:7687 environment: NEO4J_AUTH: neo4j/strazhpass - NEO4J_dbms_memory_pagecache_size: 1G - NEO4J_dbms.memory.heap.initial_size: 1G - NEO4J_dbms_memory_heap_max__size: 1G - NEO4JLABS_PLUGINS: "[\"apoc\",\"graph-data-science\"]" + NEO4J_server_memory_pagecache_size: 1G + NEO4J_server_memory_heap_initial__size: 1G + NEO4J_server_memory_heap_max__size: 1G + NEO4J_PLUGINS: "[\"apoc\",\"graph-data-science\"]" \ No newline at end of file