Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2188be7
chore: clarify note about breaking changes in top-level AGENTS.md
feO2x Mar 7, 2026
f8c6255
chore: add initial AI plan for validation
feO2x Mar 7, 2026
6e4fec3
chore: update AI plan with `ValidationOutcome<T>` and transformed val…
feO2x Mar 7, 2026
d024a50
chore: add async validator support to the plan
feO2x Mar 7, 2026
e5dc762
chore: add links to plan 24
feO2x Mar 7, 2026
5611e54
chore: finalize plan
feO2x Mar 7, 2026
8a41a14
chore: add caching strategy for Error.Target normalization to plan 24
feO2x Mar 7, 2026
b3cbe1c
chore: add additional acceptance critera
feO2x Mar 7, 2026
9d12929
chore: root AGENTS.md now contains note about checking off acceptance…
feO2x Mar 7, 2026
d324d04
chore: add validation foundation
feO2x Mar 7, 2026
d395694
chore: add initial benchmark results for validation
feO2x Mar 8, 2026
0b3e937
chore: cleanup code after initial validation implementation
feO2x Mar 8, 2026
ece683a
chore: correct src/AGENTS.md in light of new Validation project
feO2x Mar 9, 2026
1019659
chore: md files are no longer reformatted by Rider/ReSharper
feO2x Mar 9, 2026
23f42c9
chore: cleanup AGENTS.md files
feO2x Mar 9, 2026
9baa3a9
chore: add AI plan for ValidationContext optimization
feO2x Mar 9, 2026
f42d52a
perf: optimize ValidationContext handling
feO2x Mar 10, 2026
f57b08f
chore: add AI plan for validation options and error templates optimiz…
feO2x Mar 10, 2026
9ba413b
perf: optimized options and error templates
feO2x Mar 12, 2026
f794391
chore: add plan for ValidationOutcome<T> removal
feO2x Mar 12, 2026
7c0effb
perf: remove ValidatedOutcome<T>, introduce ValidatedValue<T>
feO2x Mar 12, 2026
06ea543
chore: add AI plan for target normalization optimization
feO2x Mar 12, 2026
590ca8b
perf: optimize target normalization
feO2x Mar 13, 2026
9ed6c22
chore: ValidationContextKey.ToString now contains type
feO2x Mar 13, 2026
8487789
test: add additional tests for ValidationContextKey<T> to ensure it b…
feO2x Mar 13, 2026
43ef6e9
chore: remove obsolete methods from ValidationTargets.cs
feO2x Mar 13, 2026
3cd9ebb
chore: remove setting Target when Error instance is added to the context
feO2x Mar 13, 2026
912a1ff
chore: add explanatory XML comments for when IValidationTargetNormali…
feO2x Mar 13, 2026
50d305b
chore: add plan deviations
feO2x Mar 13, 2026
54fd644
chore: add comment why there are two different if branches handling s…
feO2x Mar 13, 2026
4fc3363
chore: fix XML comment for ValidationContext.GetAutomaticNullTarget
feO2x Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Root Agents.md

Light.PortableResults is a lightweight, high-performance library implementing the Result Pattern for .NET. It stands out
for reducing allocations and being able to serialize and deserialize results across different protocols (HTTP via
RFC-9457, gRPC, Asynchronous Messaging). Extensibility is less important than performance.
Light.PortableResults is a lightweight, high-performance library implementing the Result Pattern for .NET. It stands out for reducing allocations and being able to serialize and deserialize results across different protocols (HTTP via RFC-9457, gRPC, Asynchronous Messaging). Extensibility is less important than performance.

## Implementation rules

Plans typically have acceptance criteria with check boxes. Check each box when you are finished with the corresponding criterion.

## General Rules for the Code Base

Expand All @@ -11,7 +13,7 @@ In our Directory.Build.props files in this solution, the following rules are def
- Implicit usings or global usings are not allowed - use explicit using statements for clarity.
- The Light.PortableResults project is built with .NET Standard 2.0, but you can use C# 14 features.
- All other projects use .NET 10, including the test projects.
- The library is not published yet, you can make breaking changes.
- The library is not published in a stable version yet, you can make breaking changes.
- `<TreatWarningsAsErrors>` is enabled in Release builds, so your code changes must not generate warnings.
- If it is properly encapsulated, make it public. We don't know how callers would like to use this library. When some
types are internal, this might make it hard for callers to access these in tests or when making configuration changes.
Expand All @@ -31,5 +33,9 @@ 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.
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.

