Skip to content

[Feature][SourceGenerators] Don’t generate optimized partials unless the containing type is partial; emit upgrade-safe warnings instead #59

@JerrettDavis

Description

@JerrettDavis

Background / Why

TinyBDD now ships its source generator transitively (packed into the main TinyBDD NuGet as an analyzer). In src/TinyBDD/TinyBDD.csproj, we:

  • reference the generator project as an analyzer (OutputItemType="Analyzer", ReferenceOutputAssembly="false")
  • and manually pack the analyzer DLL into analyzers/dotnet/cs

This means upgrading TinyBDD can suddenly enable source generation for existing projects without any explicit opt-in.

Today the generator produces code that includes a partial class {ContainingType.Name} (see src/TinyBDD.SourceGenerators/OptimizedCodeGenerator.cs). If the user’s containing type is not partial, compilation fails (classic “you forgot the partial keyword” problem), which is exactly the kind of upgrade foot-gun we want to avoid.

Problem Statement

Current behavior (simplified):

  • TinyBddOptimizer selects methods that look like BDD scenarios (broad predicate).
  • It generates an additional partial type containing {MethodName}_Optimized(...).
  • If the user’s type is not partial, the generated partial class causes a compile error.

Desired behavior:

  • Only emit generated optimized code when the containing type is an “expected partial”.
  • If it isn’t, do not generate and instead emit build warnings explaining how to enable optimization (make the type partial) or how to silence/opt-out.

Goals

  1. Upgrade-safe: no compilation breaks when users update TinyBDD.
  2. Actionable warnings: clear, consistent diagnostics that tell users exactly what to do.
  3. No ambiguity for implementors: explicit rules, explicit test coverage.

Non-Goals

  • We are not changing the semantics of the optimization output (just gating generation).
  • We are not implementing a CodeFix (optional future enhancement).
  • We are not redesigning the optimization strategy (though we’ll capture a couple optional follow-ups).

Proposed Change

1) Define “Expected Partial” (Generation Eligibility)

A method is eligible for generated optimized output only if all of the following are true:

Type-level constraints

  • The containing type is a class or record class (and is supported by our generator output).
  • The containing type is declared partial in every declaration (for safety in multi-partial scenarios).
  • The containing type is not nested (ContainingType.ContainingType == null)
    Reason: generator output currently only uses .ContainingType.Name and does not reproduce nesting; nested types would generate incorrect/duplicate top-level types.
  • The containing type is not generic (no type parameters)
    Reason: generator currently emits partial class Foo rather than partial class Foo<T>.

Method-level constraints

  • Method passes existing selection logic (async Task/ValueTask shape + looks like BDD chain + not opted out).

If any type-level constraint fails: skip generation and emit diagnostics (below).

2) Add Diagnostics (Warnings) Instead of Generating

Introduce the following diagnostics (IDs, messages, locations, and suppression behavior must be deterministic and stable):

ID Severity Title Message Format Applies When Location
TBDD010 Warning Optimization skipped: containing type is not partial TinyBDD optimization was skipped for '{0}.{1}' because containing type '{0}' is not declared partial. Mark '{0}' as partial to enable source-generated optimizations (or opt out via [DisableOptimization]). Selected method would be optimized, but the containing type does not include partial Type identifier location (preferred) or method identifier
TBDD011 Warning Optimization skipped: nested types not supported TinyBDD optimization was skipped for '{0}.{1}' because nested types are not currently supported by the optimizer. Move the scenario to a top-level partial type or disable optimization for this method. ContainingType.ContainingType != null Method identifier
TBDD012 Warning Optimization skipped: generic types not supported TinyBDD optimization was skipped for '{0}.{1}' because generic containing types are not currently supported by the optimizer. Use a non-generic partial type or disable optimization for this method. ContainingType.TypeParameters.Length > 0 Method identifier
TBDD001 (existing) Warning TinyBDD optimization failed Failed to optimize method '{0}': {1} Exception during generation Method location

Notes

  • The new warnings must be emitted instead of generating code when they apply.
  • Warnings must be stable (no random ordering; avoid repeated spam where possible).
  • Ideally, we should not emit TBDD010 once per method in a class with 200 tests (see “Spam control” below).

3) Spam Control (Recommended)

Add a “warn once per type” strategy for TBDD010:

  • When detecting non-partial types, group methods by containing type and emit a single TBDD010 per type.
  • Still skip generation for all methods in that type.

Implementation options:

  • In the incremental pipeline, project to the containing type symbol key and .Collect() + .Distinct() to emit one diagnostic.
  • Or emit per-method but dedupe via incremental grouping (preferred).

4) Tighten Method Detection (Optional but Strongly Recommended)

ContainsBddChain in TinyBddOptimizer is currently string-based (checks for "Given" and "When"/"Then" in method body text). This can cause false positives and unnecessary diagnostics.

Instead:

  • Use syntax + semantic checks similar to OptimizedCodeGenerator.IsBddStartingMethod(...), which already resolves symbols and ensures Given is from TinyBDD* namespace.
  • This reduces warning noise and reduces “why is TinyBDD yelling at my unrelated async method that happens to contain the word Given”.

5) Docs Update (Required)

