Skip to content

test(arch): ratchet test types that parse C# without binding symbols - #1189

Merged
mforce merged 13 commits into
mainfrom
chore/correct-scanner-ratchet
Oct 10, 2026
Merged

mforce merged 13 commits into
mainfrom
chore/correct-scanner-ratchet

Conversation

@mforce

@mforce mforce commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #1184

Guard tests that parse C# by spelling keep missing aliases, generic arity, nested types, global using and #if !DEBUG. Reviewers found these bypasses in 30 merged PRs (#1010, #1013, #1033, #1056 and others). This PR stops new ones from being added.

What it checks. SpellingScannerRatchetTests compiles every tracked tests/**/*.cs file, one compilation per test project. The compilation includes the SDK implicit usings and each project's MSBuild <Using> items. It binds every invocation and method-group argument and keeps two facts per outermost type:

  • Parses C#. It calls a Parse* or Create method on CSharpSyntaxTree or SyntaxFactory, or one of our own helpers that returns a SyntaxTree or any SyntaxNode (such as CompatibilityExceptionScanner.Parse). A call that does not bind but is spelled ParseText, ParseSyntaxTree or ParseCompilationUnit also counts, so a missing import fails closed.
  • Binds symbols. It queries a SemanticModel: a member call, or an extension method whose receiver is the model, such as GetSymbolInfo or GetDeclaredSymbol. It does not matter where the model came from or whether the variable is typed var.

The two facts are recorded separately, so a call that does both (a SemanticModel extension that returns the node it found) keeps the type exempt. A type that parses but never binds fails with:

<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.

