@zvenigora/ng-eval-forms 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zvenigora/ng-eval-forms
2
2
 
3
- Angular form field properties — `visible` and `text` — driven by **string** expressions
4
- resolved at runtime.
3
+ Angular form field properties — `visible` and `text`, plus `disabled` at the `/signals` entry
4
+ point — driven by **string** expressions resolved at runtime.
5
5
 
6
6
  ```ts
7
7
  { name: 'state', visible: "country === 'US'" }
@@ -45,36 +45,54 @@ npm install @zvenigora/ng-eval-forms @zvenigora/ng-eval-signals @zvenigora/ng-ev
45
45
 
46
46
  ## Entry points
47
47
 
48
- | Import | Contains |
49
- | :--- | :--- |
50
- | `@zvenigora/ng-eval-forms` | The shared core — context composition and the coercion and error-policy rules every adapter uses. Imports nothing from `@angular/core` or `@angular/forms`. |
51
- | `@zvenigora/ng-eval-forms/reactive` | The Angular Reactive Forms adapter — `FormGroup` / `FormControl`. **This is the entry point you want.** |
48
+ | Import | Contains | Requires |
49
+ | :--- | :--- | :--- |
50
+ | `@zvenigora/ng-eval-forms` | The shared core — context composition and the coercion and error-policy rules every adapter uses. Imports nothing from `@angular/core` or `@angular/forms`. | — |
51
+ | `@zvenigora/ng-eval-forms/reactive` | The Angular **Reactive Forms** adapter — `FormGroup` / `FormControl`. `visible` and `text`. | Angular 19+ |
52
+ | `@zvenigora/ng-eval-forms/signals` | The Angular **[Signal Forms](https://angular.dev/guide/forms/signals)** adapter — `schema()` / `form()`. `visible`, `text` and `disabled`. | **Angular 22+** |
52
53
 
53
- A `/signals` entry point for Angular's Signal Forms is designed but not built; see
54
- [the Phase 4 plan](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/phase-4-plan.md)
55
- § 9.
54
+ **Pick the adapter that matches the forms API you already use.** The two are independent — the
55
+ same expression string means the same thing at both — and neither imports the other. The design
56
+ and the measurements behind `/signals` are in
57
+ [the Phase 6 plan](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/phase-6-plan.md).
56
58
 
57
59
  ## Versions
58
60
 
59
- The package declares **one** peer range, at the floor:
61
+ `peerDependencies` are declared per **package**, not per entry point, so the manifest holds one
62
+ set of ranges covering both adapters:
60
63
 
61
64
  ```json
62
65
  "peerDependencies": {
63
66
  "@angular/core": ">=19.0.0",
64
67
  "@angular/forms": ">=19.0.0",
65
68
  "rxjs": "^7.8.0",
66
- "@zvenigora/ng-eval-core": "^0.3.0",
69
+ "acorn-walk": "^8.3.0",
70
+ "@zvenigora/ng-eval-core": ">=0.3.0 <0.5.0",
67
71
  "@zvenigora/ng-eval-signals": "^0.1.0"
68
72
  }
