diff --git a/src/PureSharp.Analyzers.Tests/AnalyzerProbe.cs b/src/PureSharp.Analyzers.Tests/AnalyzerProbe.cs new file mode 100644 index 0000000..fa26cd2 --- /dev/null +++ b/src/PureSharp.Analyzers.Tests/AnalyzerProbe.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace PureSharp.Analyzers.Tests; + +/// +/// Analyzer を直接実行して報告された Diagnostic をそのまま取得するヘルパー。 +/// +/// Microsoft.CodeAnalysis.Testing の markup 形式では「同一位置に同じ Diagnostic が +/// 複数回報告される」ようなケースを表現しづらいため、件数と位置を直接検証したい +/// 境界テストではこちらを使用する。 +/// +internal static class AnalyzerProbe +{ + private static readonly ImmutableArray References = BuildReferences(); + + private static ImmutableArray BuildReferences() + { + var assemblies = new[] + { + typeof(object).Assembly, + typeof(Console).Assembly, + typeof(Enumerable).Assembly, + }; + + var refs = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var assembly in assemblies) + { + foreach (var name in assembly.GetReferencedAssemblies().Select(a => a.Name).Concat(new[] { assembly.GetName().Name })) + { + if (name is null || !seen.Add(name)) continue; + try + { + var loaded = System.Reflection.Assembly.Load(name); + if (!string.IsNullOrEmpty(loaded.Location)) + refs.Add(MetadataReference.CreateFromFile(loaded.Location)); + } + catch + { + // 参照できないアセンブリは無視する(判定に必要な型は corelib に含まれる)。 + } + } + } + + return refs.ToImmutableArray(); + } + + /// 指定ソースに対して analyzer を実行し、報告された Diagnostic を返します。 + public static async Task> RunAsync(DiagnosticAnalyzer analyzer, string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create( + "Probe", + new[] { tree }, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return await withAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/src/PureSharp.Analyzers.Tests/DiagnosticContractTests.cs b/src/PureSharp.Analyzers.Tests/DiagnosticContractTests.cs new file mode 100644 index 0000000..a01d8c8 --- /dev/null +++ b/src/PureSharp.Analyzers.Tests/DiagnosticContractTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace PureSharp.Analyzers.Tests; + +/// +/// 公開 Diagnostic 契約の回帰テスト。 +/// +/// docs/DIAGNOSTICS.md が定義する SSOT を機械的に固定する。ID / category / +/// default severity / 既定有効であることは v1.0 の公開契約であり、これらを変更する +/// PR は必ずこのテストを更新しなければならない。 +/// +/// message 文言そのものは契約対象外のため値を固定しない。代わりに「各 descriptor が +/// 自分自身の ID に対応する resource key を参照していること」を検証する。 +/// +public class DiagnosticContractTests +{ + /// v1.0 で公開契約とする全 Diagnostic。docs/DIAGNOSTICS.md の要約表と一致する。 + public static readonly IReadOnlyList<(string Id, string Category, DiagnosticSeverity Severity)> PublicContract = + new[] + { + ("RT0001", "Purity", DiagnosticSeverity.Error), + ("RT0002", "Purity", DiagnosticSeverity.Error), + ("RT0003", "Purity", DiagnosticSeverity.Error), + ("LVP0001", "Purity", DiagnosticSeverity.Error), + ("LVP0002", "Purity", DiagnosticSeverity.Error), + ("LVP0003", "Naming", DiagnosticSeverity.Warning), + ("FIF0001", "FluentIf", DiagnosticSeverity.Error), + }; + + public static TheoryData ContractRows() + { + var data = new TheoryData(); + foreach (var (id, category, severity) in PublicContract) + data.Add(id, category, severity); + return data; + } + + private static IReadOnlyList AllAnalyzers() => new DiagnosticAnalyzer[] + { + new ReferentialTransparencyAnalyzer(), + new LocalVariablePurityAnalyzer(), + new ImmutableNamingSuggestionAnalyzer(), + new FluentIfAnalyzer(), + }; + + private static IReadOnlyList AllDescriptors() => + AllAnalyzers().SelectMany(a => a.SupportedDiagnostics).ToList(); + + private static DiagnosticDescriptor DescriptorFor(string id) => + AllDescriptors().Single(d => d.Id == id); + + // ========================================================= + // 契約: ID / category / default severity / 既定有効 + // ========================================================= + + [Theory] + [MemberData(nameof(ContractRows))] + public void Descriptor_MatchesPublicContract(string id, string category, DiagnosticSeverity severity) + { + var descriptor = DescriptorFor(id); + + Assert.Equal(id, descriptor.Id); + Assert.Equal(category, descriptor.Category); + Assert.Equal(severity, descriptor.DefaultSeverity); + Assert.True(descriptor.IsEnabledByDefault, $"{id} must be enabled by default."); + } + + [Fact] + public void SupportedDiagnostics_ContainsExactlyThePublicContract() + { + var actual = AllDescriptors().Select(d => d.Id).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + var expected = PublicContract.Select(c => c.Id).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + + // 追加・削除の両方を検出する。新しい Diagnostic は docs/DIAGNOSTICS.md と + // このリストを同時に更新しなければ通らない。 + Assert.Equal(expected, actual); + } + + [Fact] + public void Descriptor_IdsAreUnique() + { + var ids = AllDescriptors().Select(d => d.Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + } + + // ========================================================= + // 契約: ID 命名規則 + // ========================================================= + + [Theory] + [MemberData(nameof(ContractRows))] + public void DiagnosticId_FollowsNamingConvention(string id, string category, DiagnosticSeverity severity) + { + _ = category; + _ = severity; + + // PREFIX + 4 桁連番 (docs/DIAGNOSTICS.md) + Assert.Matches("^(RT|LVP|FIF)[0-9]{4}$", id); + } + + // ========================================================= + // 契約: descriptor が自分自身の resource key を参照していること + // + // ID / category / severity が正しくても description だけ別 Diagnostic の + // resource を指しているという不整合を検出する。 + // ========================================================= + + [Theory] + [MemberData(nameof(ContractRows))] + public void Descriptor_TextResolvesAndIsNonEmpty(string id, string category, DiagnosticSeverity severity) + { + _ = category; + _ = severity; + + var descriptor = DescriptorFor(id); + + Assert.False(string.IsNullOrWhiteSpace(descriptor.Title.ToString(CultureInfo.InvariantCulture)), + $"{id} title must resolve to a non-empty string."); + Assert.False(string.IsNullOrWhiteSpace(descriptor.MessageFormat.ToString(CultureInfo.InvariantCulture)), + $"{id} message format must resolve to a non-empty string."); + Assert.False(string.IsNullOrWhiteSpace(descriptor.Description.ToString(CultureInfo.InvariantCulture)), + $"{id} description must resolve to a non-empty string."); + } + + [Fact] + public void Descriptor_DescriptionsAreNotSharedBetweenDiagnostics() + { + // LVP0003 の description が FIF0001_Description を参照していた不具合の回帰テスト。 + // 説明文の使い回しは、利用者に他の Diagnostic の説明を表示してしまう。 + var descriptions = AllDescriptors() + .Select(d => (d.Id, Text: d.Description.ToString(CultureInfo.InvariantCulture))) + .Where(x => !string.IsNullOrWhiteSpace(x.Text)) + .ToList(); + + var duplicated = descriptions + .GroupBy(x => x.Text, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => string.Join(", ", g.Select(x => x.Id))) + .ToList(); + + Assert.True(duplicated.Count == 0, + "These diagnostics share an identical description, which means at least one " + + "references another diagnostic's resource key: " + string.Join(" | ", duplicated)); + } + + [Fact] + public void Descriptor_TitlesAreNotSharedBetweenDiagnostics() + { + var titles = AllDescriptors() + .Select(d => (d.Id, Text: d.Title.ToString(CultureInfo.InvariantCulture))) + .ToList(); + + var duplicated = titles + .GroupBy(x => x.Text, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => string.Join(", ", g.Select(x => x.Id))) + .ToList(); + + Assert.True(duplicated.Count == 0, + "These diagnostics share an identical title: " + string.Join(" | ", duplicated)); + } + + // ========================================================= + // 契約: category 方針 + // ========================================================= + + [Theory] + [MemberData(nameof(ContractRows))] + public void Category_SeverityPolicyIsRespected(string id, string category, DiagnosticSeverity severity) + { + _ = id; + + // docs/DIAGNOSTICS.md: Naming は既定でビルドを止めない。 + if (category == "Naming") + Assert.True(severity < DiagnosticSeverity.Error, + "Naming diagnostics must not default to Error."); + else + Assert.Equal(DiagnosticSeverity.Error, severity); + } + + [Fact] + public void Category_UsesOnlyDocumentedCategories() + { + var documented = new[] { "Purity", "Naming", "FluentIf" }; + foreach (var descriptor in AllDescriptors()) + Assert.Contains(descriptor.Category, documented); + } +} diff --git a/src/PureSharp.Analyzers.Tests/RegressionBoundaryTests.cs b/src/PureSharp.Analyzers.Tests/RegressionBoundaryTests.cs new file mode 100644 index 0000000..00a427e --- /dev/null +++ b/src/PureSharp.Analyzers.Tests/RegressionBoundaryTests.cs @@ -0,0 +1,262 @@ +using System.Threading.Tasks; +using Xunit; +using VerifyRT = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier< + PureSharp.Analyzers.ReferentialTransparencyAnalyzer>; +using VerifyLVP = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier< + PureSharp.Analyzers.LocalVariablePurityAnalyzer>; + +namespace PureSharp.Analyzers.Tests; + +/// +/// 境界ケースの回帰テスト。 +/// +/// 既存の Analyzer テストが扱っていなかった境界を固定し、v1.0 までに現在の挙動が +/// 意図せず変化しないようにする。ここでの assertion は「現在の挙動」であり、 +/// 変更する場合は Issue で意図を明示したうえでこのテストを更新すること。 +/// +public class RegressionBoundaryTests +{ + private const string PureAttributeSource = @" +namespace PureSharp.Core +{ + [System.AttributeUsage(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)] + public sealed class PureMethodAttribute : System.Attribute { } +} +"; + + // ========================================================= + // RT0001 境界: static でないフィールドは対象外 + // ========================================================= + + [Fact] + public async Task RT0001_InstanceField_NoDiagnostic() + { + var testCode = @" +using PureSharp.Core; + +public class Holder +{ + private int _instanceValue; + + [PureMethod] + public int Read() => _instanceValue; +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task RT0001_StaticMutableField_InLocalFunction_ReportsBothDiagnostics() + { + // ローカル関数の本体は外側の [PureMethod] メソッドとして解析されるため + // RT0001 が報告される。さらにローカル関数自体は [PureMethod] を持たないため、 + // その呼び出しは RT0002 (非純粋メソッド呼び出し) になる。 + // + // 「pure method 内のローカル関数」をどう扱うかは Issue #10 の検討対象。 + // ここでは現在の挙動を固定する。 + var testCode = @" +using PureSharp.Core; + +public class Holder +{ + private static int Counter; + + [PureMethod] + public int Read() + { + int Inner() => {|RT0001:Counter|}; + return {|RT0002:Inner()|}; + } +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task RT0001_StaticMutableField_ReportedExactlyOnce() + { + // 同一のフィールドアクセスが重複報告されないことを件数で固定する。 + // markup 形式では重複報告を表現しづらいため AnalyzerProbe を使用する。 + var source = @" +using PureSharp.Core; + +public class Holder +{ + private static int Counter; + + [PureMethod] + public int Read() => Counter; +} +" + PureAttributeSource; + + var diagnostics = await AnalyzerProbe.RunAsync(new ReferentialTransparencyAnalyzer(), source); + + Assert.Single(diagnostics); + Assert.Equal("RT0001", diagnostics[0].Id); + } + + // ========================================================= + // RT0002 境界: 既知の純粋型は許可される + // ========================================================= + + [Fact] + public async Task RT0002_StringMethod_NoDiagnostic() + { + var testCode = @" +using PureSharp.Core; + +public class Formatter +{ + [PureMethod] + public string Upper(string s) => s.ToUpperInvariant(); +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task RT0002_ConvertMethod_NoDiagnostic() + { + var testCode = @" +using PureSharp.Core; +using System; + +public class Parser +{ + [PureMethod] + public int ToInt(string s) => Convert.ToInt32(s); +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task RT0002_InstanceMethodOnUserType_ReportsDiagnostic() + { + var testCode = @" +using PureSharp.Core; + +public class Service +{ + public int Compute() => 1; +} + +public class Caller +{ + [PureMethod] + public int Run(Service s) => {|RT0002:s.Compute()|}; +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task RT0002_NonPureMethod_NotAnalyzed_NoDiagnostic() + { + // [PureMethod] が付いていないメソッドは解析対象外。 + var testCode = @" +using PureSharp.Core; + +public class Service +{ + public int Compute() => 1; +} + +public class Caller +{ + public int Run(Service s) => s.Compute(); +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + // ========================================================= + // RT0003 境界: I/O 型のプロパティアクセス経路 + // ========================================================= + + [Fact] + public async Task RT0003_IoPropertyAccess_ReportsDiagnostic() + { + // 呼び出しではなくプロパティ参照の経路 (IsIoPropertyAccess) を固定する。 + var testCode = @" +using PureSharp.Core; +using System; + +public class Reader +{ + [PureMethod] + public object Get() => {|RT0003:Console.Out|}; +} +" + PureAttributeSource; + await VerifyRT.VerifyAnalyzerAsync(testCode); + } + + // ========================================================= + // LVP0001 境界 + // ========================================================= + + [Fact] + public async Task LVP0001_UnderscoreVariable_DeclaredWithVar_ReassignmentReportsError() + { + var testCode = @" +public class Test +{ + public void Method() + { + var _x = 10; + {|LVP0001:_x = 20|}; + } +}"; + await VerifyLVP.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task LVP0001_UnderscoreVariable_InNestedBlock_ReportsError() + { + var testCode = @" +public class Test +{ + public void Method(bool flag) + { + int _x = 10; + if (flag) + { + {|LVP0001:_x = 20|}; + } + } +}"; + await VerifyLVP.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task LVP0001_DoubleUnderscoreVariable_ReportsError() + { + // 単一の "_" のみが discard。"__" は通常の不変ローカルとして扱う。 + var testCode = @" +public class Test +{ + public void Method() + { + int __x = 10; + {|LVP0001:__x = 20|}; + } +}"; + await VerifyLVP.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task LVP0001_UnderscoreVariable_ReadOnly_NoDiagnostic() + { + var testCode = @" +public class Test +{ + public int Method() + { + int _x = 10; + return _x + _x; + } +}"; + await VerifyLVP.VerifyAnalyzerAsync(testCode); + } + +}