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);
+ }
+
+}