Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/formula-unknown-function-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/formula": patch
---

fix(formula): an unknown-function refusal names the function and points at the callable set (#13821)

`validateExpression` refused an unknown CEL function correctly and then handed
the author a prescription that could not succeed: "`predicate`s are bare CEL
(e.g. `record.rating >= 4`)" — advice to write bare CEL, on a source that
already is bare CEL and parses fine. An unknown-function fault is graded `type`
by the engine's own `check()`, so it fell through `bracesHint` (null, no brace)
and out to that generic dialect trailer.

This is the second leg of the repair #7073 / PR #7209 made for the `bounds`
class, for the same reason `boundsHint`'s doc-comment gives: an author who obeys
the last sentence they were given — an LLM author above all — rewrites the
dialect, learns nothing, and comes back with the same unresolvable name. The
last sentence pointed at the one thing that was already correct.

The `type` class now gets its own prescription, which **names the function that
did not resolve** and points at the callable set `introspectScope` publishes:

```
invalid CEL predicate: found no matching overload for 'dyn.nosuchmethod(string)'

> 1 | record.x.nosuchmethod('a')
^ — `nosuchmethod` is not a callable name here — a NAME fault, not a
dialect mistake, so re-spelling the expression will not fix it. The callable
names this platform advertises for authoring are the `functions` list
`introspectScope` returns (`CEL_STDLIB_FUNCTIONS`) — pick one of those, or
precompute the value in a stored field and reference that field instead.
```

The front half is unchanged: it is cel-js's own vocabulary and matches the
runtime fault exactly. Only the trailer after the dash is new. `CEL_STDLIB_FUNCTIONS`
itself is untouched, and the message names no member count — what the catalog
contains is being adjudicated separately, and a sentence asserting a size would
be falsified by that ruling.

**The did-you-mean suggestion is thresholded, and the threshold is the point.**
Against this catalog the shared `nearestName` budget answers
`nearestName('can', CEL_STDLIB_FUNCTIONS)` with `'min'` — two edits on a
three-character name, a jump from a permission verb to a numeric function. That
suggestion is worse than silence: an author who takes it writes
`min(object, verb)`. This class therefore narrows locally to at most one edit per
three characters of the longer name, keeping the case that makes suggesting
worthwhile (`isBlnk` → `isBlank`) and refusing the measured hazard (`can` → no
suggestion). Both cases are pinned. The shared `nearestName` budget is unchanged,
so field-name suggestions are unaffected.

Message-only. The refusal fires on exactly the same inputs it did before — no
rule id, severity, match set or gate behaviour changed. Faults in the `type`
class that name no unresolvable call keep the existing trailer: an operator or
ternary mismatch (`1 + 'a'`), and a real function handed arguments no overload
accepts (`upper(1, 2)`, which produces the same cel-js message shape) — calling
`upper` "not a callable name" would replace a useless sentence with a false one.
125 changes: 124 additions & 1 deletion packages/formula/src/validate.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, introspectScope, expectedDialect, inferExpressionType } from './validate';
import {
validateExpression,
introspectScope,
expectedDialect,
inferExpressionType,
nearestName,
CEL_STDLIB_FUNCTIONS,
} from './validate';
import { firstUndeclaredReference } from './cel-engine';

describe('validateExpression (ADR-0032)', () => {
Expand DownExpand Up@@ -199,6 +206,122 @@ describe('validateExpression (ADR-0032)', () => {
});
});

