@zvenigora/ng-eval-forms 0.2.3 → 0.2.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,139 @@
1
+ # Changelog — `@zvenigora/ng-eval-forms`
2
+
3
+ All notable changes to `@zvenigora/ng-eval-forms` are documented in this file. The other two packages in this repository keep their own, listed in the [root changelog](../../CHANGELOG.md).
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ---
8
+
9
+ ## [Unreleased]
10
+
11
+ ---
12
+
13
+ ## [0.2.4] - 2026-09-29
14
+
15
+ **Two `/signals` fixes that bring its model lookup into line with `createSignalContext`,
16
+ [D6](../../docs/backlog-retired.md#d6) and [D4](../../docs/backlog-retired.md#d4).** A patch: no
17
+ exported symbol changes, and the `.d.ts` files are byte-identical to 0.2.3's. `/reactive` and the
18
+ shared core entry point are unchanged. The peer ranges are unchanged, and
19
+ `>=0.3.0 <0.7.0` already admits `eval-core` 0.6.1.
20
+
21
+ ### Fixed
22
+ - **eval-forms `/signals`, number keys**: `this[42]` against a model holding `"42"` resolved
23
+ `undefined`, where `/reactive` and `createSignalContext` resolve the value. A number key now
24
+ resolves as its string spelling, through the same memo entry as `this["42"]`, with or without
25
+ `caseInsensitive`. A symbol key still resolves `undefined`.
26
+ [D6](../../docs/backlog-retired.md#d6).
27
+ - **eval-forms `/signals`, a top-level key holding a signal**: `{ ready: signal(false) }`
28
+ resolved `ready` to the signal function itself, which is truthy whatever it holds, where
29
+ `createSignalContext` resolves it to `false`. The value is now called, as upstream does, and a
30
+ rule naming `ready` re-runs when that signal changes. A plain function value is still returned
31
+ uncalled. The README's note on the nested-signal diagnostic now separates this top-level case
32
+ from the nested one, which is unchanged. [D4](../../docs/backlog-retired.md#d4).
33
+
34
+ ### Changed
35
+ - **Built with Angular 22.1.** Unlike `eval-core`, this package's `.d.ts` carries no `ɵprov`
36
+ declaration, so the upgrade changes none of its types.
37
+ - **`CHANGELOG.md` now ships in the package**, beside `README.md`. Its links into the repository's
38
+ `docs/` resolve on GitHub, not on npm.
39
+
40
+ ---
41
+
42
+ ## [0.2.3] - 2026-09-26
43
+
44
+ Released because `eval-core` 0.6.0 falls outside 0.2.2's declared peer range, so installing it
45
+ beside 0.2.2 raises a peer-dependency conflict. The range is the whole of the release.
46
+
47
+ ### Changed
48
+
49
+ - **Peer range widened** to admit `@zvenigora/ng-eval-core` 0.6.0:
50
+ `>=0.3.0 <0.6.0` → `>=0.3.0 <0.7.0`. Manifest only — no code, no exported symbol and no
51
+ behaviour of this package changes, and all three entry points (`@zvenigora/ng-eval-forms`,
52
+ `/reactive`, `/signals`) are untouched. The lower bound is unchanged, so 0.3.0 to 0.5.0 remain
53
+ supported. The `peerDependencies` block quoted in the README now shows the new range. It had
54
+ shown `>=0.3.0 <0.5.0` since 0.2.1, because 0.2.2 widened the manifest and not the README.
55
+
56
+ The `"@zvenigora/ng-eval-signals": "^0.1.0"` range is unchanged: it already admits 0.1.3.
57
+
58
+ ---
59
+
60
+ ## [0.2.2] - 2026-09-17
61
+
62
+ ### Changed
63
+
64
+ - **Peer range widened** to admit `@zvenigora/ng-eval-core` 0.5.0:
65
+ `>=0.3.0 <0.5.0` → `>=0.3.0 <0.6.0`. Manifest only — no code, no exported symbol and no
66
+ behaviour of this package changes. The lower bound is unchanged, so 0.3.0 and 0.4.0 remain
67
+ supported.
68
+
69
+ ---
70
+
71
+ ## [0.2.1] - 2026-09-16
72
+
73
+ ### Changed
74
+
75
+ - **Peer range widened** to admit `@zvenigora/ng-eval-core` 0.4.0:
76
+ `"@zvenigora/ng-eval-core": "^0.3.0"` → `">=0.3.0 <0.5.0"`, for the reason given under
77
+ `eval-signals` 0.1.1. **0.3.0 remains supported.**
78
+
79
+ The `"@zvenigora/ng-eval-signals": "^0.1.0"` range is **unchanged** and needs no change: `^0.1.0`
80
+ resolves to `>=0.1.0 <0.2.0`, which already admits `eval-signals` 0.1.1.
81
+
82
+ Nothing else changed — no source file, no export, no behaviour, and all three entry points
83
+ (`@zvenigora/ng-eval-forms`, `/reactive`, `/signals`) are untouched. `/signals` still requires
84
+ Angular 22; `/reactive` still works from Angular 19. The depth-mark unwind in `evaluateRule` is
85
+ retained, for the same two reasons given under `eval-signals` 0.1.1.
86
+
87
+ ---
88
+
89
+ ## [0.2.0] - 2026-09-06
90
+
91
+ Phase 6 of the [roadmap](../../ROADMAP.md): a third entry point, `@zvenigora/ng-eval-forms/signals`, driving Angular [Signal Forms](https://angular.dev/guide/forms/signals) field properties from **string** expressions resolved at runtime — the same proposition as `/reactive`, against Angular's schema-and-model API rather than `FormGroup`. Design, measurements and the questions it settles are in `docs/forms/phase-6-plan.md`; consumer documentation in the [package README](README.md). **`/signals` requires Angular 22 or later.** `/reactive` is unchanged and the shared core changes only additively — one new export, `applyErrorPolicy`, called out below; both still work from Angular 19.
92
+
93
+ **Two things in this release reach a consumer who never imports `/signals`**, and they are named first because everything else lands behind an entry point that has no consumers yet — a reader skimming for "does this affect me" would otherwise reasonably conclude the released surface was untouched. They are the `peerDependencies` addition and `applyErrorPolicy`, both below.
94
+
95
+ ### Added
96
+ - **⚠️ `acorn-walk ^8.3.0` in `peerDependencies`** — a manifest change to a published package, and the first of the two things that reach an existing consumer. **It imposes no new install**: a consumer of this package already peer-depends on `@zvenigora/ng-eval-core`, whose own peers include `acorn-walk ^8.3.0`, so npm 7+ has already placed it, and a strict consumer who satisfied `eval-core`'s peers by hand needs nothing further. It is declared because `/signals` imports `acorn-walk`'s `simple` directly, and an undeclared import resolves today by accident of hoisting and would not resolve at all under pnpm's isolated layout. `acorn` itself is deliberately **not** declared: this package imports no `acorn` symbol, and `acorn-walk` depends on it directly, so an `acorn` peer would be surface with no caller.
97
+ - **⚠️ `applyErrorPolicy(run, policy?)`** (core, at the primary entry point) — the second, and the only new export on the already-released surface. Runs `run` under an `ExpressionErrorPolicy` and **rethrows a `SignalContextWriteError` whatever the policy says**, because an expression that assigns is statically illegal on every recompute with every dataset and swallowing it under the default policy hands you a silent blank for a bug in the rule itself. It lives in the core rather than in `/signals` so that `/reactive`'s eventual version cannot drift from it and the two end up disagreeing about the one error that must never be swallowed. Deferred from 0.1.0's release notes as "a matching helper deferred to the `/signals` phase, which is its only caller" — it is still that caller's only use, but it is a permanent addition to a package at 0.1.0 and is called out as one.
98
+ - **`createExpressionRules(model, options?)`** (`/signals`): the primary API. A **factory**, not free functions, because Angular's `LogicFn` cannot recover the source — a rule on `p.city` evaluating `country === "US"` has no route to `country` from inside the callback, so the model must be closed over at registration. Returns `evalVisible`, `evalText` and `evalDisabled`, called from inside a `schema()` body beside Angular's own rules.
99
+ - **`disabled`, which `/reactive` does not ship.** Under Reactive Forms it means calling `control.disable()` — a write back into the form, an emission on `valueChanges` that re-enters a rule naming its own field, and a value removed from the parent aggregate. Under Signal Forms it is a schema rule over derived state and none of the three exists. `evalDisabled` takes a **static** `reason?: string`, never expression-derived: Angular's `when` returns `boolean | string` and a truthy string is *both* "disabled" and "the reason", so forwarding an expression's value raw would disable a field on the string `'false'` **with the reason `"false"`**.
100
+ - **`TEXT`** (`/signals`): the metadata key `evalText` writes through and `field().metadata(TEXT)` reads back. `text` has no dedicated Signal Forms primitive the way `hidden` and `disabled` do, so it is Angular's own `metadata` mechanism rather than a second one beside it.
101
+ - **`ExpressionRules` / `ExpressionRuleOptions`** (`/signals`): the three registrars' shape, and `{ eval?, onError? }` accepted at the factory and per registration, **registration winning per key** with no deep merge.
102
+ - **Prototype-shadowed identifiers are rejected at registration** (`/signals`). An expression naming an own property of `Object.prototype` — `constructor`, `toString`, `valueOf` and the nine others — throws, naming the expression and the identifier. Without it the identifier resolves off the prototype to a *function*, a function is truthy, and `evalVisible` renders precisely the field that has no data with nothing logged. The subject is the **expression**, not the field name: `/signals`' paths are compile-time tokens and the library never sees a name it could validate, which is the mirror image of `/reactive`'s construction-time name check.
103
+
104
+ ### Notes
105
+ - **⚠️ `/reactive` and `/signals` now disagree about one authored string, deliberately.** `visible: "constructor"` throws under `/signals` and, under `/reactive`, binds cleanly and renders a data-less field — `/reactive` validates field and control *names*, never expressions. Closing the gap would make an expression that registers today start throwing, which is a breaking change to a released entry point, so it is logged as a question for a later major in [`ROADMAP.md`](../../ROADMAP.md) and is **not** decided here. Documented in the README from **both** sections, because the reader who does not know is the one reading `/reactive`'s.
106
+ - **Limitations at `/signals`, all documented in the README**: a schema **value** shared across models silently renders form B against form A's data, so the supported reuse shape is a schema *function* of the rules, called per form; `caseInsensitive` is in practice a **factory** option, since a per-registration value reaches the walk but not the factory's key memo or its context, and one expression then obeys two casing rules; the nested-signal diagnostic from `@zvenigora/ng-eval-signals` does not reach this entry point at all; the form's key set is not enumerable from upstream, deliberately; the identifier guard over-rejects a name an expression *binds* itself, deliberately; and the `SignalContextWriteError` bypass does **not** survive a call frame, so an assignment nested inside a call is routed by `onError` like any other failure and appears as a blank field.
107
+ - **There is no `destroy()` at `/signals`, and nothing to call one on.** No `EvalSignal` is created and nothing registers with a `DestroyRef`: Angular owns the field tree's lifetime and the rules die with the schema. This is the one place the two adapters differ in obligation rather than in API.
108
+ - **`readme-examples.spec.ts` now exists under both adapters**, executing this release's documented `/signals` examples as well as `/reactive`'s. It still runs the code rather than reading the markdown, and template and manifest blocks remain uncovered.
109
+ - Nothing was added to `@zvenigora/ng-eval-core` or `@zvenigora/ng-eval-signals`; both are consumed at their published surfaces.
110
+
111
+ ---
112
+
113
+ ## [0.1.0] - 2026-08-20
114
+
115
+ Phase 4 of the [roadmap](../../ROADMAP.md): the first release of `@zvenigora/ng-eval-forms`, which drives Angular form field properties from **string** expressions resolved at runtime — for schemas served by an API, authored in a form-builder UI, or versioned separately from the application. Design and rationale in `docs/forms/phase-4-plan.md`; consumer documentation in the [package README](README.md), with a [worked example](../../docs/forms/worked-example.md). Requires `@angular/core >=19`, `@angular/forms >=19`, `rxjs ^7.8`, `@zvenigora/ng-eval-core ^0.3.0` and `@zvenigora/ng-eval-signals ^0.1.0`.
116
+
117
+ **If your conditions are known at compile time and you are on Angular 22, use Angular's own Signal Forms schema instead.** This library exists for the cases where the rule is not knowable when the application is compiled; the README says so first, before the API.
118
+
119
+ ### Added
120
+ - **Two entry points, both shipped from one package.** `@zvenigora/ng-eval-forms` is the shared core and imports nothing from `@angular/core` or `@angular/forms`; `@zvenigora/ng-eval-forms/reactive` is the Angular Reactive Forms adapter. A `/signals` entry point for Signal Forms is designed (plan § 9) and not built — placing `/reactive` at the primary entry point now would have made adding it a breaking move of every symbol.
121
+ - **`bindFieldProperties(schema, group, options)`** (`/reactive`): the primary API. Validates the schema, mirrors the `FormGroup`, and returns a `FormBinding` — one `EvalSignal` per rule, recomputing when a control the rule actually *named* changes. `options.injector` is **required**: every signal it creates is built with an explicit injector and therefore takes no `DestroyRef` registration of its own, so an optional one would silently vary when teardown runs.
122
+ - **`FieldSchema`** (`/reactive`): `{ name, visible?, text? }`, and deliberately not a schema *language*. A field need not name a control.
123
+ - **`FormBinding`** (`/reactive`): `{ fields: Record<string, FieldProperties>; destroy(): void }`. One `destroy()` releases every property signal and both halves of the mirror; it is idempotent, and a `DestroyRef` registration on the caller's injector is the net under it rather than a substitute for calling it.
124
+ - **`FieldProperties`** (`/reactive`): `{ visible?: EvalSignal<boolean>; text?: EvalSignal<string> }` — `EvalSignal` rather than `Signal`, because both extra members are reachable API here: `invalidate()` is the documented hatch for a `{ emitEvent: false }` write and for a control-set change, and `destroy()` is what teardown counts.
125
+ - **`createControlSource(group, options)`** (`/reactive`): the mirror on its own, for callers composing contexts by hand. One subscription **per control, never to the group** — a disabled control is excluded from its parent's aggregate value and there is no `rawValueChanges`, so a group-backed mirror would lose a field the moment anything disabled it. The returned record holds live values behind accessors and must be passed by reference; `{ ...source }` flattens it and silently freezes every property built over the copy.
126
+ - **`createFieldContext(formSource, fieldSource, options?)`** (core): composes one `EvalContext` per field from a form-wide and a field-local source, as two live lookups rather than a joined record — so a key added to either source after construction resolves, with nothing to keep in sync.
127
+ - **`toVisible` / `toText`** (core): the two coercions. `visible` is JavaScript truthiness, so the string `'false'` is **visible**; `text` is `String(value)` with `null` and `undefined` mapping to `''`, so `0` stringifies to `'0'` rather than blanking.
128
+ - **`ExpressionErrorPolicy`** (core): `'throw' | 'undefined' | ((error) => unknown)`. **The default is `'undefined'` — the opposite of `eval-signals`' default**, because the expression's author may be an end user rather than the developer, and the right response to a bad rule is a field that does not render. A matching `applyErrorPolicy` helper is deferred to the `/signals` phase, which is its only caller.
129
+ - **Construction-time schema validation.** A duplicate field name, a non-string rule, a name that is a member of `Object.prototype`, and a control that is not a `FormControl` all throw from `bindFieldProperties`. The prototype check is the one no other layer can make: `FormGroup` accepts a control named `constructor`, and an expression naming it reads the prototype's value — a function, which is truthy — so `visible` would render precisely the field that has no data, with no error anywhere.
130
+
131
+ ### Changed
132
+ - **⚠️ `bindFieldProperties` returns `FormBinding`, not a bare `Record<string, FieldProperties>`.** The only shape change to an exported symbol in this release, and it is called out rather than folded into "adds the `/reactive` adapter" — nothing has been published, so no released shape breaks, but the entry exists so the change is found by reading. `destroy()` is nested under `fields` rather than written onto the record because the record's keys are *field names*, `destroy` is a legal one, and those names arrive from a server: a flat shape would put a silent collision between the consumer's data and this library's API in the one case where the consumer controls the names least.
133
+
134
+ ### Notes
135
+ - **Limitations, all documented in the README**: a `{ emitEvent: false }` write freezes a property until `invalidate()` (the observable is the only signal there is); the *key set* is not reactive, so `addControl` / `removeControl` needs one `invalidate()` on the rules that name the affected key; those same methods called with `{ emitEvent: false }` suppress `group.events` and have **no** hatch; an empty `FormControl` is indistinguishable from an absent key; and the binding must be constructed outside a reactive context, since `toSignal` opens with `assertNotInReactiveContext`.
136
+ - **Deferred deliberately**: `disabled` (it writes back into the form, it emits on `valueChanges` so a rule naming its own field re-enters its own input, and it removes the value from the parent aggregate); `required` and validators; form-state keys (`touched` / `dirty` / `valid`); `FormArray` and nested `FormGroup`, which are rejected at bind time rather than left to misbehave.
137
+ - **`peerDependencies` are per package, not per entry point**, so the manifest declares one range at the floor. `/reactive` works from Angular 19; when `/signals` ships it will need Angular 22, and an older consumer importing it gets `Cannot find module '@angular/forms/signals'` from Angular's own `exports` map. Narrowing the manifest to `>=22` would add no diagnostic and would break every Reactive Forms consumer on 19–21.
138
+ - **`readme-examples.spec.ts` executes the runnable examples** in the README and the worked example, so a documented example that stops working fails the suite. Template and manifest blocks are not covered, and it is narrower than the documented-symbol drift gate the roadmap still defers: it runs the code, it does not read the markdown.
139
+ - Nothing was added to `@zvenigora/ng-eval-core` or `@zvenigora/ng-eval-signals`; both are consumed at their published surfaces.
package/README.md CHANGED
@@ -679,13 +679,15 @@ Neither is about resolution. Every key an expression can name resolves, at any s
679
679
  `caseInsensitive` allows, whether or not the model held it when the form was built — there is
680
680
  no `invalidate()` at this entry point and nothing to call it on.
681
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
682
+ - **The nested-signal diagnostic does not reach `/signals`.** A *nested* property holding a
683
+ signal — `{ user: { name: signal('a') } }` — is read un-called by the member visitor, and
684
684
  `@zvenigora/ng-eval-signals`' dev-mode warning never fires here. That shape is precisely what
685
685
  the upstream scan reports, so the check is neither switched off nor blind to it: it runs over
686
686
  the *source record* a context is built from, and this adapter hands it an empty one. Every
687
687
  model key resolves through a lookup instead, where nothing scans. Widening the scan would not
688
- recover it.
688
+ recover it. A *top-level* key holding a signal is not this case: `{ ready: signal(false) }`
689
+ resolves `ready` to `false`, as `createSignalContext` resolves it, and a rule naming `ready`
690
+ re-runs when that signal changes.
689
691
  - **The form's key set is not enumerable from upstream**, because the memo is deliberately
690
692
  private. That is the fix rather than the cost: an enumerable record is exactly what froze a
691
693
  case-insensitively matched key to its first spelling for the life of the form. It is the
@@ -2,7 +2,7 @@ import { createMetadataKey, disabled, metadata, hidden } from '@angular/forms/si
2
2
  import { EvalState, call, parse, defaultParserOptions, compile } from '@zvenigora/ng-eval-core';
3
3
  import { createFieldContext, applyErrorPolicy, toVisible, toText } from '@zvenigora/ng-eval-forms';
4
4
  import { simple } from 'acorn-walk';
5
- import { computed } from '@angular/core';
5
+ import { computed, isSignal } from '@angular/core';
6
6
 
7
7
  /**
8
8
  * Runs one compiled rule against one context, and leaves the context's scope
@@ -48,14 +48,14 @@ import { computed } from '@angular/core';
48
48
  * `arrow-function-expression.ts` pushed a scope and popped it with no
49
49
  * `try`/`finally`, so an arrow body that threw skipped the pop - and Phase 2
50
50
  * step 0 fixed exactly that
51
- * ([backlog A9](../../../../../docs/backlog.md#a9)). The loop stays anyway, for
51
+ * ([backlog A9](../../../../../docs/backlog-retired.md#a9)). The loop stays anyway, for
52
52
  * two reasons that outlive the fix - and they are not the same *kind* of
53
53
  * reason, which is the part an earlier version of this docblock got wrong:
54
54
  *
55
- * - `package.json` declares `"@zvenigora/ng-eval-core": ">=0.3.0 <0.5.0"`.
56
- * That range admits the *leaking* 0.3.0 as well as the fixed 0.4.0, so a
57
- * supported installation can still be running the defect. **Range-dependent**:
58
- * it would stop being true if the range were ever raised past 0.3.0.
55
+ * - The `@zvenigora/ng-eval-core` peer range in `modules/eval-forms/package.json`
56
+ * admits the *leaking* 0.3.0, so a supported installation can still be
57
+ * running the defect. **Range-dependent**: it would stop being true if the
58
+ * range's lower bound were ever raised past 0.3.0.
59
59
  * - `EvalContext.push` and `pop` are public methods on a published class: a
60
60
  * scope can be stranded with no visitor involved at all. That is the route
61
61
  * `evaluate-rule.spec.ts`'s containment cases now drive, because it is the
@@ -276,8 +276,10 @@ const readProperty = (model, key, caseInsensitive) => {
276
276
  *
277
277
  * - The nested-signal diagnostic does not reach this adapter.
278
278
  * `findNestedSignals` only reports a key whose value `isPlainObject`, and
279
- * every value here is a `computed`. A model property holding a signal is
280
- * read un-called by the member visitor and nothing warns.
279
+ * every value here is a `computed`. A *nested* property holding a signal -
280
+ * `{ user: { name: signal('a') } }` - is read un-called by the member
281
+ * visitor and nothing warns. A top-level key holding one is called, as
282
+ * upstream's lookup does.
281
283
  * - Enumeration of the form's keys is not available upstream, because the
282
284
  * memo is deliberately private. That is the fix rather than a cost: the
283
285
  * record being enumerable by upstream's `resolve` is precisely what
@@ -322,10 +324,29 @@ const createModelSource = (model, options) => {
322
324
  // The resolver's parameter is deliberately unannotated. `EvalLookup` is
323
325
  // `(key: unknown, …) => unknown` and a parameter position is
324
326
  // contravariant under `strict`, so `(key: string) => …` does not compile;
325
- // upstream writes it the same way for the same reason. The `typeof`
326
- // narrowing is therefore not defensive padding - without it a non-string
327
- // key would be coerced into the memo as a spurious entry.
328
- context.lookups.push((key) => (typeof key === 'string' ? keySignal(key)() : undefined));
327
+ // upstream writes it the same way for the same reason.
328
+ //
329
+ // The narrowing decides which keys reach the memo, and it matches
330
+ // upstream's `resolve` for both kinds an expression can produce. A string
331
+ // passes as is. A number - `this[42]` is how a walk hands a lookup one,
332
+ // since `this` is the context and a computed key arrives raw - is spelled
333
+ // as the string JavaScript's own property access coerces it to, so
334
+ // `this[42]` and `this["42"]` share one memo entry and resolve as upstream
335
+ // does. Passed raw it would make a second entry under the number, and
336
+ // under `caseInsensitive` throw from `toLowerCase`. Anything else - a
337
+ // symbol - resolves `undefined`, where upstream would find a symbol-keyed
338
+ // own property (`docs/backlog.md` `BL-D6`).
339
+ //
340
+ // A value that is itself a signal is called, as upstream's lookup does
341
+ // (`isSignal(value) ? value() : value`), so `{ ready: signal(false) }`
342
+ // resolves `ready` to `false` rather than to a truthy function. The call
343
+ // happens in the rule's own derivation, so the rule tracks the inner
344
+ // signal as well as the key's computed (`docs/backlog.md` `BL-D4`).
345
+ context.lookups.push((key) => {
346
+ const name = typeof key === 'string' ? key : typeof key === 'number' ? String(key) : undefined;
347
+ const value = name === undefined ? undefined : keySignal(name)();
348
+ return isSignal(value) ? value() : value;
349
+ });
329
350
  return context;
330
351
  };
331
352
  return { keySignal, createRuleContext };
@@ -1 +1 @@
1
- {"version":3,"file":"zvenigora-ng-eval-forms-signals.mjs","sources":["../../../../modules/eval-forms/signals/src/lib/evaluate-rule.ts","../../../../modules/eval-forms/signals/src/lib/guard-identifiers.ts","../../../../modules/eval-forms/signals/src/lib/model-source.ts","../../../../modules/eval-forms/signals/src/lib/text-key.ts","../../../../modules/eval-forms/signals/src/lib/rules.ts","../../../../modules/eval-forms/signals/src/zvenigora-ng-eval-forms-signals.ts"],"sourcesContent":["import { EvalContext, EvalOptions, EvalState, call, stateCallback } from '@zvenigora/ng-eval-core';\r\n\r\n/**\r\n * Runs one compiled rule against one context, and leaves the context's scope\r\n * stack exactly as it found it.\r\n *\r\n * **This is the entry point's only path to the walk, and that is the whole\r\n * point of it** (plan S 3.3, `phase-4-plan.md` S 9.1). Every walk needs an\r\n * `EvalState`, and outside Angular DI there are exactly two ways to get one:\r\n * the static factory this function calls below, and the class's public\r\n * constructor. The one construction in this package is that line, inside this\r\n * function's body; S 6 gate 3 greps for both spellings and expects one hit and\r\n * zero.\r\n *\r\n * **Neither spelling is written out in this comment, deliberately.** Gate 3 is\r\n * a grep, and prose naming what it searches for is a standing hit on the file\r\n * the gate exists to bless - which is what revision 10 spent a revision\r\n * removing from that gate's third row, after a block comment in\r\n * `reactive/src/lib/field-schema.ts` made it read as failing. A gate with a\r\n * known pre-existing hit is one people learn to ignore.\r\n *\r\n * **Module-private to `/signals`, deliberately, and not merely unexported by\r\n * omission.** Placing it in the shared core was measured against this and\r\n * separates on one row that matters: a barrel re-exports whole modules, so a\r\n * helper the core's `public-api.ts` can reach **is published**, permanently,\r\n * on an entry point already released at 0.1.0 - and a published `evaluateRule`\r\n * *is* a second path to the walk by existing, since any consumer could call it\r\n * with a hand-built `EvalContext`. Here the set of callers is the set of files\r\n * in this directory, which is checkable by grep. The three placement\r\n * hypotheses that sound decisive - `/reactive` bundle weight, `@angular/core`\r\n * leakage, FESM size - were measured and are not: ng-packagr compiles each\r\n * entry point separately and the helper appears in `/reactive`'s FESM at zero\r\n * bytes either way (S 3.3's table).\r\n *\r\n * **Why the `finally` exists.** The scope stack is the third and longest-lived\r\n * of `eval-core`'s three stack invariants ([`CLAUDE.md`](../../../../../CLAUDE.md)):\r\n * unlike the value stack and the open-node stack, which live on the per-walk\r\n * `EvalState` and die with it, scopes live on the `EvalContext`. A rule holds\r\n * its context across every invocation Angular makes, and `EvalContext.get`\r\n * resolves `scopes` **first**, so one scope left behind shadows the source key\r\n * of that name for the life of the form. Nothing else drains it.\r\n *\r\n * **Retained, not redundant.** This was written against a specific defect -\r\n * `arrow-function-expression.ts` pushed a scope and popped it with no\r\n * `try`/`finally`, so an arrow body that threw skipped the pop - and Phase 2\r\n * step 0 fixed exactly that\r\n * ([backlog A9](../../../../../docs/backlog.md#a9)). The loop stays anyway, for\r\n * two reasons that outlive the fix - and they are not the same *kind* of\r\n * reason, which is the part an earlier version of this docblock got wrong:\r\n *\r\n * - `package.json` declares `\"@zvenigora/ng-eval-core\": \">=0.3.0 <0.5.0\"`.\r\n * That range admits the *leaking* 0.3.0 as well as the fixed 0.4.0, so a\r\n * supported installation can still be running the defect. **Range-dependent**:\r\n * it would stop being true if the range were ever raised past 0.3.0.\r\n * - `EvalContext.push` and `pop` are public methods on a published class: a\r\n * scope can be stranded with no visitor involved at all. That is the route\r\n * `evaluate-rule.spec.ts`'s containment cases now drive, because it is the\r\n * one no fix inside the core's visitors can close. **True at every version**,\r\n * and therefore the reason this loop is not removable at any peer range.\r\n *\r\n * **Raising the peer range does not make this removable**, and the sentence\r\n * that used to sit here said it did - \"removal is gated on raising the peer\r\n * range\" reads as a sufficient condition and is only a necessary one. Whoever\r\n * raises it retires the first reason and leaves the second untouched. The same\r\n * error was in the plan's step 0b, corrected there in Phase 2 step 7; this copy\r\n * of it was corrected in step 8.\r\n *\r\n * A third reason has expired and is recorded as gone rather than silently\r\n * dropped: this was also the backstop for the scope-push sites Phase 2 was\r\n * adding to the core. `Program`, `BlockStatement` and `ForStatement` all\r\n * shipped in 0.4.0, each popping in a `finally`.\r\n *\r\n * The unwind is a loop to a **depth mark**, not a single `pop()`: one throw\r\n * can leave more than one scope open, and unwinding to the bottom would drain\r\n * scopes this call did not push - `evaluate()` is re-entrant through the arrow\r\n * closure, so the depth on entry is not reliably zero.\r\n *\r\n * This helper deliberately does **not** apply the error policy. The throw\r\n * propagates, and `applyErrorPolicy` sits outside this call in the `LogicFn`\r\n * body (S 3.5) - containment first, then the policy - so an expression that\r\n * throws is contained whether the consumer asked for `'throw'`, `'undefined'`\r\n * or a handler.\r\n *\r\n * @param compiled - `eval-core`'s own `stateCallback`, from\r\n * `compile(parse(expression, ...))`. A registrar compiles\r\n * once and holds the callback; nothing recompiles per\r\n * invocation.\r\n * @param context - The rule's context, from `ModelSource.createRuleContext()`.\r\n * One per rule per `form()` (S 3.6), reused across every\r\n * invocation of that rule.\r\n * @param options - The walk's options, which are **not** the context's:\r\n * visitors read `caseInsensitive` off the state, so it has to\r\n * reach both to correct property names as well as identifier\r\n * keys.\r\n */\r\nexport const evaluateRule = (\r\n compiled: stateCallback,\r\n context: EvalContext,\r\n options?: EvalOptions\r\n): unknown => {\r\n\r\n const depth = context.scopes.length;\r\n\r\n // `fromContext` short-circuits on identity for an `EvalContext`, so this\r\n // builds a fresh per-walk state around the *same* context rather than\r\n // copying it - which is what lets one context back many invocations, and\r\n // equally what makes a leaked scope durable.\r\n const state = EvalState.fromContext(context, options);\r\n\r\n try {\r\n return call(compiled, state);\r\n } finally {\r\n while (context.scopes.length > depth) {\r\n context.pop();\r\n }\r\n }\r\n};\r\n","import type { parse } from '@zvenigora/ng-eval-core';\r\nimport { simple } from 'acorn-walk';\r\n\r\n/**\r\n * Throws if the expression names an identifier that is an own property of\r\n * `Object.prototype` (plan S 3.8).\r\n *\r\n * **The failure this prevents is silent and truthy.** `createSignalContext`\r\n * builds its context on an empty `original` object, and `EvalContext.get`\r\n * consults `original` *before* `lookups` - so an identifier like\r\n * `constructor` resolves off the prototype and never reaches the model\r\n * resolver at all. A function is truthy, so `toVisible` says visible and\r\n * `evalVisible(p.city, 'constructor')` renders the field with no data, no\r\n * error and nothing logged (Q10, Q11). It is not GHSA-pj3p-xpg7-h7gw's\r\n * case-variant bypass: the behaviour is identical with and without\r\n * `caseInsensitive`, and `CONSTRUCTOR` resolves `undefined` in both.\r\n *\r\n * **The subject is the expression, not the field name** - which is what makes\r\n * this the same answer `/reactive` gives (`field-schema.ts:172-178`) on a\r\n * different input. There the field names arrive in the library's own\r\n * `FieldSchema[]`; here the paths are compile-time `p.city` tokens and the\r\n * library never sees a name it could validate. What it does see, at\r\n * registration, is the expression. A model key nobody names harms nobody, so\r\n * the expression is the complete subject and this check inherits none of the\r\n * growing-key-set problem a scan of the model would have.\r\n *\r\n * **The predicate is normative and any list of names is illustrative**\r\n * (S 3.8.1, revision 18 item 2). `Object.getOwnPropertyNames(Object.prototype)`\r\n * is twelve names: the seven a form author might plausibly type -\r\n * `constructor`, `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`,\r\n * `propertyIsEnumerable`, `toLocaleString` - plus `__proto__` and the four\r\n * `__define*` / `__lookup*` accessors. Hard-coding the seven would satisfy\r\n * every other criterion of this step, which is why one of the other five is\r\n * asserted.\r\n *\r\n * **Deliberately over-rejecting** (S 3.8.1). An arrow's own frame is\r\n * genuinely safe - `EvalContext.get` resolves `scopes` at step 1 and\r\n * `original` at step 2, so a bound `valueOf` shadows the prototype and\r\n * resolves correctly - and `'[1].map(valueOf => valueOf)'` is refused anyway.\r\n * The scope-aware alternative is a second copy of `eval-core`'s frame logic,\r\n * tracking two scope-pushing visitors this library does not own, that fails\r\n * by *under*-rejecting when it drifts. The costs are asymmetric: a named\r\n * error at registration, whose fix is renaming a parameter, against a field\r\n * that always renders in production.\r\n *\r\n * **`acorn-walk`'s `simple`, borrowed rather than hand-rolled** (S 0.1) - the\r\n * same package `eval-core` walks with. Two of its properties are load-bearing\r\n * rather than incidental, and both are pinned by specs:\r\n *\r\n * - its base walker descends into `node.property` only when `node.computed`,\r\n * so `user.constructor` is **not** seen as an `Identifier`. That is\r\n * `eval-core`'s prototype-pollution guard's business and the stated upper\r\n * bound of this one;\r\n * - `base.Function` walks parameters under the `\"Pattern\"` override, which\r\n * `simple` suppresses, so a **binding** is never visited while a\r\n * **reference** is. `'[1].map(valueOf => 1)'` therefore registers.\r\n *\r\n * A hand-rolled scan over every node would reject both, which is the\r\n * difference the borrow is checked at.\r\n *\r\n * @param expression the source, named in the message so an author can tell\r\n * which of a schema's rules to fix\r\n * @param node the AST the registrar has just parsed. Typed\r\n * `ReturnType<typeof parse>` rather than `AnyNode`: `eval-core` publishes\r\n * `AnyNodeTypes` and not `AnyNode`, so spelling it out would force\r\n * `import type { AnyNode } from 'acorn'` - an undeclared dependency whose\r\n * shortest fix is the `acorn` peer S 3.8 rejects.\r\n */\r\nexport const guardIdentifiers = (expression: string, node: ReturnType<typeof parse>): void => {\r\n\r\n // `parse`'s declared return is `Program | AnyNode | undefined` and `simple`\r\n // takes a `Node`, so the narrowing is forced by the signature rather than\r\n // by a case this package can reach: `prepare` passes `defaultParserOptions`,\r\n // which sets `extractExpressions: false`, and `parse` then returns the\r\n // `Program` unconditionally. `undefined` is `extractExpression`'s answer\r\n // when a program's body is not exactly one `ExpressionStatement` - `'a; b'`\r\n // as much as the empty program - so a caller passing different options\r\n // could produce it.\r\n //\r\n // **No spec covers this branch, and skipping the walk is still right if one\r\n // ever reaches it**: nothing to walk is nothing to reject, and the caller\r\n // hands the same value to `compile`, whose `evaluate` already answers\r\n // `undefined` for a falsy node. The guard changes no contract by declining.\r\n if (node === undefined) {\r\n return;\r\n }\r\n\r\n simple(node, {\r\n Identifier(identifier) {\r\n if (Object.prototype.hasOwnProperty.call(Object.prototype, identifier.name)) {\r\n throw new Error(\r\n `Expression '${expression}': identifier '${identifier.name}' is a member of ` +\r\n `Object.prototype and cannot be resolved reliably: it reads the prototype's ` +\r\n `value whenever the model holds no such key, so the rule sees a function - ` +\r\n `which is truthy - rather than the absence it was written for. Rename the ` +\r\n `model key, or the parameter that binds it.`\r\n );\r\n }\r\n },\r\n });\r\n};\r\n","import { Signal, WritableSignal, computed } from '@angular/core';\r\nimport { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { createFieldContext } from '@zvenigora/ng-eval-forms';\r\n\r\n/**\r\n * Resolves one key against the model object, preferring an exact match.\r\n *\r\n * **This is a re-implementation of `eval-signals`' own `resolve`\r\n * (`signal-context.ts:127-144`), not a borrow, and the plan requires that be\r\n * said plainly rather than recorded as reuse** (plan S 0.1, S 3.2.1).\r\n * `resolve` is module-private there and cannot be called. The two must agree\r\n * on the same rule - exact match first, then the first key differing only in\r\n * case, in insertion order - and if upstream's ever changes, this is the\r\n * second copy that does not know.\r\n */\r\nconst readProperty = (\r\n model: Record<string, unknown>,\r\n key: string,\r\n caseInsensitive: boolean\r\n): unknown => {\r\n\r\n if (Object.prototype.hasOwnProperty.call(model, key)) {\r\n return model[key];\r\n }\r\n\r\n if (!caseInsensitive) {\r\n return undefined;\r\n }\r\n\r\n const lowered = key.toLowerCase();\r\n const match = Object.keys(model).find((candidate) => candidate.toLowerCase() === lowered);\r\n return match === undefined ? undefined : model[match];\r\n};\r\n\r\n/**\r\n * What one `createExpressionRules` factory holds: a private memo of per-key\r\n * `computed`s, and the contexts built off it (plan S 3.2.1, S 3.6).\r\n *\r\n * Module-private to the `/signals` entry point - a real export of this\r\n * module, absent from `signals/src/public-api.ts`, changeable by a later\r\n * phase without a release (S 5).\r\n */\r\nexport interface ModelSource {\r\n\r\n /**\r\n * One `computed` per key, per **factory**. Exported so the memo can be\r\n * compared by identity: the memo itself is private and the resolver returns\r\n * a *value*, so comparing two resolved values passes with or without it.\r\n */\r\n keySignal: (key: string) => Signal<unknown>;\r\n\r\n /**\r\n * S 3.6's one context per rule per `form()`, built off the shared memo.\r\n */\r\n createRuleContext: () => EvalContext;\r\n}\r\n\r\n/**\r\n * Builds the `/signals` adapter's source over the model signal a consumer\r\n * passed to `form()`.\r\n *\r\n * **The property read happens *inside* the `computed`, and that is the whole\r\n * design** (plan S 3.2.1). Two earlier shapes are withdrawn and both failed\r\n * in ways worth naming, because each looks correct until a second read:\r\n *\r\n * - A bare `() => model()[key]` in a `SignalContextSource` record resolves to\r\n * the **function object** - truthy, never called, never tracked - because\r\n * upstream's resolver unwraps signals and passes functions through\r\n * untouched. That is the silent freeze `src/lib/field-context.ts:60-64`\r\n * already records for the `/reactive` form half.\r\n * - A record whose entries are memoised `computed`s, written back as the\r\n * expression spelled the key, becomes an **exact** match on the second read\r\n * under `caseInsensitive` and shadows the model's own key for the life of\r\n * the form. Measured as Q4: the first read resolves and every read after it\r\n * is frozen.\r\n *\r\n * With resolution inside the computed there is nothing left for a record to\r\n * hold, so both `createFieldContext` sources are `{}` and the resolver\r\n * pushed onto `lookups` answers every key.\r\n *\r\n * **The read is what subscribes, and it happens even when the key resolves to\r\n * `undefined`.** `EvalContext.get` treats `undefined` as absent at every step,\r\n * so an expression naming a key the model does not hold yet resolves to\r\n * nothing - but `keySignal(key)()` has been *called* inside Angular's\r\n * derivation, so the rule is subscribed. When the model gains the key, the\r\n * computed's value changes and the rule re-runs. A record that simply lacked\r\n * the key would read nothing, subscribe to nothing, and stay frozen.\r\n *\r\n * **Per-key propagation survives even though every `computed` reads the whole\r\n * model.** Angular's `computed` memoises on `Object.is`, so a write to `zip`\r\n * re-evaluates each computed's property read and propagates only from\r\n * `zip`'s. The cost per model write is O(keys some expression actually\r\n * reads), not O(rules) and not O(model keys), because `computed` is lazy and\r\n * a key nothing names is never evaluated.\r\n *\r\n * **What is limited**, for the README beside `/reactive`'s key-set caveat -\r\n * and neither item is resolution, since every key an expression can name\r\n * resolves at any spelling `caseInsensitive` allows, whether or not the model\r\n * held it when the form was built:\r\n *\r\n * - The nested-signal diagnostic does not reach this adapter.\r\n * `findNestedSignals` only reports a key whose value `isPlainObject`, and\r\n * every value here is a `computed`. A model property holding a signal is\r\n * read un-called by the member visitor and nothing warns.\r\n * - Enumeration of the form's keys is not available upstream, because the\r\n * memo is deliberately private. That is the fix rather than a cost: the\r\n * record being enumerable by upstream's `resolve` is precisely what\r\n * produced Q4's freeze.\r\n *\r\n * @param model - The `WritableSignal` the consumer passes to `form()`.\r\n * `form()` does not copy it, so the model signal and the field\r\n * tree are two views of one thing.\r\n * @param options - Configures this source and the contexts it builds, not the\r\n * walk. As upstream, `caseInsensitive` corrects identifier\r\n * keys here but not *property* names.\r\n */\r\nexport const createModelSource = <TModel extends object>(\r\n model: WritableSignal<TModel>,\r\n options?: EvalOptions\r\n): ModelSource => {\r\n\r\n // Index access, not dotted: `EvalOptions` is an index signature and\r\n // `noPropertyAccessFromIndexSignature` is set in all three libraries.\r\n const caseInsensitive = !!options?.['caseInsensitive'];\r\n\r\n // A `Map`, deliberately, and **not** the record `createFieldContext` hands\r\n // to `createSignalContext` - see Q4 in the docblock above. Nothing upstream\r\n // can see it, so no memo entry can ever shadow a model key.\r\n const memo = new Map<string, Signal<unknown>>();\r\n\r\n const keySignal = (key: string): Signal<unknown> => {\r\n let cached = memo.get(key);\r\n\r\n if (!cached) {\r\n // Created inside Angular's reactive consumer, on the first read of any\r\n // key. That is allowed - `computed()` needs no injection context and is\r\n // not `effect()`. The inner computed becomes the active consumer for\r\n // its own body, so `model()` is attributed there and the outer consumer\r\n // records the inner one as a dependency, which is what makes the\r\n // per-key propagation above hold.\r\n cached = computed(() => readProperty(model() as Record<string, unknown>, key, caseInsensitive));\r\n memo.set(key, cached);\r\n }\r\n\r\n return cached;\r\n };\r\n\r\n const createRuleContext = (): EvalContext => {\r\n\r\n // `createFieldContext` is still what builds the context - not for its\r\n // sources, which are both empty, but for its **class**. It returns a\r\n // context whose `set` throws `SignalContextWriteError`, which is the\r\n // error the error policy must re-throw rather than swallow. A hand-built\r\n // `EvalContext` would silently accept an assigning expression.\r\n const context = createFieldContext({}, {}, options);\r\n\r\n // The resolver's parameter is deliberately unannotated. `EvalLookup` is\r\n // `(key: unknown, …) => unknown` and a parameter position is\r\n // contravariant under `strict`, so `(key: string) => …` does not compile;\r\n // upstream writes it the same way for the same reason. The `typeof`\r\n // narrowing is therefore not defensive padding - without it a non-string\r\n // key would be coerced into the memo as a spurious entry.\r\n context.lookups.push((key) => (typeof key === 'string' ? keySignal(key)() : undefined));\r\n\r\n return context;\r\n };\r\n\r\n return { keySignal, createRuleContext };\r\n};\r\n","import { createMetadataKey } from '@angular/forms/signals';\r\n\r\n/**\r\n * The metadata key `evalText` writes through and a consumer reads back\r\n * (plan S 1.2.8, S 3.5).\r\n *\r\n * `text` has no dedicated primitive in `@angular/forms/signals` the way\r\n * `hidden` and `disabled` do, so it is `metadata(path, TEXT, logic)` against a\r\n * key created once - which is Angular's own mechanism rather than a second one\r\n * beside it, per the `/signals` reviewer checklist's item 4.\r\n *\r\n * **Created once, at module scope, deliberately.** `createMetadataKey` mints a\r\n * fresh key per call, and the write and the read-back have to name the same\r\n * object: a key re-created per access would let `metadata(p.x, TEXT, …)` write\r\n * under one identity and `f.x().metadata(TEXT)` read under another, and the\r\n * read would be `undefined` with nothing to show why. `text-key.spec.ts`\r\n * asserts the two halves of that - `createMetadataKey` is per-call, and this\r\n * is one of its results rather than the function itself.\r\n */\r\nexport const TEXT = createMetadataKey<string>();\r\n","import { WritableSignal } from '@angular/core';\r\nimport { type PathKind, type SchemaPath, type SchemaPathRules, disabled, hidden, metadata } from '@angular/forms/signals';\r\nimport { type EvalOptions, compile, defaultParserOptions, parse } from '@zvenigora/ng-eval-core';\r\nimport { type ExpressionErrorPolicy, applyErrorPolicy, toText, toVisible } from '@zvenigora/ng-eval-forms';\r\nimport { evaluateRule } from './evaluate-rule';\r\nimport { guardIdentifiers } from './guard-identifiers';\r\nimport { createModelSource } from './model-source';\r\nimport { TEXT } from './text-key';\r\n\r\n/**\r\n * What `createExpressionRules` takes, and what a single registration may\r\n * override (plan S 5).\r\n */\r\nexport interface ExpressionRuleOptions {\r\n\r\n /**\r\n * Passed to the context and to the walk. `caseInsensitive` has to reach\r\n * both: on the context it corrects identifier keys, and on the walk it\r\n * corrects *property* names, which the member visitor reads off the\r\n * state's options.\r\n *\r\n * **A per-registration value reaches exactly one of the three places it\r\n * has to: the walk** (plan S 3.5.3). The other two read the **factory's**\r\n * options, which are fixed when `createExpressionRules` is called: the memo\r\n * is built once, there; the context is minted per registration by\r\n * `createRuleContext()` but always from that same fixed setting, so a\r\n * registration cannot move it. So overriding this per registration corrects\r\n * *property* names and leaves *identifier* keys on the factory's setting,\r\n * and one expression then obeys two casing rules. Set `caseInsensitive` on\r\n * the **factory** unless that is the behaviour you want.\r\n *\r\n * Revision 19 item 2 corrects \"both are made once, at\r\n * `createExpressionRules` time\", which was wrong about the context and is\r\n * the claim the README states correctly.\r\n */\r\n eval?: EvalOptions;\r\n\r\n /**\r\n * What a field property does when its expression throws at runtime.\r\n * Defaults to `'undefined'` here, the opposite of `eval-signals`' own\r\n * default - see `ExpressionErrorPolicy` for why.\r\n */\r\n onError?: ExpressionErrorPolicy;\r\n}\r\n\r\n/**\r\n * The three registrars one factory returns, one per Angular rule.\r\n *\r\n * **One call per Angular primitive, never an aggregate** (plan S 3.5.1). A\r\n * single `evalRules(p.x, { visible, text, disabled })` would register three\r\n * different Angular rules behind one name, hiding which one each property\r\n * maps to - which is the `/signals` reviewer checklist's item 4 expressed as\r\n * an API.\r\n *\r\n * Named after the **property**, not after Angular's rule: `evalVisible`\r\n * registers `hidden`, inverted once inside the registrar rather than in every\r\n * consumer's expression, so the same expression string means the same thing\r\n * at this entry point and at `/reactive`.\r\n */\r\nexport interface ExpressionRules {\r\n\r\n /** Registers Angular's `hidden`, inverted (plan S 3.5.1). */\r\n evalVisible: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions\r\n ) => void;\r\n\r\n /** Registers `metadata(path, TEXT, …)` (plan S 1.2.8, S 3.5). */\r\n evalText: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions\r\n ) => void;\r\n\r\n /**\r\n * Registers Angular's `disabled`.\r\n *\r\n * The `reason` is authored as a static string and is never\r\n * expression-derived (plan S 3.5.2). Angular's `when` returns\r\n * `boolean | string` and a truthy string is *both* \"disabled\" and \"the\r\n * reason\", so a rule yielding `'false'` would otherwise disable the field\r\n * with the reason `\"false\"` - the `toVisible` truthiness trap in a new\r\n * shape. Keeping the expression boolean and the reason static is what kills\r\n * it.\r\n */\r\n evalDisabled: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions & { reason?: string }\r\n ) => void;\r\n}\r\n\r\n/**\r\n * Binds one model signal and returns the three expression-driven registrars.\r\n *\r\n * **A factory rather than free functions, because a `LogicFn` cannot recover\r\n * the source** (plan S 3.2.1). `RootFieldContext` exposes the *current*\r\n * field's node plus compile-time-token accessors and no root or parent\r\n * handle, so a rule on `p.city` evaluating `country === \"US\"` has no route to\r\n * `country` from inside the `LogicFn`. The source must be closed over at\r\n * registration or it is unreachable.\r\n *\r\n * ```ts\r\n * const rules = createExpressionRules(model);\r\n * const s = schema<Model>((p) => {\r\n * required(p.email); // Angular's\r\n * rules.evalVisible(p.city, 'country === \"US\"'); // ours\r\n * });\r\n * const f = form(model, s);\r\n * ```\r\n *\r\n * **A schema *value* shared across models is the unsupported shape.** Nothing\r\n * stops two `form()` calls from one schema - Angular re-invokes the schema\r\n * body once per `form()`, so each form mints its own contexts - but the\r\n * registrars close over the **factory's** model, and the factory is bound to\r\n * one. A schema built from `createExpressionRules(modelA)` and reused for\r\n * `form(modelB, s)` re-registers every rule and every one of them still reads\r\n * model A: form B renders against form A's data, silently, with no error and\r\n * a fully functional form (Q9).\r\n *\r\n * The supported reuse shape is therefore a schema **function of the rules** -\r\n * `const makeSchema = (rules) => schema<Model>(p => …)`, called per form -\r\n * which keeps reuse while giving each form a factory bound to its own model.\r\n *\r\n * What one factory retains: **one private memo**, per factory, bounded by the\r\n * union of keys the rules mention; and, per rule per `form()`, one\r\n * `EvalContext` and one compiled callback. Nothing registers with a\r\n * `DestroyRef` and there is no `destroy()` - it all becomes garbage with the\r\n * form (S 3.6).\r\n */\r\nexport const createExpressionRules = <TModel extends object>(\r\n model: WritableSignal<TModel>,\r\n options?: ExpressionRuleOptions\r\n): ExpressionRules => {\r\n\r\n // Called **once** per factory: it is what fixes the memo's lifetime, one\r\n // per factory rather than one per rule (plan S 3.6). Calling it per\r\n // registrar instead would satisfy every behavioural criterion in this phase\r\n // while quietly making the memo per rule, which is why the count has a spec\r\n // of its own.\r\n const source = createModelSource(model, options?.eval);\r\n\r\n /**\r\n * Resolves the two levels `ExpressionRuleOptions` arrives at:\r\n * **registration wins, per key** (plan S 3.5.3). Each key is resolved\r\n * independently and neither is a deep merge, so a registration supplying\r\n * only `onError` keeps the factory's `eval` and vice versa.\r\n *\r\n * Exact for `onError`, partial for `eval.caseInsensitive` - see the note on\r\n * `ExpressionRuleOptions.eval`. The divergence is characterised in\r\n * `rules.spec.ts` rather than left to be discovered.\r\n */\r\n const resolveOptions = (rule?: ExpressionRuleOptions): ExpressionRuleOptions => ({\r\n eval: rule?.eval ?? options?.eval,\r\n onError: rule?.onError ?? options?.onError,\r\n });\r\n\r\n /**\r\n * Everything a registrar does before handing Angular a `LogicFn`, and the\r\n * shape of what it returns is the phase's one structural invariant.\r\n *\r\n * **Called once per registration**, so `parse` + `compile` and\r\n * `createRuleContext()` happen at schema-body time - which Angular re-runs\r\n * once per `form()` (Q8), giving S 3.6's count of one context and one\r\n * compiled callback per rule per form. Nothing here re-parses per\r\n * derivation; risk 8 is a compile that drifts into the returned closure, and\r\n * `rules.invocation-count.spec.ts` counts `compile` to say it has not.\r\n *\r\n * **`guardIdentifiers` runs here, between `parse` and `compile`, and one\r\n * call site is deliberate** (S 3.8, revision 18 item 1). Revision 16's rule\r\n * rejects \"the wrapper is shared\" as a substitute for a per-registrar case,\r\n * and the discriminator it states is whether the subject is a path Angular\r\n * owns. This one is not: the guard throws **before any Angular primitive is\r\n * reached**, so in the rejecting case `hidden`, `metadata` and\r\n * `addDisabledReasonRule` are never called and the three registrars have\r\n * nothing downstream that could diverge. Contrast the invocation count and\r\n * the write-error bypass, whose values arrive *through* those three\r\n * primitives - `rules.spec.ts` gives each of them a case per registrar for\r\n * exactly that reason. Anything the returned closure does stays\r\n * registrar-level; this runs before there is a closure.\r\n *\r\n * **`applyErrorPolicy` is the outermost call in the returned closure, and\r\n * that is load-bearing twice over** (S 3.5). The policy has to cover the\r\n * coercion's *input* rather than only the walk - a registrar that coerced\r\n * first would hand `toVisible` a value the policy never saw - and S 6.1.1's\r\n * invocation instrument counts this exact call. M7 measured what a guard\r\n * hoisted *above* the wrapper does to that number: ground truth 1,\r\n * instrument **0**, which reads as \"the rule did not re-run\" in every\r\n * negative case in this package. A later guard belongs **inside** the\r\n * `run` callback, never ahead of this call.\r\n */\r\n const prepare = (expression: string, ruleOptions?: ExpressionRuleOptions): (() => unknown) => {\r\n const resolved = resolveOptions(ruleOptions);\r\n const node = parse(expression, defaultParserOptions);\r\n\r\n guardIdentifiers(expression, node);\r\n\r\n const compiled = compile(node);\r\n const context = source.createRuleContext();\r\n\r\n return () =>\r\n applyErrorPolicy(() => evaluateRule(compiled, context, resolved.eval), resolved.onError);\r\n };\r\n\r\n return {\r\n\r\n // Angular's **config** overload (S 1.2.7); the deprecated one takes the\r\n // `LogicFn` positionally. `hidden`'s `when` is the only required config of\r\n // the three primitives this entry point registers.\r\n //\r\n // The `!` is S 3.5.1's inversion, and it lives here rather than in every\r\n // consumer's expression so the same rule string means the same thing at\r\n // this entry point and at `/reactive`'s `visible`.\r\n evalVisible: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n\r\n hidden(path, { when: () => !toVisible(evaluated()) });\r\n },\r\n\r\n // `text` has no dedicated primitive, so it is Angular's `metadata` against\r\n // the module-scope key of `text-key.ts` (S 1.2.8) - its own mechanism\r\n // rather than a second one beside it.\r\n evalText: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n\r\n metadata(path, TEXT, () => toText(evaluated()));\r\n },\r\n\r\n // Angular's polarity, uninverted: `/reactive` ships no `disabled`, so no\r\n // expression has to mean the same thing at two entry points, and `true`\r\n // disabling is what an author expects (S 3.5.1).\r\n //\r\n // **The reason is a static option, never the expression's return**\r\n // (S 3.5.2). Angular's `when` is a single field carrying both the\r\n // condition and the reason - it returns `boolean | string`, and a truthy\r\n // string is *both* (1.2.7) - so a registrar forwarding `evaluated()` raw\r\n // would disable a field on the string `'false'` **with the reason\r\n // `\"false\"`**. Coercing through `toVisible` first, and sourcing the reason\r\n // from the registration instead, is what kills that: the string never\r\n // comes from the expression at all.\r\n //\r\n // A *dynamic* reason stays out of scope - it reopens the trap and needs a\r\n // coercion rule of its own.\r\n evalDisabled: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n const reason = ruleOptions?.reason;\r\n\r\n disabled(path, {\r\n when: () => {\r\n // `evaluated()` first, so `applyErrorPolicy` stays the outermost\r\n // call in this body as it is in the other two (S 3.5). `reason` is\r\n // read from a closure rather than branched on ahead of the call:\r\n // a guard hoisted above the wrapper is M7's arrangement, which\r\n // reads 0 on an instrument whose ground truth is 1.\r\n const on = toVisible(evaluated());\r\n\r\n return on && reason !== undefined ? reason : on;\r\n },\r\n });\r\n },\r\n };\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"zvenigora-ng-eval-forms-signals.mjs","sources":["../../../../modules/eval-forms/signals/src/lib/evaluate-rule.ts","../../../../modules/eval-forms/signals/src/lib/guard-identifiers.ts","../../../../modules/eval-forms/signals/src/lib/model-source.ts","../../../../modules/eval-forms/signals/src/lib/text-key.ts","../../../../modules/eval-forms/signals/src/lib/rules.ts","../../../../modules/eval-forms/signals/src/zvenigora-ng-eval-forms-signals.ts"],"sourcesContent":["import { EvalContext, EvalOptions, EvalState, call, stateCallback } from '@zvenigora/ng-eval-core';\r\n\r\n/**\r\n * Runs one compiled rule against one context, and leaves the context's scope\r\n * stack exactly as it found it.\r\n *\r\n * **This is the entry point's only path to the walk, and that is the whole\r\n * point of it** (plan S 3.3, `phase-4-plan.md` S 9.1). Every walk needs an\r\n * `EvalState`, and outside Angular DI there are exactly two ways to get one:\r\n * the static factory this function calls below, and the class's public\r\n * constructor. The one construction in this package is that line, inside this\r\n * function's body; S 6 gate 3 greps for both spellings and expects one hit and\r\n * zero.\r\n *\r\n * **Neither spelling is written out in this comment, deliberately.** Gate 3 is\r\n * a grep, and prose naming what it searches for is a standing hit on the file\r\n * the gate exists to bless - which is what revision 10 spent a revision\r\n * removing from that gate's third row, after a block comment in\r\n * `reactive/src/lib/field-schema.ts` made it read as failing. A gate with a\r\n * known pre-existing hit is one people learn to ignore.\r\n *\r\n * **Module-private to `/signals`, deliberately, and not merely unexported by\r\n * omission.** Placing it in the shared core was measured against this and\r\n * separates on one row that matters: a barrel re-exports whole modules, so a\r\n * helper the core's `public-api.ts` can reach **is published**, permanently,\r\n * on an entry point already released at 0.1.0 - and a published `evaluateRule`\r\n * *is* a second path to the walk by existing, since any consumer could call it\r\n * with a hand-built `EvalContext`. Here the set of callers is the set of files\r\n * in this directory, which is checkable by grep. The three placement\r\n * hypotheses that sound decisive - `/reactive` bundle weight, `@angular/core`\r\n * leakage, FESM size - were measured and are not: ng-packagr compiles each\r\n * entry point separately and the helper appears in `/reactive`'s FESM at zero\r\n * bytes either way (S 3.3's table).\r\n *\r\n * **Why the `finally` exists.** The scope stack is the third and longest-lived\r\n * of `eval-core`'s three stack invariants ([`CLAUDE.md`](../../../../../CLAUDE.md)):\r\n * unlike the value stack and the open-node stack, which live on the per-walk\r\n * `EvalState` and die with it, scopes live on the `EvalContext`. A rule holds\r\n * its context across every invocation Angular makes, and `EvalContext.get`\r\n * resolves `scopes` **first**, so one scope left behind shadows the source key\r\n * of that name for the life of the form. Nothing else drains it.\r\n *\r\n * **Retained, not redundant.** This was written against a specific defect -\r\n * `arrow-function-expression.ts` pushed a scope and popped it with no\r\n * `try`/`finally`, so an arrow body that threw skipped the pop - and Phase 2\r\n * step 0 fixed exactly that\r\n * ([backlog A9](../../../../../docs/backlog-retired.md#a9)). The loop stays anyway, for\r\n * two reasons that outlive the fix - and they are not the same *kind* of\r\n * reason, which is the part an earlier version of this docblock got wrong:\r\n *\r\n * - The `@zvenigora/ng-eval-core` peer range in `modules/eval-forms/package.json`\r\n * admits the *leaking* 0.3.0, so a supported installation can still be\r\n * running the defect. **Range-dependent**: it would stop being true if the\r\n * range's lower bound were ever raised past 0.3.0.\r\n * - `EvalContext.push` and `pop` are public methods on a published class: a\r\n * scope can be stranded with no visitor involved at all. That is the route\r\n * `evaluate-rule.spec.ts`'s containment cases now drive, because it is the\r\n * one no fix inside the core's visitors can close. **True at every version**,\r\n * and therefore the reason this loop is not removable at any peer range.\r\n *\r\n * **Raising the peer range does not make this removable**, and the sentence\r\n * that used to sit here said it did - \"removal is gated on raising the peer\r\n * range\" reads as a sufficient condition and is only a necessary one. Whoever\r\n * raises it retires the first reason and leaves the second untouched. The same\r\n * error was in the plan's step 0b, corrected there in Phase 2 step 7; this copy\r\n * of it was corrected in step 8.\r\n *\r\n * A third reason has expired and is recorded as gone rather than silently\r\n * dropped: this was also the backstop for the scope-push sites Phase 2 was\r\n * adding to the core. `Program`, `BlockStatement` and `ForStatement` all\r\n * shipped in 0.4.0, each popping in a `finally`.\r\n *\r\n * The unwind is a loop to a **depth mark**, not a single `pop()`: one throw\r\n * can leave more than one scope open, and unwinding to the bottom would drain\r\n * scopes this call did not push - `evaluate()` is re-entrant through the arrow\r\n * closure, so the depth on entry is not reliably zero.\r\n *\r\n * This helper deliberately does **not** apply the error policy. The throw\r\n * propagates, and `applyErrorPolicy` sits outside this call in the `LogicFn`\r\n * body (S 3.5) - containment first, then the policy - so an expression that\r\n * throws is contained whether the consumer asked for `'throw'`, `'undefined'`\r\n * or a handler.\r\n *\r\n * @param compiled - `eval-core`'s own `stateCallback`, from\r\n * `compile(parse(expression, ...))`. A registrar compiles\r\n * once and holds the callback; nothing recompiles per\r\n * invocation.\r\n * @param context - The rule's context, from `ModelSource.createRuleContext()`.\r\n * One per rule per `form()` (S 3.6), reused across every\r\n * invocation of that rule.\r\n * @param options - The walk's options, which are **not** the context's:\r\n * visitors read `caseInsensitive` off the state, so it has to\r\n * reach both to correct property names as well as identifier\r\n * keys.\r\n */\r\nexport const evaluateRule = (\r\n compiled: stateCallback,\r\n context: EvalContext,\r\n options?: EvalOptions\r\n): unknown => {\r\n\r\n const depth = context.scopes.length;\r\n\r\n // `fromContext` short-circuits on identity for an `EvalContext`, so this\r\n // builds a fresh per-walk state around the *same* context rather than\r\n // copying it - which is what lets one context back many invocations, and\r\n // equally what makes a leaked scope durable.\r\n const state = EvalState.fromContext(context, options);\r\n\r\n try {\r\n return call(compiled, state);\r\n } finally {\r\n while (context.scopes.length > depth) {\r\n context.pop();\r\n }\r\n }\r\n};\r\n","import type { parse } from '@zvenigora/ng-eval-core';\r\nimport { simple } from 'acorn-walk';\r\n\r\n/**\r\n * Throws if the expression names an identifier that is an own property of\r\n * `Object.prototype` (plan S 3.8).\r\n *\r\n * **The failure this prevents is silent and truthy.** `createSignalContext`\r\n * builds its context on an empty `original` object, and `EvalContext.get`\r\n * consults `original` *before* `lookups` - so an identifier like\r\n * `constructor` resolves off the prototype and never reaches the model\r\n * resolver at all. A function is truthy, so `toVisible` says visible and\r\n * `evalVisible(p.city, 'constructor')` renders the field with no data, no\r\n * error and nothing logged (Q10, Q11). It is not GHSA-pj3p-xpg7-h7gw's\r\n * case-variant bypass: the behaviour is identical with and without\r\n * `caseInsensitive`, and `CONSTRUCTOR` resolves `undefined` in both.\r\n *\r\n * **The subject is the expression, not the field name** - which is what makes\r\n * this the same answer `/reactive` gives (`field-schema.ts:172-178`) on a\r\n * different input. There the field names arrive in the library's own\r\n * `FieldSchema[]`; here the paths are compile-time `p.city` tokens and the\r\n * library never sees a name it could validate. What it does see, at\r\n * registration, is the expression. A model key nobody names harms nobody, so\r\n * the expression is the complete subject and this check inherits none of the\r\n * growing-key-set problem a scan of the model would have.\r\n *\r\n * **The predicate is normative and any list of names is illustrative**\r\n * (S 3.8.1, revision 18 item 2). `Object.getOwnPropertyNames(Object.prototype)`\r\n * is twelve names: the seven a form author might plausibly type -\r\n * `constructor`, `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`,\r\n * `propertyIsEnumerable`, `toLocaleString` - plus `__proto__` and the four\r\n * `__define*` / `__lookup*` accessors. Hard-coding the seven would satisfy\r\n * every other criterion of this step, which is why one of the other five is\r\n * asserted.\r\n *\r\n * **Deliberately over-rejecting** (S 3.8.1). An arrow's own frame is\r\n * genuinely safe - `EvalContext.get` resolves `scopes` at step 1 and\r\n * `original` at step 2, so a bound `valueOf` shadows the prototype and\r\n * resolves correctly - and `'[1].map(valueOf => valueOf)'` is refused anyway.\r\n * The scope-aware alternative is a second copy of `eval-core`'s frame logic,\r\n * tracking two scope-pushing visitors this library does not own, that fails\r\n * by *under*-rejecting when it drifts. The costs are asymmetric: a named\r\n * error at registration, whose fix is renaming a parameter, against a field\r\n * that always renders in production.\r\n *\r\n * **`acorn-walk`'s `simple`, borrowed rather than hand-rolled** (S 0.1) - the\r\n * same package `eval-core` walks with. Two of its properties are load-bearing\r\n * rather than incidental, and both are pinned by specs:\r\n *\r\n * - its base walker descends into `node.property` only when `node.computed`,\r\n * so `user.constructor` is **not** seen as an `Identifier`. That is\r\n * `eval-core`'s prototype-pollution guard's business and the stated upper\r\n * bound of this one;\r\n * - `base.Function` walks parameters under the `\"Pattern\"` override, which\r\n * `simple` suppresses, so a **binding** is never visited while a\r\n * **reference** is. `'[1].map(valueOf => 1)'` therefore registers.\r\n *\r\n * A hand-rolled scan over every node would reject both, which is the\r\n * difference the borrow is checked at.\r\n *\r\n * @param expression the source, named in the message so an author can tell\r\n * which of a schema's rules to fix\r\n * @param node the AST the registrar has just parsed. Typed\r\n * `ReturnType<typeof parse>` rather than `AnyNode`: `eval-core` publishes\r\n * `AnyNodeTypes` and not `AnyNode`, so spelling it out would force\r\n * `import type { AnyNode } from 'acorn'` - an undeclared dependency whose\r\n * shortest fix is the `acorn` peer S 3.8 rejects.\r\n */\r\nexport const guardIdentifiers = (expression: string, node: ReturnType<typeof parse>): void => {\r\n\r\n // `parse`'s declared return is `Program | AnyNode | undefined` and `simple`\r\n // takes a `Node`, so the narrowing is forced by the signature rather than\r\n // by a case this package can reach: `prepare` passes `defaultParserOptions`,\r\n // which sets `extractExpressions: false`, and `parse` then returns the\r\n // `Program` unconditionally. `undefined` is `extractExpression`'s answer\r\n // when a program's body is not exactly one `ExpressionStatement` - `'a; b'`\r\n // as much as the empty program - so a caller passing different options\r\n // could produce it.\r\n //\r\n // **No spec covers this branch, and skipping the walk is still right if one\r\n // ever reaches it**: nothing to walk is nothing to reject, and the caller\r\n // hands the same value to `compile`, whose `evaluate` already answers\r\n // `undefined` for a falsy node. The guard changes no contract by declining.\r\n if (node === undefined) {\r\n return;\r\n }\r\n\r\n simple(node, {\r\n Identifier(identifier) {\r\n if (Object.prototype.hasOwnProperty.call(Object.prototype, identifier.name)) {\r\n throw new Error(\r\n `Expression '${expression}': identifier '${identifier.name}' is a member of ` +\r\n `Object.prototype and cannot be resolved reliably: it reads the prototype's ` +\r\n `value whenever the model holds no such key, so the rule sees a function - ` +\r\n `which is truthy - rather than the absence it was written for. Rename the ` +\r\n `model key, or the parameter that binds it.`\r\n );\r\n }\r\n },\r\n });\r\n};\r\n","import { Signal, WritableSignal, computed, isSignal } from '@angular/core';\r\nimport { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { createFieldContext } from '@zvenigora/ng-eval-forms';\r\n\r\n/**\r\n * Resolves one key against the model object, preferring an exact match.\r\n *\r\n * **This is a re-implementation of `eval-signals`' own `resolve`\r\n * (`signal-context.ts:127-144`), not a borrow, and the plan requires that be\r\n * said plainly rather than recorded as reuse** (plan S 0.1, S 3.2.1).\r\n * `resolve` is module-private there and cannot be called. The two must agree\r\n * on the same rule - exact match first, then the first key differing only in\r\n * case, in insertion order - and if upstream's ever changes, this is the\r\n * second copy that does not know.\r\n */\r\nconst readProperty = (\r\n model: Record<string, unknown>,\r\n key: string,\r\n caseInsensitive: boolean\r\n): unknown => {\r\n\r\n if (Object.prototype.hasOwnProperty.call(model, key)) {\r\n return model[key];\r\n }\r\n\r\n if (!caseInsensitive) {\r\n return undefined;\r\n }\r\n\r\n const lowered = key.toLowerCase();\r\n const match = Object.keys(model).find((candidate) => candidate.toLowerCase() === lowered);\r\n return match === undefined ? undefined : model[match];\r\n};\r\n\r\n/**\r\n * What one `createExpressionRules` factory holds: a private memo of per-key\r\n * `computed`s, and the contexts built off it (plan S 3.2.1, S 3.6).\r\n *\r\n * Module-private to the `/signals` entry point - a real export of this\r\n * module, absent from `signals/src/public-api.ts`, changeable by a later\r\n * phase without a release (S 5).\r\n */\r\nexport interface ModelSource {\r\n\r\n /**\r\n * One `computed` per key, per **factory**. Exported so the memo can be\r\n * compared by identity: the memo itself is private and the resolver returns\r\n * a *value*, so comparing two resolved values passes with or without it.\r\n */\r\n keySignal: (key: string) => Signal<unknown>;\r\n\r\n /**\r\n * S 3.6's one context per rule per `form()`, built off the shared memo.\r\n */\r\n createRuleContext: () => EvalContext;\r\n}\r\n\r\n/**\r\n * Builds the `/signals` adapter's source over the model signal a consumer\r\n * passed to `form()`.\r\n *\r\n * **The property read happens *inside* the `computed`, and that is the whole\r\n * design** (plan S 3.2.1). Two earlier shapes are withdrawn and both failed\r\n * in ways worth naming, because each looks correct until a second read:\r\n *\r\n * - A bare `() => model()[key]` in a `SignalContextSource` record resolves to\r\n * the **function object** - truthy, never called, never tracked - because\r\n * upstream's resolver unwraps signals and passes functions through\r\n * untouched. That is the silent freeze `src/lib/field-context.ts:60-64`\r\n * already records for the `/reactive` form half.\r\n * - A record whose entries are memoised `computed`s, written back as the\r\n * expression spelled the key, becomes an **exact** match on the second read\r\n * under `caseInsensitive` and shadows the model's own key for the life of\r\n * the form. Measured as Q4: the first read resolves and every read after it\r\n * is frozen.\r\n *\r\n * With resolution inside the computed there is nothing left for a record to\r\n * hold, so both `createFieldContext` sources are `{}` and the resolver\r\n * pushed onto `lookups` answers every key.\r\n *\r\n * **The read is what subscribes, and it happens even when the key resolves to\r\n * `undefined`.** `EvalContext.get` treats `undefined` as absent at every step,\r\n * so an expression naming a key the model does not hold yet resolves to\r\n * nothing - but `keySignal(key)()` has been *called* inside Angular's\r\n * derivation, so the rule is subscribed. When the model gains the key, the\r\n * computed's value changes and the rule re-runs. A record that simply lacked\r\n * the key would read nothing, subscribe to nothing, and stay frozen.\r\n *\r\n * **Per-key propagation survives even though every `computed` reads the whole\r\n * model.** Angular's `computed` memoises on `Object.is`, so a write to `zip`\r\n * re-evaluates each computed's property read and propagates only from\r\n * `zip`'s. The cost per model write is O(keys some expression actually\r\n * reads), not O(rules) and not O(model keys), because `computed` is lazy and\r\n * a key nothing names is never evaluated.\r\n *\r\n * **What is limited**, for the README beside `/reactive`'s key-set caveat -\r\n * and neither item is resolution, since every key an expression can name\r\n * resolves at any spelling `caseInsensitive` allows, whether or not the model\r\n * held it when the form was built:\r\n *\r\n * - The nested-signal diagnostic does not reach this adapter.\r\n * `findNestedSignals` only reports a key whose value `isPlainObject`, and\r\n * every value here is a `computed`. A *nested* property holding a signal -\r\n * `{ user: { name: signal('a') } }` - is read un-called by the member\r\n * visitor and nothing warns. A top-level key holding one is called, as\r\n * upstream's lookup does.\r\n * - Enumeration of the form's keys is not available upstream, because the\r\n * memo is deliberately private. That is the fix rather than a cost: the\r\n * record being enumerable by upstream's `resolve` is precisely what\r\n * produced Q4's freeze.\r\n *\r\n * @param model - The `WritableSignal` the consumer passes to `form()`.\r\n * `form()` does not copy it, so the model signal and the field\r\n * tree are two views of one thing.\r\n * @param options - Configures this source and the contexts it builds, not the\r\n * walk. As upstream, `caseInsensitive` corrects identifier\r\n * keys here but not *property* names.\r\n */\r\nexport const createModelSource = <TModel extends object>(\r\n model: WritableSignal<TModel>,\r\n options?: EvalOptions\r\n): ModelSource => {\r\n\r\n // Index access, not dotted: `EvalOptions` is an index signature and\r\n // `noPropertyAccessFromIndexSignature` is set in all three libraries.\r\n const caseInsensitive = !!options?.['caseInsensitive'];\r\n\r\n // A `Map`, deliberately, and **not** the record `createFieldContext` hands\r\n // to `createSignalContext` - see Q4 in the docblock above. Nothing upstream\r\n // can see it, so no memo entry can ever shadow a model key.\r\n const memo = new Map<string, Signal<unknown>>();\r\n\r\n const keySignal = (key: string): Signal<unknown> => {\r\n let cached = memo.get(key);\r\n\r\n if (!cached) {\r\n // Created inside Angular's reactive consumer, on the first read of any\r\n // key. That is allowed - `computed()` needs no injection context and is\r\n // not `effect()`. The inner computed becomes the active consumer for\r\n // its own body, so `model()` is attributed there and the outer consumer\r\n // records the inner one as a dependency, which is what makes the\r\n // per-key propagation above hold.\r\n cached = computed(() => readProperty(model() as Record<string, unknown>, key, caseInsensitive));\r\n memo.set(key, cached);\r\n }\r\n\r\n return cached;\r\n };\r\n\r\n const createRuleContext = (): EvalContext => {\r\n\r\n // `createFieldContext` is still what builds the context - not for its\r\n // sources, which are both empty, but for its **class**. It returns a\r\n // context whose `set` throws `SignalContextWriteError`, which is the\r\n // error the error policy must re-throw rather than swallow. A hand-built\r\n // `EvalContext` would silently accept an assigning expression.\r\n const context = createFieldContext({}, {}, options);\r\n\r\n // The resolver's parameter is deliberately unannotated. `EvalLookup` is\r\n // `(key: unknown, …) => unknown` and a parameter position is\r\n // contravariant under `strict`, so `(key: string) => …` does not compile;\r\n // upstream writes it the same way for the same reason.\r\n //\r\n // The narrowing decides which keys reach the memo, and it matches\r\n // upstream's `resolve` for both kinds an expression can produce. A string\r\n // passes as is. A number - `this[42]` is how a walk hands a lookup one,\r\n // since `this` is the context and a computed key arrives raw - is spelled\r\n // as the string JavaScript's own property access coerces it to, so\r\n // `this[42]` and `this[\"42\"]` share one memo entry and resolve as upstream\r\n // does. Passed raw it would make a second entry under the number, and\r\n // under `caseInsensitive` throw from `toLowerCase`. Anything else - a\r\n // symbol - resolves `undefined`, where upstream would find a symbol-keyed\r\n // own property (`docs/backlog.md` `BL-D6`).\r\n //\r\n // A value that is itself a signal is called, as upstream's lookup does\r\n // (`isSignal(value) ? value() : value`), so `{ ready: signal(false) }`\r\n // resolves `ready` to `false` rather than to a truthy function. The call\r\n // happens in the rule's own derivation, so the rule tracks the inner\r\n // signal as well as the key's computed (`docs/backlog.md` `BL-D4`).\r\n context.lookups.push((key) => {\r\n const name = typeof key === 'string' ? key : typeof key === 'number' ? String(key) : undefined;\r\n const value = name === undefined ? undefined : keySignal(name)();\r\n return isSignal(value) ? value() : value;\r\n });\r\n\r\n return context;\r\n };\r\n\r\n return { keySignal, createRuleContext };\r\n};\r\n","import { createMetadataKey } from '@angular/forms/signals';\r\n\r\n/**\r\n * The metadata key `evalText` writes through and a consumer reads back\r\n * (plan S 1.2.8, S 3.5).\r\n *\r\n * `text` has no dedicated primitive in `@angular/forms/signals` the way\r\n * `hidden` and `disabled` do, so it is `metadata(path, TEXT, logic)` against a\r\n * key created once - which is Angular's own mechanism rather than a second one\r\n * beside it, per the `/signals` reviewer checklist's item 4.\r\n *\r\n * **Created once, at module scope, deliberately.** `createMetadataKey` mints a\r\n * fresh key per call, and the write and the read-back have to name the same\r\n * object: a key re-created per access would let `metadata(p.x, TEXT, …)` write\r\n * under one identity and `f.x().metadata(TEXT)` read under another, and the\r\n * read would be `undefined` with nothing to show why. `text-key.spec.ts`\r\n * asserts the two halves of that - `createMetadataKey` is per-call, and this\r\n * is one of its results rather than the function itself.\r\n */\r\nexport const TEXT = createMetadataKey<string>();\r\n","import { WritableSignal } from '@angular/core';\r\nimport { type PathKind, type SchemaPath, type SchemaPathRules, disabled, hidden, metadata } from '@angular/forms/signals';\r\nimport { type EvalOptions, compile, defaultParserOptions, parse } from '@zvenigora/ng-eval-core';\r\nimport { type ExpressionErrorPolicy, applyErrorPolicy, toText, toVisible } from '@zvenigora/ng-eval-forms';\r\nimport { evaluateRule } from './evaluate-rule';\r\nimport { guardIdentifiers } from './guard-identifiers';\r\nimport { createModelSource } from './model-source';\r\nimport { TEXT } from './text-key';\r\n\r\n/**\r\n * What `createExpressionRules` takes, and what a single registration may\r\n * override (plan S 5).\r\n */\r\nexport interface ExpressionRuleOptions {\r\n\r\n /**\r\n * Passed to the context and to the walk. `caseInsensitive` has to reach\r\n * both: on the context it corrects identifier keys, and on the walk it\r\n * corrects *property* names, which the member visitor reads off the\r\n * state's options.\r\n *\r\n * **A per-registration value reaches exactly one of the three places it\r\n * has to: the walk** (plan S 3.5.3). The other two read the **factory's**\r\n * options, which are fixed when `createExpressionRules` is called: the memo\r\n * is built once, there; the context is minted per registration by\r\n * `createRuleContext()` but always from that same fixed setting, so a\r\n * registration cannot move it. So overriding this per registration corrects\r\n * *property* names and leaves *identifier* keys on the factory's setting,\r\n * and one expression then obeys two casing rules. Set `caseInsensitive` on\r\n * the **factory** unless that is the behaviour you want.\r\n *\r\n * Revision 19 item 2 corrects \"both are made once, at\r\n * `createExpressionRules` time\", which was wrong about the context and is\r\n * the claim the README states correctly.\r\n */\r\n eval?: EvalOptions;\r\n\r\n /**\r\n * What a field property does when its expression throws at runtime.\r\n * Defaults to `'undefined'` here, the opposite of `eval-signals`' own\r\n * default - see `ExpressionErrorPolicy` for why.\r\n */\r\n onError?: ExpressionErrorPolicy;\r\n}\r\n\r\n/**\r\n * The three registrars one factory returns, one per Angular rule.\r\n *\r\n * **One call per Angular primitive, never an aggregate** (plan S 3.5.1). A\r\n * single `evalRules(p.x, { visible, text, disabled })` would register three\r\n * different Angular rules behind one name, hiding which one each property\r\n * maps to - which is the `/signals` reviewer checklist's item 4 expressed as\r\n * an API.\r\n *\r\n * Named after the **property**, not after Angular's rule: `evalVisible`\r\n * registers `hidden`, inverted once inside the registrar rather than in every\r\n * consumer's expression, so the same expression string means the same thing\r\n * at this entry point and at `/reactive`.\r\n */\r\nexport interface ExpressionRules {\r\n\r\n /** Registers Angular's `hidden`, inverted (plan S 3.5.1). */\r\n evalVisible: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions\r\n ) => void;\r\n\r\n /** Registers `metadata(path, TEXT, …)` (plan S 1.2.8, S 3.5). */\r\n evalText: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions\r\n ) => void;\r\n\r\n /**\r\n * Registers Angular's `disabled`.\r\n *\r\n * The `reason` is authored as a static string and is never\r\n * expression-derived (plan S 3.5.2). Angular's `when` returns\r\n * `boolean | string` and a truthy string is *both* \"disabled\" and \"the\r\n * reason\", so a rule yielding `'false'` would otherwise disable the field\r\n * with the reason `\"false\"` - the `toVisible` truthiness trap in a new\r\n * shape. Keeping the expression boolean and the reason static is what kills\r\n * it.\r\n */\r\n evalDisabled: <TValue, TPathKind extends PathKind = PathKind.Root>(\r\n path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,\r\n expression: string,\r\n options?: ExpressionRuleOptions & { reason?: string }\r\n ) => void;\r\n}\r\n\r\n/**\r\n * Binds one model signal and returns the three expression-driven registrars.\r\n *\r\n * **A factory rather than free functions, because a `LogicFn` cannot recover\r\n * the source** (plan S 3.2.1). `RootFieldContext` exposes the *current*\r\n * field's node plus compile-time-token accessors and no root or parent\r\n * handle, so a rule on `p.city` evaluating `country === \"US\"` has no route to\r\n * `country` from inside the `LogicFn`. The source must be closed over at\r\n * registration or it is unreachable.\r\n *\r\n * ```ts\r\n * const rules = createExpressionRules(model);\r\n * const s = schema<Model>((p) => {\r\n * required(p.email); // Angular's\r\n * rules.evalVisible(p.city, 'country === \"US\"'); // ours\r\n * });\r\n * const f = form(model, s);\r\n * ```\r\n *\r\n * **A schema *value* shared across models is the unsupported shape.** Nothing\r\n * stops two `form()` calls from one schema - Angular re-invokes the schema\r\n * body once per `form()`, so each form mints its own contexts - but the\r\n * registrars close over the **factory's** model, and the factory is bound to\r\n * one. A schema built from `createExpressionRules(modelA)` and reused for\r\n * `form(modelB, s)` re-registers every rule and every one of them still reads\r\n * model A: form B renders against form A's data, silently, with no error and\r\n * a fully functional form (Q9).\r\n *\r\n * The supported reuse shape is therefore a schema **function of the rules** -\r\n * `const makeSchema = (rules) => schema<Model>(p => …)`, called per form -\r\n * which keeps reuse while giving each form a factory bound to its own model.\r\n *\r\n * What one factory retains: **one private memo**, per factory, bounded by the\r\n * union of keys the rules mention; and, per rule per `form()`, one\r\n * `EvalContext` and one compiled callback. Nothing registers with a\r\n * `DestroyRef` and there is no `destroy()` - it all becomes garbage with the\r\n * form (S 3.6).\r\n */\r\nexport const createExpressionRules = <TModel extends object>(\r\n model: WritableSignal<TModel>,\r\n options?: ExpressionRuleOptions\r\n): ExpressionRules => {\r\n\r\n // Called **once** per factory: it is what fixes the memo's lifetime, one\r\n // per factory rather than one per rule (plan S 3.6). Calling it per\r\n // registrar instead would satisfy every behavioural criterion in this phase\r\n // while quietly making the memo per rule, which is why the count has a spec\r\n // of its own.\r\n const source = createModelSource(model, options?.eval);\r\n\r\n /**\r\n * Resolves the two levels `ExpressionRuleOptions` arrives at:\r\n * **registration wins, per key** (plan S 3.5.3). Each key is resolved\r\n * independently and neither is a deep merge, so a registration supplying\r\n * only `onError` keeps the factory's `eval` and vice versa.\r\n *\r\n * Exact for `onError`, partial for `eval.caseInsensitive` - see the note on\r\n * `ExpressionRuleOptions.eval`. The divergence is characterised in\r\n * `rules.spec.ts` rather than left to be discovered.\r\n */\r\n const resolveOptions = (rule?: ExpressionRuleOptions): ExpressionRuleOptions => ({\r\n eval: rule?.eval ?? options?.eval,\r\n onError: rule?.onError ?? options?.onError,\r\n });\r\n\r\n /**\r\n * Everything a registrar does before handing Angular a `LogicFn`, and the\r\n * shape of what it returns is the phase's one structural invariant.\r\n *\r\n * **Called once per registration**, so `parse` + `compile` and\r\n * `createRuleContext()` happen at schema-body time - which Angular re-runs\r\n * once per `form()` (Q8), giving S 3.6's count of one context and one\r\n * compiled callback per rule per form. Nothing here re-parses per\r\n * derivation; risk 8 is a compile that drifts into the returned closure, and\r\n * `rules.invocation-count.spec.ts` counts `compile` to say it has not.\r\n *\r\n * **`guardIdentifiers` runs here, between `parse` and `compile`, and one\r\n * call site is deliberate** (S 3.8, revision 18 item 1). Revision 16's rule\r\n * rejects \"the wrapper is shared\" as a substitute for a per-registrar case,\r\n * and the discriminator it states is whether the subject is a path Angular\r\n * owns. This one is not: the guard throws **before any Angular primitive is\r\n * reached**, so in the rejecting case `hidden`, `metadata` and\r\n * `addDisabledReasonRule` are never called and the three registrars have\r\n * nothing downstream that could diverge. Contrast the invocation count and\r\n * the write-error bypass, whose values arrive *through* those three\r\n * primitives - `rules.spec.ts` gives each of them a case per registrar for\r\n * exactly that reason. Anything the returned closure does stays\r\n * registrar-level; this runs before there is a closure.\r\n *\r\n * **`applyErrorPolicy` is the outermost call in the returned closure, and\r\n * that is load-bearing twice over** (S 3.5). The policy has to cover the\r\n * coercion's *input* rather than only the walk - a registrar that coerced\r\n * first would hand `toVisible` a value the policy never saw - and S 6.1.1's\r\n * invocation instrument counts this exact call. M7 measured what a guard\r\n * hoisted *above* the wrapper does to that number: ground truth 1,\r\n * instrument **0**, which reads as \"the rule did not re-run\" in every\r\n * negative case in this package. A later guard belongs **inside** the\r\n * `run` callback, never ahead of this call.\r\n */\r\n const prepare = (expression: string, ruleOptions?: ExpressionRuleOptions): (() => unknown) => {\r\n const resolved = resolveOptions(ruleOptions);\r\n const node = parse(expression, defaultParserOptions);\r\n\r\n guardIdentifiers(expression, node);\r\n\r\n const compiled = compile(node);\r\n const context = source.createRuleContext();\r\n\r\n return () =>\r\n applyErrorPolicy(() => evaluateRule(compiled, context, resolved.eval), resolved.onError);\r\n };\r\n\r\n return {\r\n\r\n // Angular's **config** overload (S 1.2.7); the deprecated one takes the\r\n // `LogicFn` positionally. `hidden`'s `when` is the only required config of\r\n // the three primitives this entry point registers.\r\n //\r\n // The `!` is S 3.5.1's inversion, and it lives here rather than in every\r\n // consumer's expression so the same rule string means the same thing at\r\n // this entry point and at `/reactive`'s `visible`.\r\n evalVisible: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n\r\n hidden(path, { when: () => !toVisible(evaluated()) });\r\n },\r\n\r\n // `text` has no dedicated primitive, so it is Angular's `metadata` against\r\n // the module-scope key of `text-key.ts` (S 1.2.8) - its own mechanism\r\n // rather than a second one beside it.\r\n evalText: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n\r\n metadata(path, TEXT, () => toText(evaluated()));\r\n },\r\n\r\n // Angular's polarity, uninverted: `/reactive` ships no `disabled`, so no\r\n // expression has to mean the same thing at two entry points, and `true`\r\n // disabling is what an author expects (S 3.5.1).\r\n //\r\n // **The reason is a static option, never the expression's return**\r\n // (S 3.5.2). Angular's `when` is a single field carrying both the\r\n // condition and the reason - it returns `boolean | string`, and a truthy\r\n // string is *both* (1.2.7) - so a registrar forwarding `evaluated()` raw\r\n // would disable a field on the string `'false'` **with the reason\r\n // `\"false\"`**. Coercing through `toVisible` first, and sourcing the reason\r\n // from the registration instead, is what kills that: the string never\r\n // comes from the expression at all.\r\n //\r\n // A *dynamic* reason stays out of scope - it reopens the trap and needs a\r\n // coercion rule of its own.\r\n evalDisabled: (path, expression, ruleOptions) => {\r\n const evaluated = prepare(expression, ruleOptions);\r\n const reason = ruleOptions?.reason;\r\n\r\n disabled(path, {\r\n when: () => {\r\n // `evaluated()` first, so `applyErrorPolicy` stays the outermost\r\n // call in this body as it is in the other two (S 3.5). `reason` is\r\n // read from a closure rather than branched on ahead of the call:\r\n // a guard hoisted above the wrapper is M7's arrangement, which\r\n // reads 0 on an instrument whose ground truth is 1.\r\n const on = toVisible(evaluated());\r\n\r\n return on && reason !== undefined ? reason : on;\r\n },\r\n });\r\n },\r\n };\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvenigora/ng-eval-forms",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Angular form field properties from ng-eval expressions",
5
5
  "repository": {
6
6
  "type": "git",