From d3dd17f48e23711d15e8c35599923e8fcb1ffd5c Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 12:24:09 -0700 Subject: [PATCH 01/27] T1: scaffold solution and projects (S1) Add crap4csharp.sln, src/Crap4CSharp (net8.0 Exe with Roslyn Microsoft.CodeAnalysis.CSharp 4.14.0) and tests/Crap4CSharp.Tests (xUnit + coverlet.collector + FluentAssertions pinned [7.0.0,8.0.0)), importing the shared Crap4CSharp(.Tests).Common.targets. Empty build + dotnet test are green in Release (warnings-as-errors); packages.lock.json committed for deterministic restore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crap4csharp.sln | 36 + src/Crap4CSharp/Crap4CSharp.csproj | 14 + src/Crap4CSharp/Program.cs | 13 + src/Crap4CSharp/packages.lock.json | 73 ++ .../Crap4CSharp.Tests.csproj | 13 + .../ScaffoldingSanityTests.cs | 14 + tests/Crap4CSharp.Tests/packages.lock.json | 1152 +++++++++++++++++ 7 files changed, 1315 insertions(+) create mode 100644 crap4csharp.sln create mode 100644 src/Crap4CSharp/Crap4CSharp.csproj create mode 100644 src/Crap4CSharp/Program.cs create mode 100644 src/Crap4CSharp/packages.lock.json create mode 100644 tests/Crap4CSharp.Tests/Crap4CSharp.Tests.csproj create mode 100644 tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs create mode 100644 tests/Crap4CSharp.Tests/packages.lock.json diff --git a/crap4csharp.sln b/crap4csharp.sln new file mode 100644 index 0000000..54bb4ce --- /dev/null +++ b/crap4csharp.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{ED496051-9E7A-453C-9A26-247CB424CE32}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crap4CSharp", "src\Crap4CSharp\Crap4CSharp.csproj", "{63C6F08D-6D93-4A2E-9A08-4B9159DB0D13}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A013CBD6-7643-4A36-A131-357A886B0B9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crap4CSharp.Tests", "tests\Crap4CSharp.Tests\Crap4CSharp.Tests.csproj", "{693BA54C-6870-4D71-BE43-7DD723E24BE5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {63C6F08D-6D93-4A2E-9A08-4B9159DB0D13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63C6F08D-6D93-4A2E-9A08-4B9159DB0D13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63C6F08D-6D93-4A2E-9A08-4B9159DB0D13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63C6F08D-6D93-4A2E-9A08-4B9159DB0D13}.Release|Any CPU.Build.0 = Release|Any CPU + {693BA54C-6870-4D71-BE43-7DD723E24BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {693BA54C-6870-4D71-BE43-7DD723E24BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {693BA54C-6870-4D71-BE43-7DD723E24BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {693BA54C-6870-4D71-BE43-7DD723E24BE5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {63C6F08D-6D93-4A2E-9A08-4B9159DB0D13} = {ED496051-9E7A-453C-9A26-247CB424CE32} + {693BA54C-6870-4D71-BE43-7DD723E24BE5} = {A013CBD6-7643-4A36-A131-357A886B0B9B} + EndGlobalSection +EndGlobal diff --git a/src/Crap4CSharp/Crap4CSharp.csproj b/src/Crap4CSharp/Crap4CSharp.csproj new file mode 100644 index 0000000..e5ceef0 --- /dev/null +++ b/src/Crap4CSharp/Crap4CSharp.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + + + + + + + + + diff --git a/src/Crap4CSharp/Program.cs b/src/Crap4CSharp/Program.cs new file mode 100644 index 0000000..d0f5525 --- /dev/null +++ b/src/Crap4CSharp/Program.cs @@ -0,0 +1,13 @@ +namespace Microsoft.Crap4CSharp; + +/// +/// Entry point for the crap4csharp CLI. Real argument handling and analysis wiring arrive in a +/// later task; this placeholder keeps the executable buildable and warning-clean. +/// +public static class Program +{ + public static int Main(string[] args) + { + return 0; + } +} diff --git a/src/Crap4CSharp/packages.lock.json b/src/Crap4CSharp/packages.lock.json new file mode 100644 index 0000000..145ff9c --- /dev/null +++ b/src/Crap4CSharp/packages.lock.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "0k2Jwpc8eq0hjOtX6TxRkHm9clkJ2PAQ3heEHgqIJZcsfdFosC/iyz18nsgTVDDWpID80rC7aiYK7ripx+Qndg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.14.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.NetAnalyzers": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "JajbvkrBgtdRghavIjcJuNHMOja4lqBmEezbhZyqWPYh2cpLhT5mPpfC7NQVDO4IehWQum9t/nwF4v+qQGtYWg==" + }, + "StyleCop.Analyzers": { + "type": "Direct", + "requested": "[1.2.0-beta.556, )", + "resolved": "1.2.0-beta.556", + "contentHash": "llRPgmA1fhC0I0QyFLEcjvtM2239QzKr/tcnbsjArLMJxJlu0AA5G7Fft0OI30pHF3MW63Gf4aSSsjc5m82J1Q==", + "dependencies": { + "StyleCop.Analyzers.Unstable": "1.2.0.556" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "StyleCop.Analyzers.Unstable": { + "type": "Transitive", + "resolved": "1.2.0.556", + "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/tests/Crap4CSharp.Tests/Crap4CSharp.Tests.csproj b/tests/Crap4CSharp.Tests/Crap4CSharp.Tests.csproj new file mode 100644 index 0000000..e6c81ce --- /dev/null +++ b/tests/Crap4CSharp.Tests/Crap4CSharp.Tests.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + + + + + + + + + diff --git a/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs b/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs new file mode 100644 index 0000000..dda4c61 --- /dev/null +++ b/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs @@ -0,0 +1,14 @@ +namespace Microsoft.Crap4CSharp.Tests; + +/// +/// Throwaway scaffolding test proving the xUnit + FluentAssertions harness discovers and runs. +/// A later task replaces this with real behavioral tests. +/// +public class ScaffoldingSanityTests +{ + [Fact] + public void HarnessDiscoversAndRuns() + { + true.Should().BeTrue(); + } +} diff --git a/tests/Crap4CSharp.Tests/packages.lock.json b/tests/Crap4CSharp.Tests/packages.lock.json new file mode 100644 index 0000000..294a54f --- /dev/null +++ b/tests/Crap4CSharp.Tests/packages.lock.json @@ -0,0 +1,1152 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[7.0.0, 8.0.0)", + "resolved": "7.0.0", + "contentHash": "mTLbcU991EQ1SEmNbVBaGGGJy0YFzvGd1sYJGNZ07nlPKuyHSn1I22aeKzqQXgEiaKyRO6MSCto9eN9VxMwBdA==", + "dependencies": { + "System.Configuration.ConfigurationManager": "6.0.0" + } + }, + "FluentAssertions.Analyzers": { + "type": "Direct", + "requested": "[0.34.1, )", + "resolved": "0.34.1", + "contentHash": "2BnAAB8CCPdRA9P1+lAvBZOleR2BTmsxGMtGt+LnABJUARGqoGMWEFxG3znZYqHVIrMP0Za/kyw6atyt8x2mzA==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "0k2Jwpc8eq0hjOtX6TxRkHm9clkJ2PAQ3heEHgqIJZcsfdFosC/iyz18nsgTVDDWpID80rC7aiYK7ripx+Qndg==" + }, + "Microsoft.CodeAnalysis.NetAnalyzers": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "JajbvkrBgtdRghavIjcJuNHMOja4lqBmEezbhZyqWPYh2cpLhT5mPpfC7NQVDO4IehWQum9t/nwF4v+qQGtYWg==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.8.0, )", + "resolved": "17.8.0", + "contentHash": "BmTYGbD/YuDHmApIENdoyN1jCk0Rj1fJB0+B/fVekyTdVidr91IlzhqzytiUgaEAzL1ZJcYCme0MeBMYvJVzvw==", + "dependencies": { + "Microsoft.CodeCoverage": "17.8.0", + "Microsoft.TestPlatform.TestHost": "17.8.0" + } + }, + "StyleCop.Analyzers": { + "type": "Direct", + "requested": "[1.2.0-beta.556, )", + "resolved": "1.2.0-beta.556", + "contentHash": "llRPgmA1fhC0I0QyFLEcjvtM2239QzKr/tcnbsjArLMJxJlu0AA5G7Fft0OI30pHF3MW63Gf4aSSsjc5m82J1Q==", + "dependencies": { + "StyleCop.Analyzers.Unstable": "1.2.0.556" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.5.3, )", + "resolved": "2.5.3", + "contentHash": "VxYDiWSwrLxOJ3UEN+ZPrBybB0SFShQ1E6PjT65VdoKCJhorgerFznThjSwawRH/WAip73YnucDVsE8WRj/8KQ==", + "dependencies": { + "xunit.analyzers": "1.4.0", + "xunit.assert": "2.5.3", + "xunit.core": "[2.5.3]" + } + }, + "xunit.analyzers": { + "type": "Direct", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "s+M8K/Rtlgr6CmD7AYQKrNTvT5sh0l0ZKDoZ3Z/ExhlIwfV9mGAMR4f7KqIB7SSK7ZOhqDTgTUMYPmKfmvWUWQ==" + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.3, )", + "resolved": "2.5.3", + "contentHash": "HFFL6O+QLEOfs555SqHii48ovVa4CqGYanY+B32BjLpPptdE+wEJmCFNXlLHdEOD5LYeayb9EroaUpydGpcybg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]", + "System.Collections.Immutable": "9.0.0", + "System.Reflection.Metadata": "9.0.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.8.0", + "contentHash": "KC8SXWbGIdoFVdlxKk9WHccm0llm9HypcHMLUUFabRiTS3SO2fQXNZfdiF3qkEdTJhbRrxhdRxjL4jbtwPq4Ew==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.8.0", + "contentHash": "AYy6vlpGMfz5kOFq99L93RGbqftW/8eQTqjT9iGXW6s9MRP3UdtY8idJ8rJcjeSja8A18IhIro5YnH3uv1nz4g==", + "dependencies": { + "NuGet.Frameworks": "6.5.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.8.0", + "contentHash": "9ivcl/7SGRmOT0YYrHQGohWiT5YCpkmy/UEzldfVisLm6QxbLaK3FAJqZXI34rnRLmqqDCeMQxKINwmKwAPiDw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.8.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Microsoft.Win32.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.Win32.Primitives": "4.3.0", + "System.AppContext": "4.3.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Console": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.Compression.ZipFile": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.Net.Http": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Net.Sockets": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Timer": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0", + "System.Xml.XDocument": "4.3.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "6.5.0", + "contentHash": "QWINE2x3MbTODsWT1Gh71GaGb5icBz4chS8VYvTgsBnsi8esgN6wtHhydd7fvToWECYGq7T4cgBBDiKD/363fg==" + }, + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" + }, + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" + }, + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" + }, + "runtime.native.System": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", + "dependencies": { + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" + } + }, + "runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", + "dependencies": { + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" + }, + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" + }, + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" + }, + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" + }, + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" + }, + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" + }, + "StyleCop.Analyzers.Unstable": { + "type": "Transitive", + "resolved": "1.2.0.556", + "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" + }, + "System.AppContext": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Collections": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.Concurrent": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "6.0.0", + "System.Security.Permissions": "6.0.0" + } + }, + "System.Console": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Diagnostics.Debug": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Diagnostics.Tools": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.Tracing": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Calendars": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Buffers": "4.3.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.IO.Compression": "4.3.0" + } + }, + "System.IO.Compression.ZipFile": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", + "dependencies": { + "System.Buffers": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.IO.FileSystem": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Linq.Expressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Linq": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Emit.Lightweight": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.DiagnosticSource": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Extensions": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Net.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Net.Sockets": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.ObjectModel": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Reflection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.Lightweight": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "ANiqLu3DxW9kol/hMmTWbt3414t9ftdIuiIU7j80okq2YzAueo120M442xk1kDJWtmZTqWQn7wHDvMRipVOEOQ==", + "dependencies": { + "System.Collections.Immutable": "9.0.0" + } + }, + "System.Reflection.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Resources.ResourceManager": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0" + } + }, + "System.Runtime.Numerics": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", + "dependencies": { + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==" + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.Apple": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Security.Cryptography.Csp": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Linq": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", + "dependencies": { + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Cng": "4.3.0", + "System.Security.Cryptography.Csp": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", + "dependencies": { + "System.Security.AccessControl": "6.0.0", + "System.Windows.Extensions": "6.0.0" + } + }, + "System.Text.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Text.Encoding.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Threading": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Tasks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Timer": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "System.Xml.ReaderWriter": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Tasks.Extensions": "4.3.0" + } + }, + "System.Xml.XDocument": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.5.3", + "contentHash": "MK3HiBckO3vdxEdUxXZyyRPsBNPsC/nz6y1gj/UZIZkjMnsVQyZPU8yxS/3cjTchYcqskt/nqUOS5wmD8JezdQ==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.5.3", + "contentHash": "FE8yEEUkoMLd6kOHDXm/QYfX/dYzwc0c+Q4MQon6VGRwFuy6UVGwK/CFA5LEea+ZBEmcco7AEl2q78VjsA0j/w==", + "dependencies": { + "xunit.extensibility.core": "[2.5.3]", + "xunit.extensibility.execution": "[2.5.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.5.3", + "contentHash": "IjAQlPeZWXP89pl1EuOG9991GH1qgAL0rQfkmX2UV+PDenbYb7oBnQopL9ujE6YaXxgaQazp7lFjsDyyxD6Mtw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.5.3", + "contentHash": "w9eGCHl+gJj1GzZSf0VTzYPp/gv4fiUDkr+yR7/Wv9/ucO2CHltGg2TnyySLFjzekkjuxVJZUE+tZyDNzryJFw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "xunit.extensibility.core": "[2.5.3]" + } + }, + "Microsoft.Crap4CSharp": { + "type": "Project", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp": "[4.14.0, )" + } + } + } + } +} \ No newline at end of file From 0bd8c8e32bc2b7ca3d52ea2d31bb29812927dfed Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 12:24:49 -0700 Subject: [PATCH 02/27] docs(T1): mark T1 done (d3dd17f); record open decisions C1/C2 for Mr. Das Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 07a8007..1659ff7 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** Planning +**Status:** In progress — S1 complete (T1 landed `d3dd17f`); awaiting Mr. Das on C1/C2 (see Notes). S2 next. ## Requirements @@ -42,7 +42,7 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | # | Slice | Task | Status | Commit | |-----|-------|------|--------|--------| -| T1 | S1 | Repo/solution scaffolding: `crap4csharp.sln`, `src/Crap4CSharp` (Exe, net8.0, Roslyn ref), `tests/Crap4CSharp.Tests` (xUnit + coverlet + FA 7.x), import shared `.targets`; empty build + `dotnet test` run | Pending | - | +| T1 | S1 | Repo/solution scaffolding: `crap4csharp.sln`, `src/Crap4CSharp` (Exe, net8.0, Roslyn ref), `tests/Crap4CSharp.Tests` (xUnit + coverlet + FA 7.x), import shared `.targets`; empty build + `dotnet test` run | Done | `d3dd17f` | | T2 | S2 | Domain types: `CliMode`, `CliArguments`, `CoverageData`(+`CoveragePercent`), `MethodDescriptor`(+`TypeName`), `MethodMetrics` | Pending | - | | T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Pending | - | | T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Pending | - | @@ -100,3 +100,22 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an - This feature file + `docs/decisions.md` are seeded on `master`. Implementation runs on `vibe/crap4csharp-port`; JARVIS creates that branch and drives T1→T16 via Dave/Bhaskar, with Anders review per task. + +### Open decisions for Mr. Das (surfaced at S1 boundary — non-blocking for S2) + +- **C1 — Assembly structure.** T1 scaffolds one production assembly (`src/Crap4CSharp`) that already + references Roslyn; S2's pure core and S3's adapters would co-locate there, so the "core has no + ecosystem deps" rule is convention/review-enforced, not compiler-enforced. Anders recommends + **Option A** (single assembly, mirrors crap4java's single module, YAGNI); a later Core/Adapters + split is a mechanical file-move (no rework risk). Alternative: **Option B** (physical + Core/Adapters/Exe split, compile-time enforcement, diverges from Java shape). *Awaiting ruling.* +- **C2 — Test-naming under CA1707.** `AnalysisLevel=latest-all` + Release warnings-as-errors makes + CA1707 an error, so underscores in member names (incl. the `Method_State_Expected` xUnit style) + fail the build. Anders recommends **keep CA1707 on everywhere → PascalCase test names** + (parity tests mirror Java *behavior*, not names). Alternative: **suppress CA1707 for `tests/**`** + to allow `Method_State_Expected`. *Awaiting ruling; once decided, JARVIS records the convention in + `.github/copilot-instructions.md` per guardrail #10.* +- **C3 (process note).** The throwaway `ScaffoldingSanityTests.cs` is deleted by whichever task first + adds real tests to `Crap4CSharp.Tests` (not carried to T16). +- **C4 (deferred trivia).** If ever packed as a `dotnet tool`, set `ToolCommandName=crap4csharp` so the + CLI invocation name matches the contract. From 545c00040355b8e72607f73a75ae58f367249aa1 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 12:57:15 -0700 Subject: [PATCH 03/27] T2: port domain types (S2) Faithful C# port of the five crap4java domain types into the pure core: CliMode (PascalCase enum), CliArguments (IReadOnlyList), CoverageData (+exact CoveragePercent, total==0 -> 0.0), MethodDescriptor (+TypeName, the approved coverage-key-FQN departure), MethodMetrics (Double -> double?). Adds CoverageDataTests (ratio + total==0) and removes the throwaway scaffolding sanity test per decision C3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Crap4CSharp/CliArguments.cs | 5 +++++ src/Crap4CSharp/CliMode.cs | 12 +++++++++++ src/Crap4CSharp/CoverageData.cs | 14 +++++++++++++ src/Crap4CSharp/MethodDescriptor.cs | 8 ++++++++ src/Crap4CSharp/MethodMetrics.cs | 10 ++++++++++ tests/Crap4CSharp.Tests/CoverageDataTests.cs | 20 +++++++++++++++++++ .../ScaffoldingSanityTests.cs | 14 ------------- 7 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 src/Crap4CSharp/CliArguments.cs create mode 100644 src/Crap4CSharp/CliMode.cs create mode 100644 src/Crap4CSharp/CoverageData.cs create mode 100644 src/Crap4CSharp/MethodDescriptor.cs create mode 100644 src/Crap4CSharp/MethodMetrics.cs create mode 100644 tests/Crap4CSharp.Tests/CoverageDataTests.cs delete mode 100644 tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs diff --git a/src/Crap4CSharp/CliArguments.cs b/src/Crap4CSharp/CliArguments.cs new file mode 100644 index 0000000..fa369ea --- /dev/null +++ b/src/Crap4CSharp/CliArguments.cs @@ -0,0 +1,5 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CliArguments record. Java's List fileArgs becomes +// IReadOnlyList per the ratified "IReadOnlyList returns" idiom. +public record CliArguments(CliMode Mode, IReadOnlyList FileArgs); diff --git a/src/Crap4CSharp/CliMode.cs b/src/Crap4CSharp/CliMode.cs new file mode 100644 index 0000000..1dd6faa --- /dev/null +++ b/src/Crap4CSharp/CliMode.cs @@ -0,0 +1,12 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CliMode enum. Java's SCREAMING_SNAKE_CASE constants +// (HELP/ALL_SRC/CHANGED_SRC/EXPLICIT_FILES) are renamed to idiomatic PascalCase (CA1707 forbids +// underscores) — same behavior, C# spelling. +public enum CliMode +{ + Help, + AllSrc, + ChangedSrc, + ExplicitFiles, +} diff --git a/src/Crap4CSharp/CoverageData.cs b/src/Crap4CSharp/CoverageData.cs new file mode 100644 index 0000000..b8df2bb --- /dev/null +++ b/src/Crap4CSharp/CoverageData.cs @@ -0,0 +1,14 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CoverageData record. The Java field names MissedInstructions/ +// CoveredInstructions are kept verbatim for structural parity. NOTE (risk R1): under Cobertura +// (this port) these carry LINE counters, not JaCoCo INSTRUCTION counters — the algorithm is +// identical but the absolute numbers differ. +public record CoverageData(int MissedInstructions, int CoveredInstructions) +{ + // Ports coveragePercent() exactly: 0.0 when there is nothing to cover, else the covered share. + public double CoveragePercent => + MissedInstructions + CoveredInstructions == 0 + ? 0.0 + : (CoveredInstructions * 100.0) / (MissedInstructions + CoveredInstructions); +} diff --git a/src/Crap4CSharp/MethodDescriptor.cs b/src/Crap4CSharp/MethodDescriptor.cs new file mode 100644 index 0000000..02e6639 --- /dev/null +++ b/src/Crap4CSharp/MethodDescriptor.cs @@ -0,0 +1,8 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's MethodDescriptor record. Name/StartLine/EndLine/Complexity are the +// original Java fields. TypeName is the one approved departure (appended last): the enclosing +// type's namespace-qualified FQN, which C# needs as the per-method coverage-lookup key because a +// C# file may declare many types (see docs/decisions.md "coverage key = enclosing-type FQN"). +// Populated later (T9/T11); defined here for downstream tasks. +public record MethodDescriptor(string Name, int StartLine, int EndLine, int Complexity, string TypeName); diff --git a/src/Crap4CSharp/MethodMetrics.cs b/src/Crap4CSharp/MethodMetrics.cs new file mode 100644 index 0000000..13400cc --- /dev/null +++ b/src/Crap4CSharp/MethodMetrics.cs @@ -0,0 +1,10 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's MethodMetrics record. Java's boxed Double coveragePercent/crapScore +// (nullable for the N/A cases) become double?; int complexity stays non-null. +public record MethodMetrics( + string MethodName, + string ClassName, + int Complexity, + double? CoveragePercent, + double? CrapScore); diff --git a/tests/Crap4CSharp.Tests/CoverageDataTests.cs b/tests/Crap4CSharp.Tests/CoverageDataTests.cs new file mode 100644 index 0000000..228a586 --- /dev/null +++ b/tests/Crap4CSharp.Tests/CoverageDataTests.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Crap4CSharp.Tests; + +public class CoverageDataTests +{ + [Fact] + public void CoveragePercentReturnsCoveredShareOfTotal() + { + var coverage = new CoverageData(MissedInstructions: 1, CoveredInstructions: 3); + + coverage.CoveragePercent.Should().Be(75.0); + } + + [Fact] + public void CoveragePercentReturnsZeroWhenNothingToCover() + { + var coverage = new CoverageData(MissedInstructions: 0, CoveredInstructions: 0); + + coverage.CoveragePercent.Should().Be(0.0); + } +} diff --git a/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs b/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs deleted file mode 100644 index dda4c61..0000000 --- a/tests/Crap4CSharp.Tests/ScaffoldingSanityTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Microsoft.Crap4CSharp.Tests; - -/// -/// Throwaway scaffolding test proving the xUnit + FluentAssertions harness discovers and runs. -/// A later task replaces this with real behavioral tests. -/// -public class ScaffoldingSanityTests -{ - [Fact] - public void HarnessDiscoversAndRuns() - { - true.Should().BeTrue(); - } -} From bc87843bdb8587ce52206b0e80a34667200188f4 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 12:58:17 -0700 Subject: [PATCH 04/27] docs(T2): mark T2 done (545c000); record C1/C2 conventions and S2 watch-items W1-W5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 13 ++++++++ docs/features/crap4csharp-port.md | 53 +++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a15b0f4..e7b295f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,3 +52,16 @@ locked decisions, and the task plan live in `docs/decisions.md` and `docs/featur not numerically comparable to `crap4java` on such constructs. - **Coverage key** by Roslyn enclosing-type FQN; **nullable** reference types enabled; **InvariantCulture** + explicit `\n` in the report. + +## Ratified conventions (Mr. Das) + +- **Single production assembly.** All production code lives in one assembly, `src/Crap4CSharp` + (mirrors `crap4java`'s single module). The pure core must not depend on the ecosystem adapters + (Roslyn/Cobertura/process/git); this "core has no ecosystem dependencies" rule is enforced by + **review and folder/namespace discipline**, not a compile-time project boundary. No + Core/Adapters/Exe split unless Mr. Das later rules otherwise. +- **No underscores in member names (CA1707).** Analyzers run `latest-all` with warnings-as-errors in + Release, so CA1707 is fatal. All member names — **including xUnit test methods** — must be + PascalCase with no underscores (e.g. `HarnessDiscoversAndRuns`, not `Method_State_Expected`). + Parity tests preserve `crap4java`'s **behavior**, not its **names**; renaming a ported test to + PascalCase is not a fidelity break. diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 1659ff7..fc140dd 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — S1 complete (T1 landed `d3dd17f`); awaiting Mr. Das on C1/C2 (see Notes). S2 next. +**Status:** In progress — S2: T2 done (`545c000`). Domain types landed; T3/T4/T5 next. ## Requirements @@ -43,7 +43,7 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | # | Slice | Task | Status | Commit | |-----|-------|------|--------|--------| | T1 | S1 | Repo/solution scaffolding: `crap4csharp.sln`, `src/Crap4CSharp` (Exe, net8.0, Roslyn ref), `tests/Crap4CSharp.Tests` (xUnit + coverlet + FA 7.x), import shared `.targets`; empty build + `dotnet test` run | Done | `d3dd17f` | -| T2 | S2 | Domain types: `CliMode`, `CliArguments`, `CoverageData`(+`CoveragePercent`), `MethodDescriptor`(+`TypeName`), `MethodMetrics` | Pending | - | +| T2 | S2 | Domain types: `CliMode`, `CliArguments`, `CoverageData`(+`CoveragePercent`), `MethodDescriptor`(+`TypeName`), `MethodMetrics` | Done | `545c000` | | T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Pending | - | | T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Pending | - | | T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Pending | - | @@ -101,21 +101,40 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `vibe/crap4csharp-port`; JARVIS creates that branch and drives T1→T16 via Dave/Bhaskar, with Anders review per task. -### Open decisions for Mr. Das (surfaced at S1 boundary — non-blocking for S2) - -- **C1 — Assembly structure.** T1 scaffolds one production assembly (`src/Crap4CSharp`) that already - references Roslyn; S2's pure core and S3's adapters would co-locate there, so the "core has no - ecosystem deps" rule is convention/review-enforced, not compiler-enforced. Anders recommends - **Option A** (single assembly, mirrors crap4java's single module, YAGNI); a later Core/Adapters - split is a mechanical file-move (no rework risk). Alternative: **Option B** (physical - Core/Adapters/Exe split, compile-time enforcement, diverges from Java shape). *Awaiting ruling.* -- **C2 — Test-naming under CA1707.** `AnalysisLevel=latest-all` + Release warnings-as-errors makes - CA1707 an error, so underscores in member names (incl. the `Method_State_Expected` xUnit style) - fail the build. Anders recommends **keep CA1707 on everywhere → PascalCase test names** - (parity tests mirror Java *behavior*, not names). Alternative: **suppress CA1707 for `tests/**`** - to allow `Method_State_Expected`. *Awaiting ruling; once decided, JARVIS records the convention in - `.github/copilot-instructions.md` per guardrail #10.* +### Decisions ruled by Mr. Das at S1 boundary + +- **C1 — Assembly structure → Option A (RULED).** Keep the single production assembly + `src/Crap4CSharp`; "core has no ecosystem deps" is enforced by review/discipline, not a compile-time + boundary. No Core/Adapters/Exe split now. Recorded in `.github/copilot-instructions.md`. +- **C2 — Test-naming under CA1707 → Option A (RULED).** Keep CA1707 on everywhere → PascalCase test + names (no underscores); parity tests mirror Java *behavior*, not names. Recorded in + `.github/copilot-instructions.md`. - **C3 (process note).** The throwaway `ScaffoldingSanityTests.cs` is deleted by whichever task first - adds real tests to `Crap4CSharp.Tests` (not carried to T16). + adds real tests to `Crap4CSharp.Tests` (not carried to T16). *(Done in T2.)* + +### Carry-forward watch-items (from Anders's T2 review — must be honored at the noted task) + +- **W1 — CoverageData field naming → for Mr. Das before T10.** Anders recommends renaming + `MissedInstructions`/`CoveredInstructions` → `MissedLines`/`CoveredLines` (accurate for the + line-counter Cobertura adapter; the JaCoCo "instruction" concept has no referent here). It's a + semantic departure driven by the adapter swap, so it belongs in the departure register — a soft + escalation. **Not on the T3/T4/T5 path; only T10 constructs `CoverageData`.** To be ruled at the + S2 boundary. +- **W2 — T5 must decompose `CliArguments` assertions.** C# records use *reference* equality on the + `IReadOnlyList FileArgs` member (unlike Java's structural `List.equals`), so T5 tests must + assert `Mode` and `FileArgs` **separately** (`FileArgs.Should().Equal(...)`), never whole-record + equality — else a false parity break. (Mirrors how `crap4java`'s `CliArgumentsParserTest` + decomposes.) +- **W3 — T9 populates `MethodDescriptor.TypeName` at construction.** Set it from the Roslyn + enclosing-type symbol at parse time; no empty/placeholder value. Use named args at the call site + (`Name` and `TypeName` are both `string` → transposition risk). +- **W4 — `null → "N/A"` invariant.** T4 (`ReportFormatter`) and T11 (`CrapAnalyzer`) must render a + missing coverage/crap value as `N/A`; never let a default `0.0` leak in for "method absent from + report" (that would report 0% as a real measurement). +- **W5 — Test-namespace nesting (convention).** Test classes live in `Microsoft.Crap4CSharp.Tests` + (nested under the production `Microsoft.Crap4CSharp`), so production types resolve in tests with **no + explicit `using`**; global usings exist only for `Xunit` + `FluentAssertions`. This is load-bearing + for test authoring and reinforces the flat-namespace decision (keep domain types flat, no `Domain` + namespace). - **C4 (deferred trivia).** If ever packed as a `dotnet tool`, set `ToolCommandName=crap4csharp` so the CLI invocation name matches the contract. From 47c1a1c0b7c1362372fa3e6df073131b555c5dc2 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 13:10:30 -0700 Subject: [PATCH 05/27] T3: port CrapScore + parity oracles (S2) Faithful port of crap4java CrapScore (final class + private ctor -> public static class): CRAP = CC^2*(1-coverage)^3 + CC, null coverage -> null. CrapScoreTests carries all four Java oracles with identical tolerances (5.0/30.0/18.648/null). Cube via multiplication (not Math.Pow) preserves bit-identical results vs the Java oracle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Crap4CSharp/CrapScore.cs | 20 ++++++++++++++++ tests/Crap4CSharp.Tests/CrapScoreTests.cs | 28 +++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/Crap4CSharp/CrapScore.cs create mode 100644 tests/Crap4CSharp.Tests/CrapScoreTests.cs diff --git a/src/Crap4CSharp/CrapScore.cs b/src/Crap4CSharp/CrapScore.cs new file mode 100644 index 0000000..b1eb0ac --- /dev/null +++ b/src/Crap4CSharp/CrapScore.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CrapScore utility. Java's non-instantiable `final class` with a +// private ctor maps to the idiomatic C# `static class`. CRAP = CC² · (1 − coverage)³ + CC, where +// coverage = coveragePercent / 100. Returns null when coverage is unknown (Java boxed `Double`). +public static class CrapScore +{ + // Ports calculate(int, Double) exactly. + public static double? Calculate(int complexity, double? coveragePercent) + { + if (coveragePercent is null) + { + return null; + } + + double cc = complexity; + double uncovered = 1.0 - (coveragePercent.Value / 100.0); + return (cc * cc * uncovered * uncovered * uncovered) + cc; + } +} diff --git a/tests/Crap4CSharp.Tests/CrapScoreTests.cs b/tests/Crap4CSharp.Tests/CrapScoreTests.cs new file mode 100644 index 0000000..86216dd --- /dev/null +++ b/tests/Crap4CSharp.Tests/CrapScoreTests.cs @@ -0,0 +1,28 @@ +namespace Microsoft.Crap4CSharp.Tests; + +public class CrapScoreTests +{ + [Fact] + public void ReturnsComplexityWhenFullyCovered() + { + CrapScore.Calculate(5, 100.0).Should().BeApproximately(5.0, 0.0001); + } + + [Fact] + public void ReturnsCcSquaredPlusCcWhenUncovered() + { + CrapScore.Calculate(5, 0.0).Should().BeApproximately(30.0, 0.0001); + } + + [Fact] + public void ComputesPartialCoverage() + { + CrapScore.Calculate(8, 45.0).Should().BeApproximately(18.648, 0.01); + } + + [Fact] + public void ReturnsNullForUnknownCoverage() + { + CrapScore.Calculate(3, null).Should().BeNull(); + } +} From f830f0e92552fb7213f3be132345ceb290e7d313 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 13:49:38 -0700 Subject: [PATCH 06/27] T4: port ReportFormatter + golden/stability tests (S2) Faithful port of crap4java ReportFormatter: title/header/separator(88)/data rows, stable sort (scored before N/A, higher CRAP first), formatCoverage/formatCrap N/A handling. Approved determinism departures applied: InvariantCulture everywhere (CA1305) and explicit \n for ALL terminators (replacing Java's platform %n in data rows). Tests: golden exact-match, sort-order, and a stability guard using a 17-entry N/A tie-set (List.Sort is a stable insertion sort at <=16, unstable at >=17, so the guard genuinely bites). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Crap4CSharp/ReportFormatter.cs | 77 ++++++++++++ .../Crap4CSharp.Tests/ReportFormatterTests.cs | 112 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/Crap4CSharp/ReportFormatter.cs create mode 100644 tests/Crap4CSharp.Tests/ReportFormatterTests.cs diff --git a/src/Crap4CSharp/ReportFormatter.cs b/src/Crap4CSharp/ReportFormatter.cs new file mode 100644 index 0000000..a15e0a3 --- /dev/null +++ b/src/Crap4CSharp/ReportFormatter.cs @@ -0,0 +1,77 @@ +namespace Microsoft.Crap4CSharp; + +using System.Globalization; +using System.Text; + +// Faithful port of crap4java's ReportFormatter. Java's non-instantiable `final class` with a private +// ctor maps to the idiomatic C# `static class`. Renders the CRAP report: a title, a header row, a +// separator, then one row per method, sorted so scored methods (highest CRAP first) precede the N/A +// ones. Approved departures: every line terminator is an explicit '\n' and all formatting uses +// InvariantCulture, so the report is byte-for-byte deterministic across platforms and locales. +public static class ReportFormatter +{ + // Ports format(List) exactly. + public static string Format(IReadOnlyList entries) + { + // Java's List.sort(Comparator.comparing(...).thenComparing(...)) is a STABLE sort; C#'s + // List.Sort is NOT, so use LINQ OrderBy/ThenBy (stable) to preserve input order among ties. + // Scored entries (CrapScore not null -> false) sort before N/A (null -> true); among scored, + // negating the score puts the highest CRAP first. + List sorted = + [ + .. entries + .OrderBy(e => e.CrapScore is null) + .ThenBy(e => e.CrapScore is null ? 0.0 : -e.CrapScore.Value), + ]; + + string header = string.Format( + CultureInfo.InvariantCulture, + "{0,-30} {1,-35} {2,4} {3,7} {4,8}", + "Method", + "Class", + "CC", + "Cov%", + "CRAP"); + string separator = new string('-', header.Length); + + StringBuilder builder = new(); + builder.Append("CRAP Report\n"); + builder.Append("===========\n"); + builder.Append(header).Append('\n'); + builder.Append(separator).Append('\n'); + + foreach (MethodMetrics entry in sorted) + { + builder.Append(string.Format( + CultureInfo.InvariantCulture, + "{0,-30} {1,-35} {2,4} {3,7} {4,8}\n", + entry.MethodName, + entry.ClassName, + entry.Complexity, + FormatCoverage(entry.CoveragePercent), + FormatCrap(entry.CrapScore))); + } + + return builder.ToString(); + } + + private static string FormatCoverage(double? coverage) + { + if (coverage is null) + { + return " N/A "; + } + + return string.Format(CultureInfo.InvariantCulture, "{0,5:F1}%", coverage.Value); + } + + private static string FormatCrap(double? score) + { + if (score is null) + { + return " N/A"; + } + + return string.Format(CultureInfo.InvariantCulture, "{0,8:F1}", score.Value); + } +} diff --git a/tests/Crap4CSharp.Tests/ReportFormatterTests.cs b/tests/Crap4CSharp.Tests/ReportFormatterTests.cs new file mode 100644 index 0000000..6e8415a --- /dev/null +++ b/tests/Crap4CSharp.Tests/ReportFormatterTests.cs @@ -0,0 +1,112 @@ +namespace Microsoft.Crap4CSharp.Tests; + +using System.Globalization; + +public class ReportFormatterTests +{ + [Fact] + public void FormatsExactReportWithScoresAndNaValues() + { + var scored = new MethodMetrics("foo", "demo.Sample", 3, 85.0, 4.5); + var unknown = new MethodMetrics("bar", "demo.Sample", 2, null, null); + + string report = ReportFormatter.Format([scored, unknown]); + + // Build the expected report exactly as crap4java's ReportFormatterTest does: derive the header + // and separator via the same format string, then assemble the six lines. Every terminator is an + // explicit '\n' (the approved determinism departure) so this must NOT be a raw/verbatim string + // literal -- this repo is CRLF, which would embed '\r\n' and mismatch the '\n' output. + string header = string.Format( + CultureInfo.InvariantCulture, + "{0,-30} {1,-35} {2,4} {3,7} {4,8}", + "Method", + "Class", + "CC", + "Cov%", + "CRAP"); + string separator = new string('-', header.Length); + string expected = + "CRAP Report\n" + + "===========\n" + + header + "\n" + + separator + "\n" + + string.Format( + CultureInfo.InvariantCulture, + "{0,-30} {1,-35} {2,4} {3,7} {4,8}\n", + "foo", + "demo.Sample", + 3, + "85.0%", + "4.5") + + string.Format( + CultureInfo.InvariantCulture, + "{0,-30} {1,-35} {2,4} {3,7} {4,8}\n", + "bar", + "demo.Sample", + 2, + " N/A ", + " N/A"); + + report.Should().Be(expected); + } + + [Fact] + public void SortsScoredEntriesAheadOfNaEntriesAndHigherScoresFirst() + { + var lowerScore = new MethodMetrics("low", "demo.Sample", 2, 100.0, 2.0); + var unknown = new MethodMetrics("unknown", "demo.Sample", 2, null, null); + var higherScore = new MethodMetrics("high", "demo.Sample", 5, 10.0, 9.0); + + string report = ReportFormatter.Format([lowerScore, unknown, higherScore]); + + report.IndexOf("high", StringComparison.Ordinal) + .Should().BeLessThan(report.IndexOf("low", StringComparison.Ordinal)); + report.IndexOf("low", StringComparison.Ordinal) + .Should().BeLessThan(report.IndexOf("unknown", StringComparison.Ordinal)); + } + + [Fact] + public void PreservesInputOrderAmongEqualKeyedEntries() + { + // Beyond-parity guard (guardrail #8): pins the STABLE sort -- equal-keyed entries must keep + // their input order. NOTE: .NET's List.Sort uses a *stable* insertion sort for <=16 items, + // so a handful of tied entries would NOT expose an unstable regression; List.Sort only + // diverges once its introspective quicksort path engages (>16 items). We therefore use 17 tied + // N/A entries (verified to reorder under List.Sort) in a deliberately non-alphabetical input + // order, so this guard actually fails if the LINQ OrderBy/ThenBy is regressed to List.Sort. + + // Same-CRAP-score tie: both keys equal (CC=5 fully covered => CRAP=CC=5.0). Input "ttt","kkk". + var scoredFirst = new MethodMetrics("ttt", "demo.Sample", 5, 100.0, 5.0); + var scoredSecond = new MethodMetrics("kkk", "demo.Sample", 5, 100.0, 5.0); + + // Full tie: every entry is N/A (CrapScore null => primary key true, secondary 0.0). Scrambled. + string[] naOrder = + [ + "z07", "z00", "z14", "z03", "z11", "z05", "z16", "z01", "z09", + "z13", "z02", "z15", "z06", "z12", "z04", "z10", "z08", + ]; + + MethodMetrics[] entries = + [ + scoredFirst, + scoredSecond, + .. naOrder.Select(name => new MethodMetrics(name, "demo.Sample", 3, null, null)), + ]; + + string report = ReportFormatter.Format(entries); + + // Same-score tie: "ttt" must stay ahead of "kkk" (not reordered, not alphabetized). + report.IndexOf("ttt", StringComparison.Ordinal) + .Should().BeLessThan(report.IndexOf("kkk", StringComparison.Ordinal)); + + // Full N/A tie: the report must list the entries in exactly the (non-alphabetical) input order, + // i.e. each IndexOf strictly increases. An unstable sort would break this monotonic chain. + int previousIndex = -1; + foreach (string name in naOrder) + { + int index = report.IndexOf(name, StringComparison.Ordinal); + index.Should().BeGreaterThan(previousIndex); + previousIndex = index; + } + } +} From 1b7833b407ce7529a07c74c96e642e2c2eae7338 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 14:10:02 -0700 Subject: [PATCH 07/27] T5: CliArgumentsParser with 7 parity tests Port crap4java CliArgumentsParser: precedence empty->AllSrc, --help wins, --changed (throws ArgumentException if combined with files)->ChangedSrc, else->ExplicitFiles. Ordinal comparisons; unknown --flags ignored. IllegalArgumentException->ArgumentException (verbatim message preserved). CA1062-forced ArgumentNullException.ThrowIfNull(args) guard: behavior- preserving fail-fast analog of Java NPE-on-null. 7 tests assert Mode and FileArgs separately (W2: C# records do reference equality on IReadOnlyList). 16 tests green, 0/0 Release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Crap4CSharp/CliArgumentsParser.cs | 75 +++++++++++++++++++ .../CliArgumentsParserTests.cs | 66 ++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/Crap4CSharp/CliArgumentsParser.cs create mode 100644 tests/Crap4CSharp.Tests/CliArgumentsParserTests.cs diff --git a/src/Crap4CSharp/CliArgumentsParser.cs b/src/Crap4CSharp/CliArgumentsParser.cs new file mode 100644 index 0000000..de27623 --- /dev/null +++ b/src/Crap4CSharp/CliArgumentsParser.cs @@ -0,0 +1,75 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CliArgumentsParser. Java's non-instantiable `final class` with a +// private ctor maps to the idiomatic C# `static class`. Precedence is preserved exactly: no args → +// AllSrc; --help wins over everything; --changed → ChangedSrc (rejecting file args); otherwise the +// non-flag args become an ExplicitFiles list. String comparisons are ordinal to match Java's +// String.equals / String.startsWith. +public static class CliArgumentsParser +{ + // Ports parse(String[]) exactly. + public static CliArguments Parse(string[] args) + { + // Java implicitly throws NullPointerException on `args.length` when args is null; the + // idiomatic C# equivalent (and CA1062's required guard, since suppressions are banned) is a + // fail-fast ArgumentNullException. Behavior is otherwise identical to crap4java's parse. + ArgumentNullException.ThrowIfNull(args); + + if (args.Length == 0) + { + return new CliArguments(CliMode.AllSrc, []); + } + + if (ContainsFlag(args, "--help")) + { + return new CliArguments(CliMode.Help, []); + } + + bool changed = ContainsFlag(args, "--changed"); + IReadOnlyList values = NonFlagArgs(args); + EnsureChangedIsNotCombined(changed, values); + if (changed) + { + return new CliArguments(CliMode.ChangedSrc, []); + } + + return new CliArguments(CliMode.ExplicitFiles, [.. values]); + } + + private static bool ContainsFlag(string[] args, string flag) + { + foreach (string arg in args) + { + if (string.Equals(arg, flag, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static List NonFlagArgs(string[] args) + { + List values = []; + foreach (string arg in args) + { + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + values.Add(arg); + } + + return values; + } + + private static void EnsureChangedIsNotCombined(bool changed, IReadOnlyList values) + { + if (changed && values.Count > 0) + { + throw new ArgumentException("--changed cannot be combined with file arguments"); + } + } +} diff --git a/tests/Crap4CSharp.Tests/CliArgumentsParserTests.cs b/tests/Crap4CSharp.Tests/CliArgumentsParserTests.cs new file mode 100644 index 0000000..df0e09c --- /dev/null +++ b/tests/Crap4CSharp.Tests/CliArgumentsParserTests.cs @@ -0,0 +1,66 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Faithful port of crap4java's CliArgumentsParserTest. Records do reference (not structural) equality +// on the IReadOnlyList FileArgs member, so Mode and FileArgs are asserted separately rather +// than comparing whole CliArguments values. Java demo/*.java fixtures are adapted to C#-idiomatic +// src/Demo/*.cs paths; the parser is extension-agnostic, so the verified behavior is identical. +public class CliArgumentsParserTests +{ + [Fact] + public void NoArgsMeansAllSrcFiles() + { + var result = CliArgumentsParser.Parse([]); + + result.Mode.Should().Be(CliMode.AllSrc); + } + + [Fact] + public void ChangedFlagMeansChangedSrcFiles() + { + var result = CliArgumentsParser.Parse(["--changed"]); + + result.Mode.Should().Be(CliMode.ChangedSrc); + } + + [Fact] + public void FileNamesMeanExplicitFiles() + { + var result = CliArgumentsParser.Parse(["src/Demo/A.cs", "src/Demo/B.cs"]); + + result.Mode.Should().Be(CliMode.ExplicitFiles); + result.FileArgs.Should().Equal("src/Demo/A.cs", "src/Demo/B.cs"); + } + + [Fact] + public void UnknownFlagsAreIgnoredWhenCollectingExplicitFiles() + { + var result = CliArgumentsParser.Parse(["src/Demo/A.cs", "--bogus", "src/Demo/B.cs"]); + + result.Mode.Should().Be(CliMode.ExplicitFiles); + result.FileArgs.Should().Equal("src/Demo/A.cs", "src/Demo/B.cs"); + } + + [Fact] + public void HelpPrintsUsageMode() + { + var result = CliArgumentsParser.Parse(["--help"]); + + result.Mode.Should().Be(CliMode.Help); + } + + [Fact] + public void ChangedCannotBeCombinedWithFiles() + { + FluentActions.Invoking(() => CliArgumentsParser.Parse(["--changed", "src/Demo/A.cs"])) + .Should().Throw(); + } + + [Fact] + public void PlainFilesDoNotTriggerChangedMode() + { + var result = CliArgumentsParser.Parse(["src/Demo/A.cs"]); + + result.Mode.Should().Be(CliMode.ExplicitFiles); + result.FileArgs.Should().Equal("src/Demo/A.cs"); + } +} From d28070c6cfc6eb8303acaba339282fe1552e6ce0 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 14:12:23 -0700 Subject: [PATCH 08/27] docs: S2 boundary bookkeeping (T3/T4/T5 done, watch-items W6-W10) Update task table: T3 (47c1a1c), T4 (f830f0e), T5 (1b7833b) -> Done. Status line: S2 complete, 16 tests green, paused at boundary. Mark W2 done (T5 honored it). Add Anders's T5 watch-items W6-W10 for the T14/T15 CLI seam. Record open decision D-T5 (CA1062 ThrowIfNull idiom) for Mr. Das's ruling before S3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 40 +++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index fc140dd..ec8a27e 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — S2: T2 done (`545c000`). Domain types landed; T3/T4/T5 next. +**Status:** In progress — **S2 complete** (T2 `545c000`, T3 `47c1a1c`, T4 `f830f0e`, T5 `1b7833b`); pure core landed, 16 tests green, 0/0 Release. Paused at S2 boundary for Mr. Das's steer before S3. ## Requirements @@ -44,9 +44,9 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d |-----|-------|------|--------|--------| | T1 | S1 | Repo/solution scaffolding: `crap4csharp.sln`, `src/Crap4CSharp` (Exe, net8.0, Roslyn ref), `tests/Crap4CSharp.Tests` (xUnit + coverlet + FA 7.x), import shared `.targets`; empty build + `dotnet test` run | Done | `d3dd17f` | | T2 | S2 | Domain types: `CliMode`, `CliArguments`, `CoverageData`(+`CoveragePercent`), `MethodDescriptor`(+`TypeName`), `MethodMetrics` | Done | `545c000` | -| T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Pending | - | -| T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Pending | - | -| T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Pending | - | +| T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Done | `47c1a1c` | +| T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Done | `f830f0e` | +| T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Done | `1b7833b` | | T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Pending | - | | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Pending | - | | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Pending | - | @@ -124,7 +124,7 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `IReadOnlyList FileArgs` member (unlike Java's structural `List.equals`), so T5 tests must assert `Mode` and `FileArgs` **separately** (`FileArgs.Should().Equal(...)`), never whole-record equality — else a false parity break. (Mirrors how `crap4java`'s `CliArgumentsParserTest` - decomposes.) + decomposes.) *(Done in T5 — tests assert `Mode` + `FileArgs` separately; Bhaskar + Anders confirmed.)* - **W3 — T9 populates `MethodDescriptor.TypeName` at construction.** Set it from the Roslyn enclosing-type symbol at parse time; no empty/placeholder value. Use named args at the call site (`Name` and `TypeName` are both `string` → transposition risk). @@ -138,3 +138,33 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an namespace). - **C4 (deferred trivia).** If ever packed as a `dotnet tool`, set `ToolCommandName=crap4csharp` so the CLI invocation name matches the contract. + +### Carry-forward watch-items (from Anders's T5 review — for the T14/T15 CLI seam) + +- **W6 — T14 catches `ArgumentException`, not narrower.** Java `parseArguments` catches + `IllegalArgumentException` → prints `ex.Message` to **stderr** + usage → **exit 1**. Faithful port: + `catch (ArgumentException)`. This also catches the CA1062 `ArgumentNullException` subtype (unreachable, + same routing) — do **not** add a separate narrower `catch (ArgumentNullException)`. +- **W7 — Help short-circuits before file resolution, exit 0.** Java handles `HELP` inside + `parseArguments` (print usage, exit 0) *before* `filesForMode`. Mirror in T14. Use a `switch` + expression over all four `CliMode` arms with a `default` throw so a future enum value fails loud. +- **W8 — `AllSrc`/`ChangedSrc`/`Help` carry empty `FileArgs` by design.** Files for those modes come + from downstream (`SourceFileFinder` T7 / `ChangedFileDetector` T8), **not** from `FileArgs`. T14 must + not read empty `FileArgs` as "nothing to do." +- **W9 — Unknown-flag tolerance is contract.** `--bogus` is silently dropped by the `--` filter (parity + test). Downstream must **never** re-validate/reject unknown flags. Only `--changed` + non-flag files is + an error — and `--help` wins over `--changed` (checked first), so `--help --changed foo.cs` → `Help`, + not a throw. +- **W10 — The `--changed` combined-args message is a user-facing contract.** Because `parseArguments` + prints `ex.Message`, T14 should assert **stderr** contains `--changed cannot be combined with file + arguments`. The parser tests correctly assert only the exception *type* (parity); the message + assertion belongs at the T14 integration seam. + +### Open decision for Mr. Das (from T5, design-lane) + +- **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** Public API surfaces carry analyzer-forced + null guards (suppressions banned). Java's implicit NPE-on-null becomes a typed `ArgumentNullException` + (⊂ `ArgumentException`) — behavior-preserving, fail-fast-consistent, unreachable on the real + `Main(string[])` path. Anders + Bhaskar lean: log **one** standing-policy line in `docs/decisions.md` + (the pattern will recur across T6–T16), or declare it below the departure threshold. **Ruling needed + before S3** so the idiom is settled project-wide. (JARVIS does not edit `decisions.md` unilaterally.) From 9b16ab36f46ae1b57fd3f85310f0cb6732e22d60 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 14:20:31 -0700 Subject: [PATCH 09/27] docs: repoint playbook to decisions.md; move C1/C2; add root AGENTS.md pointer Slim .github/copilot-instructions.md to a lean agent-process playbook: remove the duplicated Fidelity contract (CRAP formula/threshold/departures) and the Ratified conventions (C1/C2) sections; replace with a one-line pointer to docs/decisions.md. Refine guardrail #10 to split durable project-design facts (-> decisions.md) from agent-process facts (-> .github/agents/), never global Copilot Memory. decisions.md becomes the single source of truth for design: make the CRAP formula CC^2*(1-coverage)^3+CC and threshold 8.0 explicit (new CRAP formula section, previously only implied); add C1 (single production assembly) and C2 (CA1707/PascalCase test names) as ratified conventions; 3 approved departures retained. Add thin root AGENTS.md pointer for cross-tool discovery. Zero design duplication across files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 44 +++++++++------------------------ AGENTS.md | 1 + docs/decisions.md | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 33 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e7b295f..d7aa23e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,9 +7,8 @@ Address the human as **Mr. Das** (an alt of Iron Man), "Sir", or something simil ## What this repo is `crap4csharp` is a high-fidelity C# port of the Java tool `crap4java` (read-only sibling at -`../crap4java`). It is a CRAP-metric analyzer for **C# projects**: Roslyn for parsing + cyclomatic -complexity, Coverlet → Cobertura for coverage, `dotnet test` / MSBuild as the driver. Design intent, -locked decisions, and the task plan live in `docs/decisions.md` and `docs/features/.md`. +`../crap4java`) — a CRAP-metric analyzer for **C# projects**. Project design (intent, locked choices, +the CRAP formula) lives in `docs/decisions.md`; per-feature plans in `docs/features/.md`. ## Golden rules (guardrails) @@ -31,37 +30,16 @@ locked decisions, and the task plan live in `docs/decisions.md` and `docs/featur 5. Never deploy. 6. Stop and ask when a task needs a product/architecture decision. That call belongs to Mr. Das. 7. Mr. Das can invoke any agent on demand. -8. Tests are fidelity-first: every `crap4java` test has a faithful C# counterpart asserting the same - behavior. Beyond parity, add fine-grained unit tests for business logic and integration tests only +8. Fidelity-first: preserve `crap4java`'s behavior, and give every `crap4java` test a faithful C# + counterpart asserting the same behavior — except where `docs/decisions.md` records an approved + departure. Beyond parity, add fine-grained unit tests for business logic and integration tests only for critical paths — don't overdo it. Avoid timing-sensitive tests. 9. Never use the `internal` access modifier on any C# construct — use the least-privilege alternative; if it is a must, flag it. -10. Record durable facts in the relevant `.github/agents/.md` (or this file if cross-cutting), - not global Copilot Memory. +10. Record durable facts by kind: project-design decisions/conventions → `docs/decisions.md`; + agent-process facts → the relevant `.github/agents/.md` (or this playbook if + cross-cutting). Never global Copilot Memory. -## Fidelity contract (this port) - -- Preserve `crap4java`'s class decomposition, CRAP formula (`CC² · (1 − coverage)³ + CC`, threshold - `8.0`), CLI contract, report format, and exit codes. Adapt only the ecosystem adapters (Java parser - → Roslyn, JaCoCo → Cobertura, Maven → dotnet). -- **Approved deliberate departures** from the Java tool (see `docs/decisions.md`): - - **Fail fast** on no-tests-run / no-coverage-produced for a module → non-zero exit (not the Java - warn-and-continue with `N/A`). Per-method `N/A` is unchanged. - - **Richer cyclomatic complexity** — counts switch-expression arms, `??`/`??=`, pattern - `and`/`or`/`not`, and every `when` guard, in addition to the Java decision set. CC is therefore - not numerically comparable to `crap4java` on such constructs. - - **Coverage key** by Roslyn enclosing-type FQN; **nullable** reference types enabled; - **InvariantCulture** + explicit `\n` in the report. - -## Ratified conventions (Mr. Das) - -- **Single production assembly.** All production code lives in one assembly, `src/Crap4CSharp` - (mirrors `crap4java`'s single module). The pure core must not depend on the ecosystem adapters - (Roslyn/Cobertura/process/git); this "core has no ecosystem dependencies" rule is enforced by - **review and folder/namespace discipline**, not a compile-time project boundary. No - Core/Adapters/Exe split unless Mr. Das later rules otherwise. -- **No underscores in member names (CA1707).** Analyzers run `latest-all` with warnings-as-errors in - Release, so CA1707 is fatal. All member names — **including xUnit test methods** — must be - PascalCase with no underscores (e.g. `HarnessDiscoversAndRuns`, not `Method_State_Expected`). - Parity tests preserve `crap4java`'s **behavior**, not its **names**; renaming a ported test to - PascalCase is not a fidelity break. +Project design — the CRAP formula, approved departures, the complexity node set, and ratified +conventions (assembly structure, test naming) — lives in `docs/decisions.md`; reload it before +working. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5bd4d7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +Refer `.github/copilot-instructions.md`. diff --git a/docs/decisions.md b/docs/decisions.md index 0349296..bee82b1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -12,6 +12,18 @@ Locked decisions for the C# port of `crap4java`. Source of truth alongside `docs - **Test fidelity:** every `crap4java` test gets a faithful C# counterpart with identical verification — except where an approved deliberate departure changes it. +## CRAP formula + +Preserved verbatim from `crap4java`. For a method with cyclomatic complexity `CC` and coverage +fraction `coverage ∈ [0, 1]`: + +- **Formula:** `CRAP = CC² · (1 − coverage)³ + CC` +- **Threshold:** a method is flagged **crappy** when `CRAP > 8.0`. + +The formula and threshold are unchanged from `crap4java`; only `CC` is computed with the augmented +node set below (an approved departure), so absolute CRAP scores are not numerically comparable to +`crap4java` on code using the modern constructs. + ## Locked choices | Area | Decision | Notes | @@ -26,6 +38,19 @@ Locked decisions for the C# port of `crap4java`. Source of truth alongside `docs | Coverage key | Roslyn enclosing-type **FQN** per method | C# allows many types per file; filename keys mis-match Cobertura | | Module root | nearest **`.sln`** (fallback `.csproj` → project root) | so `dotnet test` actually runs tests | +## Ratified conventions (Mr. Das) + +- **C1 — Single production assembly.** All production code lives in one assembly, `src/Crap4CSharp` + (mirrors `crap4java`'s single module). The pure core must not depend on the ecosystem adapters + (Roslyn/Cobertura/process/git); this "core has no ecosystem dependencies" rule is enforced by + **review and folder/namespace discipline**, not a compile-time project boundary. No + Core/Adapters/Exe split unless Mr. Das later rules otherwise. +- **C2 — No underscores in member names (CA1707).** Analyzers run `latest-all` with + warnings-as-errors in Release, so CA1707 is fatal on **all** member names — **including xUnit test + methods** (e.g. `HarnessDiscoversAndRuns`, not `Method_State_Expected`). Parity tests preserve + `crap4java`'s **behavior, not its names**; PascalCase-renaming a ported test is not a fidelity + break. + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a From b46d0733868e1630fb444222873e59578bda19ce Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 14:50:39 -0700 Subject: [PATCH 10/27] W1: rename CoverageData fields -> MissedLines/CoveredLines Mr. Das ruled W1 = RENAME. Drop JaCoCo instruction terminology: this port's coverage adapter is Coverlet -> Cobertura LINE counters. Rename record fields MissedInstructions/CoveredInstructions -> MissedLines/CoveredLines and reframe the doc comment; CoveragePercent math byte-identical, 16/16 tests green, 0/0 Release. Record as departure #4 in docs/decisions.md (naming counterpart to R1, not a new behavioral departure). Feature doc: mark W1 resolved, add T10 counter-derivation watch-item W11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 7 +++++ docs/features/crap4csharp-port.md | 27 ++++++++++++-------- src/Crap4CSharp/CoverageData.cs | 14 +++++----- tests/Crap4CSharp.Tests/CoverageDataTests.cs | 4 +-- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index bee82b1..429165f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -61,6 +61,13 @@ node set below (an approved departure), so absolute CRAP scores are not numerica numerically comparable** to `crap4java` on code using the modern constructs. 3. **Determinism/idiom** — nullable reference types enabled; `InvariantCulture` + explicit `"\n"` in the report. +4. **Line-based coverage field names** — `CoverageData`'s counter fields are renamed from crap4java's + `missedInstructions`/`coveredInstructions` to `MissedLines`/`CoveredLines` (and the record's doc + comment reframed to match). This port's coverage adapter is Coverlet → **Cobertura line** counters, + not JaCoCo `INSTRUCTION` counters, so "instruction" terminology has no referent here. This is the + **naming counterpart** to the already-approved coverage-granularity change (risk **R1**; see the + Coverage row under "## Locked choices") — **not** a new behavioral departure: the `CoveragePercent` + algorithm is byte-identical modulo the rename, and observable metrics/CRAP outputs are unaffected. ## Cyclomatic complexity — authoritative node set diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index ec8a27e..71bfc01 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -114,12 +114,11 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an ### Carry-forward watch-items (from Anders's T2 review — must be honored at the noted task) -- **W1 — CoverageData field naming → for Mr. Das before T10.** Anders recommends renaming +- **W1 — CoverageData field naming.** *(RESOLVED — Mr. Das ruled RENAME.)* Fields renamed `MissedInstructions`/`CoveredInstructions` → `MissedLines`/`CoveredLines` (accurate for the - line-counter Cobertura adapter; the JaCoCo "instruction" concept has no referent here). It's a - semantic departure driven by the adapter swap, so it belongs in the departure register — a soft - escalation. **Not on the T3/T4/T5 path; only T10 constructs `CoverageData`.** To be ruled at the - S2 boundary. + line-counter Cobertura adapter; the JaCoCo "instruction" concept has no referent here). Recorded as + **departure #4** in `docs/decisions.md`; landed in the W1 rename commit (Dave code + Anders register + line, Bhaskar-verified 16/16). - **W2 — T5 must decompose `CliArguments` assertions.** C# records use *reference* equality on the `IReadOnlyList FileArgs` member (unlike Java's structural `List.equals`), so T5 tests must assert `Mode` and `FileArgs` **separately** (`FileArgs.Should().Equal(...)`), never whole-record @@ -159,12 +158,20 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an prints `ex.Message`, T14 should assert **stderr** contains `--changed cannot be combined with file arguments`. The parser tests correctly assert only the exception *type* (parity); the message assertion belongs at the T14 integration seam. +- **W11 — T10 must derive line counters, not read them ready-made (Anders, W1 review).** Cobertura + emits per-line `hits`, not aggregate missed/covered per method. `CoberturaCoverageParser` (T10) must + compute, within each method's line span, `CoveredLines = count(lines with hits > 0)` and + `MissedLines = count(lines with hits == 0)` (missed = valid − covered). The rename makes the intent + explicit but does not perform the mapping — keep it on T10's plate alongside the FQN-normalization pin + (R3). ### Open decision for Mr. Das (from T5, design-lane) -- **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** Public API surfaces carry analyzer-forced - null guards (suppressions banned). Java's implicit NPE-on-null becomes a typed `ArgumentNullException` +- **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(PENDING — Mr. Das asked to clarify + before ruling; does NOT block S3.)* Public API surfaces carry analyzer-forced null guards + (suppressions banned). Java's implicit NPE-on-null becomes a typed `ArgumentNullException` (⊂ `ArgumentException`) — behavior-preserving, fail-fast-consistent, unreachable on the real - `Main(string[])` path. Anders + Bhaskar lean: log **one** standing-policy line in `docs/decisions.md` - (the pattern will recur across T6–T16), or declare it below the departure threshold. **Ruling needed - before S3** so the idiom is settled project-wide. (JARVIS does not edit `decisions.md` unilaterally.) + `Main(string[])` path. Dave continues writing the CA1062-required guards exactly as analyzers demand + (code is identical either way). The ONLY open item is whether to add a **standing-policy line** to + `docs/decisions.md`; JARVIS will not write it until Mr. Das rules. (JARVIS does not edit + `decisions.md` unilaterally.) diff --git a/src/Crap4CSharp/CoverageData.cs b/src/Crap4CSharp/CoverageData.cs index b8df2bb..29791d5 100644 --- a/src/Crap4CSharp/CoverageData.cs +++ b/src/Crap4CSharp/CoverageData.cs @@ -1,14 +1,14 @@ namespace Microsoft.Crap4CSharp; -// Faithful port of crap4java's CoverageData record. The Java field names MissedInstructions/ -// CoveredInstructions are kept verbatim for structural parity. NOTE (risk R1): under Cobertura -// (this port) these carry LINE counters, not JaCoCo INSTRUCTION counters — the algorithm is -// identical but the absolute numbers differ. -public record CoverageData(int MissedInstructions, int CoveredInstructions) +// Faithful port of crap4java's CoverageData record, with the counter fields renamed to +// MissedLines/CoveredLines (previously JaCoCo INSTRUCTION-named): this port's Cobertura adapter +// counts LINES, not JaCoCo INSTRUCTIONs (deliberate departure per docs/decisions.md; risk R1). +// Algorithm is identical. +public record CoverageData(int MissedLines, int CoveredLines) { // Ports coveragePercent() exactly: 0.0 when there is nothing to cover, else the covered share. public double CoveragePercent => - MissedInstructions + CoveredInstructions == 0 + MissedLines + CoveredLines == 0 ? 0.0 - : (CoveredInstructions * 100.0) / (MissedInstructions + CoveredInstructions); + : (CoveredLines * 100.0) / (MissedLines + CoveredLines); } diff --git a/tests/Crap4CSharp.Tests/CoverageDataTests.cs b/tests/Crap4CSharp.Tests/CoverageDataTests.cs index 228a586..e5150cc 100644 --- a/tests/Crap4CSharp.Tests/CoverageDataTests.cs +++ b/tests/Crap4CSharp.Tests/CoverageDataTests.cs @@ -5,7 +5,7 @@ public class CoverageDataTests [Fact] public void CoveragePercentReturnsCoveredShareOfTotal() { - var coverage = new CoverageData(MissedInstructions: 1, CoveredInstructions: 3); + var coverage = new CoverageData(MissedLines: 1, CoveredLines: 3); coverage.CoveragePercent.Should().Be(75.0); } @@ -13,7 +13,7 @@ public void CoveragePercentReturnsCoveredShareOfTotal() [Fact] public void CoveragePercentReturnsZeroWhenNothingToCover() { - var coverage = new CoverageData(MissedInstructions: 0, CoveredInstructions: 0); + var coverage = new CoverageData(MissedLines: 0, CoveredLines: 0); coverage.CoveragePercent.Should().Be(0.0); } From 9f28cdbad6e09166b5aa66c8f169258b0f330b24 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 15:10:28 -0700 Subject: [PATCH 11/27] docs: close D-T5 (below-threshold); log open decision D-T9 Mr. Das ruled D-T5 = below-threshold: no standing-policy line for the CA1062 ThrowIfNull idiom; treat as non-noteworthy. Record D-T9 (open, escalated): drop sourcePath/sourceUri javac plumbing + adopt Parse(string source); blocks T9 pending ruling. Anders recommends Option A (drop both). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 71bfc01..46f8c69 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -167,11 +167,15 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an ### Open decision for Mr. Das (from T5, design-lane) -- **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(PENDING — Mr. Das asked to clarify - before ruling; does NOT block S3.)* Public API surfaces carry analyzer-forced null guards - (suppressions banned). Java's implicit NPE-on-null becomes a typed `ArgumentNullException` - (⊂ `ArgumentException`) — behavior-preserving, fail-fast-consistent, unreachable on the real - `Main(string[])` path. Dave continues writing the CA1062-required guards exactly as analyzers demand - (code is identical either way). The ONLY open item is whether to add a **standing-policy line** to - `docs/decisions.md`; JARVIS will not write it until Mr. Das rules. (JARVIS does not edit - `decisions.md` unilaterally.) +- **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled + BELOW-THRESHOLD.)* The analyzer-forced null guards are a trivial idiomatic adaptation, **not** a + logged departure: **no** standing-policy line is added to `docs/decisions.md`. Dave continues writing + the CA1062-required guards exactly as before (code unchanged); Bhaskar/Anders treat them as + non-noteworthy going forward. +- **D-T9 — Parser plumbing / signature (non-1:1).** *(OPEN — escalated to Mr. Das.)* Drop + `JavaMethodParser.sourcePath`/`sourceUri` and their 2 tests (`buildsSourcePathAndUriFromClassNames`, + `acceptsClassNamesWithJavaSuffix`) as javac file-URI plumbing with no Roslyn referent, and adopt + `Parse(string source)` (drops the vestigial `className`). Anders recommends **Option A (drop both)**; + the 7 remaining Java parser tests port as faithful behavioral counterparts. **Blocks T9** (parser + signature + test set depend on the ruling). Design-lane FYIs (Anders, vetoable): collection scope = + methods only; expression-bodied methods included; nested-type class-name format is a T10 pin. From 1b90d24280dbca9693277f336214521973774199 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 15:45:56 -0700 Subject: [PATCH 12/27] T7: port SourceFileFinder (src/*.cs, exclude bin/obj, ordinal sort) Faithful port of crap4java SourceFileFinder: resolve projectRoot/src, empty on missing, recursive walk, EndsWith(".cs") not a glob (mirrors Java endsWith, dodges Windows 8.3 quirk), absolute paths. Two approved C# adaptations per the T7 plan: ordinal sort (StringComparer.Ordinal) for cross-OS determinism, and segment-exact bin/obj build-output exclusion (any depth; keeps Robin lookalike). 5 tests: faithful port + missing-src + bin/obj-exclusion + ordinal-order + only-.cs. 21/21 green, 0/0 Release. Design docs (Anders): decisions.md departure #3 extended (ordinal sort), new departure #5 (bin/obj exclusion, load-bearing since T12 repopulates bin/obj), C2 clarification (CA1707 scope = externally-visible + test methods; private fields use _camelCase). Feature file watch-items W12/W13/W14. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 29 ++++- docs/features/crap4csharp-port.md | 18 +++ src/Crap4CSharp/SourceFileFinder.cs | 63 ++++++++++ .../SourceFileFinderTests.cs | 116 ++++++++++++++++++ 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/Crap4CSharp/SourceFileFinder.cs create mode 100644 tests/Crap4CSharp.Tests/SourceFileFinderTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index 429165f..98c686a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -50,6 +50,18 @@ node set below (an approved departure), so absolute CRAP scores are not numerica methods** (e.g. `HarnessDiscoversAndRuns`, not `Method_State_Expected`). Parity tests preserve `crap4java`'s **behavior, not its names**; PascalCase-renaming a ported test is not a fidelity break. + - **C2 clarification (T7 review; Bhaskar-verified build behavior).** CA1707 targets only + **externally-visible** identifiers (public/protected) — which includes public xUnit test methods — + so **private fields are outside its scope and build clean with or without a leading underscore**. + The `.editorconfig` moreover *configures* `_camelCase` for private fields + (`dotnet_naming_rule.private_members_with_underscore`) and deliberately disables StyleCop `SA1309` + (the anti-underscore rule), so the **intended, build-safe form for a private field is `_camelCase`** + (e.g. T9 `ComplexityWalker`'s complexity counter → `_complexity`). That naming rule is surfaced by + IDE1006, which is not enforced during `dotnet build` (a known Roslyn limitation), so it is never a + Release error either way — but `_camelCase` is both the configured style and future-proof. Net: C2's + "no underscores … on **all** member names" governs the **CA1707 surface** (externally-visible + members + public test methods), **not** private fields; do not avoid a private field for fear of a + CA1707 conflict. ## Deliberate departures from crap4java (approved by Mr. Das) @@ -60,7 +72,11 @@ node set below (an approved departure), so absolute CRAP scores are not numerica 2. **Richer cyclomatic complexity** — augmented Roslyn node set (below). CC is therefore **not numerically comparable** to `crap4java` on code using the modern constructs. 3. **Determinism/idiom** — nullable reference types enabled; `InvariantCulture` + explicit `"\n"` in - the report. + the report; `StringComparer.Ordinal` sort in `SourceFileFinder` **in place of** Java's + platform-dependent `Comparator.naturalOrder()` over `Path` (case-insensitive on Windows, + case-sensitive on Unix), so source-file discovery order is identical across OSes. Below the + behavioral-departure bar (the final report is keyed/sorted downstream, not by discovery order); logged + here only as the determinism counterpart to the `InvariantCulture`/`"\n"` choices. 4. **Line-based coverage field names** — `CoverageData`'s counter fields are renamed from crap4java's `missedInstructions`/`coveredInstructions` to `MissedLines`/`CoveredLines` (and the record's doc comment reframed to match). This port's coverage adapter is Coverlet → **Cobertura line** counters, @@ -68,6 +84,17 @@ node set below (an approved departure), so absolute CRAP scores are not numerica **naming counterpart** to the already-approved coverage-granularity change (risk **R1**; see the Coverage row under "## Locked choices") — **not** a new behavioral departure: the `CoveragePercent` algorithm is byte-identical modulo the rename, and observable metrics/CRAP outputs are unaffected. +5. **Exclude C# build output** — `SourceFileFinder` omits any file under a path segment named exactly + `bin` or `obj` (case-sensitive, segment-exact, at **any** depth beneath `src`; `Robin`/`object` are + kept). Java's Maven `src` tree never holds build output (that lives under `target/`), so the Java + finder needed no such filter; in C#, `bin`/`obj` are the analog of `target/` and contain + compiler-generated `.cs` (`GlobalUsings.g.cs`, `AssemblyInfo.cs`, `.AssemblyAttributes.cs`, + source-generator output). This filter is **load-bearing**: crap4csharp itself runs `dotnet test` + (T12), which populates `bin`/`obj`, so without it generated methods would enter the analysis set and + skew CRAP. This is a **departure that affects the analysis set** (unlike #3), yet it is faithful to + Java's *intent* (analyze human-authored `src`). It deliberately does **not** exclude checked-in + generated files (`*.g.cs`/`*.Designer.cs`) that live under normal source folders — Java analyzed + generated `.java` under `src` too, so broadening the filter would break parity. ## Cyclomatic complexity — authoritative node set diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 46f8c69..048e3a7 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -165,6 +165,24 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an explicit but does not perform the mapping — keep it on T10's plate alongside the FQN-normalization pin (R3). +### Carry-forward watch-items (from Anders's T7 review — must be honored at the noted task) + +- **W12 — AllSrc + directory-arg expansion must route through `SourceFileFinder` (T14).** Java calls + `findAllJavaFilesUnderSrc` in **two** places: `ALL_SRC` mode and expanding an explicit **directory** + argument (`CliApplication.explicitFiles`). T14 must send both through `SourceFileFinder` (not a + re-enumeration), or the bin/obj build-output exclusion won't hold end-to-end. +- **W13 — Generated-code skew watch (T9/T11/T12).** The finder excludes `bin`/`obj` but intentionally + **keeps** checked-in `*.g.cs`/`*.Designer.cs` under normal source folders (fidelity — Java analyzed + generated `.java` under `src` too). Because T12 runs `dotnet test`, which **repopulates** `bin`/`obj`, + confirm T9/T11 consume the finder's filtered list so freshly-generated build output never reaches the + parser and skews CRAP. If checked-in generated files later prove to distort scores, escalate as a + product call — do **not** silently broaden the exclusion (that breaks Java parity). Recorded as + departure #5 in `docs/decisions.md`. +- **W14 — Private fields use `_camelCase` (T9).** `ComplexityWalker` needs a private mutable complexity + counter; make it `_complexity` (or similar `_camelCase`). It is CA1707-exempt and Release-clean, and + `_camelCase` is the `.editorconfig`-configured style. See the **C2 clarification** in + `docs/decisions.md`; don't repeat T7's avoidance of a private field. + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled diff --git a/src/Crap4CSharp/SourceFileFinder.cs b/src/Crap4CSharp/SourceFileFinder.cs new file mode 100644 index 0000000..18331a5 --- /dev/null +++ b/src/Crap4CSharp/SourceFileFinder.cs @@ -0,0 +1,63 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's SourceFileFinder. Java's non-instantiable `final class` with a private +// ctor maps to the idiomatic C# `static class`. Resolves projectRoot/src; if it does not exist, returns +// an empty list; otherwise it recursively enumerates every file and keeps those whose path ends with +// ".cs" -- an EndsWith test, NOT a "*.cs" glob, to mirror Java's endsWith(".java") exactly and to dodge +// the Windows 8.3 quirk where a "*.cs" glob can also match longer extensions (e.g. ".csproj"). Results +// are sorted with StringComparer.Ordinal for cross-platform determinism. +// +// One deliberate C# departure from Java (Java's src has no build output): files living under any +// directory segment named exactly "bin" or "obj" (case-sensitive, at any depth) are excluded, so +// generated build output never pollutes the analysis. Absolute paths are returned, mirroring the +// absolute java.nio.Paths the original produced. +public static class SourceFileFinder +{ + // Ports findAllJavaFilesUnderSrc(Path), adapted to C# ".cs" files plus the bin/obj exclusion. + public static IReadOnlyList FindAllCSharpFilesUnderSrc(string projectRoot) + { + // Java implicitly throws NullPointerException on projectRoot.resolve(...); the idiomatic C# + // equivalent (and CA1062's required guard, since suppressions are banned) is a fail-fast + // ArgumentNullException. Behavior is otherwise identical to crap4java's finder. + ArgumentNullException.ThrowIfNull(projectRoot); + + string src = Path.Combine(projectRoot, "src"); + if (!Directory.Exists(src)) + { + return []; + } + + return + [ + .. Directory + .EnumerateFiles(src, "*", SearchOption.AllDirectories) + .Where(file => file.EndsWith(".cs", StringComparison.Ordinal)) + .Where(file => !IsUnderBuildOutput(src, file)) + .OrderBy(file => file, StringComparer.Ordinal), + ]; + } + + // True when the file lives under a directory segment named exactly "bin" or "obj" (case-sensitive, + // at any depth beneath src). The relative path is decomposed into real directory segments -- never a + // naive substring test -- so lookalikes such as "Robin" or "object" are NOT excluded. + private static bool IsUnderBuildOutput(string src, string file) + { + string relative = Path.GetRelativePath(src, file) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + string[] segments = relative.Split( + Path.DirectorySeparatorChar, + StringSplitOptions.RemoveEmptyEntries); + + foreach (string segment in segments) + { + if (string.Equals(segment, "bin", StringComparison.Ordinal) + || string.Equals(segment, "obj", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/tests/Crap4CSharp.Tests/SourceFileFinderTests.cs b/tests/Crap4CSharp.Tests/SourceFileFinderTests.cs new file mode 100644 index 0000000..078a0af --- /dev/null +++ b/tests/Crap4CSharp.Tests/SourceFileFinderTests.cs @@ -0,0 +1,116 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Faithful port of crap4java's SourceFileFinderTest. The one Java test (findsAllJavaFilesUnderSrcOnly) +// is ported as FindsCSharpFilesUnderSrcOnly, with the demo/*.java tree adapted to src/Demo/*.cs. The +// remaining tests pin the C# adaptations the port introduces: bin/obj build-output exclusion, the +// ordinal (not culture) sort, the missing-src empty result, and the EndsWith(".cs") extension filter. +// Each test runs against a fresh temp project root that is always removed in a finally. +public class SourceFileFinderTests +{ + [Fact] + public void FindsCSharpFilesUnderSrcOnly() + { + WithTempProjectRoot(projectRoot => + { + string inSrc = WriteFile(projectRoot, "src", "Demo", "Sample.cs"); + WriteFile(projectRoot, "other", "Elsewhere.cs"); + + IReadOnlyList files = SourceFileFinder.FindAllCSharpFilesUnderSrc(projectRoot); + + files.Should().Equal(inSrc); + }); + } + + [Fact] + public void ReturnsEmptyWhenSrcDirectoryMissing() + { + WithTempProjectRoot(projectRoot => + { + Directory.CreateDirectory(projectRoot); + + IReadOnlyList files = SourceFileFinder.FindAllCSharpFilesUnderSrc(projectRoot); + + files.Should().BeEmpty(); + }); + } + + [Fact] + public void ExcludesBinAndObjOutputButKeepsSegmentLookalikes() + { + WithTempProjectRoot(projectRoot => + { + string real = WriteFile(projectRoot, "src", "App", "Real.cs"); + WriteFile(projectRoot, "src", "App", "bin", "Debug", "Generated.cs"); + WriteFile(projectRoot, "src", "App", "obj", "Sample.g.cs"); + string notExcluded = WriteFile(projectRoot, "src", "Robin", "NotExcluded.cs"); + + IReadOnlyList files = SourceFileFinder.FindAllCSharpFilesUnderSrc(projectRoot); + + // Ordinal order: "...src\App\Real.cs" precedes "...src\Robin\NotExcluded.cs" ('A' < 'R'). + // Only these two survive: the bin/ and obj/ files are dropped, while "Robin" (which contains + // the substring "bin") is kept -- proving the match is segment-exact, not Contains("bin"). + files.Should().Equal(real, notExcluded); + }); + } + + [Fact] + public void OrdersResultsByOrdinalComparison() + { + WithTempProjectRoot(projectRoot => + { + // These names sort differently under ordinal vs. culture rules: ordinal places every + // upper-case letter (B=66, Z=90) ahead of lower-case (a=97) -> [Banana, Zebra, apple]; an + // invariant-culture sort would interleave case -> [apple, Banana, Zebra]. Asserting the + // ordinal order pins the StringComparer.Ordinal determinism guarantee. + string apple = WriteFile(projectRoot, "src", "apple.cs"); + string banana = WriteFile(projectRoot, "src", "Banana.cs"); + string zebra = WriteFile(projectRoot, "src", "Zebra.cs"); + + IReadOnlyList files = SourceFileFinder.FindAllCSharpFilesUnderSrc(projectRoot); + + files.Should().Equal(banana, zebra, apple); + }); + } + + [Fact] + public void KeepsOnlyCSharpFiles() + { + WithTempProjectRoot(projectRoot => + { + string source = WriteFile(projectRoot, "src", "App", "Program.cs"); + WriteFile(projectRoot, "src", "App", "README.md"); + + // ".csproj" ends with "proj", not ".cs", so EndsWith(".cs") excludes it -- this also guards + // the Windows 8.3 quirk where a "*.cs" glob could wrongly match longer extensions. + WriteFile(projectRoot, "src", "App", "App.csproj"); + + IReadOnlyList files = SourceFileFinder.FindAllCSharpFilesUnderSrc(projectRoot); + + files.Should().Equal(source); + }); + } + + private static void WithTempProjectRoot(Action test) + { + string projectRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + test(projectRoot); + } + finally + { + if (Directory.Exists(projectRoot)) + { + Directory.Delete(projectRoot, recursive: true); + } + } + } + + private static string WriteFile(string projectRoot, params string[] relativeSegments) + { + string path = Path.Combine(projectRoot, Path.Combine(relativeSegments)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "// test fixture\n"); + return path; + } +} From da020285baed87ff0c5e067ec0bd178759532abf Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 16:09:47 -0700 Subject: [PATCH 13/27] T6: port command-execution seam (ICommandExecutor + ProcessCommandExecutor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithful port of crap4java CommandExecutor/ProcessCommandExecutor: sync int Run(IReadOnlyList command, string workingDirectory); launches command[0] with the rest via ArgumentList (UseShellExecute=false), sets the working directory, and returns the child ExitCode. Single test oracle exit==7, mirroring Java /bin/sh -c "exit 7". Ratified I-series "async stdout drain" idiom is the C# analog of Java .inheritIO(): both stdout+stderr are redirected and drained asynchronously (OutputDataReceived/ErrorDataReceived + BeginOutputReadLine/BeginErrorReadLine, written through to Console) with Start -> Begin*ReadLine -> WaitForExit ordering, so a full pipe buffer cannot deadlock and child output stays user-visible. ThrowIfNull guards are the below-threshold CA1062 idiom (D-T5). Test is cross-platform (Windows cmd.exe /c exit 7; else /bin/sh -c exit 7), identical exit-7 oracle both branches — ruled below-threshold by Anders (no departure). Anders added watch-item W15: ICommandExecutor is exit-code-only; T8 (git) keeps its own capturing process and must fix Java's latent full-pipe deadlock rather than reuse this seam. 22/22 tests green, 0 warnings / 0 errors in Release. Also backfills the task table: T6 Done, T7 Done (1b90d24). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 18 +++++- src/Crap4CSharp/ICommandExecutor.cs | 15 +++++ src/Crap4CSharp/ProcessCommandExecutor.cs | 63 +++++++++++++++++++ .../ProcessCommandExecutorTests.cs | 34 ++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 src/Crap4CSharp/ICommandExecutor.cs create mode 100644 src/Crap4CSharp/ProcessCommandExecutor.cs create mode 100644 tests/Crap4CSharp.Tests/ProcessCommandExecutorTests.cs diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 048e3a7..52d0761 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -47,8 +47,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Done | `47c1a1c` | | T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Done | `f830f0e` | | T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Done | `1b7833b` | -| T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Pending | - | -| T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Pending | - | +| T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `(next commit)` | +| T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Pending | - | | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Pending | - | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | @@ -183,6 +183,20 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `_camelCase` is the `.editorconfig`-configured style. See the **C2 clarification** in `docs/decisions.md`; don't repeat T7's avoidance of a private field. +### Carry-forward watch-items (from Anders's T6 review — must be honored at the noted task) + +- **W15 — `ICommandExecutor` is an exit-code-only seam; do NOT route git (T8) through it (T8/T12).** + In crap4java the `CommandExecutor` seam has exactly one consumer — `CoverageRunner` (T12) — which + needs only the child **exit code**. `ProcessCommandExecutor`'s async drain writes child output + **through to `Console.Out`/`Console.Error`** (the `inheritIO()` analog) and returns `int`; it does + **not** capture output. `ChangedFileDetector` (T8) deliberately uses its **own** process + (Java: `redirectErrorStream(true)` + `readAllBytes()`) to **capture** git's stdout for parsing. + Preserve that split: T8 captures stdout via its own `Process` and must read the stream to end + **before/while** awaiting exit (Java's `waitFor()`-then-read ordering is a latent full-pipe deadlock — + fix it in the port, don't copy it); do **not** reuse `ICommandExecutor` for git. T12 injects + `ICommandExecutor` (cf. `CoverageRunnerTest`'s `RecordingExecutor` fake) and consumes only the exit + code — large `dotnet test` output through the seam is safe (both streams drained → no deadlock). + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled diff --git a/src/Crap4CSharp/ICommandExecutor.cs b/src/Crap4CSharp/ICommandExecutor.cs new file mode 100644 index 0000000..0b676b0 --- /dev/null +++ b/src/Crap4CSharp/ICommandExecutor.cs @@ -0,0 +1,15 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CommandExecutor interface. Java's package-private +// `interface CommandExecutor { int run(List command, Path directory) throws Exception; }` +// maps 1:1 to this C# interface. Java's package-private visibility is unavailable in the single- +// assembly design where the `internal` modifier is banned (ratified decision C1), so the interface is +// `public`. Signature mapping: Java `List` -> `IReadOnlyList`; Java `Path directory` +// -> `string workingDirectory`; Java `throws Exception` -> exceptions propagate naturally (no C# +// throws clause). The method stays synchronous returning `int` to mirror Java's `run` exactly -- the +// async stdout drain is an implementation detail of ProcessCommandExecutor, not a signature change. +public interface ICommandExecutor +{ + // Ports run(List, Path): launches command in workingDirectory, returns the child exit code. + int Run(IReadOnlyList command, string workingDirectory); +} diff --git a/src/Crap4CSharp/ProcessCommandExecutor.cs b/src/Crap4CSharp/ProcessCommandExecutor.cs new file mode 100644 index 0000000..cc7f2a0 --- /dev/null +++ b/src/Crap4CSharp/ProcessCommandExecutor.cs @@ -0,0 +1,63 @@ +namespace Microsoft.Crap4CSharp; + +using System.Diagnostics; + +// Faithful port of crap4java's ProcessCommandExecutor. Java's `final class` maps to C# `sealed`. It +// launches `command` in `workingDirectory` and returns the child process's exit code. +// +// LOCKED IDIOM -- "async stdout drain" (docs/decisions.md, I-series adopt-now baseline). Java uses +// ProcessBuilder.inheritIO() so the child shares the parent's console. The ratified C# equivalent +// redirects stdout AND stderr, drains BOTH streams asynchronously, and writes them through to +// Console.Out / Console.Error: this preserves inheritIO's user-visible output while guaranteeing a +// full pipe buffer can never deadlock WaitForExit. +public sealed class ProcessCommandExecutor : ICommandExecutor +{ + // Ports run(List, Path) exactly. + public int Run(IReadOnlyList command, string workingDirectory) + { + // Java implicitly NPEs when dereferencing a null command/directory; the idiomatic C# equivalent + // (and CA1062's required guard, since suppressions are banned) is a fail-fast ArgumentNullException. + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(workingDirectory); + + ProcessStartInfo startInfo = new() + { + FileName = command[0], + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // command[0] is the executable; the remaining entries are arguments. Adding them to + // ArgumentList (not the single Arguments string) sidesteps platform quoting pitfalls. + for (int index = 1; index < command.Count; index++) + { + startInfo.ArgumentList.Add(command[index]); + } + + using var process = new Process { StartInfo = startInfo }; + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + Console.Out.WriteLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + Console.Error.WriteLine(e.Data); + } + }; + + // Order matters: Start(), then begin the async readers, then WaitForExit() -- the parameterless + // overload also blocks until both redirected streams have been fully drained. + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + return process.ExitCode; + } +} diff --git a/tests/Crap4CSharp.Tests/ProcessCommandExecutorTests.cs b/tests/Crap4CSharp.Tests/ProcessCommandExecutorTests.cs new file mode 100644 index 0000000..b4ed44e --- /dev/null +++ b/tests/Crap4CSharp.Tests/ProcessCommandExecutorTests.cs @@ -0,0 +1,34 @@ +namespace Microsoft.Crap4CSharp.Tests; + +using System.Runtime.InteropServices; + +// Faithful counterpart to crap4java's ProcessCommandExecutorTest. The single Java test +// (returnsExitCodeFromLaunchedProcess) launches a shell that exits 7 inside a @TempDir and asserts the +// returned exit code is 7. This port keeps the same single oracle (exit 7) and a real temp working +// directory; only the launch command is adapted per-OS because Java hardcodes the POSIX /bin/sh path, +// which cannot exist on Windows. +public class ProcessCommandExecutorTests +{ + [Fact] + public void ReturnsExitCodeFromLaunchedProcess() + { + string workingDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + try + { + // cross-platform: Java hardcodes /bin/sh; see T6 adaptation. Same oracle (exit 7) on both + // branches -- Windows runs cmd.exe locally, ubuntu CI exercises the /bin/sh branch. + IReadOnlyList command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ["cmd.exe", "/c", "exit 7"] + : ["/bin/sh", "-c", "exit 7"]; + + int exit = new ProcessCommandExecutor().Run(command, workingDirectory); + + exit.Should().Be(7); + } + finally + { + Directory.Delete(workingDirectory, recursive: true); + } + } +} From c023ef89537092f2de85e77adf2ff6231d6979b2 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 16:52:57 -0700 Subject: [PATCH 14/27] T8: port ChangedFileDetector (git status --porcelain change detection) Faithful port of crap4java ChangedFileDetector: ChangedCSharpFiles runs git -C status --porcelain, parses each porcelain line (substring(3).Trim -> RenameTarget -> .cs filter -> absolute normalized path), ordinal-sorts, and returns IReadOnlyList. ChangedCSharpFilesUnderSrc applies a segment-aware under-/src filter (Path.startsWith semantics, not a naive prefix). Public IsCandidateLine / RenameTarget helpers unit-tested. Non-zero git exit -> InvalidOperationException (Java IllegalStateException) whose message carries git's stderr (e.g. "not a git repository"). All 5 Java tests ported faithfully with a cross-platform RunGit setup helper (invokes git directly instead of Java's sh -c). Uses its OWN Process, not ICommandExecutor (W15: this is an exit-code-only seam; git needs captured stdout). Applies the W15 deadlock fix in prod + test: both StandardOutput/StandardError ReadToEndAsync start BEFORE WaitForExit, so pipes drain concurrently and cannot wedge -- deliberately NOT Java's waitFor()-then-readAllBytes() ordering. Adaptations (all confirmed by Anders as below-threshold / covered): ordinal sort under decisions.md departure #3 (now extended to name ChangedFileDetector); redirectErrorStream -> stdout+stderr merge for the error message; sh -c -> direct-git test-infra swap. Anders added W16 (T14 ChangedSrc must call the UnderSrc variant) and logged D-T8 (integration-trait / build-test.md doc-reality gap) as an OPEN decision for Mr. Das. 27/27 tests green (deterministic x2), 0 warnings / 0 errors in Release. Also backfills the task table: T6 da02028; T8 Done. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 10 +- docs/features/crap4csharp-port.md | 34 +++- src/Crap4CSharp/ChangedFileDetector.cs | 173 ++++++++++++++++++ .../ChangedFileDetectorTests.cs | 162 ++++++++++++++++ 4 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 src/Crap4CSharp/ChangedFileDetector.cs create mode 100644 tests/Crap4CSharp.Tests/ChangedFileDetectorTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index 98c686a..0448260 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -73,10 +73,12 @@ node set below (an approved departure), so absolute CRAP scores are not numerica numerically comparable** to `crap4java` on code using the modern constructs. 3. **Determinism/idiom** — nullable reference types enabled; `InvariantCulture` + explicit `"\n"` in the report; `StringComparer.Ordinal` sort in `SourceFileFinder` **in place of** Java's - platform-dependent `Comparator.naturalOrder()` over `Path` (case-insensitive on Windows, - case-sensitive on Unix), so source-file discovery order is identical across OSes. Below the - behavioral-departure bar (the final report is keyed/sorted downstream, not by discovery order); logged - here only as the determinism counterpart to the `InvariantCulture`/`"\n"` choices. + platform-dependent `Comparator.naturalOrder()` over `Path`, and the same `StringComparer.Ordinal` + sort in `ChangedFileDetector` **in place of** Java's `Path::compareTo` (both case-insensitive on + Windows, case-sensitive on Unix), so source-file discovery and changed-file order are identical + across OSes. Below the behavioral-departure bar (the final report is keyed/sorted downstream, not by + discovery order); logged here only as the determinism counterpart to the `InvariantCulture`/`"\n"` + choices. 4. **Line-based coverage field names** — `CoverageData`'s counter fields are renamed from crap4java's `missedInstructions`/`coveredInstructions` to `MissedLines`/`CoveredLines` (and the record's doc comment reframed to match). This port's coverage adapter is Coverlet → **Cobertura line** counters, diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 52d0761..6856c61 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -47,9 +47,9 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T3 | S2 | `CrapScore` + `CrapScoreTests` (oracles 5.0/30.0/18.648/null) | Done | `47c1a1c` | | T4 | S2 | `ReportFormatter` + golden `ReportFormatterTests` (InvariantCulture, `"\n"`) | Done | `f830f0e` | | T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Done | `1b7833b` | -| T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `(next commit)` | +| T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `da02028` | | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | -| T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Pending | - | +| T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `(next commit)` | | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Pending | - | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | @@ -197,6 +197,16 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `ICommandExecutor` (cf. `CoverageRunnerTest`'s `RecordingExecutor` fake) and consumes only the exit code — large `dotnet test` output through the seam is safe (both streams drained → no deadlock). +### Carry-forward watch-items (from Anders's T8 review — must be honored at the noted task) + +- **W16 — T14 `ChangedSrc` must call `ChangedCSharpFilesUnderSrc`, not `ChangedCSharpFiles`.** + `ChangedFileDetector` exposes **two** public entry points; Java's `CliApplication` + (`CHANGED_SRC -> changedJavaFilesUnderSrc`, `CliApplication.java:89`) uses the **src-filtered** + variant. T14's `ChangedSrc` arm must route through `ChangedCSharpFilesUnderSrc` (the segment-aware + under-`src` filter), never the unfiltered `ChangedCSharpFiles` — otherwise changed files outside + `src/` leak into the analysis set. Reinforces W8 (files for `ChangedSrc` come from the detector, not + `FileArgs`). + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled @@ -211,3 +221,23 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an the 7 remaining Java parser tests port as faithful behavioral counterparts. **Blocks T9** (parser signature + test set depend on the ruling). Design-lane FYIs (Anders, vetoable): collection scope = methods only; expression-bodied methods included; nested-type class-name format is a T10 pin. +- **D-T8 — Integration-test categorization / fast-loop scope (agentic-loop policy).** *(OPEN — + escalated to Mr. Das; JARVIS to route at the S3 boundary.)* `.github/skills/build-test.md` promises + the fast loop excludes integration tests via `--filter "Category!=Integration"`, but **no** test + carries that trait (0 grep matches), so the fast loop currently spawns real `git`/processes + (`ChangedFileDetectorTests`, `ProcessCommandExecutorTests`) — doc and code disagree. Two coherent + resolutions: **(a)** keep plain `[Fact]` and reword `build-test.md` to drop the "tests are tagged" + claim (collapses the two-tier loop — `build-test` and `build-test-full` then run the same set); + **(b)** add `[Trait("Category", "Integration")]` to `ProcessCommandExecutorTests` **and** + `ChangedFileDetectorTests` (and to every future process/CLI/`git`/`dotnet`-spawning test — + T12/T14/T15/T16), making the fast loop genuinely hermetic and the doc accurate as written. **Anders + recommends (b)** — it preserves the deliberate fast/full split (else `build-test-full` is redundant), + correctly categorizes tests that spawn external processes, and pays off as S4's integration tests + land; T6's plain-`[Fact]` `ProcessCommandExecutorTests` is then a gap to fix retroactively, not a + convention to propagate. This sets a **testing convention** governing future tasks and edits an + agentic-loop skill file, so it is Mr. Das's call, not Anders's design lane. **If (b):** Dave adds a + class-level `[Trait("Category", "Integration")]` to both test classes (no new `using` — `Xunit` is a + global using), and the convention is recorded in `docs/decisions.md` (Ratified conventions); no + `build-test.md` edit. **If (a):** replace `build-test.md`'s final paragraph with the no-tagging + reality (forward-looking: tag integration tests as they land). Either way the doc/reality gap must + close at the ratification point. diff --git a/src/Crap4CSharp/ChangedFileDetector.cs b/src/Crap4CSharp/ChangedFileDetector.cs new file mode 100644 index 0000000..5b148f4 --- /dev/null +++ b/src/Crap4CSharp/ChangedFileDetector.cs @@ -0,0 +1,173 @@ +namespace Microsoft.Crap4CSharp; + +using System.Diagnostics; + +// Faithful port of crap4java's ChangedFileDetector. Java's non-instantiable `final class` with a +// private ctor maps to the idiomatic C# `static class` (public because the `internal` modifier is +// banned in the single-assembly design -- ratified decision C1). It shells out to `git status +// --porcelain` and turns the porcelain lines into the set of changed C# source files. Java analyzed +// ".java"; this port analyzes ".cs" and mirrors SourceFileFinder's `CSharp` naming. +// +// Two ratified adaptations from the Java original (both behavior-preserving): +// * Deadlock-free capture (W15). Java calls waitFor() and only THEN readAllBytes() -- a latent +// full-pipe deadlock if git ever wrote more than a pipe buffer. In C# BOTH stream reads are +// started (asynchronously) BEFORE WaitForExit, so the pipes drain concurrently and can never +// wedge the child. +// * Merged error output. Java sets redirectErrorStream(true), so git's "fatal: not a git +// repository ..." message rides the captured stdout into the thrown exception. C# keeps stdout +// and stderr separate, then concatenates them (stdout + stderr) into the exception message to +// reproduce that behavior. Java's IllegalStateException maps to C# InvalidOperationException. +// +// Paths are returned as absolute, ordinal-sorted strings (Path.GetFullPath), consistent with +// SourceFileFinder and the cross-OS determinism departure recorded in docs/decisions.md (#3). +public static class ChangedFileDetector +{ + // Ports changedJavaFiles(Path): runs `git status --porcelain` in projectRoot and returns the + // absolute paths of every changed ".cs" file, ordinal-sorted. + public static IReadOnlyList ChangedCSharpFiles(string projectRoot) + { + // Java implicitly throws NullPointerException when dereferencing projectRoot; the idiomatic C# + // equivalent (and CA1062's required guard, since suppressions are banned) is a fail-fast + // ArgumentNullException. + ArgumentNullException.ThrowIfNull(projectRoot); + + ProcessStartInfo startInfo = new() + { + FileName = "git", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("-C"); + startInfo.ArgumentList.Add(projectRoot); + startInfo.ArgumentList.Add("status"); + startInfo.ArgumentList.Add("--porcelain"); + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + // W15: begin draining BOTH streams before waiting, so a full pipe buffer can never deadlock + // the child (this deliberately does NOT copy Java's wait-then-read ordering). + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + string stdout = outputTask.GetAwaiter().GetResult(); + string stderr = errorTask.GetAwaiter().GetResult(); + + if (process.ExitCode != 0) + { + // Merge stdout + stderr so git's diagnostic (which it writes to stderr) reaches the caller, + // reproducing Java's redirectErrorStream(true) behavior. + string combined = stdout + stderr; + throw new InvalidOperationException("git status failed: " + combined); + } + + // Mirror Java's split("\\R"): treat "\r\n", "\n" and "\r" as line breaks ("\r\n" first so it is + // consumed as a single terminator). RemoveEmptyEntries is intentionally NOT used -- the + // candidate-line check filters blanks. + string[] lineTerminators = ["\r\n", "\n", "\r"]; + List files = []; + foreach (string line in stdout.Split(lineTerminators, StringSplitOptions.None)) + { + string? file = ParseStatusLine(projectRoot, line); + if (file is not null) + { + files.Add(file); + } + } + + return [.. files.OrderBy(path => path, StringComparer.Ordinal)]; + } + + // Ports changedJavaFilesUnderSrc(Path): keeps only the changed files that live under + // /src. Java uses Path.startsWith, which is SEGMENT-based, so a naive string prefix + // would be wrong ("srcfoo" must NOT match "src"); the check below is segment-aware. + public static IReadOnlyList ChangedCSharpFilesUnderSrc(string projectRoot) + { + ArgumentNullException.ThrowIfNull(projectRoot); + + string src = Path.GetFullPath(Path.Combine(projectRoot, "src")); + return [.. ChangedCSharpFiles(projectRoot).Where(path => IsUnderSrc(src, path))]; + } + + // Ports isCandidateLine: null -> false; blank/whitespace-only -> false; otherwise true only when + // the line is at least four characters long (the two status chars + a space + at least one path + // char). + public static bool IsCandidateLine(string? line) + { + if (line is null) + { + return false; + } + + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + return line.Length >= 4; + } + + // Ports renameTarget: for a rename entry ("old -> new"), return the replacement/target side; when + // there is no " -> " marker, return the path unchanged. The search is Ordinal (Java's indexOf is a + // plain ordinal scan, unlike C#'s culture-sensitive default). + public static string RenameTarget(string pathPart) + { + ArgumentNullException.ThrowIfNull(pathPart); + + int index = pathPart.IndexOf(" -> ", StringComparison.Ordinal); + if (index < 0) + { + return pathPart; + } + + return pathPart.Substring(index + 4); + } + + // Ports parseStatusLine: skip the 2 status chars + 1 space (substring(3)), resolve rename targets, + // keep only ".cs" paths, and return the absolute normalized path. Returns null for lines that are + // not changed C# files. + private static string? ParseStatusLine(string root, string line) + { + if (!IsCandidateLine(line)) + { + return null; + } + + string pathPart = line.Substring(3).Trim(); + string finalPath = RenameTarget(pathPart); + if (!IsCSharpPath(finalPath)) + { + return null; + } + + return Path.GetFullPath(Path.Combine(root, finalPath)); + } + + // Ports isJavaPath, adapted to ".cs". EndsWith(".cs", Ordinal) mirrors Java's endsWith(".java") + // exactly (and, like SourceFileFinder, dodges the Windows "*.cs" glob quirk that also matches + // ".csproj"). + private static bool IsCSharpPath(string path) + { + return path.EndsWith(".cs", StringComparison.Ordinal); + } + + // Segment-aware counterpart to Java's path.startsWith(projectRoot/src): a file is under src only + // when its path relative to src neither escapes with ".." nor is rooted on a different volume. + private static bool IsUnderSrc(string src, string file) + { + string relative = Path.GetRelativePath(src, file) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + if (Path.IsPathRooted(relative)) + { + return false; + } + + string[] segments = relative.Split( + Path.DirectorySeparatorChar, + StringSplitOptions.RemoveEmptyEntries); + + return segments.Length > 0 && !string.Equals(segments[0], "..", StringComparison.Ordinal); + } +} diff --git a/tests/Crap4CSharp.Tests/ChangedFileDetectorTests.cs b/tests/Crap4CSharp.Tests/ChangedFileDetectorTests.cs new file mode 100644 index 0000000..fcd73dd --- /dev/null +++ b/tests/Crap4CSharp.Tests/ChangedFileDetectorTests.cs @@ -0,0 +1,162 @@ +namespace Microsoft.Crap4CSharp.Tests; + +using System.Diagnostics; + +// Faithful port of crap4java's ChangedFileDetectorTest. All five Java tests are ported with identical +// oracles; the *.java tree is adapted to *.cs and the changedJavaFiles* members to ChangedCSharpFiles*. +// The one test-infra adaptation is the git runner: Java drives setup through `sh -c "git ..."` (POSIX +// only), which is replaced here by a direct, cross-platform git invocation with the same commands and +// the same deadlock-free capture pattern used by the detector under test. Each test runs against a +// fresh temp directory that is always removed in a finally (mirrors JUnit's @TempDir). +public class ChangedFileDetectorTests +{ + [Fact] + public void FindsModifiedAndUntrackedCSharpFiles() + { + WithTempRepo(repo => + { + RunGit(repo, "init"); + RunGit(repo, "config", "user.email", "test@example.com"); + RunGit(repo, "config", "user.name", "test"); + + string tracked = WriteFile(repo, "class Tracked {}\n", "src", "demo", "Tracked.cs"); + + RunGit(repo, "add", "."); + RunGit(repo, "commit", "-m", "init"); + + File.WriteAllText(tracked, "class Tracked { int x = 1; }\n"); + string untracked = WriteFile(repo, "class NewFile {}\n", "src", "demo", "NewFile.cs"); + WriteFile(repo, "ignore me\n", "README.md"); + + IReadOnlyList changed = ChangedFileDetector.ChangedCSharpFiles(repo); + + // Ordinal-sorted absolute paths: "...NewFile.cs" precedes "...Tracked.cs" ('N' < 'T'), and + // README.md is excluded (not ".cs"). Both sides go through Path.GetFullPath so separator + // normalization matches on Windows. + changed.Should().Equal( + Path.GetFullPath(untracked), + Path.GetFullPath(tracked)); + }); + } + + [Fact] + public void IncludesGitErrorOutputWhenStatusFails() + { + WithTempRepo(repo => + { + // repo exists but was never `git init`-ed, so `git status` fails and its stderr diagnostic + // must surface in the exception message. + Action act = () => ChangedFileDetector.ChangedCSharpFiles(repo); + + act.Should().Throw() + .WithMessage("*not a git repository*"); + }); + } + + [Fact] + public void FiltersChangedFilesToSrcTreeOnly() + { + WithTempRepo(repo => + { + RunGit(repo, "init"); + RunGit(repo, "config", "user.email", "test@example.com"); + RunGit(repo, "config", "user.name", "test"); + + string tracked = WriteFile(repo, "class Tracked {}\n", "src", "demo", "Tracked.cs"); + + RunGit(repo, "add", "."); + RunGit(repo, "commit", "-m", "init"); + + File.WriteAllText(tracked, "class Tracked { int x = 1; }\n"); + WriteFile(repo, "class Something {}\n", "tests", "Something.cs"); + + IReadOnlyList changed = ChangedFileDetector.ChangedCSharpFilesUnderSrc(repo); + + // The changed file outside src (tests/Something.cs) is filtered out; only the src file + // survives. + changed.Should().Equal(Path.GetFullPath(tracked)); + }); + } + + [Fact] + public void CandidateLineRequiresAtLeastFourCharacters() + { + ChangedFileDetector.IsCandidateLine(null).Should().BeFalse(); + ChangedFileDetector.IsCandidateLine(string.Empty).Should().BeFalse(); + ChangedFileDetector.IsCandidateLine("abc").Should().BeFalse(); + ChangedFileDetector.IsCandidateLine("abcd").Should().BeTrue(); + } + + [Fact] + public void RenameTargetUsesReplacementSideEvenAtStartOfString() + { + ChangedFileDetector.RenameTarget("src/Old.cs -> src/New.cs").Should().Be("src/New.cs"); + ChangedFileDetector.RenameTarget(" -> New.cs").Should().Be("New.cs"); + ChangedFileDetector.RenameTarget("Plain.cs").Should().Be("Plain.cs"); + } + + // cross-platform: Java uses sh -c "git ..."; invoke git directly with the same commands. Captures + // both streams before waiting (same deadlock-free pattern as the detector) and throws on non-zero + // exit so setup failures are loud. + private static void RunGit(string dir, params string[] args) + { + ProcessStartInfo startInfo = new() + { + FileName = "git", + WorkingDirectory = dir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + string stdout = outputTask.GetAwaiter().GetResult(); + string stderr = errorTask.GetAwaiter().GetResult(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"git {string.Join(' ', args)} failed: {stdout}{stderr}"); + } + } + + private static void WithTempRepo(Action test) + { + string repo = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repo); + try + { + test(repo); + } + finally + { + if (Directory.Exists(repo)) + { + // git marks pack/object files read-only (notably on Windows), which blocks a plain + // recursive delete; clear attributes first so cleanup always succeeds. + foreach (string file in Directory.EnumerateFiles(repo, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(repo, recursive: true); + } + } + } + + private static string WriteFile(string root, string content, params string[] relativeSegments) + { + string path = Path.Combine(root, Path.Combine(relativeSegments)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } +} From 25f3089354ee124f8bbb13b0f85a9c93b3ef1fd7 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 18:13:03 -0700 Subject: [PATCH 15/27] docs: append S5 (clean-room eval) + S6 (dogfooding) finale slices; backfill T8 SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan update ruled by Mr. Das — add the two closing slices to the feature file: - S5 (depends S4): independent clean-room evaluation. Task T17 — a neutral evaluator sub-agent on gpt-5.6-sol judges the delivered port against the verbatim ## Requirements block; report-only verdict. Status: Pending (deferred). - S6 (depends S4, S5): real-world dogfooding. Task T18 — run crap4csharp on crap4csharp + ../mutate4csharp + ../dry4csharp; capture CRAP reports; summarize crappy methods. Status: Pending (deferred). Also: - R6: coverlet.collector + resolvable .sln prerequisite; sibling repos can't be modified in place — S6 may surface tool gaps. (Noted, not solved now.) - D-S6 (resolved): siblings are read-only (guardrail #2); S6 runs against COPIES in a scratch/temp workspace outside all source repos, never writing into them. - Backfill T8 commit SHA (c023ef8) into its task-table row. Docs-only change; no code/build impact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 6856c61..e3f7d61 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -35,6 +35,8 @@ the augmented complexity node set, and approved deliberate departures are in `do | S2 | Pure core (CRAP formula, report, CLI parse, domain types) with parity tests | S1 | | S3 | Ecosystem adapters (process exec, file finders, Roslyn parser, Cobertura parser) with parity tests | S1 | | S4 | Composition + CLI wiring + fail-fast + end-to-end | S2, S3 | +| S5 | Independent clean-room evaluation: a neutral evaluator on gpt-5.6-sol judges the delivered port against the VERBATIM original Requirements block (report-only) | S4 | +| S6 | Real-world dogfooding: run the finished crap4csharp on three real C# repos — crap4csharp, mutate4csharp, dry4csharp — producing actual CRAP reports | S4, S5 | ## Tasks (Tx) @@ -49,7 +51,7 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T5 | S2 | `CliArgumentsParser` + `CliArgumentsParserTests` (7 cases) | Done | `1b7833b` | | T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `da02028` | | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | -| T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `(next commit)` | +| T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `c023ef8` | | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Pending | - | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | @@ -58,6 +60,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | +| T17 | S5 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | +| T18 | S6 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 and T6/T7/T8/T13 parallelize early. @@ -72,6 +76,10 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an match parsed method FQNs. Pin against a real coverlet sample (T10). - R4: Fail-fast changes ~4 parity tests (+2 new) — the one knowing test-verification break. - R5: FluentAssertions v8 licensing — the `[7.0.0,8.0.0)` pin + lock file must hold. +- R6: crap4csharp requires each target to have a test project referencing `coverlet.collector` and a + resolvable module root (`.sln`), else fail-fast fires. `mutate4csharp`/`dry4csharp` may not satisfy + this and cannot be modified in place — S6 may surface tool gaps or need target prerequisites handled + on the copies. (Noted, not solved now.) ## Assumptions (Ax) @@ -241,3 +249,15 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `build-test.md` edit. **If (a):** replace `build-test.md`'s final paragraph with the no-tagging reality (forward-looking: tag integration tests as they land). Either way the doc/reality gap must close at the ratification point. + +### S5/S6 finale — design constraints (design-lane) + +- **D-S6 (design — resolved):** Per guardrail #2, `../mutate4csharp` and `../dry4csharp` are READ-ONLY + sibling repos; `crap4csharp` itself is write-scoped to this repo only. Because S6 runs + `dotnet test --collect`, which writes build/coverage artifacts (`bin`/`obj`/`TestResults`), S6 MUST + operate on COPIES of each target in a scratch/temp workspace OUTSIDE all source repos (e.g., under + `%TEMP%`), never writing into the sibling repos or into crap4csharp's own tree. Dogfooding + crap4csharp-on-crap4csharp likewise runs against a copy to avoid polluting the working tree. This + constraint governs T18 execution. +- Also note the ordering: S6 runs only after everything through S5 is done and pushed (S6 depends on S4 + and S5). From e4d1329ad5616d7ef6e80e98c70d6d784c125ee5 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 18:46:51 -0700 Subject: [PATCH 16/27] T13: ModuleRootResolver (nearest .sln > .csproj > start; unbounded walk) Faithful port of crap4java CliApplication.moduleRootFor, with the marker swapped pom.xml -> .sln (primary) + .csproj (fallback). Single upward walk (inclusive of start), file argument normalized to its parent dir; any .sln up the chain beats a nearer .csproj; else nearest .csproj; else the start directory. - public static class ModuleRootResolver { public static string Resolve(string startDirectory); } - Marker probe mirrors SourceFileFinder discipline: EnumerateFiles + EndsWith(Ordinal), not a *.sln glob (dodges 8.3/.slnf/.slnx quirks); .slnx/.slnf never match .sln. - ArgumentNullException.ThrowIfNull guard; nullable on; no internal; no suppressions. - 12 tests (linchpin PrefersSolutionOverNearerProjectFile). Suite 27 -> 39 green; dotnet build -c Release 0/0 (warnings-as-errors); deterministic x2 (Bhaskar). B1 (departure candidate, staged as decisions.md #6 pending Mr. Das): the walk is unbounded (climbs to filesystem root, fallback = start dir) vs Java's workspace-bounded walk / workspaceRoot fallback. Anders recommends approve-as-specced. Docs: feature-file T13 row -> Done; W17 (T14 resolve-once vs multi-module grouping, Mr. Das call at T14) + W18 (nonexistent-path normalization, below threshold) recorded; D-T13/B1 logged as open. Departure #6 NOT landed in decisions.md until ruled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 31 ++- src/Crap4CSharp/ModuleRootResolver.cs | 75 ++++++ .../ModuleRootResolverTests.cs | 229 ++++++++++++++++++ 3 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 src/Crap4CSharp/ModuleRootResolver.cs create mode 100644 tests/Crap4CSharp.Tests/ModuleRootResolverTests.cs diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index e3f7d61..164cab9 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S2 complete** (T2 `545c000`, T3 `47c1a1c`, T4 `f830f0e`, T5 `1b7833b`); pure core landed, 16 tests green, 0/0 Release. Paused at S2 boundary for Mr. Das's steer before S3. +**Status:** In progress — **S3 adapters + module-root landed** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 next commit); 39 tests green, 0/0 Release. **T9/T10 blocked on Mr. Das** (D-T9, D-T10a); paused at the S3 boundary for rulings. ## Requirements @@ -56,7 +56,7 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Pending | - | -| T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Pending | - | +| T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `(next commit)` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | @@ -215,6 +215,26 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `src/` leak into the analysis set. Reinforces W8 (files for `ChangedSrc` come from the detector, not `FileArgs`). +### Carry-forward watch-items (from Anders's T13 review — must be honored at the noted task) + +- **W17 — T14 resolves the module root ONCE; multi-module grouping is a product call.** T14 must + resolve the module root by calling `ModuleRootResolver.Resolve` **once** at the discovered `.sln`, and + must **not** port Java's per-file `groupByModuleRoot`/`analyzeByModule` for the single-solution + baseline (R2/R6 already assume a single resolvable `.sln`). This **drops** crap4java's multi-module + (multi-`.sln`) grouping — a fidelity reduction. **Escalate to Mr. Das at T14 as a departure + candidate** (log as approved departure if he takes resolve-once; otherwise preserve grouping by + calling `Resolve` per file and grouping by root). Cross-ref R2, R6, T11 (`CrapAnalyzer` per-method + `TypeName`/coverage lookup) and T12 (`CoverageRunner` `dotnet test` at the module root). Do **not** + silently drop grouping. +- **W18 — Nonexistent-start-path normalization (below threshold today).** `Resolve` uses + `File.Exists(full) ? parent : full`, which diverges from Java's `isDirectory ? self : parent` + **only** for a nonexistent leaf (C# returns the leaf; Java returns its parent). Harmless in the + current pipeline (callers pass existing discovered paths; `ContainsMarker` no-ops on a nonexistent + dir, so the divergence surfaces only in the terminal no-marker fallback that B1 already redefines). + **If any future task routes an unverified/nonexistent path into `Resolve`**, switch to the zero-cost + faithful form `Directory.Exists(full) ? full : (Path.GetDirectoryName(full) ?? full)` and add a + nonexistent-leaf test (no current test exercises this). + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled @@ -250,6 +270,13 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an reality (forward-looking: tag integration tests as they land). Either way the doc/reality gap must close at the ratification point. +- **D-T13 — Module-root walk unbounded (B1) — ratification.** *(OPEN — light; escalated at the S3 + boundary.)* `ModuleRootResolver` climbs **unbounded** to the filesystem root and falls back to the + **start directory** when no `.sln`/`.csproj` marker is found, vs crap4java's workspace-bounded walk / + `workspaceRoot` fallback. Anders recommends **approve-as-specced**; staged as **departure #6** in + `docs/decisions.md`, to be landed post-ruling. Below the behavioral bar for realistic C# layouts; + non-blocking (T13 already landed on this default). + ### S5/S6 finale — design constraints (design-lane) - **D-S6 (design — resolved):** Per guardrail #2, `../mutate4csharp` and `../dry4csharp` are READ-ONLY diff --git a/src/Crap4CSharp/ModuleRootResolver.cs b/src/Crap4CSharp/ModuleRootResolver.cs new file mode 100644 index 0000000..2a38712 --- /dev/null +++ b/src/Crap4CSharp/ModuleRootResolver.cs @@ -0,0 +1,75 @@ +namespace Microsoft.Crap4CSharp; + +// Faithful port of crap4java's CliApplication.moduleRootFor(workspaceRoot, file). Java climbs upward +// from the file (or the file itself when it is a directory) toward a bounded workspaceRoot, returning +// the nearest ancestor that contains the module marker "pom.xml"; if none is found it falls back to the +// workspaceRoot. Two ratified adaptations for the .NET ecosystem (see docs/decisions.md, Module root): +// +// * Marker swap -- the module marker becomes ".sln" (primary) with ".csproj" as the fallback marker, +// because that is what lets `dotnet test` actually run for a module. A ".sln" ANYWHERE up the chain +// beats a NEARER ".csproj", mirroring the single-marker precedence of the Java original: only when no +// ".sln" exists anywhere do we settle for the nearest ".csproj". +// +// * B1 -- unbounded walk. Java bounded the climb to workspaceRoot and fell back to it; we intentionally +// do NOT bound the walk (it climbs to the filesystem root) and fall back to the STARTING directory +// when no marker is found anywhere. Approved-as-specced departure (Mr. Das ratifies at the boundary). +// +// Marker detection mirrors SourceFileFinder's EndsWith(".ext", Ordinal) discipline rather than a +// "*.sln" glob: the glob has a Windows 8.3-shortname quirk and would also let ".slnx"/".slnf" match, +// whereas EndsWith(".sln", Ordinal) matches ".sln" exactly and nothing longer. +public static class ModuleRootResolver +{ + // Ports moduleRootFor(Path, Path): resolves the module root directory for a start location that may be + // a directory or a file. Returns an absolute, normalized directory path. + public static string Resolve(string startDirectory) + { + // Java implicitly throws NullPointerException on workspaceRoot.normalize(); the idiomatic, + // suppression-free C# equivalent (and CA1062's required guard) is a fail-fast ArgumentNullException. + ArgumentNullException.ThrowIfNull(startDirectory); + + string full = Path.GetFullPath(startDirectory); + + // Mirror Java's `Files.isDirectory(file) ? file : file.getParent()`: when the start is an existing + // file, begin from its parent directory; otherwise treat the normalized path as the start directory. + string start = (File.Exists(full) ? Path.GetDirectoryName(full) : null) ?? full; + + string? nearestProject = null; + + // Single upward walk, inclusive of the starting directory, to the filesystem root (B1: unbounded). + for (string? current = start; current is not null; current = Path.GetDirectoryName(current)) + { + // A ".sln" is the winning marker: the first one seen while climbing is the nearest, and it + // outranks any ".csproj" recorded so far (".sln" beats even a nearer ".csproj"). + if (ContainsMarker(current, ".sln")) + { + return current; + } + + // Remember only the FIRST (nearest) ".csproj" ancestor -- the fallback used if no ".sln" exists. + if (nearestProject is null && ContainsMarker(current, ".csproj")) + { + nearestProject = current; + } + } + + // No ".sln" anywhere: use the nearest ".csproj" if one was found, else fall back to the starting + // directory (B1 terminal fallback, in place of Java's workspaceRoot). + return nearestProject ?? start; + } + + // True when the directory directly contains a file whose name ends with the given marker extension. + // Uses EnumerateFiles + EndsWith(Ordinal) -- NOT a "*.sln"/"*.csproj" glob -- to dodge the Windows + // 8.3-shortname quirk and to keep ".slnx"/".slnf" from ever matching ".sln". A missing directory + // yields false rather than throwing, mirroring Java's non-throwing Files.exists(...) marker probe. + private static bool ContainsMarker(string directory, string extension) + { + if (!Directory.Exists(directory)) + { + return false; + } + + return Directory + .EnumerateFiles(directory, "*") + .Any(file => file.EndsWith(extension, StringComparison.Ordinal)); + } +} diff --git a/tests/Crap4CSharp.Tests/ModuleRootResolverTests.cs b/tests/Crap4CSharp.Tests/ModuleRootResolverTests.cs new file mode 100644 index 0000000..ce2a77d --- /dev/null +++ b/tests/Crap4CSharp.Tests/ModuleRootResolverTests.cs @@ -0,0 +1,229 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Pins ModuleRootResolver against crap4java's CliApplication.moduleRootFor: the upward marker walk, the +// ".sln"-beats-".csproj" precedence, file-argument normalization, and the ratified B1 unbounded fallback +// to the starting directory. Every test scaffolds a fresh temp tree (empty ".sln"/".csproj"/dirs) and +// removes it in a finally. Tests that rely on NO marker being found up-chain (Nos. 4-6) assume the temp +// ancestry is marker-free -- ratified to hold on CI /tmp and dev temp. +public class ModuleRootResolverTests +{ + [Fact] + public void ResolvesDirectoryContainingSolution() + { + WithTempRoot(root => + { + Touch(root, "App.sln"); + + string result = ModuleRootResolver.Resolve(root); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void PrefersSolutionOverNearerProjectFile() + { + // THE LINCHPIN: the start dir carries a ".csproj" while an ancestor carries a ".sln". The ".sln" + // must win even though the ".csproj" is nearer -- exactly Java's single-marker precedence. + WithTempRoot(root => + { + string projectDir = SubDir(root, "src", "App"); + Touch(root, "Solution.sln"); + Touch(projectDir, "App.csproj"); + + string result = ModuleRootResolver.Resolve(projectDir); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void WalksUpToFindSolution() + { + WithTempRoot(root => + { + Touch(root, "App.sln"); + string start = SubDir(root, "a", "b"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void FallsBackToNearestProjectWhenNoSolution() + { + WithTempRoot(root => + { + string projectDir = SubDir(root, "lib"); + Touch(projectDir, "Lib.csproj"); + string start = SubDir(projectDir, "nested"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(projectDir)); + }); + } + + [Fact] + public void PrefersNearestProjectAmongMultiple() + { + WithTempRoot(root => + { + Touch(root, "Outer.csproj"); + string innerDir = SubDir(root, "mid"); + Touch(innerDir, "Inner.csproj"); + string start = SubDir(innerDir, "leaf"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(innerDir)); + }); + } + + [Fact] + public void ReturnsStartDirectoryWhenNoMarker() + { + // B1 fallback: an isolated temp tree with no ".sln"/".csproj" anywhere resolves to the start dir. + WithTempRoot(root => + { + string start = SubDir(root, "x", "y"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(start)); + }); + } + + [Fact] + public void NormalizesFileArgumentToParentDirectory() + { + WithTempRoot(root => + { + Touch(root, "App.sln"); + string file = Touch(root, "Program.cs"); + + string result = ModuleRootResolver.Resolve(file); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void ResolvesRelativeStartDirectory() + { + // Scaffold under the current directory so a genuine relative path exists on every platform/drive; + // Resolve must GetFullPath-normalize it back to the absolute solution directory. + string root = Path.Combine(Directory.GetCurrentDirectory(), Guid.NewGuid().ToString("N")); + try + { + Touch(root, "App.sln"); + string relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), root); + + string result = ModuleRootResolver.Resolve(relative); + + result.Should().Be(Path.GetFullPath(root)); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Fact] + public void DoesNotMatchSlnxOrSlnfAsSolution() + { + // Only ".slnx"/".slnf" sit in the start dir (a real ".sln" is higher up). If EndsWith(".sln") + // discipline slipped, Resolve would stop at the start dir; instead it must climb to the real ".sln". + WithTempRoot(root => + { + Touch(root, "App.sln"); + string start = SubDir(root, "module"); + Touch(start, "foo.slnx"); + Touch(start, "foo.slnf"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void SolutionInStartDirectoryBeatsProjectInStartDirectory() + { + // Both markers live in the start dir; the ".sln" path returns it without falling through to any + // ".csproj"-only handling -- precedence sanity at the same directory level. + WithTempRoot(root => + { + Touch(root, "App.sln"); + Touch(root, "App.csproj"); + + string result = ModuleRootResolver.Resolve(root); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + [Fact] + public void ThrowsOnNullStartDirectory() + { + Action act = () => ModuleRootResolver.Resolve(null!); + + act.Should().Throw().WithParameterName("startDirectory"); + } + + [Fact] + public void ReturnsSolutionDirectoryDespiteNearerProjectsAtDepth() + { + // Nested-depth linchpin: a ".sln" three levels up with ".csproj" files scattered below still wins. + WithTempRoot(root => + { + Touch(root, "Top.sln"); + string levelA = SubDir(root, "a"); + Touch(levelA, "A.csproj"); + string levelB = SubDir(levelA, "b"); + Touch(levelB, "B.csproj"); + string start = SubDir(levelB, "c"); + + string result = ModuleRootResolver.Resolve(start); + + result.Should().Be(Path.GetFullPath(root)); + }); + } + + private static void WithTempRoot(Action test) + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + test(root); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static string SubDir(string parent, params string[] segments) + { + string path = Path.Combine(parent, Path.Combine(segments)); + Directory.CreateDirectory(path); + return path; + } + + private static string Touch(string directory, string fileName) + { + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, fileName); + File.WriteAllText(path, string.Empty); + return path; + } +} From d0784b39ee2abf5106699e1c136b1366cf8626de Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 19:26:16 -0700 Subject: [PATCH 17/27] docs: ratify B1 unbounded module-root walk as departure #6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mr. Das approved D-T13/B1. ModuleRootResolver climbs unbounded to the filesystem root and falls back to the start directory (nearest .sln -> .csproj -> start), in place of crap4java's workspace-bounded moduleRootFor / workspaceRoot fallback — the C# CLI has no Maven-style workspace concept. T13 shipped as-is (e4d1329); no rework. Recorded as departure #6 in docs/decisions.md; D-T13 marked RESOLVED in the feature file. Docs-only; no code/build impact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 9 +++++++++ docs/features/crap4csharp-port.md | 11 +++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 0448260..fd8769b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -97,6 +97,15 @@ node set below (an approved departure), so absolute CRAP scores are not numerica Java's *intent* (analyze human-authored `src`). It deliberately does **not** exclude checked-in generated files (`*.g.cs`/`*.Designer.cs`) that live under normal source folders — Java analyzed generated `.java` under `src` too, so broadening the filter would break parity. +6. **Module-root walk unbounded (B1)** — `ModuleRootResolver.Resolve(startDirectory)` takes a single + start path (no `workspaceRoot`), climbs **unbounded** to the filesystem root, and falls back to the + **starting directory** when no `.sln`/`.csproj` marker is found — in place of crap4java's + `moduleRootFor(workspaceRoot, file)`, which bounds the climb to `workspaceRoot` and falls back to + `workspaceRoot`. Faithful to Java's *intent* (find the nearest module root at/above the start); the + bound is dropped because the C# CLI carries no separate Maven-style `workspaceRoot`, real C# trees + carry a `.sln`/`.csproj` so the walk terminates early, and termination at the filesystem root is + guaranteed either way. Marker precedence: nearest `.sln` → nearest `.csproj` → start dir. (Ruled by + Mr. Das; T13 shipped as `e4d1329`, no rework.) ## Cyclomatic complexity — authoritative node set diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 164cab9..ba8fdf9 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -270,12 +270,11 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an reality (forward-looking: tag integration tests as they land). Either way the doc/reality gap must close at the ratification point. -- **D-T13 — Module-root walk unbounded (B1) — ratification.** *(OPEN — light; escalated at the S3 - boundary.)* `ModuleRootResolver` climbs **unbounded** to the filesystem root and falls back to the - **start directory** when no `.sln`/`.csproj` marker is found, vs crap4java's workspace-bounded walk / - `workspaceRoot` fallback. Anders recommends **approve-as-specced**; staged as **departure #6** in - `docs/decisions.md`, to be landed post-ruling. Below the behavioral bar for realistic C# layouts; - non-blocking (T13 already landed on this default). +- **D-T13 — Module-root walk unbounded (B1) — ratification.** *(RESOLVED — Mr. Das approved.)* + `ModuleRootResolver` climbs **unbounded** to the filesystem root and falls back to the **start + directory** when no `.sln`/`.csproj` marker is found, vs crap4java's workspace-bounded walk / + `workspaceRoot` fallback. Recorded as **departure #6** in `docs/decisions.md`; T13 shipped as-is + (`e4d1329`), no rework. Below the behavioral bar for realistic C# layouts. ### S5/S6 finale — design constraints (design-lane) From 400d49dc5842e836fa672c7bcb1e69eda6c22038 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 20:35:08 -0700 Subject: [PATCH 18/27] T9: CSharpMethodParser + ComplexityWalker (Roslyn; augmented CC; frozen TypeName) Faithful Roslyn port of crap4java JavaMethodParser. Mr. Das ruled D-T9 Option A: adopt Parse(string source); drop the 2 javac file-URI plumbing tests (no C# analog); port the other 7 faithfully. - public static class CSharpMethodParser { public static IReadOnlyList Parse(string source); } - Syntax-only (CSharpSyntaxTree.ParseText, LanguageVersion.Latest; no compilation/ SemanticModel) -- parse, don't bind. ArgumentNullException.ThrowIfNull guard. - ComplexityWalker: private sealed nested CSharpSyntaxWalker, _complexity seeded 1, fresh per method, counts the augmented node set from docs/decisions.md (loops, if, catch, ?:, switch labels, &&/||/??, ??=, switch-expression arms, pattern and/or/not, catch filter, when); prunes nested types; descends into lambdas + local functions (departure #2). CC oracles reproduce Java exactly (alpha3/beta4/score10/nested13/ switched4/stable1/helper1). - Methods only (bodied MethodDeclarationSyntax, document order, bare Name); ctors/ accessors/local-fns/lambdas excluded structurally. TypeName emitted in the FROZEN canonical form (dotted ns FQN + per-level backtick arity; global ns -> bare). - 17 tests (7 faithful ports + 10 new). Suite 39 -> 56 green; build -c Release 0/0 (warnings-as-errors); deterministic x2 (Bhaskar). Anders review GREEN. Docs: decisions.md records the frozen TypeName reciprocal (T9 emit / T10 produce / T11 consume). Feature file: W-T9a (T10 NormalizeTypeName -> frozen form, pin to real coverlet sample), W-T9b (skip compiler-generated names), W-T9c (map key shape); D-T9 -> RESOLVED. Back-filled T13 SHA (e4d1329). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 23 + docs/features/crap4csharp-port.md | 40 +- src/Crap4CSharp/CSharpMethodParser.cs | 244 ++++++++ .../CSharpMethodParserTests.cs | 529 ++++++++++++++++++ 4 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 src/Crap4CSharp/CSharpMethodParser.cs create mode 100644 tests/Crap4CSharp.Tests/CSharpMethodParserTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index fd8769b..d382282 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -63,6 +63,29 @@ node set below (an approved departure), so absolute CRAP scores are not numerica members + public test methods), **not** private fields; do not avoid a private field for fear of a CA1707 conflict. +## Frozen reciprocal contract — `TypeName` canonical form (T9 ⇄ T10 ⇄ T11) + +**FROZEN (T9 review, Anders).** The per-method coverage-lookup key `MethodDescriptor.TypeName` (the +"Coverage key" locked choice, elaborated) has exactly **one** canonical spelling that **both** +producers must emit byte-for-byte, so T11's exact-match keys align. This is **load-bearing**: any drift +on either side silently breaks coverage attribution (affected methods fall through to `N/A`). + +- **Form:** dotted-namespace FQN of the enclosing type, then `.`, then the containing-type chain joined + by `.` (outermost → innermost). **Each** type level that declares N type parameters carries a + per-level backtick arity suffix `` `N `` (arity 0 ⇒ no suffix). A type in the **global namespace** + yields the **bare** type chain (no leading `.`, no namespace prefix). +- **Canonical examples:** `Demo.Sample`; `Demo.Outer.Inner`; ``Demo.Container`1``; + ``Demo.Outer`1.Inner`2``; bare `Sample` (global namespace). +- **Producer 1 — `CSharpMethodParser.TypeNameOf` (T9, emit):** builds it from the syntax tree + (`BaseNamespaceDeclarationSyntax` ancestor chain + `TypeDeclarationSyntax` ancestor chain, per level + `TypeParameterList.Parameters.Count`). **Pinned** by `CSharpMethodParserTests` — all five forms above. +- **Producer 2 — `CoberturaCoverageParser.NormalizeTypeName` (T10, produce):** must normalize the + coverlet `class name=` attribute to this **same** form — nested separators (`/` or `+`) → `.`, + **keep** the backtick arity, bare-ify the global namespace — and must be **pinned against a real + coverlet sample** (R3). See watch-item **W-T9a**. +- **Consumer — `CrapAnalyzer` (T11):** builds coverage-map / method-lookup keys from this form. Do not + change one producer without the other. + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index ba8fdf9..934719d 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S3 adapters + module-root landed** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 next commit); 39 tests green, 0/0 Release. **T9/T10 blocked on Mr. Das** (D-T9, D-T10a); paused at the S3 boundary for rulings. +**Status:** In progress — **S3 nearly complete** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 next commit); 56 tests green, 0/0 Release. T9 parser landed; **T10 (`CoberturaCoverageParser`) in flight** — the last S3 task. Then S4. ## Requirements @@ -52,11 +52,11 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `da02028` | | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `c023ef8` | -| T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Pending | - | +| T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `(next commit)` | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Pending | - | -| T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `(next commit)` | +| T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | @@ -235,6 +235,31 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an faithful form `Directory.Exists(full) ? full : (Path.GetDirectoryName(full) ?? full)` and add a nonexistent-leaf test (no current test exercises this). +### Carry-forward watch-items (from Anders's T9 review — must be honored at the noted task) + +- **W-T9a — T10 `NormalizeTypeName` must yield the frozen `TypeName` form.** `CoberturaCoverageParser` + must normalize the coverlet `class name=` attribute to the **frozen reciprocal form** recorded in + `docs/decisions.md` ("Frozen reciprocal contract — `TypeName` canonical form"): coverlet nested + separators (`/` **or** `+`) → `.`, **keep** the per-level backtick arity (`` `1 ``, `` `2 ``), + bare-ify the global namespace. This is the exact string `CSharpMethodParser.TypeNameOf` (T9) emits and + is already pinned by `CSharpMethodParserTests`. **Pin `NormalizeTypeName` against a real coverlet + sample** (R3) — do not hand-fabricate the expected class attribute. Load-bearing: any mismatch drops + the method to `N/A`. +- **W-T9b — T10 must skip compiler-generated class & method names.** Coverlet reports lambdas, local + functions, iterators/async state machines, property accessors and constructors under synthesized + names. `CoberturaCoverageParser` must **ignore** entries whose class **or** method name is + compiler-generated — at minimum: any name containing `<...>` (display/state-machine classes and + `b__n` lambda methods), `get_`/`set_` accessors, `.ctor`/`.cctor`, `MoveNext`, and `b__` + helpers — so only human-authored members (the ones `CSharpMethodParser` collects) are matched. Mirrors + R3's "generated names must be normalized/ignored." Pin against the same real coverlet sample as W-T9a. +- **W-T9c — Coverage-map key shape (recommended) for T11 lookup alignment.** Recommended map key is + `NormalizeTypeName(class)#method@name:line` (type FQN in the frozen form + `#` + method name + line), + so T11's **exact** match keys off `TypeName#Name:line`. T11's **nearest-line** fallback prefix must + stay **method-scoped** — `TypeName#Name:` — so a fallback never crosses into a different method or + overload. Keep the separators (`#`, `@`, `:`) out of any identifier that can legally contain them + (none can), so the key stays unambiguous. Reconfirm the exact key shape when T10/T11 land; the binding + invariant is that emit (T9), produce (T10) and consume (T11) share the frozen `TypeName` form. + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled @@ -242,12 +267,15 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an logged departure: **no** standing-policy line is added to `docs/decisions.md`. Dave continues writing the CA1062-required guards exactly as before (code unchanged); Bhaskar/Anders treat them as non-noteworthy going forward. -- **D-T9 — Parser plumbing / signature (non-1:1).** *(OPEN — escalated to Mr. Das.)* Drop +- **D-T9 — Parser plumbing / signature (non-1:1).** *(RESOLVED — Mr. Das ruled **Option A**: drop both + plumbing methods + their 2 tests; adopt `Parse(string source)`. Shipped in T9; Anders-reviewed 🟢.)* + Drop `JavaMethodParser.sourcePath`/`sourceUri` and their 2 tests (`buildsSourcePathAndUriFromClassNames`, `acceptsClassNamesWithJavaSuffix`) as javac file-URI plumbing with no Roslyn referent, and adopt `Parse(string source)` (drops the vestigial `className`). Anders recommends **Option A (drop both)**; - the 7 remaining Java parser tests port as faithful behavioral counterparts. **Blocks T9** (parser - signature + test set depend on the ruling). Design-lane FYIs (Anders, vetoable): collection scope = + the 7 remaining Java parser tests port as faithful behavioral counterparts. **Unblocked T9** (parser + signature + test set followed the ruling; 17 tests = 7 faithful ports + 10 new, 56/56 green). + Design-lane FYIs (Anders, vetoable): collection scope = methods only; expression-bodied methods included; nested-type class-name format is a T10 pin. - **D-T8 — Integration-test categorization / fast-loop scope (agentic-loop policy).** *(OPEN — escalated to Mr. Das; JARVIS to route at the S3 boundary.)* `.github/skills/build-test.md` promises diff --git a/src/Crap4CSharp/CSharpMethodParser.cs b/src/Crap4CSharp/CSharpMethodParser.cs new file mode 100644 index 0000000..4fefb53 --- /dev/null +++ b/src/Crap4CSharp/CSharpMethodParser.cs @@ -0,0 +1,244 @@ +namespace Microsoft.Crap4CSharp; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +// Faithful port of crap4java's JavaMethodParser, re-hosted on Roslyn (docs/decisions.md: Parser row). +// Java compiled the source with the JDK tool API and walked the javac tree; the C# analog parses the +// source with Roslyn and walks the syntax tree. Two ratified departures apply (docs/decisions.md): +// * D-T9 -- the entry point is Parse(string source). Java's className -> source-path / file-URI +// plumbing (sourcePath/sourceUri) has no C# analog and is dropped, along with its two tests. +// * #2 (richer CC) -- ComplexityWalker uses the augmented node set and descends into lambdas and +// local functions, so absolute CC is not numerically comparable to crap4java on modern code. +// TypeName (the coverage-lookup key -- docs/decisions.md "Coverage key = enclosing-type FQN") is the +// one new descriptor field: the enclosing type's namespace-qualified, arity-annotated FQN. +public static class CSharpMethodParser +{ + // Ports JavaMethodParser.parse, adapted to Parse(string source) per D-T9. Syntax-only: no + // CSharpCompilation, no SemanticModel, no metadata references -- undefined types parse fine, so + // sibling types are never resolved. Methods are collected in document (source) order, not sorted. + public static IReadOnlyList Parse(string source) + { + // Java implicitly NPEs when the compiler reads a null source; the idiomatic C# equivalent (and + // CA1062's required guard, since suppressions are banned) is a fail-fast ArgumentNullException. + ArgumentNullException.ThrowIfNull(source); + + SyntaxTree tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(); + + List methods = []; + foreach (MethodDeclarationSyntax m in root.DescendantNodes().OfType()) + { + // Skip abstract/partial/extern declarations that carry no body -- the analog of Java's + // getBody() == null guard. Constructors, finalizers, operators, accessors, local + // functions and lambdas are excluded automatically: none is a MethodDeclarationSyntax. + if (m.Body is null && m.ExpressionBody is null) + { + continue; + } + + FileLinePositionSpan span = m.GetLocation().GetLineSpan(); + int startLine = span.StartLinePosition.Line + 1; + SyntaxNode body = (SyntaxNode?)m.Body ?? m.ExpressionBody!; + int endLine = body.GetLocation().GetLineSpan().EndLinePosition.Line + 1; + int complexity = ComplexityWalker.Count(body); + + methods.Add(new MethodDescriptor( + Name: m.Identifier.Text, + StartLine: startLine, + EndLine: endLine, + Complexity: complexity, + TypeName: TypeNameOf(m))); + } + + return methods; + } + + // Builds the enclosing-type FQN coverage key: dotted namespace + '.' + dot-joined containing-type + // chain, each type carrying `N when it declares N type parameters (docs/decisions.md). The global + // namespace yields no prefix. Examples: Demo.Sample; Demo.Outer.Inner; Demo.Container`1; + // Demo.Outer`1.Inner`2; Sample (no namespace). + private static string TypeNameOf(MethodDeclarationSyntax m) + { + IEnumerable types = m.Ancestors() + .OfType() + .Select(t => + { + int arity = t.TypeParameterList?.Parameters.Count ?? 0; + return arity > 0 ? $"{t.Identifier.Text}`{arity}" : t.Identifier.Text; + }) + .Reverse(); + IEnumerable namespaces = m.Ancestors() + .OfType() + .Select(n => n.Name.ToString()) + .Reverse(); + string typeChain = string.Join(".", types); + string ns = string.Join(".", namespaces); + return ns.Length == 0 ? typeChain : $"{ns}.{typeChain}"; + } + + // The augmented cyclomatic-complexity counter (docs/decisions.md "authoritative node set"). Ports + // crap4java's ComplexityCounter and adds the modern-C# decision nodes. Base CC = 1; every counted + // node adds 1; nested type/enum declarations are pruned; lambdas and local functions are descended + // into (departure #2). A fresh walker is used per method, and CC is only ever verified through + // Parse. The type is private (zero surface outside CSharpMethodParser) and uses no `internal`; + // Count is `public` only so the enclosing type can call it -- the private nested type caps its + // effective accessibility to private (a literal `private` would be uncallable, CS0122). + private sealed class ComplexityWalker : CSharpSyntaxWalker + { + private int _complexity = 1; + + public static int Count(SyntaxNode body) + { + ComplexityWalker walker = new(); + walker.Visit(body); + return walker._complexity; + } + + public override void VisitIfStatement(IfStatementSyntax node) + { + _complexity++; + base.VisitIfStatement(node); + } + + public override void VisitForStatement(ForStatementSyntax node) + { + _complexity++; + base.VisitForStatement(node); + } + + public override void VisitForEachStatement(ForEachStatementSyntax node) + { + _complexity++; + base.VisitForEachStatement(node); + } + + public override void VisitForEachVariableStatement(ForEachVariableStatementSyntax node) + { + _complexity++; + base.VisitForEachVariableStatement(node); + } + + public override void VisitWhileStatement(WhileStatementSyntax node) + { + _complexity++; + base.VisitWhileStatement(node); + } + + public override void VisitDoStatement(DoStatementSyntax node) + { + _complexity++; + base.VisitDoStatement(node); + } + + public override void VisitCatchClause(CatchClauseSyntax node) + { + _complexity++; + base.VisitCatchClause(node); + } + + public override void VisitCatchFilterClause(CatchFilterClauseSyntax node) + { + _complexity++; + base.VisitCatchFilterClause(node); + } + + public override void VisitConditionalExpression(ConditionalExpressionSyntax node) + { + _complexity++; + base.VisitConditionalExpression(node); + } + + public override void VisitCaseSwitchLabel(CaseSwitchLabelSyntax node) + { + _complexity++; + base.VisitCaseSwitchLabel(node); + } + + public override void VisitCasePatternSwitchLabel(CasePatternSwitchLabelSyntax node) + { + _complexity++; + base.VisitCasePatternSwitchLabel(node); + } + + public override void VisitDefaultSwitchLabel(DefaultSwitchLabelSyntax node) + { + _complexity++; + base.VisitDefaultSwitchLabel(node); + } + + public override void VisitSwitchExpressionArm(SwitchExpressionArmSyntax node) + { + _complexity++; + base.VisitSwitchExpressionArm(node); + } + + public override void VisitWhenClause(WhenClauseSyntax node) + { + _complexity++; + base.VisitWhenClause(node); + } + + public override void VisitBinaryPattern(BinaryPatternSyntax node) + { + _complexity++; + base.VisitBinaryPattern(node); + } + + public override void VisitUnaryPattern(UnaryPatternSyntax node) + { + _complexity++; + base.VisitUnaryPattern(node); + } + + // Only the short-circuiting logical operators (&&, ||) and null-coalescing (??) are decision + // points; the bitwise operators (&, |, ^) are not. + public override void VisitBinaryExpression(BinaryExpressionSyntax node) + { + if (node.Kind() is SyntaxKind.LogicalAndExpression + or SyntaxKind.LogicalOrExpression + or SyntaxKind.CoalesceExpression) + { + _complexity++; + } + + base.VisitBinaryExpression(node); + } + + // Only the null-coalescing assignment (??=) is a decision point; ordinary and compound + // assignments are not. + public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) + { + if (node.Kind() is SyntaxKind.CoalesceAssignmentExpression) + { + _complexity++; + } + + base.VisitAssignmentExpression(node); + } + + // Prune at nested type/enum declarations (base is intentionally not called): their members' + // decision nodes belong to their own methods, not the enclosing one -- the analog of + // crap4java's visitClass prune. + public override void VisitClassDeclaration(ClassDeclarationSyntax node) + { + } + + public override void VisitStructDeclaration(StructDeclarationSyntax node) + { + } + + public override void VisitRecordDeclaration(RecordDeclarationSyntax node) + { + } + + public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node) + { + } + + public override void VisitEnumDeclaration(EnumDeclarationSyntax node) + { + } + } +} diff --git a/tests/Crap4CSharp.Tests/CSharpMethodParserTests.cs b/tests/Crap4CSharp.Tests/CSharpMethodParserTests.cs new file mode 100644 index 0000000..bf79391 --- /dev/null +++ b/tests/Crap4CSharp.Tests/CSharpMethodParserTests.cs @@ -0,0 +1,529 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Faithful port of crap4java's JavaMethodParserTest, re-hosted on Roslyn. Per Mr. Das's ruling D-T9 +// the entry point is Parse(string source): the two Java tests that exercised the className -> +// source-path / file-URI plumbing (buildsSourcePathAndUriFromClassNames, acceptsClassNamesWithJavaSuffix) +// have no C# analog and are dropped; the other seven Java tests are ported with identical intent and +// oracle values (alpha=3, beta=4, score=10, nested=13, switched=4, stable=1, helper=1). Fixtures use +// C# raw string literals (fixture member names live inside the strings, so CA1707 does not apply); +// Java's `boolean`/`package`/anonymous-class constructs are adapted to `bool`/`namespace`/lambda + +// local function. Ten new tests pin the C#-only behaviour: the TypeName coverage key +// (docs/decisions.md "Coverage key = enclosing-type FQN") and the augmented complexity node set +// (docs/decisions.md, departure #2). Descriptors are asserted for structural equality in document +// (source) order. +public class CSharpMethodParserTests +{ + [Fact] + public void ExtractsConcreteMethodsWithLinesAndComplexity() + { + string source = """ + namespace Demo; + class Sample + { + int Alpha(bool a, bool b) + { + if (a && b) + { + return 1; + } + return 0; + } + + int Beta(int x) + { + switch (x) + { + case 1: return 1; + case 2: return 2; + default: return 0; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal( + new MethodDescriptor("Alpha", 4, 11, 3, "Demo.Sample"), + new MethodDescriptor("Beta", 13, 21, 4, "Demo.Sample")); + } + + [Fact] + public void IgnoresConstructorsAndAbstractMethods() + { + string source = """ + abstract class Sample + { + Sample() + { + } + + abstract int Missing(); + + int Present() + { + return 1; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("Present", 9, 12, 1, "Sample")); + } + + [Fact] + public void ParsesMethodsWithoutResolvingSiblingTypes() + { + string source = """ + namespace Demo; + class Sample + { + Helper helper() => new Helper(); + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("helper", 4, 4, 1, "Demo.Sample")); + } + + [Fact] + public void IgnoresKeywordsInsideCommentsAndStrings() + { + string source = """ + class Sample + { + int Stable() + { + string text = "if && || ? case default catch"; + // if && || ? case default catch + /* if && || ? case default catch */ + return 1; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("Stable", 3, 9, 1, "Sample")); + } + + [Fact] + public void CountsDecisionNodesFromTheSyntaxTree() + { + string source = """ + class Sample + { + int Score(bool a, bool b, int[] values) + { + for (int i = 0; i < values.Length; i++) + { + } + foreach (int value in values) + { + } + while (a) + { + a = false; + } + do + { + b = false; + } + while (b); + if (a && b || values.Length > 0) + { + } + try + { + return a ? 1 : 0; + } + catch (Exception ex) + { + return 2; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("Score", 3, 31, 10, "Sample")); + } + + [Fact] + public void VisitsNestedDecisionNodesInsideOtherDecisionNodes() + { + string source = """ + class Sample + { + int Nested(bool a, bool b, int[] values) + { + for (int i = 0; i < values.Length; i++) + { + if (a) + { + } + } + foreach (int value in values) + { + if (b) + { + } + } + while (a) + { + if (b) + { + } + a = false; + } + do + { + if (a) + { + } + b = false; + } + while (b); + try + { + return a ? (b ? 1 : 0) : 2; + } + catch (Exception ex) + { + if (values.Length > 0) + { + return values[0]; + } + return 3; + } + } + + int Switched(int value) + { + switch (value) + { + case 1: + if (value > 0) + { + return 1; + } + return 0; + default: + return 2; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal( + new MethodDescriptor("Nested", 3, 44, 13, "Sample"), + new MethodDescriptor("Switched", 46, 59, 4, "Sample")); + } + + [Fact] + public void DoesNotCollectLambdaOrLocalFunctionBodiesAsSeparateMethods() + { + // C# analog of Java's ignoresMethodsDeclaredInsideAnonymousClasses: Outer holds a decision-free + // lambda AND local function. Neither is a MethodDeclarationSyntax, so only Outer is collected, + // and (being decision-free) Outer's complexity stays 1. + string source = """ + class Sample + { + int Outer() + { + Action action = () => + { + int x = 1; + }; + int Local() + { + return 2; + } + action(); + return Local(); + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("Outer", 3, 15, 1, "Sample")); + } + + [Fact] + public void PopulatesTypeNameForNamespaceQualifiedType() + { + string source = """ + namespace Demo; + class Sample + { + int M() + { + return 1; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 4, 7, 1, "Demo.Sample")); + } + + [Fact] + public void PopulatesTypeNameForNestedTypes() + { + string source = """ + namespace Demo; + class Outer + { + class Inner + { + int M() + { + return 1; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 6, 9, 1, "Demo.Outer.Inner")); + } + + [Fact] + public void PopulatesTypeNameForGenericArity() + { + string source = """ + namespace Demo; + class Container + { + int First() + { + return 1; + } + } + class Outer + { + class Inner + { + int Second() + { + return 2; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal( + new MethodDescriptor("First", 4, 7, 1, "Demo.Container`1"), + new MethodDescriptor("Second", 13, 16, 1, "Demo.Outer`1.Inner`2")); + } + + [Fact] + public void PopulatesTypeNameForGlobalNamespace() + { + string source = """ + class Sample + { + int M() + { + return 1; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 3, 6, 1, "Sample")); + } + + [Fact] + public void IncludesExpressionBodiedMethods() + { + string source = """ + class Sample + { + int M() => 1; + int N(bool a) => a ? 1 : 0; + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal( + new MethodDescriptor("M", 3, 3, 1, "Sample"), + new MethodDescriptor("N", 4, 4, 2, "Sample")); + } + + [Fact] + public void CountsSwitchExpressionArmsAndWhenGuards() + { + // base(1) + arm1 SwitchExpressionArm(+1) + `and` BinaryPattern(+1) + // + arm2 SwitchExpressionArm(+1) + WhenClause(+1) + // + arm3 SwitchExpressionArm(+1) = 6. + string source = """ + class Sample + { + int M(int x, bool cond) + { + return x switch + { + > 0 and < 10 => 1, + _ when cond => 2, + _ => 0, + }; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 3, 11, 6, "Sample")); + } + + [Fact] + public void CountsNullCoalescingAndPatternCombinators() + { + // One decision family per method: ?? (CoalesceExpression), ??= (CoalesceAssignmentExpression), + // pattern `not` (UnaryPattern), pattern `or` (BinaryPattern) -- each raises base(1) to 2. + string source = """ + class Sample + { + int Coalesce(int? a) + { + return a ?? 0; + } + + void CoalesceAssign(ref int? a) + { + a ??= 0; + } + + bool NotNull(object? o) + { + return o is not null; + } + + bool OrPattern(object o) + { + return o is int or string; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal( + new MethodDescriptor("Coalesce", 3, 6, 2, "Sample"), + new MethodDescriptor("CoalesceAssign", 8, 11, 2, "Sample"), + new MethodDescriptor("NotNull", 13, 16, 2, "Sample"), + new MethodDescriptor("OrPattern", 18, 21, 2, "Sample")); + } + + [Fact] + public void CountsGuardedCatchFilter() + { + // catch (E) when (c) is a deliberate double-count: CatchClause(+1) AND CatchFilterClause(+1), + // raising base(1) to 3. + string source = """ + class Sample + { + int M(bool c) + { + try + { + return 1; + } + catch (Exception) when (c) + { + return 2; + } + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 3, 13, 3, "Sample")); + } + + [Fact] + public void CountsDecisionsInsideLambdasAndLocalFunctions() + { + // Departure #2: the walker descends into lambdas and local functions, so their decisions raise + // the enclosing method's CC -- base(1) + lambda if(+1) + lambda &&(+1) + local-fn ?:(+1) = 4 -- + // yet neither body is collected as a separate descriptor. + string source = """ + class Sample + { + int Outer(bool a, bool b) + { + Action lambda = () => + { + if (a && b) + { + } + }; + int Local(int x) + { + return x > 0 ? 1 : 0; + } + lambda(); + return Local(1); + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("Outer", 3, 17, 4, "Sample")); + } + + [Fact] + public void DoesNotCountElseTryFinallyJumpsOrBitwise() + { + // Negative oracle for the don't-count list: else, try/finally, throw, bitwise &/|/^, ?. and a + // combinator-free `is` pattern all add nothing -- only the single `if` counts, so base(1) -> 2. + string source = """ + class Sample + { + int M(bool a, int x, int y, string? s, object o) + { + if (a) + { + throw new Exception(); + } + else + { + x = y & 1; + x = y | 2; + x = y ^ 3; + } + + try + { + int? len = s?.Length; + } + finally + { + bool flag = o is string; + } + + return x; + } + } + """; + + IReadOnlyList methods = CSharpMethodParser.Parse(source); + + methods.Should().Equal(new MethodDescriptor("M", 3, 26, 2, "Sample")); + } +} From 87488ce6f27798cc292f48862ace671fe5ddf0c5 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 21:47:37 -0700 Subject: [PATCH 19/27] T10: CoberturaCoverageParser (secure XML; frozen-reciprocal normalize; behavioral XXE) Final task of Slice S3. C# analog of crap4java JacocoCoverageParser, re-hosted on Coverlet -> Cobertura per-method counters (departure #4, line-based MissedLines/CoveredLines). Implemented strictly to Anders's locked contract; Bhaskar PASS (68/68 deterministic x2, Release 0/0); Anders review GREEN. - public static class CoberturaCoverageParser { Parse(string?), NormalizeTypeName, IsCompilerGeneratedMethod }. File-scoped ns, nullable on, no internal, no suppressions. - Frozen TypeName reciprocal (LOAD-BEARING): NormalizeTypeName = Replace('/','.').Replace('+','.') keeping backtick arity -> byte-for-byte equal to T9 CSharpMethodParser.TypeNameOf. Pinned by test 11 (D-table) + test 10 (real coverlet sample). D-T10b RESOLVED (keep arity); R3 normalization pin CLOSED. - Secure XML (D-T10a RESOLVED): DtdProcessing.Ignore + XmlResolver=null; external entities never resolved. Java's white-box configuresSecureFactoryFeatures replaced by BEHAVIORAL XXE test N10 (canary + file:// entity, asserts throw + canary never read). DOCTYPE-tolerance test ports faithfully under Ignore. Not a new behavioral departure. - Walk class->methods->method->lines->line only; class-level aggregate skipped (no double-count); ignored. Key = TypeName#method:minChildLine. Skip predicate rules 1-4 + W-T10c (synthetic MoveNext skipped via angle-bracket class; real MoveNext kept). Edges faithful to Java: null/missing/empty -> empty ordinal map (no throw); malformed -> InvalidOperationException("Unable to parse Cobertura XML..."). - 12 tests (3 faithful Java ports + empty-report/null/missing/malformed + real-coverlet R3 pin + skip-predicate + D-table + behavioral XXE). Suite 56 -> 68 green. Docs: decisions.md gains the T10 register (D-T10a/b RESOLVED, R3 CLOSED, W-T10a/b/c) + "T10 discharged" note on the frozen reciprocal. Feature file: W-T10a/b/c watch-items, D-T10a/b RESOLVED, T10 -> Done. Back-filled T9 SHA (400d49d). S3 COMPLETE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 56 ++ docs/features/crap4csharp-port.md | 35 +- src/Crap4CSharp/CoberturaCoverageParser.cs | 163 ++++++ .../CoberturaCoverageParserTests.cs | 505 ++++++++++++++++++ 4 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 src/Crap4CSharp/CoberturaCoverageParser.cs create mode 100644 tests/Crap4CSharp.Tests/CoberturaCoverageParserTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index d382282..3681cb4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -85,6 +85,62 @@ on either side silently breaks coverage attribution (affected methods fall throu coverlet sample** (R3). See watch-item **W-T9a**. - **Consumer — `CrapAnalyzer` (T11):** builds coverage-map / method-lookup keys from this form. Do not change one producer without the other. +- **T10 discharged (Anders T10 review, 🟢).** Producer 2 now emits this form byte-for-byte: + `NormalizeTypeName` = `rawClassName.Replace('/', '.').Replace('+', '.')` (backtick arity **kept**), + pinned by `NormalizeTypeNameProducesFrozenReciprocalForm` (test 11, D-table) and by the real + coverlet strings in `MatchesRealCoverletSampleClassNames` (test 10). See the **T10 register** below. + +## T10 register — `CoberturaCoverageParser` (S3 close-out; Anders T10 review, 🟢) + +Resolved decisions, R3 closure, and watch-items from the final S3 task. `CoberturaCoverageParser` +is a faithful port of `JacocoCoverageParser` re-hosted on Coverlet→Cobertura per-method `` +counters (departure #4). **No new behavioral departure beyond the already-approved set.** + +- **D-T10a — behavioral XXE test replaces Java's white-box flag test (RESOLVED).** Java's + `configuresSecureFactoryFeatures` inspected `DocumentBuilderFactory` feature flags; the C# port has + no equivalent introspection seam, so the faithful counterpart is the **behavioral** test + `DoesNotResolveExternalEntities` (N10): a canary file referenced through an internal-subset external + entity via `file://` must never be read into the parse. Mechanism — + `XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }` — gives the + **observably identical** guarantee (no external DTD/entity/XInclude is ever fetched). `Ignore` (not + `Prohibit`) is deliberate: `XmlResolver = null` already blocks every external fetch, and `Ignore` + keeps the DOCTYPE-tolerance test (`ParsesXmlWithDoctypeWithoutRequiringLocalDtdFile`) faithful while + a malicious `&xxe;` reference still fails safely as an undeclared entity → wrapped + `InvalidOperationException`, no file read/network/substitution. This is a **security/fidelity + mechanism swap, NOT a new behavioral departure** — no departure number is assigned. + +- **D-T10b — `NormalizeTypeName` keeps the backtick generic arity (RESOLVED).** The normalizer is the + mechanical inverse of T9's `CSharpMethodParser.TypeNameOf`: nested separators (`/` or `+`) → `.`, + per-level `` `N `` arity **kept untouched**, dotted namespace already emitted by coverlet, global + namespace already bare. Discharged by `NormalizeTypeNameProducesFrozenReciprocalForm` (test 11, the + full D-table) and by the real-sample forms in `MatchesRealCoverletSampleClassNames` (test 10: + ``Sample.Outer`1/Inner`2`` → ``Sample.Outer`1.Inner`2``, ``Sample.Container`1`` kept). + +- **R3 — real-coverlet normalization pin CLOSED for T10.** Final `NormalizeTypeName` rule: + `rawClassName.Replace('/', '.').Replace('+', '.')` — nested `/` **and** `+` → `.`, keep per-level + `` `N `` arity, global-namespace chain left bare. Pinned against a **real** coverlet Cobertura sample + in test 10 (verbatim `class @name`/`method @name`/`` strings), closing R3's "pin against a real + coverlet sample" for the plain-FQN, nested, generic and compiler-generated forms. **Empirical note:** + real coverlet emits nested-type separators as `/` (not `+`); `NormalizeTypeName` handles both, so the + `+` branch is the defensive path for older tooling / the alternate spelling in the frozen contract. + +**Watch-items (T10 review — visibility for Mr. Das; none blocks T10):** + +- **W-T10a — async/iterator coverage-attribution gap (instance of R3).** Coverlet attributes an + `async`/iterator method's body coverage to the synthetic state machine (`Outer+d__N` / its + `MoveNext`), which T10 skips (W-T9b/W-T10c). Since T9 emits the real `M`, such methods have no + matching coverage entry and resolve to per-method `N/A` in T11 — consistent with departure #1 + ("per-method `N/A` unchanged"), **not a new departure**. Visibility: async-heavy targets will show + more `N/A` than the Java tool would. +- **W-T10b — `get_`/`set_`/`add_`/`remove_` prefix-skip false-positive (rule 3).** A user method + literally named `get_Foo` (with an underscore) would be wrongly skipped → `N/A`. Negligible + probability; sanctioned by W-T9b. Logged. +- **W-T10c — synthetic-`MoveNext` skip via containing class (ACCEPTED, ratified).** The skip predicate + does **not** blanket-skip `MoveNext` by name; a synthetic `MoveNext` is caught by its angle-bracket + state-machine class (rule 1), while a user-authored `IEnumerator.MoveNext` on a real class is kept and + attributed (T9 emits it). This is the more-faithful reading of W-T9b's "skip *synthesized* members," + ratified as the accepted choice (not a departure); pinned both ways by + `SkipsSyntheticStateMachineClassButKeepsRealMoveNext` (test 9). ## Deliberate departures from crap4java (approved by Mr. Das) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 934719d..32dcf45 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S3 nearly complete** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 next commit); 56 tests green, 0/0 Release. T9 parser landed; **T10 (`CoberturaCoverageParser`) in flight** — the last S3 task. Then S4. +**Status:** In progress — **S3 COMPLETE** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 next commit); **68 tests green, 0/0 Release**. All parsers + adapters landed. **Paused at the S3 boundary** for Mr. Das; next slice = S4 (T11 `CrapAnalyzer` → T12 → T14 → T15 → T16). ## Requirements @@ -52,8 +52,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T6 | S3 | `ICommandExecutor` + `ProcessCommandExecutor` + tests | Done | `da02028` | | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `c023ef8` | -| T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `(next commit)` | -| T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Pending | - | +| T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `400d49d` | +| T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Done | `(next commit)` | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Pending | - | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | @@ -260,6 +260,23 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an (none can), so the key stays unambiguous. Reconfirm the exact key shape when T10/T11 land; the binding invariant is that emit (T9), produce (T10) and consume (T11) share the frozen `TypeName` form. +### Carry-forward watch-items (from Anders's T10 review — must be honored at the noted task) + +- **W-T10a — async/iterator coverage-attribution gap (T11 visibility).** Coverlet attributes an + `async`/iterator body's coverage to the synthetic state machine (`Outer+d__N`/`MoveNext`), which + T10 skips (W-T9b/W-T10c). T9 emits the real `M`, so such methods have no matching coverage entry and + resolve to per-method `N/A` in T11 — consistent with departure #1 (**not** a new departure). + Async-heavy targets will show more `N/A` than the Java tool; surfaced for Mr. Das's visibility. + Recorded in `docs/decisions.md` (T10 register). +- **W-T10b — accessor-prefix skip false-positive (T11).** The rule-3 `get_`/`set_`/`add_`/`remove_` + prefix skip has a negligible false-positive on a real method literally named `get_Foo` + (underscored) → `N/A`. Sanctioned by W-T9b; logged. +- **W-T10c — synthetic `MoveNext` skipped via its containing class (ACCEPTED).** T10 does not + blanket-skip `MoveNext` by name; the synthetic one is caught by its angle-bracket state-machine class + (rule 1) while a user-authored `IEnumerator.MoveNext` on a real class is kept and attributed. Ratified + as the more-faithful reading of W-T9b (not a departure); pinned both ways by test 9 + (`SkipsSyntheticStateMachineClassButKeepsRealMoveNext`). + ### Open decision for Mr. Das (from T5, design-lane) - **D-T5 — CA1062 `ArgumentNullException.ThrowIfNull` idiom.** *(RESOLVED — Mr. Das ruled @@ -304,6 +321,18 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an `workspaceRoot` fallback. Recorded as **departure #6** in `docs/decisions.md`; T13 shipped as-is (`e4d1329`), no rework. Below the behavioral bar for realistic C# layouts. +- **D-T10a — behavioral XXE test vs. Java's white-box factory-flag test.** *(RESOLVED — ruled: port + behaviorally; N10 landed.)* Java's `configuresSecureFactoryFeatures` inspected + `DocumentBuilderFactory` flags; the C# port asserts the **observable** guarantee instead — a canary + referenced via an external entity is never resolved (`DoesNotResolveExternalEntities`). Mechanism: + `XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null }`. Security is + identical (resolver-null blocks all external fetches); `Ignore` keeps the DOCTYPE-tolerance test + faithful. **Not a new behavioral departure** — see the T10 register in `docs/decisions.md`. +- **D-T10b — `NormalizeTypeName` keeps the backtick arity.** *(RESOLVED — keep arity.)* The T10 + normalizer is the mechanical inverse of T9's `TypeNameOf`: nested `/`+`+` → `.`, per-level `` `N `` + arity **kept**, global namespace bare. Discharged by test 11 (D-table) + test 10 (real coverlet + forms). The load-bearing frozen-reciprocal property is preserved byte-for-byte. + ### S5/S6 finale — design constraints (design-lane) - **D-S6 (design — resolved):** Per guardrail #2, `../mutate4csharp` and `../dry4csharp` are READ-ONLY diff --git a/src/Crap4CSharp/CoberturaCoverageParser.cs b/src/Crap4CSharp/CoberturaCoverageParser.cs new file mode 100644 index 0000000..3d93610 --- /dev/null +++ b/src/Crap4CSharp/CoberturaCoverageParser.cs @@ -0,0 +1,163 @@ +namespace Microsoft.Crap4CSharp; + +using System.Globalization; +using System.Xml; +using System.Xml.Linq; + +// Faithful port of crap4java's JacocoCoverageParser, re-hosted on the Coverlet -> Cobertura XML +// coverage format (docs/decisions.md: Coverage row). Java read JaCoCo's INSTRUCTION counters; this +// port reads Cobertura per-method elements and counts LINES (departure #4; risk R1). The +// parsing algorithm, keying scheme, null/missing/malformed handling and skip semantics are otherwise +// preserved. Ratified adaptations, all recorded in docs/decisions.md: +// * D-T10a -- secure XML read via XmlReaderSettings (XmlResolver = null so no external DTD/entity/ +// XInclude is ever fetched) instead of Java's DocumentBuilderFactory feature flags. DtdProcessing +// is Ignore (not Prohibit): the resolver already blocks every external fetch, and Ignore keeps the +// DOCTYPE-tolerance test faithful while still failing safely on a malicious entity reference. +// * D-T10b -- NormalizeTypeName keeps the backtick generic arity untouched, emitting the frozen +// reciprocal TypeName form that CSharpMethodParser.TypeNameOf produces (the load-bearing coverage +// key -- any drift silently collapses a method's coverage to N/A). +// * Key line -- Cobertura's carries no declaration-line attribute, so the key uses the MIN +// of the method's values; T11's exact->nearest lookup (a faithful port of +// CrapAnalyzer.lookupCoverage) resolves it into the method's own StartLine range. +// Java's non-instantiable `final class` with a private ctor maps to the idiomatic C# `static class` +// (public because the `internal` modifier is banned in the single-assembly design -- ratified C1). +public static class CoberturaCoverageParser +{ + private static readonly char[] AngleBrackets = ['<', '>']; + + // Ports parse(Path). A null or non-existent path yields an empty map (Java returns Map.of()), so + // the parameter is nullable BY DESIGN and this method never throws for those two cases; module-level + // fail-fast (departure #1) handles the empty map downstream. Any parse failure is wrapped as + // InvalidOperationException (the repo's analog of Java's IllegalStateException). + public static IReadOnlyDictionary Parse(string? coberturaXmlPath) + { + if (coberturaXmlPath is null || !File.Exists(coberturaXmlPath)) + { + return new Dictionary(StringComparer.Ordinal); + } + + try + { + // D-T10a: XmlResolver = null blocks every external fetch (DTD/entity/XInclude) -- the actual + // XXE surface; DtdProcessing.Ignore tolerates (but never resolves) a DOCTYPE. + XmlReaderSettings settings = new() + { + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null, + }; + using XmlReader reader = XmlReader.Create(coberturaXmlPath, settings); + XDocument document = XDocument.Load(reader); + + // StringComparer.Ordinal mirrors Java HashMap's ordinal key semantics and keeps keys aligned + // with T11's ordinal prefix lookup (departure #3 determinism). + Dictionary coverage = new(StringComparer.Ordinal); + foreach (XElement classElement in document.Descendants("class")) + { + string rawClassName = classElement.Attribute("name")?.Value ?? string.Empty; + string typeName = NormalizeTypeName(rawClassName); + ReadClassMethods(classElement, rawClassName, typeName, coverage); + } + + return coverage; + } + catch (Exception ex) + { + throw new InvalidOperationException("Unable to parse Cobertura XML: " + coberturaXmlPath, ex); + } + } + + // The frozen-reciprocal normalizer (docs/decisions.md "Frozen reciprocal contract"). Coverlet already + // emits the namespace dotted, the backtick arity per level, and a bare chain in the global namespace; + // the only work is turning the nested-type separators ('/' or '+') into '.', which is the mechanical + // inverse of CSharpMethodParser.TypeNameOf. Public so the reciprocity-table tests can call it directly + // (the `internal` modifier is banned -- C1). + public static string NormalizeTypeName(string rawClassName) + { + ArgumentNullException.ThrowIfNull(rawClassName); + return rawClassName.Replace('/', '.').Replace('+', '.'); + } + + // The compiler-generated skip predicate (W-T9b/W-T10c). Returns true (skip) when EITHER the method's + // own name OR its containing class is synthetic. Takes the raw (un-normalized) class name so the + // angle-bracket state-machine / display-class markers are still visible. Public for the same + // least-privilege-testable reason as NormalizeTypeName. + public static bool IsCompilerGeneratedMethod(string rawClassName, string methodName) + { + ArgumentNullException.ThrowIfNull(rawClassName); + ArgumentNullException.ThrowIfNull(methodName); + + // Rule 1: a synthetic containing class (async/iterator state machines Outer+d__N, lambda + // display classes Outer+<>c / Outer+<>c__DisplayClassN) -- skips all of its methods, including a + // synthetic MoveNext (W-T10c: a user-authored MoveNext lives on a real class and is kept). + if (rawClassName.IndexOfAny(AngleBrackets) >= 0) + { + return true; + } + + // Rule 2: an angle-bracket display method hosted on a real class (lambdas b__x_y, local + // functions g__L|x_y). + if (methodName.IndexOfAny(AngleBrackets) >= 0) + { + return true; + } + + // Rule 3: property/event accessors -- T9 never emits these (they are accessor declarations, not + // method declarations). + if (methodName.StartsWith("get_", StringComparison.Ordinal) + || methodName.StartsWith("set_", StringComparison.Ordinal) + || methodName.StartsWith("add_", StringComparison.Ordinal) + || methodName.StartsWith("remove_", StringComparison.Ordinal)) + { + return true; + } + + // Rule 4: instance/static constructors -- T9 excludes constructors. + return methodName is ".ctor" or ".cctor"; + } + + // Ports readClassMethods: walk ONLY //, and per method ONLY its / + // children. The sibling class-level aggregate (which duplicates the union of all method lines) + // is deliberately never descended into, and attributes/children beyond @number/@hits (branch, + // condition-coverage, ) are ignored. + private static void ReadClassMethods( + XElement classElement, + string rawClassName, + string typeName, + Dictionary coverage) + { + XElement? methodsElement = classElement.Element("methods"); + if (methodsElement is null) + { + return; + } + + foreach (XElement method in methodsElement.Elements("method")) + { + string methodName = method.Attribute("name")?.Value ?? string.Empty; + if (IsCompilerGeneratedMethod(rawClassName, methodName)) + { + continue; + } + + List lines = method.Element("lines")?.Elements("line").ToList() ?? []; + if (lines.Count == 0) + { + // Faithful analog of Java's null INSTRUCTION counter -> skip. A method that HAS lines but + // all hits == 0 is a legitimate 0% entry and is kept. + continue; + } + + int coveredLines = lines.Count(line => ParseIntOrZero(line.Attribute("hits")?.Value) > 0); + int missedLines = lines.Count(line => ParseIntOrZero(line.Attribute("hits")?.Value) == 0); + int minChildLine = lines.Min(line => ParseIntOrZero(line.Attribute("number")?.Value)); + + string key = typeName + "#" + methodName + ":" + minChildLine.ToString(CultureInfo.InvariantCulture); + coverage[key] = new CoverageData(missedLines, coveredLines); + } + } + + // Ports parseInt(String)->0-on-failure, applied to both @hits and @number; InvariantCulture keeps it + // locale-independent (departure #3). + private static int ParseIntOrZero(string? value) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : 0; +} diff --git a/tests/Crap4CSharp.Tests/CoberturaCoverageParserTests.cs b/tests/Crap4CSharp.Tests/CoberturaCoverageParserTests.cs new file mode 100644 index 0000000..9bf4c7c --- /dev/null +++ b/tests/Crap4CSharp.Tests/CoberturaCoverageParserTests.cs @@ -0,0 +1,505 @@ +namespace Microsoft.Crap4CSharp.Tests; + +using System.Xml; + +// Faithful port of crap4java's JacocoCoverageParserTest, re-hosted on the Coverlet -> Cobertura format. +// The three Java tests are ported with identical intent and oracle values (90.0 / 0.0 percentages, the +// ":0" invalid-line key shape); the JaCoCo mechanism is adapted to +// Cobertura per-method counters (departure #4). Java's configuresSecureFactoryFeatures (which +// inspected DocumentBuilderFactory flags) is replaced by the behavioral XXE test DoesNotResolveExternal- +// Entities (D-T10a). The remaining tests pin the C#-only surface: the empty/null/missing/malformed edge +// cases, the compiler-generated/accessor/state-machine skip predicate (W-T9b/W-T10c), the frozen +// reciprocal NormalizeTypeName forms (D-table), and -- against REAL coverlet output regenerated outside +// this repo -- the nested/generic/async class-name normalization plus synthetic/accessor skipping (R3). +// Each test writes its fixture to a fresh temp file and always deletes it in a finally (mirrors the +// @TempDir pattern used by ChangedFileDetectorTests). Test method names are PascalCase (CA1707/C2). +public class CoberturaCoverageParserTests +{ + [Fact] + public void ParsesCoverageByClassAndMethod() + { + // Alpha: 9 covered (lines 10-18) + 1 missed (line 19) -> 90%. Beta: 1 missed (line 20) -> 0%. + // The class-level aggregate duplicates line 10 to prove it is never double-counted. + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result["Demo.Sample#Alpha:10"].CoveragePercent.Should().BeApproximately(90.0, 0.001); + result["Demo.Sample#Beta:20"].CoveragePercent.Should().BeApproximately(0.0, 0.001); + }); + } + + [Fact] + public void ParsesXmlWithDoctypeWithoutRequiringLocalDtdFile() + { + // A DOCTYPE naming an external DTD that does not exist on disk. DtdProcessing.Ignore tolerates + // the DOCTYPE and XmlResolver = null never fetches coverage.dtd, so the body still parses. + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result["Demo.Sample#Alpha:10"].CoveragePercent.Should().BeApproximately(90.0, 0.001); + }); + } + + [Fact] + public void ParsesInvalidLineNumbersAsZero() + { + // The lowest line's number is non-numeric ("oops") and degrades to 0, becoming minChildLine -> + // the key ends in ":0". Coverage is still 9 covered (oops + 11-18) + 1 missed (19) = 90%. + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result["Demo.Sample#Alpha:0"].CoveragePercent.Should().BeApproximately(90.0, 0.001); + }); + } + + [Fact] + public void ReturnsEmptyMapForEmptyReport() + { + string xml = """ + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result.Should().BeEmpty(); + }); + } + + [Fact] + public void ReturnsEmptyMapForNullPath() + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(null); + + result.Should().BeEmpty(); + } + + [Fact] + public void ReturnsEmptyMapForMissingFile() + { + string missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cobertura.xml"); + + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(missing); + + result.Should().BeEmpty(); + } + + [Fact] + public void ThrowsInvalidOperationExceptionForMalformedXml() + { + WithTempFile(" + { + Action act = () => CoberturaCoverageParser.Parse(path); + + act.Should().Throw() + .WithMessage("*Cobertura*") + .WithInnerException(); + }); + } + + [Fact] + public void SkipsCompilerGeneratedAndAccessorMethods() + { + // get_Value/set_Value (accessors, rule 3), .ctor (rule 4) and b__0_0 (angle-bracket + // method, rule 2) all carry lines, yet only the real method Real survives. + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result.Should().HaveCount(1); + result.Should().ContainKey("Demo.Sample#Real:9"); + result.Keys.Should().OnlyContain(k => k == "Demo.Sample#Real:9"); + }); + } + + [Fact] + public void SkipsSyntheticStateMachineClassButKeepsRealMoveNext() + { + // The synthetic state-machine class Demo.C+d__0 is skipped wholesale via rule 1 (its + // angle brackets), taking its MoveNext/SetStateMachine with it; a user-authored MoveNext on the + // real class Demo.Enumerator is kept (W-T10c: no blanket MoveNext name-skip). + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + result.Should().ContainKey("Demo.Enumerator#MoveNext:20"); + result.Keys.Should().NotContain(k => + k.Contains("d__0", StringComparison.Ordinal) + || k.Contains("DoAsync", StringComparison.Ordinal) + || k.Contains("SetStateMachine", StringComparison.Ordinal)); + }); + } + + [Fact] + public void MatchesRealCoverletSampleClassNames() + { + // VERBATIM class @name / method @name / strings emitted by a real `dotnet test + // --collect:"XPlat Code Coverage"` run (regenerated outside this repo, see the test-file note): + // * CrapScore.Calculate -- the on-disk dev sample anchor (5 covered lines) -> 100%. + // * Sample.Outer/Inner -> Sample.Outer.Inner (nested '/' -> '.') + // * Sample.Outer`1/Inner`2 -> Sample.Outer`1.Inner`2 (generic arity kept) + // * Sample.Container`1 -- get_Item accessor skipped, Count kept + // * Sample.Worker -- get_Prop accessor skipped, UseLambda (captured lambda + // inlined) kept as 10 covered lines + // * Sample.Worker/d__4 -- async state machine, whole class skipped (rule 1) + // This pins the frozen reciprocal AND the skip predicate against genuine coverlet output (R3). + string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempFile(xml, path => + { + IReadOnlyDictionary result = CoberturaCoverageParser.Parse(path); + + // Plain-FQN anchor: 5 covered lines, aggregate + ignored -> 100%. + result.Should().ContainKey("Microsoft.Crap4CSharp.CrapScore#Calculate:11") + .WhoseValue.Should().Be(new CoverageData(0, 5)); + + // Frozen reciprocal against REAL coverlet '/'-separated + backtick-arity names. + result.Should().ContainKey("Sample.Outer.Inner#Value:9") + .WhoseValue.Should().Be(new CoverageData(0, 1)); + result.Should().ContainKey("Sample.Outer`1.Inner`2#Combine:17") + .WhoseValue.Should().Be(new CoverageData(0, 1)); + result.Should().ContainKey("Sample.Container`1#Count:25") + .WhoseValue.Should().Be(new CoverageData(0, 1)); + result.Should().ContainKey("Sample.Worker#UseLambda:39") + .WhoseValue.Should().Be(new CoverageData(0, 10)); + + // The synthetic state-machine class, the accessors and the constructor are all absent. + result.Keys.Should().NotContain(k => + k.Contains('<') + || k.Contains('>') + || k.Contains("d__", StringComparison.Ordinal) + || k.Contains("MoveNext", StringComparison.Ordinal) + || k.Contains("get_", StringComparison.Ordinal) + || k.Contains(".ctor", StringComparison.Ordinal)); + }); + } + + [Fact] + public void NormalizeTypeNameProducesFrozenReciprocalForm() + { + CoberturaCoverageParser.NormalizeTypeName("Demo.Sample").Should().Be("Demo.Sample"); + CoberturaCoverageParser.NormalizeTypeName("Demo.Outer+Inner").Should().Be("Demo.Outer.Inner"); + CoberturaCoverageParser.NormalizeTypeName("Demo.Outer/Inner").Should().Be("Demo.Outer.Inner"); + CoberturaCoverageParser.NormalizeTypeName("Demo.Container`1").Should().Be("Demo.Container`1"); + CoberturaCoverageParser.NormalizeTypeName("Demo.Outer`1+Inner`2").Should().Be("Demo.Outer`1.Inner`2"); + CoberturaCoverageParser.NormalizeTypeName("Sample").Should().Be("Sample"); + } + + [Fact] + public void DoesNotResolveExternalEntities() + { + // Behavioral XXE proof (D-T10a, replaces Java's configuresSecureFactoryFeatures). A canary file + // is referenced through an internal-subset external entity via file://; the parse must fail + // WITHOUT ever reading the canary into the document. + string canaryPath = Path.Combine(Path.GetTempPath(), "canary-" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(canaryPath, "SECRET-CANARY-" + Guid.NewGuid().ToString("N")); + string canaryUri = new Uri(canaryPath).AbsoluteUri; + string maliciousPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cobertura.xml"); + + string xml = $""" + + ]> + + + + + + """; + File.WriteAllText(maliciousPath, xml); + + try + { + Action act = () => CoberturaCoverageParser.Parse(maliciousPath); + + act.Should().Throw() + .Which.ToString().Should().NotContain("SECRET-CANARY"); + } + finally + { + if (File.Exists(canaryPath)) + { + File.Delete(canaryPath); + } + + if (File.Exists(maliciousPath)) + { + File.Delete(maliciousPath); + } + } + } + + private static void WithTempFile(string content, Action test) + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cobertura.xml"); + File.WriteAllText(path, content); + try + { + test(path); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} From 17a3c3c38a6de115b8352da5e65d72cda4987118 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 22:01:17 -0700 Subject: [PATCH 20/27] docs: insert S5 (module & test resolution finalization); renumber eval->S6, dogfood->S7 Mr. Das added a closing slice. There are now THREE closing slices, in order: - S5 (NEW) Module & test resolution finalization (depends S4): resolve the file->module->test model (options a keep locked .sln-first / b adopt mutate4csharp nearest-.csproj + .Tests/.UnitTests / c hybrid, per ../mutate4csharp README "Module & Test Resolution"); Mr. Das rules WHEN THE SLICE RUNS; then rework ModuleRootResolver (T13) + shape T12/T14. Locked now: keep running ALL module tests (crap4java parity), NO unit-only [Trait] filter (distinct from the open D-T8 dev-loop tagging question). - S6 Independent clean-room evaluation (gpt-5.6-sol; formerly S5; spec unchanged; depends S5). - S7 Real-world dogfooding (formerly S6, the finale; spec unchanged; depends S6). Tasks renumbered: T17=S5 module resolution (new, first), T18=S6 eval (former T17 verbatim), T19=S7 dogfood (former T18 verbatim). R6 + D-S6 + ordering note keep their stable labels; internal slice/task refs updated S6->S7 / T18->T19. Added an engineering watch-item: keep T12/T14 decoupled behind the ModuleRootResolver seam so the later S5 model swap is cheap. NO code / resolution-model change now: the port keeps driving on the locked .sln-first model through S3->S4; S5 is the only place the model is revisited. Docs-only; Anders authored the restructure, JARVIS landed it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/features/crap4csharp-port.md | 53 ++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 32dcf45..81fac47 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -35,8 +35,9 @@ the augmented complexity node set, and approved deliberate departures are in `do | S2 | Pure core (CRAP formula, report, CLI parse, domain types) with parity tests | S1 | | S3 | Ecosystem adapters (process exec, file finders, Roslyn parser, Cobertura parser) with parity tests | S1 | | S4 | Composition + CLI wiring + fail-fast + end-to-end | S2, S3 | -| S5 | Independent clean-room evaluation: a neutral evaluator on gpt-5.6-sol judges the delivered port against the VERBATIM original Requirements block (report-only) | S4 | -| S6 | Real-world dogfooding: run the finished crap4csharp on three real C# repos — crap4csharp, mutate4csharp, dry4csharp — producing actual CRAP reports | S4, S5 | +| S5 | Module & test resolution finalization: resolve the file→module→test resolution model (Anders presents options a/b/c; Mr. Das rules), then implement the chosen model — rework T13 `ModuleRootResolver` and shape T12/T14 as needed | S4 | +| S6 | Independent clean-room evaluation: a neutral evaluator on gpt-5.6-sol judges the delivered port against the VERBATIM original Requirements block (report-only) | S5 | +| S7 | Real-world dogfooding: run the finished crap4csharp on three real C# repos — crap4csharp, mutate4csharp, dry4csharp — producing actual CRAP reports | S6 | ## Tasks (Tx) @@ -60,8 +61,9 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | -| T17 | S5 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | -| T18 | S6 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | +| T17 | S5 | Module & test resolution finalization: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | +| T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | +| T19 | S7 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 and T6/T7/T8/T13 parallelize early. @@ -78,7 +80,7 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an - R5: FluentAssertions v8 licensing — the `[7.0.0,8.0.0)` pin + lock file must hold. - R6: crap4csharp requires each target to have a test project referencing `coverlet.collector` and a resolvable module root (`.sln`), else fail-fast fires. `mutate4csharp`/`dry4csharp` may not satisfy - this and cannot be modified in place — S6 may surface tool gaps or need target prerequisites handled + this and cannot be modified in place — S7 may surface tool gaps or need target prerequisites handled on the copies. (Noted, not solved now.) ## Assumptions (Ax) @@ -333,14 +335,41 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an arity **kept**, global namespace bare. Discharged by test 11 (D-table) + test 10 (real coverlet forms). The load-bearing frozen-reciprocal property is preserved byte-for-byte. -### S5/S6 finale — design constraints (design-lane) - +### S5/S6/S7 finale — design constraints (design-lane) + +- **D-S5 (design — deferred to when S5 runs; options-only now):** S5 finalizes the + file→module→test resolution model before the port is "done-done" and before the real-world slices + (S6 eval / S7 dogfood). The port keeps driving on the **locked `.sln`-first model** (decisions.md + "Module root" locked choice + departure #6; T13 shipped `e4d1329`) through S3→S4; **S5 is the only + place the model is revisited/decided/reworked — do NOT change T13 or the resolution model before + S5.** Reference for writing the options faithfully: `../mutate4csharp` README §"Module & Test + Resolution" (READ-ONLY sibling — do not write there). + - **Options Anders presents / Mr. Das rules on WHEN THE SLICE RUNS (not now):** **(a)** keep the + locked `.sln`-first model — crap4java §6 parity, T13 as shipped; **(b)** adopt mutate4csharp's + model — owning project = nearest `.csproj` above the target `.cs` (file name w/o extension = + ``); test project = `.Tests.csproj` **or** `.UnitTests.csproj` whose + project references (transitively) include `.csproj`, and only that project's tests run + (fail fast if no owning `.csproj`/test project is found); **(c)** hybrid — `.sln` grouping but + borrow the `.Tests`/`.UnitTests` convention to select and run the right test project. Then + implement the chosen model: rework `ModuleRootResolver` (T13); shape T12/T14. + - **Locked constraint on this slice (Mr. Das already ruled):** do **NOT** adopt mutate4csharp's + unit-only `[Trait("type", …)]` filtering (which keeps `UnitTests`/`Unit`/untagged and excludes + `IntegrationTests`) — crap4csharp keeps running **ALL** module tests (crap4java parity). This is + **distinct** from the separate **open D-T8** agentic-loop fast-loop tagging question, which + governs **our own** dev-loop test suite (`[Trait("Category", "Integration")]` on process/CLI + tests), **not** how the tool runs a *target's* tests. + - **Engineering watch-item (honor during S4):** keep **T12 (`CoverageRunner`)** and **T14 + (fail-fast gate)** cleanly decoupled **behind the existing `ModuleRootResolver` abstraction**, so + the later S5 model swap is cheap — localized to the resolver seam, not spread through T12/T14. +- **Label-stability note:** the **D-S6** and **R6** labels below are **retained unchanged** for + reference stability (Mr. Das still refers to "D-S6/R6"), even though the dogfood slice/task + renumbered **S6→S7 / T18→T19**; only their internal slice/task references are updated. - **D-S6 (design — resolved):** Per guardrail #2, `../mutate4csharp` and `../dry4csharp` are READ-ONLY - sibling repos; `crap4csharp` itself is write-scoped to this repo only. Because S6 runs - `dotnet test --collect`, which writes build/coverage artifacts (`bin`/`obj`/`TestResults`), S6 MUST + sibling repos; `crap4csharp` itself is write-scoped to this repo only. Because S7 runs + `dotnet test --collect`, which writes build/coverage artifacts (`bin`/`obj`/`TestResults`), S7 MUST operate on COPIES of each target in a scratch/temp workspace OUTSIDE all source repos (e.g., under `%TEMP%`), never writing into the sibling repos or into crap4csharp's own tree. Dogfooding crap4csharp-on-crap4csharp likewise runs against a copy to avoid polluting the working tree. This - constraint governs T18 execution. -- Also note the ordering: S6 runs only after everything through S5 is done and pushed (S6 depends on S4 - and S5). + constraint governs T19 execution. +- Also note the ordering: S7 runs only after everything through S6 is done and pushed (S7 depends on S6, + transitively S4/S5). From 14de208ed9b47ec6c9d95e3303ce13a16d9a8497 Mon Sep 17 00:00:00 2001 From: das Date: Sun, 26 Jul 2026 23:49:37 -0700 Subject: [PATCH 21/27] docs: ratify resolve-once (#7) + no-traits (D-T8); trim S5; add test-policy note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold Mr. Das's S4-GO rulings into the design docs (docs-only, no code impact): - decisions.md: add approved departure #7 (resolve-once — single module root per run via ModuleRootResolver, no crap4java §6 module-group loop; supersedes watch-item W17); add D-T8 (NO TRAITS) to Ratified conventions; cross-ref resolve-once from the Test-parity note. - features/crap4csharp-port.md: W17 -> RESOLVED (resolve-once); D-T8 -> RESOLVED (no traits); trim S5 slice + T17 to resolution-model-only (grouping closed by #7); reconcile D-S5 open-D-T8 pointer. - README.md: add "## Test policy (agentic dev loop)" — unit-by-default; a test is unit unless explicitly marked integration; full suite needs explicit approval. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 ++++++ docs/decisions.md | 21 +++++++++++ docs/features/crap4csharp-port.md | 60 +++++++++++++++++++------------ 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ea9e0d2..d1d27f1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ For each invocation, per module (nearest `.sln`, else `.csproj`, else the projec dotnet test ``` +## Test policy (agentic dev loop) + +Tight agentic development loops run the **unit tests by default** for fast feedback. + +- **Unit-test convention:** a test is treated as a **unit test unless it is explicitly marked an + integration test** (e.g. `[Trait("Category", "Integration")]`). +- crap4csharp marks **none** of its tests, so **all** of them are treated as unit tests and run by + default — including the process- and `git`-touching tests. +- Running the **full** suite (including any long-running tests) requires **explicit user approval**, + because slow tests delay the tight loop. + ## Run Build: diff --git a/docs/decisions.md b/docs/decisions.md index 3681cb4..a406941 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -63,6 +63,12 @@ node set below (an approved departure), so absolute CRAP scores are not numerica members + public test methods), **not** private fields; do not avoid a private field for fear of a CA1707 conflict. +- **D-T8 — No `[Trait]` categorization (RESOLVED — Mr. Das ruled NO TRAITS).** crap4csharp adds **no** + `[Trait]` attributes to its own tests; **all** crap4csharp tests are treated as unit tests, so the + tight agentic dev loop runs them **by default** (the T6/T8 process/git integration-style tests run + as-is). Full policy detail — "unit unless explicitly marked integration" + full-suite-needs-approval + — lives in `README.md`. Closes the open **D-T8** decision; no `.github/skills/build-test.md` edit. + ## Frozen reciprocal contract — `TypeName` canonical form (T9 ⇄ T10 ⇄ T11) **FROZEN (T9 review, Anders).** The per-method coverage-lookup key `MethodDescriptor.TypeName` (the @@ -186,6 +192,18 @@ counters (departure #4). **No new behavioral departure beyond the already-approv guaranteed either way. Marker precedence: nearest `.sln` → nearest `.csproj` → start dir. (Ruled by Mr. Das; T13 shipped as `e4d1329`, no rework.) +7. **Resolve-once (single module root per run)** — `crap4csharp` resolves **one** module root per run + via the existing `ModuleRootResolver` and runs coverage **once** at that root, in place of crap4java + §6's per-module grouping (`groupByModuleRoot`/`analyzeByModule` — "group by module … run coverage + once per module group"). T11/T12/T14 therefore resolve once and run once — **no** module-group loop + in `CliApplication`/`CrapAnalyzer`, and multi-`.sln` (multi-module) targets collapse to the single + resolved root: a knowing fidelity reduction from crap4java §6. Rationale: multi-module analysis is + uncommon, the C# baseline already assumes a single resolvable `.sln` (R2/R6), and resolve-once keeps + the pipeline materially simpler. **Test-parity consequence:** crap4java's module-grouping tests + **adapt to resolve-once (assert a single resolve + single coverage run) or drop** — Bhaskar/Dave + apply this at T11/T12/T14; no group-loop tests are ported. (Ruled by Mr. Das; supersedes watch-item + **W17**.) + ## Cyclomatic complexity — authoritative node set Base `CC = 1`. Walk the method `Body`/`ExpressionBody`; **descend into lambdas + local functions**; @@ -216,6 +234,9 @@ Base `CC = 1`. Walk the method `Body`/`ExpressionBody`; **descend into lambdas + The single knowing parity break: ~4 `CliApplication`/`Program` coverage-path tests change from "warn + `N/A` + exit 0" to fail-fast; **+2** new fail-fast tests. All other assertions are identical. +Separately, **departure #7 (resolve-once)** adapts crap4java's module-grouping tests to a single +resolve + single coverage run (or drops them) — no module-group-loop tests are ported (T11/T12/T14). + ## Environment adaptations (from nucleus) - **Dropped as N/A for a CLI:** web-app liveness watch / `run-app` / `dev.ps1` / `session-startup`, diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 81fac47..e34abde 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -35,7 +35,7 @@ the augmented complexity node set, and approved deliberate departures are in `do | S2 | Pure core (CRAP formula, report, CLI parse, domain types) with parity tests | S1 | | S3 | Ecosystem adapters (process exec, file finders, Roslyn parser, Cobertura parser) with parity tests | S1 | | S4 | Composition + CLI wiring + fail-fast + end-to-end | S2, S3 | -| S5 | Module & test resolution finalization: resolve the file→module→test resolution model (Anders presents options a/b/c; Mr. Das rules), then implement the chosen model — rework T13 `ModuleRootResolver` and shape T12/T14 as needed | S4 | +| S5 | Module & test resolution finalization — **resolution model only** (`.sln` vs nearest-`.csproj` vs hybrid) **plus test-project resolution**: Anders presents options a/b/c; Mr. Das rules; then implement the chosen model — rework T13 `ModuleRootResolver` and shape T12/T14 as needed. Module *grouping* is **out of S5** (closed by resolve-once departure #7). | S4 | | S6 | Independent clean-room evaluation: a neutral evaluator on gpt-5.6-sol judges the delivered port against the VERBATIM original Requirements block (report-only) | S5 | | S7 | Real-world dogfooding: run the finished crap4csharp on three real C# repos — crap4csharp, mutate4csharp, dry4csharp — producing actual CRAP reports | S6 | @@ -61,7 +61,7 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | -| T17 | S5 | Module & test resolution finalization: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | +| T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution only**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7). Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | | T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | | T19 | S7 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | @@ -219,15 +219,16 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an ### Carry-forward watch-items (from Anders's T13 review — must be honored at the noted task) -- **W17 — T14 resolves the module root ONCE; multi-module grouping is a product call.** T14 must - resolve the module root by calling `ModuleRootResolver.Resolve` **once** at the discovered `.sln`, and - must **not** port Java's per-file `groupByModuleRoot`/`analyzeByModule` for the single-solution - baseline (R2/R6 already assume a single resolvable `.sln`). This **drops** crap4java's multi-module - (multi-`.sln`) grouping — a fidelity reduction. **Escalate to Mr. Das at T14 as a departure - candidate** (log as approved departure if he takes resolve-once; otherwise preserve grouping by - calling `Resolve` per file and grouping by root). Cross-ref R2, R6, T11 (`CrapAnalyzer` per-method - `TypeName`/coverage lookup) and T12 (`CoverageRunner` `dotnet test` at the module root). Do **not** - silently drop grouping. +- **W17 — T14 resolves the module root ONCE; multi-module grouping is a product call.** *(RESOLVED — + Mr. Das ruled RESOLVE-ONCE; recorded as departure #7 in `docs/decisions.md`.)* T14 (with T11/T12) + resolves the module root by calling `ModuleRootResolver.Resolve` **once** at the discovered `.sln` + and runs coverage **once**; it does **not** port Java's per-file + `groupByModuleRoot`/`analyzeByModule` — there is **no module-group loop** in + `CliApplication`/`CrapAnalyzer`. This **drops** crap4java §6's multi-module (multi-`.sln`) grouping — + a knowing fidelity reduction (R2/R6 already assume a single resolvable `.sln`). **Test-parity:** + crap4java's module-grouping tests **adapt to resolve-once (single resolve + single coverage run) or + drop** at T11/T12/T14. Cross-ref departure #7, R2, R6, T11 (`CrapAnalyzer` per-method + `TypeName`/coverage lookup) and T12 (`CoverageRunner` `dotnet test` at the module root). - **W18 — Nonexistent-start-path normalization (below threshold today).** `Resolve` uses `File.Exists(full) ? parent : full`, which diverges from Java's `isDirectory ? self : parent` **only** for a nonexistent leaf (C# returns the leaf; Java returns its parent). Harmless in the @@ -296,8 +297,15 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an signature + test set followed the ruling; 17 tests = 7 faithful ports + 10 new, 56/56 green). Design-lane FYIs (Anders, vetoable): collection scope = methods only; expression-bodied methods included; nested-type class-name format is a T10 pin. -- **D-T8 — Integration-test categorization / fast-loop scope (agentic-loop policy).** *(OPEN — - escalated to Mr. Das; JARVIS to route at the S3 boundary.)* `.github/skills/build-test.md` promises +- **D-T8 — Integration-test categorization / fast-loop scope (agentic-loop policy).** *(RESOLVED — + Mr. Das ruled NO TRAITS.)* **Verdict:** crap4csharp adds **no** `[Trait]` attributes to its own + tests; **all** crap4csharp tests are treated as unit tests and are run by the tight agentic dev loop + **by default** — including the T6/T8 process/git integration-style tests + (`ProcessCommandExecutorTests`, `ChangedFileDetectorTests`), which run **as-is**. **No** `[Trait]` is + added and **no** `.github/skills/build-test.md` edit is needed; the policy is documented in + `README.md` and recorded in `docs/decisions.md` (Ratified conventions). The historical context below + (the doc/code gap and the two candidate resolutions) is retained for the record but is **superseded + by this NO-TRAITS ruling**. `.github/skills/build-test.md` promises the fast loop excludes integration tests via `--filter "Category!=Integration"`, but **no** test carries that trait (0 grep matches), so the fast loop currently spawns real `git`/processes (`ChangedFileDetectorTests`, `ProcessCommandExecutorTests`) — doc and code disagree. Two coherent @@ -314,8 +322,9 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an class-level `[Trait("Category", "Integration")]` to both test classes (no new `using` — `Xunit` is a global using), and the convention is recorded in `docs/decisions.md` (Ratified conventions); no `build-test.md` edit. **If (a):** replace `build-test.md`'s final paragraph with the no-tagging - reality (forward-looking: tag integration tests as they land). Either way the doc/reality gap must - close at the ratification point. + reality (forward-looking: tag integration tests as they land). **Resolution (Mr. Das):** **NO + TRAITS** — closest to **(a)** (no tags added), but `build-test.md` is left untouched and the + doc/reality reconciliation lives in `README.md`; **D-T8 is CLOSED**. - **D-T13 — Module-root walk unbounded (B1) — ratification.** *(RESOLVED — Mr. Das approved.)* `ModuleRootResolver` climbs **unbounded** to the filesystem root and falls back to the **start @@ -338,24 +347,31 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an ### S5/S6/S7 finale — design constraints (design-lane) - **D-S5 (design — deferred to when S5 runs; options-only now):** S5 finalizes the - file→module→test resolution model before the port is "done-done" and before the real-world slices - (S6 eval / S7 dogfood). The port keeps driving on the **locked `.sln`-first model** (decisions.md + file→module→test **resolution model** (`.sln` vs nearest-`.csproj` vs hybrid) **plus test-project + resolution** before the port is "done-done" and before the real-world slices + (S6 eval / S7 dogfood). **Scope trim (post-W17):** module *grouping* is **no longer part of S5** — + the resolve-once ruling (departure #7 in `docs/decisions.md`; W17 RESOLVED) removes crap4java §6 + multi-module grouping entirely, so S5 decides only how a target file resolves to its **single** + module root and its test project. The port keeps driving on the **locked `.sln`-first model** (decisions.md "Module root" locked choice + departure #6; T13 shipped `e4d1329`) through S3→S4; **S5 is the only place the model is revisited/decided/reworked — do NOT change T13 or the resolution model before S5.** Reference for writing the options faithfully: `../mutate4csharp` README §"Module & Test Resolution" (READ-ONLY sibling — do not write there). - **Options Anders presents / Mr. Das rules on WHEN THE SLICE RUNS (not now):** **(a)** keep the - locked `.sln`-first model — crap4java §6 parity, T13 as shipped; **(b)** adopt mutate4csharp's + locked `.sln`-first **resolution** model — T13 as shipped (crap4java §6 multi-module *grouping* is + out of scope per departure #7); **(b)** adopt mutate4csharp's model — owning project = nearest `.csproj` above the target `.cs` (file name w/o extension = ``); test project = `.Tests.csproj` **or** `.UnitTests.csproj` whose project references (transitively) include `.csproj`, and only that project's tests run - (fail fast if no owning `.csproj`/test project is found); **(c)** hybrid — `.sln` grouping but - borrow the `.Tests`/`.UnitTests` convention to select and run the right test project. Then - implement the chosen model: rework `ModuleRootResolver` (T13); shape T12/T14. + (fail fast if no owning `.csproj`/test project is found); **(c)** hybrid — `.sln`-based module + resolution but borrow the `.Tests`/`.UnitTests` convention to select and run the right test project + (multi-module *grouping* remains out of scope per departure #7). Then implement the chosen model: + rework `ModuleRootResolver` (T13); shape T12/T14. - **Locked constraint on this slice (Mr. Das already ruled):** do **NOT** adopt mutate4csharp's unit-only `[Trait("type", …)]` filtering (which keeps `UnitTests`/`Unit`/untagged and excludes `IntegrationTests`) — crap4csharp keeps running **ALL** module tests (crap4java parity). This is - **distinct** from the separate **open D-T8** agentic-loop fast-loop tagging question, which + **distinct** from the separate **D-T8** agentic-loop fast-loop tagging question (now RESOLVED — + NO TRAITS), which governs **our own** dev-loop test suite (`[Trait("Category", "Integration")]` on process/CLI tests), **not** how the tool runs a *target's* tests. - **Engineering watch-item (honor during S4):** keep **T12 (`CoverageRunner`)** and **T14 From debf9def6626591d6e4662c18a32abef500073f6 Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 00:32:14 -0700 Subject: [PATCH 22/27] T11: CrapAnalyzer (exact->nearest coverage lookup) + 17 parity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithful 1:1 port of crap4java CrapAnalyzer — the flat S4 composition layer that reads the changed files and drives both parsers (T9 CSharpMethodParser, T10 CoberturaCoverageParser), composing CrapScore + MethodMetrics. - Exact->nearest-line->N/A coverage lookup keyed on the FROZEN reciprocal TypeName#method:line form (byte-for-byte aligned with T10; trailing ":" is load-bearing). InvariantCulture line formatting (departure #3); Ordinal comparisons throughout. - N/A discipline (W4 / departure #1): a missing lookup yields null, never 0.0; 0%-present vs absent preserved end-to-end. - Stable OrderBy(...).ThenByDescending(...), N/A last (not List.Sort). - Resolve-once (departure #7): flat — no module-grouping loop (a T14 concern). - Public tested helpers LookupCoverage/NearestCoverage/ParseTrailingLine (internal banned, C1); ExactCoverage private (least privilege). - Drops Java classNameFromSource + dead projectRoot param (D-T9; behaviour migrated to T9). 17 tests: P1-P9 faithful ports + C1-C8 C#-specific. Build 0/0 Release (warnings-as-errors); 85/85 tests green, deterministic x2. docs/decisions.md: add T11 register (frozen-consumer discharged, D-T11a/b, W-T11a/b/c). Backfill T10 SHA 87488ce; flip T11 -> Done. Verified by Bhaskar (PASS); design-reviewed by Anders (GREEN). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 53 +- docs/features/crap4csharp-port.md | 6 +- src/Crap4CSharp/CrapAnalyzer.cs | 159 +++++ tests/Crap4CSharp.Tests/CrapAnalyzerTests.cs | 592 +++++++++++++++++++ 4 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 src/Crap4CSharp/CrapAnalyzer.cs create mode 100644 tests/Crap4CSharp.Tests/CrapAnalyzerTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index a406941..a37209e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -90,7 +90,9 @@ on either side silently breaks coverage attribution (affected methods fall throu **keep** the backtick arity, bare-ify the global namespace — and must be **pinned against a real coverlet sample** (R3). See watch-item **W-T9a**. - **Consumer — `CrapAnalyzer` (T11):** builds coverage-map / method-lookup keys from this form. Do not - change one producer without the other. + change one producer without the other. **T11 discharged (Anders T11 review, 🟢)** — the consumer + matches these keys byte-for-byte; pinned end-to-end by + `ResolvesCoverageThroughFrozenReciprocalKeyEndToEnd`. See the **T11 register** below. - **T10 discharged (Anders T10 review, 🟢).** Producer 2 now emits this form byte-for-byte: `NormalizeTypeName` = `rawClassName.Replace('/', '.').Replace('+', '.')` (backtick arity **kept**), pinned by `NormalizeTypeNameProducesFrozenReciprocalForm` (test 11, D-table) and by the real @@ -148,6 +150,55 @@ counters (departure #4). **No new behavioral departure beyond the already-approv ratified as the accepted choice (not a departure); pinned both ways by `SkipsSyntheticStateMachineClassButKeepsRealMoveNext` (test 9). +## T11 register — `CrapAnalyzer` (S4 close-out; Anders T11 review, 🟢) + +Resolved decisions and watch-items from the final analysis-composition task. `CrapAnalyzer` is a +faithful 1:1 port of `crap4java`'s `CrapAnalyzer` (`analyze`/`lookupCoverage`/`exactCoverage`/ +`nearestCoverage`/`parseTrailingLine`) — the flat composition layer (Slice S4) that reads the changed +files itself and drives both parsers (T9 `CSharpMethodParser`, T10 `CoberturaCoverageParser`) itself, +exactly as the Java original does. **No new behavioral departure beyond the already-approved set #1–#7.** + +- **Frozen reciprocal contract — CONSUMER end discharged (T11).** `CrapAnalyzer` builds its exact/ + nearest lookup keys as `typeName + "#" + methodName + ":" + line` from `MethodDescriptor.TypeName` + (T9) and matches them byte-for-byte against T10's emitted map keys — the trailing `":"` is + load-bearing (stops `alpha` matching `alphaBeta`). Any drift on either producer silently collapses a + method's coverage to `N/A`. Line formatting uses `InvariantCulture` (departure #3) so keys round-trip. + Pinned end-to-end by `ResolvesCoverageThroughFrozenReciprocalKeyEndToEnd` (a nested generic + ``Demo.Outer`1.Inner`2`` resolving to a real 100%, not `N/A`). The T9⇄T10⇄T11 triad is now closed on + all three ends. + +- **D-T11a — `classNameFromSource` and the dead `projectRoot` param DROPPED (RESOLVED).** Java derived + one `className` per file (`package` regex + filename); C# instead carries the per-method + `MethodDescriptor.TypeName` (enclosing-type FQN, D-T9 / "coverage key = enclosing-type FQN"), so + `classNameFromSource` has no C# analog and is dropped — its "no namespace ⇒ bare name" behaviour is + already pinned by `CSharpMethodParserTests`' global-namespace form. Java's unused `projectRoot` param + is dropped with it (behaviour-preserving). Java's `usesSimpleClassNameWhenSourceHasNoPackage` test has + no C# counterpart (behaviour migrated to T9). **Not a new departure.** + +- **D-T11b — nearest-line is the COMMON path in C# (visibility, not a change).** JaCoCo's + `` equals the declaration line so Java's exact-match usually hits; T10 keys on + `minChildLine` (first executable line, ≠ `StartLine`), so C# exact-match usually MISSES and the + `exact→nearest→N/A` fallback resolves. The algorithm is faithful; only the branch hit-rate shifts. + +**Watch-items (T11 review — visibility for Mr. Das; none blocks T11):** + +- **W-T11a — tie-break relies on document-order enumeration (LOAD-BEARING invariant, not a departure).** + `nearestCoverage`'s strict `<` makes the first entry in map-enumeration order win a distance tie. The + production map is a `Dictionary(StringComparer.Ordinal)` built by T10 in + document order and **never mutated after build**, so .NET's insertion-order enumeration makes the + tie-break **deterministic (document-order-first)** — the direct analog of Java's `LinkedHashMap`-pinned + test, and strictly more deterministic than Java production's `HashMap`. Do **not** switch the map type, + re-sort it, or remove keys, or this determinism breaks silently. Pinned by + `NearestCoverageKeepsFirstEntryWhenDistancesTie`. +- **W-T11b — `ParseTrailingLine` (`int.TryParse` + `NumberStyles.Integer`) tolerates surrounding + whitespace where Java `Integer.parseInt` would not — UNREACHABLE (T10 keys are pure ASCII digits). + Negligible.** +- **W-T11c — a directory path in `changedFiles` is skipped by `File.Exists` (Java would + `readString`-throw) — UNREACHABLE (dirs are expanded upstream, W12). Benign.** +- **W-T10a inheritance — async/iterator methods resolve to per-method `N/A` here (their coverage is + attributed to synthetic state machines skipped by T9/T10). Consistent with departure #1; not a new + departure. Expect more `N/A` rows on async-heavy targets.** + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index e34abde..5693184 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S3 COMPLETE** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 next commit); **68 tests green, 0/0 Release**. All parsers + adapters landed. **Paused at the S3 boundary** for Mr. Das; next slice = S4 (T11 `CrapAnalyzer` → T12 → T14 → T15 → T16). +**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 next commit); **85 tests green, 0/0 Release**. Analysis-composition layer (`CrapAnalyzer`) landed on the critical path; next = T12 `CoverageRunner` → T14 fail-fast gate → T15 `Program`/e2e → T16 README. **Pause at the S4 boundary** for Mr. Das. ## Requirements @@ -54,8 +54,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T7 | S3 | `SourceFileFinder` (`src/**/*.cs`, exclude `bin`/`obj`, ordinal sort) + tests | Done | `1b90d24` | | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `c023ef8` | | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `400d49d` | -| T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Done | `(next commit)` | -| T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Pending | - | +| T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Done | `87488ce` | +| T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Done | `(next commit)` | | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Pending | - | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | diff --git a/src/Crap4CSharp/CrapAnalyzer.cs b/src/Crap4CSharp/CrapAnalyzer.cs new file mode 100644 index 0000000..5109660 --- /dev/null +++ b/src/Crap4CSharp/CrapAnalyzer.cs @@ -0,0 +1,159 @@ +namespace Microsoft.Crap4CSharp; + +using System.Globalization; + +// Faithful port of crap4java's CrapAnalyzer -- the flat composition layer (Slice S4) that reads the +// changed files itself and drives both parsers itself, exactly as the Java original does. Two ratified +// adaptations apply (docs/decisions.md): +// * D-T9 -- Java derived one className per file via classNameFromSource (package regex + filename); +// C# instead uses the per-method MethodDescriptor.TypeName produced by CSharpMethodParser (the +// enclosing-type FQN coverage key), so classNameFromSource has no C# analog and is dropped (its +// "no namespace -> bare name" behaviour is already pinned by CSharpMethodParserTests' global- +// namespace form). Java's dead projectRoot param is dropped with it (behaviour-preserving). +// * #7 (resolve-once) -- no module-grouping code lives here in Java either; the analyzer is already +// flat (one file list, one coverage map), so this port has zero grouping structure (grouping is a +// T14/CliApplication concern). +// The lookup helpers are public only because CrapAnalyzerTest calls them directly and the `internal` +// modifier is banned (ratified C1); ExactCoverage stays private (no test calls it directly). Lookup +// keys are built byte-for-byte from the frozen reciprocal TypeName form emitted by both +// CSharpMethodParser.TypeNameOf and CoberturaCoverageParser.NormalizeTypeName -- any drift silently +// collapses a method's coverage to N/A, and the trailing ':' is load-bearing. +public static class CrapAnalyzer +{ + // Ports analyze: for each existing changed file, parse its methods, resolve each method's coverage + // via the exact->nearest lookup, compose the CRAP score, and return the metrics sorted by CRAP + // descending with N/A (null) last. A method absent from a populated report yields a row with null + // coverage and null CRAP -- never 0.0 (W4) -- preserving the 0%-present vs absent distinction. + public static IReadOnlyList Analyze( + IReadOnlyList changedFiles, + string coberturaXmlPath) + { + ArgumentNullException.ThrowIfNull(changedFiles); + ArgumentNullException.ThrowIfNull(coberturaXmlPath); + + IReadOnlyDictionary coverageMap = + CoberturaCoverageParser.Parse(coberturaXmlPath); + + List metrics = []; + foreach (string file in changedFiles) + { + if (!File.Exists(file)) + { + continue; + } + + string source = File.ReadAllText(file); + foreach (MethodDescriptor method in CSharpMethodParser.Parse(source)) + { + double? coverage = LookupCoverage( + coverageMap, + typeName: method.TypeName, + methodName: method.Name, + line: method.StartLine); + double? crap = CrapScore.Calculate(method.Complexity, coverage); + metrics.Add(new MethodMetrics( + MethodName: method.Name, + ClassName: method.TypeName, + Complexity: method.Complexity, + CoveragePercent: coverage, + CrapScore: crap)); + } + } + + return + [ + .. metrics + .OrderBy(m => m.CrapScore is null) + .ThenByDescending(m => m.CrapScore ?? 0.0), + ]; + } + + // Ports lookupCoverage: an exact-line match wins; otherwise the nearest line within the same + // method; otherwise null (N/A). + public static double? LookupCoverage( + IReadOnlyDictionary coverageMap, + string typeName, + string methodName, + int line) + { + ArgumentNullException.ThrowIfNull(coverageMap); + + double? exact = ExactCoverage(coverageMap, typeName, methodName, line); + if (exact is not null) + { + return exact; + } + + CoverageData? nearest = NearestCoverage(coverageMap, typeName, methodName, line); + return nearest?.CoveragePercent; + } + + // Ports nearestCoverage: scan only entries under "typeName#methodName:" (ordinal prefix) and keep + // the one whose trailing line is closest. The strict '<' means the first entry in enumeration + // (document) order wins a distance tie -- the LinkedHashMap analog (W-T11a). + public static CoverageData? NearestCoverage( + IReadOnlyDictionary coverageMap, + string typeName, + string methodName, + int line) + { + ArgumentNullException.ThrowIfNull(coverageMap); + + string prefix = typeName + "#" + methodName + ":"; + CoverageData? nearest = null; + int nearestDistance = int.MaxValue; + foreach (KeyValuePair entry in coverageMap) + { + if (!entry.Key.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + int keyLine = ParseTrailingLine(entry.Key); + int distance = Math.Abs(keyLine - line); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = entry.Value; + } + } + + return nearest; + } + + // Ports parseTrailingLine: the integer after the last ':'; int.MaxValue when there is no colon, an + // empty trailer, or a non-numeric trailer (InvariantCulture keeps parsing locale-independent -- + // departure #3). + public static int ParseTrailingLine(string key) + { + ArgumentNullException.ThrowIfNull(key); + + int separator = key.LastIndexOf(':'); + if (separator < 0) + { + return int.MaxValue; + } + + string lineText = key[(separator + 1)..]; + if (lineText.Length == 0) + { + return int.MaxValue; + } + + return int.TryParse(lineText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) + ? n + : int.MaxValue; + } + + // Ports exactCoverage: private (no test calls it directly). Formats the line with InvariantCulture + // so the exact key aligns byte-for-byte with T10's emitted keys (departure #3). + private static double? ExactCoverage( + IReadOnlyDictionary coverageMap, + string typeName, + string methodName, + int line) + { + string exactKey = typeName + "#" + methodName + ":" + line.ToString(CultureInfo.InvariantCulture); + return coverageMap.TryGetValue(exactKey, out CoverageData? exact) ? exact.CoveragePercent : null; + } +} diff --git a/tests/Crap4CSharp.Tests/CrapAnalyzerTests.cs b/tests/Crap4CSharp.Tests/CrapAnalyzerTests.cs new file mode 100644 index 0000000..957d6a4 --- /dev/null +++ b/tests/Crap4CSharp.Tests/CrapAnalyzerTests.cs @@ -0,0 +1,592 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Faithful port of crap4java's CrapAnalyzerTest, re-hosted on the Roslyn parser (T9) + Cobertura +// coverage adapter (T10). The nine Java tests are ported 1:1 with identical intent and oracle values +// (2 / 75.0 / 2.0625 for computesScoresForChangedFiles; the exact->nearest / tie-break / parse-trailing +// helper behaviours; the descending-with-nulls-last sort). One Java test is DROPPED at T11: +// usesSimpleClassNameWhenSourceHasNoPackage exercised classNameFromSource, which has no C# analog -- +// the per-method TypeName is produced by CSharpMethodParser (D-T9), and the "no namespace -> bare name" +// behaviour is already pinned by CSharpMethodParserTests' global-namespace form. Eight C#-specific tests +// pin behaviour with no Java original: C1 the load-bearing frozen-reciprocal key chain (T9->T10->T11), +// C2 per-method N/A in a populated report (W4: null, never 0.0), C3 an empty report -> all N/A, C4 an +// empty file list, C5 skipping a nonexistent file, C6 overload disambiguation by nearest line, C7 the +// stable equal-CRAP sort, and C8 the null-argument guards. End-to-end fixtures are written to a fresh +// temp directory and always deleted in a finally (the @TempDir analog); test method names are PascalCase +// (CA1707/C2), while the fixtures keep crap4java's lowercase member names (inside string literals, so +// CA1707 does not apply) to maximise oracle fidelity. +public class CrapAnalyzerTests +{ + private const string AlphaSampleSource = """ + namespace demo; + class Sample + { + int alpha(bool a) + { + if (a) + { + return 1; + } + return 0; + } + } + """; + + // alpha declared at line 4; covered lines 6,7,8 (hits=1) + missed line 10 (hits=0) -> 75%, + // minChildLine 6 -> key demo.Sample#alpha:6, resolved from alpha's StartLine 4 by nearest line. + private const string Alpha75Xml = """ + + + + + + + + + + + + + + + + + + + + + + """; + + // P1 <- computesScoresForChangedFiles. + [Fact] + public void ComputesScoresForChangedFiles() + { + WithTempDir(dir => + { + string source = WriteFile(dir, "Sample.cs", AlphaSampleSource); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", Alpha75Xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([source], cobertura); + + result.Should().HaveCount(1); + MethodMetrics metric = result[0]; + metric.MethodName.Should().Be("alpha"); + metric.ClassName.Should().Be("demo.Sample"); + metric.Complexity.Should().Be(2); + metric.CoveragePercent.Should().BeApproximately(75.0, 0.001); + metric.CrapScore.Should().BeApproximately(2.0625, 0.00001); + }); + } + + // P2 <- lookupCoveragePrefersExactLineBeforeNearestMatch. + [Fact] + public void LookupCoveragePrefersExactLineOverNearest() + { + Dictionary coverageMap = new(StringComparer.Ordinal) + { + ["demo.Sample#alpha:10"] = new CoverageData(1, 3), + ["demo.Sample#alpha:12"] = new CoverageData(0, 8), + }; + + double? coverage = CrapAnalyzer.LookupCoverage(coverageMap, "demo.Sample", "alpha", 10); + + coverage.Should().BeApproximately(75.0, 0.001); + } + + // P3 <- lookupCoverageFallsBackToNearestLineWithinMethod. + [Fact] + public void LookupCoverageFallsBackToNearestLineWithinMethod() + { + Dictionary coverageMap = new(StringComparer.Ordinal) + { + ["demo.Sample#alpha:10"] = new CoverageData(1, 3), + ["demo.Sample#alpha:15"] = new CoverageData(0, 8), + }; + + double? coverage = CrapAnalyzer.LookupCoverage(coverageMap, "demo.Sample", "alpha", 13); + + coverage.Should().BeApproximately(100.0, 0.001); + } + + // P4 <- nearestCoverageKeepsFirstEntryWhenDistancesTie. Insertion order (:10 then :14) is the + // LinkedHashMap analog; strict '<' keeps the first-inserted entry when both are distance 2. + [Fact] + public void NearestCoverageKeepsFirstEntryWhenDistancesTie() + { + Dictionary coverageMap = new(StringComparer.Ordinal) + { + ["demo.Sample#alpha:10"] = new CoverageData(1, 3), + ["demo.Sample#alpha:14"] = new CoverageData(0, 8), + }; + + CoverageData? nearest = CrapAnalyzer.NearestCoverage(coverageMap, "demo.Sample", "alpha", 12); + + nearest.Should().NotBeNull(); + nearest!.CoveragePercent.Should().BeApproximately(75.0, 0.001); + } + + // P5 <- lookupCoverageReturnsNullWhenMethodHasNoCoverageEntries. + [Fact] + public void LookupCoverageReturnsNullWhenNoCoverageEntries() + { + Dictionary empty = new(StringComparer.Ordinal); + + double? coverage = CrapAnalyzer.LookupCoverage(empty, "demo.Sample", "alpha", 10); + + coverage.Should().BeNull(); + } + + // P6 <- parseTrailingLineReturnsMaxValueForMalformedKeys. + [Fact] + public void ParseTrailingLineReturnsMaxValueForMalformedKeys() + { + CrapAnalyzer.ParseTrailingLine("demo.Sample#alpha").Should().Be(int.MaxValue); + CrapAnalyzer.ParseTrailingLine("demo.Sample#alpha:").Should().Be(int.MaxValue); + CrapAnalyzer.ParseTrailingLine("demo.Sample#alpha:oops").Should().Be(int.MaxValue); + } + + // P7 <- parseTrailingLineReturnsParsedLineNumberForValidKey. + [Fact] + public void ParseTrailingLineReturnsParsedLineNumberForValidKey() + { + CrapAnalyzer.ParseTrailingLine("demo.Sample#alpha:10").Should().Be(10); + } + + // P8 <- parseTrailingLineAcceptsLeadingSeparator. + [Fact] + public void ParseTrailingLineAcceptsLeadingSeparator() + { + CrapAnalyzer.ParseTrailingLine(":10").Should().Be(10); + } + + // P9 <- sortsMetricsByScoreDescendingWithNullsLast. alpha (0% -> crap 6.0), beta (100% -> crap 1.0), + // gamma absent (N/A -> crap null) -> sorted alpha, beta, gamma. + [Fact] + public void SortsMetricsByScoreDescendingWithNullsLast() + { + const string source = """ + namespace demo; + + class Sample + { + int alpha(bool a) + { + if (a) + { + return 1; + } + return 0; + } + + int beta() + { + return 1; + } + + int gamma() + { + return 2; + } + } + """; + + const string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempDir(dir => + { + string sourcePath = WriteFile(dir, "Sample.cs", source); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([sourcePath], cobertura); + + result.Select(m => m.MethodName).Should().Equal("alpha", "beta", "gamma"); + }); + } + + // C1 -- the load-bearing consumer test: a nested generic type flows byte-for-byte through + // T9 (TypeNameOf -> Demo.Outer`1.Inner`2) and T10 (NormalizeTypeName of the '/'-separated coverlet + // name) so T11's key matches. A resolved 100% (not N/A) proves the frozen reciprocal held. + [Fact] + public void ResolvesCoverageThroughFrozenReciprocalKeyEndToEnd() + { + const string source = """ + namespace Demo; + class Outer + { + class Inner + { + int Wrap(T t, U u, V v) + { + if (t is null) + { + return 0; + } + return 1; + } + } + } + """; + + const string xml = """ + + + + + + + + + + + + + + + + + + + """; + + WithTempDir(dir => + { + string sourcePath = WriteFile(dir, "Outer.cs", source); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([sourcePath], cobertura); + + result.Should().HaveCount(1); + MethodMetrics metric = result[0]; + metric.ClassName.Should().Be("Demo.Outer`1.Inner`2"); + metric.MethodName.Should().Be("Wrap"); + metric.Complexity.Should().Be(2); + metric.CoveragePercent.Should().NotBeNull(); + metric.CoveragePercent.Should().BeApproximately(100.0, 0.001); + metric.CrapScore.Should().BeApproximately(2.0, 0.00001); + }); + } + + // C2 -- W4: a method present in the source but ABSENT from a populated report yields null coverage + // and null CRAP (never 0.0), while the present method keeps its real 75% measurement. + [Fact] + public void AnalyzeReportsNaForMethodAbsentFromPopulatedReport() + { + const string source = """ + namespace demo; + + class Sample + { + int alpha(bool a) + { + if (a) + { + return 1; + } + return 0; + } + + int beta() + { + return 1; + } + } + """; + + const string xml = """ + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempDir(dir => + { + string sourcePath = WriteFile(dir, "Sample.cs", source); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([sourcePath], cobertura); + + result.Should().HaveCount(2); + + MethodMetrics present = result[0]; + present.MethodName.Should().Be("alpha"); + present.Complexity.Should().Be(2); + present.CoveragePercent.Should().BeApproximately(75.0, 0.001); + present.CrapScore.Should().BeApproximately(2.0625, 0.00001); + + MethodMetrics absent = result[1]; + absent.MethodName.Should().Be("beta"); + absent.Complexity.Should().Be(1); + absent.CoveragePercent.Should().BeNull(); + absent.CrapScore.Should().BeNull(); + }); + } + + // C3 -- a missing report (nonexistent path -> empty map from T10) makes every method N/A. + [Fact] + public void AnalyzeTreatsMissingReportAsAllNa() + { + WithTempDir(dir => + { + string source = WriteFile(dir, "Sample.cs", AlphaSampleSource); + string missingReport = Path.Combine(dir, "does-not-exist.cobertura.xml"); + + IReadOnlyList result = CrapAnalyzer.Analyze([source], missingReport); + + result.Should().HaveCount(1); + MethodMetrics metric = result[0]; + metric.MethodName.Should().Be("alpha"); + metric.Complexity.Should().Be(2); + metric.CoveragePercent.Should().BeNull(); + metric.CrapScore.Should().BeNull(); + }); + } + + // C4 -- an empty changed-file list returns no metrics (loop body never runs). + [Fact] + public void AnalyzeReturnsEmptyForEmptyFileList() + { + string anyReport = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cobertura.xml"); + + IReadOnlyList result = CrapAnalyzer.Analyze([], anyReport); + + result.Should().BeEmpty(); + } + + // C5 -- a nonexistent path in the list is skipped (File.Exists false) with no throw. + [Fact] + public void AnalyzeSkipsNonexistentFiles() + { + WithTempDir(dir => + { + string source = WriteFile(dir, "Sample.cs", AlphaSampleSource); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", Alpha75Xml); + string missing = Path.Combine(dir, "Missing.cs"); + + IReadOnlyList result = CrapAnalyzer.Analyze([missing, source], cobertura); + + result.Should().HaveCount(1); + result[0].MethodName.Should().Be("alpha"); + result[0].CoveragePercent.Should().BeApproximately(75.0, 0.001); + }); + } + + // C6 -- two overloads share the demo.Calc#Add: prefix; each StartLine resolves to its OWN body's + // nearest coverage entry (Add(int)@5 -> :7 100%; Add(string)@10 -> :12 0%). + [Fact] + public void DisambiguatesOverloadsByNearestLine() + { + const string source = """ + namespace Demo; + + class Calc + { + int Add(int x) + { + return x + 1; + } + + int Add(string s) + { + if (s.Length > 0) + { + return 1; + } + return 0; + } + } + """; + + const string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempDir(dir => + { + string sourcePath = WriteFile(dir, "Calc.cs", source); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([sourcePath], cobertura); + + result.Should().HaveCount(2); + + MethodMetrics higher = result[0]; + higher.MethodName.Should().Be("Add"); + higher.ClassName.Should().Be("Demo.Calc"); + higher.Complexity.Should().Be(2); + higher.CoveragePercent.Should().BeApproximately(0.0, 0.001); + higher.CrapScore.Should().BeApproximately(6.0, 0.00001); + + MethodMetrics lower = result[1]; + lower.MethodName.Should().Be("Add"); + lower.Complexity.Should().Be(1); + lower.CoveragePercent.Should().BeApproximately(100.0, 0.001); + lower.CrapScore.Should().BeApproximately(1.0, 0.00001); + }); + } + + // C7 -- two equal-CRAP methods (both 100% -> crap 1.0) keep document order under the stable sort + // (the OrderBy/ThenByDescending guard against an unstable List.Sort). + [Fact] + public void SortIsStableForEqualCrapScores() + { + const string source = """ + namespace demo; + + class Sample + { + int first() + { + return 1; + } + + int second() + { + return 2; + } + } + """; + + const string xml = """ + + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempDir(dir => + { + string sourcePath = WriteFile(dir, "Sample.cs", source); + string cobertura = WriteFile(dir, "coverage.cobertura.xml", xml); + + IReadOnlyList result = CrapAnalyzer.Analyze([sourcePath], cobertura); + + result.Select(m => m.MethodName).Should().Equal("first", "second"); + result.Should().OnlyContain(m => m.CrapScore != null); + result[0].CrapScore.Should().BeApproximately(1.0, 0.00001); + result[1].CrapScore.Should().BeApproximately(1.0, 0.00001); + }); + } + + // C8 -- house-style null guards (CA1062): both public Analyze params are validated before any work, + // so even an empty file list still throws on a null path. + [Fact] + public void AnalyzeThrowsOnNullArguments() + { + Action nullFiles = () => CrapAnalyzer.Analyze(null!, "any.cobertura.xml"); + nullFiles.Should().Throw(); + + Action nullPath = () => CrapAnalyzer.Analyze([], null!); + nullPath.Should().Throw(); + } + + private static void WithTempDir(Action test) + { + string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + test(dir); + } + finally + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + } + + private static string WriteFile(string dir, string name, string content) + { + string path = Path.Combine(dir, name); + File.WriteAllText(path, content); + return path; + } +} From 9041c2c4e279c0435beed387647119c847dc9cba Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 01:20:29 -0700 Subject: [PATCH 23/27] T12: CoverageRunner (dotnet test --collect) + CoverageReportLocator + tests Faithful port of crap4java CoverageRunner (delete-stale -> run-once -> throw-on-nonzero) re-hosted on coverlet, plus a C#-specific CoverageReportLocator (JaCoCo's report path was a fixed constant; coverlet writes coverage.cobertura.xml into a random GUID subdir, so a locator is new). - CoverageRunner: public sealed; ctor takes ICommandExecutor (T6 seam reused); const ResultsDirectoryName="coverage"; GenerateCoverage(projectRoot) deletes the stale coverage/ dir, runs ["dotnet","test","--collect:XPlat Code Coverage","--results-directory","coverage"] once at projectRoot (verbatim cwd, no normalization), throws InvalidOperationException("Coverage command failed with exit N") on non-zero exit (byte-identical to Java). - CoverageReportLocator: public static; Locate(projectRoot) does an ordinal- first single-pick of one coverage.cobertura.xml (departure #8), null when absent. No mtime logic (deterministic, departure #3). - Resolve-once (#7): both take a pre-resolved projectRoot; neither calls ModuleRootResolver (load-bearing S5/T17 swap seam, D-T12b). - Fail-fast gate stays in T14: runner throws; locator returns null. - 10 tests (RecordingExecutor fake; no real dotnet test): 5 runner + 5 locator. Build 0/0 Release (warnings-as-errors); 95/95 tests green, deterministic x2. docs/decisions.md: add approved departure #8 (single-pick coverage report, Mr. Das-ruled; aggregation deferred to S5/T17) + T12 register (D-T12b/e/f load-bearing invariants). docs/features: expand S5/T17 scope to include multi-report coverage aggregation; backfill T11 SHA debf9de; flip T12 -> Done. Verified by Bhaskar (PASS); design-reviewed by Anders (GREEN). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 62 +++++++++ docs/features/crap4csharp-port.md | 20 ++- src/Crap4CSharp/CoverageReportLocator.cs | 42 ++++++ src/Crap4CSharp/CoverageRunner.cs | 68 +++++++++ .../CoverageReportLocatorTests.cs | 87 ++++++++++++ .../Crap4CSharp.Tests/CoverageRunnerTests.cs | 131 ++++++++++++++++++ 6 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 src/Crap4CSharp/CoverageReportLocator.cs create mode 100644 src/Crap4CSharp/CoverageRunner.cs create mode 100644 tests/Crap4CSharp.Tests/CoverageReportLocatorTests.cs create mode 100644 tests/Crap4CSharp.Tests/CoverageRunnerTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index a37209e..6c6a99f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -199,6 +199,49 @@ exactly as the Java original does. **No new behavioral departure beyond the alre attributed to synthetic state machines skipped by T9/T10). Consistent with departure #1; not a new departure. Expect more `N/A` rows on async-heavy targets.** +## T12 register — `CoverageRunner` + `CoverageReportLocator` (S4; Anders T12 review, 🟢) + +Resolved decisions and load-bearing invariants from the coverage generate/locate task. `CoverageRunner` +is a faithful 1:1 port of `crap4java`'s `CoverageRunner` (delete-stale → run-once → throw-on-nonzero), +re-hosted on the `dotnet test --collect` pipeline; `CoverageReportLocator` is a C#-specific addition +with no Java counterpart (JaCoCo's report path was a fixed constant, so Java needed no locator). +**No new behavioral departure beyond the already-approved set #1–#8.** + +- **D-T12b — the pre-resolved-`projectRoot` decoupling seam is LOAD-BEARING (do not break at S5/T17).** + Both types take a **pre-resolved** `projectRoot` string and **never** call `ModuleRootResolver`; T14 + resolves the one module root once (departure #7) and injects the same string into + `GenerateCoverage(root)` and `Locate(root)`. This is the seam the S5/T17 resolution-model swap + (`.sln` vs `.csproj` vs hybrid) turns on: that swap must change **only** `ModuleRootResolver` — + `CoverageRunner`/`CoverageReportLocator` stay untouched because they never learn *how* the root was + found. Do not reintroduce a resolver call into either type. + +- **D-T12e — `ResultsDirectoryName` is a frozen reciprocal contract (single source of truth).** The + results-directory name `"coverage"` is declared **once** as `public const + CoverageRunner.ResultsDirectoryName` and referenced by **both** the writer (`CoverageRunner`, which + passes it to `--results-directory`) and the reader (`CoverageReportLocator`, which `Path.Combine`s it + under `projectRoot`). Same lesson as the frozen `TypeName` contract: writer and reader must agree + byte-for-byte or the locator silently finds nothing (a false fail-fast). Do not inline the literal + `"coverage"` on either side and do not split it into two constants. + +- **D-T12f — ordinal-first single-pick is a LOAD-BEARING determinism invariant.** When more than one + `coverage.cobertura.xml` exists, `Locate` returns the **ordinal-first** full path + (`OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault()`) — OS-independent and timing-free (no + mtime/`LastWriteTime`; `CoverageRunner` deletes `coverage/` before every run, so every found file is + fresh). Do not switch to timestamp/most-recent selection (reintroduces timing sensitivity) or a + non-ordinal sort. The **multi-test-project fidelity reduction** this single-pick exposes (sibling + test-projects' coverage silently dropped → per-method `N/A`) and the **single-token `--collect` + spelling** were both ruled by Mr. Das to the contract's locked defaults — recorded as **departure + #8**; multi-report aggregation is deferred to S5/T17 and revisited if S7 dogfooding hits a + multi-test-project target. + +- **No new departure from D-T12a/c/d.** The generate/locate split (D-T12a) preserves Java's + `CoverageRunner` shape and keeps the locator independently testable; resolve-once/run-once (D-T12c) is + departure #7; `InvalidOperationException` with the byte-identical message + `"Coverage command failed with exit N"` (D-T12d) is the already-ratified `IllegalStateException` + analog (C1, as in `CoberturaCoverageParser`). The T12/T14 fail-fast boundary is unchanged: T12 + **throws** on command failure (faithful) and **returns `null`** when the report is absent (the new + locate signal); every exit-code decision (departure #1) stays in T14. + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a @@ -255,6 +298,25 @@ exactly as the Java original does. **No new behavioral departure beyond the alre apply this at T11/T12/T14; no group-loop tests are ported. (Ruled by Mr. Das; supersedes watch-item **W17**.) +8. **Single-pick coverage report (ordinal-first)** — `CoverageReportLocator` (T12) deterministically + selects **one** `coverage.cobertura.xml` via an **ordinal-first single pick** over the discovered + report paths, and hands that single file to `CoberturaCoverageParser.Parse` (T10). Root-cause + mismatch: coverlet emits **one report per test project** (`dotnet test --collect` drops a + `coverage.cobertura.xml` per test project under `TestResults/`), whereas JaCoCo emits a **single + per-module aggregate** — there is no coverlet analog of that aggregate, so the port picks one report + rather than reading a ready-made union. **Consequence (under-reporting):** on a multi-test-project + solution, methods whose coverage lives in the **non-picked** reports have no matching entry in the + chosen report and resolve to per-method `N/A` (T11) — consistent with departure #1's "per-method + `N/A` unchanged" — which makes a genuinely-covered method score as if **0-covered** and thereby + **inflates its CRAP**; every test project except the picked one is under-reported. This is the + coverage-side counterpart of **departure #7** (resolve-once): one root → one coverage run → now **one + report**, and it is faithful to the common single-test-project baseline the port already requires + (R2/R6 — one test project referencing `coverlet.collector`, one resolvable `.sln`). The single + deterministic pick — **no** configuration flags and **no** report-merging branch — is deliberately + the same shape as T10's single-path `Parse`. **Mr. Das-approved; multi-report coverage AGGREGATION + (unioning the per-test-project reports) is DEFERRED to S5/T17**, whose scope now also owns report + aggregation so multi-test-project solutions stop under-reporting. (Ruled by Mr. Das.) + ## Cyclomatic complexity — authoritative node set Base `CC = 1`. Walk the method `Body`/`ExpressionBody`; **descend into lambdas + local functions**; diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 5693184..d93fa62 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 next commit); **85 tests green, 0/0 Release**. Analysis-composition layer (`CrapAnalyzer`) landed on the critical path; next = T12 `CoverageRunner` → T14 fail-fast gate → T15 `Program`/e2e → T16 README. **Pause at the S4 boundary** for Mr. Das. +**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 next commit); **95 tests green, 0/0 Release**. Coverage pipeline (`CrapAnalyzer` + `CoverageRunner`/`CoverageReportLocator`) landed; next = T14 fail-fast gate → T15 `Program`/e2e → T16 README finalize. **Pause at the S4 boundary** for Mr. Das. ## Requirements @@ -35,7 +35,7 @@ the augmented complexity node set, and approved deliberate departures are in `do | S2 | Pure core (CRAP formula, report, CLI parse, domain types) with parity tests | S1 | | S3 | Ecosystem adapters (process exec, file finders, Roslyn parser, Cobertura parser) with parity tests | S1 | | S4 | Composition + CLI wiring + fail-fast + end-to-end | S2, S3 | -| S5 | Module & test resolution finalization — **resolution model only** (`.sln` vs nearest-`.csproj` vs hybrid) **plus test-project resolution**: Anders presents options a/b/c; Mr. Das rules; then implement the chosen model — rework T13 `ModuleRootResolver` and shape T12/T14 as needed. Module *grouping* is **out of S5** (closed by resolve-once departure #7). | S4 | +| S5 | Module & test resolution finalization — **resolution model** (`.sln` vs nearest-`.csproj` vs hybrid) **plus test-project resolution plus multi-report coverage aggregation**: Anders presents options a/b/c; Mr. Das rules; then implement the chosen model — rework T13 `ModuleRootResolver` and shape T12/T14 as needed. Module *grouping* stays **out of S5** (closed by resolve-once departure #7), but multi-report coverage **aggregation** is now **in S5** (per single-pick departure #8) — union the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. | S4 | | S6 | Independent clean-room evaluation: a neutral evaluator on gpt-5.6-sol judges the delivered port against the VERBATIM original Requirements block (report-only) | S5 | | S7 | Real-world dogfooding: run the finished crap4csharp on three real C# repos — crap4csharp, mutate4csharp, dry4csharp — producing actual CRAP reports | S6 | @@ -55,13 +55,13 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T8 | S3 | `ChangedFileDetector` (git porcelain) + integration tests | Done | `c023ef8` | | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `400d49d` | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Done | `87488ce` | -| T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Done | `(next commit)` | -| T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Pending | - | +| T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Done | `debf9de` | +| T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Done | `(next commit)` | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | -| T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution only**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7). Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | +| T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution + multi-report coverage aggregation**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7), but multi-report coverage **aggregation** IS now in scope (per single-pick departure #8) — replace T12's ordinal-first single-pick `CoverageReportLocator` with a union across the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | | T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | | T19 | S7 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | @@ -348,11 +348,17 @@ Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 an - **D-S5 (design — deferred to when S5 runs; options-only now):** S5 finalizes the file→module→test **resolution model** (`.sln` vs nearest-`.csproj` vs hybrid) **plus test-project - resolution** before the port is "done-done" and before the real-world slices + resolution plus multi-report coverage aggregation** before the port is "done-done" and before the + real-world slices (S6 eval / S7 dogfood). **Scope trim (post-W17):** module *grouping* is **no longer part of S5** — the resolve-once ruling (departure #7 in `docs/decisions.md`; W17 RESOLVED) removes crap4java §6 multi-module grouping entirely, so S5 decides only how a target file resolves to its **single** - module root and its test project. The port keeps driving on the **locked `.sln`-first model** (decisions.md + module root and its test project. **Scope add (post-T12):** multi-report coverage **aggregation** is + now **in S5** — the single-pick ruling (departure #8 in `docs/decisions.md`) accepts T12's + ordinal-first single-pick `CoverageReportLocator` as the interim behavior, so S5/T17 also owns + replacing it with a union across the per-test-project `coverage.cobertura.xml` reports so + multi-test-project solutions stop under-reporting (grouping stays out per #7; aggregation comes in + per #8). The port keeps driving on the **locked `.sln`-first model** (decisions.md "Module root" locked choice + departure #6; T13 shipped `e4d1329`) through S3→S4; **S5 is the only place the model is revisited/decided/reworked — do NOT change T13 or the resolution model before S5.** Reference for writing the options faithfully: `../mutate4csharp` README §"Module & Test diff --git a/src/Crap4CSharp/CoverageReportLocator.cs b/src/Crap4CSharp/CoverageReportLocator.cs new file mode 100644 index 0000000..246538a --- /dev/null +++ b/src/Crap4CSharp/CoverageReportLocator.cs @@ -0,0 +1,42 @@ +namespace Microsoft.Crap4CSharp; + +// A C#-specific addition with no Java counterpart. JaCoCo wrote its report to a FIXED path +// (target/site/jacoco/jacoco.xml) that Java's caller existence-checked directly, so crap4java needed no +// locator. Coverlet's `--collect:XPlat Code Coverage` instead writes coverage.cobertura.xml under a +// NON-DETERMINISTIC per-run GUID subdirectory of the results directory, so this locator finds the emitted +// report. Pure function, no process execution -- mirrors the ModuleRootResolver / SourceFileFinder / +// CoberturaCoverageParser precedent (static class; public because the `internal` modifier is banned, C1). +// +// The fail-fast gate is T14, not here: Locate returns null when no report was produced (the signal T14 acts +// on). It never chooses an exit code and never inspects report contents. +public static class CoverageReportLocator +{ + // Coverlet always emits this exact lowercase filename. + private const string CoberturaReportFileName = "coverage.cobertura.xml"; + + // Finds the coverage.cobertura.xml coverlet emits under /coverage//. Returns the + // absolute path, or null when no report was produced (the T14 fail-fast signal). + public static string? Locate(string projectRoot) + { + ArgumentNullException.ThrowIfNull(projectRoot); + + // Reciprocal contract with CoverageRunner (D-T12e): read the same results directory the runner wrote. + string resultsDirectory = Path.Combine(projectRoot, CoverageRunner.ResultsDirectoryName); + if (!Directory.Exists(resultsDirectory)) + { + return null; + } + + // Enumerate "*" then filter by an ordinal filename equality (NOT a "*.cobertura.xml" glob) -- mirrors + // ModuleRootResolver's deliberate anti-glob discipline (dodges the Windows 8.3-shortname quirk; exact + // ordinal match to coverlet's always-lowercase name). OrderBy(Ordinal) makes a multi-match pick + // deterministic and OS-independent (departure #3); FirstOrDefault returns null on zero matches (the + // fail-fast signal). No timestamp/mtime logic: CoverageRunner deletes coverage/ before the run, so + // every file found afterward is fresh. + return Directory + .EnumerateFiles(resultsDirectory, "*", SearchOption.AllDirectories) + .Where(file => Path.GetFileName(file).Equals(CoberturaReportFileName, StringComparison.Ordinal)) + .OrderBy(file => file, StringComparer.Ordinal) + .FirstOrDefault(); + } +} diff --git a/src/Crap4CSharp/CoverageRunner.cs b/src/Crap4CSharp/CoverageRunner.cs new file mode 100644 index 0000000..1467e77 --- /dev/null +++ b/src/Crap4CSharp/CoverageRunner.cs @@ -0,0 +1,68 @@ +namespace Microsoft.Crap4CSharp; + +using System.Globalization; + +// Faithful port of crap4java's CoverageRunner, re-hosted on the .NET coverage pipeline (docs/decisions.md: +// Coverage row; O1 ecosystem-adapter swap). Java's `final class` maps to C# `sealed`. generateCoverage +// (a) deletes stale coverage artifacts, (b) runs the coverage command once at projectRoot via the injected +// CommandExecutor seam, and (c) throws on a non-zero exit -- all three preserved 1:1. Two ratified +// adaptations, both recorded in docs/decisions.md: +// * O1 -- the Maven+JaCoCo command becomes `dotnet test --collect:XPlat Code Coverage --results-directory +// coverage`, and the two JaCoCo artifacts (target/site/jacoco + target/jacoco.exec) collapse to the +// single "coverage" results directory. `dotnet test` with no project/solution argument discovers the +// project in the working directory (projectRoot), exactly as `mvn` did. +// * IllegalStateException -> InvalidOperationException (the repo's ratified analog, C1). The exit-failure +// message is byte-identical to Java: "Coverage command failed with exit N". +// +// Resolve-once (departure #7): this type takes a PRE-RESOLVED projectRoot and never calls ModuleRootResolver; +// it runs coverage exactly once at the one root (no module-group loop). The fail-fast gate is T14, not here: +// GenerateCoverage throws on command failure (faithful), and the separate CoverageReportLocator returns null +// when no report was produced (the signal T14 acts on). +public sealed class CoverageRunner +{ + // Shared reciprocal contract with CoverageReportLocator (D-T12e): the writer (this runner) and the reader + // (the locator) must agree byte-for-byte on the results-directory name, so they share this single source + // of truth. Matches README "Coverage Pipeline" steps 1-2. + public const string ResultsDirectoryName = "coverage"; + + private readonly ICommandExecutor _executor; + + public CoverageRunner(ICommandExecutor executor) + { + // Java implicitly NPEs on a null executor; the idiomatic, suppression-free C# equivalent (and CA1062's + // required guard) is a fail-fast ArgumentNullException. + ArgumentNullException.ThrowIfNull(executor); + _executor = executor; + } + + // Ports generateCoverage(Path): delete the stale results dir, run the coverage command once at projectRoot, + // throw InvalidOperationException on a non-zero exit. Returns void. + public void GenerateCoverage(string projectRoot) + { + ArgumentNullException.ThrowIfNull(projectRoot); + + // Delete stale artifacts (analog of Java deleting target/site/jacoco + target/jacoco.exec; README step + // 1 deletes coverage/). Java's deleteIfExists no-ops when absent and deletes recursively when present; + // we map both Java artifacts to the single results directory, so only the directory branch is needed. + string resultsDirectory = Path.Combine(projectRoot, ResultsDirectoryName); + if (Directory.Exists(resultsDirectory)) + { + Directory.Delete(resultsDirectory, recursive: true); + } + + // Run the coverage command via the injected seam. projectRoot is forwarded verbatim as the working + // directory -- no Path.GetFullPath, no normalization (faithful: Java passed projectRoot unchanged to + // executor.run; T14 already injects the normalized resolved root). + int exit = _executor.Run( + ["dotnet", "test", "--collect:XPlat Code Coverage", "--results-directory", ResultsDirectoryName], + projectRoot); + + if (exit != 0) + { + // Byte-identical to Java; InvariantCulture keeps the exit number locale-independent (CA1305, + // departure #3). + throw new InvalidOperationException( + "Coverage command failed with exit " + exit.ToString(CultureInfo.InvariantCulture)); + } + } +} diff --git a/tests/Crap4CSharp.Tests/CoverageReportLocatorTests.cs b/tests/Crap4CSharp.Tests/CoverageReportLocatorTests.cs new file mode 100644 index 0000000..258023e --- /dev/null +++ b/tests/Crap4CSharp.Tests/CoverageReportLocatorTests.cs @@ -0,0 +1,87 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// All C#-specific additions -- crap4java had no locator because JaCoCo's report path was a fixed constant. +// Pure filesystem fixtures (no executor): coverlet writes coverage.cobertura.xml under a per-run GUID +// subdirectory of /coverage/, and Locate must find it, return null when it is absent, and pick the +// ordinal-first path deterministically when several are present (departure #3). Each test uses a fresh temp +// tree removed in a finally. +public class CoverageReportLocatorTests +{ + [Fact] + public void LocatesReportInGuidSubdirectory() + { + WithTempRoot(root => + { + string report = Path.Combine(root, "coverage", "guid", "coverage.cobertura.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(report)!); + File.WriteAllText(report, ""); + + CoverageReportLocator.Locate(root).Should().Be(report); + }); + } + + [Fact] + public void ReturnsNullWhenNoCoverageDirectory() + { + WithTempRoot(root => + { + CoverageReportLocator.Locate(root).Should().BeNull(); + }); + } + + [Fact] + public void ReturnsNullWhenCoverageDirectoryHasNoReport() + { + WithTempRoot(root => + { + string subDir = Path.Combine(root, "coverage", "guid"); + Directory.CreateDirectory(subDir); + File.WriteAllText(Path.Combine(subDir, "other.xml"), "x"); + File.WriteAllText(Path.Combine(subDir, "report.trx"), "x"); + + CoverageReportLocator.Locate(root).Should().BeNull(); + }); + } + + [Fact] + public void SelectsOrdinalFirstReportWhenMultiplePresent() + { + WithTempRoot(root => + { + // Fixed non-GUID subdir names so the ordinal winner is deterministic: "aaaa" < "bbbb". + string first = Path.Combine(root, "coverage", "aaaa", "coverage.cobertura.xml"); + string second = Path.Combine(root, "coverage", "bbbb", "coverage.cobertura.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(first)!); + Directory.CreateDirectory(Path.GetDirectoryName(second)!); + File.WriteAllText(first, ""); + File.WriteAllText(second, ""); + + CoverageReportLocator.Locate(root).Should().Be(first); + }); + } + + [Fact] + public void ThrowsOnNullProjectRoot() + { + Action act = () => CoverageReportLocator.Locate(null!); + + act.Should().Throw().WithParameterName("projectRoot"); + } + + private static void WithTempRoot(Action test) + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + test(root); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs b/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs new file mode 100644 index 0000000..401c076 --- /dev/null +++ b/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs @@ -0,0 +1,131 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Ports crap4java's CoverageRunnerTest. Java's two tests -- deletesStaleCoverageAndRunsMavenCoverageCommand +// and failsWhenCoverageCommandFails -- are both ported (tests 1-2) with the ecosystem-adapter adaptation +// recorded in docs/decisions.md: the Maven+JaCoCo command becomes the locked `dotnet test` token list and +// the two JaCoCo artifacts collapse to the single "coverage" results dir, so the deletion assertion is one +// directory rather than a dir + a file. Tests 3-5 are C#-specific guard/CA1062 additions. No real +// `dotnet test` is ever launched -- the process is faked via RecordingExecutor (1:1 with Java's fake), so +// the suite stays fast and deterministic. Each test uses a fresh temp tree removed in a finally. +public class CoverageRunnerTests +{ + [Fact] + public void DeletesStaleCoverageAndRunsCoverageCommand() + { + WithTempRoot(root => + { + // Pre-create a stale coverage//coverage.cobertura.xml plus a stray coverage/old.txt so the + // whole results dir must be deleted (analog of Java's stale target/site/jacoco + target/jacoco.exec). + string staleReport = Path.Combine(root, "coverage", "guid", "coverage.cobertura.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(staleReport)!); + File.WriteAllText(staleReport, "stale"); + File.WriteAllText(Path.Combine(root, "coverage", "old.txt"), "stale"); + + RecordingExecutor executor = new(0); + CoverageRunner runner = new(executor); + + runner.GenerateCoverage(root); + + // (a) the stale results dir is gone. + Directory.Exists(Path.Combine(root, "coverage")).Should().BeFalse(); + + // (b) the exact locked token list was run. + executor.Commands[0].Should().Equal( + "dotnet", "test", "--collect:XPlat Code Coverage", "--results-directory", "coverage"); + + // (c) the working directory is the verbatim root -- NOT Path.GetFullPath(root) (no normalization). + executor.Directories[0].Should().Be(root); + }); + } + + [Fact] + public void FailsWhenCoverageCommandFails() + { + WithTempRoot(root => + { + RecordingExecutor executor = new(2); + CoverageRunner runner = new(executor); + + Action act = () => runner.GenerateCoverage(root); + + act.Should().Throw() + .WithMessage("Coverage command failed with exit 2"); + }); + } + + [Fact] + public void RunsCoverageCommandWhenNoStaleArtifacts() + { + WithTempRoot(root => + { + // No coverage/ dir exists: the delete branch no-ops and the command still runs. + RecordingExecutor executor = new(0); + CoverageRunner runner = new(executor); + + runner.GenerateCoverage(root); + + executor.Commands[0].Should().Equal( + "dotnet", "test", "--collect:XPlat Code Coverage", "--results-directory", "coverage"); + executor.Directories[0].Should().Be(root); + }); + } + + [Fact] + public void ThrowsOnNullExecutor() + { + Action act = () => _ = new CoverageRunner(null!); + + act.Should().Throw().WithParameterName("executor"); + } + + [Fact] + public void ThrowsOnNullProjectRoot() + { + RecordingExecutor executor = new(0); + CoverageRunner runner = new(executor); + + Action act = () => runner.GenerateCoverage(null!); + + act.Should().Throw().WithParameterName("projectRoot"); + } + + private static void WithTempRoot(Action test) + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + test(root); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + // 1:1 with Java's RecordingExecutor: records each command and workingDirectory and returns a fixed exit + // code. No real process is ever launched. + private sealed class RecordingExecutor : ICommandExecutor + { + private readonly int _exitCode; + + public RecordingExecutor(int exitCode) + { + _exitCode = exitCode; + } + + public List> Commands { get; } = []; + + public List Directories { get; } = []; + + public int Run(IReadOnlyList command, string workingDirectory) + { + Commands.Add(command); + Directories.Add(workingDirectory); + return _exitCode; + } + } +} From 6e8456c19ef9a44ab6945ede7b4bf9b77ea4cd9f Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 02:27:18 -0700 Subject: [PATCH 24/27] T14: CliApplication composition + fail-fast gate (exit 0/1/2) Faithful 1:1 port of crap4java CliApplication/Main (execute/parseArguments/ filesForMode/explicitFiles/maxCrap/thresholdExceeded), module-loop removed (departure #7 resolve-once). Composes shipped types only; no new behavioral departure beyond the approved set #1-#8. - Fail-fast gate (departure #1), three greppable stderr anchors: 1) Locate -> null => "No coverage report was produced" => exit 1 2) Parse(report).Count == 0 => "contained no coverage data" => exit 1 (checks coverageMap.Count, not metrics) 3) CoverageRunner throw propagates ("Coverage command failed with exit") => realized as exit 1 at T15 - Exit-code table owned by T14: 0 (help / no files / max<=8.0), 1 (parse error / fail-fast / propagated), 2 (MaxCrap>8.0 strict -> "CRAP threshold exceeded: {max:F1} > 8.0", InvariantCulture). - Double-parse locked (option A); Analyze(map) overload deferred (Mr. Das). - Execute does NOT catch runner/parser throw (Java parity); T15 Program.Main bare-catch -> exit 1 realizes it (D-T14d). 17 parity tests (fake ICommandExecutor, no real dotnet test). 112 tests green x2, 0/0 Release. Bhaskar PASS, Anders green. T14 register + tracker updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 51 ++ docs/features/crap4csharp-port.md | 6 +- src/Crap4CSharp/CliApplication.cs | 207 ++++++++ .../Crap4CSharp.Tests/CliApplicationTests.cs | 449 ++++++++++++++++++ 4 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 src/Crap4CSharp/CliApplication.cs create mode 100644 tests/Crap4CSharp.Tests/CliApplicationTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index 6c6a99f..4c4e2ac 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -242,6 +242,57 @@ with no Java counterpart (JaCoCo's report path was a fixed constant, so Java nee **throws** on command failure (faithful) and **returns `null`** when the report is absent (the new locate signal); every exit-code decision (departure #1) stays in T14. +## T14 register — `CliApplication` + fail-fast gate (S4 close-out; Anders T14 review, 🟢) + +Resolved decisions and load-bearing invariants from the composition/orchestration layer. +`CliApplication` is a faithful 1:1 port of `crap4java`'s `CliApplication`/`Main` +(`execute`/`parseArguments`/`filesForMode`/`explicitFiles`/`maxCrap`/`thresholdExceeded`) with the +`moduleRootFor` + `groupByModuleRoot`/`analyzeByModule` module-loop removed (departure #7). It composes +already-shipped types only — no resolution/coverage/parse internals of its own — so the S5/T17 +resolution-model swap touches **only** `ModuleRootResolver` (D-T12b). **No new behavioral departure +beyond the already-approved set #1–#8.** + +- **D-T14a — the exit-code table is the canonical CLI contract (T14 owns every exit).** `Execute` + returns exactly: **0** (`--help`; or no files → `"No C# files to analyze."` on stdout; or max + CRAP ≤ 8.0), **1** (parse error `ArgumentException` → `ex.Message` on stderr + usage on stdout; or a + fail-fast trigger; or a propagated exception realized at T15), **2** (`MaxCrap(metrics) > 8.0`, strict + → `"CRAP threshold exceeded: {max:F1} > 8.0"` on stderr, `InvariantCulture`). This realizes + departure #1 (exit 1 for fail-fast) and pins the exit-2 threshold path departure #1 did not + enumerate. Do not move an exit decision out of T14. + +- **D-T14b — three fail-fast triggers, greppable stderr anchors (departure #1).** All module/run-level; + the per-method `N/A` path (a method absent from a *populated* report) stays in `CrapAnalyzer`, + **unchanged**. (1) `CoverageReportLocator.Locate` returns `null` → anchor **`No coverage report was + produced`** → exit 1. (2) `CoberturaCoverageParser.Parse(report).Count == 0` → anchor **`contained no + coverage data`** → exit 1 — trigger 2 must inspect **`coverageMap.Count`, not the metrics** (a + populated-but-non-matching report also yields all-`N/A` metrics yet must NOT fail-fast). (3) + `CoverageRunner.GenerateCoverage` throws → **propagates** (anchor **`Coverage command failed with + exit`**), realized as exit 1 at T15. Mr. Das ruled the trigger 1/2 wording to these defaults; tests + assert only the bold anchor substring, so wording may re-tune without touching test structure. + +- **D-T14c — double-parse LOCKED (option A); the `Analyze(map)` overload is DEFERRED.** T14 calls + `CoberturaCoverageParser.Parse(report)` for the trigger-2 empty check, then + `CrapAnalyzer.Analyze(filesToAnalyze, report)` re-parses the same small XML — negligible cost + (dominated by the `dotnet test` run) and **zero API change** to a discharged T11 type + (fidelity-first). Option B — add `CrapAnalyzer.Analyze(IReadOnlyList, + IReadOnlyDictionary)` to share one parse — modifies a discharged API and is + **Mr. Das's call**; deferred, not required for T14. + +- **D-T14d — the T14/T15 propagation boundary (LOAD-BEARING for T15).** `Execute` does **not** catch + the coverage-runner / parser throw (faithful to Java `execute … throws Exception`); it propagates. + Java relied on the JVM exiting 1 on an uncaught exception, but **.NET does not guarantee exit 1 on an + unhandled exception**, so T15's `Program.Main` **must** catch and convert to exit 1. Mr. Das ruled the + mechanism = a **bare `catch`** (not a `CoverageException` wrapper; Java has none), **applied at T15**, + not T14. `Program.cs` stays the current stub until T15. + +- **Below the departure bar (no departure number, no ruling needed).** `MaxCrap`/`ThresholdExceeded`/ + `Usage` are housed on `CliApplication` (T14), not on `Program` (T15) as Java put `maxCrap`/`usage` on + `Main` — avoids a composition→entry forward dependency and keeps T14 self-contained/testable; pure + organization, behavior/tests unchanged. Java's pre-format `metrics.sort` is omitted because both + `CrapAnalyzer` (T11) and `ReportFormatter` (T4) already apply the identical stable "scored desc, N/A + last" sort and `MaxCrap` is order-free (behavior-preserving). The single-line trigger-1 message + (avoids SA1118) is byte-identical to the fail-fast contract. + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index d93fa62..faed889 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 next commit); **95 tests green, 0/0 Release**. Coverage pipeline (`CrapAnalyzer` + `CoverageRunner`/`CoverageReportLocator`) landed; next = T14 fail-fast gate → T15 `Program`/e2e → T16 README finalize. **Pause at the S4 boundary** for Mr. Das. +**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 next commit); **112 tests green, 0/0 Release**. CLI composition + fail-fast gate landed (departure #1 exit 0/1/2, resolve-once #7); next = T15 `Program`/e2e (bare-catch → exit 1) → T16 README finalize. **Pause at the S4 boundary** for Mr. Das — that closes the core port (S1–S4). ## Requirements @@ -56,9 +56,9 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T9 | S3 | `CSharpMethodParser` + `ComplexityWalker` (augmented node set) + CC oracle tests | Done | `400d49d` | | T10 | S3 | `CoberturaCoverageParser` (+ empty-report case) + tests; pin FQN normalization vs a real coverlet sample | Done | `87488ce` | | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Done | `debf9de` | -| T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Done | `(next commit)` | +| T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Done | `9041c2c` | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | -| T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Pending | - | +| T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Done | `(next commit)` | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | | T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution + multi-report coverage aggregation**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7), but multi-report coverage **aggregation** IS now in scope (per single-pick departure #8) — replace T12's ordinal-first single-pick `CoverageReportLocator` with a union across the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | diff --git a/src/Crap4CSharp/CliApplication.cs b/src/Crap4CSharp/CliApplication.cs new file mode 100644 index 0000000..2b7534f --- /dev/null +++ b/src/Crap4CSharp/CliApplication.cs @@ -0,0 +1,207 @@ +namespace Microsoft.Crap4CSharp; + +using System.Globalization; + +// Faithful port of crap4java's CliApplication, adapted per docs/decisions.md departures #1 (fail-fast) +// and #7 (resolve-once). Orchestrates: parse args -> pick files -> resolve module root ONCE -> +// run coverage ONCE -> locate ONCE -> fail-fast gate -> parse -> analyze -> format -> print -> threshold. +// Composes shipped types only; contains no resolution/coverage/parse internals of its own (the S5/T17 +// model swap must touch only ModuleRootResolver -- the D-T12b seam). +public sealed class CliApplication +{ + // Adapts crap4java's Main.usage() to the C# ecosystem: "Java" -> "C#", ".java" -> ".cs", and the + // executable name to the crap4csharp product. Only ParseArguments consumes it (help + parse-error paths). + private const string Usage = """ + Usage: + crap4csharp Analyze all C# files under src/ + crap4csharp --changed Analyze changed C# files under src/ + crap4csharp Analyze files, or for directory args analyze /src/**/*.cs + crap4csharp --help Print this help message + """; + + private readonly string _projectRoot; + private readonly TextWriter _output; + private readonly TextWriter _error; + private readonly CoverageRunner _coverageRunner; + + public CliApplication( + string projectRoot, + TextWriter output, + TextWriter error, + CoverageRunner coverageRunner) + { + ArgumentNullException.ThrowIfNull(projectRoot); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + ArgumentNullException.ThrowIfNull(coverageRunner); + _projectRoot = projectRoot; + _output = output; + _error = error; + _coverageRunner = coverageRunner; + } + + // Ports execute(String[]). Owns every exit code (§4): 0 (help / no files / max CRAP <= 8.0), + // 1 (parse error / no report / empty report), 2 (threshold exceeded). The coverage-runner throw and + // any parser throw PROPAGATE (faithful to Java's `throws Exception`); T15's Program.Main converts them + // to exit 1. + public int Execute(string[] args) + { + ParseOutcome parse = ParseArguments(args); + if (parse.ExitCode >= 0) + { + return parse.ExitCode; + } + + CliArguments parsed = parse.Arguments!; + IReadOnlyList filesToAnalyze = FilesForMode(parsed); + if (filesToAnalyze.Count == 0) + { + _output.WriteLine("No C# files to analyze."); + return 0; + } + + // Resolve the module root ONCE and run coverage ONCE at it (departure #7: no module-group loop). + string moduleRoot = ModuleRootResolver.Resolve(_projectRoot); + _coverageRunner.GenerateCoverage(moduleRoot); + + // FAIL-FAST TRIGGER 1 (departure #1): no report produced at all. + string? report = CoverageReportLocator.Locate(moduleRoot); + if (report is null) + { + _error.WriteLine($"No coverage report was produced under '{moduleRoot}'. Ensure a test project references coverlet.collector so 'dotnet test --collect' emits coverage.cobertura.xml."); + return 1; + } + + // FAIL-FAST TRIGGER 2 (departure #1): a report was produced but carries no coverage data. Inspect + // the parsed map's emptiness -- NOT the metrics: a populated-but-non-matching report ALSO yields + // all-N/A metrics yet must not fail-fast (that per-method N/A path lives in CrapAnalyzer, unchanged). + IReadOnlyDictionary coverageMap = CoberturaCoverageParser.Parse(report); + if (coverageMap.Count == 0) + { + _error.WriteLine($"Coverage report '{report}' contained no coverage data. Failing fast."); + return 1; + } + + // CrapAnalyzer re-parses `report` (locked double-parse, option A): negligible cost, zero API change. + IReadOnlyList metrics = CrapAnalyzer.Analyze(filesToAnalyze, report); + + // The report already ends with '\n' and is already sorted by both CrapAnalyzer and ReportFormatter, + // so the Java pre-format sort is redundant and omitted (behavior-preserving; MaxCrap is order-free). + _output.Write(ReportFormatter.Format(metrics)); + + double max = MaxCrap(metrics); + if (ThresholdExceeded(max)) + { + _error.WriteLine(string.Format( + CultureInfo.InvariantCulture, + "CRAP threshold exceeded: {0:F1} > 8.0", + max)); + return 2; + } + + return 0; + } + + // Ports thresholdExceeded: strict '>' against 8.0 via CompareTo (Java's Double.compare analog). + public static bool ThresholdExceeded(double max) => max.CompareTo(8.0) > 0; + + // Ports Main.maxCrap: the largest non-null CrapScore, or 0.0 when every method is N/A. + public static double MaxCrap(IReadOnlyList metrics) + { + ArgumentNullException.ThrowIfNull(metrics); + + double max = 0.0; + foreach (MethodMetrics metric in metrics) + { + if (metric.CrapScore is not null) + { + max = Math.Max(max, metric.CrapScore.Value); + } + } + + return max; + } + + // Ports parseArguments: parse; on --help print usage to stdout and exit 0; on ArgumentException (W6: + // the broad type, catching the ArgumentNullException null-args case too) print the message to stderr, + // usage to stdout, and exit 1; otherwise proceed (ExitCode -1). + private ParseOutcome ParseArguments(string[] args) + { + try + { + CliArguments parsed = CliArgumentsParser.Parse(args); + if (parsed.Mode == CliMode.Help) + { + _output.WriteLine(Usage); + return ParseOutcome.Exit(0); + } + + return ParseOutcome.Ok(parsed); + } + catch (ArgumentException ex) + { + _error.WriteLine(ex.Message); + _output.WriteLine(Usage); + return ParseOutcome.Exit(1); + } + } + + // Ports filesForMode: a switch expression over all four CliMode arms plus a default throw (W7). The + // Help arm is dead (ParseArguments short-circuits it), retained for exhaustiveness. + private IReadOnlyList FilesForMode(CliArguments parsed) + { + return parsed.Mode switch + { + CliMode.AllSrc => SourceFileFinder.FindAllCSharpFilesUnderSrc(_projectRoot), + CliMode.ChangedSrc => ChangedFileDetector.ChangedCSharpFilesUnderSrc(_projectRoot), + CliMode.ExplicitFiles => ExplicitFiles(parsed.FileArgs), + CliMode.Help => [], + _ => throw new InvalidOperationException($"Unhandled CLI mode: {parsed.Mode}"), + }; + } + + // Ports explicitFiles: resolve each arg against projectRoot; a directory arg expands via SourceFileFinder + // (routing through the finder keeps the bin/obj exclusion, W12), a file arg is taken verbatim. + // De-duplicate and sort with StringComparer.Ordinal (departure #3; replaces Java's LinkedHashSet + + // naturalOrder), matching SourceFileFinder/ChangedFileDetector. + private IReadOnlyList ExplicitFiles(IReadOnlyList fileArgs) + { + HashSet files = new(StringComparer.Ordinal); + foreach (string arg in fileArgs) + { + string path = Path.GetFullPath(Path.Combine(_projectRoot, arg)); + if (Directory.Exists(path)) + { + foreach (string found in SourceFileFinder.FindAllCSharpFilesUnderSrc(path)) + { + files.Add(found); + } + } + else + { + files.Add(path); + } + } + + return [.. files.OrderBy(path => path, StringComparer.Ordinal)]; + } + + // Ports Java's ParseOutcome: Ok(arguments) proceeds (ExitCode -1); Exit(code) short-circuits with an + // exit code. Arguments is non-null exactly when ExitCode < 0. + private sealed class ParseOutcome + { + private ParseOutcome(CliArguments? arguments, int exitCode) + { + Arguments = arguments; + ExitCode = exitCode; + } + + public CliArguments? Arguments { get; } + + public int ExitCode { get; } + + public static ParseOutcome Ok(CliArguments arguments) => new(arguments, -1); + + public static ParseOutcome Exit(int code) => new(null, code); + } +} diff --git a/tests/Crap4CSharp.Tests/CliApplicationTests.cs b/tests/Crap4CSharp.Tests/CliApplicationTests.cs new file mode 100644 index 0000000..fd1237a --- /dev/null +++ b/tests/Crap4CSharp.Tests/CliApplicationTests.cs @@ -0,0 +1,449 @@ +namespace Microsoft.Crap4CSharp.Tests; + +// Ports crap4java's CliApplicationTest + MainTest into the T14 composition layer, adapted for the two +// ratified departures that reshape these tests: #1 (fail-fast) and #7 (resolve-once). CliApplication is +// exercised directly (no Program/T15), injecting a fake ICommandExecutor via CoverageRunner -- exactly the +// Java `CoverageRunner((command, directory) -> ...)` seam -- so no real `dotnet test` is ever launched. +// Java dispositions (see the T14 contract §6): parseErrors/noFiles/threshold/help/maxCrap ports; the +// coverage-path tests (doesNotWarnWhenJacocoXmlExists, explicitFileArgsAreAnalyzed, +// directoryArg..., and the replaced explicitFileUsesOwningModule...) now require a WRITTEN report to reach +// exit 0 because the Java "warn + N/A + exit 0" no-report branch is now fail-fast; two dedicated fail-fast +// tests (#10, #11) plus C#-specific exit-2 (#12) and trigger-3 propagation (#13) tests are added. Fixtures +// are modeled on CrapAnalyzerTests.Alpha75Xml; temp trees are always removed in a finally; test names are +// PascalCase (C2/CA1707) while fixture member names inside string literals keep crap4java's lowercase. +public class CliApplicationTests +{ + private const string AlphaSampleSource = """ + namespace demo; + class Sample + { + int alpha(bool a) + { + if (a) + { + return 1; + } + return 0; + } + } + """; + + // alpha declared at line 4; covered lines 6,7,8 (hits=1) + missed line 10 (hits=0) -> 75%, resolved + // from alpha's StartLine 4 by nearest line to key demo.Sample#alpha:6 -> CRAP 2.0625 (<= 8.0, exit 0). + private const string Alpha75Xml = """ + + + + + + + + + + + + + + + + + + + + + + """; + + // 1 <- CliApplicationTest.parseErrorsReturnUsageAndExitOne. --changed combined with a file arg throws + // ArgumentException (W6), caught -> message to stderr, usage to stdout, exit 1. + [Fact] + public void ParseErrorsReturnUsageAndExitOne() + { + WithTempRoot(root => + { + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, null))); + + int exit = app.Execute(["--changed", "src/Sample.cs"]); + + exit.Should().Be(1); + output.ToString().Should().Contain("Usage:"); + error.ToString().Should().Contain("--changed cannot be combined with file arguments"); + }); + } + + // 2 <- CliApplicationTest.returnsZeroWhenNoFilesAreFound (message adapted to C#). No src/ -> no files -> + // exit 0 BEFORE any coverage run. + [Fact] + public void ReturnsZeroWhenNoFilesAreFound() + { + WithTempRoot(root => + { + using StringWriter output = new(); + using StringWriter error = new(); + FakeExecutor fake = new(0, null); + CliApplication app = new(root, output, error, new CoverageRunner(fake)); + + int exit = app.Execute([]); + + exit.Should().Be(0); + output.ToString().Should().Contain("No C# files to analyze."); + fake.Directories.Should().BeEmpty(); + }); + } + + // 3 <- CliApplicationTest.doesNotWarnWhenJacocoXmlExists (adapted). A written cobertura report reaches + // exit 0; stdout has the class, stderr has NEITHER fail-fast anchor. + [Fact] + public void AnalyzesAndReportsWhenCoverageExists() + { + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, Alpha75Xml))); + + int exit = app.Execute(["Sample.cs"]); + + exit.Should().Be(0); + output.ToString().Should().Contain("Sample"); + error.ToString().Should().NotContain("No coverage report was produced"); + error.ToString().Should().NotContain("contained no coverage data"); + }); + } + + // 4 <- MainTest.helpWritesUsageToStdout (via Execute directly). --help -> usage to stdout, exit 0, no run. + [Fact] + public void HelpWritesUsageToStdout() + { + WithTempRoot(root => + { + using StringWriter output = new(); + using StringWriter error = new(); + FakeExecutor fake = new(0, null); + CliApplication app = new(root, output, error, new CoverageRunner(fake)); + + int exit = app.Execute(["--help"]); + + exit.Should().Be(0); + output.ToString().Should().Contain("Usage:"); + fake.Directories.Should().BeEmpty(); + }); + } + + // 5 <- CliApplicationTest.thresholdExceededUsesStrictlyGreaterThanEight. Pins the strict '>' boundary. + [Fact] + public void ThresholdExceededUsesStrictlyGreaterThanEight() + { + CliApplication.ThresholdExceeded(8.0).Should().BeFalse(); + CliApplication.ThresholdExceeded(8.1).Should().BeTrue(); + } + + // 6 <- MainTest.maxCrapReturnsLargestNonNullScore (now CliApplication.MaxCrap). + [Fact] + public void MaxCrapReturnsLargestNonNullScore() + { + IReadOnlyList metrics = + [ + new MethodMetrics("alpha", "demo.Sample", 1, null, null), + new MethodMetrics("beta", "demo.Sample", 1, 75.0, 4.5), + new MethodMetrics("gamma", "demo.Sample", 1, 85.0, 7.0), + ]; + + CliApplication.MaxCrap(metrics).Should().Be(7.0); + } + + // 7 <- MainTest.explicitFileArgsAreAnalyzed (adapted: a written report is now required for exit 0). + [Fact] + public void ExplicitFileArgsAreAnalyzed() + { + WithTempRoot(root => + { + string relative = Path.Combine("src", "demo", "Sample.cs"); + WriteFile(root, relative, AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, Alpha75Xml))); + + int exit = app.Execute([relative]); + + exit.Should().Be(0); + output.ToString().Should().Contain("Sample"); + output.ToString().Should().Contain("alpha"); + }); + } + + // 8 <- MainTest.directoryArgAnalyzesJavaFilesUnderThatDirectorySrc (adapted). A directory arg expands via + // SourceFileFinder under /src (W12); a written report reaches exit 0. + [Fact] + public void DirectoryArgAnalyzesCSharpFilesUnderThatDirectorySrc() + { + WithTempRoot(root => + { + WriteFile(root, Path.Combine("module-a", "src", "demo", "Sample.cs"), AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, Alpha75Xml))); + + int exit = app.Execute(["module-a"]); + + exit.Should().Be(0); + output.ToString().Should().Contain("Sample"); + output.ToString().Should().Contain("alpha"); + }); + } + + // 9 -- replaces CliApplicationTest.explicitFileUsesOwningModuleForCoverageAndJacocoXml (departure #7). + // Coverage runs EXACTLY ONCE at ModuleRootResolver.Resolve(projectRoot) with the locked token list. + [Fact] + public void RunsCoverageOnceAtResolvedModuleRoot() + { + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + FakeExecutor fake = new(0, Alpha75Xml); + CliApplication app = new(root, output, error, new CoverageRunner(fake)); + + int exit = app.Execute(["Sample.cs"]); + + exit.Should().Be(0); + fake.Directories.Should().ContainSingle(); + fake.Directories[0].Should().Be(ModuleRootResolver.Resolve(root)); + fake.Commands[0].Should().Equal( + "dotnet", "test", "--collect:XPlat Code Coverage", "--results-directory", "coverage"); + }); + } + + // 10 -- FAIL-FAST TRIGGER 1: the runner succeeds but emits no report -> exit 1, stderr anchor. + [Fact] + public void FailsFastWhenNoCoverageReportProduced() + { + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, null))); + + int exit = app.Execute(["Sample.cs"]); + + exit.Should().Be(1); + error.ToString().Should().Contain("No coverage report was produced"); + }); + } + + // 11 -- FAIL-FAST TRIGGER 2: a report is produced but parses to an empty map -> exit 1, stderr anchor. + [Fact] + public void FailsFastWhenCoverageReportIsEmpty() + { + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + const string emptyReport = ""; + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, emptyReport))); + + int exit = app.Execute(["Sample.cs"]); + + exit.Should().Be(1); + error.ToString().Should().Contain("contained no coverage data"); + }); + } + + // 12 -- C#-specific: a CC=3 method (two `if`s) with resolved 0% coverage yields CRAP 12 > 8 -> exit 2. + [Fact] + public void ReturnsTwoWhenCrapThresholdExceeded() + { + const string riskySource = """ + namespace demo; + class Sample + { + int risky(bool a, bool b) + { + if (a) + { + return 1; + } + if (b) + { + return 2; + } + return 0; + } + } + """; + + // risky declared at line 4; all lines hits=0 -> 0% coverage, resolved from StartLine 4 to the nearest + // key demo.Sample#risky:6. CRAP = 3^2 * (1-0)^3 + 3 = 12. + const string zeroCoverageXml = """ + + + + + + + + + + + + + + + + + + + + + + + """; + + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", riskySource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(0, zeroCoverageXml))); + + int exit = app.Execute(["Sample.cs"]); + + exit.Should().Be(2); + error.ToString().Should().Contain("CRAP threshold exceeded"); + }); + } + + // 13 -- C#-specific trigger-3 boundary: a non-zero coverage exit throws InvalidOperationException, which + // Execute PROPAGATES (T15 converts it to exit 1). + [Fact] + public void PropagatesWhenCoverageCommandFails() + { + WithTempRoot(root => + { + WriteFile(root, "Sample.cs", AlphaSampleSource); + using StringWriter output = new(); + using StringWriter error = new(); + CliApplication app = new(root, output, error, new CoverageRunner(new FakeExecutor(2, null))); + + Action act = () => app.Execute(["Sample.cs"]); + + act.Should().Throw() + .WithMessage("Coverage command failed with exit 2"); + }); + } + + // 14 -- CA1062 ctor guard. + [Fact] + public void ConstructorThrowsOnNullProjectRoot() + { + using StringWriter output = new(); + using StringWriter error = new(); + CoverageRunner runner = new(new FakeExecutor(0, null)); + + Action act = () => _ = new CliApplication(null!, output, error, runner); + + act.Should().Throw().WithParameterName("projectRoot"); + } + + // 15 -- CA1062 ctor guard. + [Fact] + public void ConstructorThrowsOnNullOutput() + { + using StringWriter error = new(); + CoverageRunner runner = new(new FakeExecutor(0, null)); + + Action act = () => _ = new CliApplication("root", null!, error, runner); + + act.Should().Throw().WithParameterName("output"); + } + + // 16 -- CA1062 ctor guard. + [Fact] + public void ConstructorThrowsOnNullError() + { + using StringWriter output = new(); + CoverageRunner runner = new(new FakeExecutor(0, null)); + + Action act = () => _ = new CliApplication("root", output, null!, runner); + + act.Should().Throw().WithParameterName("error"); + } + + // 17 -- CA1062 ctor guard. + [Fact] + public void ConstructorThrowsOnNullCoverageRunner() + { + using StringWriter output = new(); + using StringWriter error = new(); + + Action act = () => _ = new CliApplication("root", output, error, null!); + + act.Should().Throw().WithParameterName("coverageRunner"); + } + + private static void WithTempRoot(Action test) + { + string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + test(root); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static string WriteFile(string root, string relativePath, string content) + { + string path = Path.Combine(root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + // The one shared fake seam (contract §7): records each command + workingDirectory and, when configured + // with report XML, writes it under /coverage//coverage.cobertura.xml (the + // D-T12e reciprocal path CoverageReportLocator reads), then returns the configured exit code. No real + // process is ever launched. + private sealed class FakeExecutor : ICommandExecutor + { + private readonly int _exitCode; + private readonly string? _reportXml; + + public FakeExecutor(int exitCode, string? reportXml) + { + _exitCode = exitCode; + _reportXml = reportXml; + } + + public List> Commands { get; } = []; + + public List Directories { get; } = []; + + public int Run(IReadOnlyList command, string workingDirectory) + { + Commands.Add(command); + Directories.Add(workingDirectory); + if (_reportXml is not null) + { + string reportDirectory = + Path.Combine(workingDirectory, "coverage", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(reportDirectory); + File.WriteAllText(Path.Combine(reportDirectory, "coverage.cobertura.xml"), _reportXml); + } + + return _exitCode; + } + } +} From 1f7581ec291568002f97965cb324a8876c761d35 Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 09:00:10 -0700 Subject: [PATCH 25/27] T15: Program entry point + CoverageException + spawn e2e tests Faithful port of crap4java Main.main: resolve projectRoot (Path.GetFullPath ("."), == Path.of(".").toAbsolutePath().normalize()), wire real console + ProcessCommandExecutor, delegate to CliApplication.Execute (owns the 0/1/2 exit table). No Run seam; composition/exit ownership stay on CliApplication (D-T14b). Program adds no logic of its own. Realizes the T14/T15 propagation boundary (D-T14d) via a typed domain exception (Mr. Das ruling A, supersedes the T14-planned bare-catch / "D2"): - New CoverageException (sealed, 3 standard ctors, no obsolete serialization ctor). CoverageRunner.GenerateCoverage now throws it (message byte-identical "Coverage command failed with exit N"). - Program.Main catches CoverageException SPECIFICALLY -> full ex to stderr (ruling B) -> return 1. CA1031-clean by construction: no .editorconfig entry, no suppression. Execute keeps propagating (never catches). ProgramTests: exactly two spawn-the-real-exe integration tests (ruling C), ports of MainTest.mainProcess* -- dotnet / Microsoft.Crap4CSharp.dll (framework-dependent, cross-platform CI), temp cwd, async drain, 60s hang guard. Both short-circuit before any real dotnet test (--help -> 0 + "Usage:"; --changed+file -> 1 + parse-error anchor). Tests updated to the retyped throw: CliApplicationTests #13 + CoverageRunnerTests.FailsWhenCoverageCommandFails -> expect CoverageException. decisions.md: reconcile stale bare-catch/InvalidOperationException wording (D-T12d, D-T14b(3), D-T14d) to the shipped typed catch; add T15 register (D-T15a..e) incl. the below-threshold parser-throw edge. Build 0/0 Release (WAE); 114/114 tests green x3 deterministic. Bhaskar PASS, Anders green. Task table + status updated (T14 6e8456c filled). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/decisions.md | 85 ++++++++++++++-- docs/features/crap4csharp-port.md | 6 +- src/Crap4CSharp/CoverageException.cs | 24 +++++ src/Crap4CSharp/CoverageRunner.cs | 10 +- src/Crap4CSharp/Program.cs | 27 ++++- .../Crap4CSharp.Tests/CliApplicationTests.cs | 4 +- .../Crap4CSharp.Tests/CoverageRunnerTests.cs | 2 +- tests/Crap4CSharp.Tests/ProgramTests.cs | 98 +++++++++++++++++++ 8 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 src/Crap4CSharp/CoverageException.cs create mode 100644 tests/Crap4CSharp.Tests/ProgramTests.cs diff --git a/docs/decisions.md b/docs/decisions.md index 4c4e2ac..0fea9bc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -236,9 +236,11 @@ with no Java counterpart (JaCoCo's report path was a fixed constant, so Java nee - **No new departure from D-T12a/c/d.** The generate/locate split (D-T12a) preserves Java's `CoverageRunner` shape and keeps the locator independently testable; resolve-once/run-once (D-T12c) is - departure #7; `InvalidOperationException` with the byte-identical message - `"Coverage command failed with exit N"` (D-T12d) is the already-ratified `IllegalStateException` - analog (C1, as in `CoberturaCoverageParser`). The T12/T14 fail-fast boundary is unchanged: T12 + departure #7; the coverage-command failure throws with the byte-identical message + `"Coverage command failed with exit N"` (D-T12d) — originally the `IllegalStateException` → + `InvalidOperationException` analog (C1, as in `CoberturaCoverageParser`), but **retyped at T15 + (ruling A) to the domain `CoverageException`** (message unchanged) so `Program.Main` can catch it + specifically (D-T15b). The T12/T14 fail-fast boundary is unchanged: T12 **throws** on command failure (faithful) and **returns `null`** when the report is absent (the new locate signal); every exit-code decision (departure #1) stays in T14. @@ -266,8 +268,9 @@ beyond the already-approved set #1–#8.** produced`** → exit 1. (2) `CoberturaCoverageParser.Parse(report).Count == 0` → anchor **`contained no coverage data`** → exit 1 — trigger 2 must inspect **`coverageMap.Count`, not the metrics** (a populated-but-non-matching report also yields all-`N/A` metrics yet must NOT fail-fast). (3) - `CoverageRunner.GenerateCoverage` throws → **propagates** (anchor **`Coverage command failed with - exit`**), realized as exit 1 at T15. Mr. Das ruled the trigger 1/2 wording to these defaults; tests + `CoverageRunner.GenerateCoverage` throws `CoverageException` → **propagates** (anchor **`Coverage + command failed with exit`**), realized as exit 1 at T15 via the specific `catch (CoverageException)` + in `Program.Main` (ruling A / D-T15b). Mr. Das ruled the trigger 1/2 wording to these defaults; tests assert only the bold anchor substring, so wording may re-tune without touching test structure. - **D-T14c — double-parse LOCKED (option A); the `Analyze(map)` overload is DEFERRED.** T14 calls @@ -281,9 +284,14 @@ beyond the already-approved set #1–#8.** - **D-T14d — the T14/T15 propagation boundary (LOAD-BEARING for T15).** `Execute` does **not** catch the coverage-runner / parser throw (faithful to Java `execute … throws Exception`); it propagates. Java relied on the JVM exiting 1 on an uncaught exception, but **.NET does not guarantee exit 1 on an - unhandled exception**, so T15's `Program.Main` **must** catch and convert to exit 1. Mr. Das ruled the - mechanism = a **bare `catch`** (not a `CoverageException` wrapper; Java has none), **applied at T15**, - not T14. `Program.cs` stays the current stub until T15. + unhandled exception**, so T15's `Program.Main` **must** catch and convert to exit 1. This boundary was + planned at T14 as a **bare `catch`** (the "D2" mechanism; Java has no coverage wrapper), but + **superseded at T15 by ruling A** (see the T15 register / D-T15b): T15 instead throws the domain + `CoverageException` and catches it **specifically** (`catch (CoverageException) → return 1`), realizing + the **identical** observable behavior (full `ex` to stderr + exit 1) while keeping the entry-point + catch least-privilege / CA1031-clean with **no** suppression. The load-bearing invariant is unchanged: + `Execute` keeps propagating and `Program.Main` MUST catch to guarantee exit 1. (T15 shipped; + `Program.cs` is no longer a stub.) - **Below the departure bar (no departure number, no ruling needed).** `MaxCrap`/`ThresholdExceeded`/ `Usage` are housed on `CliApplication` (T14), not on `Program` (T15) as Java put `maxCrap`/`usage` on @@ -293,6 +301,67 @@ beyond the already-approved set #1–#8.** last" sort and `MaxCrap` is order-free (behavior-preserving). The single-line trigger-1 message (avoids SA1118) is byte-identical to the fail-fast contract. +## T15 register — `Program` entry point + spawn integration tests (S4; Anders T15 review, 🟢) + +Resolved decisions and load-bearing invariants from the entry-point task. `Program.Main` faithfully +ports crap4java's `Main.main` shape — resolve the project root, wire the real console streams + a real +`ProcessCommandExecutor`, delegate to `CliApplication.Execute`, and convert an escaping coverage-command +failure to exit 1 — while composition and exit ownership stay on `CliApplication` (D-T14b), so `Program` +adds no logic of its own. **No new behavioral departure beyond the already-approved set #1–#8.** + +- **D-T15a — `Program` is a thin entry point only (no `Run` seam).** `Main` composes and delegates in a + single expression; there is **no** intermediate `Run`/composition-root method, and + `MaxCrap`/`Usage`/`ThresholdExceeded` stay on `CliApplication` (D-T14b), not on `Program` (where Java + put `maxCrap`/`usage` on `Main`). This avoids a composition→orchestration forward dependency and keeps + the S5/T17 resolution-model swap localized to `ModuleRootResolver` (D-T12b) — `Program` never learns + how the root is found. + +- **D-T15b — `CoverageException` + the specific `catch (CoverageException) → return 1` REALIZES and + DISCHARGES the D-T14d boundary (LOAD-BEARING on both ends).** `CoverageRunner.GenerateCoverage` throws + the domain `public sealed class CoverageException` (three standard ctors, **no** serialization ctor — + the modern net8.0 CA1032 form; the legacy ctor is obsolete under `SYSLIB0051`), message byte-identical + (`"Coverage command failed with exit N"`); `CliApplication.Execute` **keeps propagating** it (never + catches); `Program.Main` catches it **specifically** and returns 1, writing the full `ex` (type + + message + stack via `Console.Error.WriteLine(ex)`, ruling B) to stderr. This is what guarantees exit 1 + on .NET (which does **not** guarantee it for an unhandled exception), so **both** ends are load-bearing: + `Execute` must not start catching, and `Program.Main` must not stop catching. The typed catch is + CA1031-clean with **no** `.editorconfig` entry / suppression, **superseding the T14-planned bare + `catch`** (D-T14d). Mr. Das's register wording, verbatim: "introduce `CoverageException` to enable a + least-privilege specific catch (CA1031-clean, no suppression); observable behavior unchanged (stderr + + exit 1), so not a behavioral departure — **supersedes D2**." (Here "D2" = the T14-planned bare-catch + mechanism recorded in D-T14d, **not** the feature-doc deferral "D2 = O2 subprocess timeout".) + +- **D-T15c — `projectRoot = Path.GetFullPath(".")` (LOAD-BEARING for downstream path combines).** Mirrors + Java `Path.of(".").toAbsolutePath().normalize()`: an absolute, normalized current directory. Every + downstream combine/resolve (`ModuleRootResolver.Resolve`, the coverage results dir, explicit-file + resolution) assumes an absolute normalized root, so this exact spelling is load-bearing — do not pass a + bare `"."`. + +- **D-T15d — spawn resolution: `dotnet ` via `AppContext.BaseDirectory`.** The + app assembly is `Microsoft.Crap4CSharp.dll` (`Common.targets`: + `AssemblyName=Microsoft.$(MSBuildProjectName)`), located next to the test assembly through + `AppContext.BaseDirectory` — present at run time because the app is a **ProjectReference** of the test + project, so its DLL is copied into the test output dir. The tests launch it **framework-dependent** as + `dotnet `, **not** the OS apphost (`Microsoft.Crap4CSharp.exe` on Windows, + extension-less/possibly non-executable after xcopy on Linux), so both spawn tests run uniformly on + every CI OS (incl. ubuntu). + +- **D-T15e — the two spawn tests must never reach a coverage run (fast/deterministic under D-T8).** Both + ported crap4java `MainTest.mainProcess*` cases pick arguments that short-circuit **before** + `CliApplication` reaches `CoverageRunner.GenerateCoverage`: `--help` → usage / exit 0; `--changed` + a + file arg → parse error (`ArgumentException`) → stderr / exit 1. No real `dotnet test` is ever spawned, + so the tests are fast and deterministic even though D-T8 runs them by default. The four remaining Java + `MainTest` cases (#1/#4/#5/#6) are covered in-process by `CliApplicationTests` (a D-T14b relocation, not + a reduction) — no fidelity loss. + - **Below-threshold consequence of the least-privilege catch (no departure number, no ruling needed).** + The specific `catch (CoverageException)` narrows the converted-to-exit-1 surface to the coverage- + **command** failure. A `CoberturaCoverageParser.Parse` `InvalidOperationException` (malformed XML, + D-T10a) would **not** be converted to a clean exit 1 — but `CoverageReportLocator.Locate` only ever + surfaces a **real coverlet** report (well-formed by construction), so this path is **untested and + practically unreachable**. It is a deliberate below-threshold consequence of the typed catch, **not** + a new behavioral departure (the reachable observable behavior — stderr + exit 1 on a coverage-command + failure — is unchanged from the bare-catch plan). + ## Deliberate departures from crap4java (approved by Mr. Das) 1. **Fail fast** — when a module produces no coverage / runs no tests, exit non-zero (`1`) with a diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index faed889..596f7f4 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 next commit); **112 tests green, 0/0 Release**. CLI composition + fail-fast gate landed (departure #1 exit 0/1/2, resolve-once #7); next = T15 `Program`/e2e (bare-catch → exit 1) → T16 README finalize. **Pause at the S4 boundary** for Mr. Das — that closes the core port (S1–S4). +**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 `6e8456c`, T15 next commit); **114 tests green, 0/0 Release**. Entry point landed: `Program.Main` → `CliApplication.Execute` with a typed `CoverageException` specific catch → exit 1 (ruling A, supersedes the T14-planned bare-catch); departure #1 exit 0/1/2, resolve-once #7; **only T16 (README finalize) remains before the S4 boundary**. **Pause at the S4 boundary** for Mr. Das — that closes the core port (S1–S4). ## Requirements @@ -58,8 +58,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T11 | S4 | `CrapAnalyzer` (exact→nearest-line lookup, per-method `TypeName`) + tests | Done | `debf9de` | | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Done | `9041c2c` | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | -| T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Done | `(next commit)` | -| T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Pending | - | +| T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Done | `6e8456c` | +| T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Done | `(next commit)` | | T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | | T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution + multi-report coverage aggregation**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7), but multi-report coverage **aggregation** IS now in scope (per single-pick departure #8) — replace T12's ordinal-first single-pick `CoverageReportLocator` with a union across the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | | T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | diff --git a/src/Crap4CSharp/CoverageException.cs b/src/Crap4CSharp/CoverageException.cs new file mode 100644 index 0000000..45804fc --- /dev/null +++ b/src/Crap4CSharp/CoverageException.cs @@ -0,0 +1,24 @@ +namespace Microsoft.Crap4CSharp; + +/// +/// Domain failure raised when the coverage COMMAND itself fails (a non-zero exit from the coverage +/// run in ). catches this type +/// specifically to convert an escaping coverage-command failure into process exit 1 (the D-T14d +/// realization), keeping the entry-point catch least-privilege / CA1031-clean without any suppression. +/// +public sealed class CoverageException : Exception +{ + public CoverageException() + { + } + + public CoverageException(string? message) + : base(message) + { + } + + public CoverageException(string? message, Exception? innerException) + : base(message, innerException) + { + } +} diff --git a/src/Crap4CSharp/CoverageRunner.cs b/src/Crap4CSharp/CoverageRunner.cs index 1467e77..5acebe2 100644 --- a/src/Crap4CSharp/CoverageRunner.cs +++ b/src/Crap4CSharp/CoverageRunner.cs @@ -11,8 +11,10 @@ namespace Microsoft.Crap4CSharp; // coverage`, and the two JaCoCo artifacts (target/site/jacoco + target/jacoco.exec) collapse to the // single "coverage" results directory. `dotnet test` with no project/solution argument discovers the // project in the working directory (projectRoot), exactly as `mvn` did. -// * IllegalStateException -> InvalidOperationException (the repo's ratified analog, C1). The exit-failure -// message is byte-identical to Java: "Coverage command failed with exit N". +// * IllegalStateException -> the domain CoverageException (introduced in T15 per Mr. Das ruling A): the +// coverage-command failure now throws CoverageException so the entry point can catch it SPECIFICALLY +// (CA1031-clean, no suppression) and convert it to exit 1. The exit-failure message is byte-identical to +// Java: "Coverage command failed with exit N". // // Resolve-once (departure #7): this type takes a PRE-RESOLVED projectRoot and never calls ModuleRootResolver; // it runs coverage exactly once at the one root (no module-group loop). The fail-fast gate is T14, not here: @@ -36,7 +38,7 @@ public CoverageRunner(ICommandExecutor executor) } // Ports generateCoverage(Path): delete the stale results dir, run the coverage command once at projectRoot, - // throw InvalidOperationException on a non-zero exit. Returns void. + // throw CoverageException on a non-zero exit. Returns void. public void GenerateCoverage(string projectRoot) { ArgumentNullException.ThrowIfNull(projectRoot); @@ -61,7 +63,7 @@ public void GenerateCoverage(string projectRoot) { // Byte-identical to Java; InvariantCulture keeps the exit number locale-independent (CA1305, // departure #3). - throw new InvalidOperationException( + throw new CoverageException( "Coverage command failed with exit " + exit.ToString(CultureInfo.InvariantCulture)); } } diff --git a/src/Crap4CSharp/Program.cs b/src/Crap4CSharp/Program.cs index d0f5525..a3ab80f 100644 --- a/src/Crap4CSharp/Program.cs +++ b/src/Crap4CSharp/Program.cs @@ -1,13 +1,34 @@ namespace Microsoft.Crap4CSharp; /// -/// Entry point for the crap4csharp CLI. Real argument handling and analysis wiring arrive in a -/// later task; this placeholder keeps the executable buildable and warning-clean. +/// Entry point for the crap4csharp CLI. Faithful port of crap4java's Main.main: resolve the +/// project root as the absolute, normalized current directory (Path.GetFullPath(".") == +/// Java Path.of(".").toAbsolutePath().normalize()), wire the real console streams and a real +/// , and delegate to (which +/// owns the full 0 / 1 / 2 exit table). The catch realizes the T14/T15 propagation boundary (D-T14d): +/// deliberately does not catch the coverage-command failure, and +/// because .NET — unlike the JVM — does not guarantee process exit code 1 on an unhandled exception, +/// catches the domain specifically and converts it to +/// exit 1. The returned is used as the process exit code (never Environment.Exit). /// public static class Program { public static int Main(string[] args) { - return 0; + string projectRoot = Path.GetFullPath("."); + try + { + return new CliApplication( + projectRoot, + Console.Out, + Console.Error, + new CoverageRunner(new ProcessCommandExecutor())) + .Execute(args); + } + catch (CoverageException ex) + { + Console.Error.WriteLine(ex); + return 1; + } } } diff --git a/tests/Crap4CSharp.Tests/CliApplicationTests.cs b/tests/Crap4CSharp.Tests/CliApplicationTests.cs index fd1237a..5842396 100644 --- a/tests/Crap4CSharp.Tests/CliApplicationTests.cs +++ b/tests/Crap4CSharp.Tests/CliApplicationTests.cs @@ -319,7 +319,7 @@ int risky(bool a, bool b) }); } - // 13 -- C#-specific trigger-3 boundary: a non-zero coverage exit throws InvalidOperationException, which + // 13 -- C#-specific trigger-3 boundary: a non-zero coverage exit throws CoverageException, which // Execute PROPAGATES (T15 converts it to exit 1). [Fact] public void PropagatesWhenCoverageCommandFails() @@ -333,7 +333,7 @@ public void PropagatesWhenCoverageCommandFails() Action act = () => app.Execute(["Sample.cs"]); - act.Should().Throw() + act.Should().Throw() .WithMessage("Coverage command failed with exit 2"); }); } diff --git a/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs b/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs index 401c076..2b3516c 100644 --- a/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs +++ b/tests/Crap4CSharp.Tests/CoverageRunnerTests.cs @@ -48,7 +48,7 @@ public void FailsWhenCoverageCommandFails() Action act = () => runner.GenerateCoverage(root); - act.Should().Throw() + act.Should().Throw() .WithMessage("Coverage command failed with exit 2"); }); } diff --git a/tests/Crap4CSharp.Tests/ProgramTests.cs b/tests/Crap4CSharp.Tests/ProgramTests.cs new file mode 100644 index 0000000..a484b2c --- /dev/null +++ b/tests/Crap4CSharp.Tests/ProgramTests.cs @@ -0,0 +1,98 @@ +namespace Microsoft.Crap4CSharp.Tests; + +using System.Diagnostics; + +// The genuinely-new T15 integration tests: spawn the REAL built crap4csharp entry point end-to-end and +// assert its process exit code. Ports crap4java MainTest's two mainProcess* tests -- the only MainTest +// cases not already pinned in-process by CliApplicationTests (see the T15 contract §4 ledger). Both spawn +// argument paths deliberately short-circuit BEFORE any coverage run (--help -> usage/exit 0; --changed + +// file -> parse error/exit 1), so no real `dotnet test` is ever launched: the tests are fast + deterministic +// even though D-T8 runs them by default. The app is spawned as +// `dotnet /Microsoft.Crap4CSharp.dll` (framework-dependent activation) -- NOT the +// OS-specific apphost -- for cross-platform (ubuntu CI) determinism. PascalCase names, no [Trait] (C2/D-T8). +public class ProgramTests +{ + private const string AppDllName = "Microsoft.Crap4CSharp.dll"; + private const int SpawnTimeoutMs = 60_000; + + [Fact] + public async Task MainProcessExitsZeroForHelp() + { + SpawnResult result = await RunEntryPointAsync("--help"); + + result.ExitCode.Should().Be(0); + result.StandardOutput.Should().Contain("Usage:"); + } + + [Fact] + public async Task MainProcessExitsNonZeroForUnknownOption() + { + SpawnResult result = await RunEntryPointAsync("--changed", "src/demo/Sample.cs"); + + result.ExitCode.Should().Be(1); + result.StandardError.Should().Contain("--changed cannot be combined with file arguments"); + } + + private static async Task RunEntryPointAsync(params string[] args) + { + // The app is a ProjectReference of the test project, so its DLL is copied next to the test assembly; + // AppContext.BaseDirectory is that output dir at run time. Assembly name is Microsoft.Crap4CSharp + // (Common.targets: AssemblyName=Microsoft.$(MSBuildProjectName)), hence Microsoft.Crap4CSharp.dll. + string appDll = Path.Combine(AppContext.BaseDirectory, AppDllName); + File.Exists(appDll).Should().BeTrue( + $"the app is a ProjectReference so '{AppDllName}' must be copied next to the test assembly"); + + string workingDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + try + { + // Spawn via `dotnet ` (framework-dependent activation) -- NOT the apphost, whose name is + // OS-specific (Microsoft.Crap4CSharp.exe on Windows, extension-less on Linux and possibly without + // the exec bit after xcopy). `dotnet ` is uniform on every platform CI runs on. + ProcessStartInfo startInfo = new() + { + FileName = "dotnet", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add(appDll); + foreach (string arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using Process process = new() { StartInfo = startInfo }; + using CancellationTokenSource cts = new(SpawnTimeoutMs); + + process.Start(); + + // Drain BOTH streams asynchronously to avoid a full-pipe deadlock; await async waits to stay + // clear of CA1849 (no blocking WaitForExit in an async method). + Task stdout = process.StandardOutput.ReadToEndAsync(cts.Token); + Task stderr = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException( + $"crap4csharp entry point did not exit within {SpawnTimeoutMs} ms."); + } + + return new SpawnResult(process.ExitCode, await stdout, await stderr); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + private sealed record SpawnResult(int ExitCode, string StandardOutput, string StandardError); +} From ff422bd26e096bdadc0ad20f77f0207ba94631f5 Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 09:22:34 -0700 Subject: [PATCH 26/27] T16: finalize README to shipped behavior; close S4 (S1-S4 complete) Reconcile README to the shipped model (Anders-authored, Bhaskar smoke PASS): - Coverage Pipeline preamble: "per module" loop -> single module root resolved once (nearest .sln -> .csproj -> project root, departure #6), coverage run exactly once (resolve-once, departure #7). - Pipeline step 3 + new Notes bullet: honest interim-limitation note that multi-test-project solutions single-pick one ordinal-first coverage report (departure #8); non-picked projects' methods resolve to N/A; aggregation deferred to S5/T17. Single-test-project layouts unaffected. - Formula / exit codes / CLI / examples / test-policy note verified accurate, left unchanged. Light truthfulness smoke (no heavy dotnet test / coverage run): --help -> 0 with the documented usage block; no files -> 0 "No C# files to analyze."; --changed + file -> 1 "--changed cannot be combined with file arguments". Build 0/0 Release. Exit-2 / fail-fast sub-paths stay pinned by CliApplicationTests #10-#13 + Program/CoverageRunner tests (S7 dogfoods live). Closes the core port: S1-S4 complete, T1-T16 landed, 114 tests green, CI green. Task table + status updated (T15 1f7581e filled). Remaining slices: S5 (T17) -> S6 (T18) -> S7 (T19). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 +++++++++-- docs/features/crap4csharp-port.md | 6 +++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d1d27f1..26588d4 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ files. ## Coverage Pipeline -For each invocation, per module (nearest `.sln`, else `.csproj`, else the project root): +Each invocation resolves a **single module root** (nearest `.sln`, else `.csproj`, else the project +root) and runs coverage **exactly once** there: 1. Delete stale coverage artifacts: - `coverage/` 2. Run `dotnet test --collect:"XPlat Code Coverage" --results-directory coverage` -3. Read the produced `coverage.cobertura.xml` +3. Read the produced `coverage.cobertura.xml` (one is picked when several exist — see Notes) 4. Analyze the selected C# files ## Build and Test @@ -93,4 +94,10 @@ dotnet run --project src/Crap4CSharp -c Release -- project-a project-b - **Fail fast:** if a module runs no tests or produces no coverage, `crap4csharp` exits non-zero rather than continuing — a deliberate, stricter departure from `crap4java`. A method simply absent from an otherwise-populated report is still reported as `N/A`. +- **Multi-test-project coverage (interim limitation):** `dotnet test --collect` emits one + `coverage.cobertura.xml` per test project, and crap4csharp deterministically picks a single + (ordinal-first) report. On a solution with multiple test projects, methods covered only by the + non-picked projects have no matching entry and resolve to `N/A` — scoring as if uncovered and + inflating their CRAP. Multi-report aggregation is deferred; single-test-project layouts (the common + case) are unaffected. - Report output is sorted by CRAP descending, with `N/A` at the bottom. diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index 596f7f4..b91a016 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** In progress — **S4 underway** (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 `6e8456c`, T15 next commit); **114 tests green, 0/0 Release**. Entry point landed: `Program.Main` → `CliApplication.Execute` with a typed `CoverageException` specific catch → exit 1 (ruling A, supersedes the T14-planned bare-catch); departure #1 exit 0/1/2, resolve-once #7; **only T16 (README finalize) remains before the S4 boundary**. **Pause at the S4 boundary** for Mr. Das — that closes the core port (S1–S4). +**Status:** **Core port COMPLETE (S1–S4 green)** — T1–T16 all landed (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 `6e8456c`, T15 `1f7581e`, T16 next commit); **114 tests green, 0/0 Release, CI green**. The full CRAP pipeline is shipped: parse → resolve-once module root (#7) → run coverage once → single-pick report (#8) → fail-fast gate (#1, exit 0/1/2) → analyze → format → threshold; `Program.Main` catches `CoverageException` → exit 1. README reconciled to shipped behavior. **PAUSED at the S4 boundary** for Mr. Das. Remaining: S5 (T17 module/test-resolution model + multi-report aggregation) → S6 (T18 independent clean-room eval) → S7 (T19 dogfood). See the Slices/Tasks tables. ## Requirements @@ -59,8 +59,8 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T12 | S4 | `CoverageRunner` (`dotnet test --collect`) + `CoverageReportLocator` + tests | Done | `9041c2c` | | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Done | `6e8456c` | -| T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Done | `(next commit)` | -| T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Pending | - | +| T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Done | `1f7581e` | +| T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Done | `(next commit)` | | T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution + multi-report coverage aggregation**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7), but multi-report coverage **aggregation** IS now in scope (per single-pick departure #8) — replace T12's ordinal-first single-pick `CoverageReportLocator` with a union across the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | | T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | | T19 | S7 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | From 4e41c628205096431464575c9162bcc993cb5b5a Mon Sep 17 00:00:00 2001 From: das Date: Mon, 27 Jul 2026 11:34:14 -0700 Subject: [PATCH 27/27] T20: broaden Program.Main catch-all for exit-code parity Close the S6 clean-room evaluator's exit-code-consistency finding: Program.Main caught only CoverageException, so git failures (--changed), I/O errors, and malformed-coverage-XML / parser throws escaped as platform-specific exit codes instead of the documented 1. Broaden to a top-level catch (Exception ex) -> full ex to stderr -> exit 1, restoring Java "main throws Exception" parity. - Program.cs: catch (CoverageException) -> catch (Exception ex); refresh summary. - .editorconfig: scoped [Program.cs] CA1031 severity = none (Mr. Das-approved entry-point carve-out; no #pragma / [SuppressMessage]; supersedes ruling A for Program.cs only). - CoverageException retained (runner still throws it; now caught by broad catch). - Exit-code matrix preserved: threshold-exceeded still returns 2 (normal return, never routed through the catch); every genuinely-thrown path -> 1. - ProgramTests: +MainProcessExitsOneWhenGitFailsForChanged (spawn --changed in a hermetic non-git temp cwd via GIT_CEILING_DIRECTORIES). - docs/decisions.md: T20 register (D-T20a-d); reconcile D-T12d/D-T14b/D-T14d/ D-T15b/D-T15e. Not a new departure (restores parity). - Feature doc: T20 row Done; fill T16 ff422bd; status refresh. 115 tests green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .editorconfig | 8 +++ docs/decisions.md | 81 ++++++++++++++++++++++--- docs/features/crap4csharp-port.md | 5 +- src/Crap4CSharp/Program.cs | 20 ++++-- tests/Crap4CSharp.Tests/ProgramTests.cs | 23 +++++++ 5 files changed, 122 insertions(+), 15 deletions(-) diff --git a/.editorconfig b/.editorconfig index ae19894..1b145d0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -152,3 +152,11 @@ dotnet_diagnostic.SA1135.severity = none # IDE0305: Simplify collection initialization dotnet_diagnostic.IDE0305.severity = warning + +# CA1031: Do not catch general exception types. Scoped relaxation for the CLI entry point ONLY. +# Program.Main is the top-level boundary that ports Java's `main throws Exception`: it deliberately +# catches every escaping exception and converts it to process exit 1 (.NET, unlike the JVM, does not +# guarantee exit 1 on an unhandled exception). This is the sole sanctioned catch-all; no other file is +# affected, and no #pragma / [SuppressMessage] is used. Ruled by Mr. Das (T20). +[Program.cs] +dotnet_diagnostic.CA1031.severity = none diff --git a/docs/decisions.md b/docs/decisions.md index 0fea9bc..75f42a4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -239,8 +239,9 @@ with no Java counterpart (JaCoCo's report path was a fixed constant, so Java nee departure #7; the coverage-command failure throws with the byte-identical message `"Coverage command failed with exit N"` (D-T12d) — originally the `IllegalStateException` → `InvalidOperationException` analog (C1, as in `CoberturaCoverageParser`), but **retyped at T15 - (ruling A) to the domain `CoverageException`** (message unchanged) so `Program.Main` can catch it - specifically (D-T15b). The T12/T14 fail-fast boundary is unchanged: T12 + (ruling A) to the domain `CoverageException`** (message unchanged) so `Program.Main` could catch it + specifically (D-T15b) — **broadened at T20** to a top-level `catch (Exception)`; `CoverageException` + is retained and now caught by the broad catch, same anchor (see the T20 register). The T12/T14 fail-fast boundary is unchanged: T12 **throws** on command failure (faithful) and **returns `null`** when the report is absent (the new locate signal); every exit-code decision (departure #1) stays in T14. @@ -269,8 +270,8 @@ beyond the already-approved set #1–#8.** coverage data`** → exit 1 — trigger 2 must inspect **`coverageMap.Count`, not the metrics** (a populated-but-non-matching report also yields all-`N/A` metrics yet must NOT fail-fast). (3) `CoverageRunner.GenerateCoverage` throws `CoverageException` → **propagates** (anchor **`Coverage - command failed with exit`**), realized as exit 1 at T15 via the specific `catch (CoverageException)` - in `Program.Main` (ruling A / D-T15b). Mr. Das ruled the trigger 1/2 wording to these defaults; tests + command failed with exit`**), realized as exit 1 via `Program.Main`'s top-level `catch (Exception)` + (broadened in T20; same `"Coverage command failed with exit"` anchor). Mr. Das ruled the trigger 1/2 wording to these defaults; tests assert only the bold anchor substring, so wording may re-tune without touching test structure. - **D-T14c — double-parse LOCKED (option A); the `Analyze(map)` overload is DEFERRED.** T14 calls @@ -291,7 +292,11 @@ beyond the already-approved set #1–#8.** the **identical** observable behavior (full `ex` to stderr + exit 1) while keeping the entry-point catch least-privilege / CA1031-clean with **no** suppression. The load-bearing invariant is unchanged: `Execute` keeps propagating and `Program.Main` MUST catch to guarantee exit 1. (T15 shipped; - `Program.cs` is no longer a stub.) + `Program.cs` is no longer a stub.) **T20 addendum** — `Program.Main` is re-broadened to a top-level + `catch (Exception)` catch-all under a scoped `[Program.cs]` CA1031 `.editorconfig` relaxation + (justification-commented; no `#pragma`/`[SuppressMessage]`); the load-bearing invariant (`Execute` + propagates; `Program.Main` catches → exit 1) is **preserved and generalized to all exceptions**, not + just `CoverageException` — full Java `main throws Exception` parity. - **Below the departure bar (no departure number, no ruling needed).** `MaxCrap`/`ThresholdExceeded`/ `Usage` are housed on `CliApplication` (T14), not on `Program` (T15) as Java put `maxCrap`/`usage` on @@ -326,7 +331,14 @@ adds no logic of its own. **No new behavioral departure beyond the already-appro on .NET (which does **not** guarantee it for an unhandled exception), so **both** ends are load-bearing: `Execute` must not start catching, and `Program.Main` must not stop catching. The typed catch is CA1031-clean with **no** `.editorconfig` entry / suppression, **superseding the T14-planned bare - `catch`** (D-T14d). Mr. Das's register wording, verbatim: "introduce `CoverageException` to enable a + `catch`** (D-T14d). **Superseded for `Program.cs` by T20** — `Program.Main` now uses a top-level + `catch (Exception) → Console.Error.WriteLine(ex) → return 1`, licensed by a scoped `[Program.cs]` + CA1031 `.editorconfig` relaxation (justification-commented; **no** `#pragma`/`[SuppressMessage]`). + `CoverageException` is **retained** — `CoverageRunner` still throws it with the byte-identical + `"Coverage command failed with exit N"` anchor, now caught by the broad catch (no behavioral loss, no + message change). The load-bearing invariant is unchanged and generalized: `Execute` keeps propagating; + `Program.Main` now converts **every** escaping exception (not just `CoverageException`) to exit 1 — + full parity with Java `main throws Exception`. Mr. Das's register wording, verbatim: "introduce `CoverageException` to enable a least-privilege specific catch (CA1031-clean, no suppression); observable behavior unchanged (stderr + exit 1), so not a behavioral departure — **supersedes D2**." (Here "D2" = the T14-planned bare-catch mechanism recorded in D-T14d, **not** the feature-doc deferral "D2 = O2 subprocess timeout".) @@ -360,7 +372,62 @@ adds no logic of its own. **No new behavioral departure beyond the already-appro surfaces a **real coverlet** report (well-formed by construction), so this path is **untested and practically unreachable**. It is a deliberate below-threshold consequence of the typed catch, **not** a new behavioral departure (the reachable observable behavior — stderr + exit 1 on a coverage-command - failure — is unchanged from the bare-catch plan). + failure — is unchanged from the bare-catch plan). **Resolved by T20** — the broad top-level + `catch (Exception)` in `Program.Main` **does** convert these paths (malformed-XML / parser / git / + I/O throws) to exit 1; the narrowing consequence no longer exists. + +## T20 register — exit-code parity (S6 close-out; Anders T20 review, 🟢) + +Resolved decisions and load-bearing invariants from the entry-point exit-code hardening. T20 broadens +`Program.Main`'s catch from the specific `catch (CoverageException)` (T15 / ruling A) to a top-level +`catch (Exception) → Console.Error.WriteLine(ex) → return 1`, licensed by a scoped `[Program.cs]` +CA1031 `.editorconfig` relaxation. **Not a new behavioral departure — it RESTORES Java parity** +(`main throws Exception`: the JVM already exits non-zero on any escaping exception; the pre-T20 escape +of a non-`CoverageException` throw as a platform-specific unhandled-exception code was the deviation). +Supersedes ruling A / D-T15b **for `Program.cs` only** (Mr. Das). **No new behavioral departure beyond +the already-approved set #1–#8.** + +- **D-T20a — CLOSES the S6 clean-room "fatal exit-code consistency" finding.** Pre-T20 `Program.Main` + caught **only** `CoverageException`, so a `git` failure (under `--changed`), an I/O error, or a + malformed-coverage-XML / parser throw **escaped** and surfaced as a platform-specific + unhandled-exception exit code instead of the documented `1`. The broad `catch (Exception)` converts + **every** escaping exception to exit 1 (full exception — type + message + stack — to stderr via + `Console.Error.WriteLine(ex)`, ruling B). `CoverageException` is **retained** — `CoverageRunner` still + throws it with the byte-identical `"Coverage command failed with exit N"` anchor, now caught by the + broad catch (no behavioral loss, no message change). + +- **D-T20b — scoped `[Program.cs]` CA1031 relaxation (LOAD-BEARING; ratified convention).** T20 appends + a trailing `[Program.cs]` section to `.editorconfig` — the **first single-filename (per-file) section + in the repo**; every prior section is a glob/extension pattern (`[*]`, `[*.md]`, `[*.{cs,vb}]`, …) and + the C# analyzer settings it overrides live in the global `[*.{cs,vb}]` block. EditorConfig has no + CSS-style specificity — later matching sections win by **order**, so the trailing per-file section + deterministically overrides the global block for the single `src/Crap4CSharp/Program.cs` (top-level + statements are disabled, so no generated/duplicate `Program.cs` exists). The section carries a + justification comment and sets `dotnet_diagnostic.CA1031.severity = none`. **Ratified convention:** + license a legitimate, localized analyzer violation via a narrowly-scoped, commented `.editorconfig` + section — **never** `#pragma` / `[SuppressMessage]`. This is the sole sanctioned catch-all; no other + file is affected. + +- **D-T20c — the exit-code matrix is reaffirmed (D-T14a still owns the 0/1/2 table).** `Program.Main`'s + `try` returns `CliApplication.Execute(args)` **verbatim**; the `catch (Exception)` intercepts **only + thrown** exceptions. Two invariants: **(I1)** no deliberate exit code is clobbered — every NORMAL + RETURN (0 / 1 fail-fast / **2 threshold-exceeded**) flows through the `try` and is returned unchanged; + **threshold-2 stays a normal return, never swallowed to 1**. **(I2)** every genuinely-thrown / + propagated failure → 1 (pre-T20 only the `CoverageException` throw converted; now all THROWN paths — + git / I/O / parser / coverage-command — convert). Pinned by the new spawn test + `MainProcessExitsOneWhenGitFailsForChanged` (`--changed` in a hermetic non-git temp cwd → + `ChangedFileDetector` throws `InvalidOperationException("git status failed: …")` → catch-all → exit 1; + git isolation via `GIT_CEILING_DIRECTORIES` set once in the shared `RunEntryPointAsync` helper). The + `Execute`-level exit-2 pin (`CliApplicationTests` #12 `ReturnsTwoWhenCrapThresholdExceeded`) is + unchanged. + +- **D-T20d — NOT a new departure (no departure #9).** T20 **restores** Java's `main throws Exception` + "any failure → non-zero" contract rather than departing from it, so there is **no** new departure + number and **no** edit to departure #1 or its exit table (D-T14a); departures #1–#8 are untouched. A + future reader should **not** expect a departure entry for T20 — the reconciliation lives in the amended + T14/T15 registers (D-T14b / D-T14d / D-T15b / D-T15e) and this T20 register. **D-T15a preserved** — + `Program` stays a thin, seam-less entry point (no `Run` method); the catch-all parity path is exercised + end-to-end through the real entry point by the spawn test, needing no injection seam. ## Deliberate departures from crap4java (approved by Mr. Das) diff --git a/docs/features/crap4csharp-port.md b/docs/features/crap4csharp-port.md index b91a016..e49f173 100644 --- a/docs/features/crap4csharp-port.md +++ b/docs/features/crap4csharp-port.md @@ -1,6 +1,6 @@ # Feature: crap4csharp — faithful C# port of crap4java **Branch:** vibe/crap4csharp-port -**Status:** **Core port COMPLETE (S1–S4 green)** — T1–T16 all landed (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 `6e8456c`, T15 `1f7581e`, T16 next commit); **114 tests green, 0/0 Release, CI green**. The full CRAP pipeline is shipped: parse → resolve-once module root (#7) → run coverage once → single-pick report (#8) → fail-fast gate (#1, exit 0/1/2) → analyze → format → threshold; `Program.Main` catches `CoverageException` → exit 1. README reconciled to shipped behavior. **PAUSED at the S4 boundary** for Mr. Das. Remaining: S5 (T17 module/test-resolution model + multi-report aggregation) → S6 (T18 independent clean-room eval) → S7 (T19 dogfood). See the Slices/Tasks tables. +**Status:** **Core port COMPLETE (S1–S4 green) + T20 exit-code-parity hardening** — T1–T16 landed (T6 `da02028`, T7 `1b90d24`, T8 `c023ef8`, T13 `e4d1329`, B1 ratify `d0784b3`, T9 `400d49d`, T10 `87488ce`, T11 `debf9de`, T12 `9041c2c`, T14 `6e8456c`, T15 `1f7581e`, T16 `ff422bd`) plus **T20** (`(next commit)`); **115 tests green, 0/0 Release, CI green**. The full CRAP pipeline is shipped: parse → resolve-once module root (#7) → run coverage once → single-pick report (#8) → fail-fast gate (#1, exit 0/1/2) → analyze → format → threshold; `Program.Main` now catches **every** escaping exception → exit 1 (T20: full Java `main throws Exception` parity via a scoped `[Program.cs]` CA1031 relaxation; `CoverageException` retained). S6/T18 independent clean-room eval **executed (report-only, gpt-5.6-sol)** — verdict relayed to Mr. Das; T20 closes its lone new exit-code-consistency finding. **HELD for Mr. Das:** S5 (T17 resolution model + multi-report aggregation) and S7 (T19 dogfood). See the Slices/Tasks tables. ## Requirements @@ -60,10 +60,11 @@ One or more tasks per slice. Full task detail and the fail-fast delta live in `d | T13 | S4 | `ModuleRootResolver` (nearest `.sln` → `.csproj` → root) + tests | Done | `e4d1329` | | T14 | S4 | `CliApplication` + tests; **fail-fast gate** (no-coverage/empty-report → exit 1) | Done | `6e8456c` | | T15 | S4 | `Program` entry (+ `CoverageException`) + integration tests (spawn built exe) | Done | `1f7581e` | -| T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Done | `(next commit)` | +| T16 | S4 | README usage section + end-to-end smoke (positive + negative fail-fast) | Done | `ff422bd` | | T17 | S5 | Module & test resolution finalization — **resolution model + test-project resolution + multi-report coverage aggregation**: Anders presents options — (a) keep the locked `.sln`-first model / (b) adopt mutate4csharp's nearest-`.csproj` owning project + `.Tests`/`.UnitTests` test-project resolution / (c) hybrid — per `../mutate4csharp` README §"Module & Test Resolution"; Mr. Das rules; then implement the chosen model — rework `ModuleRootResolver` (T13) and shape T12/T14. Module *grouping* is **no longer in scope** (removed by the resolve-once departure #7), but multi-report coverage **aggregation** IS now in scope (per single-pick departure #8) — replace T12's ordinal-first single-pick `CoverageReportLocator` with a union across the per-test-project `coverage.cobertura.xml` reports so multi-test-project solutions stop under-reporting. Keep running ALL module tests (crap4java parity); do NOT adopt unit-only `[Trait]` filtering. | Pending (deferred) | - | | T18 | S6 | Independent evaluation on gpt-5.6-sol. Neutral sub-agent (NOT Anders/Dave/Bhaskar). Inputs = verbatim `## Requirements` block + read the delivered port + crap4java ONLY; must NOT read `docs/decisions.md`, rest of `docs/features`, `.github` playbook/agents, or any rationale. Report-only verdict to Mr. Das. | Pending (deferred) | - | | T19 | S7 | Dogfood crap4csharp on crap4csharp + `../mutate4csharp` + `../dry4csharp`; capture CRAP reports; summarize crappy methods to Mr. Das. | Pending (deferred) | - | +| T20 | S4 | **Exit-code parity hardening** (closes the S6/T18 eval's exit-code-consistency finding): broaden `Program.Main` from `catch (CoverageException)` to a top-level `catch (Exception ex)` → full `ex` to stderr → exit 1 (Java `main throws Exception` parity — git/`--changed`, I/O, malformed-XML/parser throws now → 1, not a platform code); scoped `[Program.cs]` CA1031 `.editorconfig` relaxation (Mr. Das-approved; no `#pragma`/`[SuppressMessage]`; supersedes ruling A for `Program.cs` only); `CoverageException` retained; exit-matrix preserved (threshold still → **2**). +1 spawn test (`MainProcessExitsOneWhenGitFailsForChanged`). See the `docs/decisions.md` T20 register (D-T20a–d). | Done | `(next commit)` | Critical path: T1 → T2 → T9/T10 → T11 → T14 → T15 → T16. T3/T4/T5 and T6/T7/T8/T13 parallelize early. diff --git a/src/Crap4CSharp/Program.cs b/src/Crap4CSharp/Program.cs index a3ab80f..b307242 100644 --- a/src/Crap4CSharp/Program.cs +++ b/src/Crap4CSharp/Program.cs @@ -5,11 +5,19 @@ namespace Microsoft.Crap4CSharp; /// project root as the absolute, normalized current directory (Path.GetFullPath(".") == /// Java Path.of(".").toAbsolutePath().normalize()), wire the real console streams and a real /// , and delegate to (which -/// owns the full 0 / 1 / 2 exit table). The catch realizes the T14/T15 propagation boundary (D-T14d): -/// deliberately does not catch the coverage-command failure, and -/// because .NET — unlike the JVM — does not guarantee process exit code 1 on an unhandled exception, -/// catches the domain specifically and converts it to -/// exit 1. The returned is used as the process exit code (never Environment.Exit). +/// owns the full 0 / 1 / 2 exit table). Java's main is declared throws Exception and the +/// JVM maps any escaping exception to a non-zero exit; .NET does NOT guarantee exit 1 on an unhandled +/// exception, so installs a top-level catch (Exception) that writes the full +/// exception to stderr and returns 1 -- full parity with Java's "any failure becomes exit 1" contract. +/// Every propagated or unexpected failure (a git failure under --changed, an I/O error, +/// malformed coverage XML or any parser throw, or the coverage-command ) +/// therefore becomes exit 1. Deliberate exit codes are unaffected: usage-error / fail-fast (1) and +/// threshold-exceeded (2) are NORMAL RETURNS from that flow through +/// the try and are returned verbatim; the catch-all only intercepts THROWN exceptions, so +/// threshold-exceeded still returns 2 and is never swallowed to 1. The intentional entry-point catch-all +/// is licensed by a scoped CA1031 relaxation for Program.cs in .editorconfig (no +/// #pragma / no [SuppressMessage]). The returned is used as the process +/// exit code (never Environment.Exit). /// public static class Program { @@ -25,7 +33,7 @@ public static int Main(string[] args) new CoverageRunner(new ProcessCommandExecutor())) .Execute(args); } - catch (CoverageException ex) + catch (Exception ex) { Console.Error.WriteLine(ex); return 1; diff --git a/tests/Crap4CSharp.Tests/ProgramTests.cs b/tests/Crap4CSharp.Tests/ProgramTests.cs index a484b2c..c36269b 100644 --- a/tests/Crap4CSharp.Tests/ProgramTests.cs +++ b/tests/Crap4CSharp.Tests/ProgramTests.cs @@ -33,6 +33,22 @@ public async Task MainProcessExitsNonZeroForUnknownOption() result.StandardError.Should().Contain("--changed cannot be combined with file arguments"); } + // T20 -- the catch-all parity path: `--changed` in a fresh, non-git temp cwd makes ChangedFileDetector + // run `git status`, which fails (not a repository) and throws a NON-CoverageException + // (InvalidOperationException). That propagates out of CliApplication.Execute and MUST be converted by + // Program.Main's top-level catch-all to exit 1, with the full exception written to stderr. This closes + // the S6 clean-room "fatal exit-code consistency" finding (git / I/O / parser failures -> exit 1, not a + // platform-specific unhandled-exception code). GIT_CEILING_DIRECTORIES (set in RunEntryPointAsync) makes + // the git failure deterministic on every dev box and CI OS. No coverage run is ever reached (D-T15e). + [Fact] + public async Task MainProcessExitsOneWhenGitFailsForChanged() + { + SpawnResult result = await RunEntryPointAsync("--changed"); + + result.ExitCode.Should().Be(1); + result.StandardError.Should().Contain("git status failed"); + } + private static async Task RunEntryPointAsync(params string[] args) { // The app is a ProjectReference of the test project, so its DLL is copied next to the test assembly; @@ -57,6 +73,13 @@ private static async Task RunEntryPointAsync(params string[] args) RedirectStandardOutput = true, RedirectStandardError = true, }; + + // Hermetic git isolation (T20): the --changed spawn test runs `git status` in this fresh temp + // cwd; capping git's repo-discovery walk at the temp parent guarantees it is NEVER seen as a git + // repo (even on a dev box whose home dir is itself a repo), so git exits non-zero deterministically + // -> ChangedFileDetector throws -> Program.Main's catch-all -> exit 1. Harmless for the non-git + // spawn paths (--help / parse-error), which never invoke git. + startInfo.Environment["GIT_CEILING_DIRECTORIES"] = Directory.GetParent(workingDirectory)!.FullName; startInfo.ArgumentList.Add(appDll); foreach (string arg in args) {