- Validation target composition needs a clear ownership boundary: when a child validation context is already working
with a fully composed `Error.Target`, composing the prefix again duplicates paths such as
`address.address.zipCode`. The new validation package treats explicit `Error.Target` values as already absolute and
only prefixes targets when the context itself creates the target path.
638 changes: 638 additions & 0 deletions BenchmarkDotNet.Artifacts/BenchmarkRun-20260308-104943.log

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
```

BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (25D2128) [Darwin 25.3.0]
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a


```
| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---------------------------------- |---------:|--------:|--------:|------:|-------:|-------:|----------:|------------:|
| FluentValidationEndpoint | 422.5 ns | 3.29 ns | 2.91 ns | 1.00 | 0.2789 | 0.0010 | 2.28 KB | 1.00 |
| PortableResultsValidationEndpoint | 409.4 ns | 1.83 ns | 1.53 ns | 0.97 | 0.2913 | 0.0010 | 2.38 KB | 1.04 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,Gen0,Gen1,Allocated,Alloc Ratio
FluentValidationEndpoint,DefaultJob,False,Default,Default,Default,Default,Default,Default,0000000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,422.5 ns,3.29 ns,2.91 ns,1.00,0.2789,0.0010,2.28 KB,1.00
PortableResultsValidationEndpoint,DefaultJob,False,Default,Default,Default,Default,Default,Default,0000000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,409.4 ns,1.83 ns,1.53 ns,0.97,0.2913,0.0010,2.38 KB,1.04
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<title>Benchmarks.ComplexValidationEndpointBenchmarks-20260308-104951</title>