69
73
  ```
70
74
 
71
- `peerDependencies` are declared per **package**, not per entry point, so a narrower range
72
- for a single entry point is not expressible. `/reactive` — everything this release ships —
73
- works from Angular 19. When `/signals` arrives it will require **Angular 22 or later**, and
74
- an older consumer importing it gets `Cannot find module '@angular/forms/signals'` from
75
- Angular's own `exports` map rather than anything this library declares.
75
+ **The Angular range is at the floor, and the floor is `/reactive`'s.** `/signals` requires
76
+ **Angular 22 or later** — that is when `@angular/forms/signals` ships — and the manifest cannot
77
+ say so: narrowing it to `>=22.0.0` would break every Reactive Forms consumer on 19–21 to add a
78
+ diagnostic for an entry point they do not import. An older consumer importing `/signals` gets
79
+ `Cannot find module '@angular/forms/signals'` from Angular's own `exports` map rather than
80
+ anything this library declares.
81
+
82
+ **`acorn-walk` is new in 0.2.0 and imposes no new install.** `/signals` walks the parsed
83
+ expression with it. A consumer of this package already peer-depends on
84
+ `@zvenigora/ng-eval-core`, whose own peers include `acorn-walk ^8.3.0`, so npm 7+ has already
85
+ placed it; it is declared here because importing it undeclared resolves today by accident of
86
+ hoisting and would not resolve at all under pnpm's isolated layout. `acorn` itself is
87
+ deliberately **not** declared — this package imports no `acorn` symbol, and `acorn-walk`
88
+ depends on it directly.
76
89
 
77
- ## Quick start
90
+ ## Quick start — Reactive Forms (`/reactive`)
91
+
92
+ Everything from here to [Lifetime](#lifetime) is the `/reactive` adapter, except for three
93
+ sections that cover both: [How it fits together](#how-it-fits-together), [Coercion](#coercion)
94
+ and [When a rule fails](#when-a-rule-fails). Signal Forms has
95
+ [its own section](#signal-forms--signals) below.
78
96
 
79
97
  ```ts
80
98
  import { Injectable, Injector, OnDestroy, inject } from '@angular/core';
@@ -142,6 +160,11 @@ worked example of a whole form is in
142
160
  | `createFieldContext(formSource, fieldSource, options?)` | core | Composes one `EvalContext` out of a form-wide and a field-local source. |
143
161
  | `toVisible(value)` / `toText(value)` | core | The two coercions, exported so an adapter or a test can apply the same rule. |
144
162
  | `ExpressionErrorPolicy` | core | `'throw' \| 'undefined' \| ((error) => unknown)`. |
