From cbda91acad12a45c8dbf80dad527c0f7f190f3fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:26:18 +0000 Subject: [PATCH 1/2] Enable nullable in every generated file's header [patch] Roslyn compiles a file carrying the marker with the nullable context disabled, whatever the consuming project sets. Without a directive, every string? in WriteSourceFile output raised CS8669 and every unannotated member was null-oblivious to consumers. WriteFileHeader now writes #nullable enable after the marker, so WriteSourceFile inherits it. Fixes ktsu-dev/SourceGeneratorToolkit#55 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3EdFmyBthXBAkdCiZ --- README.md | 2 +- .../GeneratorBaseTests.cs | 43 ++++++++++++++++++- SourceGeneratorToolkit.Test/TestGenerators.cs | 29 +++++++++++++ SourceGeneratorToolkit/GeneratorBase.cs | 7 +++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efd9530..301602a 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ land in the test project's compile references and collide with the in-box types | `Initialize(IncrementalGeneratorInitializationContext)` | `void` | Finds the declared files and registers the source output. Sealed behaviour; do not reimplement. | | `Generate(SourceProductionContext, MetadataSet)` | `void` | Emits this generator's sources. Abstract. | | `CreateCodeBlocker()` | `CodeBlocker` | A `CodeBlocker` pinned to LF, so output is byte-identical on every platform. Static. | -| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line and the `` marker. A null or empty copyright writes only the marker. Static. | +| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line, the `` marker and `#nullable enable` (generated code otherwise compiles with nullable disabled). A null or empty copyright writes only the marker. Static. | | `WriteSourceFile(CodeBlocker, SourceFileTemplate, string?)` | `void` | Writes a whole source file, header included. Static. | ### `GeneratorBase` diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index 38ba293..5650de2 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -4,6 +4,7 @@ namespace ktsu.SourceGeneratorToolkit.Test; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using Microsoft.CodeAnalysis; @@ -293,10 +294,50 @@ public void AMultiLineCopyrightIsCommentedOnEveryLine(string lineBreak) { string source = HeaderProbe.Write($"Copyright (c) Example.{lineBreak}{lineBreak}All rights reserved."); - Assert.AreEqual("// Copyright (c) Example.\n//\n// All rights reserved.\n// \n\nnamespace Generated { }\n", source); + Assert.AreEqual("// Copyright (c) Example.\n//\n// All rights reserved.\n// \n#nullable enable\n\nnamespace Generated { }\n", source); Assert.AreEqual(0, CSharpSyntaxTree.ParseText(source).GetDiagnostics().Count(), source); } + [TestMethod] + public void TheHeaderEnablesNullableAfterTheGeneratedMarker() => + StringAssert.Contains(HeaderProbe.Write(null), "// \n#nullable enable\n"); + + [TestMethod] + public void AnnotatedGeneratedMembersCompileWithoutCS8669() + { + ImmutableArray diagnostics = CompileWithConsumer("int n = t.Description?.Length ?? 0;"); + + Assert.IsFalse(diagnostics.Any(d => d.Id == "CS8669"), string.Join("\n", diagnostics)); + } + + [TestMethod] + public void UnannotatedGeneratedMembersAreNotNullOblivious() + { + ImmutableArray diagnostics = CompileWithConsumer("t.Name = null;"); + + Assert.IsTrue(diagnostics.Any(d => d.Id == "CS8625"), string.Join("\n", diagnostics)); + } + + /// + /// Compiles a class holding a string? and a + /// string member, next to a consumer statement, the way a nullable-enabled project would. + /// + private static ImmutableArray CompileWithConsumer(string statement) + { + string generated = SourceFileProbe.WriteThingWithNullableAndNonNullableMembers(); + string consumer = $"namespace Consumer {{ public static class User {{ public static void Use(Generated.Thing t) {{ {statement} }} }} }}"; + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "NullableConsumer", + syntaxTrees: [CSharpSyntaxTree.ParseText(generated, path: "Thing.g.cs"), CSharpSyntaxTree.ParseText(consumer, path: "User.cs")], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + ImmutableArray diagnostics = compilation.GetDiagnostics(); + Assert.IsFalse(diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error), generated + "\n" + string.Join("\n", diagnostics)); + return diagnostics; + } + [TestMethod] public void TheGeneratorReusesItsOutputWhenNothingChanged() { diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index 7afef9c..568b33e 100644 --- a/SourceGeneratorToolkit.Test/TestGenerators.cs +++ b/SourceGeneratorToolkit.Test/TestGenerators.cs @@ -340,3 +340,32 @@ internal static string Write(string? copyright) return codeBlocker.ToString(); } } + +/// +/// Exposes the protected whole-file writer, so its output can be compiled alongside a consumer. +/// +internal abstract class SourceFileProbe : GeneratorBase +{ + /// + /// Writes Generated.Thing with a string? field and a string field. + /// + internal static string WriteThingWithNullableAndNonNullableMembers() + { + SourceFileTemplate file = new() { Namespace = "Generated" }; + file.Classes.Add(new ClassTemplate + { + Name = "Thing", + Kind = ktsu.CodeBlocker.Templates.TypeKind.Class, + Keywords = { CSharpKeywords.Public }, + Members = + { + new FieldTemplate { Name = "Description", Type = "string?", Keywords = { CSharpKeywords.Public } }, + new FieldTemplate { Name = "Name", Type = "string", DefaultValue = "\"\"", Keywords = { CSharpKeywords.Public } }, + }, + }); + + using CodeBlocker codeBlocker = CreateCodeBlocker(); + WriteSourceFile(codeBlocker, file, TestDiagnostics.Copyright); + return codeBlocker.ToString(); + } +} diff --git a/SourceGeneratorToolkit/GeneratorBase.cs b/SourceGeneratorToolkit/GeneratorBase.cs index 0ba1891..fa152ad 100644 --- a/SourceGeneratorToolkit/GeneratorBase.cs +++ b/SourceGeneratorToolkit/GeneratorBase.cs @@ -128,6 +128,12 @@ protected static CodeBlocker CreateCodeBlocker() => /// A parameter rather than a hard-coded literal, so a consuming repository can keep the header in /// step with its own file header template from one place, and so nothing about any one /// repository leaks into this package. + /// + /// The header ends with #nullable enable. Roslyn compiles a file carrying the + /// generated-file marker with the nullable context disabled, whatever the consuming project's + /// Nullable setting, so without the directive every ? annotation in the output + /// raises CS8669 and every unannotated reference member is null-oblivious to its callers. + /// /// /// is . protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright) @@ -143,6 +149,7 @@ protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright } codeBlocker.WriteLine("// "); + codeBlocker.WriteLine("#nullable enable"); codeBlocker.NewLine(); } From 8256749ed125495fd7c335303bfd1158a089053a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:26:27 +0000 Subject: [PATCH 2/2] Say what a null copyright leaves out of the header Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3EdFmyBthXBAkdCiZ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 301602a..3fb72f5 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ land in the test project's compile references and collide with the in-box types | `Initialize(IncrementalGeneratorInitializationContext)` | `void` | Finds the declared files and registers the source output. Sealed behaviour; do not reimplement. | | `Generate(SourceProductionContext, MetadataSet)` | `void` | Emits this generator's sources. Abstract. | | `CreateCodeBlocker()` | `CodeBlocker` | A `CodeBlocker` pinned to LF, so output is byte-identical on every platform. Static. | -| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line, the `` marker and `#nullable enable` (generated code otherwise compiles with nullable disabled). A null or empty copyright writes only the marker. Static. | +| `WriteFileHeader(CodeBlocker, string?)` | `void` | Writes the copyright line, the `` marker and `#nullable enable` (generated code otherwise compiles with nullable disabled). A null or empty copyright writes no copyright lines. Static. | | `WriteSourceFile(CodeBlocker, SourceFileTemplate, string?)` | `void` | Writes a whole source file, header included. Static. | ### `GeneratorBase`