Skip to content

Commit 4a6d791

Browse files
committed
[TypeGen]: fix stale output — global OutputDir, manifest regex, marker-based sweep, configurator invalidation
Four fixes for stale/non-updating generated files: 1. Global OutputDir fallback in FilePerClass mode — b.TypeScript(ts => ts.OutputDir = "client") now applies to all TS files that don't have an explicit per-type OutputDir. Previously only worked in SingleFile mode. Same fix applied to Zod and GraphQL emitters. 2. Manifest ReadField regex anchored to declaration pattern to prevent false matches inside Content fields. 3. Marker file (obj/.typegen-dirs) persists previous OutputDirs between builds so stale sweep can clean old directories after OutputDir rename. 4. ITypeGenConfigurator source text tracked in incremental pipeline so fluent config changes (e.g. OutputDir rename) invalidate Roslyn's generator cache and trigger manifest rebuild. 240/240 TypeGen tests pass.
1 parent 88491b4 commit 4a6d791

4 files changed

Lines changed: 63 additions & 42 deletions

File tree

‎packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/GraphQLEmitter.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
4040
}
4141
else
4242
{
43+
var globalGqlDir = gql.OutputDir;
4344
foreach (var en in model.Enums)
4445
{
4546
if ((en.Targets & TypeTarget.GraphQL) == 0) continue;
@@ -50,7 +51,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
5051
EmitEnum(sb, en);
5152
files.Add(new EmittedFile(
5253
Target: TypeTarget.GraphQL,
53-
OutputDir: en.OutputDir,
54+
OutputDir: !string.IsNullOrEmpty(globalGqlDir) && en.OutputDir == "." ? globalGqlDir : en.OutputDir,
5455
FileName: (en.EmittedName ?? en.SourceName) + ".graphql",
5556
Content: sb.ToString()));
5657
}
@@ -65,7 +66,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
6566
EmitType(sb, cls, model);
6667
files.Add(new EmittedFile(
6768
Target: TypeTarget.GraphQL,
68-
OutputDir: cls.OutputDir,
69+
OutputDir: !string.IsNullOrEmpty(globalGqlDir) && cls.OutputDir == "." ? globalGqlDir : cls.OutputDir,
6970
FileName: (cls.EmittedName ?? cls.SourceName) + ".graphql",
7071
Content: sb.ToString()));
7172
}

‎packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TypeScriptEmitter.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
7979
}
8080
else
8181
{
82+
// Global ts.OutputDir is the fallback when a class/enum doesn't have
83+
// an explicit per-type OutputDir (attribute or fluent). Without this,
84+
// b.TypeScript(ts => ts.OutputDir = "client") has no effect in
85+
// FilePerClass mode — only SingleFile mode would respect it.
86+
var globalTsDir = ts.OutputDir;
8287
foreach (var cls in model.Classes)
8388
{
8489
if (cls.TsIgnore || (cls.Targets & TypeTarget.TypeScript) == 0) continue;
@@ -88,7 +93,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
8893
EmitClass(sb, cls, ts, tsNameByCSharp, model);
8994
files.Add(new EmittedFile(
9095
Target: TypeTarget.TypeScript,
91-
OutputDir: cls.OutputDir,
96+
OutputDir: !string.IsNullOrEmpty(globalTsDir) && cls.OutputDir == "." ? globalTsDir : cls.OutputDir,
9297
FileName: cls.EmittedName + ".ts",
9398
Content: sb.ToString()));
9499
}
@@ -100,7 +105,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
100105
EmitEnum(sb, en, ts);
101106
files.Add(new EmittedFile(
102107
Target: TypeTarget.TypeScript,
103-
OutputDir: en.OutputDir,
108+
OutputDir: !string.IsNullOrEmpty(globalTsDir) && en.OutputDir == "." ? globalTsDir : en.OutputDir,
104109
FileName: en.EmittedName + ".ts",
105110
Content: sb.ToString()));
106111
}

‎packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
7272
}
7373
else
7474
{
75+
var globalZodDir = zs.OutputDir;
7576
foreach (var cls in model.Classes)
7677
{
7778
if (SkipClass(cls)) continue;
@@ -83,7 +84,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
8384
EmitClass(sb, cls, zs, nameByCSharp, model);
8485
files.Add(new EmittedFile(
8586
Target: TypeTarget.Zod,
86-
OutputDir: cls.OutputDir,
87+
OutputDir: !string.IsNullOrEmpty(globalZodDir) && cls.OutputDir == "." ? globalZodDir : cls.OutputDir,
8788
FileName: cls.EmittedName + zs.FileSuffix + ".ts",
8889
Content: sb.ToString()));
8990
}
@@ -97,7 +98,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
9798
EmitEnum(sb, en, zs);
9899
files.Add(new EmittedFile(
99100
Target: TypeTarget.Zod,
100-
OutputDir: en.OutputDir,
101+
OutputDir: !string.IsNullOrEmpty(globalZodDir) && en.OutputDir == "." ? globalZodDir : en.OutputDir,
101102
FileName: en.EmittedName + zs.FileSuffix + ".ts",
102103
Content: sb.ToString()));
103104
}

‎packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/TypeGenGenerator.cs‎

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,27 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
5454
provider.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var dir)
5555
? dir : "");
5656

