Skip to content

Commit 7008a79

Browse files
Baseline Feature (#10)
* Add baseline comparison functionality for cognitive metrics - Introduced new classes for baseline comparison, including `BaselineComparer`, `BaselineLoader`, and `BaselineSnapshotFactory`. - Implemented methods to compare current cognitive metrics against baseline snapshots, allowing for detailed analysis of changes over time. - Added support for serializing and deserializing baseline snapshots using JSON. - Enhanced existing reports (CSV, HTML, GitHub Actions, and GitLab) to include baseline comparison data, providing richer insights into cognitive metrics. - Updated `CognitiveCiSeverity` and report formatting classes to accommodate new baseline comparison features. - Added tests to validate baseline comparison logic and report generation with baseline data. * Update README to include new report types and options for baseline comparison - Added `Json` and `Csv` as new report types for cognitive analysis. - Introduced `-b|--baseline <path>` option for comparing current metrics against a JSON baseline snapshot. - Included `--show-coupling` option to enable class coupling metrics in the analysis. - Updated examples to demonstrate the usage of new options and report types. * Add default cognitive configuration and update configuration loading - Introduced `CognitiveConfigurationDefaults` class to provide a standard set of default values for cognitive analysis configuration. - Updated `ConfigurationLoader` to utilize the new defaults when creating a `CognitiveConfiguration` instance. - Added unit test to verify that the default configuration matches expected values. - Modified project file to include `cognitive-metrics-settings.json` for output directory preservation. * Refactor cognitive analysis methods and enhance baseline comparison functionality - Updated `GetFullSignature` method in `CognitiveCodeAnalyser` to include type parameters and constraints in method signatures. - Refactored `BaselineComparer` to utilize a new `ToDictionaryLastWins` method for improved dictionary creation. - Changed access modifiers of methods in `BaselineMethodKey` from internal to public for broader accessibility. - Added unit tests in `BaselineDuplicateKeyTests` to ensure proper handling of duplicate method keys and class names across different namespaces.
1 parent 5718ce7 commit 7008a79

34 files changed

Lines changed: 1794 additions & 160 deletions
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/// <copyright company="Florian Krämer">
2+
/// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
/// </copyright>
4+
5+
using CognitiveCodeAnalysis.CognitiveAnalysis;
6+
using CognitiveCodeAnalysis.CognitiveAnalysis.Baseline;
7+
using CognitiveCodeAnalysis.CouplingAnalysis;
8+
9+
namespace CognitiveCodeAnalysis.Tests.CognitiveAnalysis.Baseline;
10+
11+
public class BaselineComparerTests
12+
{
13+
[Test]
14+
public void Compare_ComputesDeltaForMatchedMethod()
15+
{
16+
var baselineMetrics = SampleMetric(totalScore: 2.0, ifCount: 1);
17+
var currentMetrics = SampleMetric(totalScore: 2.5, ifCount: 3);
18+
19+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection { baselineMetrics });
20+
var current = new CognitiveMetricsCollection { currentMetrics };
21+
22+
var comparison = BaselineComparer.Compare(current, baseline);
23+
24+
Assert.That(comparison.TryGetMethodComparison(currentMetrics, out MethodMetricsComparison? methodComparison), Is.True);
25+
Assert.That(methodComparison!.TotalScore.Delta, Is.EqualTo(0.5).Within(0.0001));
26+
Assert.That(methodComparison.IfCount.Delta, Is.EqualTo(2));
27+
}
28+
29+
[Test]
30+
public void Compare_NewMethod_HasNoBaselineDelta()
31+
{
32+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection());
33+
var currentMetrics = SampleMetric(totalScore: 1.0, ifCount: 0);
34+
var current = new CognitiveMetricsCollection { currentMetrics };
35+
36+
var comparison = BaselineComparer.Compare(current, baseline);
37+
38+
Assert.That(comparison.TryGetMethodComparison(currentMetrics, out MethodMetricsComparison? methodComparison), Is.True);
39+
Assert.That(methodComparison!.HasBaseline, Is.False);
40+
Assert.That(methodComparison.TotalScore.Delta, Is.Null);
41+
}
42+
43+
[Test]
44+
public void Compare_MatchesByNormalizedPathAndSignature()
45+
{
46+
var baselineMetrics = SampleMetric(totalScore: 1.0, ifCount: 0);
47+
baselineMetrics.FilePath = @"src\Demo.cs";
48+
49+
var currentMetrics = SampleMetric(totalScore: 2.0, ifCount: 1);
50+
currentMetrics.FilePath = "src/Demo.cs";
51+
currentMetrics.methodLineNumber = 99;
52+
53+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection { baselineMetrics });
54+
var current = new CognitiveMetricsCollection { currentMetrics };
55+
56+
var comparison = BaselineComparer.Compare(current, baseline);
57+
58+
Assert.That(comparison.TryGetMethodComparison(currentMetrics, out MethodMetricsComparison? methodComparison), Is.True);
59+
Assert.That(methodComparison!.HasBaseline, Is.True);
60+
Assert.That(methodComparison.TotalScore.Delta, Is.EqualTo(1.0).Within(0.0001));
61+
}
62+
63+
[Test]
64+
public void Compare_NullableCoverageDelta_OnlyWhenBothSidesHaveValues()
65+
{
66+
var baselineMetrics = SampleMetric(totalScore: 1.0, ifCount: 0);
67+
baselineMetrics.lineCoveragePercentage = 50.0;
68+
69+
var currentWithCoverage = SampleMetric(totalScore: 1.0, ifCount: 0);
70+
currentWithCoverage.lineCoveragePercentage = 80.0;
71+
72+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection { baselineMetrics });
73+
var current = new CognitiveMetricsCollection { currentWithCoverage };
74+
75+
var comparison = BaselineComparer.Compare(current, baseline);
76+
77+
Assert.That(comparison.TryGetMethodComparison(currentWithCoverage, out MethodMetricsComparison? methodComparison), Is.True);
78+
Assert.That(methodComparison!.LineCoveragePercentage.Delta, Is.EqualTo(30.0).Within(0.0001));
79+
}
80+
81+
[Test]
82+
public void Compare_ClassCouplingDelta()
83+
{
84+
var metrics = SampleMetric(totalScore: 1.0, ifCount: 0);
85+
var current = new CognitiveMetricsCollection { metrics };
86+
current.SetClassCouplingMetrics(
87+
[
88+
new ClassCouplingMetrics { ClassName = "C", IncomingCoupling = 3, OutgoingCoupling = 2, Stability = 0.4 },
89+
]);
90+
91+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(current);
92+
current.SetClassCouplingMetrics(
93+
[
94+
new ClassCouplingMetrics { ClassName = "C", IncomingCoupling = 5, OutgoingCoupling = 1, Stability = 0.2 },
95+
]);
96+
97+
var comparison = BaselineComparer.Compare(current, baseline);
98+
99+
Assert.That(comparison.TryGetClassCouplingComparison("C", out ClassCouplingComparison? couplingComparison), Is.True);
100+
Assert.That(couplingComparison!.IncomingCoupling.Delta, Is.EqualTo(2));
101+
Assert.That(couplingComparison.OutgoingCoupling.Delta, Is.EqualTo(-1));
102+
Assert.That(couplingComparison.Stability.Delta, Is.EqualTo(-0.2).Within(0.0001));
103+
}
104+
105+
private static CognitiveMetrics SampleMetric(double totalScore, int ifCount)
106+
{
107+
var m = new CognitiveMetrics(
108+
methodName: "Foo",
109+
className: "C",
110+
filePath: "src/Demo.cs",
111+
methodSignature: "void Foo()",
112+
methodLineNumber: 10,
113+
ifCount: ifCount
114+
);
115+
m.totalScore = totalScore;
116+
return m;
117+
}
118+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/// <copyright company="Florian Krämer">
2+
/// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
/// </copyright>
4+
5+
using CognitiveCodeAnalysis.CognitiveAnalysis;
6+
using CognitiveCodeAnalysis.CognitiveAnalysis.Baseline;
7+
using CognitiveCodeAnalysis.Configuration;
8+
9+
namespace CognitiveCodeAnalysis.Tests.CognitiveAnalysis.Baseline;
10+
11+
public class BaselineDuplicateKeyTests
12+
{
13+
private CognitiveCodeAnalyser _analyser = null!;
14+
private CognitiveConfiguration _configuration = null!;
15+
private TempFiles _tempFiles = null!;
16+
17+
[SetUp]
18+
public void SetUp()
19+
{
20+
_analyser = new CognitiveCodeAnalyser();
21+
_configuration = CognitiveConfigurationDefaults.Create();
22+
_tempFiles = new TempFiles();
23+
}
24+
25+
[TearDown]
26+
public void TearDown() => _tempFiles.CleanUp();
27+
28+
[Test]
29+
public void Compare_WithGenericConstraintOverloads_DoesNotThrow()
30+
{
31+
_tempFiles.CreateFileWithContent(
32+
"Generic.cs",
33+
"""
34+
namespace DupTest;
35+
public class GenericOverload
36+
{
37+
public void Foo<T>(T t) where T : class { }
38+
public void Foo<T>(T t) where T : struct { }
39+
}
40+
"""
41+
);
42+
43+
var files = Directory.GetFiles(_tempFiles.tmpDirectory, "*.cs").ToList();
44+
var metrics = _analyser.AnalyseFilesAsync(files, _configuration).GetAwaiter().GetResult();
45+
var snapshot = BaselineSnapshotFactory.FromMetricsCollection(metrics);
46+
47+
Assert.DoesNotThrow(() => BaselineComparer.Compare(metrics, snapshot));
48+
49+
var duplicateKeys = metrics
50+
.GroupBy(BaselineMethodKey.FromMetrics)
51+
.Where(g => g.Count() > 1)
52+
.ToList();
53+
54+
Assert.That(duplicateKeys, Is.Empty);
55+
}
56+
57+
[Test]
58+
public void Compare_WithSameClassNameInDifferentNamespaces_DoesNotThrow()
59+
{
60+
_tempFiles.CreateFileWithContent(
61+
"Worker1.cs",
62+
"""
63+
namespace NamespaceA;
64+
public class Worker { public void A() { } }
65+
"""
66+
);
67+
_tempFiles.CreateFileWithContent(
68+
"Worker2.cs",
69+
"""
70+
namespace NamespaceB;
71+
public class Worker { public void B() { } }
72+
"""
73+
);
74+
75+
var files = Directory.GetFiles(_tempFiles.tmpDirectory, "*.cs").ToList();
76+
var metrics = _analyser.AnalyseFilesAsync(files, _configuration).GetAwaiter().GetResult();
77+
var snapshot = BaselineSnapshotFactory.FromMetricsCollection(metrics);
78+
79+
Assert.DoesNotThrow(() => BaselineComparer.Compare(metrics, snapshot));
80+
Assert.That(metrics.Select(m => m.ClassName).Distinct().Count(), Is.EqualTo(2));
81+
}
82+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/// <copyright company="Florian Krämer">
2+
/// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
/// </copyright>
4+
5+
using CognitiveCodeAnalysis.CognitiveAnalysis;
6+
using CognitiveCodeAnalysis.CognitiveAnalysis.Baseline;
7+
8+
namespace CognitiveCodeAnalysis.Tests.CognitiveAnalysis.Baseline;
9+
10+
public class BaselineLoaderTests
11+
{
12+
[Test]
13+
public void RoundTrip_SerializesAndDeserializesSnapshot()
14+
{
15+
var metrics = new CognitiveMetrics(
16+
methodName: "Alpha",
17+
className: "Demo",
18+
filePath: "src/Demo.cs",
19+
methodSignature: "void Alpha()",
20+
methodLineNumber: 10
21+
);
22+
metrics.totalScore = 2.5;
23+
24+
var snapshot = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection { metrics });
25+
var path = Path.Combine(Path.GetTempPath(), "baseline-" + Guid.NewGuid() + ".json");
26+
27+
try
28+
{
29+
File.WriteAllText(path, BaselineLoader.Serialize(snapshot));
30+
var loaded = BaselineLoader.Load(path);
31+
32+
Assert.That(loaded.SchemaVersion, Is.EqualTo(CognitiveBaselineSnapshot.CurrentSchemaVersion));
33+
Assert.That(loaded.Methods, Has.Count.EqualTo(1));
34+
Assert.That(loaded.Methods[0].MethodName, Is.EqualTo("Alpha"));
35+
Assert.That(loaded.Methods[0].TotalScore, Is.EqualTo(2.5).Within(0.0001));
36+
}
37+
finally
38+
{
39+
if (File.Exists(path))
40+
{
41+
File.Delete(path);
42+
}
43+
}
44+
}
45+
46+
[Test]
47+
public void Load_ThrowsForUnsupportedSchemaVersion()
48+
{
49+
var path = Path.Combine(Path.GetTempPath(), "baseline-bad-" + Guid.NewGuid() + ".json");
50+
51+
try
52+
{
53+
File.WriteAllText(path, """{"schemaVersion":99,"generatedAt":"2026-01-01T00:00:00Z","methods":[],"classCoupling":[]}""");
54+
Assert.Throws<InvalidOperationException>(() => BaselineLoader.Load(path));
55+
}
56+
finally
57+
{
58+
if (File.Exists(path))
59+
{
60+
File.Delete(path);
61+
}
62+
}
63+
}
64+
65+
[Test]
66+
public void Load_ThrowsWhenFileMissing()
67+
{
68+
Assert.Throws<FileNotFoundException>(() => BaselineLoader.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")));
69+
}
70+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/// <copyright company="Florian Krämer">
2+
/// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
/// </copyright>
4+
5+
using CognitiveCodeAnalysis.CognitiveAnalysis.Baseline;
6+
using CognitiveCodeAnalysis.CognitiveAnalysis.Reports;
7+
8+
namespace CognitiveCodeAnalysis.Tests.CognitiveAnalysis.Baseline;
9+
10+
public class CognitiveReportDeltaFormatterTests
11+
{
12+
[Test]
13+
public void FormatHtmlDeltaSuffix_Up_IsRedWithTriangle()
14+
{
15+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 2.5, Delta = 0.5 };
16+
var suffix = CognitiveReportDeltaFormatter.FormatHtmlDeltaSuffix(delta, "F3");
17+
18+
Assert.That(suffix, Does.Contain("delta-up"));
19+
Assert.That(suffix, Does.Contain("▲0.500"));
20+
}
21+
22+
[Test]
23+
public void FormatHtmlDeltaSuffix_Down_IsGreenWithTriangle()
24+
{
25+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 1.5, Delta = -0.5 };
26+
var suffix = CognitiveReportDeltaFormatter.FormatHtmlDeltaSuffix(delta, "F3");
27+
28+
Assert.That(suffix, Does.Contain("delta-down"));
29+
Assert.That(suffix, Does.Contain("▼0.500"));
30+
}
31+
32+
[Test]
33+
public void FormatHtmlDeltaSuffix_Zero_IsEmpty()
34+
{
35+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 2.0, Delta = 0.0 };
36+
Assert.That(CognitiveReportDeltaFormatter.FormatHtmlDeltaSuffix(delta, "F3"), Is.Empty);
37+
}
38+
39+
[Test]
40+
public void FormatConsoleDeltaSuffix_Up_IsRedMarkup()
41+
{
42+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 3.0, Delta = 1.0 };
43+
var suffix = CognitiveReportDeltaFormatter.FormatConsoleDeltaSuffix(delta, "F3");
44+
45+
Assert.That(suffix, Does.Contain("[red]"));
46+
Assert.That(suffix, Does.Contain("▲1.000"));
47+
}
48+
49+
[Test]
50+
public void FormatCiSuffix_IncludesBaselineText()
51+
{
52+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 2.5, Delta = 0.5 };
53+
var suffix = CognitiveReportDeltaFormatter.FormatCiSuffix(delta, "F3");
54+
55+
Assert.That(suffix, Is.EqualTo(" (▲0.500 vs baseline)"));
56+
}
57+
58+
[Test]
59+
public void FormatCsvDelta_ReturnsSignedValue()
60+
{
61+
var delta = new MetricDelta { BaselineValue = 2.0, CurrentValue = 2.5, Delta = 0.5 };
62+
Assert.That(CognitiveReportDeltaFormatter.FormatCsvDelta(delta, "F3"), Is.EqualTo("0.500"));
63+
}
64+
}