Pinned list. The 11 types that already work this way are listed by full name, each with a one-line reason. They may change freely. A listed type that stops parsing by spelling (moved to an analyzer, deleted, or now binding) fails until its row is deleted, so the list only shrinks. No row can be removed today: all 11 still parse without binding. Moving them is out of scope (#1184).

Creating a compilation does not count as binding. Neither does asking it for a model. Only a query on the model does. A discarded _ = CSharpCompilation.Create(...) therefore no longer exempts a type (review round 1, note 5). None of the types already exempt in the real tree depended on the looser rule.

Proof against past PRs. A throwaway harness ran the same detection over tests/ at each PR's squash commit and at its parent, reading the files and MSBuild items with git show:

PR Commit New spelling-only type the ratchet would have flagged
#584 deaa260e TenantBypass.GuardScanner
#872 8f9e6728 Architecture.ModuleLedgerScanner (#1078 later moved it to bound symbols)
#1012 7ce95cf4 none
#1013 6e3f5845 none
#1043 b998b072 TenantBypass.FarmDirectoryCallerTests
#1056 20198660 Architecture.SourcePreprocessorTests

#1012 and #1013 are honest misses. InsightsReadOnlyTests and CompatibilityExceptionScanner already queried a semantic model. Their bypasses (alias normalisation, generic arity) came from comparing symbol names as strings, which this ratchet does not judge.

On the current tree, deleting the GuardScanner row (#584's scanner) turns the guard red:

Cluckwork.Application.Tests.TenantBypass.GuardScanner (tests/Cluckwork.Application.Tests/TenantBypass/GuardScanner.cs) parses C# without querying a SemanticModel, so it matches names by spelling. …

Restoring the row makes it green again (3 of 3 passed).

Review round 1. Each finding has a fixture row, and each row was red before its fix commit:

Finding Fixture rows Fix
1. A project <Using> hid CSharpSyntaxTree ProjectUsing, Unresolved, MsBuildUsingItems_BecomeGlobalUsings a53d326f. The reviewer's <Using> plus file mutation on the real tree is also red, naming ReviewProjectUsing.
2. Reusing CompatibilityExceptionScanner.Parse Reuse, ReuseNode, Group (method group) 907c5088. The reviewer's ReviewReuseParser file on the real tree is also red.
3. A CSharpCompilation parameter exempted a type CompilationParameter b10f2fe6
4. Binding through a factory or extension was rejected VarBinding, ExtensionBinding 040dd669
5. A discarded compilation exempted a type Discarded, Helper (model requested, never queried) 924461ae

Review round 2. The maintainer chose to fix two findings and note one.

Finding Fixture rows Fix
A helper returning SyntaxNode escaped ReuseBase de4a9941
A semantic query that returns syntax lost the exemption Finder, SemanticFind 05d9bbae. With the fix reverted, the guard names SemanticFind.
MSBuild <Using> items are read as raw XML none Noted under Limits.

Limits.

  • A type that queries a model in one method and matches by spelling in another counts as binding. InsightsReadOnlyTests is one such type.
  • A type that queries a model but compares symbol names as strings passes. That is the refactor(insights): read reports, exports and audit provenance through an Insights facade #1012 and test(arch): register and date every module compatibility exception #1013 shape above.
  • Regex scans of source text are not covered. They need a parser per file type, and the issue scopes this check to C# parsing.
  • A parse call made through reflection, or a call to one of our own syntax helpers through a delegate, is not detected.
  • MSBuild <Using> items are read from the XML of Directory.Build.props, tests/Directory.Build.props and each test csproj, not evaluated. Conditions, other imports, Remove, and metadata written as child elements or as Static="True" are not followed. A missed alias can hide a parser, and a stale one can make an unrelated ParseText read as a C# parse. Reading the SDK-generated global-usings file would fix this; it needs a build output the guard does not have today.

Cost. The guard binds every call in 388 files, with binding run per tree in parallel. In CI the Application job took 110 s at e05701d8, against 99 s on recent main.

Tests run locally: dotnet test tests/Cluckwork.Application.Tests --filter FullyQualifiedName~SpellingScannerRatchetTests (3 passed, at 05d9bbae).

@mforce mforce added this to the Platform hardening milestone Oct 10, 2026
@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 (Codex gpt-6-astra, head edc34421): 4 realistic guard findings, 1 contrived note

  1. P2: parser calls hidden behind project-level imports. With <Using Include="Microsoft.CodeAnalysis.CSharp" /> in the test csproj, the inspection compilation cannot resolve CSharpSyntaxTree, and the call is silently skipped. Fix: include the project's MSBuild global usings, and treat an unresolved ParseText/ParseSyntaxTree call as a parser (fail closed), not as absent.
  2. P2: reusing an existing parse helper hides a new scanner. CompatibilityExceptionScanner.Parse(file).GetRoot()… with spelling matching passes, because only direct Roslyn factory calls count. Fix: a bound call to any helper that returns a SyntaxTree/SyntaxNode also counts as a parser entry point.
  3. P2: naming the type earns the exemption. A CSharpCompilation parameter, with no binding anywhere, passes. Fix: require bound invocations that create a compilation, request a model, or run semantic queries; a type reference alone is not enough.
  4. P2: real binding through a factory or extension is a false positive. var model = Factory.For(tree); model.GetSymbolInfo(...) is flagged. Fix: count semantic queries, including extension methods on SemanticModel; switching from an explicit type to var must not change the result.

Contrived (PR note): _ = CSharpCompilation.Create(...) with no further use satisfies the rule as written.

The floor and stale-row checks behaved correctly.

@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Reply to review round 1. The head is now e05701d8. Each finding got a fixture row, red before its fix commit and green after it:

  1. Project <Using> items. Fixed in a53d326f. The inspection compilation now includes the <Using> items from Directory.Build.props, tests/Directory.Build.props and each test csproj (plain, Static and Alias forms). A call spelled ParseText, ParseSyntaxTree or ParseCompilationUnit that does not bind now counts as a parse, so the check fails closed. Fixture rows: ProjectUsing (uses ParseExpression, so only the imported usings can find it), Unresolved, and MsBuildUsingItems_BecomeGlobalUsings. Your exact mutation (a <Using> in the Application test csproj plus a tracked file with no source import) is red, naming ReviewProjectUsing.
  2. Helper reuse. Fixed in 907c5088. A bound call to any of our own methods that returns a SyntaxTree or a C# syntax node counts as a parse for the calling type. Roslyn's own navigation, such as GetRoot(), does not. The detection now binds every invocation and method-group argument instead of filtering by name first. Fixture rows: Reuse, ReuseNode, Group. Your ReviewReuseParser file on the real tree is red.
  3. A mention earned the exemption. Fixed in b10f2fe6. Only bound calls count; a parameter or type name no longer does. Fixture row: CompilationParameter.
  4. Factory and extension binding. Fixed in 040dd669. A call to any SemanticModel member, or to an extension method whose receiver is a SemanticModel, counts as binding, whether or not the variable is typed var. Fixture rows: VarBinding and ExtensionBinding. Making this pass also turned up a nullability bug: SemanticModel? receivers failed a display-string comparison, so base types are now compared by namespace and name.
  5. Discarded compilation. I chose the stricter rule, in 924461ae. Creating a compilation, or asking it for a model, no longer counts on its own; only a query on the model does. Fixture rows: Discarded, plus Helper, which requests a model and never queries it. No type in the real tree changed classification, and the pinned list is unchanged.

e05701d8 binds the trees in parallel; the result is the same.

The historical proof is now in the PR body. #584, #872, #1043 and #1056 each add a type the ratchet flags. #1012 and #1013 add none: their scanners already queried a semantic model, and their bypasses came from comparing symbol names as strings, which this ratchet does not judge. Deleting the GuardScanner row on the current tree turns the guard red, and restoring it turns it green.

Local run: SpellingScannerRatchetTests, 3 of 3 passed.

@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 2 (Codex gpt-6-astra, head e05701d8): 3 guard-only findings, no product defect

All round 1 repros are closed. New gaps:

  1. The <Using> reading parses raw XML from fixed paths instead of MSBuild's evaluated items, so import order, Remove, metadata and other imports can differ. Fix: use evaluated MSBuild Using items or the SDK-generated global-usings source, and delete the hand-written reconstruction.
  2. A helper that returns Microsoft.CodeAnalysis.SyntaxNode, the base type, escapes detection. Fix: add it to the parser-candidate return types.
  3. A semantic query that returns syntax loses the exemption, because the return-type rule wins over the binding rule. Fix: record parsing and binding separately, or let a recognised semantic query take precedence.

Cost: the guard runs in 18–32 s locally. The Application CI job is about 11 s (11–13%) slower than recent main. A name-based prefilter would reopen round 1's gaps.

Under the review-loop rule this is a stop point: two rounds whose findings were all in the guard's own detection logic (a control for a control). The maintainer decides whether to keep going.

@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Reply to review round 2. The head is now 05d9bbae, with origin/main merged in at 4b6f8dd9. I made the two fixes and the note the maintainer chose:

  • A helper returning SyntaxNode escaped. Fixed in de4a9941. Our own helpers now count as parse calls when they return any Microsoft.CodeAnalysis.SyntaxNode, not only a CSharpSyntaxNode. Fixture row ReuseBase (a helper that returns ParseText(s).GetRoot()) was red before the fix.
  • A semantic query that returns syntax lost the exemption. Fixed in 05d9bbae. "Parses" and "binds" are now recorded separately for each call, and a type is exempt when any of its calls binds. Fixture rows: Finder (a SemanticModel extension that returns the ClassDeclarationSyntax it found through GetDeclaredSymbol) and SemanticFind (its caller). With the fix reverted, the guard names SemanticFind.
  • MSBuild <Using> items read as raw XML. Noted under Limits in the PR body, as decided: conditions, other imports, Remove, child-element metadata and Static="True" are not followed.

Local run on 05d9bbae: SpellingScannerRatchetTests, 3 of 3 passed.

@mforce
mforce merged commit 7ca812e into main Oct 10, 2026
18 checks passed
@mforce
mforce deleted the chore/correct-scanner-ratchet branch October 10, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Block new spelling-based C# guard scanners; point to Cluckwork.Analyzers

1 participant