Skip to content

Refactor union dispatch to use reservation-based target assignment#295

Open
DZakh wants to merge 14 commits into
mainfrom
claude/great-euler-12p4mn
Open

Refactor union dispatch to use reservation-based target assignment#295
DZakh wants to merge 14 commits into
mainfrom
claude/great-euler-12p4mn

Conversation

@DZakh

@DZakh DZakh commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

Refactors the union decoder to use a three-tier reservation algorithm for assigning source variants to target variants when compiling source → targetUnion. This improves correctness by preventing unreachable variants and enables better code generation through per-case .to application.

Key Changes

  • New unionReservable function: Identifies variants that can participate in reservation (primitives, literals, null/undefined/NaN with no transformations).

  • New unionAssignTargets function: Implements three-tier target assignment:

    • Tier 1: Same-type match with exact const preferred (reserved, exclusive)
    • Tier 2: Nullish bridge to opposite null/undefined (reserved, exclusive)
    • Tier 3: Remaining reservable variants coerce over free targets; non-reservable variants keep full target
    • Variants with no target assigned map to never and fail at runtime
  • New helper functions: unionTypeValidationSchema, unionPrioritizeConst, unionActiveKey, unionIsPassthrough, unionRefinerAttacher, unionFail — extracted from inline union decoder logic for clarity and reusability.

  • Refactored unionDecoder: Now delegates to unionEmit for the dispatch generation, using unionIsPassthrough to detect when input can be returned unchanged.

  • Per-case .to application: When a union has a .to transformation and active key narrowing isn't possible, each variant's schema is updated with its assigned target and union refiners are attached per-case via unionRefinerAttacher.

  • Code formatting: Fixed indentation and formatting throughout the union decoder section and related functions to match project conventions.

Implementation Details

The reservation algorithm ensures that when coercing source → targetUnion:

  • A string source variant claims the string target variant (tier 1), leaving unit/None unreachable
  • A null source can bridge to undefined target if no same-type match exists (tier 2)
  • Structural variants (objects, arrays, instances, refs, nested unions) and transforming variants keep the full target and dispatch at runtime (tier 3)

This prevents silent data loss and makes union coercion semantics explicit and predictable. The refactored code is more maintainable with extracted helper functions that can be tested and reasoned about independently.

https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv

Summary by CodeRabbit

  • Documentation

    • Clarified union conversion rules, including direct-match priority, nullish bridging, target reservation, and coercion behavior.
    • Added examples explaining exclusive matching and how to opt into cross-type transformations.
  • Behavior Changes

    • Union conversions now prevent unmatched source variants from reusing targets claimed by direct matches.
    • Missing required fields and invalid "undefined" values now produce clear validation errors.
    • Improved synchronous error reporting for invalid union branches.
  • Refactor

    • Improved internal union dispatch and shaped-schema processing without changing the public API.

claude added 14 commits June 20, 2026 17:44
…regression

Source-union conversion runs the three-tier target-matching algorithm
independently per source variant with no target consumption: several
variants can resolve to the same target, and a variant with no tier-1/2
match still falls through to tier 3 instead of erroring.

Surface this with S.string->S.to(S.option(S.string)): on encode the
string variant maps via tier-1 identity while None falls to tier-3 and
stringifies to "undefined" rather than failing with invalid type. Captured
as a regression test and documented in both usage guides, with FIXMEs
marking the surprising None -> "undefined" coercion for later revisit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
…users

Add a regression for the tier-2 nullish bridge: S.option(S.string)->
S.to(S.null(S.string)) maps None <-> null in both directions instead of
stringifying, complementing the tier-1/tier-3 option case.

Rewrite the new union-conversion doc paragraphs in both usage guides to
drop tier jargon and internal "no target consumption" framing, keeping the
practical behavior a new user needs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
The None -> "undefined" encode is a bug to be fixed, not intended design, so
remove it from the usage guides. Keep only the intended optional behavior: the
nullish bridge (None <-> null) when the target has a null/undefined case.

