Skip to content

Commit 4f385ea

Browse files
committed
feat(formula): let an authoring surface declare the binding roots it mounts
`ExprSchemaHint.roots` — the roots a surface binds beyond the platform baseline (`SCOPE_ROOTS`), declared as plain data by the caller. A page component's `visibleWhen` binds three roots at runtime and the hint could express neither of the two shapes it needs: `scope: 'record'` refused `page.selectedProjectId != ''` — the worked example `page.zod.ts`'s own `visibleWhen` describe ends with — and prescribed `record.page`, which names nothing on any layer; `scope: 'flattened'` accepted that and accepted a bare `status == 'done'` with it, which is the shorthand the narrowing exists to catch. Declaring a root is not becoming permissive: the bare-field shorthand, an undeclared root and a typo of a declared root all stay hard errors. The key only ever adds, so a call site that does not pass it keeps its verdict and its prescription byte for byte. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6dfa3ea commit 4f385ea

3 files changed

Lines changed: 366 additions & 3 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
`ExprSchemaHint` gains `roots` — an authoring surface naming the binding roots it mounts beyond the platform baseline, so `validateExpression` can accept them without standing down on everything else (#18554).
6+
7+
A page component's `visibleWhen` binds three roots at runtime, and `ExprSchemaHint` could express neither of the two shapes it needs: `scope: 'record'` refused `page.selectedProjectId != ''` — the worked example `packages/spec/src/ui/page.zod.ts`'s own `visibleWhen` describe ends with, under a sentence naming the contract-bound roots as `record`, `current_user` and page state as `page.<var>` — and prescribed `record.page`, which names nothing on any layer; `scope: 'flattened'` accepted that example and accepted a bare `status == 'done'` with it, which is the shorthand the narrowing exists to catch. Downstream the refusal is not cosmetic: an editor that lints a page block on the `record` face disables Save for the author who wrote the platform's own documented spelling.
8+
9+
```ts
10+
validateExpression('predicate', "page.selectedProjectId != ''", {
11+
scope: 'record',
12+
roots: ['page'], // what this surface mounts beyond the baseline
13+
}); // -> ok; `status == 'done'` at the same site is still an error
14+
```
15+
16+
- **It only ever adds.** A root listed in `roots` is declared alongside `SCOPE_ROOTS`, never instead of it, so passing the key can turn a refusal into an acceptance and never the reverse — a caller adopting it cannot silently lose a check it has today, and a call site that does not pass it gets the verdict and the prescription it got before, byte for byte.
17+
- **Declaring a root is not becoming permissive.** The bare-field shorthand, an undeclared root, and a typo of a declared root are all still hard errors at a surface that declares `page`. Trading a false refusal for a silent acceptance is the worse of the two directions, so the surface says *which* roots it binds rather than asking the validator to stop checking.
18+
- **A mistyped root is sent to the root, not to `record.<typo>`.** When a surface has declared its roots, a namespace reference within edit distance of one of them (`pge.selectedProjectId`) is named as an unbound root and pointed at `page`. Every other shape — a bare value reference, a known field used as a JSON namespace, any site with no declared roots — keeps the existing `record.<name>` prescription, which is the right fix for the case it was written for.
19+
- **`introspectScope` advertises what the validator accepts.** Declared roots join the roots it hands an author, from the same declaration, so a root that is accepted is never one an author has no way to discover.
20+
- **Not a closed-set mechanism.** A surface that must *refuse* a baseline root it never mounts still says so with `collectCelRootIdentifiers`, which reads the AST and is independent of this key. The two directions stay two mechanisms.
21+
22+
Clause-②: yes (widening)
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `ExprSchemaHint.roots` — an authoring surface naming the binding roots it
5+
* mounts beyond the platform baseline, so the validator can accept them
6+
* WITHOUT standing down on everything else.
7+
*
8+
* ## The defect this closes
9+
*
10+
* A page component's `visibleWhen` binds three roots at runtime. Before this
11+
* key the hint could express two shapes and neither was that surface:
12+
*
13+
* - `scope: 'record'` refused `page.selectedProjectId != ''` — the example
14+
* `packages/spec/src/ui/page.zod.ts`'s own `visibleWhen`
15+
* describe ends with, under a sentence naming the
16+
* contract-bound roots as `record`, `current_user` and
17+
* 「page state as `page.<var>`」 — and prescribed
18+
* `record.page`, which names nothing on any layer.
19+
* Downstream that refusal disables Save in the designer.
20+
* - `scope: 'flattened'` accepted it, and accepted a bare `status` with it,
21+
* which is the shorthand the narrowing exists to catch.
22+
*
23+
* ⛔ The repair is NOT "make the validator permissive at that surface": trading
24+
* a false refusal for a silent acceptance is the worse of the two directions
25+
* here. A surface declares WHICH roots it binds, and every other name keeps the
26+
* verdict it had.
27+
*
28+
* ## What this file pins, and what it deliberately does not
29+
*
30+
* The assertions are about the NAMED SUBJECT of each verdict — which reference
31+
* is judged, and which spelling is prescribed — never the sentence around it.
32+
* `ExprValidationError` carries no code or status, so the prescribed spelling
33+
* IS the machine-readable part of the contract for the two rows where a
34+
* prescription is the defect; the rest of the wording is free to change.
35+
*/
36+
37+
import { describe, it, expect } from 'vitest';
38+
39+
import { SCOPE_ROOTS } from './cel-engine';
40+
import { introspectScope, validateExpression } from './validate';
41+
42+
/**
43+
* The spec's own worked example for `PageComponent.visibleWhen`, copied from
44+
* the `.describe()` in `packages/spec/src/ui/page.zod.ts` rather than invented
45+
* here. It is the sharpest form of the defect: the one spelling the platform
46+
* publishes for this key was the one the `record` scope refused.
47+
*/
48+
const SPEC_PAGE_EXAMPLE = "page.selectedProjectId != ''";
49+
50+
/** The bare-field shorthand a page block's `visibleWhen` must keep refusing. */
51+
const BARE_FIELD = "status == 'done'";
52+
53+
/** What the page surface mounts beyond the platform baseline. */
54+
const PAGE_SURFACE_ROOTS = ['page'] as const;
55+
56+
describe('ExprSchemaHint.roots — a surface declaring the roots it binds', () => {
57+
it('`page` is not in the platform baseline, and that is why the hint is needed', () => {
58+
// Read from the list, never copied: if `page` is ever added to the baseline
59+
// this row goes red and the premise below has to be re-argued.
60+
expect(SCOPE_ROOTS as readonly string[]).not.toContain('page');
61+
expect(SCOPE_ROOTS as readonly string[]).toContain('current_user');
62+
});
63+
64+
describe("the card's repro — the spec's own `page.var` example", () => {
65+
it('is refused under `record` scope with no declared roots (the defect)', () => {
66+
const r = validateExpression('predicate', SPEC_PAGE_EXAMPLE, { scope: 'record' });
67+
expect(r.ok).toBe(false);
68+
// The named subject is `page`, and the prescription is the meaningless one.
69+
expect(r.errors[0].message).toContain('`page`');
70+
expect(r.errors[0].message).toContain('record.page');
71+
});
72+
73+
it('resolves once the surface declares `page` as one of its roots', () => {
74+
const r = validateExpression('predicate', SPEC_PAGE_EXAMPLE, {
75+
scope: 'record',
76+
roots: PAGE_SURFACE_ROOTS,
77+
});
78+
expect(r.ok).toBe(true);
79+
expect(r.errors).toEqual([]);
80+
expect(r.warnings).toEqual([]);
81+
});
82+
});
83+
84+
describe('declaring a root does NOT make the surface permissive', () => {
85+
it('still refuses the bare-field shorthand, with the `record.` prescription', () => {
86+
const r = validateExpression('predicate', BARE_FIELD, {
87+
scope: 'record',
88+
roots: PAGE_SURFACE_ROOTS,
89+
});
90+
expect(r.ok).toBe(false);
91+
expect(r.errors[0].message).toContain('`status`');
92+
expect(r.errors[0].message).toContain('record.status');
93+
});
94+
95+
it('still refuses a root the surface did NOT declare', () => {
96+
const r = validateExpression('predicate', "wizard.step == 2", {
97+
scope: 'record',
98+
roots: PAGE_SURFACE_ROOTS,
99+
});
100+
expect(r.ok).toBe(false);
101+
expect(r.errors[0].message).toContain('`wizard`');
102+
});
103+
104+
it('judges the declared root and the bare field in one source, refusing on the field', () => {
105+
const r = validateExpression('predicate', `${SPEC_PAGE_EXAMPLE} && ${BARE_FIELD}`, {
106+
scope: 'record',
107+
roots: PAGE_SURFACE_ROOTS,
108+
});
109+
expect(r.ok).toBe(false);
110+
expect(r.errors[0].message).toContain('`status`');
111+
});
112+
});
113+
114+
describe('the refusal names the roots the surface does bind', () => {
115+
it('sends a typo of a declared root to that root, not to `record.<typo>`', () => {
116+
const r = validateExpression('predicate', "pge.selectedProjectId != ''", {
117+
scope: 'record',
118+
roots: PAGE_SURFACE_ROOTS,
119+
});
120+
expect(r.ok).toBe(false);
121+
const [{ message }] = r.errors;
122+
expect(message).toContain('`pge`');
123+
expect(message).toContain('`page`');
124+
// ⛔ The prescription the card calls meaningless must not be the one an
125+
// author is handed for a mistyped ROOT.
126+
expect(message).not.toContain('record.pge');
127+
});
128+
129+
it('leaves a bare VALUE reference on the generic message even when roots are declared', () => {
130+
// `pge` here is not written as a namespace, so nothing says it is a
131+
// mistyped root rather than a mistyped field.
132+
const r = validateExpression('predicate', "pge == 'x'", {
133+
scope: 'record',
134+
roots: PAGE_SURFACE_ROOTS,
135+
});
136+
expect(r.ok).toBe(false);
137+
expect(r.errors[0].message).toContain('record.pge');
138+
});
139+
140+
it('keeps `record.<field>` for a known field used as a namespace (a JSON member)', () => {
141+
const r = validateExpression('predicate', "address.city == 'SF'", {
142+
scope: 'record',
143+
roots: PAGE_SURFACE_ROOTS,
144+
fields: ['address'],
145+
});
146+
expect(r.ok).toBe(false);
147+
expect(r.errors[0].message).toContain('record.address');
148+
});
149+
});
150+
151+
describe('every existing call site is unmoved', () => {
152+
it.each([
153+
['no hint at all', undefined],
154+
['`roots` absent', { scope: 'record' as const }],
155+
['`roots` empty', { scope: 'record' as const, roots: [] }],
156+
])('%s → the pre-existing verdict and prescription', (_label, schema) => {
157+
const r = validateExpression('predicate', SPEC_PAGE_EXAMPLE, schema);
158+
if (schema === undefined) {
159+
// No `scope` ⇒ the bare-ref check does not run at all; unchanged.
160+
expect(r.ok).toBe(true);
161+
return;
162+
}
163+
expect(r.ok).toBe(false);
164+
expect(r.errors[0].message).toContain('record.page');
165+
});
166+
});
167+
168+
describe('the flattened face', () => {
169+
it('does not report a declared root as a typo of a near-miss field', () => {
170+
// Without the declaration this warns 「did you mean `pages`?」 — advice on
171+
// a root the surface really does bind.
172+
const r = validateExpression('predicate', SPEC_PAGE_EXAMPLE, {
173+
scope: 'flattened',
174+
fields: ['pages', 'status'],
175+
objectName: 'project',
176+
roots: PAGE_SURFACE_ROOTS,
177+
});
178+
expect(r.ok).toBe(true);
179+
expect(r.warnings).toEqual([]);
180+
});
181+
182+
it('still warns for that same near-miss when the root is NOT declared', () => {
183+
const r = validateExpression('predicate', SPEC_PAGE_EXAMPLE, {
184+
scope: 'flattened',
185+
fields: ['pages', 'status'],
186+
objectName: 'project',
187+
});
188+
expect(r.warnings).toHaveLength(1);
189+
expect(r.warnings[0].message).toContain('`pages`');
190+
});
191+
});
192+
193+
describe('introspection advertises what the validator accepts', () => {
194+
it('adds the declared roots to the authoring vocabulary', () => {
195+
const { roots } = introspectScope('predicate', { scope: 'record', roots: PAGE_SURFACE_ROOTS });
196+
expect(roots).toContain('page');
197+
expect(roots).toContain('record');
198+
});
199+
200+
it('is unchanged when no roots are declared', () => {
201+
expect(introspectScope('predicate').roots).toEqual(
202+
introspectScope('predicate', { scope: 'record' }).roots,
203+
);
204+
expect(introspectScope('predicate').roots).not.toContain('page');
205+
});
206+
207+
it('does not duplicate a root that is already advertised', () => {
208+
const { roots } = introspectScope('predicate', { roots: ['record', 'page'] });
209+
expect(roots.filter((r) => r === 'record')).toHaveLength(1);
210+
});
211+
});
212+
});

0 commit comments

Comments
 (0)