From 31d95daa7af9080cbf2fde4398aa68fde2143da4 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 1 Aug 2026 23:33:23 +0200 Subject: [PATCH 01/13] docs(plan): plan OpenAPI examples for non-constant boundaries Temporal, Guid, and Uri validation boundaries can never be C# constant expressions, so GetConstantValue always fails for them and the generated error example loses both its message and its metadata, silently. Plan a syntax-directed reconstruction over a closed, enumerated whitelist of constructors, factories, and well-known statics, with recursion so that a DateTimeOffset can carry a TimeSpan offset that is itself not a constant. The design constraints that shaped it, recorded so they are not rediscovered during implementation: - The generator targets netstandard2.0, where DateOnly and TimeOnly do not exist, so reconstructed values are structural payloads rather than boxed CLR objects. That also centralizes literal and canonical-message rendering, which is what keeps a message and its metadata entry from stating different values. - Evaluation runs real constructors, so expressions that compile but throw must degrade to a diagnostic; only cancellation propagates. - DateTimeKind.Local resolves against the executing process's time zone, which would make generated output depend on the build machine. - static readonly is not a promise that the initializer is the final value, and that is only decidable within a single syntax tree, so field resolution stays in the validator's own file for now. Refs #57 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184eh1G3XxvjRB11cNKyMRT --- ...pi-examples-for-non-constant-boundaries.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md new file mode 100644 index 0000000..b96b4b5 --- /dev/null +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -0,0 +1,178 @@ +# OpenAPI Error Examples for Non-Constant Validation Boundaries + +## Rationale + +The validation source generator learns a rule's boundary value through `SemanticModel.GetConstantValue`, which only succeeds for C# constant expressions. `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, `DateOnly`, `TimeOnly`, and `Uri` cannot be C# constants at all, so a temporal or identifier boundary can never be folded — not by writing it differently and not by hoisting it into a `static readonly` field. The rule then loses both its message and its metadata, and the generated example carries neither. Two checks written identically in the same validator produce usable documentation for the `int` one and an empty example for the `DateTime` one, with no diagnostic to explain the difference. + +Date and time boundaries are among the cases where a client most needs the boundary value in the example, and the degradation is invisible: nothing in the generated code, the diagnostics, or the published document distinguishes a rule that has no message from one whose message could not be reconstructed. This plan reconstructs the value from syntax where the shape allows it, and makes the remaining gaps visible through a diagnostic instead of silence. + +## Acceptance Criteria + +- [ ] A boundary written as an object creation whose arguments are themselves constants or recognized shapes — `new DateTime(...)`, `new DateTimeOffset(...)`, `new TimeSpan(...)`, `new DateOnly(...)`, `new TimeOnly(...)`, `new Guid(...)`, `new Uri("...")` — produces an error example carrying both the message and the metadata entry, both derived from the same reconstructed value. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is covered, since no `DateTimeOffset` boundary worth writing has an offset that folds as a constant. +- [ ] The accepted shapes are exactly the ones enumerated in the whitelist tables in Technical Details, and the named factories and well-known statics there reconstruct identically to the equivalent object creation. No overload, factory, or static outside those tables is reconstructed, and every entry in the omissions table reaches the diagnostic rather than being silently unsupported. +- [ ] A reference to a `static readonly` field **declared in the validator's own file**, whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. +- [ ] A field declared in another file or another assembly is not resolved. It reports a distinct warning naming multi-file resolution as unsupported, and the example degrades rather than being reconstructed from a partial view of the field. +- [ ] A `DateTime` boundary whose kind is `DateTimeKind.Local` is rejected with the diagnostic instead of reconstructed, while `Utc` and `Unspecified` reconstruct; the generated file for a given source tree is identical regardless of the build machine's time zone. +- [ ] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. +- [ ] A rule whose metadata cannot be reconstructed reports a new diagnostic identifying the rule and the argument, and a boundary computed at runtime such as `DateTime.UtcNow.AddDays(30)` reports it rather than failing the build. An expression that exceeds the recursion bound, and a `static readonly` field whose initializer chain forms a cycle, both reach that same diagnostic instead of hanging or crashing the compiler. +- [ ] An expression that is valid C# but throws when evaluated — `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)` — produces the diagnostic and a degraded example rather than an unhandled exception, and the compilation that contains it still succeeds. `OperationCanceledException` is the only exception that propagates out of reconstruction. +- [ ] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. +- [ ] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. +- [ ] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. +- [ ] `README.md` no longer states that examples require compile-time constant metadata arguments, and describes what is reconstructed and what degrades. +- [ ] Test code coverage stays above 95%, and no existing generated output changes for boundaries that already fold today. + +## Technical Details + +### Where the value is lost + +`ValidatorOpenApiAnalyzer` reads the boundary argument with `semanticModel.GetConstantValue` and leaves `hasConstantValue` false when folding fails. Everything downstream keys off that flag: message assembly abandons the whole message when a placeholder has no replacement, and `ValidatorOpenApiEmitter.EmitExamples` drops the metadata dictionary when `rule.MetadataValues.All(m => m.HasConstantValue)` is false. + +The fix belongs at the single point where the value is read. When constant folding fails, attempt a syntax-directed reconstruction and set `hasConstantValue` when it succeeds. `ValidatorOpenApiEmitter.ToLiteral` already has arms for `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, and `Uri` plus `TryCreateDateOnlyOrTimeOnlyLiteral`; they are currently unreachable through the analyzer and are the intended landing site, and they emit ticks-based literals, so the round trip is exact and culture-independent. + +What reconstruction produces is not a boxed CLR value, for the reasons in the next section, so `MetadataValueModel.Value` and the two rendering paths do change shape. That is the one structural change in this plan. + +### What may be reconstructed + +Recognition is a closed whitelist of shapes, not an expression evaluator, and it is recursive: an argument of a recognized shape is accepted when it folds through `GetConstantValue` **or** is itself a recognized shape. This is not optional detail — every useful `DateTimeOffset` constructor takes a `TimeSpan` offset or a `DateTime`, neither of which can ever be a C# constant, so a non-recursive rule would promise `DateTimeOffset` support and deliver none of it. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is the shape to work from, and `TimeSpan.FromHours(2)`, `TimeSpan.Zero`, and `new TimeSpan(2, 0, 0)` must all be acceptable in that offset position. The repository already writes these values this way — `new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2))` in `TypedMetadataRoutingTests`. + +Recursion is bounded by a depth limit and a cycle guard rather than by the shape of the grammar. A literal nesting such as `DateTimeOffset` over `TimeSpan` is two levels, and each `static readonly` field hop adds one, so a small fixed bound — four is enough for every shape named here — keeps a pathological or adversarial expression from driving the generator into deep recursion. The cycle guard tracks the field symbols already being resolved on the current path, because field initializers can reference other fields and a cycle must end in the diagnostic rather than a stack overflow inside the compiler. Exceeding either bound is treated exactly like an unrecognized shape. + +### Reconstructed values are structural, not boxed CLR objects + +A reconstructed value is carried as an explicit kind plus a deterministic payload, not as `object?` holding a framework instance: + +| Kind | Payload | +| --- | --- | +| `DateTime` | Ticks and `DateTimeKind` | +| `DateTimeOffset` | Ticks and offset ticks | +| `TimeSpan` | Ticks | +| `DateOnly` | Day number | +| `TimeOnly` | Ticks | +| `Guid` | Canonical `D`-format text | +| `Uri` | `OriginalString` | + +The generator targets `netstandard2.0`, where `DateOnly` and `TimeOnly` do not exist, so it cannot construct or even name them. The existing `TryCreateDateOnlyOrTimeOnlyLiteral` copes by reflecting on `type.FullName` and reading `DayNumber` through `GetProperty`, which works only because the analyzer host happens to run a newer runtime than the generator targets. Requiring real CLR values would spread that reflection into the analyzer and make correctness depend on the host's framework version; a payload of `int` and `long` depends on nothing. + +`DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, and `Uri` do exist in `netstandard2.0`, so evaluation still uses them — that is what gives argument validation for free — but the result is reduced to its payload immediately rather than stored. `DateOnly` and `TimeOnly` are evaluated through `DateTime` and `TimeSpan` arithmetic, which also yields their range checking: `new DateOnly(2026, 13, 1)` fails because the equivalent `DateTime` construction throws. + +Two further reasons this representation is the right one: + +- **Rendering is centralized.** One type renders both the C# literal and the canonical message text from the same payload, so the agreement required below holds by construction instead of by two `switch` statements staying in sync. +- **Equality is ordinal and total.** `Uri.Equals` ignores the fragment, so two boundaries differing only after `#` compare equal while emitting different literals; comparing `OriginalString` ordinally matches what is actually emitted. The same applies to the example de-duplication in `EmitExamples`, which currently keys on `metadata.Value?.ToString()` — `Uri.ToString()` is a canonicalized, unescaped form rather than `OriginalString`, so the dedup key and the emitted literal disagree today. + +Recognition matches **symbols**, not names. A user-defined `DateTime` type, or a static `FromHours` on someone's own struct, must not be reconstructed as if it were the framework's; resolve the symbol and compare against the corresponding `INamedTypeSymbol` from the compilation's core library. + +### The accepted set + +The whitelist is exhaustive, not indicative. Reconstruction evaluates on the runtime hosting the compiler rather than the target framework, so the set is restricted to shapes whose semantics do not vary between the two; that is the reason for enumerating overloads instead of naming method families. + +Constructors, with every argument either a folded constant or a nested accepted shape: + +| Type | Accepted constructors | +| --- | --- | +| `DateTime` | `(int, int, int)`, `(int, int, int, int, int, int)`, `(int, int, int, int, int, int, int)`, each also with a trailing `DateTimeKind`; `(long)`; `(long, DateTimeKind)` | +| `DateTimeOffset` | `(int, int, int, int, int, int, TimeSpan)`, `(int, int, int, int, int, int, int, TimeSpan)`, `(long, TimeSpan)`, `(DateTime, TimeSpan)` | +| `TimeSpan` | `(int, int, int)`, `(int, int, int, int)`, `(int, int, int, int, int)`, `(long)` | +| `DateOnly` | `(int, int, int)` | +| `TimeOnly` | `(int, int)`, `(int, int, int)`, `(int, int, int, int)`, `(long)` | +| `Guid` | `(string)` | +| `Uri` | `(string)`, `(string, UriKind)` | + +Static factory methods: + +| Type | Accepted factories | +| --- | --- | +| `TimeSpan` | `FromTicks(long)`, and the `double` overloads of `FromDays`, `FromHours`, `FromMinutes`, `FromSeconds`, `FromMilliseconds` | +| `DateOnly` | `FromDayNumber(int)` | +| `DateTimeOffset` | `FromUnixTimeSeconds(long)`, `FromUnixTimeMilliseconds(long)` | +| `Guid` | `Parse(string)`, `ParseExact(string, string)` | + +Well-known statics, recognized by symbol rather than syntax: + +| Type | Accepted statics | +| --- | --- | +| `DateTime` | `MinValue`, `MaxValue`, `UnixEpoch` | +| `DateTimeOffset` | `MinValue`, `MaxValue`, `UnixEpoch` | +| `TimeSpan` | `Zero`, `MinValue`, `MaxValue` | +| `DateOnly` | `MinValue`, `MaxValue` | +| `TimeOnly` | `MinValue`, `MaxValue` | +| `Guid` | `Empty` | + +The offset argument of a `DateTimeOffset` accepts any accepted `TimeSpan` shape: `TimeSpan.Zero`, the `From*` factories, `new TimeSpan(...)`, `MinValue`/`MaxValue`, or a `static readonly` `TimeSpan` field declared in source. `MinValue` and `MaxValue` are accepted as shapes and then throw during evaluation, which the failure contract below turns into the diagnostic — the recognizer does not need to know which offsets are legal. + +Everything else is omitted, and an omission is a diagnostic rather than a silent gap: + +| Omitted | Reason | +| --- | --- | +| `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()` | No compile-time value by definition | +| `DateTime.Parse`/`ParseExact`/`TryParse` and the `DateTimeOffset` equivalents | Culture-sensitive; see Excluded shapes | +| `new DateTimeOffset(DateTime)` | Resolves an `Unspecified` or `Local` input against the local zone, so it is nondeterministic for the same reason `DateTimeKind.Local` is | +| Constructors taking a `Calendar` | Culture-dependent interpretation of the same components | +| `new Guid(byte[])` and any array or collection argument | Requires evaluating an array creation, which is not an accepted shape | +| `default`, `default(T)`, target-typed `new()` | Keeps the recognizer to two syntax forms, object creation and member access or invocation | +| Locals, parameters, properties, and instance or non-`readonly` fields | Value depends on flow the generator does not track | +| `static readonly` fields declared outside the validator's syntax tree, including referenced assemblies | Multi-file resolution is deliberately unsupported in this iteration and reports the warning | +| Arithmetic and chaining such as `X.AddDays(1)` or `a + b` | Would require a general expression evaluator rather than a whitelist | +| `TimeSpan.FromMicroseconds`, the integer `TimeSpan.From*` overloads, `DateOnly.FromDateTime`, `TimeOnly.FromDateTime`/`FromTimeSpan` | Not needed for a boundary; add on demand rather than by default | +| Any reconstructed `DateTime` whose `Kind` is `Local` | Nondeterministic; see Excluded shapes | + +### Reconstruction must not be able to break a build + +Recognizing a shape is not the same as being able to evaluate it. Reconstruction ultimately runs real constructors and factory methods, so it inherits their argument validation, and every one of these compiles cleanly while throwing when evaluated: `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)`. An unhandled exception here does not degrade one example — it fails the generator and breaks compilation of code that was previously building, which is a far worse outcome than the silent degradation this plan exists to fix. + +The contract is therefore that evaluation cannot fail the generator: any exception other than `OperationCanceledException` is caught at the evaluation step and turned into "not reconstructable", which routes into the same diagnostic and the same degraded output as an unrecognized shape. Cancellation must keep propagating, because Roslyn relies on it to abandon superseded generator runs and swallowing it converts a responsive IDE into a hanging one. + +Scope the handler to the evaluation of a single recognized shape rather than wrapping the analyzer or the generator entry point. A blanket catch would also swallow genuine defects in this code and turn them into quietly missing documentation, which is the failure mode that is hardest to notice. + +An expression in this category is usually also a defect in the validator itself, since the same call throws at runtime. Diagnosing that is out of scope: the generator reports only that it could not reconstruct the value, and does not attempt to tell the author their boundary is invalid. + +### Excluded shapes + +`DateTimeKind.Local` is rejected and takes the diagnostic path. `MetadataValue.FromDateTime` converts a local value with `ToUniversalTime()`, which resolves against the time zone of whichever process performs the conversion. The metadata entry is converted at runtime, in the deployment time zone, by the document transformer; the message text is baked into the generated file at build time, in the build machine's time zone. Two harms follow, and either alone is disqualifying: the generated source stops being a function of the source tree, so two machines building the same commit emit different files, and whenever the build and deployment zones differ the message and the `comparativeValue` in the same example state different instants — the precise disagreement the criteria forbid. + +The rejection tests the reconstructed value's `Kind`, not the syntax: a field reference or a nested shape can yield a local value without the enum member appearing at the call site at all. + +`Utc` and `Unspecified` are both deterministic — `FromDateTime` stores them unconverted — and `Unspecified` is what every `new DateTime(...)` literal produces, so rejecting `Local` costs nothing on the common authoring path. That `Unspecified` is published without a zone remains an accepted limitation of the OpenAPI output rather than something this work changes; reconstruction only makes existing behaviour reachable for these types, and no reasoning here depends on resolving it. `DateTimeOffset` is unaffected, since it carries its offset explicitly. + +Culture-sensitive parsing is excluded: `DateTime.Parse`, `DateTimeOffset.Parse`, and their `TryParse` siblings are **not** supported even with a constant string, because the generator would have to pick a culture and any choice can disagree with what the developer meant. `Guid.Parse`/`ParseExact` are the exception — the `Guid` formats are culture-invariant. A rejected shape is a diagnostic, not a silent degradation, which keeps the unsupported set discoverable. + +For a `static readonly` field reference, resolve the symbol to its declaring syntax and apply the same recognition to the initializer. Only the well-known statics in the tables above are recognized by symbol rather than by syntax. + +**Field resolution is confined to the validator's own syntax tree.** A field declared in another file, in another partial declaration of the same type, or in a referenced assembly is not resolved at all; it reports the multi-file warning and degrades. This is a deliberate limit on the first iteration rather than a technical obstacle — resolving across trees needs the `Compilation` and a `GetSemanticModel` call per tree, which is mechanical — and it can be lifted later without changing anything else in this design. + +The restriction also buys correctness that is otherwise expensive. `static readonly` does not mean "initialized once at the declaration": a static constructor may assign the field again, and that assignment wins, because the initializer runs first and the constructor body overwrites it. Reconstructing from the initializer would then document a value the application never uses, so reconstruction is valid only when the initializer is the field's *sole* assignment. Within one syntax tree that is decidable — examine the static constructors declared there and reject the field if any assigns it. Across files it is not, because a `partial` type can hide a static constructor in a file the analyzer never looked at, and a rule that resolves cross-file fields while checking only one file's assignments would be confidently wrong. A field with no initializer degrades regardless, since there is no syntax to recurse into. + +Consequently, if the declaring type has any declaring syntax reference outside the validator's tree, the field is not resolved even when the initializer sits in the validator's own file — the assignments cannot all be seen from here. + +### Message and metadata must agree + +This is a real defect today rather than a hypothetical: `FormatMessageValue` renders through `IFormattable.ToString(null, CultureInfo.InvariantCulture)`, which for a `DateTime` yields `01/01/2026 00:00:00`, while the runtime metadata pipeline renders through `MetadataValue.ToCanonicalString()`, which is round-trip ISO-8601. Reconstruction makes both paths reachable for the same value at once, so the disagreement would ship in a single example — the message saying one thing and `comparativeValue` another. + +`MetadataValue.ToCanonicalString()` is the canonical form and the message must follow it, not the reverse: it is what the published document and the wire format already use. The generator cannot call it directly — `MetadataValue` lives in the runtime library and the generator targets `netstandard2.0` with no reference to it — so the reconstructed value's own canonical rendering has to reproduce it, and `FormatMessageValue` delegates to that instead of growing a parallel set of arms. Delegation is what makes the guarantee structural: the message and the metadata literal are then two renderings of one payload rather than two switch statements that must be kept in agreement. The criteria still require a test comparing the output against `MetadataValue.ToCanonicalString()` rather than against a literal string, because the two libraries can only be pinned to each other by execution. + +### All-or-nothing metadata is deliberate + +The issue asks whether one unreconstructable entry should still drop the entries that folded. It should. `CreateRangeSchema` marks both `lowerBoundary` and `upperBoundary` as required, and `CreateComparisonSchema` marks `comparativeValue` as required, so every metadata key of every built-in rule is required by its own schema. Emitting the foldable subset would publish an example that violates the schema published alongside it, which is worse than publishing no metadata. The `All(...)` check stays as written; the new diagnostic is what removes the silence that made it look accidental. + +### Diagnostic + +Two descriptors are added to `DiagnosticDescriptors`, continuing the `LPRSG####` sequence. + +Cardinality is **one report per distinct unresolved argument**, located at that argument. Reporting once per rule cannot be reconciled with pointing at the argument: `IsInRange` with two unreconstructable boundaries has two offending locations, and collapsing them either invents a location or hides the second problem. Per-argument reporting also means fixing one boundary visibly removes one diagnostic, which is the feedback an author needs while working through a rule. + +The first covers metadata that could not be reconstructed, at `Info` severity: the generated code is valid and the validation itself is unaffected, and a runtime-computed boundary such as `DateTime.UtcNow.AddDays(30)` is a legitimate authoring choice that should stay quiet enough to live with. + +The second covers a field reference the analyzer declines to follow out of the validator's syntax tree, at `Warning` severity. It is louder because it reports a tool limitation rather than an authoring decision: the shape is one the generator could support, the author has no way to tell from the output that the value was dropped for this reason instead of any other, and the fix is mechanical — move the constant into the validator's file or write it inline. It names multi-file resolution explicitly so the message is actionable rather than a generic failure to fold. + +One consequence to accept knowingly: a consumer building with `TreatWarningsAsErrors` fails on this where they would previously have got silently degraded documentation. That is the intended trade for visibility, and the escape hatch is the ordinary one — suppress `LPRSG####` — but it is a behaviour change for that audience rather than a purely additive diagnostic. + +### Incremental generation + +The incremental value is `ValidatorOpenApiAnalysis`, whose `Equals` compares `HintName`, the emitted `Source` string, and the diagnostics. Reconstructed values are therefore never compared directly by the pipeline: they reach the cache only through the text they render into. This makes the structural representation above a rendering concern rather than a cache-correctness one, and it means an equality mistake in a value type cannot silently poison incremental results — but it also means any nondeterminism in rendering shows up as spurious cache misses and churn in generated files, which is a further reason the payloads are fixed integers and ordinal text. + +Confining field resolution to the validator's own syntax tree keeps the pipeline as it is. Generated output remains a function of that tree, so no stage needs to take a dependency on the compilation and nothing new invalidates a cached result. Had cross-file fields been resolved, a stage keyed only to the validator's tree would have kept serving a cached example after the constant it quotes was edited — stale documentation that looks correct, which is a worse failure than the degradation this plan fixes. Whoever lifts the restriction later must move the reconstruction stage onto a compilation-derived input at the same time, and should keep it separate from the stages that need only the validator's syntax so the widened dependency does not spread. + +### Scope + +The twelve mutation survivors in `BuiltInValidationErrorBuilderExtensions` are **not** part of this work, despite an earlier triage note that grouped them here. They cover the `target`-provided branch of the hand-written typed helpers, which the generator never calls — it emits `builder.WithGreaterThanError()` without a target and writes examples through separate `WithErrorExample` calls. Those survivors are an independent test-coverage gap in the runtime helper API and need their own issue. From f1cbeb009485a9f1b824ff67957f228e2fb1d202 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 1 Aug 2026 23:43:20 +0200 Subject: [PATCH 02/13] docs(plan): correct whitelist, field scope, and diagnostic equality Three defects found in review of the reconstruction plan: DateTime has no year/month/day overload taking a DateTimeKind, so the table's "each also with a trailing DateTimeKind" named a constructor that does not exist. Enumerate the two Kind overloads that do. The static readonly criterion promised reconstruction for a field in the validator's file, while Technical Details also rejected any field whose declaring type is declared in more than one file. State the second condition in the criterion as well. Target-typed object creation is now explicitly supported rather than sitting ambiguously in the omissions table: new (2026, 7, 26, ...) is how this repository already writes such values, and it resolves to the same constructor symbol. DiagnosticsEqual compares id, severity, and message but not position, so an analysis whose diagnostics moved compares equal and the driver serves a cached result with stale locations. Since the new diagnostics point at a specific argument, equality and hashing must include source path and span, verified by an incremental test that reuses one driver. Also corrected the incremental-generation claim that no stage takes a dependency on the compilation; the analyzer already receives one. The property that matters is that a validator's output stays a function of its own syntax tree. Refs #57 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184eh1G3XxvjRB11cNKyMRT --- ...-openapi-examples-for-non-constant-boundaries.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md index b96b4b5..fe83562 100644 --- a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -10,7 +10,7 @@ Date and time boundaries are among the cases where a client most needs the bound - [ ] A boundary written as an object creation whose arguments are themselves constants or recognized shapes — `new DateTime(...)`, `new DateTimeOffset(...)`, `new TimeSpan(...)`, `new DateOnly(...)`, `new TimeOnly(...)`, `new Guid(...)`, `new Uri("...")` — produces an error example carrying both the message and the metadata entry, both derived from the same reconstructed value. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is covered, since no `DateTimeOffset` boundary worth writing has an offset that folds as a constant. - [ ] The accepted shapes are exactly the ones enumerated in the whitelist tables in Technical Details, and the named factories and well-known statics there reconstruct identically to the equivalent object creation. No overload, factory, or static outside those tables is reconstructed, and every entry in the omissions table reaches the diagnostic rather than being silently unsupported. -- [ ] A reference to a `static readonly` field **declared in the validator's own file**, whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. +- [ ] A reference to a `static readonly` field **declared in the validator's own file**, whose declaring type has no other source declaration, and whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. - [ ] A field declared in another file or another assembly is not resolved. It reports a distinct warning naming multi-file resolution as unsupported, and the example degrades rather than being reconstructed from a partial view of the field. - [ ] A `DateTime` boundary whose kind is `DateTimeKind.Local` is rejected with the diagnostic instead of reconstructed, while `Utc` and `Unspecified` reconstruct; the generated file for a given source tree is identical regardless of the build machine's time zone. - [ ] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. @@ -19,6 +19,7 @@ Date and time boundaries are among the cases where a client most needs the bound - [ ] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. - [ ] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. - [ ] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. +- [ ] `ValidatorOpenApiAnalysis` equality and hashing account for each diagnostic's source path and span, and an incremental test that reuses one generator driver across two compilations — moving an unresolved argument without otherwise changing it — observes the reported location move with it. - [ ] `README.md` no longer states that examples require compile-time constant metadata arguments, and describes what is reconstructed and what degrades. - [ ] Test code coverage stays above 95%, and no existing generated output changes for boundaries that already fold today. @@ -61,6 +62,8 @@ Two further reasons this representation is the right one: - **Rendering is centralized.** One type renders both the C# literal and the canonical message text from the same payload, so the agreement required below holds by construction instead of by two `switch` statements staying in sync. - **Equality is ordinal and total.** `Uri.Equals` ignores the fragment, so two boundaries differing only after `#` compare equal while emitting different literals; comparing `OriginalString` ordinally matches what is actually emitted. The same applies to the example de-duplication in `EmitExamples`, which currently keys on `metadata.Value?.ToString()` — `Uri.ToString()` is a canonicalized, unescaped form rather than `OriginalString`, so the dedup key and the emitted literal disagree today. +Object creation is recognized in both its explicit and target-typed forms: `new DateTime(2026, 1, 1)` and `new (2026, 1, 1)` resolve to the same constructor symbol, and symbol-based recognition sees no difference between them. This is not a nicety — `new (2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)` in `TypedMetadataValueTests` is how this repository already writes such values, so a recognizer that keyed on the explicit form would miss the dominant authoring style. `default` and `default(T)` remain omitted; unlike target-typed `new`, they are a third syntax form rather than the same one spelled shorter. + Recognition matches **symbols**, not names. A user-defined `DateTime` type, or a static `FromHours` on someone's own struct, must not be reconstructed as if it were the framework's; resolve the symbol and compare against the corresponding `INamedTypeSymbol` from the compilation's core library. ### The accepted set @@ -71,7 +74,7 @@ Constructors, with every argument either a folded constant or a nested accepted | Type | Accepted constructors | | --- | --- | -| `DateTime` | `(int, int, int)`, `(int, int, int, int, int, int)`, `(int, int, int, int, int, int, int)`, each also with a trailing `DateTimeKind`; `(long)`; `(long, DateTimeKind)` | +| `DateTime` | `(int, int, int)`, `(int, int, int, int, int, int)`, `(int, int, int, int, int, int, int)`, `(int, int, int, int, int, int, DateTimeKind)`, `(int, int, int, int, int, int, int, DateTimeKind)`, `(long)`, `(long, DateTimeKind)` — there is no year/month/day overload taking a `DateTimeKind` | | `DateTimeOffset` | `(int, int, int, int, int, int, TimeSpan)`, `(int, int, int, int, int, int, int, TimeSpan)`, `(long, TimeSpan)`, `(DateTime, TimeSpan)` | | `TimeSpan` | `(int, int, int)`, `(int, int, int, int)`, `(int, int, int, int, int)`, `(long)` | | `DateOnly` | `(int, int, int)` | @@ -110,7 +113,7 @@ Everything else is omitted, and an omission is a diagnostic rather than a silent | `new DateTimeOffset(DateTime)` | Resolves an `Unspecified` or `Local` input against the local zone, so it is nondeterministic for the same reason `DateTimeKind.Local` is | | Constructors taking a `Calendar` | Culture-dependent interpretation of the same components | | `new Guid(byte[])` and any array or collection argument | Requires evaluating an array creation, which is not an accepted shape | -| `default`, `default(T)`, target-typed `new()` | Keeps the recognizer to two syntax forms, object creation and member access or invocation | +| `default` and `default(T)` | Keeps the recognizer to two syntax forms, object creation and member access or invocation | | Locals, parameters, properties, and instance or non-`readonly` fields | Value depends on flow the generator does not track | | `static readonly` fields declared outside the validator's syntax tree, including referenced assemblies | Multi-file resolution is deliberately unsupported in this iteration and reports the warning | | Arithmetic and chaining such as `X.AddDays(1)` or `a + b` | Would require a general expression evaluator rather than a whitelist | @@ -171,7 +174,9 @@ One consequence to accept knowingly: a consumer building with `TreatWarningsAsEr The incremental value is `ValidatorOpenApiAnalysis`, whose `Equals` compares `HintName`, the emitted `Source` string, and the diagnostics. Reconstructed values are therefore never compared directly by the pipeline: they reach the cache only through the text they render into. This makes the structural representation above a rendering concern rather than a cache-correctness one, and it means an equality mistake in a value type cannot silently poison incremental results — but it also means any nondeterminism in rendering shows up as spurious cache misses and churn in generated files, which is a further reason the payloads are fixed integers and ordinal text. -Confining field resolution to the validator's own syntax tree keeps the pipeline as it is. Generated output remains a function of that tree, so no stage needs to take a dependency on the compilation and nothing new invalidates a cached result. Had cross-file fields been resolved, a stage keyed only to the validator's tree would have kept serving a cached example after the constant it quotes was edited — stale documentation that looks correct, which is a worse failure than the degradation this plan fixes. Whoever lifts the restriction later must move the reconstruction stage onto a compilation-derived input at the same time, and should keep it separate from the stages that need only the validator's syntax so the widened dependency does not spread. +Confining field resolution to the validator's own syntax tree keeps invalidation where it already is. The analyzer is handed the `Compilation` regardless, so the point is not that a compilation dependency is avoided — it is that a validator's generated output continues to depend only on its own syntax tree, and no edit to an unrelated file can change it. Had cross-file fields been resolved, an example would depend on a tree nothing tracks, and the cached result would survive an edit to the constant it quotes — stale documentation that looks correct, which is a worse failure than the degradation this plan fixes. Whoever lifts the restriction later has to make that dependency explicit rather than rely on the analyzer happening to hold a `Compilation`. + +Diagnostic locations need the same care, and here the current code is already insufficient for what this plan promises. `DiagnosticsEqual` compares only `Id`, `Severity`, and `GetMessage()`, so two analyses whose diagnostics differ only in position compare equal. Since the new diagnostics are located at a specific argument, moving an unresolved argument — adding a line above it, reordering rules — produces an identical comparison and the driver keeps the cached analysis, leaving the squiggle on the old position. `Equals` and `GetHashCode` must therefore incorporate each diagnostic's source path and span. The criteria require an incremental test that reuses one driver across two compilations to catch this, because a single-shot generator test cannot: it never consults the cache and so passes either way. ### Scope From d41de5a306a52f25e121c7e24aed18cd88b3a34e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 1 Aug 2026 23:53:50 +0200 Subject: [PATCH 03/13] docs(plan): accept both TimeSpan.From* overload families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The factory whitelist accepted only the double overloads, which rejected TimeSpan.FromHours(2) — the plan's own worked example for a nested DateTimeOffset offset. Verified against the installed SDK: .NET 10 exposes FromHours(Int32), FromHours(Int32, Int64...) and FromHours(Double), and the int literal binds to the exact match. Which family binds is not a property of the source text: the same expression resolves to the double overload on netstandard2.0 or net6.0 and to the integral one on net8.0 and later, so both must be accepted or reconstruction would depend on the consumer's target framework. Accept the single-argument overloads of FromDays, FromHours, FromMinutes, FromSeconds, FromMilliseconds and FromMicroseconds in both forms, the last of these being the explicit decision the review asked for. Exclude the multi-argument component overloads, and require matching on the full resolved signature, because their optional parameters make them candidates for a name-based match. Record that the integral overloads cannot be called by the generator at all, since netstandard2.0 lacks them: compute those from ticks in checked arithmetic rather than substituting the double overload, which rounds to milliseconds and has a different range. Refs #57 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0184eh1G3XxvjRB11cNKyMRT --- ...-openapi-examples-for-non-constant-boundaries.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md index fe83562..2acc605 100644 --- a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -16,7 +16,7 @@ Date and time boundaries are among the cases where a client most needs the bound - [ ] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. - [ ] A rule whose metadata cannot be reconstructed reports a new diagnostic identifying the rule and the argument, and a boundary computed at runtime such as `DateTime.UtcNow.AddDays(30)` reports it rather than failing the build. An expression that exceeds the recursion bound, and a `static readonly` field whose initializer chain forms a cycle, both reach that same diagnostic instead of hanging or crashing the compiler. - [ ] An expression that is valid C# but throws when evaluated — `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)` — produces the diagnostic and a degraded example rather than an unhandled exception, and the compilation that contains it still succeeds. `OperationCanceledException` is the only exception that propagates out of reconstruction. -- [ ] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. +- [ ] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. The unsuffixed `TimeSpan.FromHours(2)` is covered as written, so that overload resolution itself is exercised rather than assumed, and the multi-argument component overloads are asserted to be rejected. - [ ] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. - [ ] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. - [ ] `ValidatorOpenApiAnalysis` equality and hashing account for each diagnostic's source path and span, and an incremental test that reuses one generator driver across two compilations — moving an unresolved argument without otherwise changing it — observes the reported location move with it. @@ -86,7 +86,7 @@ Static factory methods: | Type | Accepted factories | | --- | --- | -| `TimeSpan` | `FromTicks(long)`, and the `double` overloads of `FromDays`, `FromHours`, `FromMinutes`, `FromSeconds`, `FromMilliseconds` | +| `TimeSpan` | `FromTicks(long)`, and the **single-argument** overloads of `FromDays`, `FromHours`, `FromMinutes`, `FromSeconds`, `FromMilliseconds`, `FromMicroseconds`, in both their `double` and integral forms — `FromDays(int)`, `FromHours(int)`, `FromMinutes(long)`, `FromSeconds(long)`, `FromMilliseconds(long)`, `FromMicroseconds(long)`. The multi-argument component overloads are excluded | | `DateOnly` | `FromDayNumber(int)` | | `DateTimeOffset` | `FromUnixTimeSeconds(long)`, `FromUnixTimeMilliseconds(long)` | | `Guid` | `Parse(string)`, `ParseExact(string, string)` | @@ -102,6 +102,12 @@ Well-known statics, recognized by symbol rather than syntax: | `TimeOnly` | `MinValue`, `MaxValue` | | `Guid` | `Empty` | +Both the `double` and integral `TimeSpan.From*` families must be accepted, because which one a boundary binds to is not a property of the source text. .NET 8 added integral overloads, so `TimeSpan.FromHours(2)` binds to `FromHours(double)` when the consumer targets `netstandard2.0` or `net6.0` and to `FromHours(int)` on `net8.0` and later, where the `int` literal is an exact match. Accepting only one family would make reconstruction depend on the consumer's target framework — and would reject `TimeSpan.FromHours(2)`, this plan's own worked example, on any current target. + +Recognition must match the full resolved signature, not the method name. The component overloads have optional parameters, so `FromHours(int, long, long, long, long)` is a candidate for a name-based match and must be rejected by comparing the symbol's parameters. + +Evaluating the integral overloads needs care for the reason given above: they do not exist in `netstandard2.0`, so the generator cannot call them. Compute those from the payload arithmetically — ticks as the quantity multiplied by the corresponding `TimeSpan.TicksPer*` constant, in checked arithmetic so that an overflow throws and lands in the failure contract. Do not silently substitute the `double` overload for an integral one: the two agree on ordinary integral values, but `FromDays(double)` rounds to the nearest millisecond and has a different range, so at the extremes they are not the same function. + The offset argument of a `DateTimeOffset` accepts any accepted `TimeSpan` shape: `TimeSpan.Zero`, the `From*` factories, `new TimeSpan(...)`, `MinValue`/`MaxValue`, or a `static readonly` `TimeSpan` field declared in source. `MinValue` and `MaxValue` are accepted as shapes and then throw during evaluation, which the failure contract below turns into the diagnostic — the recognizer does not need to know which offsets are legal. Everything else is omitted, and an omission is a diagnostic rather than a silent gap: @@ -117,7 +123,8 @@ Everything else is omitted, and an omission is a diagnostic rather than a silent | Locals, parameters, properties, and instance or non-`readonly` fields | Value depends on flow the generator does not track | | `static readonly` fields declared outside the validator's syntax tree, including referenced assemblies | Multi-file resolution is deliberately unsupported in this iteration and reports the warning | | Arithmetic and chaining such as `X.AddDays(1)` or `a + b` | Would require a general expression evaluator rather than a whitelist | -| `TimeSpan.FromMicroseconds`, the integer `TimeSpan.From*` overloads, `DateOnly.FromDateTime`, `TimeOnly.FromDateTime`/`FromTimeSpan` | Not needed for a boundary; add on demand rather than by default | +| The multi-argument `TimeSpan.From*` component overloads, such as `FromHours(int, long, long, long, long)` | A boundary is written as one quantity; these exist mainly to spell out components, and their optional parameters make them easy to match by accident | +| `DateOnly.FromDateTime`, `TimeOnly.FromDateTime`/`FromTimeSpan` | Not needed for a boundary; add on demand rather than by default | | Any reconstructed `DateTime` whose `Kind` is `Local` | Nondeterministic; see Excluded shapes | ### Reconstruction must not be able to break a build From 0052993a7dfe84208fba4cbf5b5bfe471fabdb88 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 04:52:19 +0200 Subject: [PATCH 04/13] feat(openapi): reconstruct non-constant boundaries Reconstruct the whitelisted temporal, Guid, and Uri validation boundary shapes, including recursively nested values and eligible static readonly fields. Keep generated messages and metadata aligned through a deterministic structural representation. Report unreconstructable values and unsupported multi-file field resolution at the offending argument, and include diagnostic locations in incremental equality. Cover accepted and rejected shapes, incremental movement, published examples, and document the behavior. Closes #57 --- README.md | 4 +- ...pi-examples-for-non-constant-boundaries.md | 28 +- .../DiagnosticDescriptors.cs | 20 + .../MetadataValueReconstructor.cs | 1240 +++++++++++++++++ .../ReconstructedMetadataValue.cs | 300 ++++ .../ValidatorOpenApiAnalyzer.cs | 79 +- .../ValidatorOpenApiEmitter.cs | 97 +- .../ValidatorOpenApiModels.cs | 13 +- .../BoundaryMetadataIncrementalTests.cs | 69 + .../BoundaryMetadataReconstructionTests.cs | 378 +++++ .../BoundaryMetadataRejectionTests.cs | 469 +++++++ .../GeneratorTestHarness.cs | 104 ++ .../ValidatorOpenApiModelEqualityTests.cs | 47 + ...TemporalMetadataOpenApiConformanceTests.cs | 18 + 14 files changed, 2786 insertions(+), 80 deletions(-) create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs diff --git a/README.md b/README.md index d3ca7dd..d960059 100644 --- a/README.md +++ b/README.md @@ -1216,7 +1216,9 @@ app.MapPut("/api/movieRatings", AddMovieRating) ); ``` -The generator analyzes top-level `context.Check(...).Rule(...)` chains and produces response schemas and examples when metadata arguments are compile-time constants (e.g. `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`). +The generator analyzes top-level `context.Check(...).Rule(...)` chains and produces response schemas and examples from compile-time constants (for example, `HasLengthIn(10, 1000)` or `IsInRange(1, 5)`). It also reconstructs deterministic `DateTime`, `DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`, `Guid`, and `Uri` boundaries written with supported constructors, factories, well-known static values, or `static readonly` fields declared in the validator's file. Reconstructed message values and example metadata use the same canonical text. + +Runtime-computed or unsupported boundary expressions still generate valid schemas, but the affected example omits its message and metadata and reports `LPRSG0015` at the argument. `DateTimeKind.Local`, culture-sensitive parsing, arithmetic or chained calls, and invalid constructor or factory arguments follow this degraded path. A `static readonly` field declared in another file or assembly is deliberately not followed and reports `LPRSG0016`; write the value inline or move its declaration into the validator's file when the complete example is required. Use `[PortableValidationOpenApiErrorHint]` to annotate codes the generator cannot infer (for example, from `Must(...)`, `Custom(...)`, or child validators): diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md index 2acc605..9fdd55f 100644 --- a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -8,20 +8,20 @@ Date and time boundaries are among the cases where a client most needs the bound ## Acceptance Criteria -- [ ] A boundary written as an object creation whose arguments are themselves constants or recognized shapes — `new DateTime(...)`, `new DateTimeOffset(...)`, `new TimeSpan(...)`, `new DateOnly(...)`, `new TimeOnly(...)`, `new Guid(...)`, `new Uri("...")` — produces an error example carrying both the message and the metadata entry, both derived from the same reconstructed value. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is covered, since no `DateTimeOffset` boundary worth writing has an offset that folds as a constant. -- [ ] The accepted shapes are exactly the ones enumerated in the whitelist tables in Technical Details, and the named factories and well-known statics there reconstruct identically to the equivalent object creation. No overload, factory, or static outside those tables is reconstructed, and every entry in the omissions table reaches the diagnostic rather than being silently unsupported. -- [ ] A reference to a `static readonly` field **declared in the validator's own file**, whose declaring type has no other source declaration, and whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. -- [ ] A field declared in another file or another assembly is not resolved. It reports a distinct warning naming multi-file resolution as unsupported, and the example degrades rather than being reconstructed from a partial view of the field. -- [ ] A `DateTime` boundary whose kind is `DateTimeKind.Local` is rejected with the diagnostic instead of reconstructed, while `Utc` and `Unspecified` reconstruct; the generated file for a given source tree is identical regardless of the build machine's time zone. -- [ ] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. -- [ ] A rule whose metadata cannot be reconstructed reports a new diagnostic identifying the rule and the argument, and a boundary computed at runtime such as `DateTime.UtcNow.AddDays(30)` reports it rather than failing the build. An expression that exceeds the recursion bound, and a `static readonly` field whose initializer chain forms a cycle, both reach that same diagnostic instead of hanging or crashing the compiler. -- [ ] An expression that is valid C# but throws when evaluated — `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)` — produces the diagnostic and a degraded example rather than an unhandled exception, and the compilation that contains it still succeeds. `OperationCanceledException` is the only exception that propagates out of reconstruction. -- [ ] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. The unsuffixed `TimeSpan.FromHours(2)` is covered as written, so that overload resolution itself is exercised rather than assumed, and the multi-argument component overloads are asserted to be rejected. -- [ ] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. -- [ ] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. -- [ ] `ValidatorOpenApiAnalysis` equality and hashing account for each diagnostic's source path and span, and an incremental test that reuses one generator driver across two compilations — moving an unresolved argument without otherwise changing it — observes the reported location move with it. -- [ ] `README.md` no longer states that examples require compile-time constant metadata arguments, and describes what is reconstructed and what degrades. -- [ ] Test code coverage stays above 95%, and no existing generated output changes for boundaries that already fold today. +- [x] A boundary written as an object creation whose arguments are themselves constants or recognized shapes — `new DateTime(...)`, `new DateTimeOffset(...)`, `new TimeSpan(...)`, `new DateOnly(...)`, `new TimeOnly(...)`, `new Guid(...)`, `new Uri("...")` — produces an error example carrying both the message and the metadata entry, both derived from the same reconstructed value. `new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(2))` is covered, since no `DateTimeOffset` boundary worth writing has an offset that folds as a constant. +- [x] The accepted shapes are exactly the ones enumerated in the whitelist tables in Technical Details, and the named factories and well-known statics there reconstruct identically to the equivalent object creation. No overload, factory, or static outside those tables is reconstructed, and every entry in the omissions table reaches the diagnostic rather than being silently unsupported. +- [x] A reference to a `static readonly` field **declared in the validator's own file**, whose declaring type has no other source declaration, and whose initializer is one of the supported shapes and is the field's only assignment, reconstructs to the same value as writing that expression inline at the call site. A field that a static constructor also assigns degrades with the diagnostic. +- [x] A field declared in another file or another assembly is not resolved. It reports a distinct warning naming multi-file resolution as unsupported, and the example degrades rather than being reconstructed from a partial view of the field. +- [x] A `DateTime` boundary whose kind is `DateTimeKind.Local` is rejected with the diagnostic instead of reconstructed, while `Utc` and `Unspecified` reconstruct; the generated file for a given source tree is identical regardless of the build machine's time zone. +- [x] The value rendered into the message text and the value carried in the metadata entry are the same text for every reconstructed kind, verified against `MetadataValue.ToCanonicalString()` rather than against a hard-coded expectation. +- [x] A rule whose metadata cannot be reconstructed reports a new diagnostic identifying the rule and the argument, and a boundary computed at runtime such as `DateTime.UtcNow.AddDays(30)` reports it rather than failing the build. An expression that exceeds the recursion bound, and a `static readonly` field whose initializer chain forms a cycle, both reach that same diagnostic instead of hanging or crashing the compiler. +- [x] An expression that is valid C# but throws when evaluated — `new DateTime(2026, 13, 1)`, `Guid.Parse("invalid")`, `new Uri("http://[")`, `TimeSpan.FromDays(double.MaxValue)` — produces the diagnostic and a degraded example rather than an unhandled exception, and the compilation that contains it still succeeds. `OperationCanceledException` is the only exception that propagates out of reconstruction. +- [x] Generator tests assert the emitted `WithErrorExample` call, driven through the analyzer rather than by calling `ToLiteral` directly, and cover every row of the whitelist tables: each accepted constructor family, each named factory, each well-known static, a nested `DateTimeOffset` offset in each accepted `TimeSpan` form, and a `static readonly` field in the validator's own file. The unsuffixed `TimeSpan.FromHours(2)` is covered as written, so that overload resolution itself is exercised rather than assumed, and the multi-argument component overloads are asserted to be rejected. +- [x] Generator tests also cover the rejection paths with their exact severity and location: a cross-file field, a `DateTimeKind.Local` value, each invalid or overflowing expression from the failure contract, a runtime-computed boundary, a recursion-bound and a cycle case, and a user-defined type or member whose name matches an accepted one but whose symbol does not. +- [x] A runtime test asserts that a reconstructed example reaches the published document with its message and metadata intact. +- [x] `ValidatorOpenApiAnalysis` equality and hashing account for each diagnostic's source path and span, and an incremental test that reuses one generator driver across two compilations — moving an unresolved argument without otherwise changing it — observes the reported location move with it. +- [x] `README.md` no longer states that examples require compile-time constant metadata arguments, and describes what is reconstructed and what degrades. +- [x] Test code coverage stays above 95%, and no existing generated output changes for boundaries that already fold today. ## Technical Details diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs index 6a3f9b5..d522a6a 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/DiagnosticDescriptors.cs @@ -148,4 +148,24 @@ public static class DiagnosticDescriptors isEnabledByDefault: true, helpLinkUri: HelpLinkBase ); + + public static readonly DiagnosticDescriptor MetadataValueCannotBeReconstructed = new ( + "LPRSG0015", + "Validation metadata value cannot be reconstructed", + "Validation rule '{0}' metadata argument '{1}' cannot be reconstructed for an OpenAPI error example", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); + + public static readonly DiagnosticDescriptor MultiFileMetadataFieldUnsupported = new ( + "LPRSG0016", + "Multi-file validation metadata field resolution is unsupported", + "Validation rule '{0}' metadata argument '{1}' references a static readonly field that requires unsupported multi-file resolution; declare it in the validator's file or write it inline", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: HelpLinkBase + ); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs new file mode 100644 index 0000000..c2921a3 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -0,0 +1,1240 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +internal enum MetadataReconstructionResult +{ + Success = 0, + Unsupported = 1, + MultiFileFieldUnsupported = 2 +} + +internal static class MetadataValueReconstructor +{ + private const int MaximumDepth = 4; + private const long TicksPerMicrosecond = 10L; + + public static MetadataReconstructionResult TryReconstruct( + SemanticModel semanticModel, + ExpressionSyntax expression, + CancellationToken cancellationToken, + out ReconstructedMetadataValue? value + ) + { + var frameworkTypes = new FrameworkTypes(semanticModel.Compilation); + var resolvingFields = new HashSet(SymbolEqualityComparer.Default); + return TryReconstruct( + semanticModel, + expression, + frameworkTypes, + depth: 0, + resolvingFields, + cancellationToken, + out value + ); + } + + private static MetadataReconstructionResult TryReconstruct( + SemanticModel semanticModel, + ExpressionSyntax expression, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out ReconstructedMetadataValue? value + ) + { + cancellationToken.ThrowIfCancellationRequested(); + value = null; + if (depth > MaximumDepth) + { + return MetadataReconstructionResult.Unsupported; + } + + var constant = semanticModel.GetConstantValue(expression, cancellationToken); + if (constant.HasValue) + { + return MetadataReconstructionResult.Unsupported; + } + + var operation = semanticModel.GetOperation(expression, cancellationToken); + switch (operation) + { + case IObjectCreationOperation objectCreation: + return TryReconstructObjectCreation( + semanticModel, + objectCreation, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IInvocationOperation invocation: + return TryReconstructInvocation( + semanticModel, + invocation, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IFieldReferenceOperation fieldReference: + return TryReconstructField( + semanticModel, + fieldReference.Field, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out value + ); + case IPropertyReferenceOperation propertyReference + when TryGetWellKnownStatic(propertyReference.Property, frameworkTypes, out value): + return MetadataReconstructionResult.Success; + default: + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructObjectCreation( + SemanticModel semanticModel, + IObjectCreationOperation operation, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out ReconstructedMetadataValue? value + ) + { + value = null; + var constructor = operation.Constructor; + if (constructor is null || + !TryGetSupportedConstructor(constructor, frameworkTypes, out var supportedConstructor)) + { + return MetadataReconstructionResult.Unsupported; + } + + var argumentResult = TryReconstructArguments( + semanticModel, + constructor, + operation.Arguments, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out var arguments + ); + if (argumentResult != MetadataReconstructionResult.Success) + { + return argumentResult; + } + + try + { + value = EvaluateConstructor(supportedConstructor, arguments); + return MetadataReconstructionResult.Success; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + value = null; + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructInvocation( + SemanticModel semanticModel, + IInvocationOperation operation, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out ReconstructedMetadataValue? value + ) + { + value = null; + var method = operation.TargetMethod; + if (!TryGetSupportedFactory(method, frameworkTypes, out var supportedFactory)) + { + return MetadataReconstructionResult.Unsupported; + } + + var argumentResult = TryReconstructArguments( + semanticModel, + method, + operation.Arguments, + frameworkTypes, + depth, + resolvingFields, + cancellationToken, + out var arguments + ); + if (argumentResult != MetadataReconstructionResult.Success) + { + return argumentResult; + } + + try + { + value = EvaluateFactory(supportedFactory, arguments); + return MetadataReconstructionResult.Success; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + value = null; + return MetadataReconstructionResult.Unsupported; + } + } + + private static MetadataReconstructionResult TryReconstructArguments( + SemanticModel semanticModel, + IMethodSymbol method, + ImmutableArray argumentOperations, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out object?[] arguments + ) + { + arguments = new object?[method.Parameters.Length]; + var assigned = new bool[method.Parameters.Length]; + foreach (var argumentOperation in argumentOperations) + { + cancellationToken.ThrowIfCancellationRequested(); + var parameter = argumentOperation.Parameter; + if (parameter is null || argumentOperation.IsImplicit) + { + return MetadataReconstructionResult.Unsupported; + } + + if (argumentOperation.Value.Syntax is not ExpressionSyntax argumentExpression) + { + return MetadataReconstructionResult.Unsupported; + } + + var constant = semanticModel.GetConstantValue(argumentExpression, cancellationToken); + if (constant.HasValue) + { + arguments[parameter.Ordinal] = constant.Value; + assigned[parameter.Ordinal] = true; + continue; + } + + var reconstructionResult = TryReconstruct( + semanticModel, + argumentExpression, + frameworkTypes, + depth + 1, + resolvingFields, + cancellationToken, + out var reconstructedValue + ); + if (reconstructionResult != MetadataReconstructionResult.Success) + { + return reconstructionResult; + } + + arguments[parameter.Ordinal] = reconstructedValue; + assigned[parameter.Ordinal] = true; + } + + return assigned.All(static isAssigned => isAssigned) ? + MetadataReconstructionResult.Success : + MetadataReconstructionResult.Unsupported; + } + + private static MetadataReconstructionResult TryReconstructField( + SemanticModel semanticModel, + IFieldSymbol field, + FrameworkTypes frameworkTypes, + int depth, + ISet resolvingFields, + CancellationToken cancellationToken, + out ReconstructedMetadataValue? value + ) + { + value = null; + if (TryGetWellKnownStatic(field, frameworkTypes, out value)) + { + return MetadataReconstructionResult.Success; + } + + if (!field.IsStatic || !field.IsReadOnly) + { + return MetadataReconstructionResult.Unsupported; + } + + if (field.DeclaringSyntaxReferences.Length != 1 || + field.ContainingType.DeclaringSyntaxReferences.Length != 1) + { + return MetadataReconstructionResult.MultiFileFieldUnsupported; + } + + var fieldSyntaxReference = field.DeclaringSyntaxReferences[0]; + var typeSyntaxReference = field.ContainingType.DeclaringSyntaxReferences[0]; + if (fieldSyntaxReference.SyntaxTree != semanticModel.SyntaxTree || + typeSyntaxReference.SyntaxTree != semanticModel.SyntaxTree) + { + return MetadataReconstructionResult.MultiFileFieldUnsupported; + } + + if (!resolvingFields.Add(field)) + { + return MetadataReconstructionResult.Unsupported; + } + + try + { + if (fieldSyntaxReference.GetSyntax(cancellationToken) is not VariableDeclaratorSyntax + { + Initializer.Value: ExpressionSyntax initializer + } || + IsAssignedInStaticConstructor( + semanticModel, + field, + typeSyntaxReference, + cancellationToken + )) + { + return MetadataReconstructionResult.Unsupported; + } + + return TryReconstruct( + semanticModel, + initializer, + frameworkTypes, + depth + 1, + resolvingFields, + cancellationToken, + out value + ); + } + finally + { + resolvingFields.Remove(field); + } + } + + private static bool IsAssignedInStaticConstructor( + SemanticModel semanticModel, + IFieldSymbol field, + SyntaxReference typeSyntaxReference, + CancellationToken cancellationToken + ) + { + if (typeSyntaxReference.GetSyntax(cancellationToken) is not TypeDeclarationSyntax typeDeclaration) + { + return true; + } + + foreach (var constructor in typeDeclaration.Members + .OfType() + .Where(static constructor => constructor.Modifiers.Any(SyntaxKind.StaticKeyword))) + { + foreach (var assignment in constructor.DescendantNodes().OfType()) + { + cancellationToken.ThrowIfCancellationRequested(); + var assignedSymbol = semanticModel.GetSymbolInfo(assignment.Left, cancellationToken).Symbol; + if (SymbolEqualityComparer.Default.Equals(assignedSymbol, field)) + { + return true; + } + } + } + + return false; + } + + private static bool TryGetWellKnownStatic( + ISymbol field, + FrameworkTypes frameworkTypes, + out ReconstructedMetadataValue? value + ) + { + value = null; + if (!field.IsStatic) + { + return false; + } + + if (IsType(field.ContainingType, frameworkTypes.DateTime)) + { + var dateTime = field.Name switch + { + "MinValue" => DateTime.MinValue, + "MaxValue" => DateTime.MaxValue, + "UnixEpoch" => new DateTime(621_355_968_000_000_000L, DateTimeKind.Utc), + _ => (DateTime?) null + }; + if (dateTime.HasValue) + { + value = ReconstructedMetadataValue.FromDateTime(dateTime.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.DateTimeOffset)) + { + var dateTimeOffset = field.Name switch + { + "MinValue" => DateTimeOffset.MinValue, + "MaxValue" => DateTimeOffset.MaxValue, + "UnixEpoch" => new DateTimeOffset(621_355_968_000_000_000L, TimeSpan.Zero), + _ => (DateTimeOffset?) null + }; + if (dateTimeOffset.HasValue) + { + value = ReconstructedMetadataValue.FromDateTimeOffset(dateTimeOffset.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.TimeSpan)) + { + var timeSpan = field.Name switch + { + "Zero" => TimeSpan.Zero, + "MinValue" => TimeSpan.MinValue, + "MaxValue" => TimeSpan.MaxValue, + _ => (TimeSpan?) null + }; + if (timeSpan.HasValue) + { + value = ReconstructedMetadataValue.FromTimeSpan(timeSpan.Value); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.DateOnly)) + { + if (field.Name == "MinValue") + { + value = ReconstructedMetadataValue.FromDateOnlyDayNumber(0); + return true; + } + + if (field.Name == "MaxValue") + { + value = ReconstructedMetadataValue.FromDateOnlyDayNumber(3_652_058); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.TimeOnly)) + { + if (field.Name == "MinValue") + { + value = ReconstructedMetadataValue.FromTimeOnlyTicks(0L); + return true; + } + + if (field.Name == "MaxValue") + { + value = ReconstructedMetadataValue.FromTimeOnlyTicks(TimeSpan.TicksPerDay - 1L); + return true; + } + } + + if (IsType(field.ContainingType, frameworkTypes.Guid) && field.Name == "Empty") + { + value = ReconstructedMetadataValue.FromGuid(Guid.Empty); + return true; + } + + return false; + } + + private static bool TryGetSupportedConstructor( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedConstructor constructor + ) + { + constructor = default; + if (Matches(method, frameworkTypes.DateTime, ParameterType.Int32, ParameterType.Int32, ParameterType.Int32)) + { + constructor = SupportedConstructor.DateTimeDate; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateTimeSecond; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateTimeMillisecond; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.DateTimeKind + )) + { + constructor = SupportedConstructor.DateTimeSecondKind; + } + else if (Matches( + method, + frameworkTypes.DateTime, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.DateTimeKind + )) + { + constructor = SupportedConstructor.DateTimeMillisecondKind; + } + else if (Matches(method, frameworkTypes.DateTime, ParameterType.Int64)) + { + constructor = SupportedConstructor.DateTimeTicks; + } + else if (Matches(method, frameworkTypes.DateTime, ParameterType.Int64, ParameterType.DateTimeKind)) + { + constructor = SupportedConstructor.DateTimeTicksKind; + } + else if (Matches( + method, + frameworkTypes.DateTimeOffset, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.TimeSpan + )) + { + constructor = SupportedConstructor.DateTimeOffsetSecond; + } + else if (Matches( + method, + frameworkTypes.DateTimeOffset, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.TimeSpan + )) + { + constructor = SupportedConstructor.DateTimeOffsetMillisecond; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, ParameterType.Int64, ParameterType.TimeSpan)) + { + constructor = SupportedConstructor.DateTimeOffsetTicks; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, ParameterType.DateTime, ParameterType.TimeSpan)) + { + constructor = SupportedConstructor.DateTimeOffsetDateTime; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanHours; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanDays; + } + else if (Matches( + method, + frameworkTypes.TimeSpan, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeSpanMilliseconds; + } + else if (Matches(method, frameworkTypes.TimeSpan, ParameterType.Int64)) + { + constructor = SupportedConstructor.TimeSpanTicks; + } + else if (Matches( + method, + frameworkTypes.DateOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.DateOnly; + } + else if (Matches(method, frameworkTypes.TimeOnly, ParameterType.Int32, ParameterType.Int32)) + { + constructor = SupportedConstructor.TimeOnlyMinute; + } + else if (Matches( + method, + frameworkTypes.TimeOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeOnlySecond; + } + else if (Matches( + method, + frameworkTypes.TimeOnly, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32, + ParameterType.Int32 + )) + { + constructor = SupportedConstructor.TimeOnlyMillisecond; + } + else if (Matches(method, frameworkTypes.TimeOnly, ParameterType.Int64)) + { + constructor = SupportedConstructor.TimeOnlyTicks; + } + else if (Matches(method, frameworkTypes.Guid, ParameterType.String)) + { + constructor = SupportedConstructor.Guid; + } + else if (Matches(method, frameworkTypes.Uri, ParameterType.String)) + { + constructor = SupportedConstructor.Uri; + } + else if (Matches(method, frameworkTypes.Uri, ParameterType.String, ParameterType.UriKind)) + { + constructor = SupportedConstructor.UriKind; + } + else + { + return false; + } + + return true; + } + + private static bool TryGetSupportedFactory( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedFactory factory + ) + { + factory = default; + if (Matches(method, frameworkTypes.TimeSpan, "FromTicks", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromTicks; + } + else if (TryGetTimeSpanQuantityFactory(method, frameworkTypes, out factory)) + { + return true; + } + else if (Matches(method, frameworkTypes.DateOnly, "FromDayNumber", ParameterType.Int32)) + { + factory = SupportedFactory.DateOnlyFromDayNumber; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, "FromUnixTimeSeconds", ParameterType.Int64)) + { + factory = SupportedFactory.DateTimeOffsetFromUnixTimeSeconds; + } + else if (Matches(method, frameworkTypes.DateTimeOffset, "FromUnixTimeMilliseconds", ParameterType.Int64)) + { + factory = SupportedFactory.DateTimeOffsetFromUnixTimeMilliseconds; + } + else if (Matches(method, frameworkTypes.Guid, "Parse", ParameterType.String)) + { + factory = SupportedFactory.GuidParse; + } + else if (Matches(method, frameworkTypes.Guid, "ParseExact", ParameterType.String, ParameterType.String)) + { + factory = SupportedFactory.GuidParseExact; + } + else + { + return false; + } + + return true; + } + + private static bool TryGetTimeSpanQuantityFactory( + IMethodSymbol method, + FrameworkTypes frameworkTypes, + out SupportedFactory factory + ) + { + factory = default; + if (Matches(method, frameworkTypes.TimeSpan, "FromDays", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromDaysDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromDays", ParameterType.Int32)) + { + factory = SupportedFactory.TimeSpanFromDaysIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromHours", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromHoursDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromHours", ParameterType.Int32)) + { + factory = SupportedFactory.TimeSpanFromHoursIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMinutes", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMinutesDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMinutes", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMinutesIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromSeconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromSecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromSeconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromSecondsIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMilliseconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMillisecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMilliseconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMillisecondsIntegral; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMicroseconds", ParameterType.Double)) + { + factory = SupportedFactory.TimeSpanFromMicrosecondsDouble; + } + else if (Matches(method, frameworkTypes.TimeSpan, "FromMicroseconds", ParameterType.Int64)) + { + factory = SupportedFactory.TimeSpanFromMicrosecondsIntegral; + } + else + { + return false; + } + + return true; + } + + private static ReconstructedMetadataValue EvaluateConstructor( + SupportedConstructor constructor, + IReadOnlyList arguments + ) => + constructor switch + { + SupportedConstructor.DateTimeDate => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt32(arguments[0]), ToInt32(arguments[1]), ToInt32(arguments[2])) + ), + SupportedConstructor.DateTimeSecond => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]) + ) + ), + SupportedConstructor.DateTimeMillisecond => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]) + ) + ), + SupportedConstructor.DateTimeSecondKind => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToDateTimeKind(arguments[6]) + ) + ), + SupportedConstructor.DateTimeMillisecondKind => ReconstructedMetadataValue.FromDateTime( + new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]), + ToDateTimeKind(arguments[7]) + ) + ), + SupportedConstructor.DateTimeTicks => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt64(arguments[0])) + ), + SupportedConstructor.DateTimeTicksKind => ReconstructedMetadataValue.FromDateTime( + new DateTime(ToInt64(arguments[0]), ToDateTimeKind(arguments[1])) + ), + SupportedConstructor.DateTimeOffsetSecond => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToTimeSpan(arguments[6]) + ) + ), + SupportedConstructor.DateTimeOffsetMillisecond => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]), + ToInt32(arguments[5]), + ToInt32(arguments[6]), + ToTimeSpan(arguments[7]) + ) + ), + SupportedConstructor.DateTimeOffsetTicks => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset(ToInt64(arguments[0]), ToTimeSpan(arguments[1])) + ), + SupportedConstructor.DateTimeOffsetDateTime => ReconstructedMetadataValue.FromDateTimeOffset( + new DateTimeOffset(ToDateTime(arguments[0]), ToTimeSpan(arguments[1])) + ), + SupportedConstructor.TimeSpanHours => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan(ToInt32(arguments[0]), ToInt32(arguments[1]), ToInt32(arguments[2])) + ), + SupportedConstructor.TimeSpanDays => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]) + ) + ), + SupportedConstructor.TimeSpanMilliseconds => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]), + ToInt32(arguments[4]) + ) + ), + SupportedConstructor.TimeSpanTicks => ReconstructedMetadataValue.FromTimeSpan( + new TimeSpan(ToInt64(arguments[0])) + ), + SupportedConstructor.DateOnly => ReconstructedMetadataValue.FromDateOnlyDayNumber( + checked( + (int) (new DateTime( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]) + ).Ticks / + TimeSpan.TicksPerDay) + ) + ), + SupportedConstructor.TimeOnlyMinute => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + second: 0, + millisecond: 0 + ), + SupportedConstructor.TimeOnlySecond => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + millisecond: 0 + ), + SupportedConstructor.TimeOnlyMillisecond => CreateTimeOnly( + ToInt32(arguments[0]), + ToInt32(arguments[1]), + ToInt32(arguments[2]), + ToInt32(arguments[3]) + ), + SupportedConstructor.TimeOnlyTicks => CreateTimeOnly(ToInt64(arguments[0])), + SupportedConstructor.Guid => ReconstructedMetadataValue.FromGuid(new Guid((string) arguments[0]!)), + SupportedConstructor.Uri => CreateUri(new Uri((string) arguments[0]!, UriKind.RelativeOrAbsolute)), + SupportedConstructor.UriKind => CreateUri( + new Uri((string) arguments[0]!, ToUriKind(arguments[1])) + ), + _ => throw new InvalidOperationException($"Unsupported constructor '{constructor}'.") + }; + + private static ReconstructedMetadataValue EvaluateFactory( + SupportedFactory factory, + IReadOnlyList arguments + ) => + factory switch + { + SupportedFactory.TimeSpanFromTicks => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromTicks(ToInt64(arguments[0])) + ), + SupportedFactory.TimeSpanFromDaysDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromDays(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromDaysIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerDay + ), + SupportedFactory.TimeSpanFromHoursDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromHours(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromHoursIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerHour + ), + SupportedFactory.TimeSpanFromMinutesDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromMinutes(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromMinutesIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerMinute + ), + SupportedFactory.TimeSpanFromSecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromSeconds(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromSecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerSecond + ), + SupportedFactory.TimeSpanFromMillisecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + TimeSpan.FromMilliseconds(ToDouble(arguments[0])) + ), + SupportedFactory.TimeSpanFromMillisecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TimeSpan.TicksPerMillisecond + ), + SupportedFactory.TimeSpanFromMicrosecondsDouble => ReconstructedMetadataValue.FromTimeSpan( + CreateDoubleTimeSpan(ToDouble(arguments[0]), TicksPerMicrosecond) + ), + SupportedFactory.TimeSpanFromMicrosecondsIntegral => CreateIntegralTimeSpan( + ToInt64(arguments[0]), + TicksPerMicrosecond + ), + SupportedFactory.DateOnlyFromDayNumber => CreateDateOnlyFromDayNumber(ToInt32(arguments[0])), + SupportedFactory.DateTimeOffsetFromUnixTimeSeconds => + ReconstructedMetadataValue.FromDateTimeOffset( + DateTimeOffset.FromUnixTimeSeconds(ToInt64(arguments[0])) + ), + SupportedFactory.DateTimeOffsetFromUnixTimeMilliseconds => + ReconstructedMetadataValue.FromDateTimeOffset( + DateTimeOffset.FromUnixTimeMilliseconds(ToInt64(arguments[0])) + ), + SupportedFactory.GuidParse => ReconstructedMetadataValue.FromGuid(Guid.Parse((string) arguments[0]!)), + SupportedFactory.GuidParseExact => ReconstructedMetadataValue.FromGuid( + Guid.ParseExact((string) arguments[0]!, (string) arguments[1]!) + ), + _ => throw new InvalidOperationException($"Unsupported factory '{factory}'.") + }; + + private static ReconstructedMetadataValue CreateIntegralTimeSpan(long quantity, long ticksPerUnit) => + ReconstructedMetadataValue.FromTimeSpan(TimeSpan.FromTicks(checked(quantity * ticksPerUnit))); + + private static ReconstructedMetadataValue CreateDateOnlyFromDayNumber(int dayNumber) + { + if (dayNumber < 0 || dayNumber > 3_652_058) + { + throw new ArgumentOutOfRangeException(nameof(dayNumber)); + } + + return ReconstructedMetadataValue.FromDateOnlyDayNumber(dayNumber); + } + + private static ReconstructedMetadataValue CreateTimeOnly( + int hour, + int minute, + int second, + int millisecond + ) + { + var value = new DateTime(1, 1, 1, hour, minute, second, millisecond, DateTimeKind.Unspecified); + return ReconstructedMetadataValue.FromTimeOnlyTicks(value.Ticks); + } + + private static ReconstructedMetadataValue CreateTimeOnly(long ticks) + { + if (ticks < 0 || ticks >= TimeSpan.TicksPerDay) + { + throw new ArgumentOutOfRangeException(nameof(ticks)); + } + + return ReconstructedMetadataValue.FromTimeOnlyTicks(ticks); + } + + private static ReconstructedMetadataValue CreateUri(Uri value) => + ReconstructedMetadataValue.FromUriOriginalString(value.OriginalString); + + private static TimeSpan CreateDoubleTimeSpan(double value, double ticksPerUnit) + { + if (double.IsNaN(value)) + { + throw new ArgumentException("TimeSpan quantity cannot be NaN.", nameof(value)); + } + + var ticks = value * ticksPerUnit; + if (ticks > TimeSpan.MaxValue.Ticks || ticks < TimeSpan.MinValue.Ticks || double.IsNaN(ticks)) + { + throw new OverflowException("The TimeSpan quantity is outside the supported range."); + } + + return ticks == TimeSpan.MaxValue.Ticks ? TimeSpan.MaxValue : TimeSpan.FromTicks((long) ticks); + } + + private static DateTime ToDateTime(object? value) + { + var reconstructedValue = GetReconstructedValue(value, ReconstructedMetadataValueKind.DateTime); + return new DateTime( + reconstructedValue.PrimaryValue, + (DateTimeKind) reconstructedValue.SecondaryValue + ); + } + + private static TimeSpan ToTimeSpan(object? value) + { + var reconstructedValue = GetReconstructedValue(value, ReconstructedMetadataValueKind.TimeSpan); + return TimeSpan.FromTicks(reconstructedValue.PrimaryValue); + } + + private static ReconstructedMetadataValue GetReconstructedValue( + object? value, + ReconstructedMetadataValueKind expectedKind + ) + { + if (value is ReconstructedMetadataValue reconstructedValue && reconstructedValue.Kind == expectedKind) + { + return reconstructedValue; + } + + throw new InvalidOperationException($"Expected a reconstructed {expectedKind} value."); + } + + private static int ToInt32(object? value) => Convert.ToInt32(value, CultureInfo.InvariantCulture); + + private static long ToInt64(object? value) => Convert.ToInt64(value, CultureInfo.InvariantCulture); + + private static double ToDouble(object? value) => Convert.ToDouble(value, CultureInfo.InvariantCulture); + + private static DateTimeKind ToDateTimeKind(object? value) => (DateTimeKind) ToInt32(value); + + private static UriKind ToUriKind(object? value) => (UriKind) ToInt32(value); + + private static bool Matches( + IMethodSymbol method, + INamedTypeSymbol? containingType, + params ParameterType[] parameterTypes + ) => + method.MethodKind == MethodKind.Constructor && + Matches(method, containingType, method.Name, parameterTypes); + + private static bool Matches( + IMethodSymbol method, + INamedTypeSymbol? containingType, + string name, + params ParameterType[] parameterTypes + ) + { + if (containingType is null || + (!method.IsStatic && method.MethodKind != MethodKind.Constructor) || + method.Name != name || + !IsType(method.ContainingType, containingType) || + method.Parameters.Length != parameterTypes.Length) + { + return false; + } + + for (var i = 0; i < parameterTypes.Length; i++) + { + if (!MatchesParameter(method.Parameters[i].Type, parameterTypes[i], containingType.ContainingAssembly)) + { + return false; + } + } + + return true; + } + + private static bool MatchesParameter( + ITypeSymbol parameterType, + ParameterType expectedType, + IAssemblySymbol coreAssembly + ) => + expectedType switch + { + ParameterType.Int32 => parameterType.SpecialType == SpecialType.System_Int32, + ParameterType.Int64 => parameterType.SpecialType == SpecialType.System_Int64, + ParameterType.Double => parameterType.SpecialType == SpecialType.System_Double, + ParameterType.String => parameterType.SpecialType == SpecialType.System_String, + ParameterType.DateTime => IsFrameworkType(parameterType, coreAssembly, "System.DateTime"), + ParameterType.DateTimeKind => IsFrameworkType(parameterType, coreAssembly, "System.DateTimeKind"), + ParameterType.TimeSpan => IsFrameworkType(parameterType, coreAssembly, "System.TimeSpan"), + ParameterType.UriKind => IsFrameworkType(parameterType, coreAssembly, "System.UriKind"), + _ => false + }; + + private static bool IsFrameworkType( + ITypeSymbol type, + IAssemblySymbol coreAssembly, + string metadataName + ) => + SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, coreAssembly) && + string.Equals(GetMetadataName(type), metadataName, StringComparison.Ordinal); + + private static bool IsType(ITypeSymbol? left, ITypeSymbol? right) => + left is not null && right is not null && SymbolEqualityComparer.Default.Equals(left, right); + + private static string GetMetadataName(ITypeSymbol type) + { + var namespaceName = type.ContainingNamespace?.IsGlobalNamespace == false ? + type.ContainingNamespace.ToDisplayString() + "." : + string.Empty; + return namespaceName + type.MetadataName; + } + + private sealed class FrameworkTypes + { + public FrameworkTypes(Compilation compilation) + { + DateTime = compilation.GetTypeByMetadataName("System.DateTime"); + DateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset"); + TimeSpan = compilation.GetTypeByMetadataName("System.TimeSpan"); + DateOnly = compilation.GetTypeByMetadataName("System.DateOnly"); + TimeOnly = compilation.GetTypeByMetadataName("System.TimeOnly"); + Guid = compilation.GetTypeByMetadataName("System.Guid"); + Uri = compilation.GetTypeByMetadataName("System.Uri"); + } + + public INamedTypeSymbol? DateTime { get; } + public INamedTypeSymbol? DateTimeOffset { get; } + public INamedTypeSymbol? TimeSpan { get; } + public INamedTypeSymbol? DateOnly { get; } + public INamedTypeSymbol? TimeOnly { get; } + public INamedTypeSymbol? Guid { get; } + public INamedTypeSymbol? Uri { get; } + } + + private enum ParameterType + { + Int32 = 0, + Int64 = 1, + Double = 2, + String = 3, + DateTime = 4, + DateTimeKind = 5, + TimeSpan = 6, + UriKind = 7 + } + + private enum SupportedConstructor + { + DateTimeDate = 0, + DateTimeSecond = 1, + DateTimeMillisecond = 2, + DateTimeSecondKind = 3, + DateTimeMillisecondKind = 4, + DateTimeTicks = 5, + DateTimeTicksKind = 6, + DateTimeOffsetSecond = 7, + DateTimeOffsetMillisecond = 8, + DateTimeOffsetTicks = 9, + DateTimeOffsetDateTime = 10, + TimeSpanHours = 11, + TimeSpanDays = 12, + TimeSpanMilliseconds = 13, + TimeSpanTicks = 14, + DateOnly = 15, + TimeOnlyMinute = 16, + TimeOnlySecond = 17, + TimeOnlyMillisecond = 18, + TimeOnlyTicks = 19, + Guid = 20, + Uri = 21, + UriKind = 22 + } + + private enum SupportedFactory + { + TimeSpanFromTicks = 0, + TimeSpanFromDaysDouble = 1, + TimeSpanFromDaysIntegral = 2, + TimeSpanFromHoursDouble = 3, + TimeSpanFromHoursIntegral = 4, + TimeSpanFromMinutesDouble = 5, + TimeSpanFromMinutesIntegral = 6, + TimeSpanFromSecondsDouble = 7, + TimeSpanFromSecondsIntegral = 8, + TimeSpanFromMillisecondsDouble = 9, + TimeSpanFromMillisecondsIntegral = 10, + TimeSpanFromMicrosecondsDouble = 11, + TimeSpanFromMicrosecondsIntegral = 12, + DateOnlyFromDayNumber = 13, + DateTimeOffsetFromUnixTimeSeconds = 14, + DateTimeOffsetFromUnixTimeMilliseconds = 15, + GuidParse = 16, + GuidParseExact = 17 + } +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs new file mode 100644 index 0000000..b3f3035 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs @@ -0,0 +1,300 @@ +using System; +using System.Globalization; +using System.Text; +using System.Xml; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Identifies the framework value represented by a reconstructed validation metadata boundary. +/// +public enum ReconstructedMetadataValueKind +{ + DateTime = 0, + DateTimeOffset = 1, + TimeSpan = 2, + DateOnly = 3, + TimeOnly = 4, + Guid = 5, + Uri = 6 +} + +/// +/// Carries a reconstructed validation metadata boundary as a deterministic structural payload. +/// +public sealed class ReconstructedMetadataValue : IEquatable +{ + private const string DateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; + private const string DateTimeOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"; + private const string DateOnlyFormat = "yyyy-MM-dd"; + private const string TimeOnlyFormat = "HH:mm:ss.FFFFFFF"; + + private ReconstructedMetadataValue( + ReconstructedMetadataValueKind kind, + long primaryValue, + long secondaryValue, + string? text + ) + { + Kind = kind; + PrimaryValue = primaryValue; + SecondaryValue = secondaryValue; + Text = text; + } + + /// + /// Gets the represented framework value kind. + /// + public ReconstructedMetadataValueKind Kind { get; } + + /// + /// Gets the primary integer payload. + /// + public long PrimaryValue { get; } + + /// + /// Gets the secondary integer payload. + /// + public long SecondaryValue { get; } + + /// + /// Gets the ordinal text payload, when the represented kind uses one. + /// + public string? Text { get; } + + /// + public bool Equals(ReconstructedMetadataValue? other) => + other is not null && + Kind == other.Kind && + PrimaryValue == other.PrimaryValue && + SecondaryValue == other.SecondaryValue && + string.Equals(Text, other.Text, StringComparison.Ordinal); + + /// + /// Creates a structural value from a deterministic . + /// + public static ReconstructedMetadataValue FromDateTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + throw new ArgumentException( + "Local DateTime values cannot be reconstructed deterministically.", + nameof(value) + ); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.DateTime, + value.Ticks, + (long) value.Kind, + text: null + ); + } + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromDateTimeOffset(DateTimeOffset value) => + new ( + ReconstructedMetadataValueKind.DateTimeOffset, + value.Ticks, + value.Offset.Ticks, + text: null + ); + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromTimeSpan(TimeSpan value) => + new (ReconstructedMetadataValueKind.TimeSpan, value.Ticks, secondaryValue: 0L, text: null); + + /// + /// Creates a structural date-only value from its day number. + /// + public static ReconstructedMetadataValue FromDateOnlyDayNumber(int dayNumber) + { + if (dayNumber < 0 || dayNumber > 3_652_058) + { + throw new ArgumentOutOfRangeException(nameof(dayNumber)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.DateOnly, + dayNumber, + secondaryValue: 0L, + text: null + ); + } + + /// + /// Creates a structural time-only value from its tick count. + /// + public static ReconstructedMetadataValue FromTimeOnlyTicks(long ticks) + { + if (ticks < 0L || ticks >= TimeSpan.TicksPerDay) + { + throw new ArgumentOutOfRangeException(nameof(ticks)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.TimeOnly, + ticks, + secondaryValue: 0L, + text: null + ); + } + + /// + /// Creates a structural value from a . + /// + public static ReconstructedMetadataValue FromGuid(Guid value) => + new ( + ReconstructedMetadataValueKind.Guid, + primaryValue: 0L, + secondaryValue: 0L, + value.ToString("D", CultureInfo.InvariantCulture) + ); + + /// + /// Creates a structural URI value from its original text. + /// + public static ReconstructedMetadataValue FromUriOriginalString(string originalString) + { + if (originalString is null) + { + throw new ArgumentNullException(nameof(originalString)); + } + + return new ReconstructedMetadataValue( + ReconstructedMetadataValueKind.Uri, + primaryValue: 0L, + secondaryValue: 0L, + originalString + ); + } + + /// + /// Renders the canonical metadata text used in generated example messages. + /// + public string ToCanonicalString() => + Kind switch + { + ReconstructedMetadataValueKind.DateTime => + new DateTime(PrimaryValue, (DateTimeKind) SecondaryValue).ToString( + DateTimeFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.DateTimeOffset => + new DateTimeOffset(PrimaryValue, TimeSpan.FromTicks(SecondaryValue)).ToString( + DateTimeOffsetFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.TimeSpan => XmlConvert.ToString(TimeSpan.FromTicks(PrimaryValue)), + ReconstructedMetadataValueKind.DateOnly => + new DateTime(checked(PrimaryValue * TimeSpan.TicksPerDay), DateTimeKind.Unspecified).ToString( + DateOnlyFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.TimeOnly => + new DateTime(PrimaryValue, DateTimeKind.Unspecified).ToString( + TimeOnlyFormat, + CultureInfo.InvariantCulture + ), + ReconstructedMetadataValueKind.Guid or ReconstructedMetadataValueKind.Uri => Text!, + _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") + }; + + /// + /// Renders the exact C# expression emitted into a generated OpenAPI contract. + /// + public string ToCSharpLiteral() => + Kind switch + { + ReconstructedMetadataValueKind.DateTime => + "new global::System.DateTime(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + GetDateTimeKindName((DateTimeKind) SecondaryValue) + + ")", + ReconstructedMetadataValueKind.DateTimeOffset => + "new global::System.DateTimeOffset(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + SecondaryValue.ToString(CultureInfo.InvariantCulture) + + "L))", + ReconstructedMetadataValueKind.TimeSpan => + "global::System.TimeSpan.FromTicks(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L)", + ReconstructedMetadataValueKind.DateOnly => + "global::System.DateOnly.FromDayNumber(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + ")", + ReconstructedMetadataValueKind.TimeOnly => + "new global::System.TimeOnly(" + + PrimaryValue.ToString(CultureInfo.InvariantCulture) + + "L)", + ReconstructedMetadataValueKind.Guid => + "global::System.Guid.ParseExact(" + ToStringLiteral(Text!) + ", \"D\")", + ReconstructedMetadataValueKind.Uri => + "new global::System.Uri(" + + ToStringLiteral(Text!) + + ", global::System.UriKind.RelativeOrAbsolute)", + _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") + }; + + /// + public override bool Equals(object? obj) => Equals(obj as ReconstructedMetadataValue); + + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = (int) Kind; + hashCode = hashCode * 31 + PrimaryValue.GetHashCode(); + hashCode = hashCode * 31 + SecondaryValue.GetHashCode(); + hashCode = hashCode * 31 + (Text is null ? 0 : StringComparer.Ordinal.GetHashCode(Text)); + return hashCode; + } + } + + private static string GetDateTimeKindName(DateTimeKind kind) => + kind switch + { + DateTimeKind.Unspecified => nameof(DateTimeKind.Unspecified), + DateTimeKind.Utc => nameof(DateTimeKind.Utc), + DateTimeKind.Local => nameof(DateTimeKind.Local), + _ => throw new InvalidOperationException($"Unsupported DateTime kind '{kind}'.") + }; + + private static string ToStringLiteral(string value) + { + var builder = new StringBuilder(value.Length + 2).Append('"'); + foreach (var c in value) + { + builder.Append(EscapeChar(c)); + } + + return builder.Append('"').ToString(); + } + + private static string EscapeChar(char value) => + value switch + { + '\\' => @"\\", + '"' => "\\\"", + '\'' => "\\'", + '\0' => "\\0", + '\a' => "\\a", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\v' => "\\v", + _ => char.IsControl(value) ? + "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : + value.ToString() + }; +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 8bac423..057a6d0 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -337,6 +337,7 @@ CancellationToken cancellationToken } var metadataValues = ImmutableArray.CreateBuilder(); + var diagnosedArguments = new HashSet(StringComparer.Ordinal); foreach (var metadataAttribute in definitionSymbol.GetAttributes() .Where(static attribute => IsAttribute(attribute, KnownTypeNames.ValidationRuleMetadataAttribute))) { @@ -361,7 +362,9 @@ CancellationToken cancellationToken cancellationToken, out var value, out var valueTypeName, - out var hasConstantValue + out var hasConstantValue, + out var argument, + out var reconstructionResult )) { diagnostics.Add( @@ -375,6 +378,24 @@ out var hasConstantValue return null; } + if (!hasConstantValue && + argument is not null && + diagnosedArguments.Add(sourceArgument!)) + { + var descriptor = + reconstructionResult == MetadataReconstructionResult.MultiFileFieldUnsupported ? + DiagnosticDescriptors.MultiFileMetadataFieldUnsupported : + DiagnosticDescriptors.MetadataValueCannotBeReconstructed; + diagnostics.Add( + Diagnostic.Create( + descriptor, + argument.GetLocation(), + symbol.Name, + sourceArgument + ) + ); + } + metadataValues.Add(new MetadataValueModel(metadataKey!, value, hasConstantValue, valueTypeName)); continue; } @@ -616,6 +637,7 @@ private static string FormatMessageValue(object? value) return value switch { null => string.Empty, + ReconstructedMetadataValue reconstructedValue => reconstructedValue.ToCanonicalString(), IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty }; @@ -768,12 +790,16 @@ private static bool TryResolveArgumentConstant( CancellationToken cancellationToken, out object? value, out string typeName, - out bool hasConstantValue + out bool hasConstantValue, + out ArgumentSyntax? resolvedArgument, + out MetadataReconstructionResult reconstructionResult ) { value = null; typeName = "object"; hasConstantValue = false; + resolvedArgument = null; + reconstructionResult = MetadataReconstructionResult.Unsupported; var parameterIndex = -1; for (var i = 0; i < symbol.Parameters.Length; i++) @@ -806,10 +832,26 @@ out bool hasConstantValue } var constant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + resolvedArgument = argument; if (constant.HasValue) { value = constant.Value; hasConstantValue = true; + reconstructionResult = MetadataReconstructionResult.Success; + } + else + { + reconstructionResult = MetadataValueReconstructor.TryReconstruct( + semanticModel, + argument.Expression, + cancellationToken, + out var reconstructedValue + ); + if (reconstructionResult == MetadataReconstructionResult.Success) + { + value = reconstructedValue; + hasConstantValue = true; + } } return true; @@ -820,6 +862,7 @@ out bool hasConstantValue { value = parameter.ExplicitDefaultValue; hasConstantValue = true; + reconstructionResult = MetadataReconstructionResult.Success; } return true; @@ -1020,22 +1063,6 @@ private static string ToCamelCase(string name) return char.ToLowerInvariant(name[0]) + name.Substring(1); } - private readonly struct MessageTemplatePart - { - private MessageTemplatePart(string text, string? placeholder) - { - Text = text; - Placeholder = placeholder; - } - - public string Text { get; } - public string? Placeholder { get; } - - public static MessageTemplatePart Literal(string text) => new (text, null); - - public static MessageTemplatePart PlaceholderValue(string placeholder) => new (string.Empty, placeholder); - } - private static string? ResolveTypedValueTypeName(IMethodSymbol symbol, RuleMetadataShape shape) { if (shape == RuleMetadataShape.Registered) @@ -1559,4 +1586,20 @@ private static string GetMetadataName(ITypeSymbol typeSymbol) string.Empty; return containingNamespace + typeSymbol.MetadataName; } + + private readonly struct MessageTemplatePart + { + private MessageTemplatePart(string text, string? placeholder) + { + Text = text; + Placeholder = placeholder; + } + + public string Text { get; } + public string? Placeholder { get; } + + public static MessageTemplatePart Literal(string text) => new (text, null); + + public static MessageTemplatePart PlaceholderValue(string placeholder) => new (string.Empty, placeholder); + } } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 8aa3ef2..44a1c25 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -262,7 +262,7 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) "\u001F", example.MetadataValues.Select( static metadata => - metadata.Key + "=" + (metadata.Value?.ToString() ?? string.Empty) + metadata.Key + "=" + GetDeduplicationValue(metadata.Value) ) ) } @@ -330,50 +330,57 @@ IEnumerable metadataValues }; private static string ToLiteral(object? value) => - TryCreateDateOnlyOrTimeOnlyLiteral(value) ?? - value switch - { - null => "null", - string stringValue => ToStringLiteral(stringValue), - char charValue => "'" + EscapeChar(charValue) + "'", - bool boolValue => boolValue ? "true" : "false", - byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), - sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), - short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), - ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture), - int intValue => intValue.ToString(CultureInfo.InvariantCulture), - uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture) + "U", - long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", - ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture) + "UL", - float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", - double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", - decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", - DateTime dateTimeValue => - "new global::System.DateTime(" + - dateTimeValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.DateTimeKind." + - dateTimeValue.Kind + - ")", - DateTimeOffset dateTimeOffsetValue => - "new global::System.DateTimeOffset(" + - dateTimeOffsetValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.TimeSpan.FromTicks(" + - dateTimeOffsetValue.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + - "L))", - TimeSpan timeSpanValue => - "global::System.TimeSpan.FromTicks(" + - timeSpanValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L)", - Guid guidValue => - "global::System.Guid.ParseExact(" + - ToStringLiteral(guidValue.ToString("D")) + - ", \"D\")", - Uri uriValue => - "new global::System.Uri(" + - ToStringLiteral(uriValue.OriginalString) + - ", global::System.UriKind.RelativeOrAbsolute)", - _ => ToStringLiteral(value.ToString() ?? string.Empty) - }; + value is ReconstructedMetadataValue reconstructedValue ? + reconstructedValue.ToCSharpLiteral() : + TryCreateDateOnlyOrTimeOnlyLiteral(value) ?? + value switch + { + null => "null", + string stringValue => ToStringLiteral(stringValue), + char charValue => "'" + EscapeChar(charValue) + "'", + bool boolValue => boolValue ? "true" : "false", + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + ushort ushortValue => ushortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + uint uintValue => uintValue.ToString(CultureInfo.InvariantCulture) + "U", + long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", + ulong ulongValue => ulongValue.ToString(CultureInfo.InvariantCulture) + "UL", + float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", + double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", + DateTime dateTimeValue => + "new global::System.DateTime(" + + dateTimeValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + dateTimeValue.Kind + + ")", + DateTimeOffset dateTimeOffsetValue => + "new global::System.DateTimeOffset(" + + dateTimeOffsetValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + dateTimeOffsetValue.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + + "L))", + TimeSpan timeSpanValue => + "global::System.TimeSpan.FromTicks(" + + timeSpanValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)", + Guid guidValue => + "global::System.Guid.ParseExact(" + + ToStringLiteral(guidValue.ToString("D")) + + ", \"D\")", + Uri uriValue => + "new global::System.Uri(" + + ToStringLiteral(uriValue.OriginalString) + + ", global::System.UriKind.RelativeOrAbsolute)", + _ => ToStringLiteral(value.ToString() ?? string.Empty) + }; + + private static string GetDeduplicationValue(object? value) => + value is ReconstructedMetadataValue reconstructedValue ? + reconstructedValue.Kind + ":" + reconstructedValue.ToCSharpLiteral() : + value?.ToString() ?? string.Empty; private static string? TryCreateDateOnlyOrTimeOnlyLiteral(object? value) { diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs index 10d9f6b..79eab89 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiModels.cs @@ -67,6 +67,9 @@ public override int GetHashCode() hashCode = hashCode * 31 + diagnostic.Id.GetHashCode(); hashCode = hashCode * 31 + diagnostic.Severity.GetHashCode(); hashCode = hashCode * 31 + diagnostic.GetMessage().GetHashCode(); + hashCode = hashCode * 31 + GetDiagnosticSourcePath(diagnostic).GetHashCode(); + hashCode = hashCode * 31 + diagnostic.Location.SourceSpan.Start.GetHashCode(); + hashCode = hashCode * 31 + diagnostic.Location.SourceSpan.Length.GetHashCode(); } return hashCode; @@ -87,7 +90,9 @@ ImmutableArray right { if (left[i].Id != right[i].Id || left[i].Severity != right[i].Severity || - left[i].GetMessage() != right[i].GetMessage()) + left[i].GetMessage() != right[i].GetMessage() || + GetDiagnosticSourcePath(left[i]) != GetDiagnosticSourcePath(right[i]) || + left[i].Location.SourceSpan != right[i].Location.SourceSpan) { return false; } @@ -95,6 +100,9 @@ ImmutableArray right return true; } + + private static string GetDiagnosticSourcePath(Diagnostic diagnostic) => + diagnostic.Location.SourceTree?.FilePath ?? string.Empty; } public sealed class ValidatorModel @@ -282,7 +290,8 @@ public bool Equals(RuleCallModel? x, RuleCallModel? y) return true; } - if (x is null || y is null || + if (x is null || + y is null || x.Code != y.Code || x.MetadataSchemaProperties.Length != y.MetadataSchemaProperties.Length) { diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs new file mode 100644 index 0000000..3415541 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataIncrementalTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataIncrementalTests +{ + [Fact] + public void GeneratorMovesUnresolvedArgumentDiagnosticWhenDriverIsReused() + { + var firstCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateSource(extraBlankLine: false)) + ); + var driver = GeneratorTestHarness.CreateDriver().RunGenerators( + firstCompilation, + TestContext.Current.CancellationToken + ); + var firstDiagnostic = GetReconstructionDiagnostic(driver); + + var secondCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateSource(extraBlankLine: true)) + ); + driver = driver.RunGenerators(secondCompilation, TestContext.Current.CancellationToken); + var secondDiagnostic = GetReconstructionDiagnostic(driver); + + secondDiagnostic.Location.SourceSpan.Start.Should().BeGreaterThan(firstDiagnostic.Location.SourceSpan.Start); + secondDiagnostic.Location.GetLineSpan().StartLinePosition.Line.Should() + .Be(firstDiagnostic.Location.GetLineSpan().StartLinePosition.Line + 1); + secondDiagnostic.Location.SourceTree!.GetText(TestContext.Current.CancellationToken) + .GetSubText(secondDiagnostic.Location.SourceSpan) + .ToString() + .Should() + .Be("DateTime.Now"); + } + + private static Diagnostic GetReconstructionDiagnostic(GeneratorDriver driver) => + driver.GetRunResult().Results + .SelectMany(static result => result.Diagnostics) + .Single(static diagnostic => diagnostic.Id == "LPRSG0015"); + + private static string CreateSource(bool extraBlankLine) + { + var blankLine = extraBlankLine ? "\n" : string.Empty; + return $$""" + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + {{blankLine}}context.Check(value).IsEqualTo(DateTime.Now); + return checkpoint.ToValidatedValue(value); + } + } + """; + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs new file mode 100644 index 0000000..583b62a --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using FluentAssertions; +using Light.PortableResults.Metadata; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataReconstructionTests +{ + private const string GuidText = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateTimeConstructorAndStatic() + { + var cases = new[] + { + DateTimeCase("new (2026, 1, 2)", new DateTime(2026, 1, 2)), + DateTimeCase("new DateTime(2026, 1, 2, 3, 4, 5)", new DateTime(2026, 1, 2, 3, 4, 5)), + DateTimeCase("new DateTime(2026, 1, 2, 3, 4, 5, 6)", new DateTime(2026, 1, 2, 3, 4, 5, 6)), + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc)", + new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) + ), + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)", + new DateTime(2026, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc) + ), + DateTimeCase("new DateTime(638713838450000000L)", new DateTime(638713838450000000L)), + DateTimeCase( + "new DateTime(638713838450000000L, DateTimeKind.Utc)", + new DateTime(638713838450000000L, DateTimeKind.Utc) + ), + DateTimeCase("DateTime.MinValue", DateTime.MinValue), + DateTimeCase("DateTime.MaxValue", DateTime.MaxValue), + DateTimeCase("DateTime.UnixEpoch", DateTime.UnixEpoch) + }; + + AssertAcceptedCases("DateTime", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateTimeOffsetConstructorFactoryAndStatic() + { + var offset = TimeSpan.FromHours(2); + var dateTime = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Unspecified); + var cases = new[] + { + DateTimeOffsetCase( + "new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.FromHours(2))", + new DateTimeOffset(2026, 1, 2, 3, 4, 5, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(2026, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(2))", + new DateTimeOffset(2026, 1, 2, 3, 4, 5, 6, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(638713838450000000L, TimeSpan.FromHours(2))", + new DateTimeOffset(638713838450000000L, offset) + ), + DateTimeOffsetCase( + "new DateTimeOffset(new DateTime(2026, 1, 2, 3, 4, 5), TimeSpan.FromHours(2))", + new DateTimeOffset(dateTime, offset) + ), + DateTimeOffsetCase( + "DateTimeOffset.FromUnixTimeSeconds(1L)", + DateTimeOffset.FromUnixTimeSeconds(1L) + ), + DateTimeOffsetCase( + "DateTimeOffset.FromUnixTimeMilliseconds(1L)", + DateTimeOffset.FromUnixTimeMilliseconds(1L) + ), + DateTimeOffsetCase("DateTimeOffset.MinValue", DateTimeOffset.MinValue), + DateTimeOffsetCase("DateTimeOffset.MaxValue", DateTimeOffset.MaxValue), + DateTimeOffsetCase("DateTimeOffset.UnixEpoch", DateTimeOffset.UnixEpoch) + }; + + AssertAcceptedCases("DateTimeOffset", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedTimeSpanConstructorFactoryAndStatic() + { + var cases = new[] + { + TimeSpanCase("new TimeSpan(2, 3, 4)", new TimeSpan(2, 3, 4)), + TimeSpanCase("new TimeSpan(1, 2, 3, 4)", new TimeSpan(1, 2, 3, 4)), + TimeSpanCase("new TimeSpan(1, 2, 3, 4, 5)", new TimeSpan(1, 2, 3, 4, 5)), + TimeSpanCase("new TimeSpan(123456789L)", new TimeSpan(123456789L)), + TimeSpanCase("TimeSpan.FromTicks(123456789L)", TimeSpan.FromTicks(123456789L)), + TimeSpanCase("TimeSpan.FromDays(0.25D)", TimeSpan.FromDays(0.25D)), + TimeSpanCase("TimeSpan.FromDays(1)", TimeSpan.FromDays(1)), + TimeSpanCase("TimeSpan.FromHours(2D)", TimeSpan.FromHours(2D)), + TimeSpanCase("TimeSpan.FromHours(2)", TimeSpan.FromHours(2)), + TimeSpanCase("TimeSpan.FromMinutes(3D)", TimeSpan.FromMinutes(3D)), + TimeSpanCase("TimeSpan.FromMinutes(3L)", TimeSpan.FromMinutes(3L)), + TimeSpanCase("TimeSpan.FromSeconds(4D)", TimeSpan.FromSeconds(4D)), + TimeSpanCase("TimeSpan.FromSeconds(4L)", TimeSpan.FromSeconds(4L)), + TimeSpanCase("TimeSpan.FromMilliseconds(5D)", TimeSpan.FromMilliseconds(5D)), + TimeSpanCase("TimeSpan.FromMilliseconds(5L)", TimeSpan.FromMilliseconds(5L)), + TimeSpanCase("TimeSpan.FromMicroseconds(6D)", TimeSpan.FromMicroseconds(6D)), + TimeSpanCase("TimeSpan.FromMicroseconds(6L)", TimeSpan.FromMicroseconds(6L)), + TimeSpanCase("TimeSpan.Zero", TimeSpan.Zero), + TimeSpanCase("TimeSpan.MinValue", TimeSpan.MinValue), + TimeSpanCase("TimeSpan.MaxValue", TimeSpan.MaxValue) + }; + + AssertAcceptedCases("TimeSpan", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedDateOnlyConstructorFactoryAndStatic() + { + var cases = new[] + { + DateOnlyCase("new DateOnly(2026, 1, 2)", new DateOnly(2026, 1, 2)), + DateOnlyCase("DateOnly.FromDayNumber(1234)", DateOnly.FromDayNumber(1234)), + DateOnlyCase("DateOnly.MinValue", DateOnly.MinValue), + DateOnlyCase("DateOnly.MaxValue", DateOnly.MaxValue) + }; + + AssertAcceptedCases("DateOnly", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedTimeOnlyConstructorAndStatic() + { + var cases = new[] + { + TimeOnlyCase("new TimeOnly(3, 4)", new TimeOnly(3, 4)), + TimeOnlyCase("new TimeOnly(3, 4, 5)", new TimeOnly(3, 4, 5)), + TimeOnlyCase("new TimeOnly(3, 4, 5, 6)", new TimeOnly(3, 4, 5, 6)), + TimeOnlyCase("new TimeOnly(123456789L)", new TimeOnly(123456789L)), + TimeOnlyCase("TimeOnly.MinValue", TimeOnly.MinValue), + TimeOnlyCase("TimeOnly.MaxValue", TimeOnly.MaxValue) + }; + + AssertAcceptedCases("TimeOnly", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedGuidConstructorFactoryAndStatic() + { + var guid = new Guid(GuidText); + var cases = new[] + { + GuidCase($"new Guid(\"{GuidText}\")", guid), + GuidCase($"Guid.Parse(\"{GuidText}\")", guid), + GuidCase("Guid.ParseExact(\"a1b2c3d4e5f67890abcdef1234567890\", \"N\")", guid), + GuidCase("Guid.Empty", Guid.Empty) + }; + + AssertAcceptedCases("Guid", cases); + } + + [Fact] + public void GeneratorReconstructsEveryAcceptedUriConstructor() + { + var absoluteText = "https://example.com/items/42?view=full#summary"; + var relativeText = "items/42#summary"; + var cases = new[] + { + UriCase($"new Uri(\"{absoluteText}\")", new Uri(absoluteText)), + UriCase( + $"new Uri(\"{relativeText}\", UriKind.Relative)", + new Uri(relativeText, UriKind.Relative) + ) + }; + + AssertAcceptedCases("Uri", cases); + } + + [Fact] + public void GeneratorReconstructsDateTimeOffsetWithEveryAcceptedTimeSpanShape() + { + var offset = TimeSpan.FromHours(2); + var expressions = new[] + { + "new TimeSpan(2, 0, 0)", + "new TimeSpan(0, 2, 0, 0)", + "new TimeSpan(0, 2, 0, 0, 0)", + "new TimeSpan(72000000000L)", + "TimeSpan.FromTicks(72000000000L)", + "TimeSpan.FromDays(0.08333333333333333D)", + "TimeSpan.FromDays(0)", + "TimeSpan.FromHours(2D)", + "TimeSpan.FromHours(2)", + "TimeSpan.FromMinutes(120D)", + "TimeSpan.FromMinutes(120L)", + "TimeSpan.FromSeconds(7200D)", + "TimeSpan.FromSeconds(7200L)", + "TimeSpan.FromMilliseconds(7200000D)", + "TimeSpan.FromMilliseconds(7200000L)", + "TimeSpan.FromMicroseconds(7200000000D)", + "TimeSpan.FromMicroseconds(7200000000L)", + "TimeSpan.Zero", + "Offset" + }; + var cases = expressions.Select( + (expression, index) => DateTimeOffsetCase( + $"new DateTimeOffset(2026, 1, {index + 1}, 0, 0, 0, {expression})", + new DateTimeOffset( + 2026, + 1, + index + 1, + 0, + 0, + 0, + expression is "TimeSpan.Zero" or "TimeSpan.FromDays(0)" ? TimeSpan.Zero : offset + ) + ) + ) + .ToArray(); + + AssertAcceptedCases( + "DateTimeOffset", + cases, + "private static readonly TimeSpan Offset = TimeSpan.FromHours(2);" + ); + } + + [Fact] + public void GeneratorReconstructsStaticReadonlyFieldDeclaredInValidatorFile() + { + var value = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var cases = new[] { DateTimeCase("Boundary", value) }; + + AssertAcceptedCases( + "DateTime", + cases, + "private static readonly DateTime Boundary = new (2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);" + ); + } + + private static void AssertAcceptedCases( + string valueType, + IReadOnlyList cases, + string additionalMember = "" + ) + { + var source = CreateValidatorSource(valueType, cases, additionalMember); + var result = GeneratorTestHarness.Run(source); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + result.ResultDiagnostics.Select(static diagnostic => diagnostic.Id) + .Should() + .NotContain(["LPRSG0015", "LPRSG0016"]); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + for (var i = 0; i < cases.Count; i++) + { + generatedSource.Should().Contain($"case{i} must be equal to {cases[i].CanonicalText}"); + generatedSource.Should().Contain($"[\"comparativeValue\"] = {cases[i].CSharpLiteral}"); + } + } + + private static string CreateValidatorSource( + string valueType, + IReadOnlyList cases, + string additionalMember + ) + { + var calls = string.Join( + Environment.NewLine, + cases.Select( + static (item, index) => + $"context.Check(value, displayName: \"case{index}\").IsEqualTo({item.Expression});" + ) + ); + return $$""" + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator<{{valueType}}> + { + {{additionalMember}} + + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue<{{valueType}}> PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + {{valueType}} value + ) + { + {{calls}} + return checkpoint.ToValidatedValue(value); + } + } + """; + } + + private static BoundaryCase DateTimeCase(string expression, DateTime value) => + new ( + expression, + MetadataValue.FromDateTime(value).ToCanonicalString(), + "new global::System.DateTime(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + value.Kind + + ")" + ); + + private static BoundaryCase DateTimeOffsetCase(string expression, DateTimeOffset value) => + new ( + expression, + MetadataValue.FromDateTimeOffset(value).ToCanonicalString(), + "new global::System.DateTimeOffset(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + value.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + + "L))" + ); + + private static BoundaryCase TimeSpanCase(string expression, TimeSpan value) => + new ( + expression, + MetadataValue.FromTimeSpan(value).ToCanonicalString(), + "global::System.TimeSpan.FromTicks(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)" + ); + + private static BoundaryCase DateOnlyCase(string expression, DateOnly value) => + new ( + expression, + MetadataValue.FromDateOnly(value).ToCanonicalString(), + "global::System.DateOnly.FromDayNumber(" + + value.DayNumber.ToString(CultureInfo.InvariantCulture) + + ")" + ); + + private static BoundaryCase TimeOnlyCase(string expression, TimeOnly value) => + new ( + expression, + MetadataValue.FromTimeOnly(value).ToCanonicalString(), + "new global::System.TimeOnly(" + + value.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)" + ); + + private static BoundaryCase GuidCase(string expression, Guid value) => + new ( + expression, + MetadataValue.FromGuid(value).ToCanonicalString(), + "global::System.Guid.ParseExact(\"" + value.ToString("D") + "\", \"D\")" + ); + + private static BoundaryCase UriCase(string expression, Uri value) => + new ( + expression, + MetadataValue.FromUri(value).ToCanonicalString(), + "new global::System.Uri(\"" + + value.OriginalString + + "\", global::System.UriKind.RelativeOrAbsolute)" + ); + + private sealed class BoundaryCase + { + public BoundaryCase(string expression, string canonicalText, string cSharpLiteral) + { + Expression = expression; + CanonicalText = canonicalText; + CSharpLiteral = cSharpLiteral; + } + + public string Expression { get; } + public string CanonicalText { get; } + public string CSharpLiteral { get; } + } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs new file mode 100644 index 0000000..1a5bb48 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -0,0 +1,469 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +public sealed class BoundaryMetadataRejectionTests +{ + public static IEnumerable OmittedBoundaryCases() + { + yield return ["DateTime", "DateTime.Now", "", ""]; + yield return ["DateTime", "DateTime.UtcNow", "", ""]; + yield return ["DateTime", "DateTime.Today", "", ""]; + yield return ["DateTimeOffset", "DateTimeOffset.Now", "", ""]; + yield return ["DateTimeOffset", "DateTimeOffset.UtcNow", "", ""]; + yield return ["Guid", "Guid.NewGuid()", "", ""]; + yield return ["DateTime", "DateTime.Parse(\"2026-01-02\")", "", ""]; + yield return + [ + "DateTime", + "DateTime.ParseExact(\"2026-01-02\", \"yyyy-MM-dd\", CultureInfo.InvariantCulture)", + "", + "" + ]; + yield return ["DateTimeOffset", "DateTimeOffset.Parse(\"2026-01-02+02:00\")", "", ""]; + yield return + [ + "DateTimeOffset", + "DateTimeOffset.ParseExact(\"2026-01-02+02:00\", \"yyyy-MM-ddzzz\", CultureInfo.InvariantCulture)", + "", + "" + ]; + yield return + [ + "DateTime", + "ParseWithTryParse()", + "private static DateTime ParseWithTryParse() { DateTime.TryParse(\"2026-01-02\", out var value); return value; }", + "" + ]; + yield return + [ + "DateTimeOffset", + "ParseOffsetWithTryParse()", + "private static DateTimeOffset ParseOffsetWithTryParse() { DateTimeOffset.TryParse(\"2026-01-02+02:00\", out var value); return value; }", + "" + ]; + yield return + [ + "DateTimeOffset", + "new DateTimeOffset(new DateTime(2026, 1, 2))", + "", + "" + ]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, new GregorianCalendar())", + "", + "" + ]; + yield return ["Guid", "new Guid(new byte[16])", "", ""]; + yield return ["DateTime", "default", "", ""]; + yield return ["DateTime", "default(DateTime)", "", ""]; + yield return ["DateTime", "value", "", ""]; + yield return + [ + "DateTime", + "BoundaryProperty", + "private static DateTime BoundaryProperty => new (2026, 1, 2);", + "" + ]; + yield return + [ + "DateTime", + "_instanceBoundary", + "private readonly DateTime _instanceBoundary = new (2026, 1, 2);", + "" + ]; + yield return + [ + "DateTime", + "MutableBoundary", + "private static DateTime MutableBoundary = new (2026, 1, 2);", + "" + ]; + yield return ["DateTime", "DateTime.UnixEpoch.AddDays(1)", "", ""]; + yield return + [ + "TimeSpan", + "TimeSpan.FromHours(1, 2, 3, 4, 5)", + "", + "" + ]; + yield return ["DateOnly", "DateOnly.FromDateTime(DateTime.UnixEpoch)", "", ""]; + yield return ["TimeOnly", "TimeOnly.FromDateTime(DateTime.UnixEpoch)", "", ""]; + yield return ["TimeOnly", "TimeOnly.FromTimeSpan(TimeSpan.Zero)", "", ""]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Local)", + "", + "" + ]; + } + + [Theory] + [MemberData(nameof(OmittedBoundaryCases))] + public void GeneratorReportsInfoAtEveryOmittedBoundary( + string valueType, + string expression, + string additionalMember, + string additionalType + ) + { + AssertRejectedBoundary(valueType, expression, additionalMember, additionalType); + } + + [Theory] + [InlineData("DateTime", "new DateTime(2026, 13, 1)")] + [InlineData("Guid", "Guid.Parse(\"invalid\")")] + [InlineData("Uri", "new Uri(\"http://[\")")] + [InlineData("TimeSpan", "TimeSpan.FromDays(double.MaxValue)")] + public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, string expression) + { + var result = AssertRejectedBoundary(valueType, expression); + + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + } + + [Theory] + [InlineData("TimeSpan.MinValue")] + [InlineData("TimeSpan.MaxValue")] + public void GeneratorDegradesWhenAcceptedTimeSpanCannotBeDateTimeOffset(string offsetExpression) + { + AssertRejectedBoundary( + "DateTimeOffset", + $"new DateTimeOffset(2026, 1, 2, 0, 0, 0, {offsetExpression})" + ); + } + + [Fact] + public void GeneratorRejectsStaticReadonlyFieldAssignedByStaticConstructor() + { + AssertRejectedBoundary( + "DateTime", + "Boundary", + """ + private static readonly DateTime Boundary = new (2026, 1, 2); + + static BoundaryValidator() + { + Boundary = new DateTime(2027, 1, 2); + } + """ + ); + } + + [Fact] + public void GeneratorRejectsLocalVariableBoundary() + { + var result = GeneratorTestHarness.Run( + """ + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + var boundary = new DateTime(2026, 1, 2); + context.Check(value).IsEqualTo(boundary); + return checkpoint.ToValidatedValue(value); + } + } + """ + ); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0015") + .Should() + .ContainSingle() + .Subject; + + diagnostic.Severity.Should().Be(DiagnosticSeverity.Info); + GetLocatedText(diagnostic).Should().Be("boundary"); + result.GeneratedSources.Should().ContainSingle().Subject + .Should() + .Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + } + + [Fact] + public void GeneratorRejectsStaticReadonlyFieldCycle() + { + AssertRejectedBoundary( + "DateTime", + "First", + """ + private static readonly DateTime First = Second; + private static readonly DateTime Second = First; + """ + ); + } + + [Fact] + public void GeneratorRejectsExpressionBeyondRecursionBound() + { + AssertRejectedBoundary( + "DateTime", + "First", + """ + private static readonly DateTime First = Second; + private static readonly DateTime Second = Third; + private static readonly DateTime Third = Fourth; + private static readonly DateTime Fourth = Fifth; + private static readonly DateTime Fifth = Sixth; + private static readonly DateTime Sixth = new (2026, 1, 2); + """ + ); + } + + [Fact] + public void GeneratorRejectsUserDefinedTypeWithFrameworkName() + { + AssertRejectedBoundary( + "Fake.DateTime", + "new Fake.DateTime(2026, 1, 2)", + additionalType: + """ + namespace Fake + { + public readonly struct DateTime + { + public DateTime(int year, int month, int day) { } + } + } + """ + ); + } + + [Fact] + public void GeneratorRejectsUserDefinedFactoryWithFrameworkMemberName() + { + AssertRejectedBoundary( + "TimeSpan", + "FakeTimeSpan.FromHours(2)", + additionalType: + """ + public static class FakeTimeSpan + { + public static TimeSpan FromHours(int value) => TimeSpan.FromHours(value); + } + """ + ); + } + + [Fact] + public void GeneratorReportsWarningForFieldInAnotherSourceFile() + { + var validatorSource = CreateValidatorSource("DateTime", "Boundaries.Value"); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ( + "Boundaries.cs", + """ + using System; + + public static class Boundaries + { + public static readonly DateTime Value = new (2026, 1, 2); + } + """ + ) + ); + + AssertCrossFileWarning(result, "Boundaries.Value"); + } + + [Fact] + public void GeneratorReportsWarningWhenFieldTypeHasAnotherSourceDeclaration() + { + var validatorSource = CreateValidatorSource( + "DateTime", + "Boundary", + "private static readonly DateTime Boundary = new (2026, 1, 2);" + ); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ("Validator.Partial.cs", "public sealed partial class BoundaryValidator { }") + ); + + AssertCrossFileWarning(result, "Boundary"); + } + + [Fact] + public void GeneratorReportsWarningForFieldInReferencedAssembly() + { + var boundaryCompilation = GeneratorTestHarness.CreateCompilation( + ( + "ExternalBoundaries.cs", + """ + using System; + + public static class ExternalBoundaries + { + public static readonly DateTime Value = new (2026, 1, 2); + } + """ + ) + ) + .WithAssemblyName("BoundaryLibrary"); + using var stream = new MemoryStream(); + var emitResult = boundaryCompilation.Emit( + stream, + cancellationToken: TestContext.Current.CancellationToken + ); + emitResult.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var reference = MetadataReference.CreateFromImage(stream.ToArray()); + var validatorCompilation = GeneratorTestHarness.CreateCompilation( + ("Validator.cs", CreateValidatorSource("DateTime", "ExternalBoundaries.Value")) + ) + .AddReferences(reference); + + var result = GeneratorTestHarness.Run(validatorCompilation); + + AssertCrossFileWarning(result, "ExternalBoundaries.Value"); + } + + [Fact] + public void GeneratorReportsOneInfoPerDistinctUnresolvedRangeArgument() + { + var source = CreateRangeValidatorSource(); + var result = GeneratorTestHarness.Run(source); + var diagnostics = result.ResultDiagnostics + .Where(static diagnostic => diagnostic.Id == "LPRSG0015") + .ToArray(); + + diagnostics.Should().HaveCount(2); + diagnostics.Select(GetLocatedText).Should().BeEquivalentTo("DateTime.Now", "DateTime.UtcNow"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"InRange\", null, null);"); + generatedSource.Should().NotContain("[\"lowerBoundary\"]"); + generatedSource.Should().NotContain("[\"upperBoundary\"]"); + } + + private static GeneratorTestResult AssertRejectedBoundary( + string valueType, + string expression, + string additionalMember = "", + string additionalType = "" + ) + { + var result = GeneratorTestHarness.Run( + CreateValidatorSource(valueType, expression, additionalMember, additionalType) + ); + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0015") + .Should() + .ContainSingle() + .Subject; + + diagnostic.Severity.Should().Be(DiagnosticSeverity.Info); + diagnostic.Location.SourceTree!.FilePath.Should().Be("Validator.cs"); + GetLocatedText(diagnostic).Should().Be(expression); + diagnostic.GetMessage().Should().Contain("IsEqualTo").And.Contain("comparativeValue"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + generatedSource.Should().NotContain("[\"comparativeValue\"]"); + return result; + } + + private static void AssertCrossFileWarning(GeneratorTestResult result, string expression) + { + result.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Should() + .BeEmpty(); + var diagnostic = result.ResultDiagnostics + .Where(static candidate => candidate.Id == "LPRSG0016") + .Should() + .ContainSingle() + .Subject; + diagnostic.Severity.Should().Be(DiagnosticSeverity.Warning); + diagnostic.Location.SourceTree!.FilePath.Should().Be("Validator.cs"); + GetLocatedText(diagnostic).Should().Be(expression); + diagnostic.GetMessage().Should().Contain("multi-file resolution"); + result.ResultDiagnostics.Select(static candidate => candidate.Id).Should().NotContain("LPRSG0015"); + var generatedSource = result.GeneratedSources.Should().ContainSingle().Subject; + generatedSource.Should().Contain("builder.WithErrorExample(\"EqualTo\", null, null);"); + generatedSource.Should().NotContain("[\"comparativeValue\"]"); + } + + private static string GetLocatedText(Diagnostic diagnostic) => + diagnostic.Location.SourceTree!.GetText().GetSubText(diagnostic.Location.SourceSpan).ToString(); + + private static string CreateValidatorSource( + string valueType, + string expression, + string additionalMember = "", + string additionalType = "" + ) => + $$""" + using System; + using System.Globalization; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator<{{valueType}}> + { + {{additionalMember}} + + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue<{{valueType}}> PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + {{valueType}} value + ) + { + context.Check(value).IsEqualTo({{expression}}); + return checkpoint.ToValidatedValue(value); + } + } + + {{additionalType}} + """; + + private static string CreateRangeValidatorSource() => + """ + using System; + using Light.PortableResults.Validation; + using Light.PortableResults.Validation.OpenApi; + + [GeneratePortableValidationOpenApi] + public sealed partial class BoundaryValidator : Validator + { + public BoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + DateTime value + ) + { + context.Check(value).IsInRange(DateTime.Now, DateTime.UtcNow); + return checkpoint.ToValidatedValue(value); + } + } + """; +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs new file mode 100644 index 0000000..983cff7 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/GeneratorTestHarness.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using Light.PortableResults.AspNetCore.OpenApi; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.OpenApi; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; + +internal static class GeneratorTestHarness +{ + public static readonly CSharpParseOptions ParseOptions = + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + + public static GeneratorTestResult Run(string source, string path = "Validator.cs") => + Run((path, source)); + + public static GeneratorTestResult Run(params (string Path, string Source)[] sources) + { + return Run(CreateCompilation(sources)); + } + + public static GeneratorTestResult Run(CSharpCompilation compilation) + { + var driver = CreateDriver().RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics + ); + var runResult = driver.GetRunResult(); + var diagnostics = outputCompilation.GetDiagnostics() + .Concat(generatorDiagnostics) + .Concat(runResult.Diagnostics) + .ToImmutableArray(); + var generatedSources = runResult.Results + .SelectMany(static result => result.GeneratedSources) + .Select(static generatedSource => generatedSource.SourceText.ToString()) + .ToImmutableArray(); + var resultDiagnostics = runResult.Results + .SelectMany(static result => result.Diagnostics) + .ToImmutableArray(); + return new GeneratorTestResult(diagnostics, resultDiagnostics, generatedSources); + } + + public static CSharpCompilation CreateCompilation(params (string Path, string Source)[] sources) + { + var syntaxTrees = sources.Select( + static source => CSharpSyntaxTree.ParseText(source.Source, ParseOptions, source.Path) + ); + return CSharpCompilation.Create( + "GeneratorTests", + syntaxTrees, + CreateMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + public static GeneratorDriver CreateDriver() => + CSharpGeneratorDriver.Create(new PortableValidationOpenApiGenerator()) + .WithUpdatedParseOptions(ParseOptions); + + private static MetadataReference[] CreateMetadataReferences() + { + var trustedPlatformAssemblies = ((string?) AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))! + .Split(Path.PathSeparator); + var references = new HashSet(trustedPlatformAssemblies, StringComparer.OrdinalIgnoreCase); + AddAssembly(references, typeof(ValidationContext).Assembly); + AddAssembly(references, typeof(GeneratePortableValidationOpenApiAttribute).Assembly); + AddAssembly(references, typeof(PortableOpenApiSchemaTypeMapper).Assembly); + AddAssembly(references, typeof(OpenApiSchema).Assembly); + AddAssembly(references, typeof(PortableValidationOpenApiGenerator).Assembly); + return references.Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + } + + private static void AddAssembly(ISet references, Assembly assembly) + { + if (!string.IsNullOrWhiteSpace(assembly.Location)) + { + references.Add(assembly.Location); + } + } +} + +internal sealed class GeneratorTestResult +{ + public GeneratorTestResult( + ImmutableArray diagnostics, + ImmutableArray resultDiagnostics, + ImmutableArray generatedSources + ) + { + Diagnostics = diagnostics; + ResultDiagnostics = resultDiagnostics; + GeneratedSources = generatedSources; + } + + public ImmutableArray Diagnostics { get; } + public ImmutableArray ResultDiagnostics { get; } + public ImmutableArray GeneratedSources { get; } +} diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs index 39d2e70..b24486e 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiModelEqualityTests.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; using FluentAssertions; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using Xunit; namespace Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests; @@ -75,8 +77,53 @@ public void Equals_ShouldReturnFalseWhenDiagnosticContentDiffers() first.Equals(second).Should().BeFalse(); } + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticSourcePathDiffers() + { + var first = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "First.cs", 1) + ); + var second = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Second.cs", 1) + ); + + first.Equals(second).Should().BeFalse(); + } + + [Fact] + public void Equals_ShouldReturnFalseWhenDiagnosticSourceSpanDiffers() + { + var first = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Validator.cs", 1) + ); + var second = new ValidatorOpenApiAnalysis( + "Hint.g.cs", + "source", + CreateDiagnostics("a", "Validator.cs", 2) + ); + + first.Equals(second).Should().BeFalse(); + } + private static ImmutableArray CreateDiagnostics(string argument) => [Diagnostic.Create(Descriptor, Location.None, argument)]; + + private static ImmutableArray CreateDiagnostics( + string argument, + string path, + int spanStart + ) + { + var syntaxTree = CSharpSyntaxTree.ParseText("abcd", path: path); + var location = Location.Create(syntaxTree, new TextSpan(spanStart, 1)); + return [Diagnostic.Create(Descriptor, location, argument)]; + } } public sealed class RuleSchemaKeyComparerTests diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs index 98203b7..5e4f505 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using FluentAssertions; using Light.PortableResults.AspNetCore.MinimalApis; @@ -51,6 +52,23 @@ public async Task DateTimeValidationProblemBodyShouldConformToGeneratedOpenApiDo ); ValidationOpenApiDocumentTestUtilities.SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); schema.Format.Should().Be("date-time"); + + var responseDefinition = (OpenApiResponse) document.Paths["/temporal-validation"] + .Operations![HttpMethod.Post] + .Responses![StatusCodes.Status400BadRequest.ToString()]; + var example = (OpenApiExample) responseDefinition.Content!["application/problem+json"] + .Examples!["ValidationProblem"]; + var exampleBody = example.Value.Should().BeOfType().Subject; + var exampleError = exampleBody["errors"]!.AsArray() + .Single(item => item!["code"]!.GetValue() == ValidationErrorCodes.InRange)!; + exampleError["message"]!.GetValue().Should() + .Be("timestamp must be between 2026-07-26T13:45:30Z and 2026-07-27T13:45:30Z"); + exampleError["metadata"]!["lowerBoundary"]!.GetValue() + .Should() + .Be("2026-07-26T13:45:30Z"); + exampleError["metadata"]!["upperBoundary"]!.GetValue() + .Should() + .Be("2026-07-27T13:45:30Z"); } private static WebApplication CreateApp() From b5502f611b57af8cb3a912ba448562375de6ce0a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 05:20:05 +0200 Subject: [PATCH 05/13] fix(openapi): evaluate single-argument Uri boundaries as absolute new Uri(string) implies UriKind.Absolute, but reconstruction evaluated the shape with UriKind.RelativeOrAbsolute. A relative boundary such as new Uri("items/42") was reconstructed into an example even though the same call throws UriFormatException at runtime. Evaluate with UriKind.Absolute so the shape degrades with the diagnostic, matching runtime behavior. The emitted literal keeps RelativeOrAbsolute, which round-trips OriginalString either way. --- .../MetadataValueReconstructor.cs | 2 +- .../BoundaryMetadataRejectionTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs index c2921a3..f5d1bfc 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -918,7 +918,7 @@ private static ReconstructedMetadataValue EvaluateConstructor( ), SupportedConstructor.TimeOnlyTicks => CreateTimeOnly(ToInt64(arguments[0])), SupportedConstructor.Guid => ReconstructedMetadataValue.FromGuid(new Guid((string) arguments[0]!)), - SupportedConstructor.Uri => CreateUri(new Uri((string) arguments[0]!, UriKind.RelativeOrAbsolute)), + SupportedConstructor.Uri => CreateUri(new Uri((string) arguments[0]!, UriKind.Absolute)), SupportedConstructor.UriKind => CreateUri( new Uri((string) arguments[0]!, ToUriKind(arguments[1])) ), diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs index 1a5bb48..f95db43 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -122,6 +122,7 @@ string additionalType [InlineData("DateTime", "new DateTime(2026, 13, 1)")] [InlineData("Guid", "Guid.Parse(\"invalid\")")] [InlineData("Uri", "new Uri(\"http://[\")")] + [InlineData("Uri", "new Uri(\"items/42\")")] [InlineData("TimeSpan", "TimeSpan.FromDays(double.MaxValue)")] public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, string expression) { From f41a2a7e2a7574740cfd069ba3be2d0746e9c7d7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 05:33:48 +0200 Subject: [PATCH 06/13] refactor(openapi): remove the emitter's unreachable CLR-value literal arms MetadataValueModel.Value can only hold a folded constant, an attribute constant, a parameter's explicit default, or a ReconstructedMetadataValue, so the DateTime, DateTimeOffset, TimeSpan, Guid, and Uri arms of ValidatorOpenApiEmitter.ToLiteral and the reflection-based TryCreateDateOnlyOrTimeOnlyLiteral were dead code kept green only by hand-built emitter test models. Removing them lands the plan's centralized rendering design: ReconstructedMetadataValue.ToCSharpLiteral is the single renderer for reconstructed boundaries. The identical ToStringLiteral/EscapeChar copies in the emitter and ReconstructedMetadataValue move into one shared CSharpLiterals helper, and the emitter test rows that fed the deleted arms are removed; temporal literal rendering stays covered end-to-end by the generator-driven reconstruction tests. --- .../CSharpLiterals.cs | 40 ++++++ .../ReconstructedMetadataValue.cs | 35 +----- .../ValidatorOpenApiEmitter.cs | 115 +++--------------- .../ValidatorOpenApiEmitterTests.cs | 25 ---- 4 files changed, 58 insertions(+), 157 deletions(-) create mode 100644 src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs new file mode 100644 index 0000000..e8f6556 --- /dev/null +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/CSharpLiterals.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Text; + +namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; + +/// +/// Renders C# string literals and escaped characters with deterministic escaping for generated source. +/// +internal static class CSharpLiterals +{ + public static string ToStringLiteral(string value) + { + var builder = new StringBuilder(value.Length + 2).Append('"'); + foreach (var c in value) + { + builder.Append(EscapeChar(c)); + } + + return builder.Append('"').ToString(); + } + + public static string EscapeChar(char value) => + value switch + { + '\\' => @"\\", + '"' => "\\\"", + '\'' => "\\'", + '\0' => "\\0", + '\a' => "\\a", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\v' => "\\v", + _ => char.IsControl(value) ? + "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : + value.ToString() + }; +} diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs index b3f3035..89dbbf6 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using System.Text; using System.Xml; namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; @@ -235,10 +234,10 @@ public string ToCSharpLiteral() => PrimaryValue.ToString(CultureInfo.InvariantCulture) + "L)", ReconstructedMetadataValueKind.Guid => - "global::System.Guid.ParseExact(" + ToStringLiteral(Text!) + ", \"D\")", + "global::System.Guid.ParseExact(" + CSharpLiterals.ToStringLiteral(Text!) + ", \"D\")", ReconstructedMetadataValueKind.Uri => "new global::System.Uri(" + - ToStringLiteral(Text!) + + CSharpLiterals.ToStringLiteral(Text!) + ", global::System.UriKind.RelativeOrAbsolute)", _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") }; @@ -267,34 +266,4 @@ private static string GetDateTimeKindName(DateTimeKind kind) => DateTimeKind.Local => nameof(DateTimeKind.Local), _ => throw new InvalidOperationException($"Unsupported DateTime kind '{kind}'.") }; - - private static string ToStringLiteral(string value) - { - var builder = new StringBuilder(value.Length + 2).Append('"'); - foreach (var c in value) - { - builder.Append(EscapeChar(c)); - } - - return builder.Append('"').ToString(); - } - - private static string EscapeChar(char value) => - value switch - { - '\\' => @"\\", - '"' => "\\\"", - '\'' => "\\'", - '\0' => "\\0", - '\a' => "\\a", - '\b' => "\\b", - '\f' => "\\f", - '\n' => "\\n", - '\r' => "\\r", - '\t' => "\\t", - '\v' => "\\v", - _ => char.IsControl(value) ? - "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : - value.ToString() - }; } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 44a1c25..d4feda7 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text; namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; @@ -95,7 +94,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo { writer .Write("builder.WithErrorCodes(") - .Write(string.Join(", ", registeredCodes.Select(ToStringLiteral))) + .Write(string.Join(", ", registeredCodes.Select(CSharpLiterals.ToStringLiteral))) .WriteLine(");"); } @@ -110,7 +109,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo .Write("builder.WithErrorMetadata<") .Write(hint.MetadataTypeName!) .Write(">(") - .Write(ToStringLiteral(hint.Code)) + .Write(CSharpLiterals.ToStringLiteral(hint.Code)) .WriteLine(");"); } @@ -194,11 +193,11 @@ string schemaId var properties = metadataSchemaProperties .OrderBy(static property => property.Key, StringComparer.Ordinal) .ToArray(); - var requiredKeys = string.Join(", ", properties.Select(static property => ToStringLiteral(property.Key))); + var requiredKeys = string.Join(", ", properties.Select(static property => CSharpLiterals.ToStringLiteral(property.Key))); writer .Write("builder.WithErrorMetadata(") - .Write(ToStringLiteral(code)) + .Write(CSharpLiterals.ToStringLiteral(code)) .WriteLine(", _ => new OpenApiSchema") .WriteLine("{") .IncreaseIndent() @@ -211,7 +210,7 @@ string schemaId { writer .Write("[") - .Write(ToStringLiteral(property.Key)) + .Write(CSharpLiterals.ToStringLiteral(property.Key)) .Write("] = PortableOpenApiSchemaTypeMapper.Map<") .Write(property.TypeName) .WriteLine(">(),"); @@ -225,7 +224,7 @@ string schemaId .WriteLine(" }") .DecreaseIndent() .Write("}, ") - .Write(ToStringLiteral(schemaId)) + .Write(CSharpLiterals.ToStringLiteral(schemaId)) .WriteLine(");"); } @@ -237,11 +236,11 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) writer .Write("builder.WithErrorExample(") - .Write(ToStringLiteral(rule.Code)) + .Write(CSharpLiterals.ToStringLiteral(rule.Code)) .Write(", ") - .Write(rule.Target is null ? "null" : ToStringLiteral(rule.Target)) + .Write(rule.Target is null ? "null" : CSharpLiterals.ToStringLiteral(rule.Target)) .Write(", ") - .Write(rule.Message is null ? "null" : ToStringLiteral(rule.Message)); + .Write(rule.Message is null ? "null" : CSharpLiterals.ToStringLiteral(rule.Message)); if (canEmitMetadata && rule.MetadataValues.Length > 0) { @@ -273,11 +272,11 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) { writer .Write("builder.WithErrorExample(") - .Write(ToStringLiteral(example.Code)) + .Write(CSharpLiterals.ToStringLiteral(example.Code)) .Write(", ") - .Write(example.Target is null ? "null" : ToStringLiteral(example.Target)) + .Write(example.Target is null ? "null" : CSharpLiterals.ToStringLiteral(example.Target)) .Write(", ") - .Write(example.Message is null ? "null" : ToStringLiteral(example.Message)); + .Write(example.Message is null ? "null" : CSharpLiterals.ToStringLiteral(example.Message)); if (example.MetadataValues.Length > 0) { @@ -304,7 +303,7 @@ IEnumerable metadataValues ", ", metadataValues.Select( static metadataValue => - $"[{ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}" + $"[{CSharpLiterals.ToStringLiteral(metadataValue.Key)}] = {ToLiteral(metadataValue.Value)}" ) ); @@ -332,12 +331,11 @@ IEnumerable metadataValues private static string ToLiteral(object? value) => value is ReconstructedMetadataValue reconstructedValue ? reconstructedValue.ToCSharpLiteral() : - TryCreateDateOnlyOrTimeOnlyLiteral(value) ?? value switch { null => "null", - string stringValue => ToStringLiteral(stringValue), - char charValue => "'" + EscapeChar(charValue) + "'", + string stringValue => CSharpLiterals.ToStringLiteral(stringValue), + char charValue => "'" + CSharpLiterals.EscapeChar(charValue) + "'", bool boolValue => boolValue ? "true" : "false", byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), sbyte sbyteValue => sbyteValue.ToString(CultureInfo.InvariantCulture), @@ -350,92 +348,11 @@ value is ReconstructedMetadataValue reconstructedValue ? float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", - DateTime dateTimeValue => - "new global::System.DateTime(" + - dateTimeValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.DateTimeKind." + - dateTimeValue.Kind + - ")", - DateTimeOffset dateTimeOffsetValue => - "new global::System.DateTimeOffset(" + - dateTimeOffsetValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L, global::System.TimeSpan.FromTicks(" + - dateTimeOffsetValue.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + - "L))", - TimeSpan timeSpanValue => - "global::System.TimeSpan.FromTicks(" + - timeSpanValue.Ticks.ToString(CultureInfo.InvariantCulture) + - "L)", - Guid guidValue => - "global::System.Guid.ParseExact(" + - ToStringLiteral(guidValue.ToString("D")) + - ", \"D\")", - Uri uriValue => - "new global::System.Uri(" + - ToStringLiteral(uriValue.OriginalString) + - ", global::System.UriKind.RelativeOrAbsolute)", - _ => ToStringLiteral(value.ToString() ?? string.Empty) + _ => CSharpLiterals.ToStringLiteral(value.ToString() ?? string.Empty) }; private static string GetDeduplicationValue(object? value) => value is ReconstructedMetadataValue reconstructedValue ? reconstructedValue.Kind + ":" + reconstructedValue.ToCSharpLiteral() : value?.ToString() ?? string.Empty; - - private static string? TryCreateDateOnlyOrTimeOnlyLiteral(object? value) - { - if (value is null) - { - return null; - } - - var type = value.GetType(); - if (string.Equals(type.FullName, "System.DateOnly", StringComparison.Ordinal)) - { - var dayNumber = (int) type.GetProperty("DayNumber")!.GetValue(value, index: null)!; - return "global::System.DateOnly.FromDayNumber(" + - dayNumber.ToString(CultureInfo.InvariantCulture) + - ")"; - } - - if (string.Equals(type.FullName, "System.TimeOnly", StringComparison.Ordinal)) - { - var ticks = (long) type.GetProperty("Ticks")!.GetValue(value, index: null)!; - return "new global::System.TimeOnly(" + - ticks.ToString(CultureInfo.InvariantCulture) + - "L)"; - } - - return null; - } - - private static string ToStringLiteral(string value) - { - var builder = new StringBuilder(value.Length + 2).Append('"'); - foreach (var c in value) - { - builder.Append(EscapeChar(c)); - } - - return builder.Append('"').ToString(); - } - - private static string EscapeChar(char value) => - value switch - { - '\\' => @"\\", - '"' => "\\\"", - '\'' => "\\'", - '\0' => "\\0", - '\a' => "\\a", - '\b' => "\\b", - '\f' => "\\f", - '\n' => "\\n", - '\r' => "\\r", - '\t' => "\\t", - '\v' => "\\v", - _ => char.IsControl(value) ? - "\\u" + ((int) value).ToString("x4", CultureInfo.InvariantCulture) : - value.ToString() - }; } diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs index bc4b66f..18a7949 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs @@ -189,16 +189,6 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() MetadataValue("d", 2.5D), MetadataValue("m", 3.5M), MetadataValue("nothing", null), - MetadataValue("fallback", Guid.Empty), - MetadataValue("dateTime", new DateTime(638891343300000000L, DateTimeKind.Utc)), - MetadataValue( - "dateTimeOffset", - new DateTimeOffset(638891343300000000L, TimeSpan.FromHours(2)) - ), - MetadataValue("dateOnly", DateOnly.FromDayNumber(739822)), - MetadataValue("timeOnly", new TimeOnly(495300000000L)), - MetadataValue("timeSpan", TimeSpan.FromSeconds(5)), - MetadataValue("uri", new Uri("https://example.com/items/42")), MetadataValue("escapes", "a\\b\"c\nd\te\rfg\0h\ai\bj\fk\vl'm") ); var model = ModelWithRules( @@ -223,21 +213,6 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() source.Should().Contain("[\"d\"] = 2.5D"); source.Should().Contain("[\"m\"] = 3.5M"); source.Should().Contain("[\"nothing\"] = null"); - source.Should().Contain( - "[\"fallback\"] = global::System.Guid.ParseExact(\"00000000-0000-0000-0000-000000000000\", \"D\")" - ); - source.Should().Contain( - "[\"dateTime\"] = new global::System.DateTime(638891343300000000L, global::System.DateTimeKind.Utc)" - ); - source.Should().Contain( - "[\"dateTimeOffset\"] = new global::System.DateTimeOffset(638891343300000000L, global::System.TimeSpan.FromTicks(72000000000L))" - ); - source.Should().Contain("[\"dateOnly\"] = global::System.DateOnly.FromDayNumber(739822)"); - source.Should().Contain("[\"timeOnly\"] = new global::System.TimeOnly(495300000000L)"); - source.Should().Contain("[\"timeSpan\"] = global::System.TimeSpan.FromTicks(50000000L)"); - source.Should().Contain( - "[\"uri\"] = new global::System.Uri(\"https://example.com/items/42\", global::System.UriKind.RelativeOrAbsolute)" - ); source.Should() .Contain("[\"escapes\"] = \"a\\\\b\\\"c\\nd\\te\\rf\\u0001g\\0h\\ai\\bj\\fk\\vl\\'m\""); } From d23fa692d5e3ebb33b4ec87bba46b53969865c92 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 05:38:59 +0200 Subject: [PATCH 07/13] refactor(openapi): drop the unused equality surface from ReconstructedMetadataValue Reconstructed values are never compared by the incremental pipeline: ValidatorOpenApiAnalysis equality covers only hint name, source text, and diagnostics, and example de-duplication keys on GetDeduplicationValue strings. IEquatable, Equals(object), and GetHashCode had no production or test caller, so they are removed instead of covered; the members can be re-added non-breakingly if a real consumer appears. --- .../ReconstructedMetadataValue.cs | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs index 89dbbf6..65940d3 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ReconstructedMetadataValue.cs @@ -21,7 +21,7 @@ public enum ReconstructedMetadataValueKind /// /// Carries a reconstructed validation metadata boundary as a deterministic structural payload. /// -public sealed class ReconstructedMetadataValue : IEquatable +public sealed class ReconstructedMetadataValue { private const string DateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; private const string DateTimeOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"; @@ -61,14 +61,6 @@ private ReconstructedMetadataValue( /// public string? Text { get; } - /// - public bool Equals(ReconstructedMetadataValue? other) => - other is not null && - Kind == other.Kind && - PrimaryValue == other.PrimaryValue && - SecondaryValue == other.SecondaryValue && - string.Equals(Text, other.Text, StringComparison.Ordinal); - /// /// Creates a structural value from a deterministic . /// @@ -242,22 +234,6 @@ public string ToCSharpLiteral() => _ => throw new InvalidOperationException($"Unsupported reconstructed metadata kind '{Kind}'.") }; - /// - public override bool Equals(object? obj) => Equals(obj as ReconstructedMetadataValue); - - /// - public override int GetHashCode() - { - unchecked - { - var hashCode = (int) Kind; - hashCode = hashCode * 31 + PrimaryValue.GetHashCode(); - hashCode = hashCode * 31 + SecondaryValue.GetHashCode(); - hashCode = hashCode * 31 + (Text is null ? 0 : StringComparer.Ordinal.GetHashCode(Text)); - return hashCode; - } - } - private static string GetDateTimeKindName(DateTimeKind kind) => kind switch { From 210d6c53eaf03ea5b3bda0787b88778b96cf2477 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:00:57 +0200 Subject: [PATCH 08/13] fix(openapi): reconstruct static readonly fields with constant initializers TryReconstruct returned Unsupported whenever GetConstantValue succeeded. That arm cannot fire at the top level because the analyzer folds constants first, but it also governs the recursion into a static readonly field's initializer, so a field such as private static readonly int Hours = 2 used as new TimeSpan(Hours, 0, 0) degraded with LPRSG0015 while the equivalent const field worked. The reconstructor's value channel is widened from ReconstructedMetadataValue to object so a folding initializer yields its constant, making a static readonly field behave exactly like the same expression written inline, as the plan requires. --- .../MetadataValueReconstructor.cs | 15 ++++----- .../BoundaryMetadataReconstructionTests.cs | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs index f5d1bfc..0a3902f 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -27,7 +27,7 @@ public static MetadataReconstructionResult TryReconstruct( SemanticModel semanticModel, ExpressionSyntax expression, CancellationToken cancellationToken, - out ReconstructedMetadataValue? value + out object? value ) { var frameworkTypes = new FrameworkTypes(semanticModel.Compilation); @@ -50,7 +50,7 @@ private static MetadataReconstructionResult TryReconstruct( int depth, ISet resolvingFields, CancellationToken cancellationToken, - out ReconstructedMetadataValue? value + out object? value ) { cancellationToken.ThrowIfCancellationRequested(); @@ -63,7 +63,8 @@ out ReconstructedMetadataValue? value var constant = semanticModel.GetConstantValue(expression, cancellationToken); if (constant.HasValue) { - return MetadataReconstructionResult.Unsupported; + value = constant.Value; + return MetadataReconstructionResult.Success; } var operation = semanticModel.GetOperation(expression, cancellationToken); @@ -114,7 +115,7 @@ private static MetadataReconstructionResult TryReconstructObjectCreation( int depth, ISet resolvingFields, CancellationToken cancellationToken, - out ReconstructedMetadataValue? value + out object? value ) { value = null; @@ -163,7 +164,7 @@ private static MetadataReconstructionResult TryReconstructInvocation( int depth, ISet resolvingFields, CancellationToken cancellationToken, - out ReconstructedMetadataValue? value + out object? value ) { value = null; @@ -269,7 +270,7 @@ private static MetadataReconstructionResult TryReconstructField( int depth, ISet resolvingFields, CancellationToken cancellationToken, - out ReconstructedMetadataValue? value + out object? value ) { value = null; @@ -367,7 +368,7 @@ CancellationToken cancellationToken private static bool TryGetWellKnownStatic( ISymbol field, FrameworkTypes frameworkTypes, - out ReconstructedMetadataValue? value + out object? value ) { value = null; diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs index 583b62a..b7ecba9 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataReconstructionTests.cs @@ -235,6 +235,37 @@ public void GeneratorReconstructsStaticReadonlyFieldDeclaredInValidatorFile() ); } + [Fact] + public void GeneratorReconstructsStaticReadonlyFieldsWithConstantInitializers() + { + AssertAcceptedCases( + "TimeSpan", + new[] { TimeSpanCase("new TimeSpan(Hours, 0, 0)", new TimeSpan(2, 0, 0)) }, + "private static readonly int Hours = 2;" + ); + AssertAcceptedCases( + "int", + new[] { new BoundaryCase("Boundary", MetadataValue.FromInt64(2).ToCanonicalString(), "2") }, + "private static readonly int Boundary = 2;" + ); + AssertAcceptedCases( + "Guid", + new[] { GuidCase("new Guid(Text)", new Guid(GuidText)) }, + $"private static readonly string Text = \"{GuidText}\";" + ); + AssertAcceptedCases( + "DateTime", + new[] + { + DateTimeCase( + "new DateTime(2026, 1, 2, 3, 4, 5, Kind)", + new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) + ) + }, + "private static readonly DateTimeKind Kind = DateTimeKind.Utc;" + ); + } + private static void AssertAcceptedCases( string valueType, IReadOnlyList cases, From 1c697e5aa32c3f762613d6a9ce8bef457076703c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:09:32 +0200 Subject: [PATCH 09/13] test(openapi): cover reconstruction failure paths Exercise cancellation, nested diagnostic propagation, and invalid DateOnly, TimeOnly, and TimeSpan values. Use exception filters so cancellation retains its propagation contract without uncovered rethrow arms. --- .../MetadataValueReconstructor.cs | 12 +---- .../BoundaryMetadataRejectionTests.cs | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs index 0a3902f..613a994 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -146,11 +146,7 @@ out var arguments value = EvaluateConstructor(supportedConstructor, arguments); return MetadataReconstructionResult.Success; } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) + catch (Exception exception) when (exception is not OperationCanceledException) { value = null; return MetadataReconstructionResult.Unsupported; @@ -194,11 +190,7 @@ out var arguments value = EvaluateFactory(supportedFactory, arguments); return MetadataReconstructionResult.Success; } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) + catch (Exception exception) when (exception is not OperationCanceledException) { value = null; return MetadataReconstructionResult.Unsupported; diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs index f95db43..2afc50f 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using FluentAssertions; using Microsoft.CodeAnalysis; using Xunit; @@ -124,6 +126,10 @@ string additionalType [InlineData("Uri", "new Uri(\"http://[\")")] [InlineData("Uri", "new Uri(\"items/42\")")] [InlineData("TimeSpan", "TimeSpan.FromDays(double.MaxValue)")] + [InlineData("DateOnly", "DateOnly.FromDayNumber(-1)")] + [InlineData("TimeOnly", "new TimeOnly(-1L)")] + [InlineData("TimeSpan", "TimeSpan.FromMicroseconds(double.NaN)")] + [InlineData("TimeSpan", "TimeSpan.FromMicroseconds(double.MaxValue)")] public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, string expression) { var result = AssertRejectedBoundary(valueType, expression); @@ -133,6 +139,27 @@ public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, st .BeEmpty(); } + [Fact] + public void GeneratorPropagatesCancellation() + { + var compilation = GeneratorTestHarness.CreateCompilation( + ( + "Validator.cs", + CreateValidatorSource("DateTime", "new DateTime(2026, 1, 2)") + ) + ); + var cancellationToken = new CancellationToken(canceled: true); + + Action act = () => GeneratorTestHarness.CreateDriver().RunGeneratorsAndUpdateCompilation( + compilation, + out _, + out _, + cancellationToken + ); + + act.Should().ThrowExactly(); + } + [Theory] [InlineData("TimeSpan.MinValue")] [InlineData("TimeSpan.MaxValue")] @@ -289,6 +316,30 @@ public static class Boundaries AssertCrossFileWarning(result, "Boundaries.Value"); } + [Fact] + public void GeneratorPropagatesCrossFileWarningFromNestedConstructorArgument() + { + const string expression = + "new DateTimeOffset(2026, 1, 2, 0, 0, 0, Boundaries.Offset)"; + var validatorSource = CreateValidatorSource("DateTimeOffset", expression); + var result = GeneratorTestHarness.Run( + ("Validator.cs", validatorSource), + ( + "Boundaries.cs", + """ + using System; + + public static class Boundaries + { + public static readonly TimeSpan Offset = TimeSpan.FromHours(2); + } + """ + ) + ); + + AssertCrossFileWarning(result, expression); + } + [Fact] public void GeneratorReportsWarningWhenFieldTypeHasAnotherSourceDeclaration() { From 29b78c24e84c2f10fa8df1a815e279a580613ac2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:13:54 +0200 Subject: [PATCH 10/13] refactor(openapi): reuse metadata reconstructor per analysis Cache framework type symbols across metadata arguments, expose the reconstruction API publicly, and name TimeSpan constructor cases by arity. --- .../MetadataValueReconstructor.cs | 45 +++++++++++++------ .../ValidatorOpenApiAnalyzer.cs | 11 ++++- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs index 613a994..98db897 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/MetadataValueReconstructor.cs @@ -11,31 +11,48 @@ namespace Light.PortableResults.Validation.OpenApi.SourceGeneration; -internal enum MetadataReconstructionResult +/// +/// Describes the outcome of reconstructing a validation metadata value. +/// +public enum MetadataReconstructionResult { Success = 0, Unsupported = 1, MultiFileFieldUnsupported = 2 } -internal static class MetadataValueReconstructor +/// +/// Reconstructs supported validation metadata expressions into deterministic values. +/// +public sealed class MetadataValueReconstructor { private const int MaximumDepth = 4; private const long TicksPerMicrosecond = 10L; + private readonly FrameworkTypes _frameworkTypes; - public static MetadataReconstructionResult TryReconstruct( + /// + /// Creates a reconstructor for expressions belonging to the specified compilation. + /// + public MetadataValueReconstructor(Compilation compilation) + { + _frameworkTypes = new FrameworkTypes(compilation); + } + + /// + /// Attempts to reconstruct the specified expression. + /// + public MetadataReconstructionResult TryReconstruct( SemanticModel semanticModel, ExpressionSyntax expression, CancellationToken cancellationToken, out object? value ) { - var frameworkTypes = new FrameworkTypes(semanticModel.Compilation); var resolvingFields = new HashSet(SymbolEqualityComparer.Default); return TryReconstruct( semanticModel, expression, - frameworkTypes, + _frameworkTypes, depth: 0, resolvingFields, cancellationToken, @@ -576,7 +593,7 @@ out SupportedConstructor constructor ParameterType.Int32 )) { - constructor = SupportedConstructor.TimeSpanHours; + constructor = SupportedConstructor.TimeSpanThreeArguments; } else if (Matches( method, @@ -587,7 +604,7 @@ out SupportedConstructor constructor ParameterType.Int32 )) { - constructor = SupportedConstructor.TimeSpanDays; + constructor = SupportedConstructor.TimeSpanFourArguments; } else if (Matches( method, @@ -599,7 +616,7 @@ out SupportedConstructor constructor ParameterType.Int32 )) { - constructor = SupportedConstructor.TimeSpanMilliseconds; + constructor = SupportedConstructor.TimeSpanFiveArguments; } else if (Matches(method, frameworkTypes.TimeSpan, ParameterType.Int64)) { @@ -858,10 +875,10 @@ private static ReconstructedMetadataValue EvaluateConstructor( SupportedConstructor.DateTimeOffsetDateTime => ReconstructedMetadataValue.FromDateTimeOffset( new DateTimeOffset(ToDateTime(arguments[0]), ToTimeSpan(arguments[1])) ), - SupportedConstructor.TimeSpanHours => ReconstructedMetadataValue.FromTimeSpan( + SupportedConstructor.TimeSpanThreeArguments => ReconstructedMetadataValue.FromTimeSpan( new TimeSpan(ToInt32(arguments[0]), ToInt32(arguments[1]), ToInt32(arguments[2])) ), - SupportedConstructor.TimeSpanDays => ReconstructedMetadataValue.FromTimeSpan( + SupportedConstructor.TimeSpanFourArguments => ReconstructedMetadataValue.FromTimeSpan( new TimeSpan( ToInt32(arguments[0]), ToInt32(arguments[1]), @@ -869,7 +886,7 @@ private static ReconstructedMetadataValue EvaluateConstructor( ToInt32(arguments[3]) ) ), - SupportedConstructor.TimeSpanMilliseconds => ReconstructedMetadataValue.FromTimeSpan( + SupportedConstructor.TimeSpanFiveArguments => ReconstructedMetadataValue.FromTimeSpan( new TimeSpan( ToInt32(arguments[0]), ToInt32(arguments[1]), @@ -1195,9 +1212,9 @@ private enum SupportedConstructor DateTimeOffsetMillisecond = 8, DateTimeOffsetTicks = 9, DateTimeOffsetDateTime = 10, - TimeSpanHours = 11, - TimeSpanDays = 12, - TimeSpanMilliseconds = 13, + TimeSpanThreeArguments = 11, + TimeSpanFourArguments = 12, + TimeSpanFiveArguments = 13, TimeSpanTicks = 14, DateOnly = 15, TimeOnlyMinute = 16, diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs index 057a6d0..4a2d582 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs @@ -72,11 +72,13 @@ out var methodDeclaration } var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree); + var metadataValueReconstructor = new MetadataValueReconstructor(compilation); var sourceParameterName = performValidation.Parameters.Length >= 3 ? performValidation.Parameters[2].Name : null; var rules = ImmutableArray.CreateBuilder(); AnalyzePerformValidationBody( semanticModel, + metadataValueReconstructor, methodDeclaration, sourceParameterName, rules, @@ -204,6 +206,7 @@ CancellationToken cancellationToken private static void AnalyzePerformValidationBody( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, MethodDeclarationSyntax methodDeclaration, string? sourceParameterName, ICollection rules, @@ -223,6 +226,7 @@ CancellationToken cancellationToken { AnalyzeCheckExpression( semanticModel, + metadataValueReconstructor, expression, sourceParameterName, rules, @@ -238,6 +242,7 @@ CancellationToken cancellationToken private static void AnalyzeCheckExpression( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, ExpressionSyntax expression, string? sourceParameterName, ICollection rules, @@ -294,6 +299,7 @@ CancellationToken cancellationToken var rule = CreateRuleCall( semanticModel, + metadataValueReconstructor, invocation, symbol, ruleAttribute, @@ -311,6 +317,7 @@ CancellationToken cancellationToken private static RuleCallModel? CreateRuleCall( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, InvocationExpressionSyntax invocation, IMethodSymbol symbol, AttributeData ruleAttribute, @@ -356,6 +363,7 @@ CancellationToken cancellationToken { if (!TryResolveArgumentConstant( semanticModel, + metadataValueReconstructor, invocation, symbol, sourceArgument!, @@ -784,6 +792,7 @@ out string typeName private static bool TryResolveArgumentConstant( SemanticModel semanticModel, + MetadataValueReconstructor metadataValueReconstructor, InvocationExpressionSyntax invocation, IMethodSymbol symbol, string sourceArgument, @@ -841,7 +850,7 @@ out MetadataReconstructionResult reconstructionResult } else { - reconstructionResult = MetadataValueReconstructor.TryReconstruct( + reconstructionResult = metadataValueReconstructor.TryReconstruct( semanticModel, argument.Expression, cancellationToken, From 2428eae5fae3eb0a74917bdcaf8faa97cdf5a86d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:15:53 +0200 Subject: [PATCH 11/13] chore: add quote to plan 57 indicating wrong technical details Signed-off-by: Kenny Pflug --- ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md index 9fdd55f..8a2808c 100644 --- a/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md +++ b/ai-plans/0057-0-openapi-examples-for-non-constant-boundaries.md @@ -1,5 +1,7 @@ # OpenAPI Error Examples for Non-Constant Validation Boundaries +> Correction: two claims in Technical Details are wrong, without consequence for the implemented design. The integral `TimeSpan.From*` overloads arrived in .NET 9, not .NET 8 — the reasoning about `TimeSpan.FromHours(2)` binding differently per target framework holds, only the version is off. And `TimeSpan.FromDays(double)` rounds to the nearest millisecond only on .NET Framework; modern .NET rounds to the tick. The conclusion still stands, because the implementation calls the real `double` overloads on the compiler host and only reimplements `FromMicroseconds(double)`, which `netstandard2.0` lacks. + ## Rationale The validation source generator learns a rule's boundary value through `SemanticModel.GetConstantValue`, which only succeeds for C# constant expressions. `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, `DateOnly`, `TimeOnly`, and `Uri` cannot be C# constants at all, so a temporal or identifier boundary can never be folded — not by writing it differently and not by hoisting it into a `static readonly` field. The rule then loses both its message and its metadata, and the generated example carries neither. Two checks written identically in the same validator produce usable documentation for the `int` one and an empty example for the `DateTime` one, with no diagnostic to explain the difference. From 308244d3f7d0f7336565d29f7e1fc9806dee9bdf Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:26:21 +0200 Subject: [PATCH 12/13] test(openapi): remove misleading cancellation coverage Roslyn intercepts a pre-cancelled token before metadata reconstruction, so the removed test did not constrain the reconstructor's exception filters. Record that the cancellation contract is currently a structural guarantee. --- AGENTS.md | 2 ++ .../BoundaryMetadataRejectionTests.cs | 23 ------------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e070cf8..f7f9c9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,5 @@ Read ./ai-plans/AGENTS.md for details on how to write plans. ## Here is Your Space If you encounter something worth noting while you are working on this code base, write it down here in this section. Once you are finished, I will discuss it with you, and we can decide where to put your notes. + +- `MetadataValueReconstructor` preserves `OperationCanceledException` through its exception filters, but this is not behaviorally testable through the generator's public surface: a pre-cancelled token is intercepted by Roslyn before reconstruction, and none of the whitelisted framework evaluations can throw cancellation. The contract therefore rests on the filters' structure unless an accepted evaluation gains a reachable cancellation path. diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs index 2afc50f..45a1155 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -1,8 +1,6 @@ -using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Threading; using FluentAssertions; using Microsoft.CodeAnalysis; using Xunit; @@ -139,27 +137,6 @@ public void GeneratorDegradesWhenRecognizedEvaluationThrows(string valueType, st .BeEmpty(); } - [Fact] - public void GeneratorPropagatesCancellation() - { - var compilation = GeneratorTestHarness.CreateCompilation( - ( - "Validator.cs", - CreateValidatorSource("DateTime", "new DateTime(2026, 1, 2)") - ) - ); - var cancellationToken = new CancellationToken(canceled: true); - - Action act = () => GeneratorTestHarness.CreateDriver().RunGeneratorsAndUpdateCompilation( - compilation, - out _, - out _, - cancellationToken - ); - - act.Should().ThrowExactly(); - } - [Theory] [InlineData("TimeSpan.MinValue")] [InlineData("TimeSpan.MaxValue")] From 930934362a8fa85d6d9872ed53ea7007beca0059 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 06:29:44 +0200 Subject: [PATCH 13/13] test(openapi): cover field-backed local DateTime kind --- .../BoundaryMetadataRejectionTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs index 45a1155..2616243 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/BoundaryMetadataRejectionTests.cs @@ -104,6 +104,13 @@ public static IEnumerable OmittedBoundaryCases() "", "" ]; + yield return + [ + "DateTime", + "new DateTime(2026, 1, 2, 3, 4, 5, Kind)", + "private static readonly DateTimeKind Kind = DateTimeKind.Local;", + "" + ]; } [Theory]