The FIXME-marked regression in S_to_test.res stays as a tripwire that will
flag the change once the behavior is fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Rewrite the union conversion intro to describe the intended design: each
source case maps to one target case, same-type matches win and claim their
target exclusively, and the remaining cases resolve over the still-free
targets via the nullish bridge or coercion (erroring if none is left).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Make the whole "Decoding into / out of a union" section consistent with the
intended design: each source case maps to one target, resolved tier 1 -> 2 ->
3 across all cases, and tiers 1 and 2 reserve their target so no other case can
reuse it (tier 3 coercion does not reserve).

Update the worked examples to match: bigint -> error once string is reserved,
and the stringified "null" -> error once the undefined -> null bridge reserves
the null target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Union `.to` conversion now resolves which target each source variant maps to
with reservation, matching the documented "direct matches are exclusive" model:

- unionAssignTargets walks reservable variants tier 1 (same-type, exact const
  preferred) then tier 2 (nullish bridge) — both claim their target so no other
  variant can reuse it — then tier 3 shares whatever targets remain free. A
  reservable variant with no target left maps to `never` and errors.
- Only primitives/literals/null/undefined that pass their value through
  unchanged are reservable; objects, instances, arrays, refs, nested unions and
  transforming variants keep the full target and dispatch at runtime.
- Reservation applies only to genuine alternative sources (activeKey === ""),
  not when a known single value is decoded into a union.

Effects: `S.option(x)->to(string)` no longer stringifies None to "undefined"
(it errors); nullish bridges (None <-> null) compile without a try/catch
dispatch wrapper; a coercion can no longer steal a target an identity match
reserved (e.g. bigint/bool/float into a claimed string target error). Tests and
the dict->object "deferred tier fix" cases updated to the new behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Move pure resolution logic out of the unionDecoder codegen into named,
testable functions, separating "which target/variant" decisions from emission:

- unionActiveKey: tier-narrowing (known input type -> single dispatch key,
  incl. nullish bridge)
- unionPrioritizeConst: const-input variant ordering
- (alongside the existing unionReservable / unionAssignTargets reservation pass)

Behavior-preserving refactor; generated code unchanged, all tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Move the tag -> canonical type-only schema mapping out of the decoder loop
into a pure unionTypeValidationSchema helper, continuing the resolution/
emission separation. Behavior-preserving; all tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Fold the three scattered passthrough conditions (self-decode noop, wider-union
input, already-output) out of unionDecoder into a single named, documented
unionIsPassthrough helper. These guards keep generated code minimal (emit the
input untouched instead of a full dispatch), so they are preserved and
centralized as explicit planning logic rather than deleted. Behavior-preserving;
all tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Lift the entire emission body out of unionDecoder into a dedicated unionEmit
function. unionDecoder now reads as passthrough -> resolve -> emit:
  - passthrough: unionIsPassthrough
  - resolve/plan: unionActiveKey, unionPrioritizeConst, unionAssignTargets
  - emit: unionEmit (the per-schema codegen optimizer)

Faithful extraction — generated code is byte-identical, all 1474 tests green,
no snapshot churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuMuwLb7tufLms3U3Gs3Kv
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Union conversion now reserves direct and nullish target cases, separates coercive fallback behavior, and updates generated decoder control flow. Documentation and tests reflect the new exclusivity rules, while shaped-schema helpers and internal formatting are reorganized.

Changes

Union conversion behavior

