Skip to content

Commit 299102e

Browse files
fix(core): rate-limit ExpressionEvaluator's fault warnings to one per authored source (#6492)
* fix(core): rate-limit ExpressionEvaluator's fault warnings to one per source `evaluate()`'s two built-in fault paths logged on EVERY evaluation, and it is the hottest of the three predicate paths in this area — `SchemaRenderer` calls it for every `properties.*` value, every `props.*` value and `content`, for every node, on every render. Measured on the built evaluator at 830ed58: three identical faulting `evaluateCondition` calls produced 3 console lines where the `{ dialect: 'cel' }` envelope produced 1, and one broken `${…}` prop across a 200-row list produced 200 lines per render. Both sibling reporters already carry a one-per-source rate limit (`warnPredicateFailure` in `fieldRules.ts`, `visibilityDiagnostic.ts` in `@object-ui/react`); this reuses that shape rather than adding a third. The key is the authoring identity — `[site, source]`, never the scope — which is both the siblings' precedent and the defect itself: the 200-row flood is ONE authored source against 200 distinct scopes, so a scope-sensitive key emits all 200 lines again. The rate limit governs the built-in line only: `onFault` still fires on every fault (#6038's passback contract) and `throwOnError` still throws every time. No symbol is added to the published surface — the whole `packages/core` `.d.ts` tree is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q * chore(changeset): declare the #6444 evaluator fault-warning rate limit Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ea4cde commit 299102e

4 files changed