<style type="text/css">
table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
td, th { padding: 6px 13px; border: 1px solid #ddd; text-align: right; }
tr { background-color: #fff; border-top: 1px solid #ccc; }
tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<pre><code>
BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (25D2128) [Darwin 25.3.0]
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
</code></pre>
<pre><code></code></pre>

<table>
<thead><tr><th>Method </th><th>Mean</th><th>Error</th><th>StdDev</th><th>Ratio</th><th>Gen0</th><th>Gen1</th><th>Allocated</th><th>Alloc Ratio</th>
</tr>
</thead><tbody><tr><td>FluentValidationEndpoint</td><td>422.5 ns</td><td>3.29 ns</td><td>2.91 ns</td><td>1.00</td><td>0.2789</td><td>0.0010</td><td>2.28 KB</td><td>1.00</td>
</tr><tr><td>PortableResultsValidationEndpoint</td><td>409.4 ns</td><td>1.83 ns</td><td>1.53 ns</td><td>0.97</td><td>0.2913</td><td>0.0010</td><td>2.38 KB</td><td>1.04</td>
</tr></tbody></table>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
```

BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (25D2128) [Darwin 25.3.0]
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a


```
| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---------------------------------- |---------:|--------:|--------:|------:|-------:|-------:|----------:|------------:|
| FluentValidationEndpoint | 147.7 ns | 0.85 ns | 0.80 ns | 1.00 | 0.1338 | 0.0002 | 1120 B | 1.00 |
| PortableResultsValidationEndpoint | 100.2 ns | 0.58 ns | 0.54 ns | 0.68 | 0.0889 | - | 744 B | 0.66 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,LargeAddressAware,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,Mean,Error,StdDev,Ratio,Gen0,Gen1,Allocated,Alloc Ratio
FluentValidationEndpoint,DefaultJob,False,Default,Default,Default,Default,Default,Default,0000000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,147.7 ns,0.85 ns,0.80 ns,1.00,0.1338,0.0002,1120 B,1.00
PortableResultsValidationEndpoint,DefaultJob,False,Default,Default,Default,Default,Default,Default,0000000000000000,Empty,RyuJit,Default,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 10.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,100.2 ns,0.58 ns,0.54 ns,0.68,0.0889,0.0000,744 B,0.66
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<title>Benchmarks.SimpleValidationEndpointBenchmarks-20260308-105045</title>

<style type="text/css">
table { border-collapse: collapse; display: block; width: 100%; overflow: auto; }
td, th { padding: 6px 13px; border: 1px solid #ddd; text-align: right; }
tr { background-color: #fff; border-top: 1px solid #ccc; }
tr:nth-child(even) { background: #f8f8f8; }
</style>
</head>
<body>
<pre><code>
BenchmarkDotNet v0.15.8, macOS Tahoe 26.3.1 (25D2128) [Darwin 25.3.0]
Apple M3 Max, 1 CPU, 16 logical and 16 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
</code></pre>
<pre><code></code></pre>

<table>
<thead><tr><th>Method </th><th>Mean</th><th>Error</th><th>StdDev</th><th>Ratio</th><th>Gen0</th><th>Gen1</th><th>Allocated</th><th>Alloc Ratio</th>
</tr>
</thead><tbody><tr><td>FluentValidationEndpoint</td><td>147.7 ns</td><td>0.85 ns</td><td>0.80 ns</td><td>1.00</td><td>0.1338</td><td>0.0002</td><td>1120 B</td><td>1.00</td>
</tr><tr><td>PortableResultsValidationEndpoint</td><td>100.2 ns</td><td>0.58 ns</td><td>0.54 ns</td><td>0.68</td><td>0.0889</td><td>-</td><td>744 B</td><td>0.66</td>
</tr></tbody></table>
</body>
</html>
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="FluentAssertions" Version="[7.2.0]" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
Expand Down
4 changes: 2 additions & 2 deletions Light.PortableResults.sln.DotSettings
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<wpf:ResourceDictionary xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
<wpf:ResourceDictionary xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xml:space="preserve">
<s:Boolean
Expand Down Expand Up @@ -58,7 +58,7 @@
&amp;lt;Reformat&amp;gt;true&amp;lt;/Reformat&amp;gt;&#xD;
&amp;lt;/Language&amp;gt;&#xD;
&amp;lt;Language id="Markdown"&amp;gt;&#xD;
&amp;lt;Reformat&amp;gt;true&amp;lt;/Reformat&amp;gt;&#xD;
&amp;lt;Reformat&amp;gt;false&amp;lt;/Reformat&amp;gt;&#xD;
&amp;lt;/Language&amp;gt;&#xD;
&amp;lt;Language id="Properties"&amp;gt;&#xD;
&amp;lt;Reformat&amp;gt;true&amp;lt;/Reformat&amp;gt;&#xD;
Expand Down
20 changes: 12 additions & 8 deletions Light.PortableResults.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
<File Path="ai-plans\0015-final-code-review.md" />
<File Path="ai-plans\0017-HTTP-write-integration-streamlining.md" />
<File Path="ai-plans\0022-rename-summary.md" />
<File Path="ai-plans\0024-normalization-optimization.md" />
<File Path="ai-plans\0024-options-and-error-templates-optimization.md" />
<File Path="ai-plans\0024-plan-deviations.md" />
<File Path="ai-plans\0024-validation-context-optimization.md" />
<File Path="ai-plans\0024-validation-outcome-removal.md" />
<File Path="ai-plans\0024-validation-support.md" />
<File Path="ai-plans\AGENTS.md" />
</Folder>
<Folder Name="/benchmarks/">
Expand All @@ -46,19 +52,17 @@
<File Path="src\AGENTS.md" />
<File Path="src\Directory.Build.props" />
<Project Path="src\Light.PortableResults.AspNetCore.Mvc\Light.PortableResults.AspNetCore.Mvc.csproj" />
<Project
Path="src\Light.PortableResults.AspNetCore.MinimalApis\Light.PortableResults.AspNetCore.MinimalApis.csproj" />
<Project Path="src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj" />
<Project Path="src\Light.PortableResults.AspNetCore.MinimalApis\Light.PortableResults.AspNetCore.MinimalApis.csproj" />
<Project Path="src\Light.PortableResults.AspNetCore.Shared\Light.PortableResults.AspNetCore.Shared.csproj" />
<Project Path="src\Light.PortableResults\Light.PortableResults.csproj" />
</Folder>
<Folder Name="/tests/">
<File Path="tests\AGENTS.md" />
<Project
Path="tests\Light.PortableResults.AspNetCore.Mvc.Tests\Light.PortableResults.AspNetCore.Mvc.Tests.csproj" />
<Project
Path="tests\Light.PortableResults.AspNetCore.MinimalApis.Tests\Light.PortableResults.AspNetCore.MinimalApis.Tests.csproj" />
<Project
Path="tests\Light.PortableResults.AspNetCore.Shared.Tests\Light.PortableResults.AspNetCore.Shared.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.Mvc.Tests\Light.PortableResults.AspNetCore.Mvc.Tests.csproj" />
<Project Path="tests/Light.PortableResults.Validation.Tests/Light.PortableResults.Validation.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.MinimalApis.Tests\Light.PortableResults.AspNetCore.MinimalApis.Tests.csproj" />
<Project Path="tests\Light.PortableResults.AspNetCore.Shared.Tests\Light.PortableResults.AspNetCore.Shared.Tests.csproj" />
<Project Path="tests\Light.PortableResults.Tests\Light.PortableResults.Tests.csproj" />
</Folder>
</Solution>
30 changes: 30 additions & 0 deletions ai-plans/0024-normalization-optimization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Validation Target Normalization Optimization

## Rationale

`DefaultValidationTargetNormalizer.NormalizeCore` currently performs several avoidable string allocations on cache misses, including whole-path trimming, per-segment substring creation, segment cleanup, and casing rewrites. This plan streamlines the miss path with a single-pass parser over the input characters so that validation target normalization remains semantically stable while producing less cold-path allocation churn for unique target expressions.

## Acceptance Criteria

- [x] `DefaultValidationTargetNormalizer.NormalizeCore` is redesigned to parse the raw path in a single pass without using `Substring` for member segments and without allocating intermediate strings for segment cleanup.
- [x] The optimized implementation preserves the current public normalization behavior for whitespace trimming, root-parameter removal, member separators, indexers, casing modes, empty inputs, and malformed indexers.
- [x] The implementation keeps the existing cache behavior and thread-safety characteristics of `DefaultValidationTargetNormalizer`.
- [x] The new normalization pipeline uses a stack-first output strategy with a safe fallback for longer targets so short and medium paths avoid unnecessary heap allocations beyond the final normalized string.
- [x] Any dead or redundant logic uncovered by the parser rewrite is removed or folded into the new flow without changing externally observable behavior.
- [x] Automated tests cover the optimized normalizer with representative simple, nested, indexed, whitespace-padded, verbatim-identifier, casing-preserving, empty, and malformed-path inputs.

## Technical Details

Keep the change localized to `Light.PortableResults.Validation.DefaultValidationTargetNormalizer`. The public API of `IValidationTargetNormalizer`, the constructor surface of `DefaultValidationTargetNormalizer`, and the surrounding cache ownership model should remain unchanged. The optimization target is the work done after a cache miss, not the dictionary lookup itself.

Replace the current normalization flow with a `ReadOnlySpan<char>`-based parser that walks the trimmed input once from left to right and writes to a `Span<char>`-backed output buffer. Instead of creating per-segment substrings and then post-processing them, compute the effective start and end indexes for each segment directly from the source characters. The parser should continue to strip the leading root segment before the first member separator when present, preserve bracketed indexers verbatim, and apply the configured casing convention only to actual member-name characters.

Handle trimming manually by computing the first and last non-whitespace positions in the original string before the main parse begins and then slicing that range as a `ReadOnlySpan<char>`. This removes the full-string `Trim` allocation. During segment handling, trim segment-local whitespace by advancing and retreating indexes within the same source span rather than materializing temporary strings.

The parser should write normalized output into a stack-first builder abstraction. When the trimmed input length is at most 512 characters, use stack-allocated storage for the output buffer so short and medium paths avoid renting or allocating an intermediate heap buffer. When the trimmed input length exceeds 512 characters, fall back to a pooled buffer rented from `ArrayPool<char>.Shared` and return that buffer reliably after the final string has been created. Avoid using a plain growable heap buffer such as `StringBuilder` or a repeatedly resized `char[]`, because the purpose of this fallback is to keep even unusually long targets on a low-allocation path. This threshold should be treated as a deliberate implementation constant rather than an incidental detail so that future benchmark work can tune it explicitly if needed. The builder should emit exactly one final normalized string once parsing is complete. Keep this helper private to the normalizer unless reuse becomes clearly justified inside the validation project.

Segment cleanup should be folded into the parse loop. In particular, remove ignored prefixes such as `@` directly from the segment window before writing, and only transform the first significant character when camel-case or pascal-case conversion is required. Characters after the first significant character should be copied through as-is. This avoids the current pattern of building a cleaned segment string and then building another derived string for casing.

Preserve current edge-case behavior deliberately. Empty or whitespace-only inputs must still normalize to `string.Empty`. A path that consists only of a root segment must still normalize to `string.Empty` after root removal. Malformed indexers without a closing `]` should continue to keep the unmatched tail rather than throwing. Before implementation, capture the current behavior with tests so the rewrite can be validated against the existing semantics rather than an inferred interpretation.

Review the current helper methods while implementing the parser. Helpers that exist only because of the substring-based implementation should be removed or collapsed into span-aware logic. If any currently unreachable cleanup logic is discovered, remove it only after tests demonstrate that no public scenario depends on it.
Loading
Loading