Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.vs
.vscode
.idea
bin
obj
.DS_Store
app
*.DotSettings.user
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 44 additions & 0 deletions Strazh.Tests/AnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Buildalyzer;
using Strazh.Analysis;
using Xunit;

namespace Strazh.Tests;

public class AnalyzerTests
{
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task GetAnalysisContext_IncludesProjectsAddedAsTransitiveReferences()
{
var solutionPath = Path.Combine(GetRepoRoot(), "SystemUnderTest", "SystemUnderTest.sln");
var manager = new AnalyzerManager(solutionPath);

var context = await Analyzer.GetAnalysisContext(manager);

var projectFileNames = context.Projects
.Select(p => Path.GetFileName(p.Item2.ProjectFilePath))
.ToList();

Assert.Contains("Strazh.Tests.ProjectA.csproj", projectFileNames);
Assert.Contains("Strazh.Tests.ProjectB.csproj", projectFileNames);
}

// [CallerFilePath] gives the compile-time absolute path of this source file.
// From Strazh.Tests/AnalyzerTests.cs, two levels up reaches the repo root.
private static string GetRepoRoot([CallerFilePath] string callerFile = "") =>
Path.GetFullPath(Path.Combine(callerFile, "../.."));
}
22 changes: 22 additions & 0 deletions Strazh.Tests/Strazh.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Strazh\Strazh.csproj" />
</ItemGroup>

</Project>
244 changes: 157 additions & 87 deletions Strazh/Analysis/Analyzer.cs

Large diffs are not rendered by default.

30 changes: 16 additions & 14 deletions Strazh/Database/DbManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,30 @@ public static class DbManager
{
private const string CONNECTION = "neo4j://localhost:7687";

public static async Task InsertData(IList<Triple> triples, CredentialsConfig credentials, bool isDelete)
public static async Task DeleteData(CredentialsConfig credentials)
{
if (credentials == null)
{
throw new ArgumentException($"Please, provide credentials.");
}
Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database...");
await using var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password));
await using var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database));
await session.RunAsync("MATCH (n) DETACH DELETE n;");
Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database complete.");
}

public static async Task InsertData(IList<Triple> triples, CredentialsConfig credentials)
{
if (credentials == null)
{
throw new ArgumentException($"Please, provide credentials.");
}
Console.WriteLine($"Code Knowledge Graph use \"{credentials.Database}\" Neo4j database.");
var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password));
var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database));
await using var driver = GraphDatabase.Driver(CONNECTION, AuthTokens.Basic(credentials.User, credentials.Password));
await using var session = driver.AsyncSession(o => o.WithDatabase(credentials.Database));
try
{
if (isDelete)
{
Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database...");
await session.RunAsync("MATCH (n) DETACH DELETE n;");
Console.WriteLine($"Deleting graph data of \"{credentials.Database}\" database complete.");
}
Console.WriteLine($"Processing {triples.Count} triples...");
foreach (var triple in triples)
{
Expand All @@ -39,11 +46,6 @@ public static async Task InsertData(IList<Triple> triples, CredentialsConfig cre
{
Console.WriteLine(ex.Message);
}
finally
{
await session.CloseAsync();
await driver.CloseAsync();
}
}
}
}
70 changes: 27 additions & 43 deletions Strazh/Domain/Nodes.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace Strazh.Domain
{
Expand All @@ -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) =>
Expand All @@ -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";
}

Expand All @@ -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";
}

Expand All @@ -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";
}

Expand All @@ -156,7 +140,7 @@ public override string Set(string node)

protected override void SetPrimaryKey()
{
Pk = $"{FullName}{Version}".GetHashCode().ToString();
Pk = DeterministicHash($"{FullName}{Version}");
}
}
}
93 changes: 25 additions & 68 deletions Strazh/Domain/Triples.cs
Original file line number Diff line number Diff line change
@@ -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);";
Expand All @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down
Loading