Rationale
The validation source generator learns a rule's comparative value by asking Roslyn to fold the argument expression:
https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L808
SemanticModel.GetConstantValue only succeeds for C# constant expressions, and the C# specification restricts those to string, char, bool, the integral types, float, double, decimal, enums, and null. DateTime, DateTimeOffset, TimeSpan, Guid, DateOnly, TimeOnly, and Uri cannot be constants in C# at all, so a temporal or identifier boundary can never be folded — not by writing it differently, not by hoisting it into a field. static readonly fields of otherwise-constant types are affected for the same reason.
When folding fails, HasConstantValue stays false and two things happen downstream. The message template loses its {comparativeValue} replacement, so the whole message is abandoned:
https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L454
and the emitter drops the metadata dictionary for that rule — all of it, not just the entry it could not fold:
https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs#L236
Reproduction
public sealed class BookingDto
{
public DateTime StartsAt { get; init; }
public int Seats { get; init; }
}
[GeneratePortableValidationOpenApi]
public sealed partial class BookingValidator : Validator<BookingDto>
{
public BookingValidator(IValidationContextFactory validationContextFactory)
: base(validationContextFactory) { }
protected override ValidatedValue<BookingDto> PerformValidation(
ValidationContext context,
ValidationCheckpoint checkpoint,
BookingDto dto
)
{
context.Check(dto.StartsAt).IsGreaterThan(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
context.Check(dto.Seats).IsGreaterThan(0);
return checkpoint.ToValidatedValue(dto);
}
}
Generated output, verbatim:
builder.WithGreaterThanError<global::System.DateTime>();
builder.WithGreaterThanError<global::System.Int32>();
builder.WithErrorExample("GreaterThan", "startsAt", null);
builder.WithErrorExample("GreaterThan", "seats", "seats must be greater than 0", new Dictionary<string, object?>(StringComparer.Ordinal) { ["comparativeValue"] = 0 });
The two checks are written identically in the validator, and only the int one produces a usable example. No diagnostic is reported for the DateTime check.
Impact
- Every temporal validation boundary —
IsGreaterThan, IsLessThan, IsInRange, IsEqualTo, and their siblings over DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan — publishes an error example with no message and no comparativeValue. Guid and Uri comparisons are affected the same way.
- The degradation is silent. Nothing in the generated output, the diagnostics, or the published document distinguishes "this rule has no message" from "this rule's message could not be reconstructed", so it reads as an authoring omission rather than a generator limitation.
- Date and time boundaries are among the most common cases where a client actually needs the boundary value in the example, which is what makes this worth fixing rather than documenting.
- The
all in canEmitMetadata widens the blast radius: one unfoldable entry drops every other metadata entry on the same rule, including ones that folded fine.
The schema side is unaffected — WithGreaterThanError<global::System.DateTime>() is emitted correctly and PortableOpenApiSchemaTypeMapper gives it type: string, format: date-time. This is purely about the example.
Proposed direction (open for discussion)
Stop asking Roslyn to evaluate the expression and reconstruct it from syntax instead. When GetConstantValue fails, inspect the argument expression and recognise the shapes that can be re-emitted into the generated file:
new DateTime(...) / new DateTimeOffset(...) / new TimeOnly(...) and friends whose arguments are themselves constants
DateOnly.FromDayNumber(...), TimeSpan.FromTicks(...), Guid.ParseExact("..."), DateTime.Parse("...")-style factory calls with constant arguments
- a reference to a
static readonly field whose initializer is one of the above
The emitter already knows how to write these: ValidatorOpenApiEmitter.ToLiteral has arms for DateTime, DateTimeOffset, TimeSpan, Guid, and Uri, plus TryCreateDateOnlyOrTimeOnlyLiteral. Those arms are currently unreachable through the analyzer for exactly the reason described above, and are being kept on purpose as the landing site for this fix.
Reconstructing the value also needs a decision on how it is rendered into the message text, which is a string template. FormatMessageValue would have to agree with the canonical text MetadataValue produces for the same kind, or the message and the comparativeValue metadata entry disagree in the same example.
Two narrower alternatives, if the syntax walk turns out to be too broad:
- Handle only
new DateTime(...)/new DateTimeOffset(...) with constant arguments, which covers the common authoring style and leaves the rest degraded.
- Leave the behaviour as is and report an informational diagnostic when a rule's metadata cannot be folded, so the degradation is at least visible at build time. This is worth doing regardless of whether the reconstruction lands.
Acceptance Criteria
Out of scope
- The schema emitted for temporal types, which is already correct.
- Runtime validation behaviour: the check itself works, this is only about what the generator can say about it at compile time.
- Boundaries computed at runtime (
DateTime.UtcNow.AddDays(30)), which have no compile-time value by definition and should keep degrading, ideally with the diagnostic from the last alternative above.
Rationale
The validation source generator learns a rule's comparative value by asking Roslyn to fold the argument expression:
https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L808
SemanticModel.GetConstantValueonly succeeds for C# constant expressions, and the C# specification restricts those tostring,char,bool, the integral types,float,double,decimal, enums, andnull.DateTime,DateTimeOffset,TimeSpan,Guid,DateOnly,TimeOnly, andUricannot be constants in C# at all, so a temporal or identifier boundary can never be folded — not by writing it differently, not by hoisting it into a field.static readonlyfields of otherwise-constant types are affected for the same reason.When folding fails,
HasConstantValuestaysfalseand two things happen downstream. The message template loses its{comparativeValue}replacement, so the whole message is abandoned:https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L454
and the emitter drops the metadata dictionary for that rule — all of it, not just the entry it could not fold:
https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs#L236
Reproduction
Generated output, verbatim:
The two checks are written identically in the validator, and only the
intone produces a usable example. No diagnostic is reported for theDateTimecheck.Impact
IsGreaterThan,IsLessThan,IsInRange,IsEqualTo, and their siblings overDateTime,DateTimeOffset,DateOnly,TimeOnly,TimeSpan— publishes an error example with no message and nocomparativeValue.GuidandUricomparisons are affected the same way.allincanEmitMetadatawidens the blast radius: one unfoldable entry drops every other metadata entry on the same rule, including ones that folded fine.The schema side is unaffected —
WithGreaterThanError<global::System.DateTime>()is emitted correctly andPortableOpenApiSchemaTypeMappergives ittype: string, format: date-time. This is purely about the example.Proposed direction (open for discussion)
Stop asking Roslyn to evaluate the expression and reconstruct it from syntax instead. When
GetConstantValuefails, inspect the argument expression and recognise the shapes that can be re-emitted into the generated file:new DateTime(...)/new DateTimeOffset(...)/new TimeOnly(...)and friends whose arguments are themselves constantsDateOnly.FromDayNumber(...),TimeSpan.FromTicks(...),Guid.ParseExact("..."),DateTime.Parse("...")-style factory calls with constant argumentsstatic readonlyfield whose initializer is one of the aboveThe emitter already knows how to write these:
ValidatorOpenApiEmitter.ToLiteralhas arms forDateTime,DateTimeOffset,TimeSpan,Guid, andUri, plusTryCreateDateOnlyOrTimeOnlyLiteral. Those arms are currently unreachable through the analyzer for exactly the reason described above, and are being kept on purpose as the landing site for this fix.Reconstructing the value also needs a decision on how it is rendered into the message text, which is a
stringtemplate.FormatMessageValuewould have to agree with the canonical textMetadataValueproduces for the same kind, or the message and thecomparativeValuemetadata entry disagree in the same example.Two narrower alternatives, if the syntax walk turns out to be too broad:
new DateTime(...)/new DateTimeOffset(...)with constant arguments, which covers the common authoring style and leaves the rest degraded.Acceptance Criteria
new DateTime(...)with constant arguments produces an error example carrying both the message and thecomparativeValuemetadata entry.DateTimeOffset,DateOnly,TimeOnly,TimeSpan, andGuidboundaries, or the unsupported ones are named explicitly in the decision.MetadataValueproduces for the same kind, so message and metadata cannot disagree.WithErrorExamplecall rather than callingToLiteraldirectly.Out of scope
DateTime.UtcNow.AddDays(30)), which have no compile-time value by definition and should keep degrading, ideally with the diagnostic from the last alternative above.