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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e 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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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 \u003e 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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
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
55 changes: 55 additions & 0 deletions .changeset/formula-receiver-call-prescription.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/formula": patch
---

fix(formula): a stdlib function written as a method gets the bare call shape, not the dialect (#14203)

`validateExpression` refused `record.name.upper()` correctly and then handed the
author the generic dialect trailer — "`predicate`s are bare CEL (e.g.
`record.rating >= 4`)" — advice that cannot succeed on a source that already IS
bare CEL and parses fine. The third instance of the same defect family as the
`bounds` class (#7073) and the unknown-name class (#13821), and the one neither
of them could cover: #13821's arm fires only when the name is ABSENT from
`CEL_STDLIB_FUNCTIONS`, and `upper` is present, so this class had no
prescription at all. The name is right; the call SHAPE is wrong.

It is a high-frequency AI-author mistake, not an exotic one: method-call syntax
is what almost every other language uses for string operations, so a generator
that knows `upper` exists reaches for `record.name.upper()` before
`upper(record.name)`. The remedy is one sentence and it is mechanical — the
correct spelling is derivable from the fault itself:

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

> 1 | record.name.upper()
^ — `upper` is callable bare, not as a method — a CALL-SHAPE fault, not
a dialect mistake, so re-spelling the expression will not fix it. Write
`upper(record.name)` instead. The callable names this platform advertises for
authoring (the `functions` list `introspectScope` returns,
`CEL_STDLIB_FUNCTIONS`) take their subject as an argument; only cel-js's own
receiver methods (`record.name.split(',')`) are written after a dot.
```

The spelling is assembled from the SOURCE, because cel-js's message names the
receiver's TYPE (`dyn.upper()`) and never the author's expression. When the
receiver is not a plain dotted chain (`record.tags[0].upper()`,
`(a + b).upper()`, `'lit'.upper()`) the message names the call shape —
`upper(…)` with the receiver as its first argument — rather than inventing a
spelling it cannot derive.

The arm is keyed on membership of the bare-callable catalog plus the
environment's own record of the receiver form, never on the call shape alone.
Two classes therefore keep exactly the behaviour they had:

- the 33 receiver-only names cel-js registers (`split`, `map`, `getFullYear`)
are correct ONLY after a dot — `record.name.split(',')` type-checks and never
reaches this arm;
- the seven advertised names registered BOTH ways (`contains`, `endsWith`,
`matches`, `size`, `startsWith`, `string`, `trim`) keep the existing trailer
when a receiver call of them faults, because the fault there is the arguments
(`record.name.contains()`), and a bare rewrite would fault just as hard.

No change to `CEL_STDLIB_FUNCTIONS`, to the registered environment, or to what
`validateExpression` accepts: the receiver call was refused before this change
and is refused after it. Only the sentence the author is told to act on changes.
68 changes: 67 additions & 1 deletion packages/formula/src/unknown-function.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest';

import { buildEnv, celEngine } from './cel-engine';
import { callNameFromNoOverload, firstUnknownFunctionCall } from './unknown-function';
import {
callNameFromNoOverload,
firstUnknownFunctionCall,
isReceiverRegistered,
receiverCallNameFromNoOverload,
} from './unknown-function';
import { CEL_STDLIB_FUNCTIONS } from './validate';

/**
Expand DownExpand Up@@ -224,3 +229,64 @@ describe('callNameFromNoOverload — the shared extraction (#13594)', () => {
});
});


// #14203 — the second question about the same message: not "which token did
// cel-js name?" but "was it written as a method?". A sibling reader rather than
// a flag, so the extraction above keeps answering both call forms identically
// for its two existing consumers.
describe('receiverCallNameFromNoOverload — the call-FORM reading (#14203)', () => {
it.each([
["found no matching overload for 'dyn.upper()'", 'upper'],
["found no matching overload for 'string.upper()'", 'upper'],
["found no matching overload for 'dyn.nosuchmethod(string)'", 'nosuchmethod'],
["found no matching overload for 'list<dyn>.size()'", 'size'],
])('%s → %s', (message, expected) => {
expect(receiverCallNameFromNoOverload(message)).toBe(expected);
});

it.each([
"found no matching overload for 'upper(int, int)'",
"found no matching overload for 'totallyBogusFn(int, int)'",
"found no matching overload for 'split(dyn, string)'",
'no such overload: int + string',
])('is undefined for a BARE call or a non-call verdict: %s', (message) => {
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});

it('does not reach past the closing quote into the source excerpt', () => {
// cel-js's `formatErrorWithHighlight` puts the author's own source on the
// following lines, dots and all — and a BARE call must stay undefined even
// when those lines are full of receiver-looking text.
const message =
"found no matching overload for 'split(dyn, string)'\n" +
" record.a.b.split(',')\n" +
' ^';
expect(receiverCallNameFromNoOverload(message)).toBeUndefined();
});
});

describe('isReceiverRegistered — the second key a call-shape prescription needs (#14203)', () => {
it.each(['contains', 'endsWith', 'matches', 'size', 'startsWith', 'string', 'trim'])(
'`%s` is advertised bare AND registered as a receiver method — both forms are real',
(name) => {
expect(isReceiverRegistered(name)).toBe(true);
},
);

it.each(['upper', 'lower', 'isBlank', 'daysFromNow', 'abs', 'coalesce'])(
'`%s` is bare-only — a receiver call of it is a call-SHAPE fault',
(name) => {
expect(isReceiverRegistered(name)).toBe(false);
},
);

it.each(['split', 'map', 'getFullYear'])('`%s` is receiver-registered — the inverse class', (name) => {
expect(isReceiverRegistered(name)).toBe(true);
});

it('says nothing about existence — an unregistered name is simply not a receiver method', () => {
// The two questions stay separate: `firstUnknownFunctionCall` owns existence.
expect(isReceiverRegistered('totallyBogusFn')).toBe(false);
expect(firstUnknownFunctionCall('totallyBogusFn(1,2)')?.name).toBe('totallyBogusFn');
});
});
73 changes: 68 additions & 5 deletions packages/formula/src/unknown-function.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,9 @@
* with one template family: a bare call (`'totallyBogusFn(int, int)'`) and a
* receiver call (`'dyn.nosuchmethod(string)'`). {@link NO_OVERLOAD_RE} takes the
* segment immediately before the argument list, after any receiver-type prefix.
* Which of the two forms was written is a separate question, asked separately by
* {@link receiverCallNameFromNoOverload} — the existence verdict below is
* deliberately blind to it.
*
* ## What this deliberately does NOT report
*
Expand DownExpand Up@@ -107,6 +110,36 @@ export function callNameFromNoOverload(message: string): string | undefined {
return NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* The RECEIVER spelling of {@link NO_OVERLOAD_RE}'s template family, with the
* receiver-type prefix REQUIRED instead of optional. Matches
* `found no matching overload for 'dyn.upper()'` and never the bare
* `…for 'upper(int, int)'`.
*
* The prefix class excludes the quote (and the newline) so it cannot reach past
* the closing `)'` — the same anchoring concern its sibling documents, since
* cel-js appends the author's own source, dots and all, on the following lines.
*/
const RECEIVER_NO_OVERLOAD_RE = /found no matching overload for '[^'\n]*[.]([A-Za-z_$][\w$]*)\(.*?\)'/;

