1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
//! The tree-walker's *consume* side of the `sui-normalize` attrset-binding
//! plan.
//!
//! Mirrors [`crate::resolve_env`]'s three parts — a one-way env-flag latch, a
//! thread-local table keyed by `(source_id, text_offset)`, and
//! populate/lookup/clear hooks wired into `eval_with_file` — because the
//! keying hazard is identical: a plan recorded for a binder at offset `o` in
//! one parse tree must never be read for a different (imported) tree that
//! happens to have a binder at the same offset.
//!
//! # ★ The failure discipline here is the INVERSE of `resolve_env`'s
//!
//! `sui-resolve` fails SAFE to `Dynamic` because its fallback is
//! *equivalent* — `lookup_fast` probes the same map with the same symbol, so
//! falling back costs only speed.
//!
//! **This table's fallback path is the divergence itself.** Falling back means
//! "walk `set.entries()` yourself", which is precisely the code that produces
//! the silent wrong answers `sui-normalize` exists to remove. So a miss must
//! never be treated as "nothing to do" in a group that NEEDED a plan.
//!
//! The shape that makes that safe: `sui-normalize` records a group **only**
//! when it has a duplicate static key or a dotted path. A miss therefore means
//! "this group has neither", which is exactly when the existing path is
//! already correct. The absence is a *positive* statement, not a fallback —
//! and that is what bounds this change's blast radius to the groups that are
//! wrong today.
//!
//! # Default ON since 2026-08-18
//!
//! `SUI_NORMALIZE=0` opts OUT, restoring the pre-plan construction path. The
//! latch survives the flip on purpose — a divergence suspected to come from
//! this pass is then one command away from being confirmed or cleared, which
//! is worth more than the tidiness of deleting it.
use RefCell;
use OnceLock;
use Rc;
use ;
/// One-time read of `SUI_NORMALIZE`. Default ON; `SUI_NORMALIZE=0` opts out.
static ENABLED: = new;
/// Whether plan-driven attrset construction is enabled. **Default: yes.**
///
/// Flipped from opt-in to opt-out on 2026-08-18, on this evidence:
///
/// * every wrong-answer shape in the class matches nix, including the
/// acceptance case `{ a = rec { b = c+1; d = 2; }; a.c = d+3; }.a.b` -> 6,
/// which needs mutual recursion ACROSS the merge boundary;
/// * a fleet scan found ZERO false rejects — every rejection is a file
/// `nix-instantiate --parse` also refuses. **Corrected 2026-08-18: the
/// count was FOUR, not "the one".** Re-scanned at 4570 files:
/// `blackmatter-services/…/jitsi`, `…/keycloak`,
/// `kindling-profiles/…/macos-developer`, and
/// `blackmatter/…/enhanced/composed.nix`, each verified individually
/// against `nix-instantiate --parse`, which refuses all four and names the
/// same attribute path sui does. The zero-false-rejects claim is unchanged
/// and is the load-bearing half; the "one" was a coverage figure that
/// rotted UPWARD — understated, so it read as modest and nothing ever
/// flagged it. Re-run the scanner rather than trusting this number:
/// `cargo run --release -p sui-normalize --example scan -- <dir>`;
/// * `sui perf-seal` moved DOWN or held on all three attr-merge rows
/// (`dotted full-set leaf deep-merge` 6 -> 5), which is what confirms the
/// splice happens at PARSE time rather than adding eval work;
/// * the suites are green both ways.
///
/// The latch is KEPT, deliberately, in the `SUI_SCOPE_NARROW` spirit:
/// `SUI_NORMALIZE=0` restores the pre-plan construction path, so a divergence
/// suspected to come from this pass can be bisected in one command instead of
/// a revert. That is also why the old entry loops are not deleted yet.
///
/// ★ SINCE THE REJECTION TIER (2026-08-18) THE LATCH ALSO DISABLES REFUSAL,
/// and that makes the engines disagree ON PURPOSE. `plan_for_node` returns
/// `Ok(None)` when disabled, so with `SUI_NORMALIZE=0` the walker goes back to
/// silently ACCEPTING `{ a = 1; a = 2; }` while the bytecode VM — which has no
/// such latch and always plans — still refuses it. That is a bisect tool
/// behaving as intended, not a bug; but it means an engine comparison run with
/// `SUI_NORMALIZE=0` is not measuring what it looks like it is measuring.
/// Clear the variable before comparing engines.
thread_local!
/// The plan for one binder node, computed ON DEMAND and memoized.
///
/// ★ Replaces a parse-door walk that planned every binder in every parsed
/// file. Laziness means most of those are never evaluated, so that work was
/// mostly discarded — measured as a ~4% wall-clock tax on a real nixpkgs eval.
/// Planning at first evaluation is identical in result (a group's plan depends
/// only on its own entries) and pays only for groups that are reached.
///
/// The memo is keyed exactly as before, so a plan computed for a node at
/// offset `o` in one parse tree is never read for a different (imported) tree
/// with a binder at the same offset.
///
/// A `None` return is a POSITIVE statement — the group has no duplicate static
/// key and no dotted path, so the caller's existing path is already correct.
/// See the module docs on why that is not a fallback.
///
/// # Errors
///
/// [`NormalizeError`] for a group nix itself rejects — a duplicate attribute
/// (`{ a = 1; a = 2; }`) or a duplicate formal.
///
/// ★ This used to swallow that error with `.ok().flatten()`, which silently
/// turned a rejection into "no plan" and sent the group down the entry loop.
/// The result was accepting what nix refuses, and — worse — accepting it
/// DIFFERENTLY from the bytecode VM: measured 2026-08-18, `{ a = 1; a = 2; }`
/// is `{ a = 2; }` on the walker and `{ a = 1; }` on the VM, both at exit 0
/// where nix exits 1. Neither answer is right, so no choice of winner
/// reconciles the engines; only refusing does.
/// A memo entry.
///
/// `Memo(Ok(None))` records "this group needs no plan", which must be
/// remembered too — otherwise every evaluation of an ordinary attrset re-runs
/// `needs_plan`, which is the cost the memo exists to remove. A rejection is
/// memoized for the same reason: a group nix refuses is refused on every
/// evaluation, and re-deriving that is pure waste.
;
/// Drop every recorded plan. Wired into the same lifecycle point as
/// `resolve_env::clear`.