Skip to content

Commit ea32ec7

Browse files
os-zhuangclaude
andauthored
feat(formula,lint): advisory type-soundness warnings for expressions (#1928 tier 4) (#3178)
* feat(formula,lint): advisory type-soundness warnings for expressions (#1928 tier 4) A `Field.formula` or record-scoped predicate that uses a text or boolean field with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults the runtime overload and silently evaluates to null (e.g. `record.title * 2`, `record.is_active + 1`). The build now surfaces this as a NON-blocking warning naming the offending field. Honours the ADR-0032 design law — flag only what the runtime would also fail: number/currency/percent/date/datetime fields are declared `dyn`, so the cases the runtime rescues never warn (`amount / 100` via registerOperator, `due == today()` and numeric-string/ISO-date values via the string-hydration retry, numeric-coded select options). Equality (`==`/`!=`) is excluded — a heterogeneous equality is runtime-safe. - formula: new `firstTypeMismatch` + optional `fieldTypes` hint on `validateExpression`; a narrow spec-type -> CEL-type map (only free-text -> string, boolean/toggle -> bool; everything else dyn). - lint: `validateStackExpressions` threads each object's field types into every record-scoped site (formula fields, validations, action/hook/sharing predicates). Warnings are advisory in build/validate, fatal only under --strict. Tests: formula 215 (+9), lint 230 (+3). Green on cel-js 8.0.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hnji7EEYR2mGt6pY53a8Bm * feat(formula,lint): extend tier-4 type-soundness to flattened flow conditions (#1928) `firstTypeMismatch` gains a `scope` param: in addition to record-scoped sites (`record.<field>`), it now covers bare-field flow/automation conditions (`status - 1`, `is_active + 1`). The flattened env binds each field as a top-level typed variable with `unlistedVariablesAreDyn: true`, so flow variables stay `dyn` and are never flagged; equality stays excluded. `validateExpression` runs the check in the flattened branch too, so the lint's flow-condition validation surfaces the same advisory warning. Message uses the bare `field` form in flattened scope, `record.field` in record scope. Tests: formula 219 (+4 flattened cases), lint 232 (+2 flow-condition cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hnji7EEYR2mGt6pY53a8Bm --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04ecd4e commit ea32ec7

6 files changed

Lines changed: 501 additions & 3 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(formula,lint): advisory type-soundness warnings for formula/predicate expressions (#1928 tier 4)
7+
8+
Closes the last open guardrail from #1928. A `Field.formula` or record-scoped
9+
predicate that uses a **text or boolean field with an arithmetic (`+ - * / %`)
10+
or ordering (`< > <= >=`) operator against a number** faults the runtime
11+
overload and silently evaluates to `null` (e.g. `record.title * 2`,
12+
`record.is_active + 1`). The build now surfaces this as a **non-blocking
13+
warning** with the offending field and a corrective message.
14+
15+
Honours the ADR-0032 design law — the checker only flags what the runtime
16+
would also fail:
17+
18+
- Number / currency / percent / date / datetime fields are declared `dyn`, so
19+
the cases the runtime rescues never warn — `record.amount / 100` (the #1930
20+
`registerOperator` fix), `record.due == today()` and numeric-string / ISO-date
21+
values (the string-hydration retry), and numeric-coded `select` option values.
22+
- Equality (`==` / `!=`) is excluded: a heterogeneous equality is runtime-safe
23+
(evaluates to `false`), never a fault.
24+
25+
New `firstTypeMismatch(source, fieldCelTypes, scope)` export in
26+
`@objectstack/formula` (and an optional `fieldTypes` hint on
27+
`validateExpression`); `@objectstack/lint`'s `validateStackExpressions` threads
28+
each object's field types into every checked site:
29+
30+
- **record-scoped** sites (`record.<field>`) — formula fields, validation rules,
31+
action / hook / sharing predicates;
32+
- **flattened** flow / automation conditions (bare `field`) — where flow
33+
variables stay `dyn` and are never flagged, and equality stays runtime-safe.
34+
35+
Warnings are advisory in `objectstack build` / `validate` (fatal only under
36+
`--strict`), matching the tier-3 channel.

packages/formula/src/cel-engine.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,164 @@ export function detectBareReference(source: string): string | null {
167167
return firstUndeclaredReference(source);
168168
}
169169

170+
/**
171+
* The CEL type a field is declared as for the Tier-4 type-soundness check
172+
* (#1928). Deliberately coarse: only genuinely-scalar, non-numeric-intent
173+
* fields are pinned to a concrete type; everything the runtime rescues stays
174+
* `dyn` and can therefore never fault. See {@link firstTypeMismatch}.
175+
*/
176+
export type FieldCelType = 'string' | 'bool' | 'dyn';
177+
178+
/**
179+
* A `no such overload` fault for an ARITHMETIC (`+ - * / %`) or ORDERING
180+
* (`< > <= >=`) operator, with the two operand types captured. Equality
181+
* (`==` / `!=`) is intentionally excluded: cel-js's checker faults on a
182+
* heterogeneous equality (`string == int`) but the runtime evaluates it
183+
* cleanly to `false` — so a fault there is NOT a runtime failure and must not
184+
* warn. Linear (no nested quantifiers) — no ReDoS. Operand types are `[\w.]+`
185+
* (e.g. `string`, `int`, `google.protobuf.Timestamp`); the operator token is
186+
* punctuation, so the two never overlap.
187+
*/
188+
const UNSOUND_OVERLOAD_RE = /no such overload:\s*([\w.]+)\s*(<=|>=|<|>|\+|-|\*|\/|%)\s*([\w.]+)/;
189+
190+
/**
191+
* A typed environment for the soundness check. Each field carries a concrete
192+
* CEL type (`string`/`bool`) or `dyn`, so cel-js's checker faults an
193+
* arithmetic/ordering operator applied across incompatible types. The `scope`
194+
* mirrors how the authoring site binds fields:
195+
* - `'record'` → `record.<field>` member access, via a typed struct on the
196+
* `record`/`previous`/`input` namespaces (formula fields,
197+
* validations, action/hook/sharing predicates).
198+
* - `'flattened'` → bare `<field>` top-level variables (flow / automation
199+
* conditions). Unlisted identifiers stay `dyn`
200+
* (`unlistedVariablesAreDyn: true`) so a flow variable never
201+
* faults — only a typed field misused does.
202+
* Built per call — cheap, and only used at build time.
203+
*/
204+
function buildTypedEnv(
205+
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
206+
scope: 'record' | 'flattened',
207+
): Environment {
208+
if (scope === 'flattened') {
209+
const env = new Environment({
210+
unlistedVariablesAreDyn: true,
211+
enableOptionalTypes: true,
212+
limits: DEFAULT_LIMITS,
213+
});
214+
registerStdLib(env, () => new Date(0));
215+
for (const root of SCOPE_ROOTS) {
216+
try { env.registerVariable(root, 'map'); } catch { /* duplicate — ignore */ }
217+
}
218+
// Fields are bound bare at top level; a name that collides with a root
219+
// (unlikely) is skipped by the duplicate guard.
220+
for (const [name, t] of Object.entries(fieldCelTypes)) {
221+
try { env.registerVariable(name, t); } catch { /* duplicate / reserved — ignore */ }
222+
}
223+
return env;
224+
}
225+
const env = new Environment({
226+
unlistedVariablesAreDyn: false,
227+
enableOptionalTypes: true,
228+
limits: DEFAULT_LIMITS,
229+
});
230+
registerStdLib(env, () => new Date(0));
231+
const fields: Record<string, string> = {};
232+
for (const [name, t] of Object.entries(fieldCelTypes)) fields[name] = t;
233+
try { env.registerType('OsRecordScope', { fields }); } catch { /* invalid field name — ignore */ }
234+
// The record namespaces carry the typed struct; every other root stays a
235+
// `map` (dyn members) so a reference through it never faults.
236+
for (const root of ['record', 'previous', 'input']) {
237+
try { env.registerVariable(root, 'OsRecordScope'); } catch { /* duplicate — ignore */ }
238+
}
239+
for (const root of SCOPE_ROOTS) {
240+
try { env.registerVariable(root, 'map'); } catch { /* already typed above / duplicate — ignore */ }
241+
}
242+
return env;
243+
}
244+
245+
/**
246+
* The first field reference in `source` whose declared CEL type matches
247+
* `celType` — best-effort attribution of an overload fault to the offending
248+
* field. In `'record'` scope it looks for `record.<field>` (or `previous.`/
249+
* `input.`); in `'flattened'` scope for a bare `<field>` not preceded by a dot.
250+
* Returns `null` if none is found.
251+
*/
252+
function offendingField(
253+
source: string,
254+
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
255+
celType: FieldCelType,
256+
scope: 'record' | 'flattened',
257+
): string | null {
258+
for (const [name, t] of Object.entries(fieldCelTypes)) {
259+
if (t !== celType) continue;
260+
// Word-bounded so `amount` does not match `amount_total`; in flattened
261+
// scope the leading lookbehind excludes a member ref like `previous.amount`.
262+
const re = scope === 'flattened'
263+
? new RegExp(`(?<![\\w$.])${name}(?![\\w$])`)
264+
: new RegExp(`(?:record|previous|input)\\.${name}(?![\\w$])`);
265+
if (re.test(source)) return name;
266+
}
267+
return null;
268+
}
269+
270+
/**
271+
* Tier-4 type-soundness (#1928): detect a `record`-scoped expression that
272+
* type-checks structurally but faults a runtime operator overload because a
273+
* text (`string`) or boolean (`bool`) field is used with an arithmetic or
274+
* ordering operator against a number. Such an expression evaluates to `null`
275+
* at runtime (unless the text value happens to be numeric), so it is surfaced
276+
* as a NON-blocking warning.
277+
*
278+
* Soundness (the ADR-0032 design law — never flag what the runtime tolerates):
279+
* - Number / currency / percent / date / datetime fields are declared `dyn`,
280+
* because the runtime rescues every mixed case for them — `registerOperator`
281+
* for `double`×`int` arithmetic and the string-hydration retry for
282+
* numeric-string / ISO-date values — so they can never fault here.
283+
* - Equality (`==` / `!=`) is excluded ({@link UNSOUND_OVERLOAD_RE}): a
284+
* heterogeneous equality is runtime-safe.
285+
*
286+
* Returns the operand types, the faulting operator, the concrete operand CEL
287+
* type, and (best-effort) the offending field — or `null` when type-sound.
288+
*
289+
* `scope` selects how fields are bound: `'record'` (default) for
290+
* `record.<field>` sites; `'flattened'` for bare-field flow/automation
291+
* conditions.
292+
*/
293+
export function firstTypeMismatch(
294+
source: string,
295+
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
296+
scope: 'record' | 'flattened' = 'record',
297+
): { operator: string; operands: string; celType: FieldCelType; field: string | null } | null {
298+
if (typeof source !== 'string' || !source.trim()) return null;
299+
// An all-`dyn` record can never fault an overload — skip the parse entirely.
300+
if (!Object.values(fieldCelTypes).some((t) => t === 'string' || t === 'bool')) return null;
301+
try {
302+
const env = buildTypedEnv(fieldCelTypes, scope);
303+
const result = env.parse(source).check?.() as
304+
| { valid?: boolean; error?: { message?: string } }
305+
| undefined;
306+
if (!result || result.valid !== false) return null;
307+
const m = UNSOUND_OVERLOAD_RE.exec(result.error?.message ?? '');
308+
if (!m) return null;
309+
const operator = m[2];
310+
const celType: FieldCelType | null =
311+
m[1] === 'string' || m[1] === 'bool' ? (m[1] as FieldCelType)
312+
: m[3] === 'string' || m[3] === 'bool' ? (m[3] as FieldCelType)
313+
: null;
314+
if (!celType) return null;
315+
return {
316+
operator,
317+
operands: `${m[1]} ${operator} ${m[3]}`,
318+
celType,
319+
field: offendingField(source, fieldCelTypes, celType, scope),
320+
};
321+
} catch {
322+
// A parse/other fault is the syntax checker's job (celEngine.compile); this
323+
// helper only reports a clean type-soundness verdict.
324+
return null;
325+
}
326+
}
327+
170328
/** Coerce cel-js's BigInt-flavored return into spec-friendly JS values. */
171329
function coerce(value: unknown): unknown {
172330
if (typeof value === 'bigint') {

packages/formula/src/validate.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,119 @@ describe('validateExpression (ADR-0032)', () => {
153153
});
154154
});
155155

156+
// #1928 tier 4 — a text/boolean field used with an arithmetic or ordering
157+
// operator against a number faults at runtime (silent null). With per-field
158+
// types the validator surfaces this as a NON-blocking warning, and — the
159+
// design law — never flags a case the runtime tolerates (number/date fields,
160+
// equality, string concat, null-guards).
161+
describe('type-soundness warnings (#1928 tier 4)', () => {
162+
const schema = {
163+
objectName: 'crm_opportunity',
164+
fields: ['name', 'amount', 'is_active', 'due', 'priority', 'title'] as const,
165+
fieldTypes: {
166+
name: 'text', title: 'textarea', amount: 'currency',
167+
is_active: 'boolean', due: 'date', priority: 'select',
168+
},
169+
scope: 'record',
170+
} as const;
171+
172+
it('warns (does not error) on a text field used in arithmetic against a number', () => {
173+
const r = validateExpression('value', 'record.name * 2', schema);
174+
expect(r.ok).toBe(true);
175+
expect(r.errors).toHaveLength(0);
176+
expect(r.warnings).toHaveLength(1);
177+
expect(r.warnings[0].message).toMatch(/type mismatch/i);
178+
expect(r.warnings[0].message).toMatch(/record\.name/);
179+
expect(r.warnings[0].message).toMatch(/evaluates to null/);
180+
});
181+
182+
it('warns on a text field ordered against a number', () => {
183+
const r = validateExpression('predicate', 'record.title >= 5', schema);
184+
expect(r.ok).toBe(true);
185+
expect(r.warnings).toHaveLength(1);
186+
expect(r.warnings[0].message).toMatch(/record\.title/);
187+
});
188+
189+
it('warns on a boolean field used in arithmetic (always faults at runtime)', () => {
190+
const r = validateExpression('value', 'record.is_active + 1', schema);
191+
expect(r.ok).toBe(true);
192+
expect(r.warnings).toHaveLength(1);
193+
expect(r.warnings[0].message).toMatch(/boolean/i);
194+
expect(r.warnings[0].message).toMatch(/record\.is_active/);
195+
});
196+
197+
it('does NOT warn on number/currency arithmetic with an int literal (#1930 runtime fix)', () => {
198+
// currency → dyn, so `amount / 100`, `amount * 2 - 50` never fault.
199+
expect(validateExpression('value', 'record.amount / 100', schema).warnings).toHaveLength(0);
200+
expect(validateExpression('value', 'record.amount * 2 - 50', schema).warnings).toHaveLength(0);
201+
});
202+
203+
it('does NOT warn on a date field compared to today()/daysFromNow()', () => {
204+
expect(validateExpression('predicate', 'record.due <= daysFromNow(30)', schema).warnings).toHaveLength(0);
205+
expect(validateExpression('predicate', 'record.due == today()', schema).warnings).toHaveLength(0);
206+
});
207+
208+
it('does NOT warn on a select field ordered against a number (option values may be numeric codes)', () => {
209+
// select → dyn, so `priority >= 3` (a numeric-coded picklist) is not flagged.
210+
expect(validateExpression('predicate', 'record.priority >= 3', schema).warnings).toHaveLength(0);
211+
});
212+
213+
it('does NOT warn on heterogeneous equality (runtime-safe, returns false)', () => {
214+
expect(validateExpression('predicate', 'record.name == 5', schema).warnings).toHaveLength(0);
215+
expect(validateExpression('predicate', 'record.name != 5', schema).warnings).toHaveLength(0);
216+
});
217+
218+
it('does NOT warn on string concatenation or a null-guard', () => {
219+
expect(validateExpression('value', 'record.name + record.title', schema).warnings).toHaveLength(0);
220+
expect(validateExpression('predicate', 'record.amount != null && record.amount > 0', schema).warnings).toHaveLength(0);
221+
});
222+
223+
it('does not run without field types', () => {
224+
// No fieldTypes → nothing to check.
225+
expect(validateExpression('value', 'record.name * 2', { objectName: 'crm_opportunity', fields: schema.fields, scope: 'record' }).warnings).toHaveLength(0);
226+
});
227+
});
228+
229+
// #1928 tier 4 (flattened) — the same soundness check for bare-field flow /
230+
// automation conditions. Fields are bound bare (`status - 1`); flow variables
231+
// stay `dyn` and are never flagged.
232+
describe('type-soundness warnings — flattened flow conditions (#1928 tier 4)', () => {
233+
const schema = {
234+
objectName: 'crm_opportunity',
235+
fields: ['stage', 'amount', 'is_active', 'title'] as const,
236+
fieldTypes: { stage: 'select', amount: 'currency', is_active: 'boolean', title: 'text' },
237+
scope: 'flattened',
238+
} as const;
239+
240+
it('warns on a bare text field used in arithmetic against a number', () => {
241+
const r = validateExpression('predicate', 'title - 1 > 0', schema);
242+
expect(r.ok).toBe(true);
243+
expect(r.warnings).toHaveLength(1);
244+
expect(r.warnings[0].message).toMatch(/type mismatch/i);
245+
// Bare form — not `record.title`.
246+
expect(r.warnings[0].message).toMatch(/`title`/);
247+
expect(r.warnings[0].message).not.toMatch(/record\.title/);
248+
});
249+
250+
it('warns on a bare boolean field used in arithmetic', () => {
251+
const r = validateExpression('predicate', 'is_active + 1 > 0', schema);
252+
expect(r.ok).toBe(true);
253+
expect(r.warnings).toHaveLength(1);
254+
expect(r.warnings[0].message).toMatch(/boolean/i);
255+
});
256+
257+
it('does NOT flag a flow variable (unlisted → dyn) or number/date fields', () => {
258+
// `expiring_count` is not a schema field → dyn → no fault.
259+
expect(validateExpression('predicate', 'expiring_count * 2 > 10', schema).warnings).toHaveLength(0);
260+
expect(validateExpression('predicate', 'amount / 100 > 5', schema).warnings).toHaveLength(0);
261+
});
262+
263+
it('does NOT flag a correct bare condition, equality, or a select comparison', () => {
264+
expect(validateExpression('predicate', 'stage == "closed_won" && amount > 1000', schema).warnings).toHaveLength(0);
265+
expect(validateExpression('predicate', 'title == "VIP"', schema).warnings).toHaveLength(0);
266+
});
267+
});
268+
156269
describe('introspection', () => {
157270
it('reports the dialect + scope for a field role', () => {
158271
expect(expectedDialect('predicate')).toBe('cel');

0 commit comments

Comments
 (0)