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..3c3e080 --- /dev/null +++ b/Strazh.Tests/AnalyzerTests.cs @@ -0,0 +1,139 @@ +using System; +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); + } + + /// + /// Verifies that the first run with a cache directory (cache miss, binlog written) produces + /// the same results as a completely uncached build. + /// + [Fact] + public async Task GetAnalysisContext_FirstCachedRun_YieldsSameResultsAsUncachedBuild() + { + var solutionPath = Path.Combine(GetRepoRoot(), "SystemUnderTest", "SystemUnderTest.sln"); + var cacheDir = Path.Combine(Path.GetTempPath(), $"strazh-test-{Guid.NewGuid():N}"); + try + { + var cachedContext = await Analyzer.GetAnalysisContext( + new AnalyzerManager(solutionPath), cacheDir); + + var uncachedContext = await Analyzer.GetAnalysisContext( + new AnalyzerManager(solutionPath)); + + AssertAnalysisContextsAreEquivalent(uncachedContext, cachedContext); + } + finally + { + if (Directory.Exists(cacheDir)) + { + Directory.Delete(cacheDir, recursive: true); + } + } + } + + /// + /// Verifies that replaying a populated cache (cache hit, binlog replayed) produces the same + /// results as a completely uncached build. + /// + [Fact] + public async Task GetAnalysisContext_CacheHitRun_YieldsSameResultsAsUncachedBuild() + { + var solutionPath = Path.Combine(GetRepoRoot(), "SystemUnderTest", "SystemUnderTest.sln"); + var cacheDir = Path.Combine(Path.GetTempPath(), $"strazh-test-{Guid.NewGuid():N}"); + try + { + // Populate the cache + await Analyzer.GetAnalysisContext(new AnalyzerManager(solutionPath), cacheDir); + + // Replay from cache + var cachedContext = await Analyzer.GetAnalysisContext( + new AnalyzerManager(solutionPath), cacheDir); + + var uncachedContext = await Analyzer.GetAnalysisContext( + new AnalyzerManager(solutionPath)); + + AssertAnalysisContextsAreEquivalent(uncachedContext, cachedContext); + } + finally + { + if (Directory.Exists(cacheDir)) + { + Directory.Delete(cacheDir, recursive: true); + } + } + } + + private static void AssertAnalysisContextsAreEquivalent( + Analyzer.AnalysisContext expected, Analyzer.AnalysisContext actual) + { + var expectedResults = expected.Projects + .Select(p => p.Item2) + .OrderBy(r => r.ProjectFilePath) + .ToList(); + + var actualResults = actual.Projects + .Select(p => p.Item2) + .OrderBy(r => r.ProjectFilePath) + .ToList(); + + Assert.Equal(expectedResults.Count, actualResults.Count); + + for (var i = 0; i < expectedResults.Count; i++) + { + var exp = expectedResults[i]; + var act = actualResults[i]; + + Assert.Equal(exp.ProjectFilePath, act.ProjectFilePath); + Assert.Equal(exp.Succeeded, act.Succeeded); + Assert.Equal( + exp.ProjectReferences.OrderBy(x => x).ToList(), + act.ProjectReferences.OrderBy(x => x).ToList()); + Assert.Equal( + exp.SourceFiles.OrderBy(x => x).ToList(), + act.SourceFiles.OrderBy(x => x).ToList()); + Assert.Equal( + exp.PackageReferences.Keys.OrderBy(x => x).ToList(), + act.PackageReferences.Keys.OrderBy(x => x).ToList()); + } + } + + // [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..4705f73 100755 --- a/Strazh/Analysis/Analyzer.cs +++ b/Strazh/Analysis/Analyzer.cs @@ -1,23 +1,25 @@ +using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CSharp.Syntax; using System.Linq; using Microsoft.CodeAnalysis; using Strazh.Domain; using Buildalyzer; +using Buildalyzer.Environment; 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; +using System.Security.Cryptography; +using System.Text; namespace Strazh.Analysis { public static class Analyzer { - public static async Task Analyze(AnalyzerConfig config) + public static async Task Analyze(AnalyzerConfig config, IAnalysisProgress progress) { Console.WriteLine($"Setup analyzer..."); @@ -30,71 +32,107 @@ 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++) - { - var triples = new List(); - - 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)); - } - 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) + // Delete graph data upfront before parallel analysis begins, so that no project + // races against the delete. + if (config.IsDelete) + { + await DbManager.DeleteData(config.Credentials); + } + + // Ensure uniqueness constraints (and their implicit indexes) exist for every node + // label before any MERGE operations run. Without indexes, each MERGE does a full + // label scan and performance degrades linearly as the database grows. + await DbManager.EnsureIndexes(config.Credentials); + + var workspace = CreateWorkspace(manager); + + // Limit concurrent Neo4j connections to one per logical processor. + var semaphore = new SemaphoreSlim(Environment.ProcessorCount); + var analysisTasks = new List(); + + // 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 progress.WrapAsync(projectAnalyzers.Count, async () => + { + await foreach (var entry in StreamProjectsAsync(manager, workspace, config.CacheDirectory, + new StreamCallbacks + { + OnBuildStarted = (path, name, isCacheHit) => progress.OnBuildStarted(path, name, isCacheHit), + OnBuildCompleted = path => progress.OnBuildCompleted(path), + OnProjectSkipped = (path, filename, reason) => progress.OnProjectSkipped(path, filename, reason) + }, config.NoCache, config.BuildLogDirectory)) { - Console.WriteLine("Error detected. Dumping detailed logging data."); - Console.WriteLine("["); - var first = true; - foreach (var triple in triples) + var capturedEntry = entry; + var projectPath = capturedEntry.Item2.ProjectFilePath; + var projectDisplayName = GetProjectName(capturedEntry.Item1.Name); + + progress.OnStageChanged(projectPath, projectDisplayName, "Analyzing"); + + analysisTasks.Add(Task.Run(async () => { - if (!first) + var triples = new List(); + + if (config.IsSolutionBased) { - Console.WriteLine(","); + 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)); + + // Connect the project's folder to the solution's root folder so the folder + // hierarchy is traversable from the solution downward. Without this triple, + // project folder nodes are orphans — present in the graph but unreachable + // from the solution folder via INCLUDED_IN traversal. + var projectRoot = capturedEntry.Item1.FilePath is { } fp ? GetRoot(fp) : null; + if (!string.IsNullOrEmpty(projectRoot) && + !projectRoot.Equals(solutionRoot, StringComparison.OrdinalIgnoreCase)) + { + var projectRootNode = new FolderNode(projectRoot, projectRoot); + triples.Add(new TripleIncludedIn(projectRootNode, solutionRootNode)); + } + + var projectNode = new ProjectNode(GetProjectName(capturedEntry.Item1.Name)); + triples.Add(new TripleContains(solutionNode, projectNode)); } - Console.Write($$"""{ "triple": {{ triple.ToInspection()}} }"""); - first = false; - } - if (triples.Any()) - { - Console.WriteLine(""); - } - Console.WriteLine("]"); - throw; + var projectTriples = await AnalyzeProject(capturedEntry, config.Tier); + triples.AddRange(projectTriples); + + progress.OnStageChanged(projectPath, projectDisplayName, "Grouping"); + try + { + triples = triples.GroupBy(x => x.ToString()).Select(x => x.First()).OrderBy(x => x.NodeA.Label) + .ToList(); + } + catch (Exception) + { + progress.OnGroupingError(projectDisplayName, triples); + throw; + } + + progress.OnStageChanged(projectPath, projectDisplayName, "Inserting"); + await semaphore.WaitAsync(); + try + { + await DbManager.InsertData(triples, config.Credentials); + } + finally + { + semaphore.Release(); + } + + progress.OnProjectCompleted(projectPath, triples.Count); + })); } - 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"); - } - context.Workspace.Dispose(); + + await Task.WhenAll(analysisTasks); + }); + + workspace.Dispose(); } public class AnalysisContext(AdhocWorkspace workspace, List<(Project, IAnalyzerResult)> projects) @@ -104,67 +142,389 @@ 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, string? cacheDirectory = null) { 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 => + var workspace = CreateWorkspace(manager); + var projects = new List<(Project, IAnalyzerResult)>(); + await foreach (var item in StreamProjectsAsync(manager, workspace, cacheDirectory)) + { + projects.Add(item); + } + + 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 readonly struct StreamCallbacks + { + public Action? OnBuildStarted { get; init; } + public Action? OnBuildCompleted { get; init; } + public Action? OnProjectSkipped { get; init; } + } + + private static async IAsyncEnumerable<(Project, IAnalyzerResult)> StreamProjectsAsync( + IAnalyzerManager manager, AdhocWorkspace workspace, string? cacheDirectory = null, + StreamCallbacks callbacks = default, bool noCache = false, string? buildLogDirectory = null) + { + if (buildLogDirectory != null) + { + Directory.CreateDirectory(buildLogDirectory); + } + + HashSet? projectsNeedingRebuild = null; + if (cacheDirectory != null) + { + Directory.CreateDirectory(cacheDirectory); + projectsNeedingRebuild = noCache + ? manager.Projects.Values + .Select(p => p.ProjectFile.Path) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + : ComputeProjectsNeedingRebuild(manager.Projects.Values, cacheDirectory); + } + + // 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 + { + IAnalyzerResult? result; + if (cacheDirectory != null) + { + var binlogPath = GetBinlogCachePath(cacheDirectory, p); + if (projectsNeedingRebuild!.Contains(p.ProjectFile.Path)) + { + callbacks.OnBuildStarted?.Invoke(p.ProjectFile.Path, GetProjectName(p.ProjectFile.Name), false); + var (_, timedOut) = await BuildWithTimeoutAsync(p, CreateBuildOptions(buildLogDirectory, GetProjectName(p.ProjectFile.Name), binlogPath)); + if (timedOut) + { + callbacks.OnProjectSkipped?.Invoke(p.ProjectFile.Path, Path.GetFileName(p.ProjectFile.Path), "build timed out"); + return null; + } + // Read from the binlog rather than the pipe result. The pipe uses + // MsBuildPipeLogger, which may not support the event types emitted by + // the SDK's MSBuild version, leaving the pipe result empty even on a + // successful build. The binlog is written natively by MSBuild and read + // by StructuredLogger, which handles the current format regardless of + // version skew. + var binlogExists = File.Exists(binlogPath); + result = binlogExists ? await TryAnalyzeBinlogAsync(manager, binlogPath) : null; + if (result == null) + { + var reason = binlogExists ? "build log could not be read" : "build failed"; + callbacks.OnProjectSkipped?.Invoke(p.ProjectFile.Path, Path.GetFileName(p.ProjectFile.Path), reason); + return null; + } + WriteDepsFile(binlogPath, result); + callbacks.OnBuildCompleted?.Invoke(p.ProjectFile.Path); + } + else + { + callbacks.OnBuildStarted?.Invoke(p.ProjectFile.Path, GetProjectName(p.ProjectFile.Name), true); + result = manager.Analyze(binlogPath).FirstOrDefault(); + if (result == null) + { + callbacks.OnProjectSkipped?.Invoke(p.ProjectFile.Path, Path.GetFileName(p.ProjectFile.Path), "cached build log could not be read"); + return null; + } + // Keep the sidecar in sync with the binlog even on a cache hit. + // Without this, a project whose build previously timed out (leaving + // the deps file from an older run) would carry stale dependency + // information into the next staleness-propagation pass. + WriteDepsFile(binlogPath, result); + callbacks.OnBuildCompleted?.Invoke(p.ProjectFile.Path); + } + } + else + { + callbacks.OnBuildStarted?.Invoke(p.ProjectFile.Path, GetProjectName(p.ProjectFile.Name), false); + var (buildResult2, timedOut2) = await BuildWithTimeoutAsync(p, CreateBuildOptions(buildLogDirectory, GetProjectName(p.ProjectFile.Name))); + result = buildResult2; + if (result == null) + { + var reason = timedOut2 ? "build timed out" : "build failed"; + callbacks.OnProjectSkipped?.Invoke(p.ProjectFile.Path, Path.GetFileName(p.ProjectFile.Path), reason); + return null; + } + callbacks.OnBuildCompleted?.Invoke(p.ProjectFile.Path); + } + 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. + // Results are sorted in dependency order so every project's references are already + // in the workspace when AddToWorkspace runs, preventing it from triggering redundant + // MSBuild builds for references it cannot find there yet. + foreach (var result in TopologicalSort(results)) + { + if (result is null) + { + continue; + } + + var existingProject = workspace.CurrentSolution.Projects + .FirstOrDefault(p => p.FilePath == result.ProjectFilePath); + if (existingProject is null) { - 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(); + // 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); + if (project is null) + { + // AddToWorkspace returns null for project types not supported by Roslyn + // (e.g. F# projects, native projects). Skip them — they cannot be analyzed. + callbacks.OnProjectSkipped?.Invoke(result.ProjectFilePath, Path.GetFileName(result.ProjectFilePath), "unsupported project type"); + continue; + } + 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. + yield return (existingProject, result); + } + } + } + + // Sorts build results so that every project appears after all of its project + // references that are also in the result set. Without this ordering, + // AddToWorkspace(addProjectReferences: true) encounters references not yet in + // the workspace and calls analyzer.Build() on them — bypassing our binlog cache + // and running full, sequential in-process MSBuild evaluations for each one. + // A DFS post-order traversal over the dependency graph produces the correct order. + private static IReadOnlyList TopologicalSort(IAnalyzerResult?[] results) + { + var byPath = results + .Where(r => r is not null) + .ToDictionary(r => r!.ProjectFilePath, r => r!, StringComparer.OrdinalIgnoreCase); + + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var sorted = new List(byPath.Count); + + void Visit(IAnalyzerResult result) + { + if (!visited.Add(result.ProjectFilePath)) + { + return; + } + foreach (var refPath in result.ProjectReferences) + { + if (byPath.TryGetValue(refPath, out var refResult)) + { + Visit(refResult); + } + } + sorted.Add(result); + } + + foreach (var result in byPath.Values) + { + Visit(result); + } + + return sorted; + } + + // If a build hangs (e.g. Android/MAUI projects waiting on SDK tools not present in the + // environment), WhenAny returns after the timeout and we skip the project. The underlying + // Task.Run continues to hold its thread until the process eventually exits or is reaped + // when the parent process terminates — that is acceptable. + private static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(5); + + // Reads the binlog, retrying once after a short delay if the first attempt yields no + // results. The MSBuild child process closes its stdout pipe (causing p.Build() to return) + // before the BinaryLogger's file write is guaranteed to be fully flushed to disk. A single + // retry covers the common case where the OS buffers have not yet been committed. + private static async Task TryAnalyzeBinlogAsync(IAnalyzerManager manager, string binlogPath) + { + var result = manager.Analyze(binlogPath).FirstOrDefault(); + if (result is not null) + { + return result; + } + await Task.Delay(500).ConfigureAwait(false); + return manager.Analyze(binlogPath).FirstOrDefault(); + } + + private static async Task<(IAnalyzerResult? result, bool timedOut)> BuildWithTimeoutAsync(IProjectAnalyzer p, EnvironmentOptions opts) + { + var buildTask = Task.Run(() => p.Build(opts).FirstOrDefault()); + if (await Task.WhenAny(buildTask, Task.Delay(BuildTimeout)).ConfigureAwait(false) != buildTask) + { + return (null, timedOut: true); + } + return (await buildTask.ConfigureAwait(false), timedOut: false); + } + + private static EnvironmentOptions CreateBuildOptions(string? buildLogDirectory, string projectName, string? binlogPath = null) + { + var opts = new EnvironmentOptions(); + opts.Arguments.Add("/nodeReuse:false"); + if (binlogPath != null) + { + // MSBuild writes the binlog directly in the child process. This avoids running the + // BinaryLogger on the host-side pipe, which can cause deserialization errors when the + // SDK's MSBuild version is newer than the MsBuildPipeLogger the host uses. + opts.Arguments.Add($"\"/bl:{binlogPath}\""); + } + if (buildLogDirectory != null) + { + var logFile = Path.Combine(buildLogDirectory, $"{projectName}.log"); + opts.Arguments.Add($"\"/fileLoggerParameters:LogFile={logFile};Verbosity=normal\""); + } + return opts; + } + + 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 string GetBinlogCachePath(string cacheDirectory, IProjectAnalyzer p) + { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes(p.ProjectFile.Path)); + var hash = Convert.ToHexString(bytes)[..8]; + return Path.Combine(cacheDirectory, $"{p.ProjectFile.Name}_{hash}.binlog"); + } + + // Returns true if no binlog exists, or if the binlog is older than any source or + // build file in the project directory (excluding obj/ and bin/ output folders). + private static bool IsBinlogStale(string binlogPath, string projectFilePath) + { + if (!File.Exists(binlogPath)) + { + return true; + } - // 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))]; + var binlogTime = File.GetLastWriteTimeUtc(binlogPath); + + if (File.GetLastWriteTimeUtc(projectFilePath) > binlogTime) + { + return true; } - // Add each result to the new workspace (sorted in solution order above, if we have a solution) - foreach (IAnalyzerResult result in results) + var projectDir = Path.GetDirectoryName(projectFilePath)!; + var trackedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { ".cs", ".fs", ".vb", ".props", ".targets" }; + + foreach (var file in Directory.EnumerateFiles(projectDir, "*", SearchOption.AllDirectories)) { - // Check for duplicate project files and don't add them - if (workspace.CurrentSolution.Projects.All(p => p.FilePath != result.ProjectFilePath)) + if (file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") || + file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) { - var project = result.AddToWorkspace(workspace, true); - projectResults.Add((project, result)); + continue; + } + + if (trackedExtensions.Contains(Path.GetExtension(file)) && + File.GetLastWriteTimeUtc(file) > binlogTime) + { + return true; + } + } + + return false; + } + + // Writes the fully-evaluated project references from a build result as a sidecar next to + // the binlog. Subsequent runs read this instead of parsing the project file, so all + // MSBuild evaluation (conditions, imports, Directory.Build.props, etc.) is accounted for. + private static void WriteDepsFile(string binlogPath, IAnalyzerResult result) => + File.WriteAllLines(binlogPath + ".deps", result.ProjectReferences); + + // Reads the deps sidecar written by a previous build. Returns empty when the file does + // not exist (first run, or cache cleared) — the project will be stale for other reasons. + private static IReadOnlyList ReadDepsFile(string binlogPath) + { + var depsPath = binlogPath + ".deps"; + return File.Exists(depsPath) ? File.ReadAllLines(depsPath) : []; + } + + // Determines which projects must be rebuilt, accounting for transitive dependencies: + // if project B needs a rebuild and project A references B, A is also marked for rebuild. + // The dependency graph is reconstructed from the sidecar files written by previous builds, + // so MSBuild's own evaluation (conditions, imports, etc.) is used — not our own parsing. + private static HashSet ComputeProjectsNeedingRebuild( + IEnumerable projects, string cacheDirectory) + { + var projectMap = projects.ToDictionary(p => p.ProjectFile.Path, StringComparer.OrdinalIgnoreCase); + + var depGraph = projectMap.ToDictionary( + kvp => kvp.Key, + kvp => ReadDepsFile(GetBinlogCachePath(cacheDirectory, kvp.Value)) + .Where(r => projectMap.ContainsKey(r)) + .ToList(), + StringComparer.OrdinalIgnoreCase); + + var needsRebuild = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (path, analyzer) in projectMap) + { + if (IsBinlogStale(GetBinlogCachePath(cacheDirectory, analyzer), path)) + { + needsRebuild.Add(path); } } - return new AnalysisContext(workspace, projectResults.ToList()); + // Propagate: if a referenced project needs a rebuild, so does the referencing project + bool changed; + do + { + changed = false; + foreach (var (path, refs) in depGraph) + { + if (!needsRebuild.Contains(path) && refs.Any(r => needsRebuild.Contains(r))) + { + needsRebuild.Add(path); + changed = true; + } + } + } while (changed); + + return needsRebuild; } - private static async Task> AnalyzeProject(int index, (Project project, IAnalyzerResult projectAnalyzerResult) item, Tiers mode) + private static async Task> AnalyzeProject((Project project, IAnalyzerResult projectAnalyzerResult) item, Tiers mode) { - Console.WriteLine($"Project #{index}:"); var root = GetRoot(item.project.FilePath); var rootNode = new FolderNode(root, root); var projectName = GetProjectName(item.project.Name); - Console.WriteLine($"Analyzing {projectName} project..."); var triples = new List(); if (mode == Tiers.All || mode == Tiers.Project) { - Console.WriteLine($"Analyzing Project tier..."); var projectNode = new ProjectNode(projectName); triples.Add(new TripleIncludedIn(projectNode, rootNode)); item.projectAnalyzerResult.ProjectReferences.ToList().ForEach(x => @@ -178,13 +538,11 @@ private static async Task> AnalyzeProject(int index, (Project proj var node = new PackageNode(x.Key, x.Key, version); triples.Add(new TripleDependsOnPackage(projectNode, node)); }); - Console.WriteLine($"Analyzing Project tier complete."); } if (item.project.SupportsCompilation && (mode == Tiers.All || mode == Tiers.Code)) { - Console.WriteLine($"Analyzing Code tier..."); var compilation = await item.project.GetCompilationAsync(); var syntaxTreeRoot = compilation.SyntaxTrees.Where(x => !x.FilePath.Contains("obj")); foreach (var st in syntaxTreeRoot) @@ -193,10 +551,8 @@ private static async Task> AnalyzeProject(int index, (Project proj Extractor.AnalyzeTree(triples, st, sem, rootNode); Extractor.AnalyzeTree(triples, st, sem, rootNode); } - Console.WriteLine($"Analyzing Code tier complete."); } - Console.WriteLine($"Analyzing {projectName} project complete."); return triples; } diff --git a/Strazh/Analysis/AnalyzerConfig.cs b/Strazh/Analysis/AnalyzerConfig.cs index 4d35b4f..390e587 100644 --- a/Strazh/Analysis/AnalyzerConfig.cs +++ b/Strazh/Analysis/AnalyzerConfig.cs @@ -1,4 +1,7 @@ -namespace Strazh.Analysis +using System; +using System.IO; + +namespace Strazh.Analysis { public class AnalyzerConfig { @@ -30,25 +33,47 @@ public enum Tiers : int Code = 2 } + public record Options( + string Credentials, + string Tier, + string Delete, + string Solution, + string[] Projects, + string? CacheDirectory = null, + bool NoCache = false, + string? BuildLogDirectory = null + ); + public CredentialsConfig Credentials { get; } public Tiers Tier { get; } public string Solution { get; } public string[] Projects { get; } public bool IsDelete { get; } + public string? CacheDirectory { get; } + public bool NoCache { get; } + public string? BuildLogDirectory { get; } public bool IsSolutionBased => !string.IsNullOrEmpty(Solution); public bool IsValid => (!string.IsNullOrEmpty(Solution) && Projects.Length == 0) || (string.IsNullOrEmpty(Solution) && Projects.Length > 0); - public AnalyzerConfig(string credentials, string tier, string delete, string solution, string[] projects) + public AnalyzerConfig(Options options) { - solution = solution == "none" ? "" : solution; - Credentials = new CredentialsConfig(credentials); - Tier = MapTier(tier); - IsDelete = delete != "false"; + var solution = options.Solution == "none" ? "" : options.Solution; + Credentials = new CredentialsConfig(options.Credentials); + Tier = MapTier(options.Tier); + IsDelete = options.Delete != "false"; Solution = solution; - Projects = projects ?? new string[] { }; + Projects = options.Projects ?? new string[] { }; + var strazhDataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "strazh"); + CacheDirectory = string.IsNullOrEmpty(options.CacheDirectory) + ? Path.Combine(strazhDataDir, "cache") + : options.CacheDirectory; + BuildLogDirectory = string.IsNullOrEmpty(options.BuildLogDirectory) + ? Path.Combine(strazhDataDir, "logs") + : options.BuildLogDirectory; + NoCache = options.NoCache; } private Tiers MapTier(string mode) diff --git a/Strazh/Analysis/CompositeAnalysisProgress.cs b/Strazh/Analysis/CompositeAnalysisProgress.cs new file mode 100644 index 0000000..bf53cea --- /dev/null +++ b/Strazh/Analysis/CompositeAnalysisProgress.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Strazh.Domain; + +namespace Strazh.Analysis +{ + /// + /// Fans out every notification to any number of + /// implementations. For , implementations are nested in the + /// order supplied so that the first implementation is the outermost wrapper — any UI + /// framework context it establishes (e.g. Spectre.Console's live display) is active + /// when subsequent implementations' WrapAsync calls run. + /// + public sealed class CompositeAnalysisProgress : IAnalysisProgress + { + private readonly IAnalysisProgress[] _implementations; + + public CompositeAnalysisProgress(params IAnalysisProgress[] implementations) + { + _implementations = implementations; + } + + public Task WrapAsync(int totalProjects, Func action) + { + // Build the nested chain from right to left so the first implementation + // is the outermost wrapper. + var current = action; + for (var i = _implementations.Length - 1; i >= 0; i--) + { + var impl = _implementations[i]; + var next = current; + current = () => impl.WrapAsync(totalProjects, next); + } + return current(); + } + + public void OnBuildStarted(string projectFilePath, string projectName, bool isCacheHit) + { + foreach (var impl in _implementations) + { + impl.OnBuildStarted(projectFilePath, projectName, isCacheHit); + } + } + + public void OnBuildCompleted(string projectFilePath) + { + foreach (var impl in _implementations) + { + impl.OnBuildCompleted(projectFilePath); + } + } + + public void OnStageChanged(string projectFilePath, string projectName, string stage) + { + foreach (var impl in _implementations) + { + impl.OnStageChanged(projectFilePath, projectName, stage); + } + } + + public void OnProjectCompleted(string projectFilePath, int tripleCount) + { + foreach (var impl in _implementations) + { + impl.OnProjectCompleted(projectFilePath, tripleCount); + } + } + + public void OnProjectSkipped(string projectFilePath, string filename, string reason) + { + foreach (var impl in _implementations) + { + impl.OnProjectSkipped(projectFilePath, filename, reason); + } + } + + public void OnGroupingError(string projectName, IReadOnlyList triples) + { + foreach (var impl in _implementations) + { + impl.OnGroupingError(projectName, triples); + } + } + } +} diff --git a/Strazh/Analysis/Extractor.cs b/Strazh/Analysis/Extractor.cs index daabc73..2360c69 100755 --- a/Strazh/Analysis/Extractor.cs +++ b/Strazh/Analysis/Extractor.cs @@ -157,7 +157,7 @@ public static void GetInherits(IList triples, TypeDeclarationSyntax decl foreach (var baseTypeSyntax in declaration.BaseList.Types) { var parentNode = sem.GetTypeInfo(baseTypeSyntax.Type).CreateTypeNode(); - if (node is ClassNode classNode) + if (node is ClassNode classNode && parentNode != null) { triples.Add(new TripleOfType(classNode, parentNode)); } diff --git a/Strazh/Analysis/FileAnalysisProgress.cs b/Strazh/Analysis/FileAnalysisProgress.cs new file mode 100644 index 0000000..8e88439 --- /dev/null +++ b/Strazh/Analysis/FileAnalysisProgress.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Strazh.Domain; + +namespace Strazh.Analysis +{ + /// + /// Implements by writing a structured, machine-readable + /// log file that captures the exact timing and outcome of every step in the analysis + /// pipeline. The file is useful for post-run diagnosis of failures that are not obvious + /// from the interactive console output (e.g. whether a project was a cache hit, whether + /// the binlog was read successfully, what order projects completed in). + /// + /// Log format — one entry per line: + /// + /// {ISO-8601-UTC} {EVENT} {key=value ...} + /// + /// String values are double-quoted; numbers and booleans are unquoted. All methods are + /// safe to call concurrently from multiple tasks. + /// + public sealed class FileAnalysisProgress : IAnalysisProgress, IDisposable + { + private readonly StreamWriter _writer; + private readonly object _lock = new(); + private readonly ConcurrentDictionary _startTimes = new(StringComparer.OrdinalIgnoreCase); + private readonly DateTime _runStart = DateTime.UtcNow; + private int _total; + + public FileAnalysisProgress(string logFilePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)!); + _writer = new StreamWriter(logFilePath, append: false, System.Text.Encoding.UTF8) + { + AutoFlush = true + }; + } + + public async Task WrapAsync(int totalProjects, Func action) + { + _total = totalProjects; + Write("RUN_STARTED", $"total={totalProjects}"); + try + { + await action(); + } + finally + { + var elapsed = DateTime.UtcNow - _runStart; + Write("RUN_COMPLETE", $"elapsed={FormatElapsed(elapsed)} total={_total}"); + } + } + + public void OnBuildStarted(string projectFilePath, string projectName, bool isCacheHit) + { + var now = DateTime.UtcNow; + _startTimes[projectFilePath] = now; + Write("BUILD_STARTED", $"cache={isCacheHit.ToString().ToLowerInvariant()} name={Q(projectName)} path={Q(projectFilePath)}"); + } + + public void OnBuildCompleted(string projectFilePath) + { + var elapsed = DateTime.UtcNow - GetStart(projectFilePath); + Write("BUILD_COMPLETE", $"elapsed={FormatElapsed(elapsed)} path={Q(projectFilePath)}"); + } + + public void OnStageChanged(string projectFilePath, string projectName, string stage) + { + Write("STAGE", $"stage={Q(stage)} name={Q(projectName)} path={Q(projectFilePath)}"); + } + + public void OnProjectCompleted(string projectFilePath, int tripleCount) + { + var elapsed = DateTime.UtcNow - GetStart(projectFilePath); + Write("PROJECT_COMPLETE", $"triples={tripleCount} elapsed={FormatElapsed(elapsed)} path={Q(projectFilePath)}"); + } + + public void OnProjectSkipped(string projectFilePath, string filename, string reason) + { + var elapsed = DateTime.UtcNow - GetStart(projectFilePath); + Write("PROJECT_SKIPPED", $"file={Q(filename)} reason={Q(reason)} elapsed={FormatElapsed(elapsed)} path={Q(projectFilePath)}"); + } + + public void OnGroupingError(string projectName, IReadOnlyList triples) + { + Write("GROUPING_ERROR", $"project={Q(projectName)} triples={triples.Count}"); + } + + public void Dispose() => _writer.Dispose(); + + // --- helpers --- + + private void Write(string eventName, string fields) + { + var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} {eventName} {fields}"; + lock (_lock) + { + _writer.WriteLine(line); + } + } + + private DateTime GetStart(string projectFilePath) + => _startTimes.TryGetValue(projectFilePath, out var t) ? t : _runStart; + + // Surrounds a string value with double quotes, escaping any embedded double quotes. + private static string Q(string value) => $"\"{value.Replace("\"", "\\\"")}\""; + + private static string FormatElapsed(TimeSpan elapsed) + => $"{elapsed.TotalSeconds:F3}s"; + } +} diff --git a/Strazh/Analysis/IAnalysisProgress.cs b/Strazh/Analysis/IAnalysisProgress.cs new file mode 100644 index 0000000..18b3bdd --- /dev/null +++ b/Strazh/Analysis/IAnalysisProgress.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Strazh.Domain; + +namespace Strazh.Analysis +{ + /// + /// Receives progress notifications from the analysis pipeline. + /// Implementations are free to present events however they choose — + /// Spectre.Console spinners, plain Console.WriteLine, or a no-op for tests. + /// All methods except may be called concurrently + /// from multiple tasks. + /// + public interface IAnalysisProgress + { + /// + /// Wraps the entire analysis pipeline. The implementation is responsible + /// for calling exactly once. + /// This is the integration point for UI frameworks that require an outer + /// context (e.g. AnsiConsole.Progress().StartAsync()) to be + /// established before any progress notifications are raised. + /// + /// Total number of projects in this run. + /// The pipeline body to execute. + Task WrapAsync(int totalProjects, Func action); + + /// + /// Raised immediately before the MSBuild design-time build (or binlog + /// cache replay) begins for the specified project. + /// + /// Absolute path to the .csproj file. + /// Display name (filename without extension). + /// + /// true when a cached binlog will be replayed; + /// false when a full MSBuild invocation will run. + /// + void OnBuildStarted(string projectFilePath, string projectName, bool isCacheHit); + + /// + /// Raised after the build or cache replay has finished and the result is + /// about to be loaded into the Roslyn workspace. + /// + /// Absolute path to the .csproj file. + void OnBuildCompleted(string projectFilePath); + + /// + /// Raised each time the active processing stage changes for a project. + /// Expected values for are "Analyzing", + /// "Grouping", and "Inserting", but implementations must + /// tolerate arbitrary strings. + /// + /// Absolute path to the .csproj file. + /// Display name (filename without extension). + /// Short human-readable label for the current stage. + void OnStageChanged(string projectFilePath, string projectName, string stage); + + /// + /// Raised after a project has been fully analyzed and all its triples + /// inserted into the database. + /// + /// Absolute path to the .csproj file. + /// Number of triples inserted for this project. + void OnProjectCompleted(string projectFilePath, int tripleCount); + + /// + /// Raised when a project cannot be loaded into the Roslyn workspace because + /// its type is not supported (e.g. F# or native projects). + /// + /// Absolute path to the project file. + /// Filename of the skipped project (e.g. MyLib.fsproj). + /// Human-readable explanation of why it was skipped. + void OnProjectSkipped(string projectFilePath, string filename, string reason); + + /// + /// Raised when triple deduplication (GroupBy) throws an exception. + /// The implementation should surface the triple list for diagnosis. + /// The pipeline will rethrow the exception after this method returns. + /// + /// Display name of the project whose grouping failed. + /// Raw (ungrouped) triples at the time of failure. + void OnGroupingError(string projectName, IReadOnlyList triples); + } +} diff --git a/Strazh/Analysis/SpectreConsoleProgress.cs b/Strazh/Analysis/SpectreConsoleProgress.cs new file mode 100644 index 0000000..adf1023 --- /dev/null +++ b/Strazh/Analysis/SpectreConsoleProgress.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Spectre.Console; +using Spectre.Console.Rendering; +using Strazh.Domain; + +namespace Strazh.Analysis +{ + /// + /// Implements using Spectre.Console's Live display. + /// Active tasks are sorted by most-recently-updated so that any task receiving a stage + /// change floats to the top of the visible area, even when more projects are in flight + /// than the terminal can display at once. + /// All methods except may be called concurrently from multiple tasks. + /// + public sealed class SpectreConsoleProgress : IAnalysisProgress + { + private record struct TaskEntry(string Name, string Stage, DateTime StartTime, DateTime LastChanged); + + private readonly ConcurrentDictionary _active = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _names = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _startTimes = + new(StringComparer.OrdinalIgnoreCase); + // Console writes (above the live area) must all come from the ticker thread to avoid + // interleaving with ctx.Refresh(), which causes Spectre.Console to corrupt its cursor + // position and crash the ticker. Concurrent callers enqueue an action; the ticker + // drains the queue before each Refresh(). + private readonly ConcurrentQueue _pendingWrites = new(); + private LiveDisplayContext? _ctx; + private int _tick; + private int _total; + private int _completed; + + // Dots spinner frames — same set used by Spectre.Console's built-in SpinnerColumn. + private static readonly string[] SpinnerFrames = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + public Task WrapAsync(int totalProjects, Func action) + { + _total = totalProjects; + return AnsiConsole.Live(new ActiveTasksRenderable(this)) + .AutoClear(true) + .Overflow(VerticalOverflow.Ellipsis) + .StartAsync(async ctx => + { + _ctx = ctx; + using var cts = new CancellationTokenSource(); + var ticker = Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + await Task.Delay(80).ConfigureAwait(false); + Interlocked.Increment(ref _tick); + if (!cts.IsCancellationRequested) + { + while (_pendingWrites.TryDequeue(out var write)) + { + write(); + } + ctx.Refresh(); + } + } + }); + try + { + await action(); + } + finally + { + await cts.CancelAsync(); + await ticker; + } + }); + } + + public void OnBuildStarted(string projectFilePath, string projectName, bool isCacheHit) + { + _names[projectFilePath] = projectName; + var now = DateTime.UtcNow; + _startTimes[projectFilePath] = now; + _active[projectFilePath] = new TaskEntry(projectName, isCacheHit ? "Cached" : "Building", now, now); + } + + public void OnBuildCompleted(string projectFilePath) + { + UpdateEntry(projectFilePath, "Loading"); + } + + public void OnStageChanged(string projectFilePath, string projectName, string stage) + { + UpdateEntry(projectFilePath, stage); + } + + public void OnProjectCompleted(string projectFilePath, int tripleCount) + { + Interlocked.Increment(ref _completed); + _active.TryRemove(projectFilePath, out _); + var name = _names.TryGetValue(projectFilePath, out var n) ? n : projectFilePath; + var elapsed = DateTime.UtcNow - (_startTimes.TryGetValue(projectFilePath, out var st) ? st : DateTime.UtcNow); + var markup = + $"[green]✓[/] [bold]{Markup.Escape(name)}[/] " + + $"[dim]{FormatElapsed(elapsed)}[/] {tripleCount} triples"; + _pendingWrites.Enqueue(() => AnsiConsole.MarkupLine(markup)); + } + + public void OnProjectSkipped(string projectFilePath, string filename, string reason) + { + Interlocked.Increment(ref _completed); + _active.TryRemove(projectFilePath, out _); + string label; + if (reason.Contains("timed out")) + { + label = "[yellow]Timeout[/]"; + } + else if (reason.Contains("could not be read")) + { + label = "[yellow]Unreadable[/]"; + } + else if (reason.Contains("failed")) + { + label = "[red]Failed[/]"; + } + else + { + label = "[yellow]Skipped[/]"; + } + var markup = $"{label} {Markup.Escape(filename)} ({Markup.Escape(reason)})"; + _pendingWrites.Enqueue(() => AnsiConsole.MarkupLine(markup)); + } + + public void OnGroupingError(string projectName, IReadOnlyList triples) + { + var capturedTriples = triples.ToList(); + _pendingWrites.Enqueue(() => + { + AnsiConsole.MarkupLine($"[red]Error[/] grouping triples for {Markup.Escape(projectName)}. Dumping detail:"); + AnsiConsole.WriteLine("["); + var first = true; + foreach (var triple in capturedTriples) + { + if (!first) + { + AnsiConsole.WriteLine(","); + } + AnsiConsole.Write(new Text($$"""{ "triple": {{ triple.ToInspection()}} }""")); + first = false; + } + if (capturedTriples.Count > 0) + { + AnsiConsole.WriteLine(""); + } + AnsiConsole.WriteLine("]"); + }); + } + + private void UpdateEntry(string projectFilePath, string stage) + { + if (_active.TryGetValue(projectFilePath, out var entry)) + { + _active[projectFilePath] = entry with { Stage = stage, LastChanged = DateTime.UtcNow }; + } + } + + private string GetSpinnerFrame() + => SpinnerFrames[Math.Abs(_tick) % SpinnerFrames.Length]; + + private (int completed, int remaining) GetCounts() + { + var completed = Volatile.Read(ref _completed); + return (completed, Math.Max(0, _total - completed)); + } + + private (List entries, int paddingRows) GetSortedEntries() + { + // Reserve 1 row for the summary line and 1 for breathing room. + var terminalHeight = AnsiConsole.Console.Profile.Height; + var maxContentRows = Math.Max(1, terminalHeight - 2); + var entries = _active.Values.OrderByDescending(e => e.LastChanged).Take(maxContentRows).ToList(); + // Pad above the active entries so the summary line sits at the bottom of the terminal. + var paddingRows = Math.Max(0, maxContentRows - entries.Count); + return (entries, paddingRows); + } + + private static string FormatElapsed(TimeSpan elapsed) + => elapsed.TotalSeconds < 60 + ? $"{elapsed.TotalSeconds:F1}s" + : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds:D2}s"; + + /// + /// Live-renderable that rebuilds on every call, + /// always placing the most recently updated task at the top. + /// + private sealed class ActiveTasksRenderable : IRenderable + { + private readonly SpectreConsoleProgress _owner; + + public ActiveTasksRenderable(SpectreConsoleProgress owner) => _owner = owner; + + public Measurement Measure(RenderOptions options, int maxWidth) => new(0, maxWidth); + + public IEnumerable Render(RenderOptions options, int maxWidth) + { + var frame = _owner.GetSpinnerFrame(); + var (entries, paddingRows) = _owner.GetSortedEntries(); + var (completed, remaining) = _owner.GetCounts(); + + // Blank lines above the active-task block push it toward the bottom of the terminal. + for (var i = 0; i < paddingRows; i++) + { + yield return Segment.LineBreak; + } + + foreach (var entry in entries) + { + var elapsed = DateTime.UtcNow - entry.StartTime; + var line = new Markup( + $"[green]{frame}[/] {entry.Stage,-9} {Markup.Escape(entry.Name)} [dim]{FormatElapsed(elapsed)}[/]"); + foreach (var seg in ((IRenderable)line).Render(options, maxWidth)) + { + yield return seg; + } + yield return Segment.LineBreak; + } + + var summary = new Markup($"[dim]{completed} done · {remaining} remaining[/]"); + foreach (var seg in ((IRenderable)summary).Render(options, maxWidth)) + { + yield return seg; + } + yield return Segment.LineBreak; + } + } + } +} diff --git a/Strazh/Database/DbManager.cs b/Strazh/Database/DbManager.cs index 04df7af..7042728 100644 --- a/Strazh/Database/DbManager.cs +++ b/Strazh/Database/DbManager.cs @@ -11,39 +11,61 @@ public static class DbManager { private const string CONNECTION = "neo4j://localhost:7687"; - public static async Task InsertData(IList triples, CredentialsConfig credentials, bool isDelete) + // All node labels that carry a pk property used as the MERGE key. + // A uniqueness constraint implicitly creates a B-tree index, turning each + // MERGE (n:Label { pk: "..." }) from a full label scan into an index lookup. + private static readonly string[] NodeLabels = + ["Class", "Interface", "Method", "File", "Folder", "Solution", "Project", "Package"]; + + public static async Task EnsureIndexes(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)); + Console.WriteLine("Ensuring Neo4j uniqueness constraints and indexes..."); + await using var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password)); + await using var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database)); + foreach (var label in NodeLabels) + { + await session.RunAsync( + $"CREATE CONSTRAINT {label.ToLowerInvariant()}_pk IF NOT EXISTS FOR (n:{label}) REQUIRE n.pk IS UNIQUE"); + } + Console.WriteLine("Neo4j uniqueness constraints and indexes ready."); + } + + 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."); + } + 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) { await session.RunAsync(triple.ToString()); } - Console.WriteLine($"Processing {triples.Count} triples complete."); } catch (Exception ex) { 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..21445e1 100644 --- a/Strazh/Program.cs +++ b/Strazh/Program.cs @@ -1,86 +1,116 @@ -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.IO; +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); + + var optionCache = new Option("--cache") + { + Description = "optional path to a directory where MSBuild binary logs are cached; defaults to the platform application data folder (e.g. ~/Library/Application Support/strazh/cache on macOS)" + }; + rootCommand.Options.Add(optionCache); + + var optionNoCache = new Option("--no-cache") + { + Description = "when set, rebuilds all projects and writes fresh cache entries without reading any existing cached binlogs" + }; + rootCommand.Options.Add(optionNoCache); + + var optionBuildLogDir = new Option("--build-log-dir") + { + Description = "optional path to a directory where per-project MSBuild build logs are written; defaults to the platform application data folder (e.g. ~/Library/Application Support/strazh/logs on macOS)" + }; + rootCommand.Options.Add(optionBuildLogDir); + + rootCommand.SetAction(async (ParseResult parseResult, CancellationToken token) => + { + await BuildKnowledgeGraph(new AnalyzerConfig.Options( + Credentials: parseResult.GetValue(optionCredentials), + Tier: parseResult.GetValue(optionMode), + Delete: parseResult.GetValue(optionDelete), + Solution: parseResult.GetValue(optionSolution), + Projects: parseResult.GetValue(optionProjects), + CacheDirectory: parseResult.GetValue(optionCache), + NoCache: parseResult.GetValue(optionNoCache), + BuildLogDirectory: parseResult.GetValue(optionBuildLogDir) + )); + }); + + return await rootCommand.Parse(args).InvokeAsync(); + } + + private static async Task BuildKnowledgeGraph(AnalyzerConfig.Options options) + { + try + { + var config = new AnalyzerConfig(options); + 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}\"."); + var runLogPath = Path.Combine( + config.BuildLogDirectory!, + $"strazh-run-{DateTime.UtcNow:yyyy-MM-ddTHHmmssZ}.log"); + using var fileProgress = new FileAnalysisProgress(runLogPath); + var progress = new CompositeAnalysisProgress(new SpectreConsoleProgress(), fileProgress); + await Analyzer.Analyze(config, progress); + Console.WriteLine("Code Knowledge Graph created."); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + } + } +} diff --git a/Strazh/Strazh.csproj b/Strazh/Strazh.csproj index 5ebf613..69f43d2 100644 --- a/Strazh/Strazh.csproj +++ b/Strazh/Strazh.csproj @@ -2,19 +2,19 @@ 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