Update README / docs to explicitly state:

  • Source generation is enabled by default (already stated), and
  • Scenarios are optimized only when the containing type is partial
  • How to fix: public partial class MyTests ...
  • How to opt out: [DisableOptimization] or [GenerateOptimized(Enabled = false)] (as supported today)

Implementation Plan (Concrete)

Step A: Add eligibility checks (type constraints)

In src/TinyBDD.SourceGenerators/TinyBddOptimizer.cs, in Execute(...) before calling OptimizedCodeGenerator:

  1. Compute INamedTypeSymbol containingType = method.MethodSymbol.ContainingType;
  2. If containingType.ContainingType != null → report TBDD011 and return.
  3. If containingType.TypeParameters.Length > 0 → report TBDD012 and return.
  4. Determine partial-ness by inspecting all declaring syntaxes:
static bool IsPartial(INamedTypeSymbol type)
{
    foreach (var decl in type.DeclaringSyntaxReferences)
    {
        if (decl.GetSyntax() is TypeDeclarationSyntax tds)
        {
            if (!tds.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
                return false;
        }
    }
    return true;
}

If !IsPartial(containingType) → report TBDD010 and return.

Only then proceed with OptimizedCodeGenerator.Generate() and context.AddSource(...).

Step B: Add DiagnosticDescriptors (centralize)

Create a small internal diagnostic catalog class in the generator project, e.g.:

  • src/TinyBDD.SourceGenerators/Diagnostics.cs

Expose DiagnosticDescriptor instances for TBDD010/011/012 and reuse in Execute(...).

Step C: Spam control

Refactor pipeline so we can produce type-level diagnostics once:

  • Pipeline 1: Optimizable methods where type is eligible → generate sources.
  • Pipeline 2: Optimizable methods where type is ineligible → group by type → emit a single warning per type (TBDD010/011/012 depending on reason; or prefer most specific).

Step D: Update docs

  • README: add a “Partial Types Required” section under the source generator docs.
  • Include a “Common upgrade warning” snippet showing TBDD010 and the fix.

Test Plan (No Ambiguity)

We need generator-level tests and end-to-end compilation tests.

1) Generator Diagnostics & Output Tests (Recommended: Roslyn test harness)

Add a dedicated test project using Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing (or equivalent) to assert:

Good cases (should generate, no warnings)

  1. Partial class containing a BDD scenario method → generated source exists.
  2. Partial record class containing a BDD scenario method → generated source exists.
  3. Scenario method uses var in locals and/or lambdas → generator succeeds and output compiles.
  4. Scenario uses expression-bodied method: public async Task X() => await Given(...).Then(...); → generates.
  5. Method has parameters and returns Task/ValueTask → generates with correct signature.

Bad cases (should NOT generate; should warn)
6. Non-partial class with a valid BDD chain method:

  • Assert no generated sources
  • Assert TBDD010 warning at type or method location
  1. Nested partial type scenario:

    • public partial class Outer { public partial class Inner { ... } }
    • Assert TBDD011
    • Assert no generated sources
  2. Generic partial type scenario:

    • public partial class Tests<T> { ... }
    • Assert TBDD012
    • Assert no generated sources

Edge cases (should not crash; should be deterministic)
9. Method uses method-group instead of lambda where generator expects lambda → should not crash; either:

  • Report TBDD001 (existing), OR
  • Skip generation (if we add new “unsupported pattern” diagnostic later)
  1. Multiple scenario methods in one non-partial class:
  • Assert only one TBDD010 (spam control)
  1. Method includes Given/When/Then identifiers that are not TinyBDD symbols:
  • Ensure no warnings and no generation (requires semantic detection improvement)

2) Integration Tests (Existing TinyBDD.SourceGenerators.Tests)

Extend tests/TinyBDD.SourceGenerators.Tests with:

  1. A new non-partial test class that looks like the current SimpleScenarioTests but intentionally not partial.
  • Ensure the test project builds and runs.
  • Ensure generated output folder does not contain optimized files for that class.

If we can’t assert warnings easily in this style of test, we still keep it as a “compile doesn’t break” guardrail.


Acceptance Criteria

  • Upgrading TinyBDD does not introduce new compilation failures for projects whose test classes are not partial.
  • For a BDD scenario method in a non-partial containing type, the build succeeds and emits TBDD010 warning (once per type).
  • For nested types, the build succeeds and emits TBDD011 warning (once per type).
  • For generic types, the build succeeds and emits TBDD012 warning (once per type).
  • Existing generator failure diagnostic TBDD001 remains functional and unchanged.
  • Generated sources are produced only when eligibility checks pass.
  • README/docs explicitly describe the partial requirement and how to resolve warnings.

Extra Follow-ups (Optional, separate issues if needed)

  • Add a CodeFix provider: “Make containing type partial” for TBDD010.
  • Add a .editorconfig / MSBuild property to control severity (e.g., allow users to turn warnings into suggestions):
    TINYBDD_SG_DIAGNOSTICS_SEVERITY=TBDD010:suggestion
  • Replace ContainsBddChain string heuristics with semantic detection to reduce noise.

If you want, I can also draft the exact code diff (new Diagnostics.cs, updated pipeline shape, and the minimal set of changes in TinyBddOptimizer.Execute) in a copy/paste-friendly format.

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions