Skip to content

Commit 098cbb7

Browse files
claude[bot]claude
andauthored
fix(formula): refuse a non-string expression source through errors[] instead of a raw TypeError (#16048)
* fix(formula): refuse a non-string expression `source` through `errors[]` instead of throwing a raw TypeError `validateExpression(role, input)` accepts `string | { dialect?, source? }` and called `.trim()` on the envelope's `source` unguarded, so an envelope whose `source` is not a string threw `TypeError: source.trim is not a function` out of a validator whose documented contract is that it never throws. That bypassed the located-reporting contract every caller is built on: `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, node, slot and source (ADR-0032 §1d), and `@objectstack/lint`'s stack walk attributes each finding to the hook / sharing rule / action it came from. An exception thrown from inside the shared validator took both down instead, naming none of them. The guard goes at `toSource`, the entry both public functions share, once — not in each caller's own try/catch (PD #12's tolerant-consumer shape). A present, non-string `source` becomes an ordinary `ExprValidationError` on `errors[]`; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type". Absent / null / empty / whitespace sources and `{ ast }` envelopes are unchanged — only the population that previously produced no verdict at all moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(changeset): minor for the non-string expression source refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6491463 commit 098cbb7

4 files changed

Lines changed: 383 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
`validateExpression` now refuses a non-string expression `source` through `errors[]`, instead of throwing a raw `TypeError` that wiped out the caller's located reporting.
6+
7+
`validateExpression(role, input)` accepts `string | { dialect?, source? }`, and read the envelope's `source` unguarded — `if (!source.trim())`. `ExprInput` declares `source?: string`, but every production call site casts, because the value comes out of **metadata**, where a declaration is a claim about stored data and not a guarantee about it. An envelope whose `source` was present and not a string therefore threw `TypeError: source.trim is not a function` out of a validator whose own docblock promises it never throws.
8+
9+
**The defect was not "it throws" — it was that it threw the wrong kind and bypassed a whole located-reporting contract.** `AutomationEngine.validateFlowExpressions` collects located findings and throws one assembled error naming the flow, the node, the slot and the source (ADR-0032 §1d); `@objectstack/lint`'s stack walk attributes every finding to the hook, sharing rule, action or field it came from. An exception raised *inside* the shared validator skipped both, so the author was handed an internal message naming none of them. Measured before the fix, on a stack whose `hooks[].condition` was `{ source: { nested: 1 } }`: the whole `objectstack validate` run died on `source.trim is not a function`. After: one located `error` reading ``hook 'gate_hook' (lead) condition``.
10+
11+
The guard sits at `toSource`, the entry `validateExpression` and `inferExpressionType` share — **once**, not in each caller's own `try`/`catch`, which is the tolerant-consumer shape Prime Directive #12 forbids. `validateExpression` returns `ok: false` with one `ExprValidationError` naming what was found and both authorable forms; `inferExpressionType` answers `'unknown'`, its existing "cannot prove a type".
12+
13+
**No exported symbol or signature moves** — measured by diffing the built `dist/index.d.ts` before and after: 39 exported declarations on both sides, and `validateExpression`'s declaration byte-identical. What changes is behaviour at a published entry, which is why this is `minor` rather than `patch`: an input that previously produced **no verdict at all** now produces a rejection.
14+
15+
**What does not change.** Absent, `null`, empty and whitespace-only sources still read as "not authored" (`ok: true`), an `{ ast }` envelope carrying no `source` is still admitted (its admission is `ExpressionSchema`'s rule, not this entry's), and a malformed *string* still gets its own diagnostic — the brace trap, the dialect mismatch, the unknown function — never the shape refusal. No input that previously returned `ok: true` now returns `ok: false`, and none that returned `ok: false` now returns `ok: true`.
16+
17+
A caller that relied on catching the `TypeError` would need to read `result.ok` instead. None does: all nine production call sites (`@objectstack/lint` ×4, its docs gate ×2, `@objectstack/service-automation` ×3) read `.errors`/`.warnings` directly, and the one call site inside a `try` (`@objectstack/mcp`'s `validate_expression` tool) has a handler-level catch that degrades to an error result and declares its `expression` parameter `z.string()`.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* #15663 — an expression envelope whose `source` is NOT a string is refused
3+
* through `errors[]`, not by throwing a raw `TypeError` out of the validator.
4+
*
5+
* ## The acceptance test is not "it no longer throws"
6+
*
7+
* `validateExpression`'s own docblock promises it never throws, and every
8+
* consumer is built on that promise: `AutomationEngine.validateFlowExpressions`
9+
* collects LOCATED findings and throws one assembled error naming the flow, the
10+
* node, the slot and the source (ADR-0032 §1d), and `@objectstack/lint`'s stack
11+
* walk attributes each finding to the hook / sharing rule / action it came from.
12+
* A `TypeError` escaping from inside the validator bypassed all of it — the
13+
* author got `source.trim is not a function` and no location at all.
14+
*
15+
* So the pin is TWO-sided: the refusal arrives on the `errors[]` channel here,
16+
* and the caller's location survives to the author (pinned next door, in
17+
* `@objectstack/lint`'s `validate-expressions-nonstring-source.test.ts`).
18+
*
19+
* ## Why the entry, once
20+
*
21+
* `validateExpression` is the shared parse every predicate/value slot in the
22+
* platform goes through, and the value arrives from METADATA — `ExprInput`
23+
* declares `source?: string`, but every production call site casts, because a
24+
* declaration is a claim about stored data and not a guarantee about it. The
25+
* guard therefore belongs at the entry both public functions share, never in
26+
* each caller's own try/catch (Prime Directive #12's tolerant-consumer shape).
27+
*/
28+
import { describe, it, expect } from 'vitest';
29+
30+
import { validateExpression, inferExpressionType } from './validate';
31+
32+
/** The refusal sentence, spelled once. Not exported from the package — the
33+
* published surface does not move for this fix; the text travels `errors[]`. */
34+
const REFUSAL = 'an expression envelope carries its expression as a string `source`';
35+
36+
describe('#15663 — a non-string envelope `source`', () => {
37+
describe('is refused through `errors[]`, for every role', () => {
38+
const CASES: Array<[label: string, source: unknown, found: string]> = [
39+
['an object', { nested: 1 }, 'found an object'],
40+
['a nested object (the card\'s own value)', { nested: 1 }, 'found an object'],
41+
['a number', 1, 'found a number'],
42+
['an array', ['a'], 'found an array'],
43+
['a boolean', true, 'found a boolean'],
44+
];
45+
46+
for (const role of ['predicate', 'value', 'template'] as const) {
47+
for (const [label, source, found] of CASES) {
48+
it(`${role}: refuses ${label} without throwing`, () => {
49+
const r = validateExpression(role, { source } as never);
50+
expect(r.ok).toBe(false);
51+
expect(r.errors).toHaveLength(1);
52+
expect(r.errors[0].message).toContain(REFUSAL);
53+
expect(r.errors[0].message).toContain(found);
54+
// The defect's own signature must be gone from what the author reads.
55+
expect(r.errors[0].message).not.toContain('is not a function');
56+
});
57+
}
58+
}
59+
60+
it('does not throw — the property the whole located-reporting contract rests on', () => {
61+
expect(() => validateExpression('predicate', { source: { nested: 1 } } as never)).not.toThrow();
62+
expect(() => validateExpression('value', { dialect: 'cel', source: ['a'] } as never)).not.toThrow();
63+
expect(() => validateExpression('template', { source: true } as never)).not.toThrow();
64+
});
65+
66+
it('refuses regardless of the declared dialect — the shape is judged first', () => {
67+
for (const dialect of ['cel', 'template', 'cron', undefined]) {
68+
const r = validateExpression('predicate', { dialect, source: 1 } as never);
69+
expect(r.ok).toBe(false);
70+
expect(r.errors[0].message).toContain(REFUSAL);
71+
}
72+
});
73+
});
74+
75+
describe('the message is self-correcting — it names both authorable forms', () => {
76+
it('a CEL role is shown bare CEL and a `cel` envelope', () => {
77+
const m = validateExpression('predicate', { source: 1 } as never).errors[0].message;
78+
expect(m).toContain('record.rating >= 4');
79+
expect(m).toContain("dialect: 'cel'");
80+
});
81+
82+
it('the `template` role is shown a TEMPLATE, not a CEL predicate', () => {
83+
const m = validateExpression('template', { source: 1 } as never).errors[0].message;
84+
expect(m).toContain('{{ record.name }}');
85+
expect(m).toContain("dialect: 'template'");
86+
// Prescribing bare CEL to a text template is advice that cannot succeed.
87+
expect(m).not.toContain('record.rating >= 4');
88+
});
89+
90+
it('attributes to the empty string — the value that WOULD be the location is the value being refused', () => {
91+
// `source` is declared `string` on `ExprValidationError` and every caller
92+
// renders it; echoing the offending non-string would put an object into it.
93+
expect(validateExpression('predicate', { source: { nested: 1 } } as never).errors[0].source).toBe('');
94+
});
95+
});
96+
97+
describe('`inferExpressionType` — the SECOND consumer of the same entry', () => {
98+
it('answers `unknown` instead of throwing', () => {
99+
expect(() => inferExpressionType({ source: 1 } as never)).not.toThrow();
100+
expect(inferExpressionType({ source: 1 } as never)).toBe('unknown');
101+
expect(inferExpressionType({ dialect: 'cel', source: ['a'] } as never)).toBe('unknown');
102+
});
103+
104+
it('CONTROL — a real numeric expression still infers `number`', () => {
105+
expect(inferExpressionType({ dialect: 'cel', source: '1 + 1' })).toBe('number');
106+
expect(inferExpressionType('1 + 1')).toBe('number');
107+
});
108+
});
109+
110+
/**
111+
* CONTROLS. The reject set and the accept set of everything that already
112+
* RETURNED are unchanged by this fix — only the population that used to
113+
* produce no verdict at all (a crash) moved, and it moved into the reject set.
114+
* These are the shapes that must keep their existing verdict exactly.
115+
*/
116+
describe('CONTROLS — what must NOT change', () => {
117+
it('bare text still validates', () => {
118+
expect(validateExpression('predicate', 'record.rating >= 4').ok).toBe(true);
119+
expect(validateExpression('value', 'record.amount / 100').ok).toBe(true);
120+
expect(validateExpression('template', 'Hi {{ record.name }}').ok).toBe(true);
121+
});
122+
123+
it('a well-formed envelope still validates', () => {
124+
expect(validateExpression('predicate', { dialect: 'cel', source: '1 == 1' }).ok).toBe(true);
125+
expect(validateExpression('predicate', { source: '1 == 1' }).ok).toBe(true);
126+
});
127+
128+
it('"not authored" still reads as `ok: true` — absent, null, empty, whitespace', () => {
129+
expect(validateExpression('predicate', null).ok).toBe(true);
130+
expect(validateExpression('predicate', undefined).ok).toBe(true);
131+
expect(validateExpression('predicate', '').ok).toBe(true);
132+
expect(validateExpression('predicate', ' ').ok).toBe(true);
133+
expect(validateExpression('predicate', {}).ok).toBe(true);
134+
expect(validateExpression('predicate', { dialect: 'cel' }).ok).toBe(true);
135+
expect(validateExpression('predicate', { source: undefined }).ok).toBe(true);
136+
// A NULL `source` is "not authored" too, and stays so: only a PRESENT
137+
// non-string is the fourth population this card refuses.
138+
expect(validateExpression('predicate', { source: null } as never).ok).toBe(true);
139+
});
140+
141+
it('an `{ ast }` envelope carrying no `source` is still admitted', () => {
142+
// Its admission is `ExpressionSchema`'s rule, not this entry's; the guard
143+
// must not start refusing it as "a non-string source".
144+
expect(validateExpression('predicate', { ast: { kind: 'whatever' } } as never).ok).toBe(true);
145+
});
146+
147+
it('a malformed STRING still gets its own diagnostic, not the shape refusal', () => {
148+
const brace = validateExpression('predicate', '{record.rating} >= 4');
149+
expect(brace.ok).toBe(false);
150+
expect(brace.errors[0].message).not.toContain(REFUSAL);
151+
expect(brace.errors[0].message).toContain('template brace');
152+
expect(brace.errors[0].source).toBe('{record.rating} >= 4');
153+
154+
const dialect = validateExpression('template', { dialect: 'cel', source: 'record.x' });
155+
expect(dialect.ok).toBe(false);
156+
expect(dialect.errors[0].message).not.toContain(REFUSAL);
157+
});
158+
});
159+
});

