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