sui-normalize 0.1.215

Parse-time attrset-binding normalizer for sui — reproduces CppNix's ExprAttrs::addAttr, which merges duplicate attribute paths by SPLICING into the first-declared node (whose rec flag governs) and rejects everything else at parse time
Documentation

Parse-time attrset-binding normalizer — CppNix's ExprAttrs::addAttr.

The defect this exists to remove

sui decides duplicate-key merge-vs-overwrite at EVAL time, by forcing the colliding value to WHNF and asking "is it an attrset?". Real nix decides it at PARSE time, from SYNTAX. Because sui asks a different question at a different time, all three engines give SILENT WRONG ANSWERS on legal nix. Measured against nix 2.31.5 on 2026-08-18:

let a = {b=1;}; a = {c=2;}; in a       nix {b=1;c=2;}    sui {c=2;}
rec { o = {e=1;}; o.x = 2; }           nix {o={e=1;x=2;};} sui {o={x=2;};}
let b=1; in { a={x=2;}; a=rec{b=99;c=b;}; }
                                       nix c=1           sui c=99

Every row exits 0. No error anywhere.

The rule, measured

Merge iff both sides are syntactic attrset literals ({…} / rec {…}, including the implicit ones a dotted path creates), recursively. Reject otherwise. Decided from SYNTAX, never from the runtime value — so let x = {b=1;}; in { s = x; s = {c=2;}; } is a parse error even though the value of x is an attrset.

★ It is a destructive SPLICE, not predicate-plus-union

nix splices the second side's bindings INTO THE FIRST-DECLARED NODE. The first node's rec governs and a later rec is DISCARDED; the second side's bindings are RE-SCOPED into the first node's scope:

{ a = rec {b=1;}; a = {c = b+1;}; }.a.c   -> 2    non-rec side binds to the FIRST's rec scope
{ a = {b=1;}; a = rec {c=2; d=c;}; }      -> parse error: undefined variable 'c'
                                                 a standalone-valid rec block's INTERNAL
                                                 reference is destroyed by the merge
{ a = rec {b=1; c=b+1;}; a.d = 3; }       -> `{ a = rec { b=1; c=(b+1); d=3; }; }`
                                                 the DOTTED member lands INSIDE the rec

No value-level merge can express re-scoping. That is why this is a structural pass and not a fix inside any engine's collection loop.

Why a side-table rather than a rewritten tree

rnix's CST is lossless and IMMUTABLE — the tree cannot be spliced. So this pass emits a [NormalizeTable]: a plan per binding-group node, which each engine consumes INSTEAD OF its own for entry in set.entries() loop. The shape is deliberately modelled on sui-resolve's ResolveTable, including its most useful property:

An absent entry means "nothing to normalize" — no collision, no dotted path — so every engine's existing fast path is untouched for the overwhelming majority of attrsets, and the table stays small. That is the parity-by-construction argument: this pass can only change the groups it records, and it records only the groups that today are wrong.

Placement

This crate must be reachable by all three engines, so it sits at sui-resolve's level and depends on sui-intern + rnix + rowan only. It must NOT depend on sui-eval: sui-bytecode carries sui-eval as a path-only dev-dep to break a publish cycle, and a real edge here would close it. Hence the error type is self-contained — no EvalError — and each engine maps [NormalizeError] into its own error on the way out.

Status

STAGE 0: UNWIRED. Nothing consumes this yet, by design — the rule is built and proven against the oracle before it can change any engine's behaviour. Wiring is stages 1–4.