Lines changed: 304 additions & 11 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
`ExpressionEvaluator.evaluate` now reports a faulting `${…}` at most **once per authored
6+
source** instead of once per evaluation (objectui#6444). It is the hottest of the three
7+
predicate paths in this area — `SchemaRenderer` calls it for every `properties.*` value,
8+
every `props.*` value and `content`, for every node, on every render — so a single broken
9+
`${…}` prop in a 200-row list wrote 200 console lines per render, and 200 more on the next
10+
one. Measured on the built evaluator before the fix: three identical faulting
11+
`evaluateCondition` calls produced 3 lines where the `{ dialect: 'cel' }` envelope produced
12+
1; the 200-row list produced 200. After: 1 in every case.
13+
14+
This is the one-per-source rate limit both sibling reporters already carry
15+
(`warnPredicateFailure` in `fieldRules.ts`, `visibilityDiagnostic.ts` in `@object-ui/react`),
16+
not a third mechanism. The dedupe key is the predicate's **authoring** identity — the fault
17+
site plus the source text, never the scope it ran against — which is both the siblings'
18+
precedent and the defect itself: the 200-row flood is one authored source evaluated against
19+
200 distinct scopes, so a scope-sensitive key would emit all 200 lines again.
20+
21+
Nothing else moves. The two message texts are unchanged, a distinct broken source still gets
22+
its own line, `EvaluationOptions.onFault` still fires on every fault (objectui#6038's passback
23+
contract, so a caller doing its own warn-once bookkeeping keeps control of it), `throwOnError`
24+
still throws on every evaluation, and no symbol is added to the published surface.

‎packages/core/src/evaluator/ExpressionEvaluator.ts‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,26 +85,90 @@ export interface EvaluationOptions {
8585
onFault?: (reason: string) => void;
8686
}
8787

88+
/**
89+
* The two built-in fault sites of {@link ExpressionEvaluator.evaluate}, tagged
90+
* so they cannot silence each other through the dedupe below. They describe
91+
* different faults — one PART of a multi-part template failed to evaluate,
92+
* versus the WHOLE expression did — and each owns its message text.
93+
*/
94+
type EvaluationFaultSite = 'template-part' | 'whole-expression';
95+
96+
/**
97+
* Authored sources already reported by {@link reportEvaluationFault}, keyed on
98+
* `[site, source]`.
99+
*
100+
* ## Why a rate limit at all (objectui#6444)
101+
*
102+
* `evaluate` is the hottest of the three predicate paths in this area:
103+
* `SchemaRenderer` calls it for every `properties.*` value, every `props.*`
104+
* value and `content`, for every node, on every render — and both sites below
105+
* used to log on EVERY evaluation. Measured on the built evaluator at
106+
* `830ed5803`: three identical faulting `evaluateCondition` calls produced
107+
* **3** console lines where the `{ dialect: 'cel' }` envelope produced **1**,
108+
* and one broken `${…}` prop rendered across a 200-row list produced **200**
109+
* lines — then 200 more on the next render.
110+
*
111+
* This is the one-per-source ceiling both sibling reporters already carry, not
112+
* a third mechanism: `warnPredicateFailure` in `./fieldRules.js` (keyed on
113+
* dialect + source) and `visibilityDiagnostic.ts` in `@object-ui/react` (keyed
114+
* on type + key + source).
115+
*
116+
* ## Why the key is the source TEXT, and never the scope
117+
*
118+
* Both siblings key on the AUTHORING identity of the predicate, and neither
119+
* keys on the data it was evaluated against: "a broken predicate is
120+
* re-evaluated on every render/keystroke, and the point is one loud line, not
121+
* a scrolling wall". Here that precedent is also the defect itself — the
122+
* 200-row flood is ONE authored source evaluated against 200 DIFFERENT scopes,
123+
* so a scope-sensitive key reproduces all 200 lines and fixes nothing.
124+
*
125+
* The key is JSON-encoded, never joined with a control character — that is
126+
* what made a sibling of this file binary to grep (objectstack#5450).
127+
*
128+
* Unbounded, exactly like both siblings: entries are distinct AUTHORED
129+
* expressions, a count an app's metadata bounds; a row count does not.
130+
*/
131+
const warnedEvaluationFaults = new Set<string>();
132+
88133
/**
89134
* One fault, one report: hand the reason to the caller's {@link
90135
* EvaluationOptions.onFault} when it supplied one, otherwise fall back to this
91-
* class's historical `console.warn`.
136+
* class's historical `console.warn` — now at most once per authored source.
92137
*
93138
* Kept as a module-local function rather than repeating the ternary at each
94139
* catch, so the "supplying `onFault` suppresses the built-in line" rule is
95-
* stated once and cannot drift between the three sites that implement it.
140+
* stated once and cannot drift between the three sites that implement it. The
141+
* two message texts moved in here for the same reason: the dedupe key and the
142+
* line a reader sees are now built from the same `source`, so the stated
143+
* ceiling cannot drift from what the console actually prints.
144+
*
145+
* The rate limit governs the built-in line ONLY. `onFault` still fires on
146+
* every fault, matching the passback contract `./fieldRules.js` already
147+
* documents for the canonical engine — "independent of … the one-time-warning
148+
* dedupe: it fires on every fault, so a caller doing its own warn-once
149+
* bookkeeping keeps control of it". A caller that reports per node must not
150+
* have its faults swallowed by this module's bookkeeping.
96151
*/
97152
function reportEvaluationFault(
98153
onFault: ((reason: string) => void) | undefined,
99-
builtinMessage: string,
154+
site: EvaluationFaultSite,
155+
source: string,
100156
error: unknown,
101157
): void {
102158
const reason = error instanceof Error ? error.message : String(error);
103159
if (onFault) {
104160
onFault(reason);
105161
return;
106162
}
107-
console.warn(builtinMessage, error);
163+
const key = JSON.stringify([site, source]);
164+
if (warnedEvaluationFaults.has(key)) return;
165+
warnedEvaluationFaults.add(key);
166+
console.warn(
167+
site === 'template-part'
168+
? `Expression evaluation failed for: ${source}`
169+
: `Failed to evaluate expression: ${source}`,
170+
error,
171+
);
108172
}
109173

110174
/**
@@ -181,15 +245,15 @@ export class ExpressionEvaluator {
181245
if (throwOnError) {
182246
throw error;
183247
}
184-
reportEvaluationFault(onFault, `Expression evaluation failed for: ${expr}`, error);
248+
reportEvaluationFault(onFault, 'template-part', expr, error);
185249
return match; // Return original if evaluation fails
186250
}
187251
});
188252
} catch (error) {
189253
if (throwOnError) {
190254
throw error;
191255
}
192-
reportEvaluationFault(onFault, `Failed to evaluate expression: ${expression}`, error);
256+
reportEvaluationFault(onFault, 'whole-expression', expression, error);
193257
return defaultValue ?? expression;
194258
}
195259
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#6444 — the built-in fault warnings of `evaluate` are rate limited to
11+
* ONE line per authored source, like both sibling reporters already are.
12+
*
13+
* ## The defect, measured on the built evaluator at `830ed5803`
14+
*
15+
* `evaluateCondition('${nosuchroot.x > 1}')` three times in a row produced
16+
* **3** console lines, where the `{ dialect: 'cel' }` envelope produced **1**
17+
* for the same three calls. `evaluate` is the hottest of the three paths —
18+
* `SchemaRenderer` calls it for every `properties.*` value, every `props.*`
19+
* value and `content`, for every node, on every render — so ONE broken `${…}`
20+
* prop in a 200-row list was 200 console lines per render, and 200 more on the
21+
* next one.
22+
*
23+
* ## What these cells have to measure, and why one direction is not enough
24+
*
25+
* "Two faults from the same source log once" passes just as well if the dedupe
26+
* silenced everything; "two faults from different sources log twice" passes
27+
* just as well if it deduped nothing. Only the pair measures the GRANULARITY,
28+
* so both are pinned below — plus the cell that discriminates the granularity
29+
* actually chosen: the same source across MANY DIFFERENT SCOPES is still one
30+
* line. That third cell is the card's open point (source text alone vs source +
31+
* scope) settled as a measurement: the 200-row flood is one authored source
32+
* against 200 distinct scopes, so a scope-sensitive key emits all 200 lines
33+
* again and fixes nothing.
34+
*
35+
* Every cell also asserts that the evaluation REALLY happened and returned its
36+
* documented fail-soft value — a "only one warn" pin passes vacuously if the
37+
* evaluator stopped being called or stopped faulting.
38+
*/
39+
40+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
41+
42+
type EvaluatorModule = typeof import('../ExpressionEvaluator.js');
43+
44+
/**
45+
* The dedupe under test is MODULE state, and module state outlives a test case:
46+
* without this reset the second cell to fault on a given source reads the first
47+
* cell's entry, sees silence, and passes having checked nothing. `vi.resetModules()`
48+
* + a fresh dynamic import gives every cell its own `Set` — and the last cell in
49+
* this file proves that this reset genuinely resets, rather than assuming it.
50+
* (Cells also use distinct source texts, so the discipline does not rest on the
51+
* hook alone.)
52+
*/
53+
let mod: EvaluatorModule;
54+
let warn: ReturnType<typeof vi.spyOn>;
55+
56+
beforeEach(async () => {
57+
vi.resetModules();
58+
mod = await import('../ExpressionEvaluator.js');
59+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
60+
});
61+
62+
afterEach(() => {
63+
vi.restoreAllMocks();
64+
});
65+
66+
const SCOPE = { record: { id: 1, status: 'open' }, data: { total: 99 } };
67+
const make = (scope: Record<string, unknown> = SCOPE) => new mod.ExpressionEvaluator(scope);
68+
const lines = () => warn.mock.calls.map((c: unknown[]) => String(c[0]));
69+
70+
describe('#6444 — one authoring mistake, one loud line', () => {
71+
it('SAME source, three evaluations: ONE line — and all three evaluations really ran and really faulted', () => {
72+
const src = '${nosuchroot.dedupe_same > 1}';
73+
const e = make();
74+
75+
const verdicts = [e.evaluateCondition(src), e.evaluateCondition(src), e.evaluateCondition(src)];
76+
77+
// The fault happened every time: the documented fail-OPEN verdict, three times.
78+
expect(verdicts).toEqual([true, true, true]);
79+
// And the evaluator is still doing its job in the same run — so the single
80+
// line is a rate limit, not a stopped evaluator.
81+
expect(e.evaluate('Total: ${data.total}')).toBe('Total: 99');
82+
expect(warn).toHaveBeenCalledTimes(1);
83+
expect(lines()[0]).toBe('Failed to evaluate expression: ${nosuchroot.dedupe_same > 1}');
84+
});
85+
86+
it('DIFFERENT sources: each still gets its own line — the dedupe is not blanket silence', () => {
87+
const e = make();
88+
89+
expect(e.evaluateCondition('${nosuchroot.distinct_a > 1}')).toBe(true);
90+
expect(e.evaluateCondition('${nosuchroot.distinct_b > 1}')).toBe(true);
91+
92+
expect(warn).toHaveBeenCalledTimes(2);
93+
expect(lines()).toEqual([
94+
'Failed to evaluate expression: ${nosuchroot.distinct_a > 1}',
95+
'Failed to evaluate expression: ${nosuchroot.distinct_b > 1}',
96+
]);
97+
});
98+
99+
it('THE OPEN POINT: one source across 200 DIFFERENT scopes is ONE line — the 200-row list from the card', () => {
100+
// This is the cell that separates the two candidate keyings. Keyed on the
101+
// source TEXT (what shipped) this is 1; keyed on source + scope it is 200,
102+
// which is the defect verbatim.
103+
const src = 'Row ${record.id}: ${nosuchroot.row_total}';
104+
const rendered: string[] = [];
105+
for (let row = 0; row < 200; row++) {
106+
rendered.push(make({ record: { id: row }, data: { total: 99 } }).evaluate(src) as string);
107+
}
108+
109+
expect(warn).toHaveBeenCalledTimes(1);
110+
expect(lines()[0]).toBe('Expression evaluation failed for: nosuchroot.row_total');
111+
112+
// 200 real evaluations against 200 real scopes: the healthy half of the
113+
// template interpolates each row's OWN id, and the faulting half returns
114+
// its source verbatim (the documented fail-soft value for a template part).
115+
expect(rendered).toHaveLength(200);
116+
expect(rendered[0]).toBe('Row 0: ${nosuchroot.row_total}');
117+
expect(rendered[199]).toBe('Row 199: ${nosuchroot.row_total}');
118+
expect(new Set(rendered).size).toBe(200);
119+
});
120+
121+
it('the two fault SITES report independently and keep their own message text', () => {
122+
// `evaluate` has two built-in fault paths: one PART of a multi-part
123+
// template failing, and the WHOLE expression failing. They are different
124+
// faults, so the dedupe key is tagged with the site and neither can
125+
// silence the other. Message texts are unchanged from before #6444.
126+
const e = make();
127+
128+
expect(e.evaluate('a ${nosuchroot.site_part} b')).toBe('a ${nosuchroot.site_part} b');
129+
expect(e.evaluate('${nosuchroot.site_whole}')).toBe('${nosuchroot.site_whole}');
130+
131+
expect(lines()).toEqual([
132+
'Expression evaluation failed for: nosuchroot.site_part',
133+
'Failed to evaluate expression: ${nosuchroot.site_whole}',
134+
]);
135+
});
136+
137+
it('a HEALTHY expression never warns — the silence that makes one line mean something', () => {
138+
const e = make();
139+
expect(e.evaluate('Total: ${data.total}')).toBe('Total: 99');
140+
expect(e.evaluate('${record.status}')).toBe('open');
141+
expect(e.evaluateCondition("${record.status == 'open'}")).toBe(true);
142+
expect(e.evaluateCondition("${record.status == 'closed'}")).toBe(false);
143+
expect(warn).not.toHaveBeenCalled();
144+
});
145+
});
146+
147+
describe('#6444 — the rate limit governs the built-in line ONLY', () => {
148+
it('`onFault` still fires on EVERY fault for the same source — the passback contract is unmoved', () => {
149+
// `fieldRules.ts` documents this for the canonical engine ("independent of
150+
// … the one-time-warning dedupe: it fires on every fault, so a caller doing
151+
// its own warn-once bookkeeping keeps control of it"). A caller that reports
152+
// per node must not have its faults swallowed by THIS module's bookkeeping.
153+
const src = '${nosuchroot.passback > 1}';
154+
const reasons: string[] = [];
155+
const e = make();
156+
157+
for (let i = 0; i < 3; i++) {
158+
expect(e.evaluateCondition(src, { onFault: (r) => reasons.push(r) })).toBe(true);
159+
}
160+
161+
expect(reasons).toHaveLength(3);
162+
expect(reasons.every((r) => r.includes('nosuchroot'))).toBe(true);
163+
// Supplying `onFault` still suppresses the built-in line entirely (#6038).
164+
expect(warn).not.toHaveBeenCalled();
165+
});
166+
167+
it('`throwOnError` still throws on EVERY evaluation — the fail-closed signal is not rate limited', () => {
168+
const src = '${nosuchroot.failclosed > 1}';
169+
const e = make();
170+
171+
expect(() => e.evaluateCondition(src, { throwOnError: true })).toThrow();
172+
expect(() => e.evaluateCondition(src, { throwOnError: true })).toThrow();
173+
expect(warn).not.toHaveBeenCalled();
174+
});
175+
});
176+
177+
describe('#6444 — the module reset this file depends on', () => {
178+
it('the dedupe survives new evaluator INSTANCES, and `vi.resetModules()` genuinely clears it', async () => {
179+
// Both halves matter. The first is why the fix works at all: a 200-row list
180+
// builds a fresh evaluator per row, so a per-instance Set would dedupe
181+
// nothing. The second is why every other cell in this file is a real
182+
// measurement rather than a reading of the previous cell's leftover entry.
183+
const src = '${nosuchroot.reset_probe > 1}';
184+
const first = await import('../ExpressionEvaluator.js');
185+
186+
expect(new first.ExpressionEvaluator(SCOPE).evaluateCondition(src)).toBe(true);
187+
expect(new first.ExpressionEvaluator(SCOPE).evaluateCondition(src)).toBe(true);
188+
expect(warn).toHaveBeenCalledTimes(1); // distinct instances, one line
189+
190+
vi.resetModules();
191+
const second = await import('../ExpressionEvaluator.js');
192+
expect(second.ExpressionEvaluator).not.toBe(first.ExpressionEvaluator); // really a new module
193+
194+
expect(new second.ExpressionEvaluator(SCOPE).evaluateCondition(src)).toBe(true);
195+
expect(warn).toHaveBeenCalledTimes(2); // fresh Set → the SAME source warns again
196+
});
197+
});

‎packages/core/src/evaluator/__tests__/ExpressionEvaluator.onFault.test.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,24 @@ describe('#6038 — the seam moves no verdict and adds no evaluation', () => {
160160
expect(withProbe).toBeGreaterThan(plain);
161161
});
162162

163-
it('without onFault, nothing about the existing console output moves', () => {
164-
// The compatibility half: every caller that does not opt in must see the
165-
// exact lines it saw before. A bare-string fault stays silent (it always
166-
// was), and the two loud dialects keep their own generic lines.
163+
it('without onFault, the loud dialect still prints its own generic line', () => {
164+
// The compatibility half: every caller that does not opt in must still see
165+
// its line. A bare-string fault stays silent (it always was), and the
166+
// `${…}` dialect keeps its own generic text.
167+
//
168+
// objectui#6444 rate limited that built-in line to ONE per authored source,
169+
// module-wide, so this cell faults on a source no other cell in this file
170+
// uses. Reusing `FAULT_TEMPLATE` here would read the dedupe entry the
171+
// verdict-parity cell above already made and see silence — a green run
172+
// that measured nothing. The rate limit itself is pinned in
173+
// `ExpressionEvaluator.faultWarnDedupe.test.ts`.
174+
const OWN_TEMPLATE = '${nosuchroot.compat_6038 > 1}';
167175
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
168176
try {
169177
expect(evaluator().evaluateCondition(FAULT_BARE)).toBe(true);
170178
expect(warn).not.toHaveBeenCalled();
171179

172-
expect(evaluator().evaluateCondition(FAULT_TEMPLATE)).toBe(true);
180+
expect(evaluator().evaluateCondition(OWN_TEMPLATE)).toBe(true);
173181
expect(warn).toHaveBeenCalledTimes(1);
174182
expect(String(warn.mock.calls[0][0])).toContain('Failed to evaluate expression');
175183
} finally {

0 commit comments

Comments
 (0)