packages/formula/src/validate.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,59 @@ export function expectedDialect(role: FieldRole): 'cel' | 'template' {
197197
return role === 'template' ? 'template' : 'cel';
198198
}
199199

200-
function toSource(input: ExprInput): { dialect?: string; source: string } {
200+
/** `an array` / `an object` / `a number` — never called with a string or nullish. */
201+
function describeNonStringSource(value: unknown): string {
202+
if (Array.isArray(value)) return 'an array';
203+
if (typeof value === 'object') return 'an object';
204+
return `a ${typeof value}`;
205+
}
206+
207+
/**
208+
* Normalize the three authorable spellings — bare text, an envelope, absent —
209+
* into the dialect and the source text the checks below read.
210+
*
211+
* `nonStringSource` is the fourth population, which is not authorable at all: an
212+
* envelope whose `source` is present but is NOT a string. `ExprInput` declares
213+
* `source?: string`, so this cannot arrive through a typed call — but every
214+
* production call site casts, because the value comes out of METADATA, where the
215+
* declaration is a claim and not a guarantee. Read unguarded it reached
216+
* `source.trim()` and threw a bare `TypeError: source.trim is not a function`
217+
* out of a validator whose whole contract is that it never throws: callers that
218+
* exist to collect LOCATED findings (`AutomationEngine.validateFlowExpressions`,
219+
* `@objectstack/lint`'s stack walk) were bypassed entirely, so `registerFlow`
220+
* died on an internal message naming neither the flow nor the node, and
221+
* `objectstack validate` died naming neither the hook nor the sharing rule.
222+
*
223+
* The refusal is reported HERE, once, at the entry every predicate/value slot in
224+
* the platform shares — never by each caller wrapping the call in a try/catch,
225+
* which is the tolerant-consumer shape Prime Directive #12 forbids.
226+
*
227+
* ⚠️ Only a PRESENT non-string qualifies. `null` / `undefined` / a missing
228+
* `source` still normalize to `''` and still read as "not authored" (`ok: true`),
229+
* unchanged — including the `{ ast }` envelope, which carries no `source` at all
230+
* and whose admission is `ExpressionSchema`'s rule, not this function's.
231+
*/
232+
function toSource(input: ExprInput): { dialect?: string; source: string; nonStringSource?: string } {
201233
if (input == null) return { source: '' };
202234
if (typeof input === 'string') return { source: input };
203-
return { dialect: input.dialect, source: input.source ?? '' };
235+
const raw = (input as { source?: unknown }).source;
236+
if (raw == null) return { dialect: input.dialect, source: '' };
237+
if (typeof raw !== 'string') {
238+
return { dialect: input.dialect, source: '', nonStringSource: describeNonStringSource(raw) };
239+
}
240+
return { dialect: input.dialect, source: raw };
204241
}
205242

243+
/**
244+
* The refusal text for a non-string envelope `source`, shared by the message
245+
* every role composes. Deliberately NOT exported: the published surface of
246+
* `@objectstack/formula` does not move for this fix — the refusal travels the
247+
* `errors[]` channel that already exists, so no consumer needs a new symbol to
248+
* read it.
249+
*/
250+
const NON_STRING_SOURCE_REFUSAL =
251+
'an expression envelope carries its expression as a string `source`';
252+
206253
function bracesHint(source: string): string | null {
207254
const m = SINGLE_BRACE_RE.exec(source);
208255
if (!m) return null;
@@ -549,15 +596,34 @@ function checkRoleCatalog(
549596
* Validate one expression for a given field role. Never throws — returns a
550597
* structured result. Call sites decide whether to throw (build/registration)
551598
* or report (agent tool).
599+
*
600+
* "Never throws" is the contract, and it now holds for the one input that used
601+
* to break it: an envelope whose `source` is not a string is refused through
602+
* `errors[]` like any other malformed expression, so the caller's located
603+
* reporting survives to the author. See {@link toSource}.
552604
*/
553605
export function validateExpression(
554606
role: FieldRole,
555607
input: ExprInput,
556608
schema?: ExprSchemaHint,
557609
): ExprValidationResult {
558-
const { dialect, source } = toSource(input);
610+
const { dialect, source, nonStringSource } = toSource(input);
559611
const errors: ExprValidationError[] = [];
560612
const warnings: ExprValidationError[] = [];
613+
if (nonStringSource !== undefined) {
614+
// Attributed to the empty string, deliberately: the value that would be the
615+
// location IS the value being refused, so echoing it would put a non-string
616+
// into a `source: string` slot every caller renders.
617+
errors.push({
618+
source: '',
619+
message:
620+
`invalid ${role} envelope: ${NON_STRING_SOURCE_REFUSAL} — found ${nonStringSource}. ` +
621+
`Write the expression as bare text (e.g. ${role === 'template' ? '`Hi {{ record.name }}`' : '`record.rating >= 4`'}), ` +
622+
`or as an envelope whose \`source\` is that text ` +
623+
`(e.g. \`{ dialect: '${expectedDialect(role)}', source: '…' }\`).`,
624+
});
625+
return { ok: false, errors, warnings };
626+
}
561627
if (!source.trim()) return { ok: true, errors, warnings };
562628

563629
if (role === 'template') {
@@ -735,7 +801,12 @@ function celTypeToValueType(celType: string | null): InferredValueType {
735801
* construction — see {@link inferCelType}.
736802
*/
737803
export function inferExpressionType(input: ExprInput, schema?: ExprSchemaHint): InferredValueType {
738-
const { source } = toSource(input);
804+
const { source, nonStringSource } = toSource(input);
805+
// The second consumer of the shared entry, and it crashed identically. There
806+
// is no `errors[]` here to route a refusal through, and this function is
807+
// conservative by construction: a source it cannot read is a type it cannot
808+
// prove, which is exactly what `'unknown'` already means.
809+
if (nonStringSource !== undefined) return 'unknown';
739810
if (!source.trim()) return 'unknown';
740811
return celTypeToValueType(inferCelType(source, schema?.fields));
741812
}

0 commit comments

Comments
 (0)