From f4affeb2d7b9d626b7caef48bd321ef35fbb2b32 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:29:37 +0000 Subject: [PATCH 01/11] test(arch): ratchet test types that parse C# without binding symbols (#1184) --- .../SpellingScannerRatchetTests.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs new file mode 100644 index 000000000..db00c3e69 --- /dev/null +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -0,0 +1,144 @@ +using Cluckwork.Application.Tests.Documentation; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Cluckwork.Application.Tests.Architecture; + +// A test type that parses C# but never builds a CSharpCompilation resolves names by spelling, and reviewers then +// find one bypass per round: aliases, generic arity, nested types, `global using`, `#if !DEBUG` (#1010, #1013, +// #1033, #1056; 30 PRs in all, #1184). New C# invariants go in Cluckwork.Analyzers, where the compiler binds every +// name. The types already written this way are listed by fully qualified name and may change freely; the list only +// shrinks. Detection binds symbols itself, so an alias or `using static` of CSharpSyntaxTree is still a parse call. +public sealed class SpellingScannerRatchetTests +{ + // Fails an enumeration that silently finds nothing; 387 tracked test files on 2026-10-10. + private const int ScannedFileFloor = 300; + + // Type → what it guards and why it may stay until someone next changes it. + private static readonly Dictionary Legacy = new(StringComparer.Ordinal) + { + ["Cluckwork.Api.IntegrationTests.TrackedMutationReadTests"] = + "#546/#1057 tracked-read guard; matches repository members and DbSet entity names by spelling. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.Architecture.AdapterReachScanner"] = + "#846 adapter reach ratchet; resolves parameter types by name. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.Architecture.AdapterTierScanner"] = + "#843 adapter tiers; matches [McpServerToolType] and MapMcp by name. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.Architecture.ExportSnapshotSourceTests"] = + "#1025 export snapshot pin; matches the activeDb identifier by name. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.Architecture.NamespaceFolderAgreementTests"] = + "Namespace declarations against folders; the declaration's text is the subject, so syntax is enough.", + ["Cluckwork.Application.Tests.Architecture.SourcePreprocessorTests"] = + "#1056 tests ModuleLedgerScanner.ParseOptions itself; which #if branches parse is the subject.", + ["Cluckwork.Application.Tests.Eggs.EggLotLockSqlTests"] = + "#1028 pins lock SQL string literals; literal text is the subject. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.Sales.TransactionDelegateShapeTests"] = + "#751 add-item transaction shape; matches call names inside the delegate. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.TenantBypass.FarmDirectoryCallerTests"] = + "#1043 IFarmDirectory caller allow-list; matches callers by name. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.TenantBypass.FindBySlugCallerTests"] = + "#1061 FindBySlugAsync caller allow-list; matches callers by name. Legacy, predates #1184.", + ["Cluckwork.Application.Tests.TenantBypass.GuardScanner"] = + "#584 tenant-bypass scanner; matches banned methods by name, keyed by #632 token hash. Legacy, predates #1184.", + }; + + [Fact] + public void NoNewTestType_ParsesCSharpWithoutBindingSymbols() + { + var root = TenancyDocsFreshnessTests.RepoRoot(); + var files = TenancyDocsFreshnessTests.TrackedFiles(root) + .Where(p => p.StartsWith("tests/", StringComparison.Ordinal) && p.EndsWith(".cs", StringComparison.Ordinal)) + .Select(p => (Project: p.Split('/')[1], Path: p, Source: File.ReadAllText(Path.Combine(root, p)))) + .ToList(); + Assert.True(files.Count >= ScannedFileFloor, + $"Scanned {files.Count} test files, below the floor of {ScannedFileFloor}; the enumeration is excluding too much."); + + var found = SpellingOnlyParsers(files); + + var failures = new List(); + foreach (var (type, path) in found.Where(f => !Legacy.ContainsKey(f.Key)).OrderBy(f => f.Key, StringComparer.Ordinal)) + { + failures.Add($"{type} ({path}) parses C# without a CSharpCompilation, so it matches names by spelling. " + + "Write the invariant as a Cluckwork.Analyzers diagnostic (see src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs, " + + "CW1004), or bind symbols through CSharpCompilation and its SemanticModel."); + } + foreach (var type in Legacy.Keys.Where(k => !found.ContainsKey(k)).Order(StringComparer.Ordinal)) + failures.Add($"{type}: listed, but no longer parses C# by spelling (moved, deleted or now binds symbols). Delete its row."); + + Assert.True(failures.Count == 0, "Spelling-based C# scanners:\n " + string.Join("\n ", failures)); + } + + [Fact] + public void Detection_BindsAliasesAndStaticImports_AndExemptsCompilations() + { + const string header = "using Microsoft.CodeAnalysis.CSharp;\n"; + var found = SpellingOnlyParsers( + [ + ("P", "Direct.cs", header + "class Direct { object M() => CSharpSyntaxTree.ParseText(\"\"); }"), + ("P", "Alias.cs", "using T = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree;\nclass Alias { object M() => T.ParseText(\"\"); }"), + ("P", "Static.cs", "using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;\nclass Static { object M() => ParseCompilationUnit(\"\"); }"), + ("P", "Nested.cs", header + "namespace N { class Outer { class Inner { System.Func F = s => CSharpSyntaxTree.ParseText(s); } } }"), + ("P", "Bound.cs", header + "class Bound { object M() => CSharpCompilation.Create(\"x\", [CSharpSyntaxTree.ParseText(\"\")]); }"), + ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), + ]); + + Assert.Equal(["Alias", "Direct", "N.Outer", "Static"], found.Keys.Order(StringComparer.Ordinal)); + } + + // Keyed by the outermost type, merged across partial declarations. A parse call counts when its bound symbol is a + // Parse* or Create method on CSharpSyntaxTree or SyntaxFactory; a method name cannot be aliased, so the name + // prefilter loses nothing short of reflection. The type is exempt when any of its code binds CSharpCompilation. + internal static Dictionary SpellingOnlyParsers(IEnumerable<(string Project, string Path, string Source)> files) + { + var found = new Dictionary(StringComparer.Ordinal); + foreach (var project in files.GroupBy(f => f.Project)) + { + var trees = project.Select(f => CSharpSyntaxTree.ParseText(f.Source, ModuleLedgerScanner.ParseOptions, f.Path)).ToList(); + var compilation = CompatibilityExceptionScanner.Compile(project.Key, trees, + CompatibilityExceptionScanner.ImplicitUsings, CompatibilityExceptionScanner.References(project.Key)); + + var parsers = new Dictionary(SymbolEqualityComparer.Default); + var bound = new HashSet(SymbolEqualityComparer.Default); + foreach (var tree in trees) + { + var model = compilation.GetSemanticModel(tree); + foreach (var name in tree.GetRoot().DescendantNodes().OfType()) + { + var text = name.Identifier.ValueText; + if (!text.StartsWith("Parse", StringComparison.Ordinal) && text is not ("Create" or "CSharpCompilation")) + continue; + var info = model.GetSymbolInfo(name); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + if (OutermostType(model, name) is not { } owner) + continue; + if (IsCompilation(symbol)) + bound.Add(owner); + else if (symbol is IMethodSymbol method && IsParse(method)) + parsers.TryAdd(owner, tree.FilePath); + } + } + + foreach (var (owner, path) in parsers.Where(p => !bound.Contains(p.Key))) + found[owner.ToDisplayString()] = path; + } + + return found; + } + + private static bool IsParse(IMethodSymbol method) => + method.ContainingType.ToDisplayString() is "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree" or "Microsoft.CodeAnalysis.CSharp.SyntaxFactory" + && (method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create"); + + private static bool IsCompilation(ISymbol? symbol) => + (symbol as INamedTypeSymbol ?? symbol?.ContainingType)?.ToDisplayString() == "Microsoft.CodeAnalysis.CSharp.CSharpCompilation"; + + private static INamedTypeSymbol? OutermostType(SemanticModel model, SyntaxNode node) + { + var type = model.GetEnclosingSymbol(node.SpanStart) is { } enclosing + ? enclosing as INamedTypeSymbol ?? enclosing.ContainingType + : null; + while (type?.ContainingType is { } outer) + type = outer; + return type; + } +} From 9e48180c8928a57e69a4a66de974d05fab5a078c Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:30:32 +0000 Subject: [PATCH 02/11] test(arch): trim repeated legacy-row wording --- .../SpellingScannerRatchetTests.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index db00c3e69..efc5bd385 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -15,31 +15,31 @@ public sealed class SpellingScannerRatchetTests // Fails an enumeration that silently finds nothing; 387 tracked test files on 2026-10-10. private const int ScannedFileFloor = 300; - // Type → what it guards and why it may stay until someone next changes it. + // Type → what it guards. private static readonly Dictionary Legacy = new(StringComparer.Ordinal) { ["Cluckwork.Api.IntegrationTests.TrackedMutationReadTests"] = - "#546/#1057 tracked-read guard; matches repository members and DbSet entity names by spelling. Legacy, predates #1184.", + "#546/#1057 tracked-read guard; matches repository members and DbSet entity names by spelling.", ["Cluckwork.Application.Tests.Architecture.AdapterReachScanner"] = - "#846 adapter reach ratchet; resolves parameter types by name. Legacy, predates #1184.", + "#846 adapter reach ratchet; resolves parameter types by name.", ["Cluckwork.Application.Tests.Architecture.AdapterTierScanner"] = - "#843 adapter tiers; matches [McpServerToolType] and MapMcp by name. Legacy, predates #1184.", + "#843 adapter tiers; matches [McpServerToolType] and MapMcp by name.", ["Cluckwork.Application.Tests.Architecture.ExportSnapshotSourceTests"] = - "#1025 export snapshot pin; matches the activeDb identifier by name. Legacy, predates #1184.", + "#1025 export snapshot pin; matches the activeDb identifier by name.", ["Cluckwork.Application.Tests.Architecture.NamespaceFolderAgreementTests"] = "Namespace declarations against folders; the declaration's text is the subject, so syntax is enough.", ["Cluckwork.Application.Tests.Architecture.SourcePreprocessorTests"] = "#1056 tests ModuleLedgerScanner.ParseOptions itself; which #if branches parse is the subject.", ["Cluckwork.Application.Tests.Eggs.EggLotLockSqlTests"] = - "#1028 pins lock SQL string literals; literal text is the subject. Legacy, predates #1184.", + "#1028 pins lock SQL string literals; literal text is the subject.", ["Cluckwork.Application.Tests.Sales.TransactionDelegateShapeTests"] = - "#751 add-item transaction shape; matches call names inside the delegate. Legacy, predates #1184.", + "#751 add-item transaction shape; matches call names inside the delegate.", ["Cluckwork.Application.Tests.TenantBypass.FarmDirectoryCallerTests"] = - "#1043 IFarmDirectory caller allow-list; matches callers by name. Legacy, predates #1184.", + "#1043 IFarmDirectory caller allow-list; matches callers by name.", ["Cluckwork.Application.Tests.TenantBypass.FindBySlugCallerTests"] = - "#1061 FindBySlugAsync caller allow-list; matches callers by name. Legacy, predates #1184.", + "#1061 FindBySlugAsync caller allow-list; matches callers by name.", ["Cluckwork.Application.Tests.TenantBypass.GuardScanner"] = - "#584 tenant-bypass scanner; matches banned methods by name, keyed by #632 token hash. Legacy, predates #1184.", + "#584 tenant-bypass scanner; matches banned methods by name, keyed by #632 token hash.", }; [Fact] From edc34421621eb5bcd3eb9baf32f6c2e6d588130b Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:32:04 +0000 Subject: [PATCH 03/11] test(arch): count a SemanticModel request as binding symbols --- .../Architecture/SpellingScannerRatchetTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index efc5bd385..20b66bf59 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -69,7 +69,7 @@ public void NoNewTestType_ParsesCSharpWithoutBindingSymbols() } [Fact] - public void Detection_BindsAliasesAndStaticImports_AndExemptsCompilations() + public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() { const string header = "using Microsoft.CodeAnalysis.CSharp;\n"; var found = SpellingOnlyParsers( @@ -79,6 +79,7 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsCompilations() ("P", "Static.cs", "using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;\nclass Static { object M() => ParseCompilationUnit(\"\"); }"), ("P", "Nested.cs", header + "namespace N { class Outer { class Inner { System.Func F = s => CSharpSyntaxTree.ParseText(s); } } }"), ("P", "Bound.cs", header + "class Bound { object M() => CSharpCompilation.Create(\"x\", [CSharpSyntaxTree.ParseText(\"\")]); }"), + ("P", "Helper.cs", header + "class Helper { object M(Microsoft.CodeAnalysis.Compilation c) => c.GetSemanticModel(CSharpSyntaxTree.ParseText(\"\")); }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ]); @@ -87,7 +88,8 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsCompilations() // Keyed by the outermost type, merged across partial declarations. A parse call counts when its bound symbol is a // Parse* or Create method on CSharpSyntaxTree or SyntaxFactory; a method name cannot be aliased, so the name - // prefilter loses nothing short of reflection. The type is exempt when any of its code binds CSharpCompilation. + // prefilter loses nothing short of reflection. The type is exempt when any of its code creates a CSharpCompilation or + // asks a compilation for its SemanticModel, which also covers a compilation built by a shared helper. internal static Dictionary SpellingOnlyParsers(IEnumerable<(string Project, string Path, string Source)> files) { var found = new Dictionary(StringComparer.Ordinal); @@ -105,13 +107,13 @@ internal static Dictionary SpellingOnlyParsers(IEnumerable<(stri foreach (var name in tree.GetRoot().DescendantNodes().OfType()) { var text = name.Identifier.ValueText; - if (!text.StartsWith("Parse", StringComparison.Ordinal) && text is not ("Create" or "CSharpCompilation")) + if (!text.StartsWith("Parse", StringComparison.Ordinal) && text is not ("Create" or "CSharpCompilation" or "GetSemanticModel" or "SemanticModel")) continue; var info = model.GetSymbolInfo(name); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); if (OutermostType(model, name) is not { } owner) continue; - if (IsCompilation(symbol)) + if (BindsSymbols(symbol)) bound.Add(owner); else if (symbol is IMethodSymbol method && IsParse(method)) parsers.TryAdd(owner, tree.FilePath); @@ -129,8 +131,9 @@ private static bool IsParse(IMethodSymbol method) => method.ContainingType.ToDisplayString() is "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree" or "Microsoft.CodeAnalysis.CSharp.SyntaxFactory" && (method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create"); - private static bool IsCompilation(ISymbol? symbol) => - (symbol as INamedTypeSymbol ?? symbol?.ContainingType)?.ToDisplayString() == "Microsoft.CodeAnalysis.CSharp.CSharpCompilation"; + private static bool BindsSymbols(ISymbol? symbol) => + (symbol as INamedTypeSymbol ?? symbol?.ContainingType)?.ToDisplayString() is + "Microsoft.CodeAnalysis.CSharp.CSharpCompilation" or "Microsoft.CodeAnalysis.Compilation" or "Microsoft.CodeAnalysis.SemanticModel"; private static INamedTypeSymbol? OutermostType(SemanticModel model, SyntaxNode node) { From a53d326f7da4416608cef2702317f89b9e2e567e Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:43:43 +0000 Subject: [PATCH 04/11] test(arch): read MSBuild Using items and fail closed on unresolved parse calls --- .../SpellingScannerRatchetTests.cs | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 20b66bf59..accca2190 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -53,7 +53,7 @@ public void NoNewTestType_ParsesCSharpWithoutBindingSymbols() Assert.True(files.Count >= ScannedFileFloor, $"Scanned {files.Count} test files, below the floor of {ScannedFileFloor}; the enumeration is excluding too much."); - var found = SpellingOnlyParsers(files); + var found = SpellingOnlyParsers(files, project => ProjectUsings(root, project)); var failures = new List(); foreach (var (type, path) in found.Where(f => !Legacy.ContainsKey(f.Key)).OrderBy(f => f.Key, StringComparer.Ordinal)) @@ -81,23 +81,41 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("P", "Bound.cs", header + "class Bound { object M() => CSharpCompilation.Create(\"x\", [CSharpSyntaxTree.ParseText(\"\")]); }"), ("P", "Helper.cs", header + "class Helper { object M(Microsoft.CodeAnalysis.Compilation c) => c.GetSemanticModel(CSharpSyntaxTree.ParseText(\"\")); }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), - ]); + ("P", "Unresolved.cs", "class Unresolved { object M() => CSharpSyntaxTree.ParseText(\"\"); }"), + ("Q", "ProjectUsing.cs", "class ProjectUsing { object M() => SyntaxFactory.ParseExpression(\"x\"); }"), + ], project => project == "Q" ? "global using Microsoft.CodeAnalysis.CSharp;" : ""); - Assert.Equal(["Alias", "Direct", "N.Outer", "Static"], found.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["Alias", "Direct", "N.Outer", "ProjectUsing", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); + } + + [Fact] + public void MsBuildUsingItems_BecomeGlobalUsings() + { + var usings = MsBuildGlobalUsings(System.Xml.Linq.XDocument.Parse(""" + + + + + + """)); + + Assert.Equal(["global using A.B;", "global using static A.C;", "global using E = A.D;"], usings); } // Keyed by the outermost type, merged across partial declarations. A parse call counts when its bound symbol is a // Parse* or Create method on CSharpSyntaxTree or SyntaxFactory; a method name cannot be aliased, so the name // prefilter loses nothing short of reflection. The type is exempt when any of its code creates a CSharpCompilation or // asks a compilation for its SemanticModel, which also covers a compilation built by a shared helper. - internal static Dictionary SpellingOnlyParsers(IEnumerable<(string Project, string Path, string Source)> files) + internal static Dictionary SpellingOnlyParsers( + IEnumerable<(string Project, string Path, string Source)> files, Func projectUsings) { var found = new Dictionary(StringComparer.Ordinal); foreach (var project in files.GroupBy(f => f.Project)) { var trees = project.Select(f => CSharpSyntaxTree.ParseText(f.Source, ModuleLedgerScanner.ParseOptions, f.Path)).ToList(); var compilation = CompatibilityExceptionScanner.Compile(project.Key, trees, - CompatibilityExceptionScanner.ImplicitUsings, CompatibilityExceptionScanner.References(project.Key)); + CompatibilityExceptionScanner.ImplicitUsings + "\n" + projectUsings(project.Key), + CompatibilityExceptionScanner.References(project.Key)); var parsers = new Dictionary(SymbolEqualityComparer.Default); var bound = new HashSet(SymbolEqualityComparer.Default); @@ -115,7 +133,7 @@ internal static Dictionary SpellingOnlyParsers(IEnumerable<(stri continue; if (BindsSymbols(symbol)) bound.Add(owner); - else if (symbol is IMethodSymbol method && IsParse(method)) + else if (symbol is IMethodSymbol method ? IsParse(method) : symbol is null && UnresolvedParse.Contains(text)) parsers.TryAdd(owner, tree.FilePath); } } @@ -127,6 +145,23 @@ internal static Dictionary SpellingOnlyParsers(IEnumerable<(stri return found; } + // The SDK turns items into generated global usings, which tracked source does not contain. + internal static IEnumerable MsBuildGlobalUsings(System.Xml.Linq.XDocument project) => + project.Descendants("Using").Where(u => u.Attribute("Include") is not null).Select(u => + (string?)u.Attribute("Alias") is { } alias ? $"global using {alias} = {(string)u.Attribute("Include")!};" + : (string?)u.Attribute("Static") == "true" ? $"global using static {(string)u.Attribute("Include")!};" + : $"global using {(string)u.Attribute("Include")!};"); + + private static string ProjectUsings(string root, string project) => string.Join("\n", + new[] { "Directory.Build.props", "tests/Directory.Build.props", $"tests/{project}/{project}.csproj" } + .Select(p => Path.Combine(root, p)).Where(File.Exists) + .SelectMany(p => MsBuildGlobalUsings(System.Xml.Linq.XDocument.Load(p)))); + + + // Fail closed: a parser call that does not bind (a missing import or reference) still counts as one. + private static readonly HashSet UnresolvedParse = new(StringComparer.Ordinal) + { "ParseText", "ParseSyntaxTree", "ParseCompilationUnit" }; + private static bool IsParse(IMethodSymbol method) => method.ContainingType.ToDisplayString() is "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree" or "Microsoft.CodeAnalysis.CSharp.SyntaxFactory" && (method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create"); From 907c508850f00bcf569abc62fe8884929138695e Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:47:33 +0000 Subject: [PATCH 05/11] test(arch): count helpers that return a syntax tree or C# node as parse calls --- .../SpellingScannerRatchetTests.cs | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index accca2190..1a41eda0b 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -81,11 +81,17 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("P", "Bound.cs", header + "class Bound { object M() => CSharpCompilation.Create(\"x\", [CSharpSyntaxTree.ParseText(\"\")]); }"), ("P", "Helper.cs", header + "class Helper { object M(Microsoft.CodeAnalysis.Compilation c) => c.GetSemanticModel(CSharpSyntaxTree.ParseText(\"\")); }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), + ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + + "internal static object Bind(Microsoft.CodeAnalysis.Compilation c, Microsoft.CodeAnalysis.SyntaxTree t) => c.GetSemanticModel(t); }"), + ("P", "Reuse.cs", "class Reuse { object M() => HelperOwner.Tree(\"\").GetRoot(); }"), + ("P", "ReuseNode.cs", "class ReuseNode { object M() => HelperOwner.Root(\"\"); }"), + ("P", "Group.cs", "class Group { object M(string[] s) => s.Select(HelperOwner.Tree).ToList(); }"), ("P", "Unresolved.cs", "class Unresolved { object M() => CSharpSyntaxTree.ParseText(\"\"); }"), ("Q", "ProjectUsing.cs", "class ProjectUsing { object M() => SyntaxFactory.ParseExpression(\"x\"); }"), ], project => project == "Q" ? "global using Microsoft.CodeAnalysis.CSharp;" : ""); - Assert.Equal(["Alias", "Direct", "N.Outer", "ProjectUsing", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["Alias", "Direct", "Group", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); } [Fact] @@ -102,9 +108,8 @@ public void MsBuildUsingItems_BecomeGlobalUsings() Assert.Equal(["global using A.B;", "global using static A.C;", "global using E = A.D;"], usings); } - // Keyed by the outermost type, merged across partial declarations. A parse call counts when its bound symbol is a - // Parse* or Create method on CSharpSyntaxTree or SyntaxFactory; a method name cannot be aliased, so the name - // prefilter loses nothing short of reflection. The type is exempt when any of its code creates a CSharpCompilation or + // Keyed by the outermost type, merged across partial declarations. Every invocation and method-group argument is + // bound; a parse call is one whose symbol IsParser, or an unbound call spelled like a Roslyn parse method. The type is exempt when any of its code creates a CSharpCompilation or // asks a compilation for its SemanticModel, which also covers a compilation built by a shared helper. internal static Dictionary SpellingOnlyParsers( IEnumerable<(string Project, string Path, string Source)> files, Func projectUsings) @@ -122,19 +127,30 @@ internal static Dictionary SpellingOnlyParsers( foreach (var tree in trees) { var model = compilation.GetSemanticModel(tree); - foreach (var name in tree.GetRoot().DescendantNodes().OfType()) + foreach (var node in tree.GetRoot().DescendantNodes()) { - var text = name.Identifier.ValueText; - if (!text.StartsWith("Parse", StringComparison.Ordinal) && text is not ("Create" or "CSharpCompilation" or "GetSemanticModel" or "SemanticModel")) + var call = node switch + { + InvocationExpressionSyntax invocation => invocation.Expression, + ArgumentSyntax { Expression: IdentifierNameSyntax or MemberAccessExpressionSyntax } group => group.Expression, + _ => null, + }; + if (call is null) continue; - var info = model.GetSymbolInfo(name); + var info = model.GetSymbolInfo(call); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); - if (OutermostType(model, name) is not { } owner) + var text = (call as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText ?? (call as SimpleNameSyntax)?.Identifier.ValueText; + if ((symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text)) + && OutermostType(model, node) is { } owner) + parsers.TryAdd(owner, tree.FilePath); + } + foreach (var name in tree.GetRoot().DescendantNodes().OfType()) + { + if (name.Identifier.ValueText is not ("Create" or "CSharpCompilation" or "GetSemanticModel" or "SemanticModel")) continue; - if (BindsSymbols(symbol)) + var info = model.GetSymbolInfo(name); + if (BindsSymbols(info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) && OutermostType(model, name) is { } owner) bound.Add(owner); - else if (symbol is IMethodSymbol method ? IsParse(method) : symbol is null && UnresolvedParse.Contains(text)) - parsers.TryAdd(owner, tree.FilePath); } } @@ -162,9 +178,20 @@ private static string ProjectUsings(string root, string project) => string.Join( private static readonly HashSet UnresolvedParse = new(StringComparer.Ordinal) { "ParseText", "ParseSyntaxTree", "ParseCompilationUnit" }; - private static bool IsParse(IMethodSymbol method) => + // A Roslyn parse factory, or a helper of ours that hands back a tree or C# node: its caller scans syntax too. + private static bool IsParser(IMethodSymbol method) => method.ContainingType.ToDisplayString() is "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree" or "Microsoft.CodeAnalysis.CSharp.SyntaxFactory" - && (method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create"); + ? method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create" + : !method.ContainingAssembly.Name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal) + && DerivesFrom(method.ReturnType, "Microsoft.CodeAnalysis.SyntaxTree", "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxNode"); + + private static bool DerivesFrom(ITypeSymbol? type, params string[] bases) + { + for (; type is not null; type = type.BaseType) + if (bases.Contains(type.ToDisplayString())) + return true; + return false; + } private static bool BindsSymbols(ISymbol? symbol) => (symbol as INamedTypeSymbol ?? symbol?.ContainingType)?.ToDisplayString() is From b10f2fe66f5485f740eb9a407882a622bb174751 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:49:38 +0000 Subject: [PATCH 06/11] test(arch): require a binding call, not a CSharpCompilation mention, for the exemption --- .../SpellingScannerRatchetTests.cs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 1a41eda0b..77f364087 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -78,12 +78,15 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("P", "Alias.cs", "using T = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree;\nclass Alias { object M() => T.ParseText(\"\"); }"), ("P", "Static.cs", "using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;\nclass Static { object M() => ParseCompilationUnit(\"\"); }"), ("P", "Nested.cs", header + "namespace N { class Outer { class Inner { System.Func F = s => CSharpSyntaxTree.ParseText(s); } } }"), - ("P", "Bound.cs", header + "class Bound { object M() => CSharpCompilation.Create(\"x\", [CSharpSyntaxTree.ParseText(\"\")]); }"), + ("P", "Bound.cs", header + "class Bound { object? M() { var t = CSharpSyntaxTree.ParseText(\"\"); " + + "return CSharpCompilation.Create(\"x\", [t]).GetSemanticModel(t).GetDeclaredSymbol(t.GetRoot()); } }"), + ("P", "CompilationParameter.cs", header + "class CompilationParameter { object M(CSharpCompilation existing, string s) => " + + "CSharpSyntaxTree.ParseText(s, (CSharpParseOptions)existing.SyntaxTrees.First().Options); }"), ("P", "Helper.cs", header + "class Helper { object M(Microsoft.CodeAnalysis.Compilation c) => c.GetSemanticModel(CSharpSyntaxTree.ParseText(\"\")); }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + - "internal static object Bind(Microsoft.CodeAnalysis.Compilation c, Microsoft.CodeAnalysis.SyntaxTree t) => c.GetSemanticModel(t); }"), + "internal static object? Bind(Microsoft.CodeAnalysis.Compilation c, Microsoft.CodeAnalysis.SyntaxTree t) => c.GetSemanticModel(t).GetDeclaredSymbol(t.GetRoot()); }"), ("P", "Reuse.cs", "class Reuse { object M() => HelperOwner.Tree(\"\").GetRoot(); }"), ("P", "ReuseNode.cs", "class ReuseNode { object M() => HelperOwner.Root(\"\"); }"), ("P", "Group.cs", "class Group { object M(string[] s) => s.Select(HelperOwner.Tree).ToList(); }"), @@ -91,7 +94,7 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("Q", "ProjectUsing.cs", "class ProjectUsing { object M() => SyntaxFactory.ParseExpression(\"x\"); }"), ], project => project == "Q" ? "global using Microsoft.CodeAnalysis.CSharp;" : ""); - Assert.Equal(["Alias", "Direct", "Group", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["Alias", "CompilationParameter", "Direct", "Group", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); } [Fact] @@ -140,17 +143,15 @@ internal static Dictionary SpellingOnlyParsers( var info = model.GetSymbolInfo(call); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); var text = (call as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText ?? (call as SimpleNameSyntax)?.Identifier.ValueText; - if ((symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text)) - && OutermostType(model, node) is { } owner) - parsers.TryAdd(owner, tree.FilePath); - } - foreach (var name in tree.GetRoot().DescendantNodes().OfType()) - { - if (name.Identifier.ValueText is not ("Create" or "CSharpCompilation" or "GetSemanticModel" or "SemanticModel")) - continue; - var info = model.GetSymbolInfo(name); - if (BindsSymbols(info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) && OutermostType(model, name) is { } owner) + if (symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text)) + { + if (OutermostType(model, node) is { } owner) + parsers.TryAdd(owner, tree.FilePath); + } + else if (symbol is IMethodSymbol binding && BindsSymbols(binding) && OutermostType(model, node) is { } owner) + { bound.Add(owner); + } } } @@ -193,9 +194,14 @@ private static bool DerivesFrom(ITypeSymbol? type, params string[] bases) return false; } - private static bool BindsSymbols(ISymbol? symbol) => - (symbol as INamedTypeSymbol ?? symbol?.ContainingType)?.ToDisplayString() is - "Microsoft.CodeAnalysis.CSharp.CSharpCompilation" or "Microsoft.CodeAnalysis.Compilation" or "Microsoft.CodeAnalysis.SemanticModel"; + // A call, not a mention: creating a compilation, asking it for a model, or querying the model. + private static bool BindsSymbols(IMethodSymbol method) => + method.ContainingType.ToDisplayString() switch + { + "Microsoft.CodeAnalysis.CSharp.CSharpCompilation" => method.Name == "Create", + "Microsoft.CodeAnalysis.Compilation" => method.Name == "GetSemanticModel", + _ => DerivesFrom(method.ContainingType, "Microsoft.CodeAnalysis.SemanticModel"), + }; private static INamedTypeSymbol? OutermostType(SemanticModel model, SyntaxNode node) { From 040dd669b3391daa304bd7059912d93da27a1e25 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:52:17 +0000 Subject: [PATCH 07/11] test(arch): count semantic-model queries, including extension methods, as binding --- .../SpellingScannerRatchetTests.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 77f364087..bee97168c 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -83,6 +83,13 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("P", "CompilationParameter.cs", header + "class CompilationParameter { object M(CSharpCompilation existing, string s) => " + "CSharpSyntaxTree.ParseText(s, (CSharpParseOptions)existing.SyntaxTrees.First().Options); }"), ("P", "Helper.cs", header + "class Helper { object M(Microsoft.CodeAnalysis.Compilation c) => c.GetSemanticModel(CSharpSyntaxTree.ParseText(\"\")); }"), + ("P", "Factory.cs", header + "static class Factory { internal static Microsoft.CodeAnalysis.SemanticModel For(Microsoft.CodeAnalysis.SyntaxTree t) => " + + "CSharpCompilation.Create(\"p\", [t]).GetSemanticModel(t); " + + "internal static Microsoft.CodeAnalysis.SemanticModel BindReview(this Microsoft.CodeAnalysis.SyntaxTree t) => For(t); }"), + ("P", "VarBinding.cs", header + "class VarBinding { object M(string s) { var tree = CSharpSyntaxTree.ParseText(s); var model = Factory.For(tree); " + + "return tree.GetRoot().DescendantNodes().OfType().Select(n => model.GetSymbolInfo(n).Symbol).ToArray(); } }"), + ("P", "ExtensionBinding.cs", header + "class ExtensionBinding { object? M(string s) { var tree = CSharpSyntaxTree.ParseText(s); " + + "var model = tree.BindReview(); return model.GetDeclaredSymbol(tree.GetRoot()); } }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + @@ -189,19 +196,24 @@ private static bool IsParser(IMethodSymbol method) => private static bool DerivesFrom(ITypeSymbol? type, params string[] bases) { for (; type is not null; type = type.BaseType) - if (bases.Contains(type.ToDisplayString())) + if (bases.Contains($"{type.ContainingNamespace}.{type.Name}")) return true; return false; } - // A call, not a mention: creating a compilation, asking it for a model, or querying the model. - private static bool BindsSymbols(IMethodSymbol method) => - method.ContainingType.ToDisplayString() switch + // A call, not a mention: creating a compilation, asking it for a model, or querying the model, including through + // an extension method such as GetSymbolInfo or GetDeclaredSymbol whose receiver is the model. + private static bool BindsSymbols(IMethodSymbol method) + { + var declared = method.ReducedFrom ?? method; + return method.ContainingType.ToDisplayString() switch { "Microsoft.CodeAnalysis.CSharp.CSharpCompilation" => method.Name == "Create", "Microsoft.CodeAnalysis.Compilation" => method.Name == "GetSemanticModel", - _ => DerivesFrom(method.ContainingType, "Microsoft.CodeAnalysis.SemanticModel"), + _ => DerivesFrom(method.ContainingType, "Microsoft.CodeAnalysis.SemanticModel") + || declared.IsExtensionMethod && DerivesFrom(declared.Parameters[0].Type, "Microsoft.CodeAnalysis.SemanticModel"), }; + } private static INamedTypeSymbol? OutermostType(SemanticModel model, SyntaxNode node) { From 924461ae7bd7ad3b1e3ba1820c558ef567c117f3 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:55:26 +0000 Subject: [PATCH 08/11] test(arch): count only semantic-model queries as binding, so an unused compilation no longer exempts --- .../SpellingScannerRatchetTests.cs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index bee97168c..36319300b 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -5,7 +5,7 @@ namespace Cluckwork.Application.Tests.Architecture; -// A test type that parses C# but never builds a CSharpCompilation resolves names by spelling, and reviewers then +// A test type that parses C# but never queries a SemanticModel resolves names by spelling, and reviewers then // find one bypass per round: aliases, generic arity, nested types, `global using`, `#if !DEBUG` (#1010, #1013, // #1033, #1056; 30 PRs in all, #1184). New C# invariants go in Cluckwork.Analyzers, where the compiler binds every // name. The types already written this way are listed by fully qualified name and may change freely; the list only @@ -58,7 +58,7 @@ public void NoNewTestType_ParsesCSharpWithoutBindingSymbols() var failures = new List(); foreach (var (type, path) in found.Where(f => !Legacy.ContainsKey(f.Key)).OrderBy(f => f.Key, StringComparer.Ordinal)) { - failures.Add($"{type} ({path}) parses C# without a CSharpCompilation, so it matches names by spelling. " + + failures.Add($"{type} ({path}) parses C# without querying a SemanticModel, so it matches names by spelling. " + "Write the invariant as a Cluckwork.Analyzers diagnostic (see src/Cluckwork.Analyzers/ModuleEdgeAnalyzer.cs, " + "CW1004), or bind symbols through CSharpCompilation and its SemanticModel."); } @@ -90,6 +90,8 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() "return tree.GetRoot().DescendantNodes().OfType().Select(n => model.GetSymbolInfo(n).Symbol).ToArray(); } }"), ("P", "ExtensionBinding.cs", header + "class ExtensionBinding { object? M(string s) { var tree = CSharpSyntaxTree.ParseText(s); " + "var model = tree.BindReview(); return model.GetDeclaredSymbol(tree.GetRoot()); } }"), + ("P", "Discarded.cs", header + "class Discarded { object M(string s) { var tree = CSharpSyntaxTree.ParseText(s); " + + "_ = CSharpCompilation.Create(\"unused\", [tree]); return tree; } }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + @@ -101,7 +103,7 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("Q", "ProjectUsing.cs", "class ProjectUsing { object M() => SyntaxFactory.ParseExpression(\"x\"); }"), ], project => project == "Q" ? "global using Microsoft.CodeAnalysis.CSharp;" : ""); - Assert.Equal(["Alias", "CompilationParameter", "Direct", "Group", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["Alias", "CompilationParameter", "Direct", "Discarded", "Group", "Helper", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); } [Fact] @@ -119,8 +121,8 @@ public void MsBuildUsingItems_BecomeGlobalUsings() } // Keyed by the outermost type, merged across partial declarations. Every invocation and method-group argument is - // bound; a parse call is one whose symbol IsParser, or an unbound call spelled like a Roslyn parse method. The type is exempt when any of its code creates a CSharpCompilation or - // asks a compilation for its SemanticModel, which also covers a compilation built by a shared helper. + // bound; a parse call is one whose symbol IsParser, or an unbound call spelled like a Roslyn parse method. The type + // is exempt when any of its code makes a BindsSymbols call, wherever its model came from. internal static Dictionary SpellingOnlyParsers( IEnumerable<(string Project, string Path, string Source)> files, Func projectUsings) { @@ -201,18 +203,14 @@ private static bool DerivesFrom(ITypeSymbol? type, params string[] bases) return false; } - // A call, not a mention: creating a compilation, asking it for a model, or querying the model, including through - // an extension method such as GetSymbolInfo or GetDeclaredSymbol whose receiver is the model. + // A query on a SemanticModel, not a mention of one: a member call, or an extension method such as GetSymbolInfo or + // GetDeclaredSymbol whose receiver is the model. Creating a compilation or asking it for a model does not count on + // its own; a discarded `CSharpCompilation.Create` binds nothing. private static bool BindsSymbols(IMethodSymbol method) { var declared = method.ReducedFrom ?? method; - return method.ContainingType.ToDisplayString() switch - { - "Microsoft.CodeAnalysis.CSharp.CSharpCompilation" => method.Name == "Create", - "Microsoft.CodeAnalysis.Compilation" => method.Name == "GetSemanticModel", - _ => DerivesFrom(method.ContainingType, "Microsoft.CodeAnalysis.SemanticModel") - || declared.IsExtensionMethod && DerivesFrom(declared.Parameters[0].Type, "Microsoft.CodeAnalysis.SemanticModel"), - }; + return DerivesFrom(method.ContainingType, "Microsoft.CodeAnalysis.SemanticModel") + || declared.IsExtensionMethod && DerivesFrom(declared.Parameters[0].Type, "Microsoft.CodeAnalysis.SemanticModel"); } private static INamedTypeSymbol? OutermostType(SemanticModel model, SyntaxNode node) From e05701d8057e68942032570263c0fd434d9d6216 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 19:56:56 +0000 Subject: [PATCH 09/11] test(arch): bind the scanner census per tree in parallel --- .../SpellingScannerRatchetTests.cs | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 36319300b..6053efca1 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -134,38 +134,11 @@ internal static Dictionary SpellingOnlyParsers( CompatibilityExceptionScanner.ImplicitUsings + "\n" + projectUsings(project.Key), CompatibilityExceptionScanner.References(project.Key)); - var parsers = new Dictionary(SymbolEqualityComparer.Default); - var bound = new HashSet(SymbolEqualityComparer.Default); - foreach (var tree in trees) - { - var model = compilation.GetSemanticModel(tree); - foreach (var node in tree.GetRoot().DescendantNodes()) - { - var call = node switch - { - InvocationExpressionSyntax invocation => invocation.Expression, - ArgumentSyntax { Expression: IdentifierNameSyntax or MemberAccessExpressionSyntax } group => group.Expression, - _ => null, - }; - if (call is null) - continue; - var info = model.GetSymbolInfo(call); - var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); - var text = (call as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText ?? (call as SimpleNameSyntax)?.Identifier.ValueText; - if (symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text)) - { - if (OutermostType(model, node) is { } owner) - parsers.TryAdd(owner, tree.FilePath); - } - else if (symbol is IMethodSymbol binding && BindsSymbols(binding) && OutermostType(model, node) is { } owner) - { - bound.Add(owner); - } - } - } - - foreach (var (owner, path) in parsers.Where(p => !bound.Contains(p.Key))) - found[owner.ToDisplayString()] = path; + // Binding dominates the run time; models of different trees bind independently. + var calls = trees.AsParallel().SelectMany(tree => Calls(compilation, tree)).ToList(); + var bound = calls.Where(c => !c.Parses).Select(c => c.Owner).ToHashSet(SymbolEqualityComparer.Default); + foreach (var call in calls.Where(c => c.Parses && !bound.Contains(c.Owner)).OrderBy(c => c.Path, StringComparer.Ordinal)) + found.TryAdd(call.Owner.ToDisplayString(), call.Path); } return found; @@ -184,6 +157,28 @@ private static string ProjectUsings(string root, string project) => string.Join( .SelectMany(p => MsBuildGlobalUsings(System.Xml.Linq.XDocument.Load(p)))); + private static IEnumerable<(INamedTypeSymbol Owner, bool Parses, string Path)> Calls(Compilation compilation, SyntaxTree tree) + { + var model = compilation.GetSemanticModel(tree); + foreach (var node in tree.GetRoot().DescendantNodes()) + { + var call = node switch + { + InvocationExpressionSyntax invocation => invocation.Expression, + ArgumentSyntax { Expression: IdentifierNameSyntax or MemberAccessExpressionSyntax } group => group.Expression, + _ => null, + }; + if (call is null) + continue; + var info = model.GetSymbolInfo(call); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + var text = (call as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText ?? (call as SimpleNameSyntax)?.Identifier.ValueText; + var parses = symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text); + if ((parses || symbol is IMethodSymbol binding && BindsSymbols(binding)) && OutermostType(model, node) is { } owner) + yield return (owner, parses, tree.FilePath); + } + } + // Fail closed: a parser call that does not bind (a missing import or reference) still counts as one. private static readonly HashSet UnresolvedParse = new(StringComparer.Ordinal) { "ParseText", "ParseSyntaxTree", "ParseCompilationUnit" }; From de4a994119135817bc8725ce956ef72bcde21db8 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 22:44:44 +0000 Subject: [PATCH 10/11] test(arch): count helpers returning any SyntaxNode as parse calls --- .../Architecture/SpellingScannerRatchetTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 6053efca1..146e3326b 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -95,15 +95,17 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + + "internal static Microsoft.CodeAnalysis.SyntaxNode Node(string s) => CSharpSyntaxTree.ParseText(s).GetRoot(); " + "internal static object? Bind(Microsoft.CodeAnalysis.Compilation c, Microsoft.CodeAnalysis.SyntaxTree t) => c.GetSemanticModel(t).GetDeclaredSymbol(t.GetRoot()); }"), ("P", "Reuse.cs", "class Reuse { object M() => HelperOwner.Tree(\"\").GetRoot(); }"), + ("P", "ReuseBase.cs", "class ReuseBase { object M() => HelperOwner.Node(\"\"); }"), ("P", "ReuseNode.cs", "class ReuseNode { object M() => HelperOwner.Root(\"\"); }"), ("P", "Group.cs", "class Group { object M(string[] s) => s.Select(HelperOwner.Tree).ToList(); }"), ("P", "Unresolved.cs", "class Unresolved { object M() => CSharpSyntaxTree.ParseText(\"\"); }"), ("Q", "ProjectUsing.cs", "class ProjectUsing { object M() => SyntaxFactory.ParseExpression(\"x\"); }"), ], project => project == "Q" ? "global using Microsoft.CodeAnalysis.CSharp;" : ""); - Assert.Equal(["Alias", "CompilationParameter", "Direct", "Discarded", "Group", "Helper", "N.Outer", "ProjectUsing", "Reuse", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); + Assert.Equal(["Alias", "CompilationParameter", "Direct", "Discarded", "Group", "Helper", "N.Outer", "ProjectUsing", "Reuse", "ReuseBase", "ReuseNode", "Static", "Unresolved"], found.Keys.Order(StringComparer.Ordinal)); } [Fact] @@ -183,12 +185,12 @@ private static string ProjectUsings(string root, string project) => string.Join( private static readonly HashSet UnresolvedParse = new(StringComparer.Ordinal) { "ParseText", "ParseSyntaxTree", "ParseCompilationUnit" }; - // A Roslyn parse factory, or a helper of ours that hands back a tree or C# node: its caller scans syntax too. + // A Roslyn parse factory, or a helper of ours that hands back a tree or syntax node: its caller scans syntax too. private static bool IsParser(IMethodSymbol method) => method.ContainingType.ToDisplayString() is "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree" or "Microsoft.CodeAnalysis.CSharp.SyntaxFactory" ? method.Name.StartsWith("Parse", StringComparison.Ordinal) || method.Name == "Create" : !method.ContainingAssembly.Name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal) - && DerivesFrom(method.ReturnType, "Microsoft.CodeAnalysis.SyntaxTree", "Microsoft.CodeAnalysis.CSharp.CSharpSyntaxNode"); + && DerivesFrom(method.ReturnType, "Microsoft.CodeAnalysis.SyntaxTree", "Microsoft.CodeAnalysis.SyntaxNode"); private static bool DerivesFrom(ITypeSymbol? type, params string[] bases) { From 05d9bbaeaa2824ae149fea4f12fa1031e0a1e7a8 Mon Sep 17 00:00:00 2001 From: mforce Date: Sat, 10 Oct 2026 22:47:30 +0000 Subject: [PATCH 11/11] test(arch): exempt a type on any binding call, even one that also returns syntax --- .../Architecture/SpellingScannerRatchetTests.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs index 146e3326b..5776d4e03 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/SpellingScannerRatchetTests.cs @@ -92,6 +92,11 @@ public void Detection_BindsAliasesAndStaticImports_AndExemptsSemanticModels() "var model = tree.BindReview(); return model.GetDeclaredSymbol(tree.GetRoot()); } }"), ("P", "Discarded.cs", header + "class Discarded { object M(string s) { var tree = CSharpSyntaxTree.ParseText(s); " + "_ = CSharpCompilation.Create(\"unused\", [tree]); return tree; } }"), + ("P", "Finder.cs", "using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax;\nstatic class Finder { " + + "internal static ClassDeclarationSyntax? Find(this SemanticModel model, SyntaxNode root) { " + + "foreach (var c in root.DescendantNodes().OfType()) if (model.GetDeclaredSymbol((SyntaxNode)c) is not null) return c; " + + "return null; } }"), + ("P", "SemanticFind.cs", "class SemanticFind { object? M(Microsoft.CodeAnalysis.SemanticModel model, Microsoft.CodeAnalysis.SyntaxNode root) => model.Find(root); }"), ("P", "Unrelated.cs", "class Unrelated { int ParseText(string s) => s.Length; int M() => ParseText(\"\"); }"), ("P", "HelperOwner.cs", header + "static class HelperOwner { internal static Microsoft.CodeAnalysis.SyntaxTree Tree(string s) => CSharpSyntaxTree.ParseText(s); " + "internal static Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax Root(string s) => SyntaxFactory.ParseCompilationUnit(s); " + @@ -138,7 +143,7 @@ internal static Dictionary SpellingOnlyParsers( // Binding dominates the run time; models of different trees bind independently. var calls = trees.AsParallel().SelectMany(tree => Calls(compilation, tree)).ToList(); - var bound = calls.Where(c => !c.Parses).Select(c => c.Owner).ToHashSet(SymbolEqualityComparer.Default); + var bound = calls.Where(c => c.Binds).Select(c => c.Owner).ToHashSet(SymbolEqualityComparer.Default); foreach (var call in calls.Where(c => c.Parses && !bound.Contains(c.Owner)).OrderBy(c => c.Path, StringComparer.Ordinal)) found.TryAdd(call.Owner.ToDisplayString(), call.Path); } @@ -159,7 +164,7 @@ private static string ProjectUsings(string root, string project) => string.Join( .SelectMany(p => MsBuildGlobalUsings(System.Xml.Linq.XDocument.Load(p)))); - private static IEnumerable<(INamedTypeSymbol Owner, bool Parses, string Path)> Calls(Compilation compilation, SyntaxTree tree) + private static IEnumerable<(INamedTypeSymbol Owner, bool Parses, bool Binds, string Path)> Calls(Compilation compilation, SyntaxTree tree) { var model = compilation.GetSemanticModel(tree); foreach (var node in tree.GetRoot().DescendantNodes()) @@ -176,8 +181,10 @@ private static string ProjectUsings(string root, string project) => string.Join( var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); var text = (call as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText ?? (call as SimpleNameSyntax)?.Identifier.ValueText; var parses = symbol is IMethodSymbol method ? IsParser(method) : symbol is null && text is not null && UnresolvedParse.Contains(text); - if ((parses || symbol is IMethodSymbol binding && BindsSymbols(binding)) && OutermostType(model, node) is { } owner) - yield return (owner, parses, tree.FilePath); + // One call can do both, such as a SemanticModel extension that returns the node it found. + var binds = symbol is IMethodSymbol binding && BindsSymbols(binding); + if ((parses || binds) && OutermostType(model, node) is { } owner) + yield return (owner, parses, binds, tree.FilePath); } }