This guide helps contributors get started with StructuraLens development, from initial setup through testing, debugging, and submitting changes.
- Getting Started
- Project Structure
- Development Workflow
- Building the Project
- Running Tests
- Running Locally
- Debugging
- Code Style and Conventions
- Testing Guidelines
- Performance Considerations
- Contributing
- .NET 10 SDK - Download
- Git - Download
- IDE (recommended):
- Visual Studio 2024 or later
- JetBrains Rider 2024 or later
- VS Code with C# Dev Kit extension
-
Clone the repository:
git clone https://github.com/your-org/structuralens.git cd structuralens -
Restore dependencies:
dotnet restore
-
Build the project:
dotnet build
-
Run tests to verify setup:
dotnet test
If all tests pass, you're ready to start developing!
StructuraLens/
├── src/
│ ├── StructuraLens.Core/ # Core analysis library
│ │ ├── Abstractions/ # Service interfaces for DI
│ │ ├── Analysis/ # Analyzers, calculators, collectors
│ │ ├── Export/ # Report exporters and generators
│ │ ├── Infrastructure/ # MSBuild, NuGet, file system
│ │ └── Models/ # Domain models and DTOs
│ └── StructuraLens.Cli/ # CLI application
│ ├── Program.cs # Entry point and DI setup
│ └── Logging/ # Source-generated logging
├── tests/
│ └── StructuraLens.Tests/ # TUnit test project
│ ├── Analysis/ # Analysis component tests
│ ├── Export/ # Export tests
│ ├── Infrastructure/ # Infrastructure tests
│ └── Models/ # Model tests
├── docs/ # Documentation
│ ├── usage.md # User-facing usage guide
│ ├── design.md # Architecture and design
│ ├── architecture.md # Internal architecture details
│ ├── development.md # This file
│ └── compact-format.md # Compact format specification
├── .github/
│ ├── workflows/ # GitHub Actions CI/CD
│ └── copilot-instructions.md # Contribution guidelines
├── StructuraLens.slnx # Solution file (.NET XML format)
└── README.md # Project overview
- src/StructuraLens.Core - All core logic, zero dependency on CLI
- src/StructuraLens.Cli - Thin CLI wrapper, argument parsing, DI setup
- tests/StructuraLens.Tests - Comprehensive test coverage using TUnit
-
Create feature branch:
git checkout -b feature/short-description # or for bug fixes: git checkout -b fix/short-description -
Make changes and commit frequently:
git add . git commit -m "feat: add new metric calculator"
-
Push to remote:
git push origin feature/short-description
-
Open pull request against
mainbranch
StructuraLens is licensed under the MIT License (MIT).
Repository policy relies on root-level licensing (LICENSE.md) and distribution metadata rather than mandatory per-file source headers.
Use Conventional Commits for all commit messages:
<type>: <description>
[optional body]
[optional footer]
Types:
feat:- New featurefix:- Bug fixchore:- Maintenance (dependencies, build, etc.)docs:- Documentation changestest:- Test additions or changesrefactor:- Code refactoring without behavior changeperf:- Performance improvements
Examples:
git commit -m "feat: add depth of inheritance calculator"
git commit -m "fix: handle null reference in coupling analyzer"
git commit -m "docs: update architecture guide with DI patterns"
git commit -m "test: add thread-safety tests for SQLite collector"
git commit -m "chore: update Microsoft.CodeAnalysis to 10.0.0"Why Conventional Commits?
- Enables automated semantic versioning via semantic-release
- Clear changelog generation
- PR titles must follow this format (PRs are squashed on merge)
# Debug build (default)
dotnet build
# Release build
dotnet build -c Release# Build only Core library
dotnet build src/StructuraLens.Core
# Build only CLI
dotnet build src/StructuraLens.Clidotnet clean
dotnet build# Windows
dotnet publish src/StructuraLens.Cli -c Release -r win-x64 --self-contained
# Linux
dotnet publish src/StructuraLens.Cli -c Release -r linux-x64 --self-contained
# macOS
dotnet publish src/StructuraLens.Cli -c Release -r osx-x64 --self-containedOutput will be in src/StructuraLens.Cli/bin/Release/net10.0/{runtime}/publish/
dotnet testdotnet test --verbosity normaldotnet test --filter "FullyQualifiedName~CyclomaticComplexityCalculatorTests"dotnet test --filter "FullyQualifiedName~Calculate_SimpleIfStatement_ReturnsTwo"# Integration tests only
dotnet test --filter "FullyQualifiedName~IntegrationTests"
# Unit tests (exclude integration)
dotnet test --filter "FullyQualifiedName!~IntegrationTests"# TRX format (compatible with Azure DevOps, GitHub Actions)
dotnet test --logger:"trx;LogFileName=test-results.trx"dotnet watch test# Analyze the StructuraLens solution itself (self-analysis)
dotnet run --project src/StructuraLens.Cli -- analyze StructuraLens.slnx --format summary
# Analyze another solution
dotnet run --project src/StructuraLens.Cli -- analyze /path/to/MySolution.sln --format html --out report.html
# Enable verbose logging
dotnet run --project src/StructuraLens.Cli -- analyze MySolution.sln --verbose --format summary# After building
./src/StructuraLens.Cli/bin/Debug/net10.0/StructuraLens.Cli analyze MySolution.sln
# Or publish and run
dotnet publish src/StructuraLens.Cli -c Release
./src/StructuraLens.Cli/bin/Release/net10.0/publish/StructuraLens.Cli analyze MySolution.sln# Quick self-analysis with summary
dotnet run --project src/StructuraLens.Cli -- analyze StructuraLens.slnx --format summary
# Generate HTML report for visual inspection
dotnet run --project src/StructuraLens.Cli -- analyze StructuraLens.slnx --format html --out test-report.html
# Test memory management with SQLite
dotnet run --project src/StructuraLens.Cli -- analyze StructuraLens.slnx --aggregation-strategy SQLite --verbose- Set
StructuraLens.Clias startup project - Right-click project → Properties → Debug
- Set Command line arguments:
analyze StructuraLens.slnx --format summary - Press F5 to start debugging
Create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug StructuraLens",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/StructuraLens.Cli/bin/Debug/net10.0/StructuraLens.Cli.dll",
"args": ["analyze", "StructuraLens.slnx", "--format", "summary", "--verbose"],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}Press F5 to start debugging.
- Open Run/Debug Configurations
- Add new .NET Project configuration
- Set Project: StructuraLens.Cli
- Set Program arguments:
analyze StructuraLens.slnx --format summary - Click Debug button
Set breakpoints in key locations:
SolutionAnalyzer.AnalyzeSolutionAsync()- Main orchestrationCouplingAnalyzer.AnalyzeCoupling()- Dependency extractionMetricsCalculator.CalculateMethodMetrics()- Metric computation- Collector
AddDependency()methods - Aggregation logic
Use conditional breakpoints:
// Only break when CC > 10
if (cyclomaticComplexity > 10)
{
// Set breakpoint here
}Watch expressions:
_dependencies.Count(in collectors)GC.GetTotalMemory(false)(memory usage)compilation.GetDiagnostics()(Roslyn diagnostics)
- Target: C# 13 / .NET 10
- Nullable reference types: Enabled (all projects)
- Implicit usings: Enabled
- File-scoped namespaces: Preferred
Interfaces:
public interface ISolutionAnalyzer { }
public interface IMetricsCalculator { }Classes:
public sealed class SolutionAnalyzer : ISolutionAnalyzer { }
public class MetricsCalculator : IMetricsCalculator { }Private fields:
private readonly ILogger<SolutionAnalyzer> _logger;
private readonly AnalysisOptions _options;Methods:
public async Task<AnalysisReport> AnalyzeSolutionAsync(string solutionPath);
private IDependencyCollector CreateDependencyCollector();Usings order:
- System namespaces
- Microsoft namespaces
- Third-party namespaces
- Project namespaces
Class member order:
- Private fields
- Constructors
- Public methods
- Internal methods
- Private methods
Use source-generated logging for performance:
// Define log methods in partial class
public sealed partial class SolutionAnalyzer
{
[LoggerMessage(
EventId = 4001,
Level = LogLevel.Information,
Message = "Analyzing solution: {SolutionPath}")]
private partial void LogAnalyzingSolution(string solutionPath);
}
// Use in code
LogAnalyzingSolution(solutionPath);Event ID ranges:
- 4000-4099: CLI operations
- 4100-4199: Warnings
- 4200-4299: Errors
Constructor injection only:
public SolutionAnalyzer(
ILogger<SolutionAnalyzer> logger,
INuGetRestorer nugetRestorer,
// ... other dependencies
AnalysisOptions? options = null)
{
_logger = logger;
_nugetRestorer = nugetRestorer;
_options = options ?? new AnalysisOptions();
}Service interfaces in Abstractions/:
- One interface per file
- Interface name matches implementation (with
Iprefix) - Document public methods with XML comments
Fail fast for programmer errors:
ArgumentNullException.ThrowIfNull(solutionPath);Log and handle expected errors:
try
{
await _nugetRestorer.RestoreAsync(solutionPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NuGet restore failed, continuing with best-effort analysis");
}AAA Pattern (Arrange, Act, Assert):
[Test]
public async Task CalculateMaintainabilityIndex_SimpleMethod_ReturnsHighScore()
{
// Arrange
var code = """
public class Test
{
public int Add(int a, int b) => a + b;
}
""";
var method = GetMethod(code);
// Act
var mi = MaintainabilityIndexCalculator.Calculate(1, 1, 10);
// Assert
await Assert.That(mi).IsGreaterThan(80);
}Pattern: {MethodName}_{Scenario}_{ExpectedResult}
Examples:
Calculate_EmptyMethod_ReturnsOne
AnalyzeSolutionAsync_NonExistentFile_ThrowsFileNotFoundException
AddDependency_DuplicateEdge_IncreasesReferenceCount
ParallelAdd_ThreadSafeusing TUnit.Core;
public class MyServiceTests
{
private MyService _service;
[Before(Test)]
public void Setup()
{
_service = new MyService();
}
[After(Test)]
public void Cleanup()
{
_service?.Dispose();
}
[Test]
public async Task TestMethod()
{
// Test implementation
}
}Use fluent await Assert.That() syntax:
// Null checks
await Assert.That(result).IsNotNull();
await Assert.That(nullValue).IsNull();
// Equality
await Assert.That(count).IsEqualTo(5);
await Assert.That(name).IsEqualTo("Expected");
// Comparisons
await Assert.That(value).IsGreaterThan(0);
await Assert.That(value).IsLessThan(100);
await Assert.That(value).IsGreaterThanOrEqualTo(10);
// Collections
await Assert.That(list).HasCount().EqualTo(3);
await Assert.That(list).Contains("item");
await Assert.That(list).IsEmpty();
await Assert.That(list).IsNotEmpty();
// Exceptions
await Assert.ThrowsAsync<FileNotFoundException>(
async () => await analyzer.AnalyzeSolutionAsync("/nonexistent"));using FakeItEasy;
[Test]
public void Create_CallsRegistrationService()
{
// Arrange - Create fake
var registrationService = A.Fake<IMSBuildRegistrationService>();
var factory = new MSBuildWorkspaceFactory(registrationService);
// Act
using var workspace = factory.Create();
// Assert - Verify call
A.CallTo(() => registrationService.EnsureMSBuildRegistered())
.MustHaveHappenedOnceExactly();
}
[Test]
public async Task Method_WithMockedDependency_ReturnsExpectedResult()
{
// Arrange - Setup return value
var fileSystem = A.Fake<IFileSystemService>();
A.CallTo(() => fileSystem.FileExists(A<string>._))
.Returns(true);
// Act & Assert
var result = await fileSystem.FileExists("/path");
await Assert.That(result).IsTrue();
}Use C# 13 raw string literals for code samples:
var code = """
public class TestClass
{
public void Method()
{
if (true)
{
Console.WriteLine("Hello");
}
}
}
""";Integration tests run against the actual StructuraLens solution (self-testing):
[Test]
public async Task AnalyzeSolutionAsync_OwnSolution_ReturnsValidReport()
{
// Arrange
var solutionPath = Path.Combine(
Path.GetDirectoryName(typeof(SolutionAnalyzerIntegrationTests).Assembly.Location)!,
"../../../../../StructuraLens.slnx");
var analyzer = CreateAnalyzer(); // Real services, no mocks
// Act
var report = await analyzer.AnalyzeSolutionAsync(solutionPath);
// Assert
await Assert.That(report).IsNotNull();
await Assert.That(report.TotalProjects).IsGreaterThan(0);
}Verify concurrent access patterns:
[Test]
public async Task ParallelAdd_ThreadSafe()
{
using var collector = new InMemoryDependencyCollector();
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
collector.AddDependency(
new DependencyEdge("A", "B", DependencyType.TypeReference, 1));
}
}))
.ToList();
await Task.WhenAll(tasks);
var result = collector.GetAggregatedDependencies();
await Assert.That(result).HasCount().EqualTo(1);
await Assert.That(result[0].ReferenceCount).IsEqualTo(100_000);
}Use using for disposables:
using var workspace = _workspaceFactory.Create();
using var collector = CreateDependencyCollector();Dispose collectors explicitly:
try
{
var result = collector.GetAggregatedDependencies();
return CreateReport(result);
}
finally
{
collector.Dispose();
}Use ConfigureAwait(false) in library code:
await Task.Delay(1000).ConfigureAwait(false);Avoid sync-over-async:
// Bad
var result = task.Result; // Can deadlock
// Good
var result = await task;Reuse semantic models:
var semanticModel = compilation.GetSemanticModel(syntaxTree);
// Use same semantic model for all nodes in treeCache compilations:
// MSBuildWorkspace automatically caches compilations
var compilation = await project.GetCompilationAsync();Use appropriate collection types:
// For deduplication and lookups
var seen = new HashSet<string>();
// For thread-safe aggregation
var dict = new ConcurrentDictionary<string, int>();
// For ordered iteration
var list = new List<string>();-
Run tests:
dotnet test -
Build in Release mode:
dotnet build -c Release
-
Self-analyze:
dotnet run --project src/StructuraLens.Cli -- analyze StructuraLens.slnx --format summary
-
Check for warnings:
dotnet build /warnaserror
- PR title follows Conventional Commits format
- All tests pass (
dotnet test) - New features have tests
- Documentation updated if needed
- Code follows style conventions
- No new compiler warnings
- Ran self-analysis successfully
PR titles must follow Conventional Commits (PRs are squashed on merge):
feat: add new aggregation strategy
fix: resolve null reference in coupling analyzer
docs: update architecture guide
test: add integration tests for exporters
chore: update dependencies
- Automated checks run (build, test, analysis)
- Maintainer review for code quality and design
- Address feedback with new commits
- Squash and merge when approved
- Issues: Open GitHub issue for bugs or feature requests
- Discussions: Use GitHub Discussions for questions
- Documentation: Check docs/ directory for detailed guides
- Architecture Guide - Internal architecture details
- Usage Guide - CLI reference and examples
- Design Document - High-level design and decisions
- Compact Format - Output format specification
- Copilot Instructions - Quick contributor reference
- Create calculator class in
src/StructuraLens.Core/Analysis/Calculators/ - Implement static
Calculatemethod - Add tests in
tests/StructuraLens.Tests/Analysis/Calculators/ - Integrate into
MetricsCalculator.cs - Update models if needed (
MethodMetrics,TypeMetrics)
- Create exporter class in
src/StructuraLens.Core/Export/ - Implement export logic
- Add format option to CLI (
Program.cs) - Add tests in
tests/StructuraLens.Tests/Export/ - Document in
docs/usage.md
- Implement
IDependencyCollectorinsrc/StructuraLens.Core/Analysis/Collectors/ - Add strategy enum value to
DependencyAggregationStrategy - Update factory method in
SolutionAnalyzer.CreateDependencyCollector() - Add tests in
tests/StructuraLens.Tests/Analysis/Collectors/ - Document strategy in docs
# Check for outdated packages
dotnet list package --outdated
# Update specific package
dotnet add package Microsoft.CodeAnalysis.CSharp --version 10.0.0
# Update all packages (careful!)
dotnet outdated --upgradeUse BenchmarkDotNet for micro-benchmarks:
[MemoryDiagnoser]
public class CollectorBenchmarks
{
[Benchmark]
public void InMemoryCollector()
{
using var collector = new InMemoryDependencyCollector();
for (int i = 0; i < 10000; i++)
{
collector.AddDependency(new DependencyEdge("A", "B", DependencyType.TypeReference, 1));
}
_ = collector.GetAggregatedDependencies();
}
}Profile real analysis with dotnet-trace:
dotnet tool install --global dotnet-trace
dotnet trace collect --process-id <pid> --providers Microsoft-DotNETCore-SampleProfilerHappy coding! If you have questions or need help, don't hesitate to open an issue or discussion on GitHub.