57-
var combined = classes.Collect().Combine(compilation).Combine(projectDir);
57+
// Track ITypeGenConfigurator implementations so changes to fluent config
58+
// (e.g. OutputDir rename) invalidate the incremental pipeline even when no
59+
// [GenerateTypes] class changed. Without this, Roslyn skips re-running the
60+
// callback and the manifest stays stale.
61+
var configurators = context.SyntaxProvider.CreateSyntaxProvider(
62+
predicate: static (node, _) => node is ClassDeclarationSyntax,
63+
transform: static (ctx, _) =>
64+
{
65+
if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not INamedTypeSymbol symbol) return null;
66+
foreach (var iface in symbol.AllInterfaces)
67+
if (iface.Name == "ITypeGenConfigurator") return ctx.Node.ToFullString();
68+
return null;
69+
})
70+
.Where(static s => s is not null)
71+
.Collect();
72+
73+
var combined = classes.Collect().Combine(compilation).Combine(projectDir).Combine(configurators);
5874

5975
context.RegisterSourceOutput(combined, static (spc, source) =>
6076
{
61-
var ((symbols, compilation), projectDir) = source;
77+
var (((symbols, compilation), projectDir), _configuratorSources) = source;
6278
// Note: do NOT early-return on empty symbols — the configurator's
6379
// .WithGeneratedTypes(...) fluent discovery (handled below) is a valid
6480
// sole driver of generation. The bottom of this callback exits anyway
@@ -358,51 +374,49 @@ private static void TryWriteFilesDirectly(IReadOnlyList<EmittedFile> files, stri
358374
try { touchedDirs.Add(System.IO.Path.GetFullPath(fullDir)); } catch { /* bad path, skip */ }
359375
}
360376

361-
// Also sweep parent directories of each touchedDir — catches stale files
362-
// from previous OutputDir values that share a common ancestor. For example,
363-
// if OutputDir changed from "generated/ts" to "generated/client", the parent
364-
// "generated/" is the same, so we also sweep "generated/ts/".
365-
var parentDirs = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
366-
foreach (var td in touchedDirs)
377+
// Load previously written directories from marker file and add them to
378+
// the sweep set. This catches stale files when OutputDir changes (e.g.
379+
// "generated" → "generated1" — old dir must be swept even though no
380+
// current file targets it).
381+
var markerPath = !string.IsNullOrEmpty(projectDir)
382+
? System.IO.Path.Combine(projectDir, "obj", ".typegen-dirs")
383+
: null;
384+
if (markerPath != null)
367385
{
368386
try
369387
{
370-
var parent = System.IO.Directory.GetParent(td)?.FullName;
371-
if (parent != null && !string.Equals(System.IO.Path.GetFullPath(parent),
372-
System.IO.Path.GetFullPath(projectDir), System.StringComparison.OrdinalIgnoreCase))
373-
parentDirs.Add(parent);
388+
if (System.IO.File.Exists(markerPath))
389+
{
390+
foreach (var line in System.IO.File.ReadAllLines(markerPath))
391+
{
392+
if (string.IsNullOrWhiteSpace(line)) continue;
393+
var prev = System.IO.Path.IsPathRooted(line) ? line : System.IO.Path.Combine(projectDir, line);
394+
try { touchedDirs.Add(System.IO.Path.GetFullPath(prev)); } catch { }
395+
}
396+
}
374397
}
375-
catch { /* bad path */ }
398+
catch { }
376399
}
377-
foreach (var parent in parentDirs)
400+
401+
// Persist current directories for the next run's stale sweep.
402+
if (markerPath != null)
378403
{
379404
try
380405
{
381-
foreach (var sibling in System.IO.Directory.EnumerateDirectories(parent))
406+
var relative = new List<string>();
407+
var projFull = System.IO.Path.GetFullPath(projectDir);
408+
foreach (var td in touchedDirs)
382409
{
383-
var fullSibling = System.IO.Path.GetFullPath(sibling);
384-
if (touchedDirs.Contains(fullSibling)) continue;
385-
// Check if sibling contains any file with our banner.
386-
foreach (var candidate in System.IO.Directory.EnumerateFiles(sibling))
387-
{
388-
try
389-
{
390-
using var fs = new System.IO.FileStream(candidate,
391-
System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read);
392-
var buf = new byte[200];
393-
int n = fs.Read(buf, 0, buf.Length);
394-
var head = System.Text.Encoding.UTF8.GetString(buf, 0, n);
395-
if (head.IndexOf("@generated by ZibStack.NET.TypeGen", System.StringComparison.Ordinal) >= 0)
396-
{
397-
touchedDirs.Add(fullSibling);
398-
break;
399-
}
400-
}
401-
catch { }
402-
}
410+
if (td.StartsWith(projFull, System.StringComparison.OrdinalIgnoreCase))
411+
relative.Add(td.Substring(projFull.Length).TrimStart(
412+
System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
413+
else
414+
relative.Add(td);
403415
}
416+
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(markerPath)!);
417+
System.IO.File.WriteAllLines(markerPath, relative);
404418
}
405-
catch { /* parent enumeration failed — rely on MSBuild task sweep */ }
419+
catch { }
406420
}
407421

408422
bool anyWriteFailed = false;

0 commit comments

Comments
 (0)