diff --git a/docs/experimental-apis.md b/docs/experimental-apis.md index 3d6a57390..870656c88 100644 --- a/docs/experimental-apis.md +++ b/docs/experimental-apis.md @@ -50,6 +50,9 @@ Status values: **Experimental** (live, opt-in), **Promoted** (attribute removed, | ID | Surface | Introduced | Status | Notes | |----|---------|------------|--------|-------| | `FALLOUT001` | `Fallout.Components.IPublish.PublishTargets` / `.PublishTo` (multi-channel publishing) | 10.5.x | Experimental | Fan a single `Pack` output across multiple feeds with per-feed package-ID routing (`PublishTarget`). Shape may change while the CD model firms up (epic #332). Promote by deleting the attribute once `ReleaseChannel`/`DeploymentTarget` (#334) settle. | +| `FALLOUT005` | `Fallout.Components.IInspect`, `InspectReport`, `InspectRuleTable`, `InspectFinding`, `InspectBucket` (inspection triage) | 10.5.x | Experimental | `Inspect` target: one Roslyn SARIF error log per project plus ReSharper `inspectcode`, merged and sorted into triage buckets (`inspect.sarif`, `inspect.tsv`, `inspect.md`). The rule table and bucket names may change after use in more repositories. | + +`FALLOUT002` to `FALLOUT004` are taken by warnings that are not experimental APIs: the legacy environment variable warning, the legacy keychain warning and the `fallout-migrate` analyzer. Allocate the next experimental ID after the highest `FALLOUT0xx` used anywhere in the repository. + $(NoWarn);FALLOUT005 diff --git a/tests/Fallout.Components.Specs/InspectReportSpecs.cs b/tests/Fallout.Components.Specs/InspectReportSpecs.cs new file mode 100644 index 000000000..d597b7405 --- /dev/null +++ b/tests/Fallout.Components.Specs/InspectReportSpecs.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Components.Specs; + +public class InspectReportSpecs : IDisposable +{ + private readonly AbsolutePath root; + private readonly AbsolutePath reSharperSarif; + private readonly AbsolutePath roslynSarif; + + public InspectReportSpecs() + { + root = AbsolutePath.Temp("inspect-report"); + reSharperSarif = root / "resharper.sarif"; + roslynSarif = root / "roslyn.sarif"; + + // ReSharper writes paths relative to the solution directory through a uriBaseId. + reSharperSarif.WriteAllText($$""" + { + "version": "2.1.0", + "runs": [{ + "tool": { "driver": { "name": "InspectCode" } }, + "originalUriBaseIds": { "solutionDir": { "uri": "{{new Uri(root + "/").AbsoluteUri}}" } }, + "results": [ + {{Result("SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault", "note", "src/Store/Metrics.cs", 307, baseId: true)}}, + {{Result("PossibleMultipleEnumeration", "warning", "src/Analysis/Engine.cs", 171, column: 65, baseId: true)}}, + {{Result("PossibleMultipleEnumeration", "warning", "src/Analysis/Engine.cs", 171, column: 98, baseId: true)}}, + {{Result("RedundantNameQualifier", "warning", "tests/Store.Tests/MetricSpecs.cs", 12, baseId: true)}}, + {{Result("CA1822", "warning", "src/Build.cs", 96, baseId: true)}}, + {{Result("InconsistentNaming", "note", "src/Store/Metrics.Generated.cs", 3, baseId: true)}} + ] + }] + } + """); + + // Roslyn writes absolute file URIs and keeps suppressed diagnostics in the log. + roslynSarif.WriteAllText($$""" + { + "version": "2.1.0", + "runs": [{ + "tool": { "driver": { "name": "Microsoft (R) Visual C# Compiler" } }, + "results": [ + {{Result("CA2100", "note", FileUri("src/Store/Store.cs"), 85)}}, + {{Result("CA1822", "warning", FileUri("src/Build.cs"), 96)}}, + {{Result("CA1307", "note", FileUri("src/Store/Store.cs"), 40, suppressed: true)}}, + {{Result("IDE0005", "note", FileUri("artifacts/obj/Store/GlobalUsings.cs"), 1)}}, + {{Result("CA1002", "note", "file:///outside/the/root/Other.cs", 5)}}, + { "ruleId": "AD0001", "level": "warning", "message": { "text": "Analyzer threw" } } + ] + }] + } + """); + } + + public void Dispose() => root.DeleteDirectory(); + + [Fact] + public void Reading_drops_suppressed_generated_obj_outside_and_locationless_results() + { + // Act + var findings = InspectReport.Read(roslynSarif, root, "Roslyn"); + + // Assert + findings.Select(x => (x.RuleId, x.File, x.Line)).Should().Equal( + ("CA2100", "src/Store/Store.cs", 85), + ("CA1822", "src/Build.cs", 96)); + } + + [Fact] + public void Reading_resolves_paths_relative_to_the_uri_base() + { + // Act + var findings = InspectReport.Read(reSharperSarif, root, "ReSharper"); + + // Assert + findings.Should().HaveCount(5); + findings[0].File.Should().Be("src/Store/Metrics.cs"); + findings[1].Column.Should().Be(65); + findings.Should().OnlyContain(x => x.Tool == "ReSharper"); + } + + [Fact] + public void Merging_keeps_one_finding_per_rule_file_and_line_across_tools() + { + // Arrange + var findings = InspectReport.Read(roslynSarif, root, "Roslyn") + .Concat(InspectReport.Read(reSharperSarif, root, "ReSharper")); + + // Act + var merged = InspectReport.Merge(findings, InspectRuleTable.Default); + + // Assert + merged.Should().HaveCount(5); + merged.Should().ContainSingle(x => x.RuleId == "PossibleMultipleEnumeration"); + merged.Should().ContainSingle(x => x.RuleId == "CA1822").Which.Tool.Should().Be("Roslyn"); + } + + [Fact] + public void Merging_sorts_findings_into_buckets_and_flags_test_files() + { + // Arrange + var findings = InspectReport.Read(roslynSarif, root, "Roslyn") + .Concat(InspectReport.Read(reSharperSarif, root, "ReSharper")); + + // Act + var merged = InspectReport.Merge(findings, InspectRuleTable.Default); + + // Assert + merged.Select(x => (x.RuleId, x.Bucket, x.IsTest)).Should().Equal( + ("PossibleMultipleEnumeration", InspectBucket.RuntimeCandidate, false), + ("SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault", InspectBucket.RuntimeCandidate, false), + ("CA2100", InspectBucket.Review, false), + ("CA1822", InspectBucket.Mechanical, false), + ("RedundantNameQualifier", InspectBucket.Mechanical, true)); + } + + [Fact] + public void The_first_matching_rule_sets_the_bucket() + { + // Arrange + var rules = new List<(string Rule, InspectBucket Bucket)> + { + ("CA1307", InspectBucket.RuntimeCandidate), + ("CA*", InspectBucket.Mechanical), + }; + + // Act / Assert + InspectReport.Classify("CA1307", rules).Should().Be(InspectBucket.RuntimeCandidate); + InspectReport.Classify("ca1002", rules).Should().Be(InspectBucket.Mechanical); + InspectReport.Classify("SomeNewRule", rules).Should().Be(InspectBucket.Unclassified); + } + + [Fact] + public void Count_lines_name_every_bucket_with_its_test_share() + { + // Arrange + var merged = InspectReport.Merge(InspectReport.Read(reSharperSarif, root, "ReSharper"), InspectRuleTable.Default); + + // Act + var lines = InspectReport.GetCountLines(merged); + + // Assert + lines.Should().Equal( + "inspect: runtime-candidate 2 (tests 0)", + "inspect: review 0 (tests 0)", + "inspect: mechanical 2 (tests 1)", + "inspect: unclassified 0 (tests 0)"); + } + + [Fact] + public void Tsv_report_has_a_header_and_one_row_per_finding() + { + // Arrange + var merged = InspectReport.Merge(InspectReport.Read(roslynSarif, root, "Roslyn"), InspectRuleTable.Default); + + // Act + var rows = InspectReport.ToTsv(merged).TrimEnd('\n').Split('\n'); + + // Assert + rows.Should().Equal( + "bucket\ttest\trule\tlevel\ttool\tfile\tline\tcolumn\tmessage", + "review\t\tCA2100\tnote\tRoslyn\tsrc/Store/Store.cs\t85\t1\tCA2100 message", + "mechanical\t\tCA1822\twarning\tRoslyn\tsrc/Build.cs\t96\t1\tCA1822 message"); + } + + private string FileUri(string relativePath) => new Uri(root / relativePath).AbsoluteUri; + + private static string Result(string ruleId, string level, string uri, int line, int column = 1, bool baseId = false, bool suppressed = false) + => $$""" + { + "ruleId": "{{ruleId}}", + "level": "{{level}}", + "message": { "text": "{{ruleId}} message" }, + "locations": [{ "physicalLocation": { + "artifactLocation": { "uri": "{{uri}}"{{(baseId ? ", \"uriBaseId\": \"solutionDir\"" : "")}} }, + "region": { "startLine": {{line}}, "startColumn": {{column}} } + } }]{{(suppressed ? ", \"suppressions\": [{ \"kind\": \"inSource\" }]" : "")}} + } + """; +} diff --git a/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.cs b/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.cs index b37db0800..e1f9b5871 100644 --- a/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.cs +++ b/tests/Fallout.SourceGenerators.Specs/TransitionShimGeneratorSpecs.cs @@ -124,6 +124,67 @@ public static class HandBridgedRegular { } result.Diagnostics.Should().BeEmpty(); } + // An [Experimental] type is new surface without NUKE-era consumers. A shim deriving from it + // would fail the shim project's build with the experimental diagnostic, so it gets none. + [Fact] + public void SkipsExperimentalTypesSilently() + { + var canonical = CompileCanonicalAssembly(""" + namespace System.Diagnostics.CodeAnalysis + { + [System.AttributeUsage(System.AttributeTargets.All)] + public sealed class ExperimentalAttribute : System.Attribute + { + public ExperimentalAttribute(string diagnosticId) {} + } + } + + namespace Fallout.Common + { + [System.Diagnostics.CodeAnalysis.Experimental("FALLOUT999")] + public interface INewSurface { } + + [System.Diagnostics.CodeAnalysis.Experimental("FALLOUT999")] + public sealed class NewSealedSurface { } + + public class Stable + { + public Stable() {} + + [System.Diagnostics.CodeAnalysis.Experimental("FALLOUT999")] + public class NewNested { public NewNested() {} } + } + } + """); + + var shimCompilation = CSharpCompilation.Create("Nuke.Experimental", + new[] + { + CSharpSyntaxTree.ParseText(""" + [assembly: Fallout.Migrate.Shims.ShimAllPublicTypesUnder("Fallout.Common", "Nuke.Common")] + """) + }, + NetStandard20.References.All + .Concat(new[] + { + canonical + }), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver.Create(new TransitionShimGenerator()); + var result = driver.RunGenerators(shimCompilation).GetRunResult(); + + // Only the stable type gets a shim, and it leaves out the experimental nested type. + var shims = result.GeneratedTrees + .Where(t => !t.FilePath.EndsWith("ShimAllPublicTypesUnderAttribute.g.cs", StringComparison.Ordinal)) + .ToList(); + shims.Should().ContainSingle(); + shims[0].ToString().Should().Contain("class Stable").And.NotContain("NewNested"); + + // No SHIM001 for the sealed experimental class either. + result.Diagnostics.Should().BeEmpty(); + } + [Fact] public void EmitsNothingWhenNoMarkerAttributePresent() {