Skip to content

Commit ac0c3d6

Browse files
authored
Add configuration path to NCDI001 diagnostic (#89)
Add configuration path to NCDI001 diagnostic (#88) NCDI001 now reports the root configuration type and the configuration path the non-hot-reloadable type is bound at, so types buried in a complex config graph can be traced to where they are referenced.
1 parent bb8008f commit ac0c3d6

5 files changed

Lines changed: 98 additions & 8 deletions

File tree

Neovolve.Configuration.DependencyInjection.Generator.UnitTests/ConfigureWithGeneratorTests.cs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,12 +376,14 @@ public void ReportsNotHotReloadableForStructConfigType()
376376

377377
harness.GeneratorDiagnostics.Should()
378378
.Contain(diagnostic => diagnostic.Id == "NCDI001"
379-
&& diagnostic.GetMessage().Contains("Sample.SettingsStruct"));
379+
&& diagnostic.GetMessage().Contains("Sample.SettingsStruct")
380+
&& diagnostic.GetMessage().Contains("Sample.RootConfig")
381+
&& diagnostic.GetMessage().Contains("Settings"));
380382

381-
// The root is not hot reloaded, so it is never reported.
383+
// The root is not hot reloaded, so it is never reported as the not hot reloadable type.
382384
harness.GeneratorDiagnostics.Should()
383385
.NotContain(diagnostic => diagnostic.Id == "NCDI001"
384-
&& diagnostic.GetMessage().Contains("Sample.RootConfig"));
386+
&& diagnostic.GetMessage().Contains("'Sample.RootConfig' cannot be hot reloaded"));
385387
}
386388

387389
[Fact]
@@ -411,7 +413,46 @@ public static class Caller
411413
harness.GeneratorDiagnostics.Should()
412414
.Contain(diagnostic => diagnostic.Id == "NCDI001"
413415
&& diagnostic.GetMessage().Contains("Sample.ChildRecord")
414-
&& diagnostic.GetMessage().Contains("no writable properties"));
416+
&& diagnostic.GetMessage().Contains("no writable properties")
417+
&& diagnostic.GetMessage().Contains("Sample.RootConfig")
418+
&& diagnostic.GetMessage().Contains("Child"));
419+
}
420+
421+
[Fact]
422+
public void ReportsNotHotReloadableWithNestedConfigurationPath()
423+
{
424+
const string source = @"
425+
namespace Sample
426+
{
427+
using Microsoft.Extensions.Hosting;
428+
429+
public sealed record EndpointRecord(string Host, int Port);
430+
431+
public sealed class ServiceConfig
432+
{
433+
public EndpointRecord Endpoint { get; set; } = new(string.Empty, 0);
434+
public string ServiceValue { get; set; } = string.Empty;
435+
}
436+
437+
public sealed class RootConfig
438+
{
439+
public ServiceConfig Service { get; set; } = new();
440+
public string RootValue { get; set; } = string.Empty;
441+
}
442+
443+
public static class Caller
444+
{
445+
public static void Configure(IHostBuilder builder) => builder.ConfigureWith<RootConfig>();
446+
}
447+
}";
448+
449+
var harness = GeneratorTestHarness.Run(source);
450+
451+
harness.GeneratorDiagnostics.Should()
452+
.Contain(diagnostic => diagnostic.Id == "NCDI001"
453+
&& diagnostic.GetMessage().Contains("Sample.EndpointRecord")
454+
&& diagnostic.GetMessage().Contains("Service:Endpoint")
455+
&& diagnostic.GetMessage().Contains("Sample.RootConfig"));
415456
}
416457

417458
[Fact]

Neovolve.Configuration.DependencyInjection.Generator/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55

66
Rule ID | Category | Severity | Notes
77
--------|----------|----------|-------
8-
NCDI001 | Neovolve.Configuration | Warning | Configuration type cannot be hot reloaded (value type or no writable properties)
8+
NCDI001 | Neovolve.Configuration | Warning | Configuration type cannot be hot reloaded (value type or no writable properties); reports the root configuration type and the configuration path the type is bound at

