@structuralists/scaffolding 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/forms/plan.md CHANGED
@@ -28,7 +28,8 @@ TS wall can't strand finished work behind it.
28
28
  (state/useFormState/errorAt.ts) — the smallest surface that keeps stories
29
29
  readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
30
30
  `errorMessage`. The Debugger needed no change (`toInspectable`
31
- index-keys arrays).
31
+ index-keyed arrays at the time; it now passes them through, since
32
+ `JsonTable` renders arrays natively).
32
33
  4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
33
34
  item 6 so the wrappers land in their final home once. ✅ *done* — one
34
35
  `src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
@@ -45,6 +46,18 @@ TS wall can't strand finished work behind it.
45
46
  Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
46
47
  any runtime work; three outcomes (works / slow / intractable) each with a
47
48
  known response. Worst case, everything above still shipped.
49
+ - **Spike: ✅ done — verdict WORKS, decisively.** The full recursive
50
+ grammar (nested specs, `each`, arrays at every level, recursive
51
+ `Refine`) plus a stress tier (~110-leaf form, 12-level object ladder,
52
+ 4-deep `each` ladder) cost **1.31× instantiations, check time flat** —
53
+ nowhere near the ~10×/multi-second failure thresholds. No depth caps,
54
+ lazy-recursion or boxing tricks needed. Two binding grammar
55
+ adjustments came out of it, both folded into the sketches below:
56
+ value-model-first branch order in `RefineField`, and the `Refine`
57
+ identity gate for the default-`V` case.
58
+ - **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
59
+ "Phases" below.
60
+ - **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
48
61
 
49
62
  ## Goal
50
63
 
@@ -126,30 +139,55 @@ useFormState({
126
139
 
127
140
  ## Refinement through the grammar
128
141
 
129
- `Refine` becomes recursive, mirroring the grammar. Sketch:
142
+ `Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
143
+ type-spike adjustments):
130
144
 
131
145
  ```ts
132
- type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
133
146
  // MemberExcludes<C>: the per-member-sound union of a validator tuple's
134
147
  // excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
135
148
 
136
- type RefineField<F, C> =
137
- C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
138
- : C extends readonly unknown[] ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
139
- : C extends { readonly each: infer E } ? F extends FormValueList
140
- ? Array<RefineObject<F[number], E>> : F // per-element
141
- : C extends object ? F extends FormValuesObject
142
- ? RefineObject<F, C> : F // nested spec
143
- : F; // bare fn / no marker
149
+ type RefineField<F, C> = C extends Refinement<infer Excluded>
150
+ ? Exclude<F, Excluded> // single marked validator
151
+ : C extends readonly unknown[]
152
+ ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
153
+ : C extends object // structural: interrogate F (the value model) FIRST
154
+ ? F extends FormValueList
155
+ ? C extends { readonly each: infer E }
156
+ ? Array<RefineObject<F[number], E>> // per-element
157
+ : F
158
+ : F extends FormValuesObject
159
+ ? RefineObject<F, C> // nested spec
160
+ : F
161
+ : F; // bare fn / no marker
144
162
 
145
163
  type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
164
+
165
+ // The identity gate — NOT optional (spike adjustment 2): with constraints
166
+ // omitted, the default V = Validations<T> would distribute through the walk
167
+ // and hand back unions of structurally-identical mapped copies of every
168
+ // section — assignable to T but not identity-equal (mangled hover types,
169
+ // fails toEqualTypeOf). Validations<T> extends V exactly when V is the
170
+ // default (or empty/fully-widened — no markers survive anyway).
171
+ type Refine<T extends FormValuesObject, V extends Validations<T>> =
172
+ Validations<T> extends V ? T : RefineObject<T, V>;
146
173
  ```
147
174
 
148
- Branch-order constraints discovered in the baseline, which this sketch bakes
149
- in:
175
+ Branch-order constraints baked in (baseline + spike):
150
176
 
151
177
  - **Check `Refinement` before `object`** — validator functions are objects.