CognitiveCodeAnalysis.Tests/src/CognitiveAnalysis/Reports/HtmlReportGoldenTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/// </copyright>
44

55
using CognitiveCodeAnalysis.CognitiveAnalysis;
6+
using CognitiveCodeAnalysis.CognitiveAnalysis.Baseline;
67
using CognitiveCodeAnalysis.CognitiveAnalysis.Reports;
78
using CognitiveCodeAnalysis.Configuration;
89

@@ -61,4 +62,49 @@ public void HtmlReport_RendersStableTitleAndMethodRow()
6162
}
6263
}
6364
}
65+
66+
[Test]
67+
public void HtmlReport_WithBaseline_ShowsColoredDeltaSuffixes()
68+
{
69+
var baselineMetrics = new CognitiveMetrics(
70+
methodName: "Alpha",
71+
className: "Demo",
72+
filePath: "src/Demo.cs",
73+
methodSignature: "void Alpha()",
74+
methodLineNumber: 10
75+
);
76+
baselineMetrics.totalScore = 2.0;
77+
78+
var currentMetrics = new CognitiveMetrics(
79+
methodName: "Alpha",
80+
className: "Demo",
81+
filePath: "src/Demo.cs",
82+
methodSignature: "void Alpha()",
83+
methodLineNumber: 10
84+
);
85+
currentMetrics.totalScore = 2.5;
86+
87+
var baseline = BaselineSnapshotFactory.FromMetricsCollection(new CognitiveMetricsCollection { baselineMetrics });
88+
var current = new CognitiveMetricsCollection { currentMetrics };
89+
var comparison = BaselineComparer.Compare(current, baseline);
90+
var config = new CognitiveConfiguration { GroupByClass = false, ShowOnlyMethodsExceedingThreshold = false };
91+
92+
var path = Path.Combine(Path.GetTempPath(), "html-baseline-" + Guid.NewGuid() + ".html");
93+
try
94+
{
95+
new HtmlReport().RenderMetrics(path, current, config, comparison);
96+
var html = File.ReadAllText(path);
97+
98+
Assert.That(html, Does.Contain("delta-up"));
99+
Assert.That(html, Does.Contain("▲0.500"));
100+
Assert.That(html, Does.Contain("2.500"));
101+
}
102+
finally
103+
{
104+
if (File.Exists(path))
105+
{
106+
File.Delete(path);
107+
}
108+
}
109+
}
64110
}

0 commit comments

Comments
 (0)