Directory.Packages.props
+2
-1
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c152db4..8c4b470 100644
@@ -10,6 +10,7 @@
</GlobalPackageReference>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Glob" Version="1.1.9" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.9.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.14" />
@@ -18,4 +19,4 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
</Project>
</Project>
\ No newline at end of file
cli/src/ScanCommand.cs
+38
-1
diff --git a/cli/src/ScanCommand.cs b/cli/src/ScanCommand.cs
index ffc395e..4ec7e65 100644
@@ -2,10 +2,12 @@ using System;
using System.Collections.Immutable;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GlobExpressions;
using Microsoft.CodeAnalysis;
namespace Cfa.Cli;
@@ -99,12 +101,42 @@ internal sealed class ScanCommand : Command
},
};
private static Option<IImmutableList<Glob>> IncludeOption { get; } =
new("--include", "-i")
{
Description =
"Glob patterns matched against project filenames (e.g. 'MyApp*.csproj'). Include projects matching any pattern.",
Arity = ArgumentArity.OneOrMore,
CustomParser = PatternParser,
};
private static Option<IImmutableList<Glob>> ExcludeOption { get; } =
new("--exclude", "-e")
{
Description =
"Glob patterns matched against project filenames (e.g. '*Tests*'). Exclude projects matching any pattern.",
Arity = ArgumentArity.OneOrMore,
CustomParser = PatternParser,
};
private static IImmutableList<Glob>? PatternParser(ArgumentResult result)
{
if (result.Tokens is [])
{
result.AddError("No pattern specified.");
return default;
}
return [.. result.Tokens.Select(token => new Glob(token.Value))];
}
public ScanCommand()
: base("scan", "Scan the code for analysis")
{
Action = new ScanAction();
Arguments.Add(PathArgument);
Options.Add(StartFileOption);
Options.Add(IncludeOption);
Options.Add(ExcludeOption);
}
internal sealed class ScanAction : AsynchronousCommandLineAction
@@ -112,7 +144,12 @@ internal sealed class ScanCommand : Command
public override async Task<int> InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var solutionFile = parseResult.GetRequiredValue(PathArgument);
using var scanner = await Scanner.Create(solutionFile, cancellationToken);
var options = new ScannerOptions()
{
IncludePatterns = parseResult.GetValue(IncludeOption) ?? [],
ExcludePatterns = parseResult.GetValue(ExcludeOption) ?? [],
};
using var scanner = await Scanner.Create(solutionFile, options, cancellationToken);
var (startFile, startLine) = parseResult.GetRequiredValue(StartFileOption);
var callGraph = await scanner.BuildCallGraph(startFile, startLine, cancellationToken);
cli/src/packages.lock.json
+7
-0
diff --git a/cli/src/packages.lock.json b/cli/src/packages.lock.json
index 190150c..28003a0 100644
@@ -132,10 +132,17 @@
"cfa": {
"type": "Project",
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )"
}
},
"Glob": {
"type": "CentralTransitive",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",
core/src/CallGraphBuilder.cs
+28
-19
diff --git a/core/src/CallGraphBuilder.cs b/core/src/CallGraphBuilder.cs
index 8d0286f..ebf978f 100644
@@ -12,23 +12,14 @@ namespace Cfa;
internal sealed class CallGraphBuilder
{
public static async Task<CallGraph> BuildCallGraph(
ScannerOptions options,
Solution solution,
IReadOnlyCollection<Compilation> compilations,
ISymbol origin,
CancellationToken cancellationToken
)
{
var builder = new CallGraphBuilder(
solution,
compilations,
[
.. compilations
.SelectMany(compilation =>
compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type, cancellationToken)
)
.OfType<INamedTypeSymbol>(),
]
);
var builder = new CallGraphBuilder(options, solution, compilations);
builder.FindCallees(origin, cancellationToken);
builder.InvertCallees(origin);
@@ -43,6 +34,8 @@ internal sealed class CallGraphBuilder
private readonly Solution solution;
private readonly IReadOnlyCollection<Compilation> compilations;
private readonly HashSet<ProjectId> includedProjects;
private readonly HashSet<SyntaxTree> includedTrees;
private readonly IReadOnlyCollection<INamedTypeSymbol> types;
private readonly HashSet<ISymbol> seenFrom = [with(SymbolEqualityComparer.Default)];
internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachableFromSymbols { get; } =
@@ -51,15 +44,30 @@ internal sealed class CallGraphBuilder
internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachesToSymbols { get; } =
ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
internal CallGraphBuilder(
Solution solution,
IReadOnlyCollection<Compilation> compilations,
IReadOnlyCollection<INamedTypeSymbol> types
)
internal CallGraphBuilder(ScannerOptions options, Solution solution, IReadOnlyCollection<Compilation> compilations)
{
this.solution = solution;
this.compilations = compilations;
this.types = types;
includedProjects =
[
.. solution
.Projects.Where(project => options.IncludesProject(project.FilePath ?? project.Name))
.Select(project => project.Id),
];
includedTrees =
[
.. compilations
.SelectMany(compilation => compilation.SyntaxTrees)
.Where(tree =>
solution.GetDocument(tree) is { Project.Id: var projectId } && includedProjects.Contains(projectId)
),
];
types =
[
.. compilations
.SelectMany(compilation => compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type))
.OfType<INamedTypeSymbol>(),
];
}
internal void FindCallees(ISymbol symbol, CancellationToken cancellationToken)
@@ -238,8 +246,9 @@ internal sealed class CallGraphBuilder
_ => null,
};
private static bool IsDeclaredInSource(ISymbol symbol) =>
symbol.DeclaringSyntaxReferences.Length > 0 && !symbol.IsImplicitlyDeclared;
private bool IsDeclaredInSource(ISymbol symbol) =>
!symbol.IsImplicitlyDeclared
&& symbol.DeclaringSyntaxReferences.Any(reference => includedTrees.Contains(reference.SyntaxTree));
private static bool HasBody(IPropertySymbol property, CancellationToken cancellationToken) =>
property.DeclaringSyntaxReferences.Any(reference =>
core/src/Cfa.csproj
+1
-0
diff --git a/core/src/Cfa.csproj b/core/src/Cfa.csproj
index 2b8091b..39ecf65 100644
@@ -7,6 +7,7 @@
<InternalsVisibleTo Include="Cfa.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Glob" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
</ItemGroup>
core/src/Scanner.cs
+16
-4
diff --git a/core/src/Scanner.cs b/core/src/Scanner.cs
index 4ddd1d7..e546f4d 100644
@@ -13,24 +13,36 @@ namespace Cfa;
public sealed class Scanner : IDisposable
{
public static async Task<Scanner> Create(FileInfo solutionFile, CancellationToken cancellationToken = default)
public static async Task<Scanner> Create(
FileInfo solutionFile,
ScannerOptions options,
CancellationToken cancellationToken = default
)
{
var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(solutionFile.FullName, cancellationToken: cancellationToken);
var compilations = await solution
.Projects.ToAsyncEnumerable()
.Where(p => options.IncludesProject(p.FilePath ?? p.Name))
.Select(async (p, ct) => await p.GetCompilationAsync(ct))
.OfType<Compilation>()
.ToListAsync(cancellationToken);
return new Scanner(workspace, solution, compilations);
return new Scanner(options, workspace, solution, compilations);
}
private readonly ScannerOptions options;
private readonly Workspace workspace;
private readonly Solution solution;
private readonly IReadOnlyCollection<Compilation> compilations;
internal Scanner(Workspace workspace, Solution solution, IReadOnlyCollection<Compilation> compilations)
internal Scanner(
ScannerOptions options,
Workspace workspace,
Solution solution,
IReadOnlyCollection<Compilation> compilations
)
{
this.options = options;
this.workspace = workspace;
this.solution = solution;
this.compilations = compilations;
@@ -48,7 +60,7 @@ public sealed class Scanner : IDisposable
semanticModel.GetDeclaredSymbol(startMethod, cancellationToken)
?? throw new InvalidOperationException($"Unable to get declared symbol for method: {startMethod}");
return await CallGraphBuilder.BuildCallGraph(solution, compilations, symbol, cancellationToken);
return await CallGraphBuilder.BuildCallGraph(options, solution, compilations, symbol, cancellationToken);
}
private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
core/src/ScannerOptions.cs
+15
-0
diff --git a/core/src/ScannerOptions.cs b/core/src/ScannerOptions.cs
new file mode 100644
index 0000000..609dfff
@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using System.Linq;
using GlobExpressions;
namespace Cfa;
public sealed record ScannerOptions
{
public IImmutableList<Glob> IncludePatterns { get; init; } = [];
public IImmutableList<Glob> ExcludePatterns { get; init; } = [];
internal bool IncludesProject(string projectPath) =>
(IncludePatterns.Count == 0 || IncludePatterns.Any(pattern => pattern.IsMatch(projectPath)))
&& !ExcludePatterns.Any(pattern => pattern.IsMatch(projectPath));
}
core/src/packages.lock.json
+6
-0
diff --git a/core/src/packages.lock.json b/core/src/packages.lock.json
index 68fbb9d..7ffe8ea 100644
@@ -8,6 +8,12 @@
"resolved": "1.3.0",
"contentHash": "jzzLpmz7F6LyBh5FTZy8/4dEYVcAG9VjBPKryZ82LIoUuupcBkCSXzaeS/4oYcp/rBC8yVLcDstJ8CIv9n2IFw=="
},
"Glob": {
"type": "Direct",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "Direct",
"requested": "[5.9.0, )",
core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs
+92
-0
diff --git a/core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs b/core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs
new file mode 100644
index 0000000..d4d58dc
@@ -0,0 +1,92 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GlobExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Shouldly;
using Xunit;
namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class BuildCallGraphShould
{
[Fact]
public async Task ExcludeTestProjectMembersButNotProductionFilesNamedTests()
{
// Arrange
using var workspace = new AdhocWorkspace();
var app = workspace.AddProject(
ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
"App",
"App",
LanguageNames.CSharp,
filePath: "/repo/App.csproj",
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
metadataReferences: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]
)
);
var tests = workspace.AddProject(
ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
"App.Tests",
"App.Tests",
LanguageNames.CSharp,
filePath: "/repo/App.Tests.csproj",
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
metadataReferences: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
projectReferences: [new ProjectReference(app.Id)]
)
);
workspace.AddDocument(
app.Id,
"Tests.cs",
SourceText.From(
"""
public class App
{
public static void Target() { }
public static void Caller() { Target(); }
}
"""
)
);
workspace.AddDocument(
tests.Id,
"Production.cs",
SourceText.From(
"""
class Tests
{
void Start() { Helper(); App.Target(); }
void Helper() { }
}
"""
)
);
var solution = workspace.CurrentSolution;
var appCompilation = (await solution.GetProject(app.Id)!.GetCompilationAsync()).ShouldNotBeNull();
var testCompilation = (await solution.GetProject(tests.Id)!.GetCompilationAsync()).ShouldNotBeNull();
appCompilation.GetDiagnostics().ShouldBeEmpty();
testCompilation.GetDiagnostics().ShouldBeEmpty();
var start = testCompilation.GetTypeByMetadataName("Tests")!.GetMembers("Start").Single();
var target = appCompilation.GetTypeByMetadataName("App")!.GetMembers("Target").Single();
var builder = new CallGraphBuilder(
new ScannerOptions { ExcludePatterns = [new("*Tests*")] },
solution,
[appCompilation, testCompilation]
);
// Act
builder.FindCallees(start, CancellationToken.None);
await builder.FindCallers(target, CancellationToken.None);
// Assert
builder.ReachableFromSymbols[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Target");
builder.ReachesToSymbols[target].ShouldHaveSingleItem().Target.Name.ShouldBe("Caller");
}
}
core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
+1
-5
diff --git a/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs b/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
index 1818f6c..20fc3f0 100644
@@ -57,11 +57,7 @@ internal static class SourceToCallGraphHelper
.ShouldNotBeNull()
.GetMembers("Start")
.ShouldHaveSingleItem();
var callGraphBuilder = new CallGraphBuilder(
workspace.CurrentSolution,
[compilation],
[.. compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type).OfType<INamedTypeSymbol>()]
);
var callGraphBuilder = new CallGraphBuilder(options: new(), workspace.CurrentSolution, [compilation]);
return (start, callGraphBuilder);
}
}
core/tests/ScannerOptionsTests/IncludesProjectShould.cs
+60
-0
diff --git a/core/tests/ScannerOptionsTests/IncludesProjectShould.cs b/core/tests/ScannerOptionsTests/IncludesProjectShould.cs
new file mode 100644
index 0000000..728a422
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
namespace Cfa.Tests.ScannerOptionsTests;
public sealed class IncludesProjectShould
{
[Theory]
[InlineData("/repo/src/MyApp.csproj")]
[InlineData("/repo/tests/MyApp.Tests.csproj")]
public void IncludeEverythingWhenNoPatternsAreSpecified(string path)
{
// Arrange
var options = new ScannerOptions();
// Act
var result = options.IncludesProject(path);
// Assert
result.ShouldBeTrue();
}
[Theory]
[InlineData("/repo/tests/MyApp.Tests.csproj", false)]
[InlineData(@"C:\repo\tests\MyApp.Tests.csproj", false)]
[InlineData("/repo/Tests/MyApp.csproj", true)]
public void MatchExclusionsAgainstTheProjectFilenameOnly(string path, bool expected)
{
// Arrange
var options = new ScannerOptions { ExcludePatterns = [new("*Tests*")] };
// Act
var result = options.IncludesProject(path);
// Assert
result.ShouldBe(expected);
}
[Theory]
[InlineData("/repo/MyApp.csproj", true)]
[InlineData("/repo/Other.csproj", true)]
[InlineData("/repo/Unrelated.csproj", false)]
[InlineData("/repo/MyApp.Tests.csproj", false)]
[InlineData("/repo/MyApp.Specs.csproj", false)]
public void AcceptAnyIncludeButLetExclusionsWin(string path, bool expected)
{
// Arrange
var options = new ScannerOptions
{
IncludePatterns = [new("MyApp*"), new("Other*")],
ExcludePatterns = [new("*Tests*"), new("*Specs*")],
};
// Act
var result = options.IncludesProject(path);
// Assert
result.ShouldBe(expected);
}
}
core/tests/packages.lock.json
+7
-0
diff --git a/core/tests/packages.lock.json b/core/tests/packages.lock.json
index 3640dce..7f8f465 100644
@@ -248,10 +248,17 @@
"cfa": {
"type": "Project",
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )"
}
},
"Glob": {
"type": "CentralTransitive",
"requested": "[1.1.9, )",
"resolved": "1.1.9",
"contentHash": "AfK5+ECWYTP7G3AAdnU8IfVj+QpGjrh9GC2mpdcJzCvtQ4pnerAGwHsxJ9D4/RnhDUz2DSzd951O/lQjQby2Sw=="
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "CentralTransitive",
"requested": "[5.9.0, )",