diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ae255b5..1d24ebb 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(dotnet test:*)",
- "Bash(dotnet build:*)"
+ "Bash(dotnet build:*)",
+ "Bash(ls:*)",
+ "Bash(dotnet publish:*)"
]
}
}
diff --git a/src/MinimalWorker.Generators/InvocationModel.cs b/src/MinimalWorker.Generators/InvocationModel.cs
index 5c64b20..bdb43bf 100644
--- a/src/MinimalWorker.Generators/InvocationModel.cs
+++ b/src/MinimalWorker.Generators/InvocationModel.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
namespace MinimalWorker.Generators;
@@ -15,6 +16,26 @@ internal sealed class WorkerInvocationModel
public string ReturnType { get; set; } = "Task";
public WorkerType Type { get; set; }
public string? ScheduleArgument { get; set; } // TimeSpan or cron expression
+
+ ///
+ /// Diagnostics collected during analysis. Reported during code generation.
+ ///
+ public List Diagnostics { get; set; } = new();
+
+ ///
+ /// Indicates whether the model has errors that prevent code generation.
+ ///
+ public bool HasErrors { get; set; }
+}
+
+///
+/// Represents a diagnostic to be reported during code generation.
+///
+internal sealed class DiagnosticInfo
+{
+ public DiagnosticDescriptor Descriptor { get; set; } = null!;
+ public Location Location { get; set; } = Location.None;
+ public object?[]? MessageArgs { get; set; }
}
internal sealed class ParameterModel
diff --git a/src/MinimalWorker.Generators/WorkerEmitter.cs b/src/MinimalWorker.Generators/WorkerEmitter.cs
index 84ef1c6..9bd4ce6 100644
--- a/src/MinimalWorker.Generators/WorkerEmitter.cs
+++ b/src/MinimalWorker.Generators/WorkerEmitter.cs
@@ -396,6 +396,18 @@ private static void EmitContinuousWorkerInit(StringBuilder sb, WorkerInvocationM
sb.AppendLine();
sb.AppendLine(" // Start worker immediately - host has already started");
sb.AppendLine(" var token = lifetime.ApplicationStopping;");
+ sb.AppendLine(" var workerId = registration.Id.ToString();");
+ sb.AppendLine(" var workerName = registration.DisplayName;");
+ sb.AppendLine();
+ sb.AppendLine(" // Cache service lookups at initialization (outside Task.Run)");
+ sb.AppendLine(" var workerLogger = host.Services.GetService()?.CreateLogger($\"MinimalWorker.{workerName}\");");
+ sb.AppendLine();
+ sb.AppendLine(" // Register worker for status tracking");
+ sb.AppendLine(" MinimalWorkerObservability.RegisterWorker(workerId, workerName, \"continuous\");");
+ sb.AppendLine();
+ sb.AppendLine(" // Log worker started");
+ sb.AppendLine(" if (workerLogger != null) WorkerLogMessages.WorkerStarted(workerLogger, workerName, \"continuous\", workerId, null);");
+ sb.AppendLine();
sb.AppendLine(" _ = Task.Run(async () =>");
sb.AppendLine(" {");
sb.AppendLine(" using var scope = host.Services.CreateScope();");
@@ -413,18 +425,6 @@ private static void EmitContinuousWorkerInit(StringBuilder sb, WorkerInvocationM
}
}
- sb.AppendLine();
- sb.AppendLine(" var workerId = registration.Id.ToString();");
- sb.AppendLine(" var workerName = registration.DisplayName;");
- sb.AppendLine();
- sb.AppendLine(" // Get logger for structured logging");
- sb.AppendLine(" var workerLogger = scope.ServiceProvider.GetService()?.CreateLogger($\"MinimalWorker.{workerName}\");");
- sb.AppendLine();
- sb.AppendLine(" // Register worker for status tracking");
- sb.AppendLine(" MinimalWorkerObservability.RegisterWorker(workerId, workerName, \"continuous\");");
- sb.AppendLine();
- sb.AppendLine(" // Log worker started");
- sb.AppendLine(" if (workerLogger != null) WorkerLogMessages.WorkerStarted(workerLogger, workerName, \"continuous\", workerId, null);");
sb.AppendLine();
sb.AppendLine(" var tags = new TagList");
sb.AppendLine(" {");
@@ -472,13 +472,9 @@ private static void EmitContinuousWorkerInit(StringBuilder sb, WorkerInvocationM
sb.AppendLine(" activity?.SetStatus(ActivityStatusCode.Error, ex.Message);");
sb.AppendLine(" activity?.RecordException(ex);");
sb.AppendLine();
- sb.AppendLine(" var errorTags = new TagList");
- sb.AppendLine(" {");
- sb.AppendLine(" { \"worker.id\", workerId },");
- sb.AppendLine(" { \"worker.name\", workerName },");
- sb.AppendLine(" { \"worker.type\", \"continuous\" },");
- sb.AppendLine(" { \"exception.type\", ex.GetType().FullName }");
- sb.AppendLine(" };");
+ sb.AppendLine(" // Copy base tags struct and add exception type (avoids full allocation in catch)");
+ sb.AppendLine(" var errorTags = tags;");
+ sb.AppendLine(" errorTags.Add(\"exception.type\", ex.GetType().FullName);");
sb.AppendLine(" MinimalWorkerObservability.ErrorCounter.Add(1, errorTags);");
sb.AppendLine(" MinimalWorkerObservability.RecordFailure(workerId);");
sb.AppendLine();
@@ -533,8 +529,9 @@ private static void EmitPeriodicWorkerInit(StringBuilder sb, WorkerInvocationMod
sb.AppendLine(" var workerName = registration.DisplayName;");
sb.AppendLine(" var token = lifetime.ApplicationStopping;");
sb.AppendLine();
- sb.AppendLine(" // Get logger for structured logging");
+ sb.AppendLine(" // Cache service lookups at initialization (outside Task.Run)");
sb.AppendLine(" var workerLogger = host.Services.GetService()?.CreateLogger($\"MinimalWorker.{workerName}\");");
+ sb.AppendLine(" var timeProvider = host.Services.GetService() ?? TimeProvider.System;");
sb.AppendLine();
sb.AppendLine(" // Register worker for status tracking");
sb.AppendLine(" MinimalWorkerObservability.RegisterWorker(workerId, workerName, \"periodic\");");
@@ -553,9 +550,6 @@ private static void EmitPeriodicWorkerInit(StringBuilder sb, WorkerInvocationMod
sb.AppendLine(" };");
sb.AppendLine(" var scheduleString = schedule.ToString();");
sb.AppendLine();
- sb.AppendLine(" // Resolve TimeProvider from DI, fallback to system time");
- sb.AppendLine(" var timeProvider = host.Services.GetService() ?? TimeProvider.System;");
- sb.AppendLine();
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" using var timer = new PeriodicTimer(schedule, timeProvider);");
@@ -618,13 +612,9 @@ private static void EmitPeriodicWorkerInit(StringBuilder sb, WorkerInvocationMod
sb.AppendLine(" activity?.SetStatus(ActivityStatusCode.Error, ex.Message);");
sb.AppendLine(" activity?.RecordException(ex);");
sb.AppendLine();
- sb.AppendLine(" var errorTags = new TagList");
- sb.AppendLine(" {");
- sb.AppendLine(" { \"worker.id\", workerId },");
- sb.AppendLine(" { \"worker.name\", workerName },");
- sb.AppendLine(" { \"worker.type\", \"periodic\" },");
- sb.AppendLine(" { \"exception.type\", ex.GetType().FullName }");
- sb.AppendLine(" };");
+ sb.AppendLine(" // Copy base tags struct and add exception type (avoids full allocation in catch)");
+ sb.AppendLine(" var errorTags = tags;");
+ sb.AppendLine(" errorTags.Add(\"exception.type\", ex.GetType().FullName);");
sb.AppendLine(" MinimalWorkerObservability.ErrorCounter.Add(1, errorTags);");
sb.AppendLine(" MinimalWorkerObservability.RecordFailure(workerId);");
sb.AppendLine();
@@ -689,8 +679,9 @@ private static void EmitCronWorkerInit(StringBuilder sb, WorkerInvocationModel w
sb.AppendLine(" var workerName = registration.DisplayName;");
sb.AppendLine(" var token = lifetime.ApplicationStopping;");
sb.AppendLine();
- sb.AppendLine(" // Get logger for structured logging");
+ sb.AppendLine(" // Cache service lookups at initialization (outside Task.Run)");
sb.AppendLine(" var workerLogger = host.Services.GetService()?.CreateLogger($\"MinimalWorker.{workerName}\");");
+ sb.AppendLine(" var timeProvider = host.Services.GetService() ?? TimeProvider.System;");
sb.AppendLine();
sb.AppendLine(" // Register worker for status tracking");
sb.AppendLine(" MinimalWorkerObservability.RegisterWorker(workerId, workerName, \"cron\");");
@@ -708,9 +699,6 @@ private static void EmitCronWorkerInit(StringBuilder sb, WorkerInvocationModel w
sb.AppendLine(" { \"worker.type\", \"cron\" }");
sb.AppendLine(" };");
sb.AppendLine();
- sb.AppendLine(" // Resolve TimeProvider from DI, fallback to system time");
- sb.AppendLine(" var timeProvider = host.Services.GetService() ?? TimeProvider.System;");
- sb.AppendLine();
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" while (!token.IsCancellationRequested)");
@@ -785,13 +773,9 @@ private static void EmitCronWorkerInit(StringBuilder sb, WorkerInvocationModel w
sb.AppendLine(" activity?.SetStatus(ActivityStatusCode.Error, ex.Message);");
sb.AppendLine(" activity?.RecordException(ex);");
sb.AppendLine();
- sb.AppendLine(" var errorTags = new TagList");
- sb.AppendLine(" {");
- sb.AppendLine(" { \"worker.id\", workerId },");
- sb.AppendLine(" { \"worker.name\", workerName },");
- sb.AppendLine(" { \"worker.type\", \"cron\" },");
- sb.AppendLine(" { \"exception.type\", ex.GetType().FullName }");
- sb.AppendLine(" };");
+ sb.AppendLine(" // Copy base tags struct and add exception type (avoids full allocation in catch)");
+ sb.AppendLine(" var errorTags = tags;");
+ sb.AppendLine(" errorTags.Add(\"exception.type\", ex.GetType().FullName);");
sb.AppendLine(" MinimalWorkerObservability.ErrorCounter.Add(1, errorTags);");
sb.AppendLine(" MinimalWorkerObservability.RecordFailure(workerId);");
sb.AppendLine();
diff --git a/src/MinimalWorker.Generators/WorkerGenerator.cs b/src/MinimalWorker.Generators/WorkerGenerator.cs
index 6802e59..b1a5aa5 100644
--- a/src/MinimalWorker.Generators/WorkerGenerator.cs
+++ b/src/MinimalWorker.Generators/WorkerGenerator.cs
@@ -139,10 +139,11 @@ private static bool IsWorkerInvocation(SyntaxNode node)
private static bool AnalyzeDelegate(GeneratorSyntaxContext context, ExpressionSyntax delegateExpression, WorkerInvocationModel model)
{
var typeInfo = context.SemanticModel.GetTypeInfo(delegateExpression);
-
+ var location = delegateExpression.GetLocation();
+
// Try to get the actual delegate type, not just the converted type
INamedTypeSymbol? delegateType = null;
-
+
if (typeInfo.Type is INamedTypeSymbol namedType && namedType.DelegateInvokeMethod != null)
{
delegateType = namedType;
@@ -151,7 +152,7 @@ private static bool AnalyzeDelegate(GeneratorSyntaxContext context, ExpressionSy
{
delegateType = convertedNamedType;
}
-
+
if (delegateType == null)
return false;
@@ -167,7 +168,7 @@ private static bool AnalyzeDelegate(GeneratorSyntaxContext context, ExpressionSy
{
Name = param.Name,
Type = param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
- IsCancellationToken = param.Type.Name == "CancellationToken" &&
+ IsCancellationToken = param.Type.Name == "CancellationToken" &&
param.Type.ContainingNamespace?.ToDisplayString() == "System.Threading"
};
@@ -177,20 +178,34 @@ private static bool AnalyzeDelegate(GeneratorSyntaxContext context, ExpressionSy
model.Parameters.Add(paramModel);
}
- // Validate only one CancellationToken
+ // Validate only one CancellationToken - report MW0001
if (cancellationTokenCount > 1)
+ {
+ model.Diagnostics.Add(new DiagnosticInfo
+ {
+ Descriptor = Diagnostics.MultipleCancellationTokens,
+ Location = location
+ });
+ model.HasErrors = true;
return false;
+ }
// Determine return type
var returnType = invokeMethod.ReturnType;
model.IsAsync = returnType.Name == "Task" || returnType.Name == "ValueTask";
model.ReturnType = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- // Ensure Task or void for now (simplification)
+ // Ensure Task or void for now (simplification) - report MW0002
if (model.ReturnType != "void" &&
!model.ReturnType.Contains("Task") &&
!model.ReturnType.Contains("ValueTask"))
{
+ model.Diagnostics.Add(new DiagnosticInfo
+ {
+ Descriptor = Diagnostics.InvalidReturnType,
+ Location = location
+ });
+ model.HasErrors = true;
return false;
}
@@ -207,6 +222,21 @@ private static bool AnalyzeDelegate(GeneratorSyntaxContext context, ExpressionSy
private static void Execute(SourceProductionContext context, ImmutableArray workers)
{
+ // Report all collected diagnostics first
+ foreach (var worker in workers)
+ {
+ if (worker?.Diagnostics != null)
+ {
+ foreach (var diag in worker.Diagnostics)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ diag.Descriptor,
+ diag.Location,
+ diag.MessageArgs ?? System.Array.Empty