Layer / File(s) Summary
Union target reservation contract
docs/*-usage.md, packages/sury/src/Sury.res.mjs, packages/sury/tests/S_to*.res
Documents and tests define exclusive direct matches, reservable nullish bridges, non-reserving coercions, and updated failure behavior.
Union decoder generation and internal reflow
packages/sury/src/Sury.res.mjs, packages/sury/src/Sury.res
Union decoder generation adds target assignment, passthrough detection, discriminant handling, and centralized failures; ReScript internals are primarily reformatted.
Shaped-schema traversal and parser outputs
packages/sury/src/Sury.res.mjs
Shaped definitions, parser outputs, serializer outputs, and helper placement are reorganized around recursive traversal.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SourceUnion
  participant unionAssignTargets
  participant unionDecoder
  participant TargetSchema
  SourceUnion->>unionAssignTargets: provide source and target cases
  unionAssignTargets->>TargetSchema: reserve direct or nullish targets
  unionAssignTargets-->>unionDecoder: return target assignments
  unionDecoder->>TargetSchema: decode assigned cases
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: union dispatch was refactored around reservation-based target assignment.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/great-euler-12p4mn
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch claude/great-euler-12p4mn

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/sury/src/Sury.res`:
- Around line 3122-3142: Update unionIsSelfDecodeNoop to require both
schema.refiner and schema.inputRefiner to be absent, alongside the existing
passthrough conditions. This ensures unionIsPassthrough never returns refined
input unchanged and preserves required user refiner calls.
- Around line 3493-3524: Update unionRefinerAttacher’s attach caching so it does
not reuse complete refiner check arrays across union cases. Regenerate
fn(~input) for each case-specific val, while caching only predicate data that is
independent of the case schema, expected type, path, and variable allocation;
preserve the existing merge behavior for current and newly generated checks.
- Around line 3282-3311: Update the tier-2 nullish bridge logic in the pass
containing `claim` so a source variant may claim the opposite nullish target
only when `unionReservable` approves it. Apply this check before selecting or
reserving a target, while preserving the existing null/undefined pairing and
reservation behavior for eligible variants.
- Around line 3253-3274: The fallback matching loop in the union reservation
pass must not reserve a mismatched literal target. In the `chosen.contents ===
-1` fallback within the `unionReservable` handling, require the candidate target
to be non-literal while retaining the `unionToKey` match; leave exact constant
matching in the preceding pass unchanged so broad targets remain available for
unmatched source literals.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ad0dcade-7073-4370-80ef-271f5e04cb6e

📥 Commits

Reviewing files that changed from the base of the PR and between 7ee36f0 and f4da00b.

📒 Files selected for processing (6)
  • docs/js-usage.md
  • docs/rescript-usage.md
  • packages/sury/src/Sury.res
  • packages/sury/src/Sury.res.mjs
  • packages/sury/tests/S_to_dictToObject_test.res
  • packages/sury/tests/S_to_test.res

Comment on lines 3122 to +3142
and unionIsSelfDecodeNoop = (schema: internal) => {
schema.to === None &&
schema.parser === None &&
!(schema.tag->TagFlag.get->Flag.unsafeHas(TagFlag.ref)) &&
switch schema.anyOf {
| Some(anyOf) => anyOf->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.items {
| Some(items) => items->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.properties {
| Some(properties) => properties->Stdlib.Dict.valuesToArray->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.additionalItems {
| Some(Schema(s)) => s->castToInternal->unionIsSelfDecodeNoop
| _ => true
}
schema.to === None &&
schema.parser === None &&
!(schema.tag->TagFlag.get->Flag.unsafeHas(TagFlag.ref)) &&
switch schema.anyOf {
| Some(anyOf) => anyOf->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.items {
| Some(items) => items->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.properties {
| Some(properties) => properties->Stdlib.Dict.valuesToArray->Array.every(unionIsSelfDecodeNoop)
| None => true
} &&
switch schema.additionalItems {
| Some(Schema(s)) => s->castToInternal->unionIsSelfDecodeNoop
| _ => true
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not treat refined unions as passthroughs.

unionIsSelfDecodeNoop ignores refiner and inputRefiner. Consequently, unionIsPassthrough can return the input unchanged for a schema that still requires custom validation, silently skipping those refiners. Require both fields to be absent before classifying a schema as a self-decode noop.

As per coding guidelines: “Never skip this call when user refiners must be preserved.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sury/src/Sury.res` around lines 3122 - 3142, Update
unionIsSelfDecodeNoop to require both schema.refiner and schema.inputRefiner to
be absent, alongside the existing passthrough conditions. This ensures
unionIsPassthrough never returns refined input unchanged and preserves required
user refiner calls.

Source: Coding guidelines

Comment on lines +3253 to +3274
// Pass 1: tier 1 — same-type match (exact const preferred), reserves it.
for si in 0 to sourceLen - 1 {
let s = sources->Array.getUnsafe(si)
if unionReservable(s) {
let sKey = unionToKey(s)
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
let t = targetCases->Array.getUnsafe(ti)
if unionToKey(t) === sKey && t.const === s.const {
chosen := ti
}
}
}
if chosen.contents === -1 {
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
if unionToKey(targetCases->Array.getUnsafe(ti)) === sKey {
chosen := ti
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not reserve mismatched literal targets.

The fallback tag match lets source literal "a" reserve target literal "b" when no exact constant matches. If a broad string target is also available, "a" is assigned to "b" and fails at runtime instead of using the broad target.

Restrict this fallback to non-literal targets; exact literal matches are already handled by the preceding pass.

Suggested fix
-            if unionToKey(targetCases->Array.getUnsafe(ti)) === sKey {
+            let target = targetCases->Array.getUnsafe(ti)
+            if unionToKey(target) === sKey && !target->isLiteral {
               chosen := ti
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pass 1: tier 1 — same-type match (exact const preferred), reserves it.
for si in 0 to sourceLen - 1 {
let s = sources->Array.getUnsafe(si)
if unionReservable(s) {
let sKey = unionToKey(s)
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
let t = targetCases->Array.getUnsafe(ti)
if unionToKey(t) === sKey && t.const === s.const {
chosen := ti
}
}
}
if chosen.contents === -1 {
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
if unionToKey(targetCases->Array.getUnsafe(ti)) === sKey {
chosen := ti
}
}
}
// Pass 1: tier 1 — same-type match (exact const preferred), reserves it.
for si in 0 to sourceLen - 1 {
let s = sources->Array.getUnsafe(si)
if unionReservable(s) {
let sKey = unionToKey(s)
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
let t = targetCases->Array.getUnsafe(ti)
if unionToKey(t) === sKey && t.const === s.const {
chosen := ti
}
}
}
if chosen.contents === -1 {
for ti in 0 to targetLen - 1 {
if chosen.contents === -1 && !(reserved->Array.getUnsafe(ti)) {
let target = targetCases->Array.getUnsafe(ti)
if unionToKey(target) === sKey && !target->isLiteral {
chosen := ti
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sury/src/Sury.res` around lines 3253 - 3274, The fallback matching
loop in the union reservation pass must not reserve a mismatched literal target.
In the `chosen.contents === -1` fallback within the `unionReservable` handling,
require the candidate target to be non-literal while retaining the `unionToKey`
match; leave exact constant matching in the preceding pass unchanged so broad
targets remain available for unmatched source literals.

Comment on lines +3282 to +3311
// Pass 2: tier 2 — nullish bridge to the opposite nullish target, reserves it.
for si in 0 to sourceLen - 1 {
switch assigned->Array.getUnsafe(si) {
| Some(_) => ()
| None =>
let sTag = (sources->Array.getUnsafe(si)).tag
let oppositeTag = if sTag === nullTag {
undefinedTag
} else if sTag === undefinedTag {
nullTag
} else {
sTag
}
if oppositeTag !== sTag {
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if (
chosen.contents === -1 &&
!(reserved->Array.getUnsafe(ti)) &&
(targetCases->Array.getUnsafe(ti)).tag === oppositeTag
) {
chosen := ti
}
}
if chosen.contents !== -1 {
claim(si, chosen.contents)
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Only reservable variants may claim nullish bridges.

This pass does not call unionReservable, so a transformed null/undefined variant with .to, parser, or serializer can reserve the opposite nullish target. That violates the reservation contract and can force the transformed case into an incompatible target.

Suggested fix
     | None =>
       let sTag = (sources->Array.getUnsafe(si)).tag
-      let oppositeTag = if sTag === nullTag {
+      if unionReservable(sources->Array.getUnsafe(si)) {
+        let oppositeTag = if sTag === nullTag {
           undefinedTag
-      } else if sTag === undefinedTag {
+        } else if sTag === undefinedTag {
           nullTag
-      } else {
+        } else {
           sTag
-      }
-      if oppositeTag !== sTag {
+        }
+        if oppositeTag !== sTag {
           ...
+        }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pass 2: tier 2 — nullish bridge to the opposite nullish target, reserves it.
for si in 0 to sourceLen - 1 {
switch assigned->Array.getUnsafe(si) {
| Some(_) => ()
| None =>
let sTag = (sources->Array.getUnsafe(si)).tag
let oppositeTag = if sTag === nullTag {
undefinedTag
} else if sTag === undefinedTag {
nullTag
} else {
sTag
}
if oppositeTag !== sTag {
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if (
chosen.contents === -1 &&
!(reserved->Array.getUnsafe(ti)) &&
(targetCases->Array.getUnsafe(ti)).tag === oppositeTag
) {
chosen := ti
}
}
if chosen.contents !== -1 {
claim(si, chosen.contents)
}
}
}
}
// Pass 2: tier 2 — nullish bridge to the opposite nullish target, reserves it.
for si in 0 to sourceLen - 1 {
switch assigned->Array.getUnsafe(si) {
| Some(_) => ()
| None =>
let sTag = (sources->Array.getUnsafe(si)).tag
if unionReservable(sources->Array.getUnsafe(si)) {
let oppositeTag = if sTag === nullTag {
undefinedTag
} else if sTag === undefinedTag {
nullTag
} else {
sTag
}
if oppositeTag !== sTag {
let chosen = ref(-1)
for ti in 0 to targetLen - 1 {
if (
chosen.contents === -1 &&
!(reserved->Array.getUnsafe(ti)) &&
(targetCases->Array.getUnsafe(ti)).tag === oppositeTag
) {
chosen := ti
}
}
if chosen.contents !== -1 {
claim(si, chosen.contents)
}
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sury/src/Sury.res` around lines 3282 - 3311, Update the tier-2
nullish bridge logic in the pass containing `claim` so a source variant may
claim the opposite nullish target only when `unionReservable` approves it. Apply
this check before selecting or reserving a target, while preserving the existing
null/undefined pairing and reservation behavior for eligible variants.

Comment on lines +3493 to +3524
and unionRefinerAttacher = (~selfSchema: internal): (internal => unit) => {
let unionRefiner = selfSchema.refiner
let unionInputRefiner = selfSchema.inputRefiner
// Call each source refiner at most once so its predicate is embedded
// in `input.global.embeded` once and every case references the same
// `e[N]`. `B.embed` is append-only, so a per-case call would duplicate.
let cachedRefinerChecks = ref(None)
let cachedInputRefinerChecks = ref(None)
let attach = (current, source, cache) =>
switch source {
| None => current
| Some(fn) =>
let getCached = (~input) =>
switch cache.contents {
| Some(checks) => checks
| None =>
let checks = fn(~input)
cache := Some(checks)
checks
}
switch current {
| None => Some(getCached)
| Some(existing) =>
Some(
(~input) => {
let arr = existing(~input)
let next = getCached(~input)
for i in 0 to next->Array.length - 1 {
arr->Array.push(next->Array.getUnsafe(i))->ignore
}
arr
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not cache complete refiner checks across cases.

fn(~input) receives a val, so the generated condition and failure builder may depend on that case’s schema, expected type, path, or variable allocation. Caching the entire check array from the first case reuses those val-specific checks for every later case. Cache only reusable embedded predicate data, or regenerate the checks per case.

As per coding guidelines: “Treat val as the compile-time view of a runtime value and preserve its core fields.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sury/src/Sury.res` around lines 3493 - 3524, Update
unionRefinerAttacher’s attach caching so it does not reuse complete refiner
check arrays across union cases. Regenerate fn(~input) for each case-specific
val, while caching only predicate data that is independent of the case schema,
expected type, path, and variable allocation; preserve the existing merge
behavior for current and newly generated checks.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants