From 938be2624003d6c03169c66cf5d827e306153bf5 Mon Sep 17 00:00:00 2001 From: RejectKid Date: Fri, 6 Nov 2026 16:40:04 -0500 Subject: [PATCH] [TypeGen]: clear stale manifests when generation becomes empty --- .../docs/packages/typegen/configuration.md | 29 +++ .../ZibStack.NET.TypeGen/TypeGenGenerator.cs | 42 ++-- .../EmptyManifestBuildTests.cs | 215 ++++++++++++++++++ 3 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/EmptyManifestBuildTests.cs diff --git a/docs/src/content/docs/packages/typegen/configuration.md b/docs/src/content/docs/packages/typegen/configuration.md index c71a81e..a5d2a41 100644 --- a/docs/src/content/docs/packages/typegen/configuration.md +++ b/docs/src/content/docs/packages/typegen/configuration.md @@ -176,6 +176,35 @@ stale files in the touched directories: any file carrying our `@generated` banner that isn't in the current emit set gets deleted, so renames don't leak orphaned outputs. +### Removing the last generation request + +When a successful generation pass has no output, TypeGen still emits a valid +manifest with `FileCount = 0`. Removing the final `[GenerateTypes]` attribute +or fluent `.WithGeneratedTypes(...)` registration therefore replaces the +previous manifest rather than leaving it available for MSBuild to replay. +An emitter that legitimately produces no files follows the same path. + +Both live writes and the MSBuild task use `obj/.typegen-dirs` to find previously +used output directories and remove files bearing the TypeGen banner. Handwritten +files are preserved. Subsequent builds cannot resurrect old output from the +empty manifest, even when compilation is skipped. Compilation errors or fatal +TypeGen diagnostics that prevent generation are not treated as successful empty +runs and do not authorize this cleanup. + +After upgrading from a version that retained stale manifests, run +`dotnet build -t:Rebuild` once for each affected project to replace its cached +manifest. A configurator in another project does not update this project's +manifest. If the TypeGen package has already been removed entirely, its generator +and cleanup task cannot run; clear the affected project's old `obj` cache and +remove obsolete generated files manually. + +Live cleanup requires an IDE host that reruns the generator and permits analyzer +file I/O; otherwise build the project to run the MSBuild cleanup. Files generated +with banners disabled are not automatically removed. Ownership remains +banner-based and scoped to directories rather than individual projects: use +separate output directories for different projects, since sharing one can cause +one project's sweep to remove another project's generated files. + No companion task DLL, no reflection over the built assembly — the inline task is ~60 lines of C# directly in the `.targets` file. Files are skipped when their content is unchanged (stable mtimes, no file-watcher thrash in frontend dev servers). diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs index 4685368..e660d94 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs @@ -77,10 +77,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var (((symbols, compilation), projectDir), _configuratorSources) = source; // Note: do NOT early-return on empty symbols — the configurator's // .WithGeneratedTypes(...) fluent discovery (handled below) is a valid - // sole driver of generation. The bottom of this callback exits anyway - // if nothing accumulates in the model. + // sole driver of generation. A successful empty run must replace the + // persisted manifest too, otherwise MSBuild replays the previous run. if (symbols.IsDefault) symbols = ImmutableArray.Empty; + bool hasGenerationErrors = false; + void ReportDiagnostic(Diagnostic diagnostic) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) hasGenerationErrors = true; + spc.ReportDiagnostic(diagnostic); + } + // Warn about [CrudApi]-only classes that TypeGen can't emit paths for — // they need [GenerateTypes] so we discover them via the same provider. // (Actually they're already in `symbols` thanks to the dual predicate above, @@ -89,7 +96,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { if (sym is null) continue; if (!SchemaParser.HasGenerateTypes(sym) && SchemaParser.HasCrudApi(sym)) - spc.ReportDiagnostic(Diagnostic.Create( + ReportDiagnostic(Diagnostic.Create( TypeGenDiagnostics.CrudApiWithoutGenerateTypes, sym.Locations.FirstOrDefault() ?? Location.None, sym.ToDisplayString())); @@ -97,7 +104,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Parse ITypeGenConfigurator DSL first — global settings feed the emitters, // per-type overrides merge into SchemaClass/SchemaEnum below. - var config = ConfiguratorParser.Parse(compilation, spc.ReportDiagnostic); + var config = ConfiguratorParser.Parse(compilation, ReportDiagnostic); var model = new SchemaModel(); foreach (var sym in symbols) @@ -108,7 +115,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // instead of emitting broken output. if (sym.IsGenericType) { - spc.ReportDiagnostic(Diagnostic.Create( + ReportDiagnostic(Diagnostic.Create( TypeGenDiagnostics.GenericType, sym.Locations.FirstOrDefault() ?? Location.None, sym.ToDisplayString())); @@ -120,8 +127,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var en = SchemaParser.ParseEnum(sym); if (en is null) continue; ApplyFluentToEnum(en, config); - ReportTargetsIfEmpty(spc, sym, en.Targets); - ReportInvalidOutputDirIfEmpty(spc, sym, en.OutputDir); + ReportTargetsIfEmpty(ReportDiagnostic, sym, en.Targets); + ReportInvalidOutputDirIfEmpty(ReportDiagnostic, sym, en.OutputDir); model.Enums.Add(en); } else @@ -129,8 +136,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var cls = SchemaParser.ParseClass(sym); if (cls is null) continue; ApplyFluentToClass(cls, config); - ReportTargetsIfEmpty(spc, sym, cls.Targets); - ReportInvalidOutputDirIfEmpty(spc, sym, cls.OutputDir); + ReportTargetsIfEmpty(ReportDiagnostic, sym, cls.Targets); + ReportInvalidOutputDirIfEmpty(ReportDiagnostic, sym, cls.OutputDir); model.Classes.Add(cls); // Synthesize Create/Update/Response companion schemas from Dto's attributes. @@ -312,8 +319,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // so target names / OutputDirs are final. SchemaParser.ResolveGenericTypeReferences(model); - if (model.Classes.Count == 0 && model.Enums.Count == 0) return; - // Surface every property whose C# type won't render into a sane TS / // OpenAPI expression (fell back to `unknown` / `object` because the type // isn't primitive, isn't in the model, isn't overridden, isn't ignored). @@ -349,7 +354,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (RequestsTarget(model, TypeTarget.TanStackQuery)) allFiles.AddRange(TanStackQueryEmitter.Emit(model, settings)); - if (allFiles.Count == 0) return; + // Empty is an authoritative result only when generation succeeded. + // In particular an incomplete IDE edit / failed configurator must not + // be mistaken for removing the last generation request. + if (allFiles.Count == 0 && (hasGenerationErrors || + compilation.GetDiagnostics(spc.CancellationToken).Any(d => d.Severity == DiagnosticSeverity.Error))) + return; // Emit the manifest as a .g.cs containing string constants. The companion // MSBuild task reads this file at build time, decodes the constants, and @@ -699,19 +709,19 @@ private static bool RequestsTarget(SchemaModel model, TypeTarget target) return false; } - private static void ReportTargetsIfEmpty(SourceProductionContext spc, ISymbol sym, TypeTarget targets) + private static void ReportTargetsIfEmpty(System.Action report, ISymbol sym, TypeTarget targets) { if (targets == TypeTarget.None) - spc.ReportDiagnostic(Diagnostic.Create( + report(Diagnostic.Create( TypeGenDiagnostics.NoTargets, sym.Locations.FirstOrDefault() ?? Location.None, sym.Name)); } - private static void ReportInvalidOutputDirIfEmpty(SourceProductionContext spc, ISymbol sym, string outputDir) + private static void ReportInvalidOutputDirIfEmpty(System.Action report, ISymbol sym, string outputDir) { if (string.IsNullOrWhiteSpace(outputDir)) - spc.ReportDiagnostic(Diagnostic.Create( + report(Diagnostic.Create( TypeGenDiagnostics.InvalidOutputDir, sym.Locations.FirstOrDefault() ?? Location.None, outputDir ?? "", diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/EmptyManifestBuildTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/EmptyManifestBuildTests.cs new file mode 100644 index 0000000..ea36dd5 --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/EmptyManifestBuildTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Security; +using System.Threading.Tasks; +using Xunit; + +namespace TypeGenTests; + +/// Real NuGet consumers: retain obj across builds and exercise the shipped +/// props/targets, including runs where analyzer-side live writes are unavailable. +public class EmptyManifestBuildTests : IClassFixture +{ + private readonly TypeGenPackageFixture _package; + public EmptyManifestBuildTests(TypeGenPackageFixture package) => _package = package; + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task RemovingLastRequest_ReplacesManifestAndCleansOwnedFiles(bool fluent, bool liveWrites) + { + var dir = _package.CreateConsumer(liveWrites); + string Source(string output, bool example, bool other) => + "using ZibStack.NET.TypeGen;\n" + + (!fluent && example ? $"[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod, OutputDir = \"{output}\")]\n" : "") + + "public class Example { public string Name { get; set; } = \"\"; }\n" + + (!fluent && other ? $"[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod, OutputDir = \"{output}\")]\n" : "") + + "public class Other { public int Id { get; set; } }\n" + + (fluent ? $$""" + public class Config : ITypeGenConfigurator { + public void Configure(ITypeGenBuilder b) { + b.TypeScript(t => { t.OutputDir = "{{output}}"; }); + b.Zod(z => { z.OutputDir = "{{output}}"; }); + {{(example ? "b.ForType().WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.Zod);" : "")}} + {{(other ? "b.ForType().WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.Zod);" : "")}} + } + } + """ : ""); + + async Task Build(string? source = null, bool expectEmptyLiveOutput = false) + { + if (source != null) await File.WriteAllTextAsync(Path.Combine(dir, "Models.cs"), source); + await TypeGenPackageFixture.Run("build Consumer.csproj --nologo" + + (expectEmptyLiveOutput ? " -p:ExpectEmptyLiveOutput=true" : ""), dir); + } + string FileAt(string output, string file) => Path.Combine(dir, output, file); + await Build(Source("generated", true, true)); + var editorConfig = Assert.Single(Directory.GetFiles(Path.Combine(dir, "obj"), "*.GeneratedMSBuildEditorConfig.editorconfig", SearchOption.AllDirectories)); + Assert.Equal(liveWrites, File.ReadAllText(editorConfig).Contains("build_property.MSBuildProjectDirectory =")); + var original = File.ReadAllText(FileAt("generated", "Example.ts")); + Assert.True(File.Exists(FileAt("generated", "Example.schema.ts"))); + var timestamp = File.GetLastWriteTimeUtc(FileAt("generated", "Example.ts")); + // Force compilation while keeping generator output identical. + await Build(Source("generated", true, true) + "\n// unchanged output\n"); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(FileAt("generated", "Example.ts"))); + await File.WriteAllTextAsync(FileAt("generated", "handwritten.ts"), "export const keep = true;"); + + await Build(Source("renamed", true, true)); + Assert.False(File.Exists(FileAt("generated", "Example.ts"))); + Assert.True(File.Exists(FileAt("generated", "handwritten.ts"))); + await File.WriteAllTextAsync(FileAt("renamed", "handwritten.ts"), "export const keep = true;"); + await Build(Source("renamed", false, true)); + Assert.False(File.Exists(FileAt("renamed", "Example.ts"))); + Assert.True(File.Exists(FileAt("renamed", "Other.ts"))); + Assert.True(File.Exists(FileAt("renamed", "Other.schema.ts"))); + + // Reproduce deleting output while retaining obj before the empty run. + File.Delete(FileAt("renamed", "Other.schema.ts")); + await Build(Source("renamed", false, false), expectEmptyLiveOutput: liveWrites); + var manifest = Assert.Single(Directory.GetFiles(Path.Combine(dir, "obj"), "ZibStack.TypeGen.Manifest.g.cs", SearchOption.AllDirectories)); + Assert.Contains("FileCount = 0;", File.ReadAllText(manifest)); + Assert.False(File.Exists(FileAt("renamed", "Other.ts"))); + Assert.False(File.Exists(FileAt("renamed", "Other.schema.ts"))); + Assert.True(File.Exists(FileAt("renamed", "handwritten.ts"))); + + // The empty manifest must also drive MSBuild's cleanup on a skipped + // compilation (live writes cannot mask that path). + await File.WriteAllTextAsync(FileAt("generated", "Example.ts"), original); + await Build(); + Assert.False(File.Exists(FileAt("generated", "Example.ts"))); + await Build(); + Assert.False(File.Exists(FileAt("renamed", "Other.ts"))); + Assert.True(File.Exists(FileAt("generated", "handwritten.ts"))); + Assert.True(File.Exists(FileAt("renamed", "handwritten.ts"))); + } + + [Fact] + public async Task EmptyEmitterResult_CleansOutput_ButFailedGenerationDoesNot() + { + var dir = _package.CreateConsumer(liveWrites: true); + var sourcePath = Path.Combine(dir, "Models.cs"); + const string initial = """ + using ZibStack.NET.TypeGen; + [GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod, OutputDir = "generated")] + public class Example { public string Name { get; set; } = ""; } + """; + await File.WriteAllTextAsync(sourcePath, initial); + await TypeGenPackageFixture.Run("build --nologo", dir); + var output = Path.Combine(dir, "generated", "Example.ts"); + var original = File.ReadAllText(output); + var manifest = Assert.Single(Directory.GetFiles(Path.Combine(dir, "obj"), "ZibStack.TypeGen.Manifest.g.cs", SearchOption.AllDirectories)); + var originalManifest = File.ReadAllText(manifest); + foreach (var invalid in new[] + { + // No discovered model due to a temporarily broken compilation. + "public class Broken {", + // Parser error with no model; must not masquerade as an empty run. + """ + using ZibStack.NET.TypeGen; + public class First : ITypeGenConfigurator { public void Configure(ITypeGenBuilder b) {} } + public class Second : ITypeGenConfigurator { public void Configure(ITypeGenBuilder b) {} } + """, + // Generator rejects the only requested type. + """ + using ZibStack.NET.TypeGen; + [GenerateTypes(Targets = TypeTarget.TypeScript, OutputDir = "generated")] + public class Unsupported { } + """, + // Existing fatal Zod validation path must continue to preserve output. + """ + using ZibStack.NET.TypeGen; + [GenerateTypes(Targets = TypeTarget.Zod, OutputDir = "generated")] + public class Example { + [ZodSchema("z.string()", ImportFrom = "zod")] + public string Name { get; set; } = ""; + } + """, + }) + { + await File.WriteAllTextAsync(sourcePath, invalid); + await TypeGenPackageFixture.Run("build --nologo", dir, success: false); + Assert.Equal(original, File.ReadAllText(output)); + Assert.Equal(originalManifest, File.ReadAllText(manifest)); + } + + // A model exists, but the only requested emitter has no endpoints. + // This covers the second former early-return (allFiles.Count == 0). + await File.WriteAllTextAsync(sourcePath, """ + using ZibStack.NET.TypeGen; + [GenerateTypes(Targets = TypeTarget.TanStackQuery, OutputDir = "generated")] + public class Example { public string Name { get; set; } = ""; } + """); + await TypeGenPackageFixture.Run("build --nologo", dir); + Assert.Contains("FileCount = 0;", File.ReadAllText(manifest)); + Assert.False(File.Exists(output)); + Assert.False(File.Exists(Path.Combine(dir, "generated", "Example.schema.ts"))); + } +} + +public sealed class TypeGenPackageFixture : IDisposable +{ + private const string Version = "0.0.0-empty-manifest-tests"; + private readonly string _root = Path.Combine(Path.GetTempPath(), "typegen-package-" + Guid.NewGuid().ToString("N")); + public TypeGenPackageFixture() + { + var repo = new DirectoryInfo(AppContext.BaseDirectory); + while (repo != null && !File.Exists(Path.Combine(repo.FullName, "ZibStack.NET.slnx"))) repo = repo.Parent; + Assert.NotNull(repo); + Directory.CreateDirectory(Path.Combine(_root, "feed")); + var src = Path.Combine(repo!.FullName, "packages", "ZibStack.NET.TypeGen", "src"); + var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent!.Name; + foreach (var name in new[] { "ZibStack.NET.TypeGen.Abstractions", "ZibStack.NET.TypeGen" }) + Run($"pack \"{Path.Combine(src, name, name + ".csproj")}\" -c {configuration} --no-build -p:PackageVersion={Version} -o \"{Path.Combine(_root, "feed")}\"", repo.FullName).GetAwaiter().GetResult(); + } + + public string CreateConsumer(bool liveWrites) + { + var dir = Path.Combine(_root, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "Consumer.csproj"), $$""" + + + net10.0 + false + {{SecurityElement.Escape(Path.Combine(_root, "feed"))}} + {{SecurityElement.Escape(Path.Combine(_root, "packages"))}} + + + + + + + + + + """); + return dir; + } + + public static async Task Run(string args, string dir, bool success = true) + { + var start = new ProcessStartInfo("dotnet", args) + { + WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true, + UseShellExecute = false, CreateNoWindow = true, + }; + start.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + start.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromMinutes(2)); + try { await process.WaitForExitAsync(timeout.Token); } + catch (OperationCanceledException) { process.Kill(entireProcessTree: true); throw; } + var output = await stdout + await stderr; + Assert.True((process.ExitCode == 0) == success, $"dotnet {args}\n{output}"); + return output; + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + } +}