From ebb4c21e5ac17d31a4f82f09e2e855a28c3d1fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:30:08 +0000 Subject: [PATCH] Make ReusesCachedOutputOnRerun detect a non-incremental generator The check reran the driver against the same Compilation instance. Roslyn compares incremental inputs by reference, so every step came back Cached and the check passed for any generator, including one that recomputes on every keystroke. The second run now gets a new but equivalent compilation, which is what an edit hands a generator. A generator with no tracked output steps now reports false instead of passing by default. Downstream tests that call this check may now fail. Each such failure is a generator that really was not incremental. Fixes #19 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019StKk4VdpfAZ5cdW5X685e --- README.md | 2 +- .../GeneratorBaseTests.cs | 14 ++++++++ SourceGeneratorToolkit.Test/TestGenerators.cs | 22 +++++++++++++ .../GeneratorHarness.cs | 32 +++++++++++++++---- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e08a9b0..efd9530 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ argument. | `Run(IIncrementalGenerator, IReadOnlyDictionary?)` | `GeneratorRunResult` | Runs one generator against every file in the directory. An override replaces a file's contents, or adds a file. | | `RunWithOnly(IIncrementalGenerator, params string[])` | `GeneratorRunResult` | Runs one generator against only the named files, so a test can assert what it does when one is absent. | | `RunAll(IReadOnlyList, IReadOnlyDictionary?)` | `GeneratorDriverRunResult` | Runs several generators and returns the whole driver result. | -| `ReusesCachedOutputOnRerun(IIncrementalGenerator)` | `bool` | Runs the generator twice over identical metadata and reports whether the second run reused the first run's outputs. | +| `ReusesCachedOutputOnRerun(IIncrementalGenerator)` | `bool` | Runs the generator twice over identical metadata, the second time against a new but equivalent compilation as an edit would, and reports whether the second run reused the first run's outputs. A generator with no tracked output steps reports `false`. | ## Contributing diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index 3e93d7c..cbe8d8e 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -206,4 +206,18 @@ public void TheGeneratorReusesItsOutputWhenNothingChanged() Harness.ReusesCachedOutputOnRerun(new ThingsGenerator()), "An IIncrementalGenerator that recomputes everything on every keystroke still passes every output assertion."); } + + [TestMethod] + public void TheRerunCheckCatchesAGeneratorThatRecomputesOnEveryEdit() + { + Assert.IsFalse( + Harness.ReusesCachedOutputOnRerun(new CompilationBoundGenerator()), + "A generator whose output hangs off the compilation recomputes on every keystroke, and the check must say so."); + } + + [TestMethod] + public void TheRerunCheckDoesNotPassAGeneratorWithNoOutputSteps() + { + Assert.IsFalse(Harness.ReusesCachedOutputOnRerun(new SilentGenerator())); + } } diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index c24da3f..28a5977 100644 --- a/SourceGeneratorToolkit.Test/TestGenerators.cs +++ b/SourceGeneratorToolkit.Test/TestGenerators.cs @@ -255,3 +255,25 @@ internal sealed class AbsentFileGenerator() : GeneratorBase("now protected override void Generate(SourceProductionContext context, ThingsMetadata metadata, CodeBlocker codeBlocker) => context.AddSource("Nowhere.g.cs", "// unreachable"); } + +/// +/// Derives its output from the compilation itself, so it recomputes on every edit: the +/// non-incremental generator the rerun check has to catch. +/// +internal sealed class CompilationBoundGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterSourceOutput( + context.CompilationProvider.Select((_, _) => new object()), + (productionContext, _) => productionContext.AddSource("x.g.cs", "// x")); +} + +/// +/// Registers no output at all, so the rerun check has nothing to prove incremental. +/// +internal sealed class SilentGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + } +} diff --git a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs index cdeb6ba..a05d4cf 100644 --- a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs +++ b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs @@ -89,15 +89,28 @@ public GeneratorDriverRunResult RunAll( Drive(generators, BuildTexts(overrides)); /// - /// Runs a generator twice over identical metadata and reports whether the second run reused the - /// first run's cached outputs. + /// Runs a generator twice over identical metadata, the second time against a new but equivalent + /// compilation, and reports whether the second run reused the first run's cached outputs. /// /// The generator to run. - /// True when no tracked output step had to be recomputed on the second run. + /// + /// True when the generator tracked at least one output step and none of them had to be + /// recomputed on the second run. + /// /// /// These are s, and output assertions do not check that they /// behave like one: a generator that recomputes everything on every keystroke still passes every /// output assertion, it just makes the IDE slow. + /// + /// A keystroke gives the generator a new while the metadata files are + /// untouched, so that is what the second run gets. Rerunning against the same compilation instance + /// would prove nothing: Roslyn compares incremental inputs by reference, so every step would be + /// reported as cached whatever the generator did with the compilation. + /// + /// + /// A generator with no tracked output steps has nothing to prove incremental, so it is reported + /// as not reusing its output rather than passing by default. + /// /// public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator) { @@ -112,12 +125,19 @@ public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator) driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); driver = driver.RunGenerators(compilation); - GeneratorDriverRunResult second = driver.RunGenerators(compilation).GetRunResult(); - return !second.Results[0].TrackedOutputSteps + // An empty tree changes nothing the generator could observe semantically, but it is a new + // Compilation instance, which is what an edit hands the generator. + CSharpCompilation edited = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(string.Empty)); + GeneratorDriverRunResult second = driver.RunGenerators(edited).GetRunResult(); + + List reasons = [.. second.Results[0].TrackedOutputSteps .SelectMany(pair => pair.Value) .SelectMany(step => step.Outputs) - .Any(output => output.Reason is not (IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + .Select(output => output.Reason)]; + + return reasons.Count > 0 + && reasons.All(reason => reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged); } private List BuildTexts(IReadOnlyDictionary? overrides)