@structuralists/scaffolding 0.10.2 → 0.12.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.
Files changed (35) hide show
  1. package/eslint.config.mjs +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
  4. package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
  5. package/src/components/Json/JsonTable/index.tsx +13 -6
  6. package/src/components/Json/JsonTable/styles.module.css +20 -0
  7. package/src/components/Json/JsonTable/types.ts +3 -5
  8. package/src/forms/CLAUDE.md +195 -41
  9. package/src/forms/elements/Input/index.tsx +2 -0
  10. package/src/forms/elements/Input/types.ts +2 -1
  11. package/src/forms/plan.md +146 -29
  12. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  13. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  14. package/src/forms/state/path/path.test.ts +71 -1
  15. package/src/forms/state/path/path.ts +50 -0
  16. package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
  17. package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
  18. package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
  19. package/src/forms/state/useFormState/deriveErrors.ts +10 -10
  20. package/src/forms/state/useFormState/errorAt.test.ts +3 -3
  21. package/src/forms/state/useFormState/errorAt.ts +8 -12
  22. package/src/forms/state/useFormState/inspectable.test.ts +9 -9
  23. package/src/forms/state/useFormState/inspectable.ts +5 -7
  24. package/src/forms/state/useFormState/types.ts +35 -4
  25. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  26. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  27. package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
  28. package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
  29. package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
  30. package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
  31. package/src/forms/state/useFormState/useFormState.ts +12 -3
  32. package/src/forms/state/validations/types.ts +77 -17
  33. package/src/forms/state/validations/walk.test.ts +159 -19
  34. package/src/forms/state/validations/walk.ts +86 -25
  35. package/tokens.css +55 -0
package/src/forms/plan.md CHANGED
@@ -28,17 +28,35 @@ 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>/`,
35
36
  the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
36
37
  5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
37
- error model and the split; `Path`/`ValueAt` already validated. *current*
38
- 6. **Type spike, then items 2/3/4** the recursion risk zone. Throwaway
39
- `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
38
+ error model and the split; `Path`/`ValueAt` already validated. *done*
39
+ `getFormFieldPropsAt` on the hook result (value / typed onChange /
40
+ policy-aware errorMessage / onBlur), `write()` as the immutable mirror of
41
+ `read()` in `state/path/path.ts`, touched tracking as real state, and the
42
+ wrapper-style element shorthands prototyped (`state/bindings/`:
43
+ `TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
44
+ baseline note under the recursion budget).
45
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. ← *current*
46
+ Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
40
47
  any runtime work; three outcomes (works / slow / intractable) each with a
41
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.
42
60
 
43
61
  ## Goal
44
62
 
@@ -120,30 +138,55 @@ useFormState({
120
138
 
121
139
  ## Refinement through the grammar
122
140
 
123
- `Refine` becomes recursive, mirroring the grammar. Sketch:
141
+ `Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
142
+ type-spike adjustments):
124
143
 
125
144
  ```ts
126
- type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
127
145
  // MemberExcludes<C>: the per-member-sound union of a validator tuple's
128
146
  // excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
129
147
 
130
- type RefineField<F, C> =
131
- C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
132
- : C extends readonly unknown[] ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
133
- : C extends { readonly each: infer E } ? F extends FormValueList
134
- ? Array<RefineObject<F[number], E>> : F // per-element
135
- : C extends object ? F extends FormValuesObject
136
- ? RefineObject<F, C> : F // nested spec
137
- : F; // bare fn / no marker
148
+ type RefineField<F, C> = C extends Refinement<infer Excluded>
149
+ ? Exclude<F, Excluded> // single marked validator
150
+ : C extends readonly unknown[]
151
+ ? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
152
+ : C extends object // structural: interrogate F (the value model) FIRST
153
+ ? F extends FormValueList
154
+ ? C extends { readonly each: infer E }
155
+ ? Array<RefineObject<F[number], E>> // per-element
156
+ : F
157
+ : F extends FormValuesObject
158
+ ? RefineObject<F, C> // nested spec
159
+ : F
160
+ : F; // bare fn / no marker
138
161
 
139
162
  type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
163
+
164
+ // The identity gate — NOT optional (spike adjustment 2): with constraints
165
+ // omitted, the default V = Validations<T> would distribute through the walk
166
+ // and hand back unions of structurally-identical mapped copies of every
167
+ // section — assignable to T but not identity-equal (mangled hover types,
168
+ // fails toEqualTypeOf). Validations<T> extends V exactly when V is the
169
+ // default (or empty/fully-widened — no markers survive anyway).
170
+ type Refine<T extends FormValuesObject, V extends Validations<T>> =
171
+ Validations<T> extends V ? T : RefineObject<T, V>;
140
172
  ```
