From cd672574f3a86f245e6bd55347e5f45753c63ba3 Mon Sep 17 00:00:00 2001 From: mao2009 <39354512+mao2009@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:52:54 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=E6=97=A2=E5=AD=98rule=E3=81=AE?= =?UTF-8?q?=E5=9B=9E=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88=E3=82=AB=E3=83=90?= =?UTF-8?q?=E3=83=AC=E3=83=83=E3=82=B8=E3=82=92=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #8 に対応し、既存 RT/LVP/FIF Analyzer の現在の挙動を v1.0 まで 壊さないための回帰テストを体系化する。テスト数 56 -> 101。 追加: DiagnosticContractTests.cs (33 tests) docs/DIAGNOSTICS.md の SSOT を機械的に固定する。 - 全 7 Diagnostic の ID / category / default severity / 既定有効を検証 - SupportedDiagnostics が公開契約と厳密に一致することを検証 (追加・削除の双方を検出する。新規 Diagnostic は SSOT とこのリストを 同時に更新しなければ通らない) - ID 命名規則 (PREFIX + 4桁) を検証 - category 方針 (Naming は既定 Error にしない) を検証 - 文書化済み category 以外を使っていないことを検証 - 各 descriptor の title / message / description が解決可能かつ非空で あることを検証 - title / description が Diagnostic 間で共有されていないことを検証 message 文言そのものは公開契約対象外のため値は固定していない。代わりに 「descriptor が自分自身の ID の resource key を参照していること」を 検証している。 追加: RegressionBoundaryTests.cs (11 tests) 既存テストが扱っていなかった境界を固定する。 - RT0001: instance field は対象外 - RT0001: 同一フィールドアクセスが重複報告されないことを件数で固定 - RT0002: string / Convert など既知純粋型の呼び出しは許可 - RT0002: user type の instance method 呼び出しは検出 - RT0002: [PureMethod] のないメソッドは解析対象外 - RT0003: 呼び出しではなく property 参照経路 (Console.Out) を固定 - LVP0001: var 宣言 / nested block / 二重アンダースコア - LVP0001: 読み取りのみは検出しない 追加: AnalyzerProbe.cs Analyzer を直接実行して報告 Diagnostic をそのまま取得するヘルパー。 Microsoft.CodeAnalysis.Testing の markup 形式では重複報告の有無を 表現しづらいため、件数と位置を直接検証したい境界テストで使用する。 Issue #8 スコープの「Analyzer Test 共通パターンの整理」に該当する。 実装調査で判明した現在の挙動 (テストで固定済み): [PureMethod] メソッド内のローカル関数について、本体は外側メソッドとして 解析されるため static mutable field アクセスに RT0001 が報告される。 一方でローカル関数自体は [PureMethod] を持たないため、その呼び出しは RT0002 (非純粋メソッド呼び出し) になる。この扱いが妥当かどうかは Issue #10 (pure method contract / interprocedural analysis) の検討対象で あり、本 Issue では挙動の変更を行わず現状を固定するに留めた。 Issue #7 への依存 (実測で確認): DiagnosticContractTests の Descriptor_DescriptionsAreNotSharedBetweenDiagnostics は、Issue #7 の LVP0003 descriptor 修正がない状態では "LVP0003, FIF0001" として失敗することを実測で確認した。 したがって本 PR は Issue #7 の後に merge する必要がある。 検証結果: - dotnet build: PASS (0 error) - dotnet test --no-build: PASS (101/101、baseline 56 から +45) - git diff --check: PASS Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XB2mDSE67ZDQS9mk5KfAdZ --- .../AnalyzerProbe.cs | 69 +++++ .../DiagnosticContractTests.cs | 194 +++++++++++++ .../RegressionBoundaryTests.cs | 262 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 src/PureSharp.Analyzers.Tests/AnalyzerProbe.cs create mode 100644 src/PureSharp.Analyzers.Tests/DiagnosticContractTests.cs create mode 100644 src/PureSharp.Analyzers.Tests/RegressionBoundaryTests.cs 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); + } + +}