152
- - **A bare function** (no marker) falls to the final `F` arm — narrows nothing.
178
+ - **The structural arms branch on the value model (`F`) before the
179
+ constraint's shape** (spike adjustment 1): asking `C extends { each }`
180
+ first would strand an object field that legitimately owns a key named
181
+ `each` — its nested spec would match the `each` arm, fail the list test,
182
+ and fall back unrefined. Pinned by the `audit` disambiguation probe in
183
+ `useFormState.test-d.ts`.
184
+ - **A bare function** (no marker) falls through: on a scalar field to the
185
+ final `F` arm; on a structural field into `RefineObject<F, C>` with
186
+ `keyof C` empty — an identity map, structurally unchanged, as a
187
+ whole-value validator should be.
188
+ - Nested occurrences need no identity gate: a concrete literal `V` has only
189
+ the keys actually written, so uncovered fields take the `T[K]` arm
190
+ verbatim at every level.
153
191
  - The runtime cast in `submit()` stays the same single honest cast: valid ⇒
154
192
  every constrained node passed, at every depth, which is what the recursive
155
193
  `Refine` now encodes.
@@ -200,11 +238,44 @@ multi-second check time:
200
238
  0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
201
239
  post-item-5 — the `ValueAt` instantiations at each binding call site are
202
240
  real but cheap; check time flat)
241
+ - type spike (full recursive grammar, chunky + stress probes, throwaway
242
+ worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
243
+ check — 1.31× post-item-6; verdict works, no mitigation tricks required.
244
+ The real phases should land well under that: the spike carried a stress
245
+ tier (~110-leaf mega form with a large spec inlined twice, 12-level
246
+ object ladder, 4-deep `each` ladder) the phases don't.
247
+ - post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
248
+ ported chunky/boundary/negative probe suite, recursive walk): check
249
+ 0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
250
+ post-item-6; matches the spike's equivalent non-stress milestone at
251
+ 134,441 almost exactly)
252
+ - post-phase-3 (`each` runtime + the `ListValidation` story with its
253
+ numeric-path bindings; no new type machinery): check 0.84 s, 136,741
254
+ instantiations, 58,813 types (+1.8% over the pre-phase-3 HEAD at
255
+ 134,330/58,034 — mostly the story's new hook call site and deep
256
+ `Path`/`ValueAt` bindings)
203
257
 
204
258
  ## Runtime consequences (can't be dodged)
205
259
 