141
173
 
142
- Branch-order constraints discovered in the baseline, which this sketch bakes
143
- in:
174
+ Branch-order constraints baked in (baseline + spike):
144
175
 
145
176
  - **Check `Refinement` before `object`** — validator functions are objects.
146
- - **A bare function** (no marker) falls to the final `F` arm — narrows nothing.
177
+ - **The structural arms branch on the value model (`F`) before the
178
+ constraint's shape** (spike adjustment 1): asking `C extends { each }`
179
+ first would strand an object field that legitimately owns a key named
180
+ `each` — its nested spec would match the `each` arm, fail the list test,
181
+ and fall back unrefined. Pinned by the `audit` disambiguation probe in
182
+ `useFormState.test-d.ts`.
183
+ - **A bare function** (no marker) falls through: on a scalar field to the
184
+ final `F` arm; on a structural field into `RefineObject<F, C>` with
185
+ `keyof C` empty — an identity map, structurally unchanged, as a
186
+ whole-value validator should be.
187
+ - Nested occurrences need no identity gate: a concrete literal `V` has only
188
+ the keys actually written, so uncovered fields take the `T[K]` arm
189
+ verbatim at every level.
147
190
  - The runtime cast in `submit()` stays the same single honest cast: valid ⇒
148
191
  every constrained node passed, at every depth, which is what the recursive
149
192
  `Refine` now encodes.
@@ -187,11 +230,46 @@ multi-second check time:
187
230
  - post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
188
231
  plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
189
232
  (+4.4% instantiations over pre-step-3 HEAD)
233
+ - post-item-5 split (0.10.1): check 0.81 s, 112,981 instantiations,
234
+ 52,504 types
235
+ - post-item-6 (field binding: `getFormFieldPropsAt`, `FieldBinding`
236
+ wrappers, plus deep-path probes at `InsuranceQuoteForm` scale): check
237
+ 0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
238
+ post-item-5 — the `ValueAt` instantiations at each binding call site are
239
+ real but cheap; check time flat)
240
+ - type spike (full recursive grammar, chunky + stress probes, throwaway
241
+ worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
242
+ check — 1.31× post-item-6; verdict works, no mitigation tricks required.
243
+ The real phases should land well under that: the spike carried a stress
244
+ tier (~110-leaf mega form with a large spec inlined twice, 12-level
245
+ object ladder, 4-deep `each` ladder) the phases don't.
246
+ - post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
247
+ ported chunky/boundary/negative probe suite, recursive walk): check
248
+ 0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
249
+ post-item-6; matches the spike's equivalent non-stress milestone at
250
+ 134,441 almost exactly)
190
251
 
191
252
  ## Runtime consequences (can't be dodged)
192
253
 