163
+ | `applyErrorPolicy(run, policy?)` | core | Runs `run` under a policy, **new in 0.2.0**. Rethrows a `SignalContextWriteError` whatever the policy says — see [When a rule fails](#when-a-rule-fails). Exported so an adapter applies the rule rather than reimplementing it. |
164
+ | `createExpressionRules(model, options?)` | `/signals` | The primary API. Binds one model signal and returns `evalVisible` / `evalText` / `evalDisabled`. |
165
+ | `ExpressionRules` | `/signals` | The three registrars, each `(path, expression, options?) => void`. |
166
+ | `ExpressionRuleOptions` | `/signals` | `{ eval?: EvalOptions; onError?: ExpressionErrorPolicy }`, accepted by the factory and per registration. |
167
+ | `TEXT` | `/signals` | The metadata key `evalText` writes through and `field().metadata(TEXT)` reads back. |
145
168
 
146
169
  `FieldProperties` members are `EvalSignal`, not plain `Signal`, and both extra members
147
170
  matter here: `invalidate()` is the escape hatch described under
@@ -193,6 +216,41 @@ the diff runs in a subscriber, where a throw is reported out of band and far fro
193
216
  `addControl` that caused it — and it could not undo that call anyway. Validate a control
194
217
  set you assemble dynamically, or re-bind.
195
218
 
219
+ ### Expressions are not validated
220
+
221
+ **No check in the table above inspects an expression.** Two of the four are on **names** — the
222
+ schema's field names and the group's control names; the other two are on a rule's *type* and a
223
+ control's *class*. `bindFieldProperties` compiles each expression, so one that does not *parse*
224
+ throws; nothing inspects what a parsed expression **names**.
225
+
226
+ **So `visible: "constructor"` binds here without complaint, and renders the field:**
227
+
228
+ ```ts
229
+ const form = new FormGroup({ country: new FormControl('CA') });
230
+
231
+ const binding = bindFieldProperties(
232
+ [{ name: 'city', visible: 'constructor' }],
233
+ form,
234
+ { injector }
235
+ );
236
+
237
+ binding.fields['city'].visible?.(); // => true — against a form with no `city` and no
238
+ // `constructor`, with nothing logged
239
+ ```
240
+
241
+ The identifier resolves off `Object.prototype` to a *function*, a function is truthy, and
242
+ truthiness means visible. It is the same failure the field-name check above prevents, reached
243
+ through the expression instead of through the name.
244
+
245
+ **`/signals` rejects this and `/reactive` does not**, so one authored rule string behaves two
246
+ ways: it throws under `@zvenigora/ng-eval-forms/signals` and silently renders a data-less field
247
+ here. The asymmetry is deliberate rather than an oversight — adding the check to `/reactive`
248
+ would make an expression that registers today start throwing, which is a breaking change to a
249
+ released entry point, and it is logged as a question for a later major in the
250
+ [roadmap](https://github.com/zvenigora/ng-eval/blob/master/ROADMAP.md). Until it is answered:
251
+ if your model keys or expressions can come from a server or a form-builder UI, prefer
252
+ `/signals`, or screen the expressions yourself.
253
+
196
254
  ## What an expression can name
197
255
 
198
256
  **The values of the form's controls, by control name.** Every control is mirrored,
@@ -274,7 +332,28 @@ expression that fails here may have been typed into a form builder by an end use
274
332
  right response to "the administrator wrote a bad rule" is a field that does not render, not
275
333
  an application that throws on every change-detection pass.
276
334
 
277
- Two things are **not** routed through it:
335
+ `applyErrorPolicy` is that decision on its own, exported so an adapter applies the rule rather
336
+ than reimplementing it — and so you can apply it to a rule invocation you drive yourself:
337
+
338
+ ```ts
339
+ import { applyErrorPolicy } from '@zvenigora/ng-eval-forms';
340
+ // SignalContextWriteError is @zvenigora/ng-eval-signals' — neither adapter re-exports it.
341
+
342
+ const boom = () => { throw new Error('bad rule'); };
343
+
344
+ applyErrorPolicy(boom); // undefined — the default
345
+ applyErrorPolicy(boom, 'undefined'); // undefined
346
+ applyErrorPolicy(boom, () => '—'); // '—'
347
+ applyErrorPolicy(boom, 'throw'); // rethrows Error('bad rule')
348
+ applyErrorPolicy(() => 'fine'); // 'fine' — nothing thrown, nothing to police
349
+
350
+ // A write violation is rethrown whatever the policy says:
351
+ const write = () => { throw new SignalContextWriteError('country', 'country = "CA"'); };
352
+
353
+ applyErrorPolicy(write, 'undefined'); // throws SignalContextWriteError
354
+ ```
355
+
356
+ Two things are **not** routed through `options.onError`, in either adapter:
278
357
 
279
358
  - **A parse error throws from `bindFieldProperties` itself**, whatever the policy.
280
359
  Expressions are compiled eagerly, so `visible: 'country ==='` fails at bind time — and
@@ -284,6 +363,16 @@ Two things are **not** routed through it:
284
363
  dataset. Swallowing it under the default would hand you a silent blank for a syntax bug
285
364
  in the rule itself.
286
365
 
366
+ **That guarantee has one boundary, and it is stated here because the guarantee is.** It
367
+ holds for an assignment the expression makes directly. An assignment **nested inside a
368
+ call** — `[1].map(x => (country = 'CA'))` — is caught by the evaluator's own call wrapper
369
+ and re-raised as a plain `Error`, which loses the class the bypass matches on. It is then
370
+ routed by `onError` like any other failure, so under the default you get a blank field with
371
+ nothing in the console. It is a misuse inside a misuse — a rule author writing an assignment
372
+ writes `country = 'CA'`, not one buried in a `.map` callback — but it is the one shape where
373
+ the mechanism cannot see what it is for. The re-wrap belongs to
374
+ `@zvenigora/ng-eval-core` and cannot be fixed from this side.
375
+
287
376
  ## Reactivity, and its two holes
288
377
 
289
378
  A property recomputes when a control it named emits on `valueChanges`. The mirror
@@ -394,16 +483,226 @@ throws, with an error naming `toSignal` and nothing naming this library. If you
394
483
  one would silently pick up the ambient injection context when there is one, and teardown
395
484
  would then run at a time that varied with where the call happened to sit.
396
485
 
486
+ ## Signal Forms — `/signals`
487
+
488
+ **Requires Angular 22 or later.** Import from `@zvenigora/ng-eval-forms/signals`.
489
+
490
+ One factory, bound to one model signal, returning three registrars you call from inside a
491
+ `schema()` body beside Angular's own rules:
492
+
493
+ ```ts
494
+ import { signal } from '@angular/core';
495
+ import { form, schema } from '@angular/forms/signals';
496
+ import { TEXT, createExpressionRules } from '@zvenigora/ng-eval-forms/signals';
497
+
498
+ interface Order {
499
+ country: string;
500
+ state: string;
501
+ zip: string;
502
+ orderTotal: number;
503
+ }
504
+
505
+ const model = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 80 });
506
+
507
+ const rules = createExpressionRules(model);
508
+
509
+ const orderSchema = schema<Order>((p) => {
510
+ rules.evalVisible(p.state, "country === 'US'");
511
+ rules.evalText(p.zip, "orderTotal >= 100 ? 'Free shipping' : 'Standard'");
512
+ rules.evalDisabled(p.zip, "country !== 'US'", { reason: 'ZIP is US-only' });
513
+ });
514
+
515
+ const f = form(model, orderSchema);
516
+ ```
517
+
518
+ There is no `FormBinding` here and nothing to look a field up in: the rules register through
519
+ Angular's own primitives, so you read them back off Angular's field state.
520
+
521
+ ```ts
522
+ f.state().hidden(); // => true
523
+ f.zip().metadata(TEXT)?.(); // => 'Standard'
524
+ f.zip().disabled(); // => true
525
+ f.zip().disabledReasons().map((r) => r.message); // => ['ZIP is US-only']
526
+
527
+ model.set({ country: 'US', state: '', zip: '', orderTotal: 120 });
528
+
529
+ f.state().hidden(); // => false
530
+ f.zip().metadata(TEXT)?.(); // => 'Free shipping'
531
+ f.zip().disabled(); // => false
532
+ ```
533
+
534
+ Recompute is Angular's own dependency tracking, per key: a rule that named `country`
535
+ re-evaluates when `country` changes and not when any other key does.
536
+
537
+ ### The three registrars, and their polarity
538
+
539
+ | Registrar | Registers | A **true** expression means |
540
+ | :--- | :--- | :--- |
541
+ | `evalVisible(path, expression, options?)` | Angular's `hidden`, **inverted** | the field is **visible** |
542
+ | `evalText(path, expression, options?)` | `metadata(path, TEXT, …)` | — |
543
+ | `evalDisabled(path, expression, options?)` | Angular's `disabled`, uninverted | the field is **disabled** |
544
+
545
+ **`evalVisible` is named after the property, not after Angular's rule, and the inversion lives
546
+ inside the library.** That is the whole reason it exists rather than a thin `hidden` wrapper:
547
+ the same rule string means the same thing at both entry points, so `country === 'US'` is "show
548
+ it when the country is US" under `/reactive`'s `visible` and under `evalVisible` alike, with no
549
+ consumer's expression carrying a `!`. `evalDisabled` keeps Angular's polarity instead, because
550
+ `/reactive` ships no `disabled` — there is no second entry point for its expressions to agree
551
+ with, and `true` disabling is what an author expects.
552
+
553
+ **`reason` is a static string, never expression-derived.** Angular's `disabled` config is a
554
+ single field: `when` returns `boolean | string`, and a truthy string is *both* "disabled" and
555
+ "the reason". A registrar forwarding the expression's value raw would disable a field on the
556
+ string `'false'` **with the reason `"false"`** — [Coercion](#coercion)'s truthiness trap in a
557
+ new shape. Keeping the expression boolean and sourcing the reason from the registration is what
558
+ kills it. A *dynamic* reason is out of scope for the same reason.
559
+
560
+ ### Lifetime — there is nothing to destroy
561
+
562
+ Unlike [`/reactive`](#lifetime), this entry point creates no `EvalSignal`, registers nothing
563
+ with a `DestroyRef`, and has **no `destroy()`**. Angular owns the field tree's lifetime and the
564
+ rules die with the schema. What is retained, on two different clocks: **per factory**, one
565
+ private memo of per-key `computed`s, bounded by the union of keys the rules name — it lives as
566
+ long as the `createExpressionRules` value does, which for a factory built at module scope is
567
+ longer than any one form; and **per rule per `form()`**, one evaluation context and one compiled
568
+ expression, garbage when that form is.
569
+
570
+ ### Reuse a schema *function*, not a schema *value*
571
+
572
+ The registrars close over the **factory's** model, and a factory is bound to one model. So the
573
+ supported way to share rules across forms is a function of the rules, called once per form:
574
+
575
+ ```ts
576
+ import { ExpressionRules } from '@zvenigora/ng-eval-forms/signals';
577
+
578
+ const makeSchema = (rules: ExpressionRules) =>
579
+ schema<Order>((p) => {
580
+ rules.evalVisible(p.state, "country === 'US'");
581
+ });
582
+
583
+ const modelA = signal<Order>({ country: 'US', state: '', zip: '', orderTotal: 0 });
584
+ const modelB = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 0 });
585
+
586
+ const fA = form(modelA, makeSchema(createExpressionRules(modelA)));
587
+ const fB = form(modelB, makeSchema(createExpressionRules(modelB)));
588
+ ```
589
+
590
+ **Sharing a schema *value* across models compiles, runs, and is wrong.** Angular re-invokes the
591
+ schema body once per `form()`, so each form does mint its own contexts — but every rule inside
592
+ them still reads the model the *factory* was given. A schema built from
593
+ `createExpressionRules(modelA)` and passed to `form(modelB, …)` yields a fully functional form B
594
+ rendering against form A's data, silently, with no error anywhere.
595
+
596
+ ### Prototype-shadowed identifiers are rejected
597
+
598
+ An expression naming a member of `Object.prototype` — `constructor`, `toString`, `valueOf`,
599
+ `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `toLocaleString`, and the five
600
+ `__proto__`-style accessors `Object.getOwnPropertyNames(Object.prototype)` also returns —
601
+ **throws**, naming the expression and the identifier:
602
+
603
+ ```ts
604
+ const bad = schema<Order>((p) => {
605
+ rules.evalVisible(p.state, 'constructor');
606
+ });
607
+
608
+ form(model, bad); // throws: identifier 'constructor' is a member of Object.prototype …
609
+ ```
610
+
611
+ Without the check the identifier resolves off the prototype to a *function*, a function is
612
+ truthy, and `evalVisible` would render precisely the field that has no data — with nothing
613
+ logged. The fix is to rename the model key.
614
+
615
+ **The throw arrives from `form()`, not from `schema()`.** The schema body is what registers, and
616
+ Angular invokes that body once per `form()` — so building the schema is silent and every
617
+ `form()` made from it throws. `/reactive` makes its (different, name-based) check at the
618
+ `bindFieldProperties(…)` call instead, so **the two entry points reject at different times**.
619
+
620
+ Three bounds on the check, none of them obvious from the paragraph above:
621
+
622
+ - **It is the expression that is checked, never the model.** A model key named off
623
+ `Object.prototype` that no expression names stays unreadable and unreported. That is
624
+ harmless — a key is only ever read because some expression names it — but it is not covered,
625
+ and it is the one thing `/reactive`'s control-name check catches that this does not.
626
+ - **A *member* expression is not this check's business.** `user.constructor` goes to
627
+ `@zvenigora/ng-eval-core`'s prototype-pollution guard, under the rules documented there.
628
+ - **It over-rejects a name the expression *binds* itself**, deliberately.
629
+ `'[1].map(valueOf => valueOf)'` throws, even though an arrow's own parameter shadows the
630
+ prototype and would have resolved correctly. A scope-aware guard would be a second copy of
631
+ the evaluator's frame logic, and one that drifted out of step would fail by
632
+ *under*-rejecting — a silent wrong answer in place of a rename. Rename the parameter.
633
+ (`'[1].map(valueOf => 1)'` registers: a binding that is never referenced is not visited.)
634
+
635
+ **`/reactive` makes no equivalent check on expressions** — see
636
+ [Expressions are not validated](#expressions-are-not-validated).
637
+
638
+ ### `caseInsensitive` is in practice a *factory* option
639
+
640
+ `ExpressionRuleOptions` — `{ eval?, onError? }` — is accepted by the factory and by each
641
+ registration, and **registration wins per key**: a registration supplying only `onError` keeps
642
+ the factory's `eval`, and vice versa. Neither key is deep-merged.
643
+
644
+ That resolution is exact for `onError` and **only partial for `eval.caseInsensitive`**:
645
+
646
+ ```ts
647
+ interface Profile {
648
+ country: string;
649
+ address: { name: string };
650
+ label: string;
651
+ }
652
+
653
+ const profile = signal<Profile>({ country: 'US', address: { name: 'HQ' }, label: '' });
654
+
655
+ const rules = createExpressionRules(profile); // caseInsensitive off at the factory
656
+
657
+ const profileSchema = schema<Profile>((p) => {
658
+ rules.evalText(p.label, 'Country + address.NAME', {
659
+ eval: { caseInsensitive: true }, // on for this registration
660
+ });
661
+ });
662
+
663
+ form(profile, profileSchema).label().metadata(TEXT)?.();
664
+ // => 'undefinedHQ'
665
+ // address.NAME resolved — a *property* name, corrected by the walk
666
+ // Country did not — an *identifier* key, still on the factory's setting
667
+ ```
668
+
669
+ The walk is the only one of the three places the option must reach that a per-registration
670
+ value gets to. The other two — the factory's key memo, and the evaluation context every rule is
671
+ given — are built from the options handed to `createExpressionRules`, fixed at factory time; the
672
+ context is minted per rule, but always from that same fixed setting, so a registration cannot
673
+ move it. One expression then ends up obeying two casing rules. **Set `caseInsensitive` on the
674
+ factory** unless that is precisely what you want.
675
+
676
+ ### Two things that are not available here
677
+
678
+ Neither is about resolution. Every key an expression can name resolves, at any spelling
679
+ `caseInsensitive` allows, whether or not the model held it when the form was built — there is
680
+ no `invalidate()` at this entry point and nothing to call it on.
681
+
682
+ - **The nested-signal diagnostic does not reach `/signals`.** A model property holding a signal
683
+ — `{ user: { name: signal('a') } }` — is read un-called by the member visitor, and
684
+ `@zvenigora/ng-eval-signals`' dev-mode warning never fires here. That shape is precisely what
685
+ the upstream scan reports, so the check is neither switched off nor blind to it: it runs over
686
+ the *source record* a context is built from, and this adapter hands it an empty one. Every
687
+ model key resolves through a lookup instead, where nothing scans. Widening the scan would not
688
+ recover it.
689
+ - **The form's key set is not enumerable from upstream**, because the memo is deliberately
690
+ private. That is the fix rather than the cost: an enumerable record is exactly what froze a
691
+ case-insensitively matched key to its first spelling for the life of the form. It is the
692
+ counterpart to [`/reactive`'s key-set caveat](#the-key-set-is-not-reactive) and the milder
693
+ one — nothing here goes stale.
694
+
397
695
  ## What is not here
398
696
 
399
697
  Deferred deliberately, each additive when it arrives:
400
698
 
401
- - **`disabled`.** Applying it means calling `control.disable()`, which is three problems at
402
- once: it is a write back into the form rather than derived state; it emits on
403
- `valueChanges` by default, so a rule naming its own field re-enters its own input and
404
- whether that converges depends on the expression; and it removes the value from the
405
- parent's aggregate. Under Signal Forms none of the three exists, which is why `disabled`
406
- is a better fit for the future `/signals` entry point than for this one.
699
+ - **`disabled` at `/reactive`.** It ships at [`/signals`](#the-three-registrars-and-their-polarity)
700
+ and not here, and the asymmetry is the point rather than a gap. Applying it to a
701
+ `FormControl` means calling `control.disable()`, which is three problems at once: it is a
702
+ write back into the form rather than derived state; it emits on `valueChanges` by default, so
703
+ a rule naming its own field re-enters its own input and whether that converges depends on the
704
+ expression; and it removes the value from the parent's aggregate. Under Signal Forms
705
+ `disabled` is a schema rule over derived state and none of the three exists.
407
706
  - **`required` and validators**, which affect form validity rather than presentation.
408
707
  - **Form state keys**, `FormArray` and nested `FormGroup`, and field-local keys — see
409
708
  [What an expression can name](#what-an-expression-can-name).
@@ -419,6 +718,12 @@ npx nx test eval-forms
419
718
  npx nx run eval-forms:build:production
420
719
  ```
421
720
 
422
- `readme-examples.spec.ts` executes the runnable examples in this file and in the worked
423
- example, so a documented example that stops working fails the suite rather than shipping.
424
- The template and manifest blocks are not executable and are not covered.
721
+ Two `readme-examples.spec.ts` files execute the runnable examples in this document, and the
722
+ split is not quite by folder: the one under `reactive/src/lib/` covers the `/reactive` blocks,
723
+ the shared core's two [Coercion](#coercion) blocks and the
724
+ [worked example](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/worked-example.md);
725
+ the one under `signals/src/lib/` covers the `/signals` blocks **plus the one `/reactive` block
726
+ whose subject is the difference between the two entry points**, because that claim is a pair and
727
+ splitting it would let either half drift alone. So a documented example that stops working fails
728
+ the suite rather than shipping. The template and manifest blocks are not executable and are not
729
+ covered.
@@ -281,12 +281,13 @@ const validate = (schema, group) => {
281
281
  * ```
282
282
  *
283
283
  * **One `EvalContext` per field, never one shared** (plan S 3.4.1). `get`
284
- * resolves `scopes` and `original` *before* `lookups`, and
285
- * `arrow-function-expression.ts` pushes its scope with no `try`/`finally` - so
286
- * one field's arrow function that throws leaves a scope on the context for the
287
- * life of the form, and under a shared context it would shadow every other
288
- * field's key of the same name. The count is N contexts for N fields, and not
289
- * N x M: the properties of one field resolve against the same names.
284
+ * resolves `scopes` and `original` *before* `lookups`, and an arrow function's
285
+ * parameter scope sits on the context for as long as its body runs - so under
286
+ * a shared context one field's arrow would shadow every other field's key of
287
+ * the same name for that window, and anything that strands a scope rather than
288
+ * popping it would do so for the life of the form. The count is N contexts for
289
+ * N fields, and not N x M: the properties of one field resolve against the
290
+ * same names.
290
291
  *
291
292
  * **The field half of each context is empty in this phase.**
292
293
  * `createFieldContext` takes two sources and the second is `{}` here. What a