// #13821 — the second leg of #7073. An unknown-function refusal is graded
// `type` by the engine's own `check()`, so before this it fell through to
// `bracesHint` (null, no brace) and out to the dialect trailer: "predicates
// are bare CEL" handed to an author whose source already IS bare CEL and
// parses fine. The guard itself was and stays correct — only the sentence the
// author is told to act on was wrong.
//
// These assert the SPECIFIC prescription, not merely that an error fires; an
// error already fired before the repair. And the `bounds` control below is
// load-bearing: it proves a new class was routed rather than the shared tail
// replaced for everyone.
describe('unknown function vs dialect: the prescription follows the fault class (#13821)', () => {
const dialectTrailer = (role: 'predicate' | 'value') =>
` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`;

it.each([
{ name: 'a receiver call', source: "record.x.nosuchmethod('a')", fn: 'nosuchmethod' },
{ name: 'a bare call', source: 'totallyBogusFn(1,2)', fn: 'totallyBogusFn' },
{ name: 'a call nested in a conjunction', source: "record.a == 1 && zzzznope(record.b)", fn: 'zzzznope' },
{ name: 'a receiver-only cel-js name used bare', source: "split(record.name, ',') == ['a']", fn: 'split' },
])('names the unresolvable function and points at the callable set — $name', ({ source, fn }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors).toHaveLength(1);
const { message } = r.errors[0];
// The front half — cel-js's own vocabulary, matching the runtime fault — is kept.
expect(message).toMatch(/^invalid CEL predicate: found no matching overload for /);
// The prescription NAMES the function that did not resolve…
expect(message).toContain(`\`${fn}\` is not a callable name here`);
expect(message).toMatch(/NAME fault, not a dialect mistake/);
// …and points at the callable set, via the introspection API that publishes it.
expect(message).toContain('`introspectScope`');
expect(message).toContain('`CEL_STDLIB_FUNCTIONS`');
// ⛔ The defect itself: the dialect trailer must NOT reach this class.
expect(message).not.toContain(dialectTrailer('predicate'));
expect(message).not.toMatch(/bare CEL/);
});

it('applies to the `value` role too — one producer, all ~10 slots', () => {
const r = validateExpression('value', 'nosuchfn(1)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/^invalid CEL value:/);
expect(r.errors[0].message).toContain('`nosuchfn` is not a callable name here');
expect(r.errors[0].message).not.toContain(dialectTrailer('value'));
});

// ⛔ The message may never state how many functions the catalog holds: what
// the catalog contains is being adjudicated separately, and a sentence
// asserting a count would be falsified by that ruling without failing here.
it('refers to the callable set without asserting its size', () => {
const message = validateExpression('predicate', 'totallyBogusFn(1,2)').errors[0].message;
expect(message).not.toMatch(/\b\d+\s+(?:functions|names|entries)\b/);
});

// did-you-mean is measured dangerous, so both directions are pinned. The
// threshold is the whole safety argument; either case flipping is a
// regression, not a tuning.
describe('did-you-mean is thresholded (both measured cases pinned)', () => {
it('suggests on a real typo: `isBlnk` → `isBlank`', () => {
const message = validateExpression('predicate', 'isBlnk(record.name)').errors[0].message;
expect(message).toContain('`isBlnk` is not a callable name here');
expect(message).toContain('Did you mean `isBlank`?');
});

it('stays SILENT on a distant match: `can` must never be answered with `min`', () => {
// `nearestName('can', CEL_STDLIB_FUNCTIONS)` is `'min'` — two edits on a
// three-character name, a jump from a permission verb to a numeric
// function. Worse than silence: an author who takes it writes
// `min(object, verb)`.
const message = validateExpression('predicate', 'current_user.can(object, verb)').errors[0].message;
expect(message).toContain('`can` is not a callable name here');
expect(message).not.toMatch(/Did you mean/);
expect(message).not.toMatch(/`min`/);
});

it('leaves the shared `nearestName` budget alone — this class narrows locally', () => {
// The hazard is this catalog's, not the heuristic's: field-name
// suggestions keep the shared budget. If this ever stops answering
// `'min'`, the local threshold is no longer the thing protecting the
// message and the pin above has quietly become vacuous.
expect(nearestName('can', CEL_STDLIB_FUNCTIONS)).toBe('min');
expect(nearestName('isBlnk', CEL_STDLIB_FUNCTIONS)).toBe('isBlank');
});
});

// The flipped pins. The refusal surface may never shrink, and this arm may
// never speak for faults it cannot name.
it('keeps the dialect trailer on a real function given arguments no overload accepts', () => {
// Same cel-js message SHAPE, different fault: `upper` exists. Calling it
// "not a callable name" would replace a useless sentence with a false one.
const r = validateExpression('predicate', 'upper(1, 2)');
expect(r.ok).toBe(false);
expect(r.errors[0].message).toMatch(/found no matching overload for 'upper\(int, int\)'/);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it.each([
{ name: 'an operator type mismatch', source: "1 + 'a'" },
{ name: 'a ternary branch mismatch', source: "record.rating >= 4 ? 1 : 'x'" },
])('keeps the dialect trailer on a `type` fault that names no call — $name', ({ source }) => {
const r = validateExpression('predicate', source);
expect(r.ok).toBe(false);
expect(r.errors[0].message).toContain(dialectTrailer('predicate'));
expect(r.errors[0].message).not.toMatch(/NAME fault/);
});

it('leaves the `bounds` prescription untouched — a new class was routed, not the shared tail replaced', () => {
const overBudget = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && ');
const message = validateExpression('predicate', overBudget).errors[0].message;
expect(message).toMatch(/SIZE fault, not a dialect mistake/);
expect(message).toMatch(/Shrink it/);
expect(message).not.toMatch(/NAME fault/);
});
});

describe('templates', () => {
it('accepts a valid {{ path }} template', () => {
const r = validateExpression('template', 'Hot lead: {{ record.full_name }}');
Expand Down
118 changes: 115 additions & 3 deletions packages/formula/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -254,6 +254,105 @@ function boundsHint(source: string): string | null {
);
}

/**
* cel-js's unknown-call vocabulary, both of its spellings — a bare call
* (`` `found no matching overload for 'totallyBogusFn(int, int)'` ``) and a
* receiver call (`` `…for 'dyn.nosuchmethod(string)'` ``). Both are emitted from
* one template family in `cel-js/lib/operators.js`, and the name we want is the
* segment immediately before the argument list, after any receiver-type prefix.
*
* Anchored on the closing `)'` so the greedy receiver prefix cannot run past the
* call into the source excerpt cel-js appends on the following lines.
*/
const NO_OVERLOAD_RE = /found no matching overload for '(?:.*[.])?([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The nearest advertised callable to `name`, or `undefined` when nothing is
* close enough that a suggestion beats silence.
*
* Deliberately STRICTER than {@link nearestName}'s shared budget, and that
* difference is the entire reason this function exists rather than a call to
* the shared one. `nearest` spends `max(2, floor(name.length / 3))` edits —
* right for a field name checked against the handful of fields on one object,
* measurably wrong against this catalog: `nearestName('can', CEL_STDLIB_FUNCTIONS)`
* answers `'min'`. Two edits on a three-character name, jumping from a
* permission verb to a numeric function — a confident suggestion across an
* unrelated namespace, which is worse than silence. An author who takes it (an
* LLM author above all, following the last sentence it was handed) writes
* `min(object, verb)` and is further from working than before it asked.
*
* The budget here is proportional rather than floored: at most one edit per
* three characters of the LONGER name, so at least two thirds of a suggestion
* must already be typed. That keeps the case that makes suggesting worthwhile
* (`isBlnk` → `isBlank`, one edit in seven) and refuses the measured hazard
* (`can` → `min`, two edits in three). Both are pinned in `validate.test.ts`;
* a change to this budget that loses either is a regression, not a tuning.
*
* The distance metric stays the module's one {@link levenshtein} — only the
* acceptance budget is class-specific, which is what the hazard is about.
*/
function nearestCallable(name: string): string | undefined {
let best: string | undefined;
let bestDistance = Infinity;
for (const candidate of CEL_STDLIB_FUNCTIONS) {
const distance = levenshtein(name, candidate);
if (distance > Math.floor(Math.max(name.length, candidate.length) / 3)) continue;
if (distance >= bestDistance) continue;
bestDistance = distance;
best = candidate;
}
return best;
}

/**
* The prescription for the **unknown-name** arm of a `type` refusal — an
* expression that is perfectly good CEL and merely calls something by a name
* that resolves to nothing in this position.
*
* The second leg of the repair {@link boundsHint} made for the `bounds` class
* (#7073). Until this hint, every unknown-function refusal ended with the same
* dialect trailer ("`predicate`s are bare CEL (e.g. `record.rating >= 4`)"),
* and that sentence is actively wrong here for exactly the reason it was wrong
* for `bounds`: the source already IS bare CEL and parses fine. An author who
* obeys the last sentence they were given rewrites the dialect, learns nothing,
* and comes back with the same unresolvable name. The front half (cel-js's own
* `found no matching overload for '…'`) was right all along and is kept
* verbatim; only the prescription lied.
*
* ### Why the name must be checked against the advertised catalog first
*
* cel-js emits ONE message shape for two different faults: a name that resolves
* to nothing (`upperr(record.name)`) and a real function handed arguments no
* overload accepts (`upper(1, 2)` → `found no matching overload for
* 'upper(int, int)'`). Telling the second author that `upper` "is not a
* callable name" would be a fresh false statement in place of a merely useless
* one, so an advertised name falls through to the existing trailer untouched.
*
* ### Why the wording is "not a callable name HERE"
*
* {@link CEL_STDLIB_FUNCTIONS} is a curated bare-callable subset, not an oracle
* for existence — 33 further names cel-js registers are callable only on a
* receiver (`record.name.split(',')` works; bare `split(…)` faults here and
* lands in this arm). So the message may say the name cannot be called in this
* position, and may point at what IS advertised, but must not claim the name
* does not exist. For the same reason it names no size: what the catalog
* contains is being adjudicated separately, and a message asserting a count
* would be falsified by that ruling.
*/
function unknownFunctionHint(celMessage: string): string | null {
const name = NO_OVERLOAD_RE.exec(celMessage)?.[1];
if (!name || CEL_STDLIB_FUNCTIONS.includes(name)) return null;
const suggestion = nearestCallable(name);
return (
`\`${name}\` is not a callable name here — a NAME fault, not a dialect mistake, so ` +
`re-spelling the expression will not fix it.` +
(suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
` The callable names this platform advertises for authoring are the \`functions\` list ` +
`\`introspectScope\` returns (\`CEL_STDLIB_FUNCTIONS\`) — pick one of those, or precompute ` +
`the value in a stored field and reference that field instead.`
);
}

function checkFieldExistence(source: string, schema: ExprSchemaHint | undefined, errors: ExprValidationError[]): void {
if (!schema?.fields || schema.fields.length === 0) return;
const known = new Set(schema.fields);
Expand DownExpand Up@@ -400,9 +499,22 @@ export function validateExpression(
if (!compiled.ok) {
// #7073 — a bounds refusal gets the SIZE prescription, never the dialect
// trailer: the source is already bare CEL, so "write bare CEL" is advice
// that cannot succeed. Checked first because the class is certain (it comes
// from the engine's own verdict) while the braces hint is a heuristic.
const hint = (compiled.error.kind === 'bounds' ? boundsHint(source) : null) ?? bracesHint(source);
// that cannot succeed. #13821 routes the `type` class the same way for the
// same reason, one class per arm. Both are checked before the braces hint
// because the class is certain (it comes from the engine's own verdict)
// while the braces hint is a heuristic.
//
// A `type` fault that names no unresolvable call — an operator or ternary
// mismatch (`1 + 'a'`, `no such overload: int + string`), or a real function
// handed wrong arguments — returns null from `unknownFunctionHint` and keeps
// the existing trailer: this arm has a name to hand back or it says nothing.
const classHint =
compiled.error.kind === 'bounds'
? boundsHint(source)
: compiled.error.kind === 'type'
? unknownFunctionHint(compiled.error.message)
: null;
const hint = classHint ?? bracesHint(source);
errors.push({
source,
message:
Expand Down
Loading