206
- - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
207
- `state/path/path.ts`; share the traversal or keep them deliberately parallel.
260
+ - **The validator walk becomes a tree walk.** *Landed with phase 2*
261
+ (`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
262
+ `PathStep[]` address; deliberately parallel to `read()`, not shared —
263
+ the walk descends a *constraints* tree, `read()` a *path*). Interpretation
264
+ of a structural spec is directed by the VALUE at the path, mirroring the
265
+ type level (see "Disambiguation").
266
+ - **Absent sections: a nested spec on an absent/null section is skipped.**
267
+ *Decided with the type spike.* The type level commits to this implicitly —
268
+ `F` distributes naked through the structural grammar arms, so a spec on
269
+ `UsAddress | undefined` refines only the present branch and the
270
+ nullability survives around the refined interior — and the runtime walk
271
+ mirrors it: nothing to walk when the section is absent (same for `each`
272
+ over a null list). A **required section** is expressed as a *leaf*
273
+ validator on the section field (`mailingAddress: notEmpty(…)`), which
274
+ stays legal on structural fields — with the recorded caveat that leaf and
275
+ structural forms are mutually exclusive per key (the "whole-value +
276
+ structural constraints on the same key" open decision below). Never
277
+ "help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
278
+ distribution and blows the recursion stack (TS2589).
208
279
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
209
280
  ✅ *Landed with working-order step 3* (on the flat baseline, single-key
210
281
  paths). Decided: errors become a plain list of structured entries,
@@ -244,13 +315,34 @@ tests, story updates where visible, probe ratchet.
244
315
  distributes over a naked constraint parameter, which made union-typed
245
316
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
246
317
  refinements, pinned in `state/validations/types.test-d.ts`).
247
- 2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
248
- ratchet matters most here. (The `{path, error}[]` error model already
249
- landed in working-order step 3 — nested errors just carry longer paths;
250
- no error plumbing changes in this phase.)
251
- 3. **List `each` specs.** Runtime walks every element; error paths carry the
252
- numeric step (`['drivers', 3, 'name']`). Refined element type flows
253
- through `Array<...>`.
318
+ 2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
319
+ by the type spike. *done* full recursive grammar
320
+ (`FieldConstraint`/`ListConstraint`/`Validations` in
321
+ state/validations/types.ts) and recursive `Refine` with both spike
322
+ adjustments (value-model-first `RefineField` branch order; the identity
323
+ gate); recursive `validateEntry` walk with absent-section skip; the
324
+ spike's chunky/boundary/soundness/negative probe suite ported into
325
+ `useFormState.test-d.ts` (the error model needed no change, as
326
+ predicted — nested errors just carry longer paths). **The `each` TYPE
327
+ arm landed here too** (the spike proved the grammar whole, and carving
328
+ it out of `RefineField` would have been artificial), but the runtime
329
+ walk for it was phase 3 — in the interim an `each` constraint on a
330
+ present list THREW from the walk (pinned in walk.test.ts) rather than
331
+ silently not validating; on a null list it skipped, which already
332
+ matched phase-3 semantics.
333
+ 3. **List `each` specs — runtime.** ✅ *done* — the walk visits every
334
+ element; error paths carry the numeric step (`['drivers', 3, 'name']`),
335
+ replacing the phase-2 throw. (The type level — refined element flowing
336
+ through `Array<...>` — already landed with phase 2.) Absent/null list ⇒
337
+ skip, same decided semantics as absent sections; elements fail
338
+ independently; walk-level semantics pinned React-free in walk.test.ts,
339
+ the typed boundary in deriveErrors.test.ts, element errors through
340
+ `errorMessage` at numeric paths in useFieldBinding.test.tsx, and the
341
+ visible flow in the `ListValidation` story. Two probes the spike said
342
+ to keep pinned were already in the phase-2 suite: the `audit`-style
343
+ "object field literally named `each`" disambiguation probe, and the
344
+ `each: <bare validator>` negative (rejected by TypeScript's weak-type
345
+ check — an obscure checker rule worth a canary).
254
346
  4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
255
347
  `perField` still the entry point for pre-built specs, docs
256
348
  (forms/CLAUDE.md) updated to the new grammar.
@@ -44,7 +44,8 @@ describe('FormDebugger', () => {
44
44
 
45
45
  expect(screen.getByText('isValid')).toBeTruthy();
46
46
  expect(screen.getByText('submitAttempted')).toBeTruthy();
47
- expect(screen.getByText("'nickname' cannot be empty")).toBeTruthy();
47
+ // String leaves render with surrounding quotes (Chrome-inspector style).
48
+ expect(screen.getByText('"\'nickname\' cannot be empty"')).toBeTruthy();
48
49
  });
49
50
 
50
51
  test('the open window live-updates as the form changes', () => {
@@ -57,8 +58,8 @@ describe('FormDebugger', () => {
57
58
 
58
59
  // The window survived the form re-render (stable component identity —
59
60
  // a remount would have reset it to closed) and shows the new state.
60
- expect(screen.getByText('will')).toBeTruthy();
61
- expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
61
+ expect(screen.getByText('"will"')).toBeTruthy();
62
+ expect(screen.queryByText('"\'nickname\' cannot be empty"')).toBeNull();
62
63
  });
63
64
 
64
65
  test('clicking the trigger again closes the window', () => {
@@ -74,3 +74,132 @@ describe('deriveFormErrors', () => {
74
74
  expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
75
75
  });
76
76
  });
77
+
78
+ // The recursive grammar: nested object specs and list `each` specs walk the
79
+ // value tree and address failures with real multi-step paths — numeric steps
80
+ // for list elements. Walk-level semantics (element iteration, sibling
81
+ // independence, absent-value skips) are pinned in walk.test.ts; these tests
82
+ // pin the typed boundary: a constraints literal checked against the form
83
+ // type produces `Path<T>`-addressed entries.
84
+
85
+ type ProfileForm = {
86
+ email: string | undefined;
87
+ homeAddress: {
88
+ city: string | undefined;
89
+ postalCode: string | undefined;
90
+ };
91
+ mailingAddress: { city: string | undefined } | undefined;
92
+ pets: Array<{ name: string | undefined }>;
93
+ pastPolicies: Array<{ insurer: string | undefined }> | null;
94
+ };
95
+
96
+ const emptyProfile: ProfileForm = {
97
+ email: undefined,
98
+ homeAddress: { city: undefined, postalCode: undefined },
99
+ mailingAddress: undefined,
100
+ pets: [],
101
+ pastPolicies: null,
102
+ };
103
+
104
+ describe('deriveFormErrors — nested object specs', () => {
105
+ test('a failing nested leaf contributes an entry addressed by its full path', () => {
106
+ const errors = deriveFormErrors(emptyProfile, {
107
+ homeAddress: { city: notEmpty('city') },
108
+ });
109
+ expect(errors).toEqual([
110
+ { path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
111
+ ]);
112
+ });
113
+
114
+ test('sibling fields fail independently — one entry per failing node', () => {
115
+ const errors = deriveFormErrors(emptyProfile, {
116
+ email: notEmpty('email'),
117
+ homeAddress: {
118
+ city: notEmpty('city'),
119
+ postalCode: notEmpty('postalCode'),
120
+ },
121
+ });
122
+ expect(errors).toEqual([
123
+ { path: ['email'], error: "'email' cannot be empty" },
124
+ { path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
125
+ {
126
+ path: ['homeAddress', 'postalCode'],
127
+ error: "'postalCode' cannot be empty",
128
+ },
129
+ ]);
130
+ });
131
+
132
+ test('passing nested leaves contribute nothing', () => {
133
+ const errors = deriveFormErrors(
134
+ {
135
+ ...emptyProfile,
136
+ homeAddress: { city: 'SF', postalCode: '94110' },
137
+ },
138
+ {
139
+ homeAddress: {
140
+ city: notEmpty('city'),
141
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
142
+ },
143
+ },
144
+ );
145
+ expect(errors).toEqual([]);
146
+ });
147
+
148
+ test('a nested spec on an absent section is skipped', () => {
149
+ // Decided with the phase-2 type spike: the honest runtime mirror of the
150
+ // type level (which refines only the present branch of a nullable
151
+ // section) is to validate nothing when the section is absent.
152
+ const errors = deriveFormErrors(emptyProfile, {
153
+ mailingAddress: { city: notEmpty('city') },
154
+ });
155
+ expect(errors).toEqual([]);
156
+ });
157
+
158
+ test('the same nested spec fires once the section is present', () => {
159
+ const errors = deriveFormErrors(
160
+ { ...emptyProfile, mailingAddress: { city: undefined } },
161
+ { mailingAddress: { city: notEmpty('city') } },
162
+ );
163
+ expect(errors).toEqual([
164
+ { path: ['mailingAddress', 'city'], error: "'city' cannot be empty" },
165
+ ]);
166
+ });
167
+
168
+ test('a required section is a leaf validator on the section field', () => {
169
+ const errors = deriveFormErrors(emptyProfile, {
170
+ mailingAddress: notEmpty('mailingAddress'),
171
+ });
172
+ expect(errors).toEqual([
173
+ { path: ['mailingAddress'], error: "'mailingAddress' cannot be empty" },
174
+ ]);
175
+ });
176
+ });
177
+
178
+ describe('deriveFormErrors — list `each` specs', () => {
179
+ test('each element failure is addressed with the numeric index step', () => {
180
+ const profile: ProfileForm = {
181
+ ...emptyProfile,
182
+ pets: [{ name: 'Rex' }, { name: undefined }],
183
+ };
184
+ const errors = deriveFormErrors(profile, {
185
+ pets: { each: { name: notEmpty('name') } },
186
+ });
187
+ expect(errors).toEqual([
188
+ { path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
189
+ ]);
190
+ });
191
+
192
+ test('an `each` spec on a null list is skipped; a leaf validator is how a list is required', () => {
193
+ const errors = deriveFormErrors(emptyProfile, {
194
+ pastPolicies: { each: { insurer: notEmpty('insurer') } },
195
+ });
196
+ expect(errors).toEqual([]);
197
+
198
+ const required = deriveFormErrors(emptyProfile, {
199
+ pastPolicies: notEmpty('pastPolicies'),
200
+ });
201
+ expect(required).toEqual([
202
+ { path: ['pastPolicies'], error: "'pastPolicies' cannot be empty" },
203
+ ]);
204
+ });
205
+ });
@@ -1,7 +1,7 @@
1
1
  import type { Path } from '../path/types';
2
2
  import type { Validations } from '../validations/types';
3
3
  import { validateEntry } from '../validations/walk';
4
- import type { FlatConstraintEntry } from '../validations/walk';
4
+ import type { ConstraintEntry } from '../validations/walk';
5
5
  import type { FormError, FormValuesObject } from './types';
6
6
 
7
7
  // The pure half of the hook's error model: current values + constraints in,
@@ -19,16 +19,16 @@ export const deriveFormErrors = <T extends FormValuesObject>(
19
19
  for (const key of Object.keys(constraints) as (keyof T & string)[]) {
20
20
  // No cast: if the grammar grows a constraint form the walk doesn't
21
21
  // understand, this assignment is the compile error that says so.
22
- const entry: FlatConstraintEntry | undefined = constraints[key];
22
+ const entry: ConstraintEntry | undefined = constraints[key];
23
23
  if (entry == null) continue;
24
- const failure = validateEntry(entry, values[key], [key]);
25
- if (failure == null) continue;
26
- // The walk returns the address it was handed, and `[key]` — a key of
27
- // a constraints object type-checked against T is a valid single-key
28
- // Path<T>. TS can't compute Path<T> for an unresolved generic T, so
29
- // the correlation needs the same honest widening as the keys cast
30
- // above.
31
- errors.push({ path: failure.path as Path<T>, error: failure.error });
24
+ for (const failure of validateEntry(entry, values[key], [key])) {
25
+ // The walk extends the address it was handed only along keys of specs
26
+ // type-checked against T's subtree, so every returned path is a valid
27
+ // Path<T>. TS can't compute Path<T> for an unresolved generic T to
28
+ // see the correlation, so it needs the same honest widening as the
29
+ // keys cast above.
30
+ errors.push({ path: failure.path as Path<T>, error: failure.error });
31
+ }
32
32
  }
33
33
 
34
34
  return errors;
@@ -2,9 +2,9 @@ import { describe, test, expect } from 'bun:test';
2
2
  import { errorAt } from './errorAt';
3
3
  import type { FormErrors } from './types';
4
4
 
5
- // The flat grammar only produces single-key paths today, but errorAt's
6
- // equality must already be exact over multi-step and numeric-step paths —
7
- // the recursive grammar (plan phases 2–3) reuses it unchanged.
5
+ // errorAt's equality must be exact over multi-step and numeric-step paths
6
+ // the recursive grammar produces real multi-step addresses (nested specs,
7
+ // and numeric index steps from runtime `each` over list elements).
8
8
  type Form = {
9
9
  email: string | undefined;
10
10
  address: { city: string | undefined };
@@ -11,15 +11,15 @@ describe('toInspectable', () => {
11
11
  expect(toInspectable(true)).toBe(true);
12
12
  });
13
13
 
14
- test('converts arrays to index-keyed objects', () => {
15
- expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
14
+ test('keeps arrays as arrays (JsonTable renders them natively)', () => {
15
+ expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
16
16
  });
17
17
 
18
- test('converts arrays of objects recursively', () => {
19
- expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
20
- 0: { name: 'ada' },
21
- 1: { name: 'bo' },
22
- });
18
+ test('walks array elements recursively (Sets may hide inside)', () => {
19
+ expect(toInspectable([{ roles: new Set(['admin']) }, { name: 'bo' }])).toEqual([
20
+ { roles: 'Set(1) { "admin" }' },
21
+ { name: 'bo' },
22
+ ]);
23
23
  });
24
24
 
25
25
  test('renders Sets as a descriptive string leaf', () => {
@@ -35,8 +35,8 @@ describe('toInspectable', () => {
35
35
  };
36
36
  expect(toInspectable(form)).toEqual({
37
37
  email: 'a@b.co',
38
- address: { city: undefined, tags: { 0: 'home' } },
39
- drivers: { 0: { name: 'ada', incidents: {} } },
38
+ address: { city: undefined, tags: ['home'] },
39
+ drivers: [{ name: 'ada', incidents: [] }],
40
40
  });
41
41
  });
42
42
  });
@@ -1,10 +1,10 @@
1
1
  // Converts a form-state snapshot into a shape JsonTable renders without
2
- // throwing. JsonTable dispatches on "plain object vs leaf": arrays and Sets
3
- // fall to the leaf renderer, and an array of objects (FormValueList) would
4
- // throw as a React child. A debugger must render *any* legal form state, so:
2
+ // throwing. JsonTable recurses into plain objects and arrays; everything
3
+ // else falls to the leaf renderer, and a Set there would throw as a React
4
+ // child. A debugger must render *any* legal form state, so:
5
5
  //
6
- // - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
7
6
  // - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
7
+ // - arrays → walked recursively (a Set may hide inside), kept as arrays
8
8
  // - objects → walked recursively
9
9
  // - leaves → passed through untouched
10
10
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -20,9 +20,7 @@ export const toInspectable = (value: unknown): unknown => {
20
20
  }
21
21
 
22
22
  if (Array.isArray(value)) {
23
- return Object.fromEntries(
24
- value.map((element, index) => [index, toInspectable(element)]),
25
- );
23
+ return value.map(toInspectable);
26
24
  }
27
25
 
28
26
  if (isPlainObject(value)) {
@@ -78,8 +78,8 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
78
78
  : unknown;
79
79
 
80
80
  // Structured error model: one entry per failing constrained node, addressed
81
- // by a typed path (single-key paths on the flat grammar; deeper addresses
82
- // arrive with the recursive grammar). Deliberately a plain list — at form
81
+ // by a typed path as deep as the node (root leaves get single-key paths,
82
+ // nested-spec leaves get the full address). Deliberately a plain list — at form
83
83
  // scale a linear scan is fine, and serialized string keys ('drivers.0.name')
84
84
  // are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
85
85
  // never by hand-assembled keys.
@@ -138,6 +138,40 @@ describe('getFormFieldPropsAt — error-display policy', () => {
138
138
  ).toBeUndefined();
139
139
  });
140
140
 
141
+ test('element errors from an `each` spec surface at numeric paths, per element', () => {
142
+ const { result } = renderHook(() =>
143
+ useFormState({
144
+ initialValues,
145
+ constraints: { pets: { each: { name: notEmpty('name') } } },
146
+ }),
147
+ );
148
+
149
+ act(() => {
150
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange(undefined);
151
+ });
152
+
153
+ // The raw list addresses the failing element by its index step …
154
+ expect(result.current.errors).toEqual([
155
+ { path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
156
+ ]);
157
+ // … and errorMessage applies the same display policy at that path.
158
+ expect(
159
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
160
+ ).toBeUndefined();
161
+
162
+ act(() => {
163
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).onBlur();
164
+ });
165
+
166
+ expect(
167
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
168
+ ).toBe("'name' cannot be empty");
169
+ // The sibling element passed — no error at its own numeric path.
170
+ expect(
171
+ result.current.getFormFieldPropsAt(['pets', 0, 'name']).errorMessage,
172
+ ).toBeUndefined();
173
+ });
174
+
141
175
  test('repeat blurs on the same path keep the touched list stable', () => {
142
176
  // At the useFieldBinding boundary, where the touched list is returned.
143
177
  const { result } = renderHook(() =>