Neovolve.Configuration.DependencyInjection.Generator/ConfigureWithGenerator.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ private static void ReportNotHotReloadable(SourceProductionContext context, IEnu
103103

104104
foreach (var root in roots)
105105
{
106+
var pathsByType = BuildConfigPathLookup(root);
107+
106108
foreach (var configType in root.ConfigTypes)
107109
{
108110
if (configType.FullyQualifiedName == root.RootTypeFullyQualifiedName)
@@ -149,12 +151,59 @@ private static void ReportNotHotReloadable(SourceProductionContext context, IEnu
149151
? configType.FullyQualifiedName.Substring("global::".Length)
150152
: configType.FullyQualifiedName;
151153

154+
var pathDescription = DescribeConfigPaths(root, configType.FullyQualifiedName, pathsByType);
155+
152156
context.ReportDiagnostic(Diagnostic.Create(
153-
DiagnosticDescriptors.NotHotReloadable, location, displayName, reason, fix));
157+
DiagnosticDescriptors.NotHotReloadable, location, displayName, reason, fix, pathDescription));
154158
}
155159
}
156160
}
157161

162+
private static Dictionary<string, List<string>> BuildConfigPathLookup(RootModel root)
163+
{
164+
var pathsByType = new Dictionary<string, List<string>>(StringComparer.Ordinal);
165+
166+
foreach (var registration in root.Registrations)
167+
{
168+
if (pathsByType.TryGetValue(registration.TypeFullyQualifiedName, out var paths) == false)
169+
{
170+
paths = new List<string>();
171+
pathsByType[registration.TypeFullyQualifiedName] = paths;
172+
}
173+
174+
if (paths.Contains(registration.SectionPath) == false)
175+
{
176+
paths.Add(registration.SectionPath);
177+
}
178+
}
179+
180+
return pathsByType;
181+
}
182+
183+
private static string DescribeConfigPaths(RootModel root, string typeFullyQualifiedName,
184+
Dictionary<string, List<string>> pathsByType)
185+
{
186+
if (pathsByType.TryGetValue(typeFullyQualifiedName, out var paths) == false
187+
|| paths.Count == 0)
188+
{
189+
return string.Empty;
190+
}
191+
192+
var rootDisplay = root.RootTypeFullyQualifiedName.StartsWith("global::", StringComparison.Ordinal)
193+
? root.RootTypeFullyQualifiedName.Substring("global::".Length)
194+
: root.RootTypeFullyQualifiedName;
195+
196+
var orderedPaths = paths.ToList();
197+
198+
orderedPaths.Sort(StringComparer.Ordinal);
199+
200+
var quotedPaths = string.Join("', '", orderedPaths);
201+
var pathLabel = orderedPaths.Count == 1 ? "configuration path" : "configuration paths";
202+
203+
return $". It is bound under root configuration type '{rootDisplay}' at {pathLabel} '{quotedPaths}'";
204+
}
205+
206+
158207
private static bool HasModuleInitializer(Compilation compilation)
159208
{
160209
var symbol = compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.ModuleInitializerAttribute");

Neovolve.Configuration.DependencyInjection.Generator/DiagnosticDescriptors.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ internal static class DiagnosticDescriptors
2020
"NCDI001",
2121
"Configuration type cannot be hot reloaded",
2222
"Configuration type '{0}' cannot be hot reloaded because it {1}; injected instances will not receive "
23-
+ "configuration updates - {2}",
23+
+ "configuration updates - {2}{3}",
2424
Category,
2525
DiagnosticSeverity.Warning,
2626
isEnabledByDefault: true,

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Hot reload works by updating the existing singleton instance of a configuration
177177
- **Value types** (`struct` and `record struct`). A struct is copied when it is injected, so updating the registered instance would not reach the copies already handed to application classes. Struct configuration types are bound and registered once as a snapshot of the configuration at startup, and they do not receive later updates.
178178
- **Reference types with no writable properties** (for example a positional `record` whose properties are all `init`-only). There is nowhere to write the updated values, so the injected instance keeps its startup values.
179179

180-
The source generator reports these cases at compile time as warning **NCDI001** so the limitation is visible where the type is declared rather than failing silently at runtime. The accompanying code fix converts a `struct` (or `record struct`) configuration type into a class so it can hot reload. For a record with no writable properties, add settable properties or convert it to a mutable class.
180+
The source generator reports these cases at compile time as warning **NCDI001** so the limitation is visible where the type is declared rather than failing silently at runtime. The warning also reports the root configuration type and the configuration path the offending type is bound at (for example `Service:Endpoint`), so a type buried deep in a complex configuration graph can be traced back to where it is referenced. The accompanying code fix converts a `struct` (or `record struct`) configuration type into a class so it can hot reload. For a record with no writable properties, add settable properties or convert it to a mutable class.
181181

182182
If a one-time snapshot is the intended behaviour for a given type, suppress the warning for that type:
183183

0 commit comments

Comments
 (0)