/**
* The METHOD name inside a receiver-form `found no matching overload for '…'`
* message (`'dyn.upper()'` → `upper`), or `undefined` when the message is any
* other shape — a bare call included.
*
* A sibling of {@link callNameFromNoOverload} rather than a flag on it: that
* function answers "which token did cel-js name?" for two consumers that must
* keep getting the same answer for both call forms, while this one answers a
* second, independent question — "was it written as a method?".
*
* The receiver segment cel-js prints is a TYPE (`dyn`, `string`,
* `list<dyn>`), never the author's own expression, so it is matched and
* discarded. A caller that wants to NAME the receiver has to read the source.
*/
export function receiverCallNameFromNoOverload(message: string): string | undefined {
return RECEIVER_NO_OVERLOAD_RE.exec(message)?.[1];
}

/**
* Every function name the canonical evaluation environment registers — bare
* callables (`upper(x)`) and receiver-only methods (`s.split(',')`) alike.
Expand All@@ -121,18 +154,48 @@ export function callNameFromNoOverload(message: string): string | undefined {
* names exist. The clock passed here is the same fixed instant `compile` uses
* for its own parse-time environment, and is never called.
*/
let registeredNames: ReadonlySet<string> | undefined;
interface RegisteredNames {
/** Every registered name, whichever call form(s) it occupies. */
all: ReadonlySet<string>;
/** The subset registered as a receiver method, `x.fn()`. */
receiver: ReadonlySet<string>;
}

let registeredNames: RegisteredNames | undefined;

function registeredFunctionNames(): ReadonlySet<string> {
function registeredFunctionNames(): RegisteredNames {
if (!registeredNames) {
const env = buildEnv(() => new Date(0)) as unknown as {
getDefinitions(): { functions: Array<{ name: string }> };
getDefinitions(): { functions: Array<{ name: string; receiverType: string | null }> };
};
const functions = env.getDefinitions().functions;
registeredNames = {
all: new Set(functions.map((fn) => fn.name)),
// `receiverType` is cel-js's own record of which call form a definition
// occupies: `null` for `fn(x)`, the receiver's type for `x.fn()`. A name
// can hold definitions of both kinds.
receiver: new Set(functions.filter((fn) => fn.receiverType != null).map((fn) => fn.name)),
};
registeredNames = new Set(env.getDefinitions().functions.map((fn) => fn.name));
}
return registeredNames;
}

/**
* Whether the evaluation environment registers `name` as a receiver method
* (`x.fn()`) — in ADDITION to a bare call, or instead of one.
*
* Exported because "the name is in the bare-callable catalog" does not imply
* "writing it after a dot is wrong": seven advertised names hold definitions of
* both kinds (`contains`, `endsWith`, `matches`, `size`, `startsWith`,
* `string`, `trim`; `validate.test.ts` pins the set). A consumer that
* prescribes a call SHAPE needs this second answer, or it tells the author of
* `record.name.contains()` — an arity fault on a legitimate receiver call — to
* rewrite into a bare call that faults just as hard.
*/
export function isReceiverRegistered(name: string): boolean {
return registeredFunctionNames().receiver.has(name);
}

/** A call to a name the evaluation environment does not register. */
export interface UnknownFunctionCall {
/** The called name, e.g. `totallyBogusFn` — for a receiver call, the METHOD name. */
Expand DownExpand Up@@ -174,6 +237,6 @@ export function firstUnknownFunctionCall(source: string): UnknownFunctionCall |
if (!name) return null;
// Registered, so the fault is about the ARGUMENTS or the call position, not
// about whether the name exists. Blind spot, deliberately (refinement 3).
if (registeredFunctionNames().has(name)) return null;
if (registeredFunctionNames().all.has(name)) return null;
return { name, detail: compiled.error.message.split('\n')[0].trim() };
}
Loading
Loading