193
- - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
194
- `state/path/path.ts`; share the traversal or keep them deliberately parallel.
254
+ - **The validator walk becomes a tree walk.** *Landed with phase 2*
255
+ (`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
256
+ `PathStep[]` address; deliberately parallel to `read()`, not shared —
257
+ the walk descends a *constraints* tree, `read()` a *path*). Interpretation
258
+ of a structural spec is directed by the VALUE at the path, mirroring the
259
+ type level (see "Disambiguation").
260
+ - **Absent sections: a nested spec on an absent/null section is skipped.**
261
+ *Decided with the type spike.* The type level commits to this implicitly —
262
+ `F` distributes naked through the structural grammar arms, so a spec on
263
+ `UsAddress | undefined` refines only the present branch and the
264
+ nullability survives around the refined interior — and the runtime walk
265
+ mirrors it: nothing to walk when the section is absent (same for `each`
266
+ over a null list). A **required section** is expressed as a *leaf*
267
+ validator on the section field (`mailingAddress: notEmpty(…)`), which
268
+ stays legal on structural fields — with the recorded caveat that leaf and
269
+ structural forms are mutually exclusive per key (the "whole-value +
270
+ structural constraints on the same key" open decision below). Never
271
+ "help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
272
+ distribution and blows the recursion stack (TS2589).
195
273
  - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
196
274
  ✅ *Landed with working-order step 3* (on the flat baseline, single-key
197
275
  paths). Decided: errors become a plain list of structured entries,
@@ -231,13 +309,29 @@ tests, story updates where visible, probe ratchet.
231
309
  distributes over a naked constraint parameter, which made union-typed
232
310
  *single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
233
311
  refinements, pinned in `state/validations/types.test-d.ts`).
234
- 2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
235
- ratchet matters most here. (The `{path, error}[]` error model already
236
- landed in working-order step 3 — nested errors just carry longer paths;
237
- no error plumbing changes in this phase.)
238
- 3. **List `each` specs.** Runtime walks every element; error paths carry the
239
- numeric step (`['drivers', 3, 'name']`). Refined element type flows
240
- through `Array<...>`.
312
+ 2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
313
+ by the type spike. *done* full recursive grammar
314
+ (`FieldConstraint`/`ListConstraint`/`Validations` in
315
+ state/validations/types.ts) and recursive `Refine` with both spike
316
+ adjustments (value-model-first `RefineField` branch order; the identity
317
+ gate); recursive `validateEntry` walk with absent-section skip; the
318
+ spike's chunky/boundary/soundness/negative probe suite ported into
319
+ `useFormState.test-d.ts` (the error model needed no change, as
320
+ predicted — nested errors just carry longer paths). **The `each` TYPE
321
+ arm landed here too** (the spike proved the grammar whole, and carving
322
+ it out of `RefineField` would have been artificial), but the runtime
323
+ walk for it is phase 3 — until then an `each` constraint on a present
324
+ list THROWS from the walk (pinned in walk.test.ts) rather than silently
325
+ not validating; on a null list it skips, which already matches phase-3
326
+ semantics.
327
+ 3. **List `each` specs — runtime.** The walk visits every element; error
328
+ paths carry the numeric step (`['drivers', 3, 'name']`), replacing the
329
+ phase-2 throw. (The type level — refined element flowing through
330
+ `Array<...>` — already landed with phase 2.) Two probes the spike says
331
+ to keep pinned are already in the phase-2 suite: the `audit`-style
332
+ "object field literally named `each`" disambiguation probe, and the
333
+ `each: <bare validator>` negative (rejected by TypeScript's weak-type
334
+ check — an obscure checker rule worth a canary).
241
335
  4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
242
336
  `perField` still the entry point for pre-built specs, docs
243
337
  (forms/CLAUDE.md) updated to the new grammar.
@@ -275,7 +369,29 @@ Blast radius that was handled when this landed:
275
369
  Debugger is a designated bridge layer). Explicitly allowed for in the new
276
370
  boundaries rules.
277
371
 
278
- ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
372
+ ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands ✅ done
373
+
374
+ Landed as specced below; deltas and decisions:
375
+
376
+ - `write()` in `state/path/path.ts` is the immutable-update mirror of
377
+ `read()` — clones only the spine, dead-step semantics mirror read()
378
+ returning undefined (writing through an absent section is an
379
+ identity-preserving no-op; materialize the section first).
380
+ - Touched is real state (`useFieldBinding`), a `Path<T>[]` compared with
381
+ `pathsEqual` — the same structural representation as `FormErrors`. It
382
+ also shows up in the Debugger snapshot.
383
+ - The error-display policy (touched-or-submitAttempted) lives inside
384
+ `errorMessage`, nowhere else — see "Field binding" in forms/CLAUDE.md.
385
+ - **Element-shorthand style: the wrapper style is the prototype**
386
+ (`TextInputForForm`, `SingleSelectForForm` in `src/forms/state/bindings/`,
387
+ the second sanctioned state→elements bridge after the Debugger). The
388
+ wrappers declare what they display/emit via `FieldBinding<Display, Emit>`
389
+ and plain structural assignability rejects wrong-shaped bindings — no
390
+ generics at the use site. Union-typed props on the elements themselves
391
+ remain the fallback if the parallel component set gets heavy; judge after
392
+ the wrapper set grows past these two.
393
+
394
+ Original spec follows.
279
395
 
280
396
  Today every field is wired by hand in JSX: read `values.x`, spread-update via
281
397
  `onValueChanges`, look up the error, gate it on `submitAttempted`. That's
@@ -427,8 +543,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
427
543
  the guaranteed path. **Research topic for when we get here** — record
428
544
  findings in the learning map before committing to either.
429
545
 
430
- Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
431
- path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
546
+ Sequencing: visited tracking *landed with item 6* (its `onBlur` is the
547
+ write path; `touched: Path<T>[]` in `useFieldBinding`, structural equality
548
+ via `pathsEqual`). Keys + array-sync matter from phase 3 (list `each` specs) onward —
432
549
  error paths and per-element meta both need element identity once lists are
433
550
  editable.
434
551
 
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { SingleSelect } from '../../elements/Select';
3
+ import type { SelectOption, SelectSize } from '../../elements/Select';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type SingleSelectForFormProps<T extends string> = {
7
+ // Displays T|null|undefined, emits T — a FormFieldProps<V> fits when the
8
+ // path's value type sits between them, so the options' literal union and
9
+ // the field's type must line up or the binding fails to compile.
10
+ formFieldProps: FieldBinding<T | null | undefined, T>;
11
+ options: SelectOption<T>[];
12
+ label: string;
13
+ hint?: string;
14
+ placeholder?: string;
15
+ size?: SelectSize;
16
+ isDisabled?: boolean;
17
+ };
18
+
19
+ // The form-aware flavor of the single select. A select has no meaningful
20
+ // blur moment — committing an option IS the completed interaction — so the
21
+ // commit marks the field touched alongside the write.
22
+ export const SingleSelectForForm = <T extends string>(
23
+ props: SingleSelectForFormProps<T>,
24
+ ) => {
25
+ const { formFieldProps, options, label, hint, placeholder, size, isDisabled } =
26
+ props;
27
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
28
+
29
+ return (
30
+ <Field label={label} hint={hint} error={errorMessage}>
31
+ <SingleSelect
32
+ options={options}
33
+ value={value ?? null}
34
+ onChange={(next) => {
35
+ onChange(next);
36
+ onBlur();
37
+ }}
38
+ size={size}
39
+ isDisabled={isDisabled}
40
+ placeholder={placeholder}
41
+ ariaLabel={label}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { Input } from '../../elements/Input';
3
+ import type { InputProps } from '../../elements/Input';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type TextInputForFormProps = {
7
+ // The bundle from `getFormFieldPropsAt(path)`. The binding shape declares
8
+ // what this element can do — display string-ish, emit string — so a
9
+ // FormFieldProps<V> only fits when V is a text-shaped field
10
+ // (string / string|null / string|undefined / both). A number- or
11
+ // boolean-typed path is a compile error right here.
12
+ formFieldProps: FieldBinding<string | null | undefined, string>;
13
+ label: string;
14
+ hint?: string;
15
+ placeholder?: string;
16
+ id?: string;
17
+ type?: InputProps['type'];
18
+ size?: InputProps['size'];
19
+ disabled?: boolean;
20
+ };
21
+
22
+ // The form-aware flavor of the text input: one prop wires value, change,
23
+ // error display, and touched tracking. Composes Field (label/hint/error
24
+ // presentation) around Input; the error-display policy already happened
25
+ // inside `errorMessage` — this component just renders what it's given.
26
+ export const TextInputForForm = (props: TextInputForFormProps) => {
27
+ const { formFieldProps, label, hint, placeholder, id, type, size, disabled } =
28
+ props;
29
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
30
+
31
+ return (
32
+ <Field label={label} hint={hint} error={errorMessage} htmlFor={id}>
33
+ <Input
34
+ id={id}
35
+ type={type}
36
+ size={size}
37
+ value={value ?? ''}
38
+ onChange={(e) => onChange(e.target.value)}
39
+ onBlur={onBlur}
40
+ placeholder={placeholder}
41
+ disabled={disabled}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { path, read } from './path';
2
+ import { path, pathsEqual, read, write } from './path';
3
3
 
4
4
  // These tests pin read()'s runtime semantics to exactly what the type level
5
5
  // (ValueAt) promises — see the union-semantics comments in ./types.ts and
@@ -95,6 +95,65 @@ describe('read step failures', () => {
95
95
  ).toBeUndefined();
96
96
  });
97
97
 
98
+ test('write replaces the value at a deep path without mutating the original', () => {
99
+ const next = write(filled, ['address', 'city'], 'Paris') as Form;
100
+
101
+ expect(next.address?.city).toBe('Paris');
102
+ expect(filled.address?.city).toBe('London');
103
+ });
104
+
105
+ test('write clones only the spine — untouched siblings keep their identity', () => {
106
+ const next = write(filled, ['a', 'b', 'c'], 'deeper') as Form;
107
+
108
+ // The spine to the written leaf is new at every level …
109
+ expect(next).not.toBe(filled);
110
+ expect(next.a).not.toBe(filled.a);
111
+ expect(next.a?.b).not.toBe(filled.a?.b);
112
+ // … while every branch off the spine is the same object.
113
+ expect(next.address).toBe(filled.address);
114
+ expect(next.entries).toBe(filled.entries);
115
+ });
116
+
117
+ test('write into a list element clones the array and that element only', () => {
118
+ const twoEntries: Form = {
119
+ ...filled,
120
+ entries: [
121
+ { title: 'first', qty: 1 },
122
+ { title: 'second', qty: 2 },
123
+ ],
124
+ };
125
+ const next = write(twoEntries, ['entries', 1, 'qty'], 5) as Form;
126
+
127
+ expect(next.entries?.[1]).toEqual({ title: 'second', qty: 5 });
128
+ expect(next.entries).not.toBe(twoEntries.entries);
129
+ expect(next.entries?.[0]).toBe(twoEntries.entries?.[0]);
130
+ expect(twoEntries.entries?.[1].qty).toBe(2);
131
+ });
132
+
133
+ test('write with an empty path replaces the root', () => {
134
+ expect(write(filled, [], { name: 'Eve' })).toEqual({ name: 'Eve' });
135
+ });
136
+
137
+ test('write through a dead ancestor is an identity-preserving no-op', () => {
138
+ // Mirrors read() returning undefined for a dead step: you cannot edit a
139
+ // field of an absent section — materialize the section first.
140
+ expect(write(empty, ['address', 'city'], 'Paris')).toBe(empty);
141
+ expect(write(empty, ['entries', 0, 'title'], 'x')).toBe(empty);
142
+ });
143
+
144
+ test('write on a shape mismatch or out-of-bounds index is a no-op', () => {
145
+ // Numeric step on a non-array, key step on a scalar, index past the end
146
+ // (appending is list manipulation, not a path write).
147
+ expect(write(filled, ['name', 'length'], 3)).toBe(filled);
148
+ expect(write(filled, ['entries', 5, 'title'], 'x')).toBe(filled);
149
+ const list = [1, 2];
150
+ expect(write(list, ['key'], 'x')).toBe(list);
151
+ });
152
+
153
+ test('writing the value already present is an identity-preserving no-op', () => {
154
+ expect(write(filled, ['address', 'city'], 'London')).toBe(filled);
155
+ });
156
+
98
157
  test('rejecting narrow predicate returns undefined; accepting one passes through', () => {
99
158
  const isString = (val: unknown): val is string => typeof val === 'string';
100
159
  const narrowed = path<Form>()
@@ -110,3 +169,14 @@ describe('read step failures', () => {
110
169
  expect(read(filled, rejecting)).toBeUndefined();
111
170
  });
112
171
  });
172
+
173
+ describe('pathsEqual', () => {
174
+ test('structural equality over steps — same steps equal, no prefix matching', () => {
175
+ expect(pathsEqual(['a', 0, 'b'], ['a', 0, 'b'])).toBe(true);
176
+ expect(pathsEqual([], [])).toBe(true);
177
+ expect(pathsEqual(['a'], ['a', 'b'])).toBe(false);
178
+ expect(pathsEqual(['a', 0], ['a', 1])).toBe(false);
179
+ // A numeric step never equals its string spelling.
180
+ expect(pathsEqual(['a', 0], ['a', '0'])).toBe(false);
181
+ });
182
+ });
@@ -22,6 +22,15 @@ const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
22
22
 
23
23
  export const path = <T>(): Cursor<T> => makeCursor<T>([]);
24
24
 
25
+ // Structural path equality: same steps, same order, no prefix matching.
26
+ // The one sanctioned way to compare paths — `errorAt` and touched tracking
27
+ // both build on it, so "do these address the same field?" has exactly one
28
+ // definition.
29
+ export const pathsEqual = (
30
+ a: readonly PathStep[],
31
+ b: readonly PathStep[],
32
+ ): boolean => a.length === b.length && a.every((step, index) => step === b[index]);
33
+
25
34
  // Walk steps against a value. Returns undefined if any step fails — a dead
26
35
  // value (null or undefined) encountered mid-path, missing key, out-of-bounds
27
36
  // index, or a narrow predicate that rejects the current value. Always
@@ -51,3 +60,44 @@ export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
51
60
 
52
61
  return cursor;
53
62
  };
63
+
64
+ // The immutable-update mirror of read(): returns a new root with `value`
65
+ // placed at `steps`, cloning only the containers along the path — untouched
66
+ // siblings keep their identity. Key-only steps (a `Path<T>` value), not
67
+ // CursorStep — there is no meaningful way to write "through" a narrow
68
+ // predicate.
69
+ //
70
+ // Dead-step semantics mirror read() returning undefined: a step that hits a
71
+ // null/undefined container, a shape mismatch (key step on a non-object,
72
+ // numeric step on a non-array), or an out-of-bounds index makes the whole
73
+ // write a no-op that returns `root` by identity — you can't edit a field of
74
+ // an absent section; materialize the section first. (Appending/splicing is
75
+ // list-manipulation territory, deliberately not a path write.) Writing a
76
+ // value that is already there (Object.is) is also an identity-preserving
77
+ // no-op, so state setters can bail out of re-rendering.
78
+ export const write = (
79
+ root: unknown,
80
+ steps: readonly PathStep[],
81
+ value: unknown,
82
+ ): unknown => {
83
+ if (steps.length === 0) return value;
84
+ const [head, ...rest] = steps;
85
+
86
+ if (typeof head === 'number') {
87
+ if (!Array.isArray(root)) return root;
88
+ if (head < 0 || head >= root.length) return root;
89
+ const child = write(root[head], rest, value);
90
+ if (Object.is(child, root[head])) return root;
91
+ const clone = root.slice();
92
+ clone[head] = child;
93
+ return clone;
94
+ }
95
+
96
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
97
+ return root;
98
+ }
99
+ const record = root as Record<string, unknown>;
100
+ const child = write(record[head], rest, value);
101
+ if (Object.is(child, record[head])) return root;
102
+ return { ...record, [head]: child };
103
+ };
@@ -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', () => {
@@ -43,6 +43,7 @@ export const createFormDebugger = <T extends FormValuesObject>(
43
43
  submitAttempted: snapshot.submitAttempted,
44
44
  values: snapshot.values,
45
45
  errors: snapshot.errors,
46
+ touched: snapshot.touched,
46
47
  })}
47
48
  />
48
49
  </div>
@@ -74,3 +74,97 @@ describe('deriveFormErrors', () => {
74
74
  expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
75
75
  });
76
76
  });
77
+
78
+ // The recursive grammar: nested object specs walk the value tree and
79
+ // address failures with real multi-step paths. (List `each` specs are
80
+ // type-level only until plan phase 3 — the walk throws on one; pinned in
81
+ // walk.test.ts.)
82
+
83
+ type ProfileForm = {
84
+ email: string | undefined;
85
+ homeAddress: {
86
+ city: string | undefined;
87
+ postalCode: string | undefined;
88
+ };
89
+ mailingAddress: { city: string | undefined } | undefined;
90
+ };
91
+
92
+ const emptyProfile: ProfileForm = {
93
+ email: undefined,
94
+ homeAddress: { city: undefined, postalCode: undefined },
95
+ mailingAddress: undefined,
96
+ };
97
+
98
+ describe('deriveFormErrors — nested object specs', () => {
99
+ test('a failing nested leaf contributes an entry addressed by its full path', () => {
100
+ const errors = deriveFormErrors(emptyProfile, {
101
+ homeAddress: { city: notEmpty('city') },
102
+ });
103
+ expect(errors).toEqual([
104
+ { path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
105
+ ]);
106
+ });
107
+
108
+ test('sibling fields fail independently — one entry per failing node', () => {
109
+ const errors = deriveFormErrors(emptyProfile, {
110
+ email: notEmpty('email'),
111
+ homeAddress: {
112
+ city: notEmpty('city'),
113
+ postalCode: notEmpty('postalCode'),
114
+ },
115
+ });
116
+ expect(errors).toEqual([
117
+ { path: ['email'], error: "'email' cannot be empty" },
118
+ { path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
119
+ {
120
+ path: ['homeAddress', 'postalCode'],
121
+ error: "'postalCode' cannot be empty",
122
+ },
123
+ ]);
124
+ });
125
+
126
+ test('passing nested leaves contribute nothing', () => {
127
+ const errors = deriveFormErrors(
128
+ {
129
+ ...emptyProfile,
130
+ homeAddress: { city: 'SF', postalCode: '94110' },
131
+ },
132
+ {
133
+ homeAddress: {
134
+ city: notEmpty('city'),
135
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
136
+ },
137
+ },
138
+ );
139
+ expect(errors).toEqual([]);
140
+ });
141
+
142
+ test('a nested spec on an absent section is skipped', () => {
143
+ // Decided with the phase-2 type spike: the honest runtime mirror of the
144
+ // type level (which refines only the present branch of a nullable
145
+ // section) is to validate nothing when the section is absent.
146
+ const errors = deriveFormErrors(emptyProfile, {
147
+ mailingAddress: { city: notEmpty('city') },
148
+ });
149
+ expect(errors).toEqual([]);
150
+ });
151
+
152
+ test('the same nested spec fires once the section is present', () => {
153
+ const errors = deriveFormErrors(
154
+ { ...emptyProfile, mailingAddress: { city: undefined } },
155
+ { mailingAddress: { city: notEmpty('city') } },
156
+ );
157
+ expect(errors).toEqual([
158
+ { path: ['mailingAddress', 'city'], error: "'city' cannot be empty" },
159
+ ]);
160
+ });
161
+
162
+ test('a required section is a leaf validator on the section field', () => {
163
+ const errors = deriveFormErrors(emptyProfile, {
164
+ mailingAddress: notEmpty('mailingAddress'),
165
+ });
166
+ expect(errors).toEqual([
167
+ { path: ['mailingAddress'], error: "'mailingAddress' cannot be empty" },
168
+ ]);
169
+ });
170
+ });
@@ -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;